HUGE sidebar and typing performance improvements (#516)

This commit is contained in:
Gregory Schier
2026-08-13 12:23:56 -07:00
committed by GitHub
parent b9d4f76193
commit 74d1b5d6ce
16 changed files with 620 additions and 130 deletions
+92 -2
View File
@@ -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);
}