mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 04:13:59 +02:00
HUGE sidebar and typing performance improvements (#516)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import { AnyModel, ModelPayload } from "../bindings/gen_models";
|
||||
import { modelStoreDataAtom } from "./atoms";
|
||||
import { ExtractModel, JotaiStore, ModelStoreData } from "./types";
|
||||
@@ -12,6 +13,9 @@ const pendingModelWrites = new Set<Promise<unknown>>();
|
||||
export function initModelStore(store: JotaiStore) {
|
||||
_store = store;
|
||||
|
||||
// Don't lose debounced patches if the window closes while one is pending
|
||||
window.addEventListener("beforeunload", flushAllPendingPatches);
|
||||
|
||||
getCurrentWebviewWindow()
|
||||
.listen<ModelPayload>("model_write", ({ payload }) => {
|
||||
if (shouldIgnoreModel(payload)) return;
|
||||
@@ -53,6 +57,7 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
export async function flushAllModelWrites(): Promise<void> {
|
||||
flushAllPendingPatches();
|
||||
const results = await Promise.allSettled(pendingModelWrites);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
if (rejected?.status === "rejected") {
|
||||
@@ -60,6 +65,85 @@ export async function flushAllModelWrites(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const PATCH_DEBOUNCE_MS = 400;
|
||||
|
||||
interface PendingPatch {
|
||||
model: AnyModel["model"];
|
||||
id: string;
|
||||
patch: Record<string, unknown>;
|
||||
write: ReturnType<typeof debounce>;
|
||||
}
|
||||
|
||||
const pendingPatches = new Map<string, PendingPatch>();
|
||||
|
||||
/**
|
||||
* Like patchModel, but coalesces rapid patches to the same model (eg. one per
|
||||
* keystroke) into a single write. Later fields overwrite earlier ones, so it's
|
||||
* only safe for whole-value fields like url, body, or headers. Pending patches
|
||||
* flush after a short delay, and flushAllModelWrites() (called before sends and
|
||||
* duplicates) flushes them immediately.
|
||||
*/
|
||||
export function patchModelDebounced<
|
||||
M extends AnyModel["model"],
|
||||
T extends ExtractModel<AnyModel, M>,
|
||||
>(base: Pick<T, "id" | "model">, patch: Partial<T>): void {
|
||||
const key = `${base.model}.${base.id}`;
|
||||
let pending = pendingPatches.get(key);
|
||||
if (pending == null) {
|
||||
pending = {
|
||||
model: base.model,
|
||||
id: base.id,
|
||||
patch: {},
|
||||
write: debounce(() => writePendingPatch(key), PATCH_DEBOUNCE_MS),
|
||||
};
|
||||
pendingPatches.set(key, pending);
|
||||
}
|
||||
pending.patch = { ...pending.patch, ...patch };
|
||||
pending.write();
|
||||
}
|
||||
|
||||
function writePendingPatch(key: string) {
|
||||
const pending = pendingPatches.get(key);
|
||||
if (pending == null) return;
|
||||
pendingPatches.delete(key);
|
||||
try {
|
||||
void patchModelById(pending.model, pending.id, pending.patch);
|
||||
} catch (err) {
|
||||
// Model may have been deleted while the patch was pending
|
||||
console.warn("Failed to flush pending patch", key, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function flushAllPendingPatches() {
|
||||
for (const pending of Array.from(pendingPatches.values())) {
|
||||
pending.write.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a model's pending patch, if it has one that hasn't been written yet.
|
||||
*
|
||||
* The store only moves forward when the backend echoes a write back, so between a keystroke and
|
||||
* its debounced write the stored copy is behind what the user typed. Reading through the pending
|
||||
* patch keeps that window invisible to the imperative readers below, which are the ones that go
|
||||
* on to write the model back.
|
||||
*/
|
||||
function withPendingPatch<T>(model: T | null): T | null {
|
||||
if (model == null || pendingPatches.size === 0) return model;
|
||||
const { model: modelType, id } = model as { model?: string; id?: string };
|
||||
const pending = pendingPatches.get(`${modelType}.${id}`);
|
||||
return pending == null ? model : ({ ...model, ...pending.patch } as T);
|
||||
}
|
||||
|
||||
/** Drop a model's pending patch and cancel its scheduled write */
|
||||
function consumePendingPatch(model: AnyModel["model"], id: string) {
|
||||
const key = `${model}.${id}`;
|
||||
const pending = pendingPatches.get(key);
|
||||
if (pending == null) return;
|
||||
pending.write.cancel();
|
||||
pendingPatches.delete(key);
|
||||
}
|
||||
|
||||
let _activeWorkspaceId: string | null = null;
|
||||
|
||||
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
||||
@@ -96,7 +180,7 @@ export function getModel<M extends AnyModel["model"], T extends ExtractModel<Any
|
||||
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
|
||||
for (const t of types) {
|
||||
let v = data[t][id];
|
||||
if (v?.model === t) return v as T;
|
||||
if (v?.model === t) return withPendingPatch(v as T);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -106,7 +190,7 @@ export function getAnyModel(id: string): AnyModel | null {
|
||||
for (const t of Object.keys(data)) {
|
||||
// oxlint-disable-next-line no-explicit-any -- dynamic key access
|
||||
let v = (data as any)[t]?.[id];
|
||||
if (v?.model === t) return v;
|
||||
if (v?.model === t) return withPendingPatch(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -116,11 +200,17 @@ export function patchModelById<M extends AnyModel["model"], T extends ExtractMod
|
||||
id: string,
|
||||
patch: Partial<T> | ((prev: T) => T),
|
||||
): Promise<string> {
|
||||
// Reads through any pending debounced patch, so the merge below can't put a stale value back
|
||||
// over something the user has already typed
|
||||
let prev = getModel<M, T>(model, id);
|
||||
if (prev == null) {
|
||||
throw new Error(`Failed to get model to patch id=${id} model=${model}`);
|
||||
}
|
||||
|
||||
// `prev` already carries the pending patch, so this write supersedes it. Leaving it queued
|
||||
// would let it land afterwards and undo whatever this write decided.
|
||||
consumePendingPatch(model, id);
|
||||
|
||||
const newModel = typeof patch === "function" ? patch(prev) : { ...prev, ...patch };
|
||||
return updateModel(newModel);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user