[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
269
application/state/systemManager/useAsyncRecordCache.ts
Normal file
269
application/state/systemManager/useAsyncRecordCache.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface AsyncRecordState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
type RecordMap<T> = Record<string, AsyncRecordState<T>>;
|
||||
|
||||
interface UseAsyncRecordCacheOptions<TItem, TValue> {
|
||||
items: TItem[];
|
||||
enabled: boolean;
|
||||
getKey: (item: TItem) => string;
|
||||
fetchRecord: (item: TItem) => Promise<TValue | null>;
|
||||
prefetchLimit?: number;
|
||||
prefetchDelayMs?: number;
|
||||
staleTimeMs?: number;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function scheduleIdleTask(callback: () => void): () => void {
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const id = window.requestIdleCallback(callback, { timeout: 1200 });
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
|
||||
const id = window.setTimeout(callback, 80);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
|
||||
function normalizeRecordError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error || 'Unknown error');
|
||||
}
|
||||
|
||||
function isRecordFresh<TValue>(record: AsyncRecordState<TValue> | undefined, staleTimeMs: number): boolean {
|
||||
if (!record || record.error || record.updatedAt === null) return false;
|
||||
if (!Number.isFinite(staleTimeMs)) return true;
|
||||
return Date.now() - record.updatedAt < staleTimeMs;
|
||||
}
|
||||
|
||||
const EMPTY_RECORDS = {};
|
||||
|
||||
export function useAsyncRecordCache<TItem, TValue>({
|
||||
items,
|
||||
enabled,
|
||||
getKey,
|
||||
fetchRecord,
|
||||
prefetchLimit = 64,
|
||||
prefetchDelayMs = 16,
|
||||
staleTimeMs = 30_000,
|
||||
}: UseAsyncRecordCacheOptions<TItem, TValue>) {
|
||||
const [records, setRecords] = useState<RecordMap<TValue>>(() => EMPTY_RECORDS);
|
||||
const recordsRef = useRef<RecordMap<TValue>>(records);
|
||||
const enabledRef = useRef(enabled);
|
||||
const inflightRef = useRef(new Set<string>());
|
||||
const requestVersionRef = useRef(new Map<string, number>());
|
||||
const queuedForceRef = useRef(new Set<string>());
|
||||
const loadRecordRef = useRef<(
|
||||
item: TItem,
|
||||
options?: { force?: boolean; urgent?: boolean },
|
||||
) => Promise<void>>(async () => {});
|
||||
|
||||
recordsRef.current = records;
|
||||
enabledRef.current = enabled;
|
||||
|
||||
const commitRecords = useCallback((
|
||||
updater: (prev: RecordMap<TValue>) => RecordMap<TValue>,
|
||||
urgent = false,
|
||||
) => {
|
||||
const apply = () => {
|
||||
setRecords((prev) => {
|
||||
const next = updater(prev);
|
||||
recordsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (urgent) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(apply);
|
||||
}, []);
|
||||
|
||||
const loadRecord = useCallback(async (
|
||||
item: TItem,
|
||||
options?: { force?: boolean; urgent?: boolean },
|
||||
) => {
|
||||
if (!enabledRef.current) return;
|
||||
const key = getKey(item);
|
||||
if (!key) return;
|
||||
if (inflightRef.current.has(key)) {
|
||||
if (options?.force) {
|
||||
queuedForceRef.current.add(key);
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = recordsRef.current[key];
|
||||
if (!options?.force && isRecordFresh(existing, staleTimeMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestVersion = (requestVersionRef.current.get(key) ?? 0) + 1;
|
||||
requestVersionRef.current.set(key, requestVersion);
|
||||
inflightRef.current.add(key);
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data: prev[key]?.data ?? null,
|
||||
loading: true,
|
||||
error: null,
|
||||
updatedAt: prev[key]?.updatedAt ?? null,
|
||||
},
|
||||
}), options?.urgent);
|
||||
|
||||
try {
|
||||
const data = await fetchRecord(item);
|
||||
if (requestVersionRef.current.get(key) !== requestVersion) return;
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
if (requestVersionRef.current.get(key) !== requestVersion) return;
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data: prev[key]?.data ?? null,
|
||||
loading: false,
|
||||
error: normalizeRecordError(error),
|
||||
updatedAt: prev[key]?.updatedAt ?? null,
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
inflightRef.current.delete(key);
|
||||
if (queuedForceRef.current.has(key)) {
|
||||
if (enabledRef.current) {
|
||||
queuedForceRef.current.delete(key);
|
||||
void loadRecordRef.current(item, { force: true, urgent: options?.urgent });
|
||||
} else {
|
||||
commitRecords((prev) => {
|
||||
const current = prev[key];
|
||||
if (!current?.loading) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...current,
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [commitRecords, fetchRecord, getKey, staleTimeMs]);
|
||||
|
||||
loadRecordRef.current = loadRecord;
|
||||
|
||||
useEffect(() => {
|
||||
const itemKeys = new Set(items.map(getKey).filter(Boolean));
|
||||
for (const key of queuedForceRef.current) {
|
||||
if (!itemKeys.has(key)) {
|
||||
queuedForceRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
commitRecords((prev) => {
|
||||
let changed = false;
|
||||
const next: RecordMap<TValue> = {};
|
||||
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
|
||||
if (!itemKeys.has(key)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = value;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [commitRecords, getKey, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || queuedForceRef.current.size === 0) return;
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
if (!key || !queuedForceRef.current.has(key)) continue;
|
||||
queuedForceRef.current.delete(key);
|
||||
void loadRecord(item, { force: true, urgent: true });
|
||||
}
|
||||
}, [enabled, getKey, items, loadRecord]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || items.length === 0 || prefetchLimit <= 0) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
const candidates = items.slice(0, prefetchLimit);
|
||||
const cancelIdleTask = scheduleIdleTask(() => {
|
||||
void (async () => {
|
||||
for (const item of candidates) {
|
||||
if (cancelled) return;
|
||||
await loadRecord(item);
|
||||
if (prefetchDelayMs > 0) {
|
||||
await delay(prefetchDelayMs);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleTask();
|
||||
};
|
||||
}, [enabled, items, loadRecord, prefetchDelayMs, prefetchLimit]);
|
||||
|
||||
const invalidateRecord = useCallback((key: string) => {
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
queuedForceRef.current.delete(key);
|
||||
commitRecords((prev) => {
|
||||
if (!(key in prev)) return prev;
|
||||
const { [key]: _removed, ...next } = prev;
|
||||
return next;
|
||||
}, true);
|
||||
}, [commitRecords]);
|
||||
|
||||
const invalidateMatching = useCallback((matches: (key: string) => boolean) => {
|
||||
for (const key of requestVersionRef.current.keys()) {
|
||||
if (matches(key)) {
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
queuedForceRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
commitRecords((prev) => {
|
||||
let changed = false;
|
||||
const next: RecordMap<TValue> = {};
|
||||
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
|
||||
if (matches(key)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = value;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
}, true);
|
||||
}, [commitRecords]);
|
||||
|
||||
const refreshRecord = useCallback(
|
||||
(item: TItem) => loadRecord(item, { force: true, urgent: true }),
|
||||
[loadRecord],
|
||||
);
|
||||
|
||||
return {
|
||||
records,
|
||||
loadRecord,
|
||||
refreshRecord,
|
||||
invalidateRecord,
|
||||
invalidateMatching,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user