mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 16:28:18 +00:00
Merge main into NFC transport support
This commit is contained in:
commit
4957f8d28f
160 files changed
+12441
-1388
No files matched your search
@@ -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() {
|
||||
|
||||
+184
-5
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { computed, reactive, reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
reactiveSource,
|
||||
type ReactiveSource,
|
||||
type ReactiveValue,
|
||||
} from "octagonal-wheels/dataobject/reactive";
|
||||
import {
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_INFO,
|
||||
@@ -7,6 +13,7 @@ import {
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { createOwnedResource, type OwnedResource } from "octagonal-wheels/resource";
|
||||
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import {
|
||||
chunkFetchCounts,
|
||||
@@ -46,7 +53,11 @@ import {
|
||||
isValidFilenameInDarwin,
|
||||
isValidFilenameInWidows,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
MARK_LOG_NETWORK_ERROR,
|
||||
MARK_LOG_SEPARATOR,
|
||||
type LogFunction,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
@@ -57,20 +68,22 @@ import {
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
// DI the log again.
|
||||
const recentLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
const globalLogFunction = (message: unknown, level?: number, key?: string) => {
|
||||
const messageX =
|
||||
message instanceof Error
|
||||
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
||||
: typeof message === "string"
|
||||
? message
|
||||
: JSON.stringify(message);
|
||||
const entry = { message: messageX, level, key } as LogEntry;
|
||||
recentLogEntries.value = [...recentLogEntries.value, entry];
|
||||
};
|
||||
function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
|
||||
return (message: unknown, level?: number, key?: string) => {
|
||||
const messageX =
|
||||
message instanceof Error
|
||||
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
||||
: typeof message === "string"
|
||||
? message
|
||||
: JSON.stringify(message);
|
||||
const entry = { message: messageX, level, key } as LogEntry;
|
||||
entries.value = [...entries.value, entry];
|
||||
};
|
||||
}
|
||||
|
||||
setGlobalLogFunction(globalLogFunction);
|
||||
// Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
|
||||
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
|
||||
// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs.
|
||||
// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI.
|
||||
const logForDump = [] as string[];
|
||||
@@ -113,6 +126,16 @@ function redactLog(log: string) {
|
||||
const showDebugLog = false;
|
||||
export const MARK_DONE = "\u{2009}\u{2009}";
|
||||
export class ModuleLog extends AbstractObsidianModule {
|
||||
/** The host releases this connection after the complete asynchronous unload. */
|
||||
logResource!: OwnedResource<LogFunction>;
|
||||
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
private readonly displayLogEntries = () => {
|
||||
if (this.recentLogEntries.value.length === 0) return;
|
||||
const entries = [...this.recentLogEntries.value];
|
||||
this.recentLogEntries.value = [];
|
||||
entries.forEach((entry) => this.__addLog(entry.message, entry.level, entry.key));
|
||||
};
|
||||
|
||||
statusBar?: HTMLElement;
|
||||
|
||||
statusDiv?: HTMLElement;
|
||||
@@ -460,12 +483,7 @@ ${stringifyYaml(info)}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
recentLogEntries.onChanged((entries) => {
|
||||
if (entries.value.length === 0) return;
|
||||
const newEntries = [...entries.value];
|
||||
recentLogEntries.value = [];
|
||||
newEntries.forEach((e) => this.__addLog(e.message, e.level, e.key));
|
||||
});
|
||||
this.recentLogEntries.onChanged(this.displayLogEntries);
|
||||
eventHub.onEvent(EVENT_FILE_RENAMED, (data) => {
|
||||
void this.setFileStatus();
|
||||
});
|
||||
@@ -597,7 +615,22 @@ ${stringifyYaml(info)}
|
||||
}
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.API.addLog.setHandler(globalLogFunction);
|
||||
const log = createBufferedLog(this.recentLogEntries);
|
||||
const unregisterLog = services.API.addLog.setHandler(log);
|
||||
const collectGlobalLogs = () => {
|
||||
const entries = recentGlobalLogEntries.value;
|
||||
if (entries.length === 0) return;
|
||||
recentGlobalLogEntries.value = [];
|
||||
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
|
||||
};
|
||||
this.logResource = createOwnedResource(log, () => {
|
||||
unregisterLog();
|
||||
recentGlobalLogEntries.offChanged(collectGlobalLogs);
|
||||
this.recentLogEntries.offChanged(this.displayLogEntries);
|
||||
this.recentLogEntries.value = [];
|
||||
});
|
||||
recentGlobalLogEntries.onChanged(collectGlobalLogs);
|
||||
collectGlobalLogs();
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
debounce: (callback: () => void) => callback,
|
||||
normalizePath: (path: string) => path,
|
||||
Notice: class {},
|
||||
stringifyYaml: vi.fn(),
|
||||
}));
|
||||
vi.mock("./Log/LogPaneView.ts", () => ({ LogPaneView: class {}, VIEW_TYPE_LOG: "log" }));
|
||||
vi.mock("@/common/reportTool.ts", () => ({ generateReport: vi.fn() }));
|
||||
vi.mock("@/common/utils.ts", () => ({ isValidPath: () => true }));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_FILE_RENAMED: "renamed",
|
||||
EVENT_LAYOUT_READY: "ready",
|
||||
EVENT_LEAF_ACTIVE_CHANGED: "active",
|
||||
EVENT_ON_UNRESOLVED_ERROR: "error",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector", () => ({
|
||||
P2PLogCollector: class {},
|
||||
}));
|
||||
|
||||
async function createHarness() {
|
||||
const { ModuleLog } = await import("./ModuleLog.ts");
|
||||
const { bindableFunction, allFunction } =
|
||||
await import("@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils");
|
||||
const services = {
|
||||
API: {
|
||||
addLog: bindableFunction<(message: unknown, level?: number, key?: string) => void>("log"),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
context: { events: {}, notices: { hide: vi.fn() } },
|
||||
appLifecycle: {
|
||||
onInitialise: allFunction<() => Promise<boolean>>(),
|
||||
onSettingLoaded: allFunction<() => Promise<boolean>>(),
|
||||
onLoaded: allFunction<() => Promise<boolean>>(),
|
||||
onBeforeUnload: allFunction<() => Promise<boolean>>(),
|
||||
},
|
||||
};
|
||||
const core = { services, settings: {} };
|
||||
const module = new ModuleLog({ app: {} } as never, core as never);
|
||||
const sink = vi.spyOn(module, "__addLog").mockImplementation(() => undefined);
|
||||
vi.spyOn(module, "observeForLogs").mockImplementation(() => undefined);
|
||||
vi.spyOn(module, "adjustStatusDivPosition").mockImplementation(() => undefined);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
const { Logger } = await import("octagonal-wheels/common/logger");
|
||||
return { module, services, sink, Logger };
|
||||
}
|
||||
|
||||
describe("ModuleLog service connection", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("document", { querySelectorAll: () => [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("buffers startup logs and preserves global and service call order", async () => {
|
||||
const { services, sink, Logger } = await createHarness();
|
||||
Logger("before-global");
|
||||
services.API.addLog("before-service", 32, "startup");
|
||||
Logger("between-global");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
const messages = sink.mock.calls.map(([message]) => message);
|
||||
expect(messages.filter((message) => typeof message === "string" && /^(before|between)-/.test(message))).toEqual(
|
||||
["before-global", "before-service", "between-global"]
|
||||
);
|
||||
expect(sink).toHaveBeenCalledWith("before-service", 32, "startup");
|
||||
});
|
||||
|
||||
it("delivers service logs only to their own module", async () => {
|
||||
const first = await createHarness();
|
||||
const second = await createHarness();
|
||||
await first.services.appLifecycle.onSettingLoaded();
|
||||
await second.services.appLifecycle.onSettingLoaded();
|
||||
first.sink.mockClear();
|
||||
second.sink.mockClear();
|
||||
|
||||
first.services.API.addLog("first", 32, "first-key");
|
||||
second.services.API.addLog("second", 64, "second-key");
|
||||
expect(first.sink.mock.calls).toEqual([["first", 32, "first-key"]]);
|
||||
expect(second.sink.mock.calls).toEqual([["second", 64, "second-key"]]);
|
||||
});
|
||||
|
||||
it("preserves message conversion, levels, keys, and error causes", async () => {
|
||||
const { services, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const error = new Error("failed");
|
||||
services.API.addLog(error, 128, "failure");
|
||||
services.API.addLog({ result: "value" }, 32, "object");
|
||||
services.API.addLog("plain");
|
||||
const converted = sink.mock.calls[0][0] as Error & { cause: unknown };
|
||||
expect(converted.message).toBe("[Error Logged]: failed");
|
||||
expect(converted.cause).toBe(error);
|
||||
expect(sink.mock.calls[0].slice(1)).toEqual([128, "failure"]);
|
||||
expect(sink.mock.calls.slice(1)).toEqual([
|
||||
['{"result":"value"}', 32, "object"],
|
||||
["plain", undefined, undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("detaches both service registration and legacy subscription on disposal", async () => {
|
||||
const first = await createHarness();
|
||||
const second = await createHarness();
|
||||
await first.services.appLifecycle.onSettingLoaded();
|
||||
await second.services.appLifecycle.onSettingLoaded();
|
||||
first.sink.mockClear();
|
||||
second.sink.mockClear();
|
||||
|
||||
await first.module.logResource.dispose();
|
||||
await first.module.logResource.dispose();
|
||||
expect(() => first.services.API.addLog("retired")).toThrow("not assigned");
|
||||
second.services.API.addLog("active", 32, "service");
|
||||
first.Logger("legacy-after-retirement", 32, "legacy");
|
||||
expect(first.sink).not.toHaveBeenCalled();
|
||||
expect(second.sink.mock.calls).toEqual([
|
||||
["active", 32, "service"],
|
||||
["legacy-after-retirement", 32, "legacy"],
|
||||
]);
|
||||
await second.module.logResource.dispose();
|
||||
});
|
||||
|
||||
it("leaves a replacement service handler registered when the old owner is disposed", async () => {
|
||||
const { services, module, sink, Logger } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
const replacement = vi.fn();
|
||||
services.API.addLog.setHandler(replacement, true);
|
||||
sink.mockClear();
|
||||
|
||||
await module.logResource.dispose();
|
||||
await module.logResource.dispose();
|
||||
services.API.addLog("replacement", 32, "key");
|
||||
Logger("detached-legacy");
|
||||
expect(replacement).toHaveBeenCalledExactlyOnceWith("replacement", 32, "key");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases a connection which never reached the settings-loaded phase", async () => {
|
||||
const { services, module, sink, Logger } = await createHarness();
|
||||
services.API.addLog("pending");
|
||||
await module.logResource.dispose();
|
||||
Logger("after-disposal");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
|
||||
const replacement = await createHarness();
|
||||
await replacement.services.appLifecycle.onSettingLoaded();
|
||||
const messages = replacement.sink.mock.calls.map(([message]) => message);
|
||||
expect(messages).toContain("after-disposal");
|
||||
expect(messages).not.toContain("pending");
|
||||
await replacement.module.logResource.dispose();
|
||||
});
|
||||
|
||||
it.each(["complete", "fail"] as const)(
|
||||
"keeps logging through asynchronous database shutdown and releases after it: %s",
|
||||
async (outcome) => {
|
||||
const { services, module, sink, Logger } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const { ControlService } = await import("@vrtmrz/livesync-commonlib/compat/services/base/ControlService");
|
||||
const { ServiceContext } = await import("@vrtmrz/livesync-commonlib/context");
|
||||
const { withOwnedResource } = await import("octagonal-wheels/resource");
|
||||
let finish!: () => void;
|
||||
let fail!: (reason: Error) => void;
|
||||
let started!: () => void;
|
||||
const closing = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
finish = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
const control = new ControlService(new ServiceContext({ translate: (key) => key }), {
|
||||
appLifecycleService: {
|
||||
...services.appLifecycle,
|
||||
onAppUnload: vi.fn(async () => []),
|
||||
onUnload: vi.fn(async () => {
|
||||
services.API.addLog("lifecycle-unload");
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
APIService: services.API,
|
||||
databaseService: {
|
||||
localDatabaseDirect: {
|
||||
onunload: vi.fn(() => services.API.addLog("database-unload")),
|
||||
close: vi.fn(() => {
|
||||
started();
|
||||
return closed;
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const unloading = withOwnedResource(module.logResource, () => control.onUnload());
|
||||
await closing;
|
||||
services.API.addLog("during-close");
|
||||
expect(sink.mock.calls.map(([message]) => message)).toEqual([
|
||||
"lifecycle-unload",
|
||||
"database-unload",
|
||||
"during-close",
|
||||
]);
|
||||
if (outcome === "fail") {
|
||||
const failure = new Error("database close failed");
|
||||
const rejected = expect(unloading).rejects.toBe(failure);
|
||||
fail(failure);
|
||||
await rejected;
|
||||
} else {
|
||||
finish();
|
||||
await unloading;
|
||||
expect(sink.mock.calls[sink.mock.calls.length - 1][0]).toContain(
|
||||
"moduleLiveSyncMain.logUnloadingPlugin"
|
||||
);
|
||||
}
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
sink.mockClear();
|
||||
Logger("after-unload");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
statusDisplay,
|
||||
type ConfigurationItem,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import {
|
||||
type AllSettingItemKey,
|
||||
getConfig,
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
type AllBooleanItemKey,
|
||||
} from "./settingConstants.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
|
||||
import type { AutoWireOption, OnUpdateResult } from "./SettingPane.ts";
|
||||
import { onlyWhenChanged } from "octagonal-wheels/function";
|
||||
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
|
||||
|
||||
export class LiveSyncSetting extends Setting {
|
||||
@@ -109,11 +110,15 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
LiveSyncSetting.env.requestUpdate();
|
||||
}
|
||||
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||
private autoWireStringComponent<TComponent extends TextComponent | TextAreaComponent>(
|
||||
key: AllStringItemKey,
|
||||
addComponent: (callback: (component: TComponent) => void) => void,
|
||||
opt?: AutoWireOption
|
||||
) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addText((text) => {
|
||||
addComponent((text) => {
|
||||
this.autoWiredComponent = text;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
const setValue = onlyWhenChanged((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
||||
@@ -127,26 +132,25 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
this.autoWireComponent(this.autoWiredComponent, conf, opt);
|
||||
});
|
||||
}
|
||||
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||
this.autoWireStringComponent<TextComponent>(
|
||||
key,
|
||||
(callback) => {
|
||||
this.addText(callback);
|
||||
},
|
||||
opt
|
||||
);
|
||||
return this;
|
||||
}
|
||||
autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addTextArea((text) => {
|
||||
this.autoWiredComponent = text;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
||||
this.invalidateValue();
|
||||
text.onChange(async (value) => {
|
||||
await this.commitValue(value);
|
||||
});
|
||||
if (opt?.isPassword) {
|
||||
text.inputEl.setAttribute("type", "password");
|
||||
this.hasPassword = true;
|
||||
}
|
||||
this.autoWireComponent(this.autoWiredComponent, conf, opt);
|
||||
});
|
||||
this.autoWireStringComponent<TextAreaComponent>(
|
||||
key,
|
||||
(callback) => {
|
||||
this.addTextArea(callback);
|
||||
},
|
||||
opt
|
||||
);
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(
|
||||
@@ -163,7 +167,7 @@ export class LiveSyncSetting extends Setting {
|
||||
text.inputEl.setAttribute("max", `${opt.clampMax}`);
|
||||
}
|
||||
let lastError = false;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
const setValue = onlyWhenChanged((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => {
|
||||
@@ -209,7 +213,7 @@ export class LiveSyncSetting extends Setting {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addToggle((toggle) => {
|
||||
this.autoWiredComponent = toggle;
|
||||
const setValue = wrapMemo((value: boolean) => {
|
||||
const setValue = onlyWhenChanged((value: boolean) => {
|
||||
toggle.setValue(opt?.invert ? !value : value);
|
||||
});
|
||||
this.invalidateValue = () =>
|
||||
@@ -228,7 +232,7 @@ export class LiveSyncSetting extends Setting {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addDropdown((dropdown) => {
|
||||
this.autoWiredComponent = dropdown;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
const setValue = onlyWhenChanged((value: string) => {
|
||||
dropdown.setValue(value);
|
||||
});
|
||||
|
||||
@@ -248,7 +252,7 @@ export class LiveSyncSetting extends Setting {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addDropdown((dropdown) => {
|
||||
this.autoWiredComponent = dropdown;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
const setValue = onlyWhenChanged((value: string) => {
|
||||
dropdown.setValue(value);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const liveSyncSettingHarness = vi.hoisted(() => ({
|
||||
components: [] as unknown[],
|
||||
settingElements: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => {
|
||||
class StringComponent {
|
||||
values: string[] = [];
|
||||
changeHandler?: (value: string) => Promise<void>;
|
||||
placeholder?: string;
|
||||
inputEl = {
|
||||
attributes: new Map<string, string>(),
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes.set(name, value);
|
||||
},
|
||||
toggleClass: (_name: string, _enabled: boolean) => {},
|
||||
};
|
||||
setValue(value: string) {
|
||||
this.values.push(value);
|
||||
}
|
||||
onChange(handler: (value: string) => Promise<void>) {
|
||||
this.changeHandler = handler;
|
||||
return this;
|
||||
}
|
||||
setPlaceholder(value: string) {
|
||||
this.placeholder = value;
|
||||
return this;
|
||||
}
|
||||
constructor() {
|
||||
liveSyncSettingHarness.components.push(this);
|
||||
}
|
||||
}
|
||||
class TextComponent extends StringComponent {}
|
||||
class TextAreaComponent extends StringComponent {}
|
||||
class Setting {
|
||||
settingEl = {
|
||||
classes: [] as { name: string; enabled: boolean }[],
|
||||
toggleClass(name: string, enabled: boolean) {
|
||||
this.classes.push({ name, enabled });
|
||||
},
|
||||
};
|
||||
constructor(_containerEl: HTMLElement) {
|
||||
liveSyncSettingHarness.settingElements.push(this.settingEl);
|
||||
}
|
||||
setDesc(_description: string | DocumentFragment) {
|
||||
return this;
|
||||
}
|
||||
setName(_name: string | DocumentFragment) {
|
||||
return this;
|
||||
}
|
||||
addText(callback: (component: TextComponent) => void) {
|
||||
callback(new TextComponent());
|
||||
return this;
|
||||
}
|
||||
addTextArea(callback: (component: TextAreaComponent) => void) {
|
||||
callback(new TextAreaComponent());
|
||||
return this;
|
||||
}
|
||||
setTooltip(_description: string) {
|
||||
return this;
|
||||
}
|
||||
setDisabled(_disabled: boolean) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
return { Setting, TextComponent, TextAreaComponent, ButtonComponent: class {} };
|
||||
});
|
||||
vi.mock("./ObsidianLiveSyncSettingTab.ts", () => ({}));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
|
||||
LEVEL_ADVANCED: 1,
|
||||
LEVEL_POWER_USER: 2,
|
||||
statusDisplay: () => "",
|
||||
}));
|
||||
vi.mock("./settingConstants.ts", () => ({
|
||||
getConfig: (key: string) => ({
|
||||
name: key,
|
||||
desc: `description for ${key}`,
|
||||
placeHolder: `configured placeholder for ${key}`,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./settingComponentStyles.ts", () => ({ setButtonDestructiveState: vi.fn() }));
|
||||
vi.mock("octagonal-wheels/collection", () => ({ unique: (items: unknown[]) => [...new Set(items)] }));
|
||||
|
||||
import { LiveSyncSetting } from "./LiveSyncSetting.ts";
|
||||
|
||||
type WireKind = "text" | "textarea";
|
||||
type StringComponentHarness = {
|
||||
values: string[];
|
||||
changeHandler?: (value: string) => Promise<void>;
|
||||
placeholder?: string;
|
||||
inputEl: { attributes: Map<string, string> };
|
||||
};
|
||||
type SettingElementHarness = { classes: { name: string; enabled: boolean }[] };
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function wireStringSetting(
|
||||
kind: WireKind,
|
||||
options: {
|
||||
holdValue?: boolean;
|
||||
isPassword?: boolean;
|
||||
placeHolder?: string;
|
||||
onUpdate?: () => { visibility?: boolean };
|
||||
} = {}
|
||||
) {
|
||||
const key = kind === "text" ? "syncOnlyRegEx" : "ignoreFiles";
|
||||
const events: string[] = [];
|
||||
const editingSettings: Record<string, unknown> = { [key]: 17 };
|
||||
const saveSettings = vi.fn(async (_keys: string[]) => {});
|
||||
const requestUpdate = vi.fn(() => events.push("update"));
|
||||
const env = {
|
||||
settingComponents: [] as unknown[],
|
||||
editingSettings,
|
||||
saveSettings,
|
||||
requestUpdate,
|
||||
};
|
||||
LiveSyncSetting.env = env as never;
|
||||
|
||||
const setting = new LiveSyncSetting({} as HTMLElement);
|
||||
const onUpdate = vi.fn(options.onUpdate ?? (() => ({ visibility: false })));
|
||||
const wireOptions = { ...options, onUpdate };
|
||||
if (kind === "text") {
|
||||
setting.autoWireText(key as never, wireOptions);
|
||||
} else {
|
||||
setting.autoWireTextArea(key as never, wireOptions);
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
events,
|
||||
editingSettings,
|
||||
saveSettings,
|
||||
requestUpdate,
|
||||
setting,
|
||||
component: setting.autoWiredComponent as unknown as StringComponentHarness,
|
||||
settingElement: setting.settingEl as unknown as SettingElementHarness,
|
||||
onUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
describe("LiveSyncSetting string controls", () => {
|
||||
it.each(["text", "textarea"] as const)("wires %s through the shared string commit contract", async (kind) => {
|
||||
const {
|
||||
key,
|
||||
events,
|
||||
editingSettings,
|
||||
saveSettings,
|
||||
requestUpdate,
|
||||
setting,
|
||||
component,
|
||||
settingElement,
|
||||
onUpdate,
|
||||
} = wireStringSetting(kind, { isPassword: true, placeHolder: "option placeholder" });
|
||||
|
||||
expect(component.values).toEqual(["17"]);
|
||||
setting.invalidateValue?.();
|
||||
expect(component.values).toEqual(["17"]);
|
||||
editingSettings[key] = 18;
|
||||
setting.invalidateValue?.();
|
||||
expect(component.values).toEqual(["17", "18"]);
|
||||
expect(component.inputEl.attributes.get("type")).toBe("password");
|
||||
expect(setting.hasPassword).toBe(true);
|
||||
expect(setting.updateHandlers.size).toBe(1);
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(setting._getComputedStatus()).toEqual({ visibility: false });
|
||||
expect(onUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(settingElement.classes).toContainEqual({ name: "sls-setting-hidden", enabled: true });
|
||||
if (kind === "text") {
|
||||
expect(component.placeholder).toBe("configured placeholder for syncOnlyRegEx");
|
||||
} else {
|
||||
expect(component.placeholder).toBeUndefined();
|
||||
}
|
||||
|
||||
const pendingSave = deferred();
|
||||
saveSettings.mockImplementationOnce(async (keys) => {
|
||||
events.push(`save:${keys.join(",")}`);
|
||||
await pendingSave.promise;
|
||||
events.push("saved");
|
||||
});
|
||||
const commit = component.changeHandler!("changed");
|
||||
expect(editingSettings[key]).toBe("changed");
|
||||
expect(saveSettings).toHaveBeenCalledWith([key]);
|
||||
expect(events).toEqual([`save:${key}`]);
|
||||
expect(requestUpdate).not.toHaveBeenCalled();
|
||||
pendingSave.resolve();
|
||||
await commit;
|
||||
expect(events).toEqual([`save:${key}`, "saved", "update"]);
|
||||
|
||||
events.length = 0;
|
||||
await component.changeHandler!("changed");
|
||||
expect(saveSettings).toHaveBeenCalledTimes(1);
|
||||
expect(events).toEqual(["update"]);
|
||||
|
||||
events.length = 0;
|
||||
const failure = new Error("setting save failed");
|
||||
saveSettings.mockImplementationOnce(async (keys) => {
|
||||
events.push(`save:${keys.join(",")}`);
|
||||
throw failure;
|
||||
});
|
||||
await expect(component.changeHandler!("failed")).rejects.toBe(failure);
|
||||
expect(editingSettings[key]).toBe("failed");
|
||||
expect(events).toEqual([`save:${key}`]);
|
||||
expect(requestUpdate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["text", "textarea"] as const)("keeps %s held values in the editing copy without saving", async (kind) => {
|
||||
const { key, editingSettings, saveSettings, requestUpdate, component, events } = wireStringSetting(kind, {
|
||||
holdValue: true,
|
||||
});
|
||||
|
||||
await component.changeHandler!("held value");
|
||||
|
||||
expect(editingSettings[key]).toBe("held value");
|
||||
expect(saveSettings).not.toHaveBeenCalled();
|
||||
expect(requestUpdate).toHaveBeenCalledOnce();
|
||||
expect(events).toEqual(["update"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { LEVEL_ADVANCED, type CustomRegExpSource } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
LEVEL_ADVANCED,
|
||||
type CustomRegExpSource,
|
||||
type CustomRegExpSourceList,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { constructCustomRegExpList, splitCustomRegExpList } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import MultipleRegExpControl from "./MultipleRegExpControl.svelte";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -7,6 +11,35 @@ import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
type RegExpControlOwner = Pick<
|
||||
ObsidianLiveSyncSettingTab,
|
||||
"saveAllDirtySettings" | "requestPageRefresh" | "lifetimeComponent"
|
||||
>;
|
||||
|
||||
function mountMultipleRegExpControl<D extends "|[]|" | ",">(
|
||||
owner: RegExpControlOwner,
|
||||
target: HTMLElement,
|
||||
source: CustomRegExpSourceList<D>,
|
||||
delimiter: D,
|
||||
assign: (serializedPatterns: CustomRegExpSourceList<D>) => void
|
||||
): void {
|
||||
const patterns = splitCustomRegExpList(source, delimiter);
|
||||
const control = mount(MultipleRegExpControl, {
|
||||
target,
|
||||
props: {
|
||||
patterns,
|
||||
originals: [...patterns],
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
assign(constructCustomRegExpList(newPatterns, delimiter));
|
||||
await owner.saveAllDirtySettings();
|
||||
owner.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
owner.lifetimeComponent.register(() => void unmount(control));
|
||||
}
|
||||
|
||||
export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
void addPanel(paneEl, $msg("Normal Files")).then((paneEl) => {
|
||||
const syncFilesSetting = new Setting(paneEl)
|
||||
@@ -16,37 +49,29 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
|
||||
)
|
||||
);
|
||||
const syncFilesControl = mount(MultipleRegExpControl, {
|
||||
target: syncFilesSetting.controlEl,
|
||||
props: {
|
||||
patterns: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"),
|
||||
originals: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"),
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncOnlyRegEx = constructCustomRegExpList(newPatterns, "|[]|");
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(syncFilesControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
syncFilesSetting.controlEl,
|
||||
this.editingSettings.syncOnlyRegEx,
|
||||
"|[]|",
|
||||
(value) => {
|
||||
this.editingSettings.syncOnlyRegEx = value;
|
||||
}
|
||||
);
|
||||
|
||||
const nonSyncFilesSetting = new Setting(paneEl)
|
||||
.setName($msg("Non-Synchronising files"))
|
||||
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
||||
|
||||
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
||||
target: nonSyncFilesSetting.controlEl,
|
||||
props: {
|
||||
patterns: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"),
|
||||
originals: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"),
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncIgnoreRegEx = constructCustomRegExpList(newPatterns, "|[]|");
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(nonSyncFilesControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
nonSyncFilesSetting.controlEl,
|
||||
this.editingSettings.syncIgnoreRegEx,
|
||||
"|[]|",
|
||||
(value) => {
|
||||
this.editingSettings.syncIgnoreRegEx = value;
|
||||
}
|
||||
);
|
||||
new Setting(paneEl).autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 });
|
||||
|
||||
new Setting(paneEl).autoWireToggle("useIgnoreFiles");
|
||||
@@ -58,41 +83,30 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
const targetPatternSetting = new Setting(paneEl)
|
||||
.setName($msg("Target patterns"))
|
||||
.setDesc($msg("Patterns to match files for syncing"));
|
||||
const patTarget = splitCustomRegExpList(this.editingSettings.syncInternalFilesTargetPatterns, ",");
|
||||
const targetPatternControl = mount(MultipleRegExpControl, {
|
||||
target: targetPatternSetting.controlEl,
|
||||
props: {
|
||||
patterns: patTarget,
|
||||
originals: [...patTarget],
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncInternalFilesTargetPatterns = constructCustomRegExpList(newPatterns, ",");
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(targetPatternControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
targetPatternSetting.controlEl,
|
||||
this.editingSettings.syncInternalFilesTargetPatterns,
|
||||
",",
|
||||
(value) => {
|
||||
this.editingSettings.syncInternalFilesTargetPatterns = value;
|
||||
}
|
||||
);
|
||||
|
||||
const defaultSkipPattern = "\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/";
|
||||
const defaultSkipPatternXPlat =
|
||||
defaultSkipPattern + ",\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$";
|
||||
|
||||
const pat = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
|
||||
const patSetting = new Setting(paneEl).setName($msg("Ignore patterns")).setDesc("");
|
||||
|
||||
const ignorePatternControl = mount(MultipleRegExpControl, {
|
||||
target: patSetting.controlEl,
|
||||
props: {
|
||||
patterns: pat,
|
||||
originals: [...pat],
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncInternalFilesIgnorePatterns = constructCustomRegExpList(newPatterns, ",");
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(ignorePatternControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
patSetting.controlEl,
|
||||
this.editingSettings.syncInternalFilesIgnorePatterns,
|
||||
",",
|
||||
(value) => {
|
||||
this.editingSettings.syncInternalFilesIgnorePatterns = value;
|
||||
}
|
||||
);
|
||||
|
||||
const addDefaultPatterns = async (patterns: string) => {
|
||||
const oldList = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
|
||||
@@ -122,22 +136,14 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
const overwritePatterns = new Setting(paneEl)
|
||||
.setName($msg("Overwrite patterns"))
|
||||
.setDesc($msg("Patterns to match files for overwriting instead of merging"));
|
||||
const patTarget2 = splitCustomRegExpList(this.editingSettings.syncInternalFileOverwritePatterns, ",");
|
||||
const overwritePatternControl = mount(MultipleRegExpControl, {
|
||||
target: overwritePatterns.controlEl,
|
||||
props: {
|
||||
patterns: patTarget2,
|
||||
originals: [...patTarget2],
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncInternalFileOverwritePatterns = constructCustomRegExpList(
|
||||
newPatterns,
|
||||
","
|
||||
);
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(overwritePatternControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
overwritePatterns.controlEl,
|
||||
this.editingSettings.syncInternalFileOverwritePatterns,
|
||||
",",
|
||||
(value) => {
|
||||
this.editingSettings.syncInternalFileOverwritePatterns = value;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const paneSelectorHarness = vi.hoisted(() => ({
|
||||
mounts: [] as { component: unknown; target: unknown; props: Record<string, unknown>; handle: unknown }[],
|
||||
unmounted: [] as unknown[],
|
||||
settings: [] as { containerEl: HTMLElement; controlEl: unknown }[],
|
||||
buttons: [] as { text: string; handler?: () => void | Promise<void> }[],
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({ $msg: (value: string) => value }));
|
||||
vi.mock("./MultipleRegExpControl.svelte", () => ({ default: { name: "MultipleRegExpControl" } }));
|
||||
vi.mock("svelte", () => ({
|
||||
mount: vi.fn((component: unknown, options: { target: unknown; props: Record<string, unknown> }) => {
|
||||
const handle = { id: paneSelectorHarness.mounts.length };
|
||||
paneSelectorHarness.mounts.push({ component, target: options.target, props: options.props, handle });
|
||||
return handle;
|
||||
}),
|
||||
unmount: vi.fn((component: unknown) => {
|
||||
paneSelectorHarness.unmounted.push(component);
|
||||
}),
|
||||
}));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
controlEl: { id: number };
|
||||
constructor(containerEl: HTMLElement) {
|
||||
this.controlEl = { id: paneSelectorHarness.settings.length };
|
||||
paneSelectorHarness.settings.push({ containerEl, controlEl: this.controlEl });
|
||||
}
|
||||
setName(_name: string) {
|
||||
return this;
|
||||
}
|
||||
setDesc(_description: string) {
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(_key: string, _options: unknown) {
|
||||
return this;
|
||||
}
|
||||
autoWireToggle(_key: string) {
|
||||
return this;
|
||||
}
|
||||
autoWireTextArea(_key: string, _options: unknown) {
|
||||
return this;
|
||||
}
|
||||
addButton(
|
||||
callback: (button: {
|
||||
setButtonText: (text: string) => unknown;
|
||||
onClick: (handler: () => void | Promise<void>) => unknown;
|
||||
}) => void
|
||||
) {
|
||||
const button = {
|
||||
text: "",
|
||||
handler: undefined as (() => void | Promise<void>) | undefined,
|
||||
setButtonText(text: string) {
|
||||
this.text = text;
|
||||
return this;
|
||||
},
|
||||
onClick(handler: () => void | Promise<void>) {
|
||||
this.handler = handler;
|
||||
paneSelectorHarness.buttons.push(this);
|
||||
return this;
|
||||
},
|
||||
};
|
||||
callback(button);
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { paneSelector } from "./PaneSelector.ts";
|
||||
|
||||
type PatternControlProps = {
|
||||
patterns: string[];
|
||||
originals: string[];
|
||||
apply: (patterns: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function renderSelector() {
|
||||
const normalPanel = { id: "normal" } as unknown as HTMLElement;
|
||||
const hiddenPanel = { id: "hidden" } as unknown as HTMLElement;
|
||||
const addPanel = vi.fn((_parent: HTMLElement, title: string) =>
|
||||
Promise.resolve(title === "Normal Files" ? normalPanel : hiddenPanel)
|
||||
);
|
||||
const disposers: (() => void)[] = [];
|
||||
const tab = {
|
||||
editingSettings: {
|
||||
syncOnlyRegEx: "alpha|[]|beta",
|
||||
syncIgnoreRegEx: "skip|[]|also-skip",
|
||||
syncInternalFilesTargetPatterns: "target-a,target-b",
|
||||
syncInternalFilesIgnorePatterns: "existing,\\/node_modules\\/",
|
||||
syncInternalFileOverwritePatterns: "overwrite-a,overwrite-b",
|
||||
},
|
||||
saveAllDirtySettings: vi.fn(async () => {}),
|
||||
requestPageRefresh: vi.fn(),
|
||||
lifetimeComponent: { register: (dispose: () => void) => disposers.push(dispose) },
|
||||
};
|
||||
|
||||
paneSelector.call(tab as never, {} as HTMLElement, { addPanel } as never);
|
||||
await vi.waitFor(() => expect(paneSelectorHarness.mounts).toHaveLength(5));
|
||||
return { tab, addPanel, disposers };
|
||||
}
|
||||
|
||||
function controlProps(index: number): PatternControlProps {
|
||||
return paneSelectorHarness.mounts[index]!.props as unknown as PatternControlProps;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
paneSelectorHarness.mounts.length = 0;
|
||||
paneSelectorHarness.unmounted.length = 0;
|
||||
paneSelectorHarness.settings.length = 0;
|
||||
paneSelectorHarness.buttons.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("paneSelector multiple-pattern controls", () => {
|
||||
it("keeps all five bindings, independent originals, delimiter choices, save ordering, and owned cleanup", async () => {
|
||||
const { tab, addPanel, disposers } = await renderSelector();
|
||||
expect(addPanel.mock.calls.map(([, title]) => title)).toEqual(["Normal Files", "Hidden Files"]);
|
||||
expect(paneSelectorHarness.mounts.map(({ props }) => (props as PatternControlProps).patterns)).toEqual([
|
||||
["alpha", "beta"],
|
||||
["skip", "also-skip"],
|
||||
["target-a", "target-b"],
|
||||
["existing", "\\/node_modules\\/"],
|
||||
["overwrite-a", "overwrite-b"],
|
||||
]);
|
||||
|
||||
for (let index = 0; index < paneSelectorHarness.mounts.length; index++) {
|
||||
const { patterns, originals } = controlProps(index);
|
||||
expect(originals).toEqual(patterns);
|
||||
expect(originals).not.toBe(patterns);
|
||||
patterns.push("edited-current-value");
|
||||
expect(originals).not.toContain("edited-current-value");
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
index: 0,
|
||||
key: "syncOnlyRegEx",
|
||||
patterns: ["new-normal-a", "new-normal-b"],
|
||||
value: "new-normal-a|[]|new-normal-b",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
key: "syncIgnoreRegEx",
|
||||
patterns: ["new-skip-a", "new-skip-b"],
|
||||
value: "new-skip-a|[]|new-skip-b",
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
key: "syncInternalFilesTargetPatterns",
|
||||
patterns: ["new-target-a", "new-target-b"],
|
||||
value: "new-target-a,new-target-b",
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
key: "syncInternalFilesIgnorePatterns",
|
||||
patterns: ["new-ignore-a", "new-ignore-b"],
|
||||
value: "new-ignore-a,new-ignore-b",
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
key: "syncInternalFileOverwritePatterns",
|
||||
patterns: ["new-overwrite-a", "new-overwrite-b"],
|
||||
value: "new-overwrite-a,new-overwrite-b",
|
||||
},
|
||||
] as const;
|
||||
const events: string[] = [];
|
||||
for (let index = 0; index < cases.length; index++) {
|
||||
const item = cases[index]!;
|
||||
const pendingSave = deferred();
|
||||
events.length = 0;
|
||||
tab.saveAllDirtySettings.mockImplementationOnce(async () => {
|
||||
events.push(`save:${item.key}`);
|
||||
await pendingSave.promise;
|
||||
events.push("saved");
|
||||
});
|
||||
tab.requestPageRefresh.mockImplementationOnce(() => {
|
||||
events.push("refresh");
|
||||
});
|
||||
|
||||
const applying = controlProps(item.index).apply([...item.patterns]);
|
||||
expect(tab.editingSettings[item.key as keyof typeof tab.editingSettings]).toBe(item.value);
|
||||
expect(tab.saveAllDirtySettings).toHaveBeenCalledTimes(index + 1);
|
||||
expect(events).toEqual([`save:${item.key}`]);
|
||||
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(index);
|
||||
|
||||
pendingSave.resolve();
|
||||
await applying;
|
||||
expect(events).toEqual([`save:${item.key}`, "saved", "refresh"]);
|
||||
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(index + 1);
|
||||
}
|
||||
|
||||
const failure = new Error("settings save failed");
|
||||
tab.saveAllDirtySettings.mockRejectedValueOnce(failure);
|
||||
await expect(controlProps(2).apply(["assigned-before-failure"])).rejects.toBe(failure);
|
||||
expect(tab.editingSettings.syncInternalFilesTargetPatterns).toBe("assigned-before-failure");
|
||||
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(cases.length);
|
||||
|
||||
expect(disposers).toHaveLength(5);
|
||||
disposers.forEach((dispose) => dispose());
|
||||
expect(paneSelectorHarness.unmounted).toEqual(paneSelectorHarness.mounts.map(({ handle }) => handle));
|
||||
});
|
||||
|
||||
it("merges default hidden-file ignores with existing patterns and removes duplicates", async () => {
|
||||
const { tab } = await renderSelector();
|
||||
const defaultButton = paneSelectorHarness.buttons.find(({ text }) => text === "Default");
|
||||
const crossPlatformButton = paneSelectorHarness.buttons.find(({ text }) => text === "Cross-platform");
|
||||
expect(defaultButton?.handler).toBeDefined();
|
||||
expect(crossPlatformButton?.handler).toBeDefined();
|
||||
|
||||
await defaultButton!.handler!();
|
||||
expect(tab.editingSettings.syncInternalFilesIgnorePatterns).toBe(
|
||||
"existing,\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/"
|
||||
);
|
||||
await crossPlatformButton!.handler!();
|
||||
expect(tab.editingSettings.syncInternalFilesIgnorePatterns).toBe(
|
||||
"existing,\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/,\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$"
|
||||
);
|
||||
expect(tab.saveAllDirtySettings).toHaveBeenCalledTimes(2);
|
||||
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -97,16 +97,6 @@ export function findAttrFromParent(el: HTMLElement, attr: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function wrapMemo<T>(func: (arg: T) => void) {
|
||||
let buf: T | undefined = undefined;
|
||||
return (arg: T) => {
|
||||
if (buf !== arg) {
|
||||
func(arg);
|
||||
buf = arg;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Defers pane construction until the owning settings page can confirm that its
|
||||
* render scope is still active.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues";
|
||||
|
||||
export const checkConfig = async (
|
||||
checkResultDiv: HTMLDivElement | undefined,
|
||||
@@ -89,19 +90,6 @@ export const checkConfig = async (
|
||||
addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
|
||||
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
|
||||
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
|
||||
const versionParts = `${versionStr}.0.0.0`.split(".");
|
||||
// Compare version string with the target version.
|
||||
// version must be a string like "3.2.1" or "3.10.2", and must be two or three parts.
|
||||
function isGreaterThanOrEqual(version: string) {
|
||||
const targetParts = version.split(".");
|
||||
for (let i = 0; i < targetParts.length; i++) {
|
||||
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
|
||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
||||
if (result > 0) return true;
|
||||
if (result < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Admin check
|
||||
// for database creation and deletion
|
||||
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
|
||||
@@ -109,7 +97,7 @@ export const checkConfig = async (
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||
}
|
||||
if (isGreaterThanOrEqual("3.2.0")) {
|
||||
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||
// HTTP user-authorization check
|
||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||
isSuccessful = false;
|
||||
@@ -148,7 +136,7 @@ export const checkConfig = async (
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||
}
|
||||
if (isGreaterThanOrEqual("3.2.0")) {
|
||||
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"));
|
||||
@@ -240,12 +228,7 @@ export const checkConfig = async (
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseHeaders = Object.fromEntries(
|
||||
Object.entries(rr.headers).map((e) => {
|
||||
e[0] = `${e[0]}`.toLowerCase();
|
||||
return e;
|
||||
})
|
||||
);
|
||||
const responseHeaders = normaliseResponseHeaderKeys(rr.headers);
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues";
|
||||
|
||||
export type ResultMessage = { message: string; classes: string[] };
|
||||
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
|
||||
@@ -26,24 +27,6 @@ export type ConfigCheckResult<T = unknown, U = unknown> =
|
||||
| ResultError<U>
|
||||
| ResultMessage
|
||||
| ResultErrorMessage;
|
||||
/**
|
||||
* Compares two version strings to determine if the baseVersion is greater than or equal to the version.
|
||||
* @param baseVersion a.b.c format
|
||||
* @param version a.b.c format
|
||||
* @returns true if baseVersion is greater than or equal to version, false otherwise
|
||||
*/
|
||||
function isGreaterThanOrEqual(baseVersion: string, version: string) {
|
||||
const versionParts = `${baseVersion}.0.0.0`.split(".");
|
||||
const targetParts = version.split(".");
|
||||
for (let i = 0; i < targetParts.length; i++) {
|
||||
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
|
||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
||||
if (result > 0) return true;
|
||||
if (result < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the remote CouchDB setting with the given key and value.
|
||||
* @param setting Connection settings
|
||||
@@ -153,7 +136,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||
}
|
||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
||||
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||
// HTTP user-authorization check
|
||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||
addError(
|
||||
@@ -189,7 +172,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||
}
|
||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
||||
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"),
|
||||
@@ -287,12 +270,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseHeaders = Object.fromEntries(
|
||||
Object.entries(rr.headers).map((e) => {
|
||||
e[0] = `${e[0]}`.toLowerCase();
|
||||
return e;
|
||||
})
|
||||
);
|
||||
const responseHeaders = normaliseResponseHeaderKeys(rr.headers);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { createDeferredAction, type TimeoutScheduler } from "octagonal-wheels/concurrency/deferredAction";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
|
||||
vi.mock("octagonal-wheels/concurrency/deferredAction", { spy: true });
|
||||
|
||||
function createdControllers() {
|
||||
return vi.mocked(createDeferredAction).mock.results.map((result) => {
|
||||
if (result.type !== "return") throw new Error("Expected a real timeout controller");
|
||||
return result.value;
|
||||
});
|
||||
}
|
||||
|
||||
function createScheduler() {
|
||||
const entries: { callback: () => void; delayMs: number; cancel: ReturnType<typeof vi.fn> }[] = [];
|
||||
const schedule = vi.fn<TimeoutScheduler>((callback, delayMs) => {
|
||||
const cancel = vi.fn();
|
||||
entries.push({ callback, delayMs, cancel });
|
||||
return cancel;
|
||||
});
|
||||
return { schedule, entries };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-24T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("status timeout composition", () => {
|
||||
it("uses the real shared controller for minimum visibility, resumption, and disposal", () => {
|
||||
const scheduler = createScheduler();
|
||||
const source = reactiveSource(0);
|
||||
const display = createMinimumVisibleActivityCount(source, 150, scheduler.schedule);
|
||||
expect(createDeferredAction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), scheduler.schedule);
|
||||
const controller = createdControllers()[0];
|
||||
const schedule = vi.spyOn(controller, "schedule");
|
||||
const cancel = vi.spyOn(controller, "cancel");
|
||||
const dispose = vi.spyOn(controller, "dispose");
|
||||
|
||||
source.value = 1;
|
||||
vi.advanceTimersByTime(50);
|
||||
source.value = 0;
|
||||
expect(schedule).toHaveBeenCalledExactlyOnceWith(100);
|
||||
expect(scheduler.entries[0].delayMs).toBe(100);
|
||||
source.value = 2;
|
||||
expect(cancel).toHaveBeenCalledTimes(3);
|
||||
expect(scheduler.entries[0].cancel).toHaveBeenCalledTimes(1);
|
||||
scheduler.entries[0].callback();
|
||||
expect(display.value).toBe(2);
|
||||
|
||||
source.value = 0;
|
||||
scheduler.entries[1].callback();
|
||||
expect(display.value).toBe(0);
|
||||
source.value = 3;
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
display.dispose();
|
||||
scheduler.entries[2].callback();
|
||||
expect(display.value).toBe(3);
|
||||
expect(scheduler.entries[2].cancel).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses the shared controller for the counter linger and cancels through it when activity resumes", () => {
|
||||
const scheduler = createScheduler();
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "A", 25, scheduler.schedule);
|
||||
expect(createDeferredAction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), scheduler.schedule);
|
||||
const controller = createdControllers()[0];
|
||||
const schedule = vi.spyOn(controller, "schedule");
|
||||
const cancel = vi.spyOn(controller, "cancel");
|
||||
const dispose = vi.spyOn(controller, "dispose");
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
expect(schedule).toHaveBeenCalledExactlyOnceWith(25);
|
||||
source.value = 2;
|
||||
expect(cancel).toHaveBeenCalledTimes(3);
|
||||
expect(scheduler.entries[0].cancel).toHaveBeenCalledTimes(1);
|
||||
scheduler.entries[0].callback();
|
||||
expect(display.value).toBe(" A\u20072");
|
||||
expect(display.count.value).toBe(2);
|
||||
|
||||
source.value = 0;
|
||||
scheduler.entries[1].callback();
|
||||
expect(display.value).toBe("");
|
||||
expect(display.count.value).toBeUndefined();
|
||||
source.value = 3;
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
scheduler.entries[2].callback();
|
||||
expect(display.value).toBe(" A\u20070");
|
||||
expect(display.count.value).toBe(0);
|
||||
expect(scheduler.entries[2].cancel).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the scheduler to independent chunk-count controllers and disposes both children", () => {
|
||||
const scheduler = createScheduler();
|
||||
const source = reactiveSource({ initial: 2, retrying: 3 });
|
||||
const display = createChunkFetchCounterLabel(source, scheduler.schedule);
|
||||
expect(display.initialCount.value).toBe(2);
|
||||
expect(display.retryingCount.value).toBe(3);
|
||||
expect(createDeferredAction).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(createDeferredAction).mock.calls.map((args) => args[1])).toEqual([
|
||||
scheduler.schedule,
|
||||
scheduler.schedule,
|
||||
]);
|
||||
const controllers = createdControllers();
|
||||
const disposers = controllers.map((controller) => vi.spyOn(controller, "dispose"));
|
||||
source.value = { initial: 0, retrying: 0 };
|
||||
expect(scheduler.entries.map((entry) => entry.delayMs)).toEqual([
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
]);
|
||||
scheduler.entries[0].callback();
|
||||
expect(display.value.replace(/\u2007/g, "")).toBe(" 🔁0");
|
||||
expect(display.initialCount.value).toBeUndefined();
|
||||
expect(display.retryingCount.value).toBe(0);
|
||||
display.dispose();
|
||||
display.dispose();
|
||||
expect(scheduler.entries[0].cancel).not.toHaveBeenCalled();
|
||||
expect(scheduler.entries[1].cancel).toHaveBeenCalledTimes(1);
|
||||
for (const dispose of disposers) expect(dispose).toHaveBeenCalledTimes(1);
|
||||
scheduler.entries[1].callback();
|
||||
expect(display.value.replace(/\u2007/g, "")).toBe(" 🔁0");
|
||||
expect(display.initialCount.value).toBeUndefined();
|
||||
expect(display.retryingCount.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import { createDeferredAction, type TimeoutScheduler } from "octagonal-wheels/concurrency/deferredAction";
|
||||
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
const scheduleStatusTimeout: TimeoutScheduler = (callback, delayMs) => {
|
||||
const handle = compatGlobal.setTimeout(callback, delayMs);
|
||||
return () => compatGlobal.clearTimeout(handle);
|
||||
};
|
||||
|
||||
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
|
||||
|
||||
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
|
||||
@@ -37,32 +43,32 @@ function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => v
|
||||
/**
|
||||
* Mirrors an activity count while keeping each visible period on screen for a
|
||||
* minimum total lifetime. The delay applies only when the source becomes zero.
|
||||
*
|
||||
* @param source - The activity count to observe.
|
||||
* @param minimumVisibleMs - The minimum visible lifetime, clamped to zero.
|
||||
* @param scheduleTimeout - The scheduler for this display. Defaults to the
|
||||
* existing compatible host, preserving its timer method receivers.
|
||||
*/
|
||||
export function createMinimumVisibleActivityCount(
|
||||
source: ReactiveValue<number>,
|
||||
minimumVisibleMs: number
|
||||
minimumVisibleMs: number,
|
||||
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||
): DisposableReactiveValue<number> {
|
||||
const minimumLifetime = Math.max(0, minimumVisibleMs);
|
||||
const displayed = reactiveSource(Math.max(0, source.value));
|
||||
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
|
||||
let hideTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelHide = () => {
|
||||
if (hideTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(hideTimer);
|
||||
hideTimer = undefined;
|
||||
};
|
||||
const hideIfIdle = () => {
|
||||
hideTimer = undefined;
|
||||
if (disposed || Math.max(0, source.value) > 0) return;
|
||||
displayed.value = 0;
|
||||
visibleSince = undefined;
|
||||
};
|
||||
const hideTimeout = createDeferredAction(hideIfIdle, scheduleTimeout);
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const nextCount = Math.max(0, source.value);
|
||||
cancelHide();
|
||||
hideTimeout.cancel();
|
||||
if (nextCount > 0) {
|
||||
if (displayed.value === 0) {
|
||||
visibleSince = Date.now();
|
||||
@@ -80,7 +86,7 @@ export function createMinimumVisibleActivityCount(
|
||||
if (remaining === 0) {
|
||||
hideIfIdle();
|
||||
} else {
|
||||
hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining);
|
||||
hideTimeout.schedule(remaining);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,7 +94,7 @@ export function createMinimumVisibleActivityCount(
|
||||
return asDisposableReactiveValue(displayed, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelHide();
|
||||
hideTimeout.dispose();
|
||||
source.offChanged(update);
|
||||
});
|
||||
}
|
||||
@@ -96,24 +102,31 @@ export function createMinimumVisibleActivityCount(
|
||||
/**
|
||||
* Formats a counter with a stable width and briefly retains its zero value so
|
||||
* that the completion of queued work remains visible.
|
||||
*
|
||||
* @param source - The counter to observe.
|
||||
* @param mark - The label prefix before the padded count.
|
||||
* @param inactiveLingerMs - The delay before clearing a zero count, clamped to zero.
|
||||
* @param scheduleTimeout - The scheduler for this display. Defaults to the
|
||||
* existing compatible host, preserving its timer method receivers.
|
||||
*/
|
||||
export function createPaddedCounterLabel(
|
||||
source: ReactiveValue<number>,
|
||||
mark: string,
|
||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
|
||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||
): PaddedCounterLabel {
|
||||
const linger = Math.max(0, inactiveLingerMs);
|
||||
const formatted = reactiveSource("");
|
||||
const displayedCount = reactiveSource<number | undefined>(undefined);
|
||||
let maximumLength = 1;
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(clearTimer);
|
||||
clearTimer = undefined;
|
||||
};
|
||||
const clearLabelTimeout = createDeferredAction(() => {
|
||||
if (disposed) return;
|
||||
formatted.value = "";
|
||||
displayedCount.value = undefined;
|
||||
maximumLength = 1;
|
||||
}, scheduleTimeout);
|
||||
const format = (count: number) => {
|
||||
const requiredLength = `${Math.abs(count)}`.length + 1;
|
||||
maximumLength = Math.max(maximumLength, requiredLength);
|
||||
@@ -121,25 +134,19 @@ export function createPaddedCounterLabel(
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
cancelClear();
|
||||
clearLabelTimeout.cancel();
|
||||
const count = source.value;
|
||||
formatted.value = format(count);
|
||||
displayedCount.value = count;
|
||||
if (count !== 0) return;
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
formatted.value = "";
|
||||
displayedCount.value = undefined;
|
||||
maximumLength = 1;
|
||||
}, linger);
|
||||
clearLabelTimeout.schedule(linger);
|
||||
};
|
||||
|
||||
source.onChanged(update);
|
||||
const display = asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
clearLabelTimeout.dispose();
|
||||
source.offChanged(update);
|
||||
});
|
||||
return {
|
||||
@@ -162,14 +169,28 @@ export function createPaddedCounterLabel(
|
||||
/**
|
||||
* Displays the disjoint initial and retry chunk-fetch counts with the same
|
||||
* padding and inactive linger behaviour as the other status counters.
|
||||
*
|
||||
* @param source - The initial and retrying chunk-fetch counts.
|
||||
* @param scheduleTimeout - The scheduler passed to each independent child display.
|
||||
*/
|
||||
export function createChunkFetchCounterLabel(
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>,
|
||||
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||
): ChunkFetchCounterLabel {
|
||||
const initialCount = reactiveSource(0);
|
||||
const retryingCount = reactiveSource(0);
|
||||
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
|
||||
const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁");
|
||||
const initialLabel = createPaddedCounterLabel(
|
||||
initialCount,
|
||||
"🛄",
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
scheduleTimeout
|
||||
);
|
||||
const retryingLabel = createPaddedCounterLabel(
|
||||
retryingCount,
|
||||
"🔁",
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
scheduleTimeout
|
||||
);
|
||||
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
|
||||
let updatingCounts = false;
|
||||
let disposed = false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -194,3 +195,121 @@ describe("createChunkFetchCounterLabel", () => {
|
||||
expect(withoutPadding()).toBe(" 🔁0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status timeout ownership", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-24T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("counts the minimum lifetime from construction when activity is already visible", () => {
|
||||
const source = reactiveSource(2);
|
||||
const display = createMinimumVisibleActivityCount(source, 150);
|
||||
vi.advanceTimersByTime(40);
|
||||
source.value = 0;
|
||||
|
||||
vi.advanceTimersByTime(109);
|
||||
expect(display.value).toBe(2);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBe(0);
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("hides immediately for a non-positive minimum lifetime and clamps negative activity", () => {
|
||||
const source = reactiveSource(-2);
|
||||
const display = createMinimumVisibleActivityCount(source, -10);
|
||||
expect(display.value).toBe(0);
|
||||
source.value = 1;
|
||||
source.value = -2;
|
||||
|
||||
expect(display.value).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("keeps a zero-duration counter clear asynchronous", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "A", 0);
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
|
||||
expect(display.value).toBe(" A\u20070");
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(display.value).toBe("");
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it.each(["activity", "label"] as const)(
|
||||
"retains timer receivers, zero handles, and idempotent disposal for %s",
|
||||
(kind) => {
|
||||
const source = reactiveSource(0);
|
||||
const offChanged = vi.spyOn(source, "offChanged");
|
||||
const setTimer = vi.spyOn(compatGlobal as Window, "setTimeout").mockReturnValue(0);
|
||||
const clearTimer = vi.spyOn(compatGlobal as Window, "clearTimeout").mockImplementation(() => {});
|
||||
const display =
|
||||
kind === "activity"
|
||||
? createMinimumVisibleActivityCount(source, 150)
|
||||
: createPaddedCounterLabel(source, "A", 150);
|
||||
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
expect(setTimer).toHaveBeenCalledTimes(1);
|
||||
expect(setTimer.mock.contexts[0]).toBe(compatGlobal);
|
||||
expect(setTimer.mock.calls[0][1]).toBe(150);
|
||||
source.value = 2;
|
||||
expect(clearTimer).toHaveBeenCalledExactlyOnceWith(0);
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
display.dispose();
|
||||
source.value = 3;
|
||||
|
||||
expect(setTimer).toHaveBeenCalledTimes(2);
|
||||
expect(clearTimer).toHaveBeenCalledTimes(2);
|
||||
expect(clearTimer.mock.calls).toEqual([[0], [0]]);
|
||||
expect(clearTimer.mock.contexts).toEqual([compatGlobal, compatGlobal]);
|
||||
expect(offChanged).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps pending clears independent between display instances", () => {
|
||||
const firstSource = reactiveSource(0);
|
||||
const secondSource = reactiveSource(0);
|
||||
const first = createPaddedCounterLabel(firstSource, "A", 100);
|
||||
const second = createPaddedCounterLabel(secondSource, "B", 100);
|
||||
firstSource.value = 1;
|
||||
firstSource.value = 0;
|
||||
secondSource.value = 1;
|
||||
secondSource.value = 0;
|
||||
expect(vi.getTimerCount()).toBe(2);
|
||||
|
||||
first.dispose();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(first.value).toBe(" A\u20070");
|
||||
expect(second.value).toBe("");
|
||||
second.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("disposes both child timers and its source subscription for chunk counts", () => {
|
||||
const source = reactiveSource({ initial: 2, retrying: 3 });
|
||||
const offChanged = vi.spyOn(source, "offChanged");
|
||||
const display = createChunkFetchCounterLabel(source);
|
||||
source.value = { initial: 0, retrying: 0 };
|
||||
const lastLabel = display.value;
|
||||
expect(vi.getTimerCount()).toBe(2);
|
||||
|
||||
display.dispose();
|
||||
display.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||
source.value = { initial: 4, retrying: 5 };
|
||||
expect(display.value).toBe(lastLabel);
|
||||
expect(offChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export function isCouchDBVersionAtLeast(baseVersion: string, targetVersion: string) {
|
||||
const versionParts = `${baseVersion}.0.0.0`.split(".");
|
||||
const targetParts = targetVersion.split(".");
|
||||
for (let i = 0; i < targetParts.length; i++) {
|
||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
||||
if (result > 0) return true;
|
||||
if (result < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normaliseResponseHeaderKeys<T>(headers: Record<string, T>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map((entry) => {
|
||||
entry[0] = `${entry[0]}`.toLowerCase();
|
||||
return entry;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "./couchDBDiagnosticValues";
|
||||
|
||||
describe("CouchDB diagnostic values", () => {
|
||||
describe("version comparison", () => {
|
||||
it.each([
|
||||
["3.10.2", "3.2.0", true],
|
||||
["3.2.0", "3.10.0", false],
|
||||
["3.2", "3.2.0", true],
|
||||
["3.10.1b", "3.10.1a", true],
|
||||
["3.10.1a", "3.10.1b", false],
|
||||
["3.x", "3.2", true],
|
||||
])("compares %s with %s", (baseVersion, targetVersion, expected) => {
|
||||
expect(isCouchDBVersionAtLeast(baseVersion, targetVersion)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("response header keys", () => {
|
||||
it("lower-cases keys while retaining last collision values and entry order", () => {
|
||||
const headers = { Server: "first", server: "last", Zed: "z", Alpha: "a" };
|
||||
|
||||
const normalised = normaliseResponseHeaderKeys(headers);
|
||||
|
||||
expect(normalised).toEqual({ server: "last", zed: "z", alpha: "a" });
|
||||
expect(Object.keys(normalised)).toEqual(["server", "zed", "alpha"]);
|
||||
expect(headers).toEqual({ Server: "first", server: "last", Zed: "z", Alpha: "a" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,43 @@ declare module "obsidian" {
|
||||
}
|
||||
}
|
||||
|
||||
const cacheReuseDirectives = new Set(["max-age", "max-stale", "min-fresh", "only-if-cached"]);
|
||||
|
||||
function splitHeaderDirectives(value: string): string[] {
|
||||
const directives: string[] = [];
|
||||
let start = 0;
|
||||
let inQuotes = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const character = value[index];
|
||||
if (inQuotes && escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (inQuotes && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (character === "," && !inQuotes) {
|
||||
const directive = value.slice(start, index).trim();
|
||||
if (directive) directives.push(directive);
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const lastDirective = value.slice(start).trim();
|
||||
if (lastDirective) directives.push(lastDirective);
|
||||
return directives;
|
||||
}
|
||||
|
||||
function getDirectiveName(directive: string): string {
|
||||
const equalsIndex = directive.indexOf("=");
|
||||
return (equalsIndex === -1 ? directive : directive.slice(0, equalsIndex)).trim().toLowerCase();
|
||||
}
|
||||
|
||||
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
|
||||
_customHandler: ObsHttpHandler | undefined;
|
||||
_confirmInstance: Confirm;
|
||||
@@ -182,6 +219,30 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
|
||||
if (cacheMode === "no-store" && ["GET", "HEAD"].includes(method.toUpperCase())) {
|
||||
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
|
||||
const cacheControlKeys = Object.keys(transformedHeaders).filter(
|
||||
(key) => key.toLowerCase() === "cache-control"
|
||||
);
|
||||
const cacheControlDirectives = cacheControlKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => {
|
||||
const name = getDirectiveName(directive);
|
||||
return !cacheReuseDirectives.has(name) && name !== "no-cache" && name !== "no-store";
|
||||
});
|
||||
const cacheControlKey = cacheControlKeys[0] ?? "cache-control";
|
||||
for (const key of cacheControlKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[cacheControlKey] = [...cacheControlDirectives, "no-cache", "no-store"].join(", ");
|
||||
|
||||
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
|
||||
const pragmaDirectives = pragmaKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => getDirectiveName(directive) !== "no-cache");
|
||||
const pragmaKey = pragmaKeys[0] ?? "pragma";
|
||||
for (const key of pragmaKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[pragmaKey] = [...pragmaDirectives, "no-cache"].join(", ");
|
||||
}
|
||||
const contentType =
|
||||
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
|
||||
const requestParam: RequestUrlParam = {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RequestUrlParam } from "@/deps";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requestUrl: vi.fn<
|
||||
(request: RequestUrlParam) => Promise<{
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
arrayBuffer: ArrayBuffer;
|
||||
}>
|
||||
>(),
|
||||
platform: {
|
||||
isMobile: false,
|
||||
},
|
||||
@@ -8,12 +16,12 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
requestUrl: mocks.requestUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/deps", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
requestUrl: mocks.requestUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
||||
@@ -36,6 +44,8 @@ function createService(workspace: Record<string, unknown>, isMobile = false): Ob
|
||||
beforeEach(() => {
|
||||
mocks.platform.isMobile = false;
|
||||
vi.clearAllMocks();
|
||||
mocks.requestUrl.mockReset();
|
||||
mocks.requestUrl.mockResolvedValue({ status: 200, headers: {}, arrayBuffer: new ArrayBuffer(0) });
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
@@ -65,3 +75,115 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
const url = "https://couchdb.example.test/vault/f%3Ametadata";
|
||||
|
||||
function sentHeaders(): Headers {
|
||||
expect(mocks.requestUrl).toHaveBeenCalledOnce();
|
||||
return new Headers(mocks.requestUrl.mock.calls[0][0].headers);
|
||||
}
|
||||
|
||||
it.each(["GET", "HEAD", "get", "head"])("bypasses cached responses for an explicit no-store %s", async (method) => {
|
||||
const headers = {
|
||||
Authorization: "Basic fixture",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"cAcHe-CoNtRoL": "max-stale",
|
||||
Pragma: "cache",
|
||||
};
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("public, no-cache, no-store");
|
||||
expect(sentHeaders().get("pragma")).toBe("cache, no-cache");
|
||||
expect(sentHeaders().get("authorization")).toBe("Basic fixture");
|
||||
expect(headers["Cache-Control"]).toBe("public, max-age=3600");
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
|
||||
});
|
||||
|
||||
it("preserves non-conflicting cache directives when enforcing no-store", async () => {
|
||||
const headers = {
|
||||
"Cache-Control": 'max-age=3600, no-transform, custom="a,b"',
|
||||
"cAcHe-CoNtRoL": 'max-stale=30, min-fresh=5, only-if-cached, custom-two="max-age=1, only-if-cached"',
|
||||
Pragma: 'legacy="a,b", no-cache',
|
||||
"X-Custom-Header": "preserved",
|
||||
};
|
||||
const originalHeaders = { ...headers };
|
||||
|
||||
await createService({}).nativeFetch(url, { cache: "no-store", headers });
|
||||
|
||||
const sent = mocks.requestUrl.mock.calls[0][0].headers ?? {};
|
||||
expect(sent["Cache-Control"]).toBe(
|
||||
'no-transform, custom="a,b", custom-two="max-age=1, only-if-cached", no-cache, no-store'
|
||||
);
|
||||
expect(sent["cAcHe-CoNtRoL"]).toBeUndefined();
|
||||
expect(sent.Pragma).toBe('legacy="a,b", no-cache');
|
||||
expect(sent["X-Custom-Header"]).toBe("preserved");
|
||||
expect(headers).toEqual(originalHeaders);
|
||||
});
|
||||
|
||||
it("uses the Request cache mode when the options do not override it", async () => {
|
||||
const request = new Request(url, {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
headers: { "X-Fixture": "request" },
|
||||
});
|
||||
const headers = new Headers({ "X-Other": "options" });
|
||||
|
||||
await createService({}).nativeFetch(request, { headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
|
||||
expect(sentHeaders().get("pragma")).toBe("no-cache");
|
||||
expect(sentHeaders().get("x-fixture")).toBe("request");
|
||||
expect(sentHeaders().get("x-other")).toBe("options");
|
||||
expect(headers.has("cache-control")).toBe(false);
|
||||
expect(request.headers.has("cache-control")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the options request no-store for an otherwise cacheable Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "force-cache" }), { cache: "no-store" });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
|
||||
});
|
||||
|
||||
it("lets an explicit default cache mode override a no-store Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { cache: "default" });
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, "default", "no-cache", "reload", "force-cache"] as const)(
|
||||
"preserves caller headers for cache mode %s",
|
||||
async (cache) => {
|
||||
const headers = { "Cache-Control": "max-age=60", Pragma: "fixture" };
|
||||
|
||||
await createService({}).nativeFetch(url, { cache, headers });
|
||||
|
||||
expect(mocks.requestUrl.mock.calls[0][0].headers).toEqual(headers);
|
||||
}
|
||||
);
|
||||
|
||||
it("does not add cache directives to an ordinary request", async () => {
|
||||
await createService({}).nativeFetch(url);
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["POST", "PUT", "DELETE"])("preserves a no-store %s request", async (method) => {
|
||||
const headers = { "Cache-Control": "no-transform" };
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
|
||||
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method, headers });
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the effective method from the options", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { method: "POST" });
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user