Files
openchamber/packages/ui/src/stores/useGitIdentitiesStore.ts
T
Tom RochetteandBohdan Triapitsyn 1505274f94 perf(stores): defer safeStorage writes off the interaction path (#1941)
* perf(stores): defer safeStorage writes off the interaction path

Session switches funnel every persisted store slice through safeStorage.setItem,
and doing those large JSON.stringify writes synchronously blocked the main
thread for over a second. Add a write-behind buffer that:

- Defers each setItem/removeItem to a later task via setTimeout(0) so the
  click-to-paint path is not blocked.
- Coalesces repeated writes to the same key into a single backing flush.
- Serves pending values from memory so read-after-write stays consistent
  within the deferral window.
- Flushes synchronously on pagehide/beforeunload/visibilitychange/freeze so
  deferred state survives tab close, reload, and the mobile freeze lifecycle.

Adds a test covering write deferral, coalescing, and pending read serving.

* fix(stores): defer persisted JSON serialization

* fix(stores): defer direct safeStorage writes

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-06-30 11:47:52 +03:00

289 lines
9.0 KiB
TypeScript

import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import {
getGitIdentities,
createGitIdentity,
updateGitIdentity,
deleteGitIdentity,
discoverGitCredentials,
getGlobalGitIdentity
} from "@/lib/gitApi";
import { updateDesktopSettings } from "@/lib/persistence";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { runtimeFetch } from "@/lib/runtime-fetch";
export type GitIdentityAuthType = 'ssh' | 'token';
export interface GitIdentityProfile {
id: string;
name: string;
userName: string;
userEmail: string;
authType?: GitIdentityAuthType;
sshKey?: string | null;
signCommits?: boolean;
signingKey?: string | null;
host?: string | null;
color?: string | null;
icon?: string | null;
}
export interface DiscoveredGitCredential {
host: string;
username: string;
}
interface GitIdentitiesStore {
selectedProfileId: string | null;
defaultGitIdentityId: string | null; // null = unset, 'global' = system, profile id = custom
profiles: GitIdentityProfile[];
globalIdentity: GitIdentityProfile | null;
discoveredCredentials: DiscoveredGitCredential[];
isLoading: boolean;
setSelectedProfile: (id: string | null) => void;
loadProfiles: () => Promise<boolean>;
loadGlobalIdentity: () => Promise<boolean>;
loadDiscoveredCredentials: () => Promise<boolean>;
loadDefaultGitIdentityId: () => Promise<boolean>;
setDefaultGitIdentityId: (id: string | null) => Promise<boolean>;
createProfile: (profile: Omit<GitIdentityProfile, 'id'> & { id?: string }) => Promise<boolean>;
updateProfile: (id: string, updates: Partial<GitIdentityProfile>) => Promise<boolean>;
deleteProfile: (id: string) => Promise<boolean>;
getProfileById: (id: string) => GitIdentityProfile | undefined;
getUnimportedCredentials: () => DiscoveredGitCredential[];
}
declare global {
interface Window {
__zustand_git_identities_store__?: UseBoundStore<StoreApi<GitIdentitiesStore>>;
}
}
export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
devtools(
persist(
(set, get) => ({
selectedProfileId: null,
defaultGitIdentityId: null,
profiles: [],
globalIdentity: null,
discoveredCredentials: [],
isLoading: false,
setSelectedProfile: (id: string | null) => {
set({ selectedProfileId: id });
},
loadProfiles: async () => {
set({ isLoading: true });
const previousProfiles = get().profiles;
try {
const profiles = await getGitIdentities();
set({ profiles, isLoading: false });
return true;
} catch (error) {
console.error("Failed to load git identity profiles:", error);
set({ profiles: previousProfiles, isLoading: false });
return false;
}
},
loadGlobalIdentity: async () => {
try {
const data = await getGlobalGitIdentity();
if (data && data.userName && data.userEmail) {
const globalProfile: GitIdentityProfile = {
id: 'global',
name: 'Global Identity',
userName: data.userName,
userEmail: data.userEmail,
authType: data.sshCommand ? 'ssh' : undefined,
sshKey: data.sshCommand ? data.sshCommand.replace('ssh -i ', '') : null,
color: 'info',
icon: 'house'
};
set({ globalIdentity: globalProfile });
} else {
set({ globalIdentity: null });
}
return true;
} catch (error) {
console.error("Failed to load global git identity:", error);
set({ globalIdentity: null });
return false;
}
},
loadDiscoveredCredentials: async () => {
try {
const credentials = await discoverGitCredentials();
set({ discoveredCredentials: credentials });
return true;
} catch (error) {
console.error("Failed to discover git credentials:", error);
set({ discoveredCredentials: [] });
return false;
}
},
loadDefaultGitIdentityId: async () => {
const normalize = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
try {
let defaultId: string | null = null;
if (defaultId === null) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
const settings = (result?.settings || {}) as Record<string, unknown>;
defaultId = normalize(settings.defaultGitIdentityId);
} catch {
// fall through
}
}
}
if (defaultId === null) {
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null;
defaultId = normalize(data?.defaultGitIdentityId);
}
} catch {
// ignore
}
}
set({ defaultGitIdentityId: defaultId });
return true;
} catch (error) {
console.error('Failed to load default git identity setting:', error);
return false;
}
},
setDefaultGitIdentityId: async (id) => {
try {
const trimmed = typeof id === 'string' ? id.trim() : '';
const value = trimmed.length > 0 ? trimmed : '';
await updateDesktopSettings({ defaultGitIdentityId: value });
set({ defaultGitIdentityId: value.length > 0 ? value : null });
return true;
} catch (error) {
console.error('Failed to save default git identity setting:', error);
return false;
}
},
createProfile: async (profileData) => {
try {
const profile = {
...profileData,
id: profileData.id || `profile-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
color: profileData.color || 'keyword',
icon: profileData.icon || 'branch'
};
await createGitIdentity(profile);
await get().loadProfiles();
return true;
} catch (error) {
console.error("Failed to create git identity profile:", error);
return false;
}
},
updateProfile: async (id, updates) => {
try {
const existing = get().profiles.find(p => p.id === id);
if (!existing) {
throw new Error("Profile not found");
}
const updated = { ...existing, ...updates };
await updateGitIdentity(id, updated);
await get().loadProfiles();
return true;
} catch (error) {
console.error("Failed to update git identity profile:", error);
return false;
}
},
deleteProfile: async (id) => {
try {
await deleteGitIdentity(id);
if (get().selectedProfileId === id) {
set({ selectedProfileId: null });
}
await get().loadProfiles();
return true;
} catch (error) {
console.error("Failed to delete git identity profile:", error);
return false;
}
},
getProfileById: (id) => {
const { profiles, globalIdentity } = get();
if (id === 'global') {
return globalIdentity || undefined;
}
return profiles.find((p) => p.id === id);
},
getUnimportedCredentials: () => {
const { profiles, discoveredCredentials } = get();
// Filter out credentials that have already been imported as token-based profiles
return discoveredCredentials.filter(cred => {
return !profiles.some(p =>
p.authType === 'token' && p.host === cred.host
);
});
},
}),
{
name: "git-identities-store",
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedProfileId: state.selectedProfileId,
}),
},
),
{
name: "git-identities-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_git_identities_store__ = useGitIdentitiesStore;
}