Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/ai/startupModelSync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { scheduleStartupModelSync } from "./startupModelSync";

/** A fake plugin: layout-ready is fired by the test; unload runs registered cleanups. */
function fakeHost() {
const cleanups: Array<() => void> = [];
const layoutReady: Array<() => void> = [];
return {
app: {
workspace: {
onLayoutReady: (cb: () => void) => void layoutReady.push(cb),
},
} as any,
register: (cb: () => void) => void cleanups.push(cb),
fireLayoutReady: () => layoutReady.splice(0).forEach((cb) => cb()),
unload: () => cleanups.splice(0).forEach((cb) => cb()),
};
}

describe("scheduleStartupModelSync", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it("syncs once, 5 s after layout-ready, while the plugin stays loaded", () => {
const host = fakeHost();
const sync = vi.fn();
scheduleStartupModelSync(host, sync);

vi.advanceTimersByTime(10_000);
expect(sync).not.toHaveBeenCalled(); // layout not ready yet

host.fireLayoutReady();
vi.advanceTimersByTime(4_999);
expect(sync).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(sync).toHaveBeenCalledTimes(1);
});

it("does not sync when the plugin unloads during the delay", () => {
const host = fakeHost();
const sync = vi.fn();
scheduleStartupModelSync(host, sync);

host.fireLayoutReady();
vi.advanceTimersByTime(1_000);
host.unload();
vi.advanceTimersByTime(60_000);

expect(sync).not.toHaveBeenCalled();
});

it("does not schedule a sync when the plugin unloads before layout-ready", () => {
const host = fakeHost();
const sync = vi.fn();
scheduleStartupModelSync(host, sync);

host.unload();
host.fireLayoutReady();
vi.advanceTimersByTime(60_000);

expect(sync).not.toHaveBeenCalled();
});

it("leaves only the live instance's sync after a reload", () => {
const oldInstance = fakeHost();
const newInstance = fakeHost();
const oldSync = vi.fn();
const newSync = vi.fn();

scheduleStartupModelSync(oldInstance, oldSync);
oldInstance.fireLayoutReady();
vi.advanceTimersByTime(1_000);
oldInstance.unload();
scheduleStartupModelSync(newInstance, newSync);
newInstance.fireLayoutReady();
vi.advanceTimersByTime(60_000);

expect(oldSync).not.toHaveBeenCalled();
expect(newSync).toHaveBeenCalledTimes(1);
});
});
35 changes: 35 additions & 0 deletions src/ai/startupModelSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { App } from "obsidian";

/** The slice of an Obsidian `Plugin` the scheduler needs. */
export interface StartupSyncHost {
app: Pick<App, "workspace">;
/** Obsidian `Component.register`: runs the callback when the plugin unloads. */
register(cleanup: () => void): void;
}

export const STARTUP_MODEL_SYNC_DELAY_MS = 5_000;

/**
* Run the background model sync shortly after layout-ready, so it never
* competes with startup work. The pending run belongs to this plugin instance:
* unloading it (disable, reload, update) cancels the run, including before
* layout-ready. Otherwise an unloaded instance would still sync against the
* network seconds after the user turned QuickAdd off.
*/
export function scheduleStartupModelSync(
host: StartupSyncHost,
sync: () => void,
delayMs = STARTUP_MODEL_SYNC_DELAY_MS,
): void {
let unloaded = false;
let timer: number | undefined;
host.register(() => {
unloaded = true;
window.clearTimeout(timer);
});

host.app.workspace.onLayoutReady(() => {
if (unloaded) return;
timer = window.setTimeout(sync, delayMs);
});
}
10 changes: 5 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { ingestImagesIntoActivePrompt as ingestPromptImages } from "./gui/imageP
import { setQuickAddInstance } from "./quickAddInstance";
import { registerQuickAddUri } from "./uri/registerQuickAddUri";
import { registerCoreCommands } from "./plugin/registerCoreCommands";
import { scheduleStartupModelSync } from "./ai/startupModelSync";

// The settingsStore subscriber fires on every store change — including high-frequency
// ones like folder collapse toggles. Coalesce those full-settings disk writes into one
Expand Down Expand Up @@ -214,11 +215,10 @@ export default class QuickAdd extends Plugin {

// Keep AI provider model lists current without plugin releases: a quiet,
// daily-throttled background sync for providers that opted in. Deferred
// past layout-ready so it never competes with startup work.
this.app.workspace.onLayoutReady(() => {
window.setTimeout(() => {
void autoSyncEnabledProviders(this.app);
}, 5_000);
// past layout-ready so it never competes with startup work, and cancelled
// if this instance unloads first.
scheduleStartupModelSync(this, () => {
void autoSyncEnabledProviders(this.app);
});

this.announceUpdate();
Expand Down
Loading