mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 14:58:18 +00:00
Merge shared runtime changes onto current main
This commit is contained in:
commit
6211597d2d
128 files changed
+9204
-1285
No files matched your search
@@ -1,11 +1,10 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
|
||||
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
|
||||
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
/**
|
||||
* Compatibility facade consumed by Commonlib while browser presentation is
|
||||
@@ -14,26 +13,26 @@ import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
|
||||
readonly context: T;
|
||||
|
||||
constructor(context: T) {
|
||||
const dialogueController = new AbortController();
|
||||
constructor(context: T, ui: BrowserUiScope = createBrowserUiScope(context)) {
|
||||
const notifications = new BrowserUiNotifications({
|
||||
document: _activeDocument,
|
||||
document: ui.document,
|
||||
});
|
||||
super({
|
||||
ui: createBrowserUi({
|
||||
document: _activeDocument,
|
||||
signal: dialogueController.signal,
|
||||
document: ui.document,
|
||||
signal: ui.signal,
|
||||
renderMarkdown: ({ container, markdown }) => {
|
||||
renderMessageMarkdownInto(container, markdown);
|
||||
},
|
||||
}),
|
||||
notifications,
|
||||
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
|
||||
createActionAnchor: () => createNativeElement(ui.document, "a"),
|
||||
});
|
||||
this.context = context;
|
||||
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
dialogueController.abort();
|
||||
if (ui.signal.aborted) {
|
||||
notifications.dispose();
|
||||
});
|
||||
} else {
|
||||
ui.signal.addEventListener("abort", () => notifications.dispose(), { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { mount } from "svelte";
|
||||
import type { BrowserUiOptions } from "./BrowserUiScope";
|
||||
import { mount, unmount } from "svelte";
|
||||
import MenuView from "./ui/MenuView.svelte";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type MountedMenu = ReturnType<typeof mount>;
|
||||
type MenuSession = {
|
||||
waitingForClose: PromiseWithResolvers<void>;
|
||||
element: HTMLElement;
|
||||
component?: MountedMenu;
|
||||
abortListener?: () => void;
|
||||
closed: boolean;
|
||||
};
|
||||
|
||||
export class MenuItem {
|
||||
type = "item";
|
||||
title = "";
|
||||
@@ -29,7 +39,15 @@ export class Menu {
|
||||
type = "menu";
|
||||
items: (MenuItem | MenuSeparator)[] = [];
|
||||
|
||||
constructor() {}
|
||||
private readonly document: Document;
|
||||
private readonly signal?: AbortSignal;
|
||||
private activeSession?: MenuSession;
|
||||
|
||||
constructor(options: BrowserUiOptions = {}) {
|
||||
this.document = options.document ?? _activeDocument;
|
||||
this.signal = options.signal;
|
||||
}
|
||||
|
||||
addItem(callback: (item: MenuItem) => void) {
|
||||
const item = new MenuItem();
|
||||
callback(item);
|
||||
@@ -41,32 +59,75 @@ export class Menu {
|
||||
return this;
|
||||
}
|
||||
waitingForClose?: PromiseWithResolvers<void>;
|
||||
showAtPosition(pos: { x: number; y: number }) {
|
||||
const el = createNativeElement(_activeDocument, "div");
|
||||
if (this.waitingForClose) {
|
||||
this.waitingForClose.resolve();
|
||||
|
||||
showAtPosition(pos: { x: number; y: number }): Promise<void> {
|
||||
if (this.activeSession) {
|
||||
this.closeSession(this.activeSession);
|
||||
}
|
||||
this.waitingForClose = promiseWithResolvers<void>();
|
||||
mount(MenuView, {
|
||||
target: el,
|
||||
props: {
|
||||
items: this.items,
|
||||
closeMenu: () => {
|
||||
this.waitingForClose?.resolve();
|
||||
this.waitingForClose = undefined;
|
||||
if (this.signal?.aborted) {
|
||||
this.waitingForClose = undefined;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const waitingForClose = promiseWithResolvers<void>();
|
||||
const element = createNativeElement(this.document, "div");
|
||||
const session: MenuSession = {
|
||||
waitingForClose,
|
||||
element,
|
||||
closed: false,
|
||||
};
|
||||
this.activeSession = session;
|
||||
this.waitingForClose = waitingForClose;
|
||||
|
||||
const closeMenu = () => this.closeSession(session);
|
||||
session.abortListener = closeMenu;
|
||||
this.signal?.addEventListener("abort", closeMenu, { once: true });
|
||||
|
||||
try {
|
||||
session.component = mount(MenuView, {
|
||||
target: element,
|
||||
props: {
|
||||
items: this.items,
|
||||
closeMenu,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
},
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
},
|
||||
});
|
||||
_activeDocument.body.appendChild(el);
|
||||
void this.waitingForClose.promise.finally(() => {
|
||||
el.remove();
|
||||
});
|
||||
return this.waitingForClose.promise;
|
||||
});
|
||||
if (!session.closed) {
|
||||
this.document.body.appendChild(element);
|
||||
} else if (session.component) {
|
||||
fireAndForget(() => unmount(session.component!));
|
||||
}
|
||||
} catch (error) {
|
||||
this.closeSession(session);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return waitingForClose.promise;
|
||||
}
|
||||
|
||||
private closeSession(session: MenuSession): void {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
if (session.abortListener) {
|
||||
this.signal?.removeEventListener("abort", session.abortListener);
|
||||
session.abortListener = undefined;
|
||||
}
|
||||
if (this.activeSession === session) {
|
||||
this.activeSession = undefined;
|
||||
this.waitingForClose = undefined;
|
||||
}
|
||||
|
||||
session.element.remove();
|
||||
if (session.component) {
|
||||
fireAndForget(() => unmount(session.component!));
|
||||
}
|
||||
session.waitingForClose.resolve();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.waitingForClose?.resolve();
|
||||
this.waitingForClose = undefined;
|
||||
if (this.activeSession) {
|
||||
this.closeSession(this.activeSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
|
||||
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
|
||||
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
export class BrowserModal {
|
||||
contentEl: HTMLElement;
|
||||
@@ -15,14 +16,14 @@ export class BrowserModal {
|
||||
modalEl: HTMLElement;
|
||||
isOpen: boolean = false;
|
||||
baseEl: HTMLElement;
|
||||
constructor() {
|
||||
const baseEl = createNativeElement(_activeDocument, "popup");
|
||||
constructor(document: Document = _activeDocument) {
|
||||
const baseEl = createNativeElement(document, "popup");
|
||||
this.baseEl = baseEl;
|
||||
this.contentEl = createNativeElement(_activeDocument, "div");
|
||||
this.contentEl = createNativeElement(document, "div");
|
||||
this.contentEl.className = "modal-content";
|
||||
this.titleEl = createNativeElement(_activeDocument, "div");
|
||||
this.titleEl = createNativeElement(document, "div");
|
||||
this.titleEl.className = "modal-title";
|
||||
this.modalEl = createNativeElement(_activeDocument, "div");
|
||||
this.modalEl = createNativeElement(document, "div");
|
||||
this.modalEl.className = "modal";
|
||||
this.modalEl.hidden = true;
|
||||
this.modalEl.appendChild(this.titleEl);
|
||||
@@ -33,7 +34,7 @@ export class BrowserModal {
|
||||
this.isOpen = true;
|
||||
this.modalEl.hidden = false;
|
||||
if (!this.baseEl.parentElement) {
|
||||
_activeDocument.body.appendChild(this.baseEl);
|
||||
this.baseEl.ownerDocument.body.appendChild(this.baseEl);
|
||||
}
|
||||
this.onOpen();
|
||||
}
|
||||
@@ -57,9 +58,10 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
context: C,
|
||||
dependents: SvelteDialogManagerDependencies<C>,
|
||||
component: ComponentHasResult<T, U>,
|
||||
initialData?: U
|
||||
initialData?: U,
|
||||
private readonly ui?: BrowserUiScope
|
||||
) {
|
||||
super();
|
||||
super(ui?.document ?? _activeDocument);
|
||||
this.session = new SvelteDialogSession({
|
||||
surface: this,
|
||||
context,
|
||||
@@ -67,9 +69,15 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
dialogHost: DialogHost,
|
||||
component,
|
||||
initialData,
|
||||
signal: ui?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
override open(): void {
|
||||
if (this.ui?.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||
super.open();
|
||||
}
|
||||
|
||||
override onOpen(): void {
|
||||
this.session.onOpen();
|
||||
}
|
||||
@@ -83,11 +91,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
}
|
||||
}
|
||||
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
|
||||
constructor(
|
||||
context: T,
|
||||
dependencies: SvelteDialogManagerDependencies<T>,
|
||||
private readonly ui: BrowserUiScope = createBrowserUiScope(context)
|
||||
) {
|
||||
super(context, dependencies);
|
||||
}
|
||||
|
||||
override async openSvelteDialog<TT, TU>(
|
||||
component: ComponentHasResult<TT, TU>,
|
||||
initialData?: TU
|
||||
): Promise<TT | undefined> {
|
||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(this.context, this.dependents, component, initialData);
|
||||
if (this.ui.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(
|
||||
this.context,
|
||||
this.dependents,
|
||||
component,
|
||||
initialData,
|
||||
this.ui
|
||||
);
|
||||
dialog.open();
|
||||
return await dialog.waitForClose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
export interface BrowserUiOptions {
|
||||
readonly document?: Document;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Browser presentation target and lifetime owned by one application context. */
|
||||
export interface BrowserUiScope {
|
||||
readonly document: Document;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export function createBrowserUiScope(
|
||||
context: Pick<ServiceContext, "events">,
|
||||
options: BrowserUiOptions = {}
|
||||
): BrowserUiScope {
|
||||
const controller = new AbortController();
|
||||
const parent = options.signal;
|
||||
let unregisterUnload: (() => void) | undefined;
|
||||
const end = (reason: unknown): void => {
|
||||
if (controller.signal.aborted) return;
|
||||
unregisterUnload?.();
|
||||
unregisterUnload = undefined;
|
||||
parent?.removeEventListener("abort", onParentAbort);
|
||||
controller.abort(reason);
|
||||
};
|
||||
const onParentAbort = () => end(parent?.reason);
|
||||
if (parent?.aborted) {
|
||||
controller.abort(parent.reason);
|
||||
} else {
|
||||
unregisterUnload = context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => end(new Error("Plug-in unloaded")));
|
||||
parent?.addEventListener("abort", onParentAbort, { once: true });
|
||||
}
|
||||
return { document: options.document ?? _activeDocument, signal: controller.signal };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/ser
|
||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
|
||||
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||
import type { BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||
API: InjectableAPIService<T>;
|
||||
@@ -20,15 +21,19 @@ export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIServic
|
||||
override get dialogToCopy() {
|
||||
return DialogToCopy;
|
||||
}
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>, ui?: BrowserUiScope) {
|
||||
const browserConfirm = dependents.API.confirm;
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: browserConfirm,
|
||||
control: dependents.control,
|
||||
});
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(
|
||||
context,
|
||||
{
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: browserConfirm,
|
||||
control: dependents.control,
|
||||
},
|
||||
ui
|
||||
);
|
||||
super(context, {
|
||||
dialogManager: obsidianSvelteDialogManager,
|
||||
APIService: dependents.API,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
|
||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { createBrowserUiScope, type BrowserUiOptions, type BrowserUiScope } from "./BrowserUiScope";
|
||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
@@ -41,6 +42,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
deviceValues: ValueStore & ValueDeleter;
|
||||
context?: T;
|
||||
ui?: BrowserUiOptions;
|
||||
getSystemVaultName?: () => string;
|
||||
settings?: LiveSyncBrowserSettingsPersistence;
|
||||
restart?: LiveSyncBrowserRestartPolicy;
|
||||
@@ -56,15 +58,18 @@ class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValu
|
||||
|
||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||
readonly browserUi: BrowserUiScope;
|
||||
|
||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||
const context =
|
||||
options.context ??
|
||||
(new ServiceContext({
|
||||
translate: translateLiveSyncMessage,
|
||||
}) as T);
|
||||
const browserUi = createBrowserUiScope(context, options.ui);
|
||||
const API = new LiveSyncBrowserAPIService(context, {
|
||||
...options.API,
|
||||
confirm: new BrowserConfirm(context),
|
||||
confirm: new BrowserConfirm(context, browserUi),
|
||||
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
|
||||
});
|
||||
const conflict = new InjectableConflictService(context);
|
||||
@@ -147,13 +152,17 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
APIService: API,
|
||||
replicatorService: replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(context, {
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(
|
||||
context,
|
||||
{
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
},
|
||||
browserUi
|
||||
);
|
||||
|
||||
super(context, {
|
||||
API,
|
||||
@@ -175,6 +184,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
ui,
|
||||
vault,
|
||||
});
|
||||
this.browserUi = browserUi;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export async function findCaseInsensitiveFile<T extends object>(
|
||||
requestedPath: string,
|
||||
findExact: () => Promise<T | null>,
|
||||
cachedFiles: ReadonlyMap<string, T>,
|
||||
scan: () => Promise<void>
|
||||
): Promise<T | null> {
|
||||
const exact = await findExact();
|
||||
if (exact) return exact;
|
||||
|
||||
const lowerPath = requestedPath.toLowerCase();
|
||||
const findCached = (): T | null => {
|
||||
for (const [cachedPath, cachedFile] of cachedFiles) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) return cachedFile;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cached = findCached();
|
||||
if (cached) return cached;
|
||||
|
||||
await scan();
|
||||
return findCached();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for Node.js
|
||||
@@ -87,27 +88,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
}
|
||||
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await findCaseInsensitiveFile(
|
||||
pathStr,
|
||||
() => this.getAbstractFileByPath(pathStr),
|
||||
this.fileCache,
|
||||
() => this.scanDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(): Promise<NodeFile[]> {
|
||||
|
||||
@@ -124,6 +124,83 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter case-insensitive path lookup", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createVault(files: string[] = []) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-insensitive-"));
|
||||
tempDirs.push(directory);
|
||||
for (const file of files) {
|
||||
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true });
|
||||
await fs.writeFile(path.join(directory, file), `content of ${file}`);
|
||||
}
|
||||
return { directory, adapter: new NodeFileSystemAdapter(directory) };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("returns an exact path without scanning the directory", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
await adapter.scanDirectory();
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||
});
|
||||
|
||||
it("returns null after one scan when no cached path matches", async () => {
|
||||
const { adapter } = await createVault(["other.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns null and reports a directory scan failure", async () => {
|
||||
const missingDirectory = path.join(os.tmpdir(), `livesync-cli-insensitive-missing-${Date.now()}`);
|
||||
const reportDiagnostic = vi.fn();
|
||||
const adapter = new NodeFileSystemAdapter(missingDirectory, reportDiagnostic);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(reportDiagnostic).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error scanning directory"),
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
@@ -15,30 +14,7 @@ import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
|
||||
/**
|
||||
* CLI-specific type guard adapter
|
||||
*/
|
||||
class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, NodeFolder> {
|
||||
isFile(file: unknown): file is NodeFile {
|
||||
return !!(
|
||||
file &&
|
||||
typeof file === "object" &&
|
||||
"path" in file &&
|
||||
"stat" in file &&
|
||||
!(file as { isFolder?: boolean }).isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: unknown): item is NodeFolder {
|
||||
return !!(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"path" in item &&
|
||||
(item as { isFolder?: boolean }).isFolder === true
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
|
||||
|
||||
/**
|
||||
* CLI-specific persistence adapter (file-based snapshot)
|
||||
@@ -211,7 +187,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
* Composite adapter for CLI StorageEventManager
|
||||
*/
|
||||
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
||||
readonly typeGuard: CLITypeGuardAdapter;
|
||||
readonly typeGuard: NodeTypeGuardAdapter;
|
||||
readonly persistence: CLIPersistenceAdapter;
|
||||
readonly watch: CLIWatchAdapter;
|
||||
readonly status: CLIStatusAdapter;
|
||||
@@ -223,7 +199,7 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
||||
watchEnabled: boolean = false,
|
||||
reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||
) {
|
||||
this.typeGuard = new CLITypeGuardAdapter();
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
|
||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
||||
this.status = new CLIStatusAdapter();
|
||||
|
||||
@@ -201,3 +201,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLIStorageEventManagerAdapter type guard", () => {
|
||||
const typeGuard = new CLIStorageEventManagerAdapter("/base").typeGuard;
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { stat: {} }, expected: false },
|
||||
{ label: "an object without a stat", value: { path: "note.md" }, expected: false },
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: true },
|
||||
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: false },
|
||||
])("classifies $label as a file", ({ value, expected }) => {
|
||||
expect(typeGuard.isFile(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { isFolder: true }, expected: false },
|
||||
{ label: "an object without the folder marker", value: { path: "folder" }, expected: false },
|
||||
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: true },
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: false },
|
||||
])("classifies $label as a folder", ({ value, expected }) => {
|
||||
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
@@ -10,7 +8,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { IgnoreRules } from "./IgnoreRules";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||
|
||||
/**
|
||||
* Initialize service modules for CLI version
|
||||
@@ -82,40 +80,7 @@ export function initialiseServiceModulesCLI(
|
||||
vault: services.vault,
|
||||
});
|
||||
|
||||
// File handler (platform-independent)
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||
|
||||
return {
|
||||
rebuilder,
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { defaultServerConditions, defaultServerMainFields, defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { fileURLToPath, fs, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fileURLToPath, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { readVersion } from "../readVersion";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const resolve = (...args: string[]) => path.resolve(...args).replace(/\\/g, "/");
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
|
||||
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
||||
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
||||
// https://vite.dev/config/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { fs } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
/** Read a string-valued version field from a JSON file during Node builds. */
|
||||
export function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readVersion } from "./readVersion";
|
||||
|
||||
describe("readVersion", () => {
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), "livesync-read-version-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns only string version values", () => {
|
||||
const filePath = join(directory, "version.json");
|
||||
writeFileSync(filePath, JSON.stringify({ version: "1.2.3" }));
|
||||
expect(readVersion(filePath)).toBe("1.2.3");
|
||||
|
||||
for (const contents of ["null", "[]", "{}", '{"version": 42}', '{"version": null}']) {
|
||||
writeFileSync(filePath, contents);
|
||||
expect(readVersion(filePath)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves file read and JSON parse errors", () => {
|
||||
expect(() => readVersion(join(directory, "missing.json"))).toThrow();
|
||||
|
||||
const filePath = join(directory, "invalid.json");
|
||||
writeFileSync(filePath, "not JSON");
|
||||
expect(() => readVersion(filePath)).toThrow(SyntaxError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
|
||||
/** Compose the service modules shared by app hosts after their storage setup. */
|
||||
export function composeSharedServiceModules(
|
||||
services: InjectableServiceHub<ServiceContext>,
|
||||
databaseFileAccess: DatabaseFileAccess,
|
||||
storageAccess: StorageAccess
|
||||
): Pick<ServiceModules, "fileHandler" | "rebuilder"> {
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
return { rebuilder, fileHandler };
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { initialiseServiceModulesCLI } from "@/apps/cli/serviceModules/CLIServiceModules";
|
||||
import { initialiseServiceModulesFSAPI } from "@/apps/webapp/serviceModules/FSAPIServiceModules";
|
||||
import { FILE_REFLECTION_PROVENANCE_STORE } from "@/serviceModules/FileReflectionProvenance";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
|
||||
const composition = vi.hoisted(() => ({
|
||||
calls: [] as string[],
|
||||
dependencies: {} as Record<string, unknown>,
|
||||
instances: {} as Record<string, unknown>,
|
||||
capture(name: string, dependencies: unknown, instance: unknown) {
|
||||
this.calls.push(name);
|
||||
this.dependencies[name] = dependencies;
|
||||
this.instances[name] = instance;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager", () => ({
|
||||
StorageAccessManager: class {
|
||||
constructor() {
|
||||
composition.capture("StorageAccessManager", undefined, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder", () => ({
|
||||
ServiceRebuilder: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceRebuilder", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/serviceModules/FileHandler", () => ({
|
||||
ServiceFileHandler: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileHandler", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/FileAccessCLI", () => ({
|
||||
FileAccessCLI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("FileAccessCLI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/ServiceFileAccessImpl", () => ({
|
||||
ServiceFileAccessCLI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileAccessCLI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/DatabaseFileAccess", () => ({
|
||||
ServiceDatabaseFileAccessCLI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceDatabaseFileAccessCLI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/managers/StorageEventManagerCLI", () => ({
|
||||
StorageEventManagerCLI: class {
|
||||
close = vi.fn(async () => undefined);
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("StorageEventManagerCLI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/FileAccessFSAPI", () => ({
|
||||
FileAccessFSAPI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("FileAccessFSAPI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/ServiceFileAccessImpl", () => ({
|
||||
ServiceFileAccessFSAPI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileAccessFSAPI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/DatabaseFileAccess", () => ({
|
||||
ServiceDatabaseFileAccessFSAPI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceDatabaseFileAccessFSAPI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/managers/StorageEventManagerFSAPI", () => ({
|
||||
StorageEventManagerFSAPI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("StorageEventManagerFSAPI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
function createServices() {
|
||||
const hostServices = Object.fromEntries(
|
||||
[
|
||||
"API",
|
||||
"conflict",
|
||||
"control",
|
||||
"database",
|
||||
"databaseEvents",
|
||||
"fileProcessing",
|
||||
"path",
|
||||
"remote",
|
||||
"replication",
|
||||
"replicator",
|
||||
"setting",
|
||||
"UI",
|
||||
"vault",
|
||||
].map((name) => [name, { name }])
|
||||
);
|
||||
const events = { label: "host event hub" };
|
||||
const addUnloadHandler = vi.fn<(handler: () => Promise<boolean>) => void>();
|
||||
const openSimpleStore = vi.fn((name: string) => {
|
||||
composition.calls.push("openSimpleStore");
|
||||
composition.dependencies.openSimpleStore = name;
|
||||
return { name };
|
||||
});
|
||||
|
||||
return {
|
||||
...hostServices,
|
||||
appLifecycle: { onUnload: { addHandler: addUnloadHandler } },
|
||||
context: { events },
|
||||
events,
|
||||
keyValueDB: { openSimpleStore },
|
||||
addUnloadHandler,
|
||||
};
|
||||
}
|
||||
|
||||
function createCore() {
|
||||
return {} as LiveSyncBaseCore<ServiceContext, never>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
composition.calls.length = 0;
|
||||
composition.dependencies = {};
|
||||
composition.instances = {};
|
||||
});
|
||||
|
||||
describe("app service module composition", () => {
|
||||
it("preserves CLI host construction, shared wiring, provenance, and watcher shutdown", async () => {
|
||||
const services = createServices();
|
||||
const core = createCore();
|
||||
|
||||
const modules = initialiseServiceModulesCLI(
|
||||
"/vault",
|
||||
core,
|
||||
services as unknown as InjectableServiceHub<ServiceContext>
|
||||
);
|
||||
|
||||
expect(composition.calls).toEqual([
|
||||
"StorageAccessManager",
|
||||
"FileAccessCLI",
|
||||
"StorageEventManagerCLI",
|
||||
"ServiceFileAccessCLI",
|
||||
"ServiceDatabaseFileAccessCLI",
|
||||
"openSimpleStore",
|
||||
"ServiceFileHandler",
|
||||
"ServiceRebuilder",
|
||||
]);
|
||||
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
|
||||
|
||||
const [basePath, fileAccessOptions] = composition.dependencies.FileAccessCLI as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const [eventBasePath, eventCore, eventDependencies, ignoreRules, watchEnabled] = composition.dependencies
|
||||
.StorageEventManagerCLI as [string, unknown, Record<string, unknown>, unknown, boolean];
|
||||
const storageAccessDependencies = composition.dependencies.ServiceFileAccessCLI as Record<string, unknown>;
|
||||
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessCLI as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const fileHandlerDependencies = composition.dependencies.ServiceFileHandler as Record<string, unknown>;
|
||||
const rebuilderDependencies = composition.dependencies.ServiceRebuilder as Record<string, unknown>;
|
||||
|
||||
expect(basePath).toBe("/vault");
|
||||
expect(fileAccessOptions).toEqual({
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultService: services.vault,
|
||||
settingService: services.setting,
|
||||
APIService: services.API,
|
||||
pathService: services.path,
|
||||
});
|
||||
expect(eventBasePath).toBe("/vault");
|
||||
expect(eventCore).toBe(core);
|
||||
expect(eventDependencies).toEqual({
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
APIService: services.API,
|
||||
});
|
||||
expect(ignoreRules).toBeUndefined();
|
||||
expect(watchEnabled).toBe(false);
|
||||
expect(storageAccessDependencies).toEqual({
|
||||
API: services.API,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
storageEventManager: composition.instances.StorageEventManagerCLI,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultAccess: composition.instances.FileAccessCLI,
|
||||
});
|
||||
expect(databaseAccessDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
path: services.path,
|
||||
storageAccess: modules.storageAccess,
|
||||
vault: services.vault,
|
||||
});
|
||||
expect(modules.databaseFileAccess).toBe(composition.instances.ServiceDatabaseFileAccessCLI);
|
||||
expect(modules.fileHandler).toBe(composition.instances.ServiceFileHandler);
|
||||
expect(modules.rebuilder).toBe(composition.instances.ServiceRebuilder);
|
||||
expect(fileHandlerDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: modules.databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: modules.storageAccess,
|
||||
fileReflectionProvenance: expect.any(Object),
|
||||
});
|
||||
expect(rebuilderDependencies).toEqual({
|
||||
path: services.path,
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: modules.fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: modules.storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
const unloadHandler = services.addUnloadHandler.mock.calls[0]?.[0];
|
||||
expect(unloadHandler).toBeDefined();
|
||||
await expect(unloadHandler?.()).resolves.toBe(true);
|
||||
expect(
|
||||
(composition.instances.StorageEventManagerCLI as { close: ReturnType<typeof vi.fn> }).close
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves WebApp host construction and returns its vault access and event manager", () => {
|
||||
const services = createServices();
|
||||
const core = createCore();
|
||||
const rootHandle = {} as FileSystemDirectoryHandle;
|
||||
|
||||
const modules = initialiseServiceModulesFSAPI(
|
||||
rootHandle,
|
||||
core,
|
||||
services as unknown as InjectableServiceHub<ServiceContext>
|
||||
);
|
||||
|
||||
expect(composition.calls).toEqual([
|
||||
"StorageAccessManager",
|
||||
"FileAccessFSAPI",
|
||||
"StorageEventManagerFSAPI",
|
||||
"ServiceFileAccessFSAPI",
|
||||
"ServiceDatabaseFileAccessFSAPI",
|
||||
"openSimpleStore",
|
||||
"ServiceFileHandler",
|
||||
"ServiceRebuilder",
|
||||
]);
|
||||
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
|
||||
const [fileAccessRoot, fileAccessOptions] = composition.dependencies.FileAccessFSAPI as [
|
||||
FileSystemDirectoryHandle,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const [eventRoot, eventCore, eventDependencies] = composition.dependencies.StorageEventManagerFSAPI as [
|
||||
FileSystemDirectoryHandle,
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const storageAccessDependencies = composition.dependencies.ServiceFileAccessFSAPI as Record<string, unknown>;
|
||||
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessFSAPI as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(fileAccessRoot).toBe(rootHandle);
|
||||
expect(fileAccessOptions).toEqual({
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultService: services.vault,
|
||||
settingService: services.setting,
|
||||
APIService: services.API,
|
||||
pathService: services.path,
|
||||
});
|
||||
expect(eventRoot).toBe(rootHandle);
|
||||
expect(eventCore).toBe(core);
|
||||
expect(eventDependencies).toEqual({
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
APIService: services.API,
|
||||
});
|
||||
expect(storageAccessDependencies).toEqual({
|
||||
API: services.API,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
storageEventManager: composition.instances.StorageEventManagerFSAPI,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultAccess: composition.instances.FileAccessFSAPI,
|
||||
});
|
||||
expect(databaseAccessDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
path: services.path,
|
||||
storageAccess: modules.storageAccess,
|
||||
vault: services.vault,
|
||||
});
|
||||
expect(modules.vaultAccess).toBe(composition.instances.FileAccessFSAPI);
|
||||
expect(modules.storageEventManager).toBe(composition.instances.StorageEventManagerFSAPI);
|
||||
expect(composition.dependencies.ServiceFileHandler).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: modules.databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: modules.storageAccess,
|
||||
fileReflectionProvenance: expect.any(Object),
|
||||
});
|
||||
expect(composition.dependencies.ServiceRebuilder).toEqual({
|
||||
path: services.path,
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: modules.fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: modules.storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
|
||||
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
|
||||
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for FileSystem API
|
||||
@@ -85,28 +86,12 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
|
||||
*/
|
||||
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
// TODO: Refactor: Very, Very heavy.
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await findCaseInsensitiveFile(
|
||||
pathStr,
|
||||
() => this.getAbstractFileByPath(pathStr),
|
||||
this.fileCache,
|
||||
() => this.scanDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(): Promise<FSAPIFile[]> {
|
||||
|
||||
@@ -110,6 +110,83 @@ describe("FileSystemAccessStorageAdapter", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter creation", () => {
|
||||
it.each(["text", "binary"] as const)(
|
||||
"creates nested %s files with exact content and stored metadata",
|
||||
async (kind) => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const data = new Uint8Array([0, 255, 1, 128, 42]).buffer;
|
||||
const path = "/nested//folder/file.dat";
|
||||
const file =
|
||||
kind === "text" ? await adapter.create(path, "日本語😀") : await adapter.createBinary(path, data);
|
||||
const expected = kind === "text" ? new TextEncoder().encode("日本語😀") : new Uint8Array(data);
|
||||
const nested = await root.getDirectoryHandle("nested");
|
||||
const folder = await nested.getDirectoryHandle("folder");
|
||||
const stored = await folder.getFileHandle("file.dat");
|
||||
expect(file.path).toBe(path);
|
||||
expect(file.handle).toBe(stored);
|
||||
expect(new Uint8Array(await adapter.readBinary(file))).toEqual(expected);
|
||||
expect(file.stat).toEqual({ size: expected.byteLength, mtime: 1, ctime: 1, type: "file" });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["text", "binary"] as const)("waits for the %s writer to close before reading metadata", async (kind) => {
|
||||
let releaseClose!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
const events: string[] = [];
|
||||
const getFile = vi.fn(async () => {
|
||||
events.push("metadata");
|
||||
return new File(["result"], "file.dat", { lastModified: 123 });
|
||||
});
|
||||
const close = vi.fn(async () => {
|
||||
events.push("close-start");
|
||||
await closed;
|
||||
events.push("closed");
|
||||
});
|
||||
const write = vi.fn(async () => {
|
||||
events.push("write");
|
||||
});
|
||||
const fileHandle = { createWritable: vi.fn(async () => ({ write, close })), getFile };
|
||||
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const data = new Uint8Array([0, 255]).buffer;
|
||||
const pending = kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", data);
|
||||
try {
|
||||
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseClose();
|
||||
}
|
||||
const file = await pending;
|
||||
expect(events).toEqual(["write", "close-start", "closed", "metadata"]);
|
||||
expect(write).toHaveBeenCalledWith(kind === "text" ? "text" : data);
|
||||
expect(file.stat).toEqual({ size: 6, ctime: 123, mtime: 123, type: "file" });
|
||||
});
|
||||
|
||||
it.each(["text", "binary"] as const)("propagates a %s write failure without returning metadata", async (kind) => {
|
||||
const failure = new Error("fixture write failed");
|
||||
const getFile = vi.fn();
|
||||
const fileHandle = {
|
||||
createWritable: vi.fn(async () => ({
|
||||
write: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
getFile,
|
||||
};
|
||||
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const pending =
|
||||
kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", new ArrayBuffer(2));
|
||||
await expect(pending).rejects.toBe(failure);
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter.rename", () => {
|
||||
it("moves the file through a temporary copy while preserving content", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root");
|
||||
@@ -142,6 +219,20 @@ describe("FSAPIVaultAdapter.rename", () => {
|
||||
});
|
||||
|
||||
describe("FSAPIFileSystemAdapter path case", () => {
|
||||
it("returns an exact path without scanning the directory", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the stored path case from a case-insensitive file system", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root", true);
|
||||
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
|
||||
@@ -155,6 +246,48 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
await adapter.scanDirectory();
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||
});
|
||||
|
||||
it("returns null after one scan when no cached path matches", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("other.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports scan failures through the injected Webapp log", async () => {
|
||||
const root = {
|
||||
name: "root",
|
||||
@@ -173,4 +306,25 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
||||
"fsapi-scan"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null and reports scan failure during case-insensitive lookup", async () => {
|
||||
const root = {
|
||||
name: "root",
|
||||
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
||||
throw new Error("scan failed");
|
||||
},
|
||||
} as unknown as FileSystemDirectoryHandle;
|
||||
const addLog = vi.fn();
|
||||
const adapter = new FSAPIFileSystemAdapter(root, addLog);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(addLog).toHaveBeenCalledWith(
|
||||
"Error scanning directory '.': Error: scan failed",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"fsapi-scan"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
|
||||
}
|
||||
|
||||
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Navigate to parent directory, creating as needed
|
||||
let currentHandle = this.rootHandle;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
|
||||
}
|
||||
|
||||
// Create the file
|
||||
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
|
||||
// Get file metadata
|
||||
const fileObject = await fileHandle.getFile();
|
||||
|
||||
return {
|
||||
path: p as FilePath,
|
||||
stat: {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
},
|
||||
handle: fileHandle,
|
||||
};
|
||||
return await this.createFile(p, data);
|
||||
}
|
||||
|
||||
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
return await this.createFile(p, data);
|
||||
}
|
||||
|
||||
private async createFile(p: string, data: string | ArrayBuffer): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
@@ -14,6 +13,7 @@ import type { FSAPIFile, FSAPIFolder } from "@/apps/webapp/adapters/FSAPITypes";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||
import { FSAPITypeGuardAdapter } from "@/apps/webapp/adapters/FSAPITypeGuardAdapter";
|
||||
|
||||
type FileSystemObserverRecord = {
|
||||
changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle;
|
||||
@@ -34,32 +34,6 @@ type GlobalWithFileSystemObserver = typeof compatGlobal & {
|
||||
FileSystemObserver?: FileSystemObserverConstructor;
|
||||
};
|
||||
|
||||
/**
|
||||
* FileSystem API-specific type guard adapter
|
||||
*/
|
||||
class FSAPITypeGuardAdapter implements IStorageEventTypeGuardAdapter<FSAPIFile, FSAPIFolder> {
|
||||
isFile(file: unknown): file is FSAPIFile {
|
||||
return !!(
|
||||
file &&
|
||||
typeof file === "object" &&
|
||||
"path" in file &&
|
||||
"stat" in file &&
|
||||
"handle" in file &&
|
||||
!(file as { isFolder?: boolean }).isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: unknown): item is FSAPIFolder {
|
||||
return !!(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"path" in item &&
|
||||
(item as { isFolder?: boolean }).isFolder === true &&
|
||||
"handle" in item
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystem API-specific persistence adapter (IndexedDB-based snapshot)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FSAPIStorageEventManagerAdapter } from "./FSAPIStorageEventManagerAdapter";
|
||||
|
||||
describe("FSAPIStorageEventManagerAdapter type guard", () => {
|
||||
const typeGuard = new FSAPIStorageEventManagerAdapter({} as FileSystemDirectoryHandle, vi.fn()).typeGuard;
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { stat: {}, handle: undefined }, expected: false },
|
||||
{ label: "an object without a stat", value: { path: "note.md", handle: undefined }, expected: false },
|
||||
{ label: "an object without a handle", value: { path: "note.md", stat: {} }, expected: false },
|
||||
{
|
||||
label: "a file-shaped object with a handle property",
|
||||
value: { path: "note.md", stat: {}, handle: undefined },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
label: "a folder-shaped object",
|
||||
value: { path: "folder", handle: undefined, isFolder: true },
|
||||
expected: false,
|
||||
},
|
||||
])("classifies $label as a file", ({ value, expected }) => {
|
||||
expect(typeGuard.isFile(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { isFolder: true, handle: undefined }, expected: false },
|
||||
{ label: "an object without the folder marker", value: { path: "folder", handle: undefined }, expected: false },
|
||||
{ label: "an object without a handle", value: { path: "folder", isFolder: true }, expected: false },
|
||||
{
|
||||
label: "a folder-shaped object with a handle property",
|
||||
value: { path: "folder", isFolder: true, handle: undefined },
|
||||
expected: true,
|
||||
},
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {}, handle: undefined }, expected: false },
|
||||
])("classifies $label as a folder", ({ value, expected }) => {
|
||||
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
|
||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
@@ -9,8 +8,7 @@ import { ServiceFileAccessFSAPI } from "./ServiceFileAccessImpl";
|
||||
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||
|
||||
export interface FSAPIServiceModules extends ServiceModules {
|
||||
vaultAccess: FileAccessFSAPI;
|
||||
@@ -73,40 +71,7 @@ export function initialiseServiceModulesFSAPI(
|
||||
vault: services.vault,
|
||||
});
|
||||
|
||||
// File handler (platform-independent)
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||
|
||||
return {
|
||||
rebuilder,
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fileURLToPath, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { readVersion } from "../readVersion";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
|
||||
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
||||
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
||||
// https://vite.dev/config/
|
||||
|
||||
@@ -145,7 +145,7 @@ export class WebPeerRuntime {
|
||||
|
||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||
this.menu?.hide();
|
||||
this.menu = new Menu()
|
||||
this.menu = new Menu(this.services.browserUi)
|
||||
.addItem((item) =>
|
||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||
|
||||
Reference in new issue
Block a user