perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
This commit is contained in:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -50,17 +50,42 @@ Examples:
|
||||
|
||||
These stores coordinate persistent project/session metadata across multiple views.
|
||||
|
||||
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
|
||||
|
||||
Global refresh rules:
|
||||
|
||||
- Per-directory refresh is bounded to two requests across callers and prioritizes the current directory.
|
||||
- Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally.
|
||||
- Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions.
|
||||
- Runtime switch increments the load generation and clears the previous runtime's snapshot so stale in-flight work cannot commit.
|
||||
- Live session mutations update the cache directly after successful SDK actions; they preserve stable directory metadata when lighter event payloads omit it.
|
||||
- Full and per-directory loads capture a mutation revision. At commit time they overlay only per-session create/update/archive/delete/move mutations newer than that baseline, including no-op deletion tombstones, so an older response cannot undo newer local authority.
|
||||
|
||||
Permission auto-accept policy is authoritative in the active Web server or VS Code extension host. Owner snapshots carry a monotonic revision; the UI rejects lower revisions and any hydration or mutation completion captured before a runtime reset. Persisted UI policy is not live authority. The version-2 store retains an old unscoped policy only as a one-runtime legacy migration candidate, then removes it after successful migration.
|
||||
|
||||
Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover.
|
||||
|
||||
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors.
|
||||
|
||||
Session folders persist in runtime-specific v2 browser keys without silently evicting older runtime namespaces. Runtime switch, page hide, app freeze, and unload synchronously flush the pending browser snapshot before lifecycle suspension or namespace replacement. A runtime switch then cancels stale old-runtime disk work and starts generation-owned disk hydration. Missing or malformed server files are not authoritative empty snapshots; disk data may replace browser state only when it carries a real revision and no newer local folder mutation occurred. Server writes are serialized and reject non-newer revisions so delayed or duplicate requests cannot overwrite the current state. File-search cache and in-flight keys include runtime plus directory and are cleared on endpoint reset.
|
||||
|
||||
Persisted session todos use a bounded composite key of runtime, normalized directory, and session ID. Ambiguous legacy todo entries are discarded rather than claimed by whichever runtime starts first. Authoritative deletion uses an explicit runtime identity, and session-folder deletion scans every scope in the active runtime so archived assignments cannot survive after their session is gone.
|
||||
|
||||
Chat composer drafts, confirmed mentions, inline-comment drafts, and pinned sessions use the same runtime/directory/session ownership rule. Chat drafts use a bounded shared envelope and notify mounted composers when authoritative deletion clears their identity, preventing unmount autosave from resurrecting deleted text. Inline drafts enforce per-session, global-session, and serialized-byte bounds. Pins retain every valid composite key across runtimes without silent age/count eviction and are never pruned from the first startup list. Confirmed local deletion and routed deletion events clear immediately; after an authoritative baseline exists, a later complete omission also cleans persisted state. Ambiguous session-only legacy drafts and pins are not claimed.
|
||||
|
||||
Composer draft edits remain immediate in memory and use a trailing durable-write debounce. Pending text and confirmed mentions flush synchronously when the document becomes hidden, freezes, receives `pagehide`, switches identity, or unmounts; authoritative deletion cancels pending work before any lifecycle flush can run. The shared chat-draft envelope reuses its parsed snapshot until the storage value changes. Inline-comment draft byte accounting indexes serialized buckets and recalculates only the changed session bucket during normal edits; deferred storage still performs the final full-envelope serialization and lifecycle flush.
|
||||
|
||||
## Git / PR Stores
|
||||
|
||||
The Git and PR stores are the most important stores to understand before editing this directory.
|
||||
|
||||
### `useGitStore.ts`
|
||||
|
||||
`useGitStore` is a centralized per-directory Git cache.
|
||||
`useGitStore` is a centralized active-runtime, per-directory Git cache.
|
||||
|
||||
Core model:
|
||||
|
||||
- top-level keyed by `directory`
|
||||
- active runtime owns one `directories` map keyed by directory
|
||||
- each directory entry contains:
|
||||
- repo detection
|
||||
- status
|
||||
@@ -77,11 +102,15 @@ Important properties:
|
||||
- loading state is per-directory, not global
|
||||
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
|
||||
- in-flight dedupe exists for status and `ensureAll()`
|
||||
- diff data is separately cached and capped with size + count limits
|
||||
- runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions
|
||||
- status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations
|
||||
- status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes
|
||||
- branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once
|
||||
- diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected
|
||||
|
||||
### `useGitHubPrStatusStore.ts`
|
||||
|
||||
`useGitHubPrStatusStore` is a centralized PR cache keyed by `directory::branch`.
|
||||
`useGitHubPrStatusStore` is a centralized PR cache keyed by a collision-safe tuple of runtime, directory, branch, and requested remote.
|
||||
|
||||
Core model:
|
||||
|
||||
@@ -98,9 +127,11 @@ Important properties:
|
||||
|
||||
- `ensureEntry()` initializes a key lazily
|
||||
- `setParams()` attaches runtime context
|
||||
- parameter changes advance an entry revision; stale queued, successful, and failed requests cannot update a newer authority
|
||||
- `startWatching()` / `stopWatching()` are for true live PR consumers only
|
||||
- `refreshTargets()` supports one-shot multi-target bootstrap without turning on live watching
|
||||
- persisted cache is for page refresh continuity, not for broad background syncing
|
||||
- runtime reset disposes timers, watchers, API references, and request ownership while inert namespaced snapshots remain isolated
|
||||
- persisted cache is versioned, TTL-filtered, and bounded for page refresh continuity, not broad background syncing
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
@@ -114,6 +145,8 @@ These rules are important. Breaking them tends to reintroduce idle CPU churn, st
|
||||
6. Header should not depend on PR store.
|
||||
7. Closed sidebar should not create live PR work.
|
||||
8. File tree Git status should update only when the file tree is visible.
|
||||
9. Global session refresh must remain bounded and failure-isolated per directory.
|
||||
10. Global session cache must not drive live activity indicators or message-loading state.
|
||||
|
||||
## Selector Rules
|
||||
|
||||
|
||||
@@ -95,4 +95,29 @@ describe('listGlobalSessionPages', () => {
|
||||
expect(calls[1]).toEqual({ directory: '/repo', archived: false, roots: false, limit: 2, cursor: 10 })
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_root', 'ses_child_1', 'ses_child_2'])
|
||||
})
|
||||
|
||||
test('retries SDK error responses before treating the load as failed', async () => {
|
||||
let calls = 0
|
||||
const apiClient = {
|
||||
experimental: {
|
||||
session: {
|
||||
list: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return { error: { message: 'warming up' }, response: { status: 503 } }
|
||||
}
|
||||
return {
|
||||
data: [{ id: 'ses_1', time: { updated: 1 } }],
|
||||
response: { headers: new Headers() },
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const sessions = await listGlobalSessionPages(apiClient, { archived: false, pageSize: 500 })
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
|
||||
import { retry } from "@/sync/retry";
|
||||
import { stripSessionListDetails } from "@/sync/sanitize";
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch";
|
||||
import { startSessionLoadPerformanceEvent } from "@/sync/session-load-performance";
|
||||
|
||||
export type GlobalSessionRecord = Session & {
|
||||
project?: {
|
||||
@@ -86,21 +88,49 @@ export async function listGlobalSessionPages(
|
||||
const all: GlobalSessionRecord[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
let cursor: number | undefined;
|
||||
let operation: string;
|
||||
if (!options.directory) {
|
||||
operation = `global-sessions.${options.archived ? "archived" : "active"}`;
|
||||
} else if (options.roots === true) {
|
||||
operation = "bootstrap.sessions.roots";
|
||||
} else if (options.archived) {
|
||||
operation = "bootstrap.sessions.archived";
|
||||
} else {
|
||||
operation = "bootstrap.sessions.all";
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const response = await retry(
|
||||
() => apiClient.experimental.session.list({
|
||||
...(options.directory ? { directory: options.directory } : {}),
|
||||
archived: options.archived,
|
||||
...(options.roots !== undefined ? { roots: options.roots } : {}),
|
||||
limit: options.pageSize,
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
}),
|
||||
let attempts = 0;
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
operation,
|
||||
runtimeKey: getRuntimeKey(),
|
||||
directory: options.directory,
|
||||
caller: cursor === undefined ? "initial-page" : "pagination",
|
||||
});
|
||||
const { response, payload } = await retry(
|
||||
async () => {
|
||||
attempts += 1;
|
||||
const response = await apiClient.experimental.session.list({
|
||||
...(options.directory ? { directory: options.directory } : {}),
|
||||
archived: options.archived,
|
||||
...(options.roots !== undefined ? { roots: options.roots } : {}),
|
||||
limit: options.pageSize,
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
});
|
||||
const payload = unwrapSessionList(response, "experimental.session.list")
|
||||
.map((session) => stripSessionListDetails(session) as GlobalSessionRecord);
|
||||
return { response, payload };
|
||||
},
|
||||
{ attempts: 3, delay: 500, retryIf: () => true },
|
||||
);
|
||||
).catch((error) => {
|
||||
finishPerformanceEvent("error", { retryCount: Math.max(0, attempts - 1) });
|
||||
throw error;
|
||||
});
|
||||
|
||||
const payload = unwrapSessionList(response, "experimental.session.list")
|
||||
.map((session) => stripSessionListDetails(session) as GlobalSessionRecord);
|
||||
finishPerformanceEvent("complete", {
|
||||
retryCount: Math.max(0, attempts - 1),
|
||||
recordCount: payload.length,
|
||||
});
|
||||
if (payload.length === 0) break;
|
||||
|
||||
let appended = 0;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createMessageQueueTarget,
|
||||
getMessageQueueKey,
|
||||
migrateMessageQueueState,
|
||||
parseMessageQueueKey,
|
||||
useMessageQueueStore,
|
||||
} from "./messageQueueStore"
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {} })
|
||||
})
|
||||
|
||||
describe("message queue runtime ownership", () => {
|
||||
test("isolates colliding session IDs by runtime and directory", () => {
|
||||
const a = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
const b = createMessageQueueTarget("session-1", "/repo", "runtime-b")!
|
||||
useMessageQueueStore.getState().addToQueue(a, { content: "from A" })
|
||||
useMessageQueueStore.getState().addToQueue(b, { content: "from B" })
|
||||
|
||||
expect(useMessageQueueStore.getState().getQueueForTarget(a)[0]?.content).toBe("from A")
|
||||
expect(useMessageQueueStore.getState().getQueueForTarget(b)[0]?.content).toBe("from B")
|
||||
})
|
||||
|
||||
test("round trips a composite queue key", () => {
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
expect(parseMessageQueueKey(getMessageQueueKey(target))).toEqual(target)
|
||||
})
|
||||
|
||||
test("quarantines legacy session-only queues instead of activating them", () => {
|
||||
const migrated = migrateMessageQueueState({
|
||||
queuedMessages: {
|
||||
"session-1": [{ id: "queued-1", content: "legacy", createdAt: 1 }],
|
||||
},
|
||||
}, 1)
|
||||
|
||||
expect(migrated.queuedMessages).toEqual({})
|
||||
expect(migrated.quarantinedLegacyMessages?.["session-1"]?.[0]?.content).toBe("legacy")
|
||||
})
|
||||
|
||||
test("bounds each queue to the newest 20 messages", () => {
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
useMessageQueueStore.getState().addToQueue(target, { content: `message-${index}` })
|
||||
}
|
||||
|
||||
const queue = useMessageQueueStore.getState().getQueueForTarget(target)
|
||||
expect(queue).toHaveLength(20)
|
||||
expect(queue[0]?.content).toBe("message-5")
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import { devtools, persist } from 'zustand/middleware';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
import type { AttachedFile } from './types/sessionTypes';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
|
||||
export type FollowUpBehavior = 'steer' | 'queue';
|
||||
|
||||
@@ -52,38 +54,82 @@ export interface QueuedMessage {
|
||||
};
|
||||
}
|
||||
|
||||
export type MessageQueueTarget = {
|
||||
runtimeKey: string;
|
||||
directory: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
const MAX_QUEUE_TARGETS = 50;
|
||||
const MAX_MESSAGES_PER_QUEUE = 20;
|
||||
|
||||
export const createMessageQueueTarget = (
|
||||
sessionId: string,
|
||||
directory: string | null | undefined,
|
||||
runtimeKey: string = getRuntimeKey(),
|
||||
): MessageQueueTarget | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!runtimeKey || !normalizedDirectory || !sessionId) return null;
|
||||
return { runtimeKey, directory: normalizedDirectory, sessionId };
|
||||
};
|
||||
|
||||
export const getMessageQueueKey = (target: MessageQueueTarget): string =>
|
||||
`${target.runtimeKey}\n${target.directory}\n${target.sessionId}`;
|
||||
|
||||
export const parseMessageQueueKey = (key: string): MessageQueueTarget | null => {
|
||||
const [runtimeKey, directory, ...sessionParts] = key.split('\n');
|
||||
return createMessageQueueTarget(sessionParts.join('\n'), directory, runtimeKey);
|
||||
};
|
||||
|
||||
interface MessageQueueState {
|
||||
queuedMessages: Record<string, QueuedMessage[]>; // sessionId → queue
|
||||
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
|
||||
quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
|
||||
followUpBehavior: FollowUpBehavior;
|
||||
}
|
||||
|
||||
interface MessageQueueActions {
|
||||
addToQueue: (sessionId: string, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
|
||||
removeFromQueue: (sessionId: string, messageId: string) => void;
|
||||
reorderQueue: (sessionId: string, fromId: string, toId: string) => void;
|
||||
popToInput: (sessionId: string, messageId: string) => QueuedMessage | null;
|
||||
clearQueue: (sessionId: string) => void;
|
||||
addToQueue: (target: MessageQueueTarget, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
|
||||
removeFromQueue: (target: MessageQueueTarget, messageId: string) => void;
|
||||
reorderQueue: (target: MessageQueueTarget, fromId: string, toId: string) => void;
|
||||
popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null;
|
||||
clearQueue: (target: MessageQueueTarget) => void;
|
||||
clearAllQueues: () => void;
|
||||
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
|
||||
getQueueForSession: (sessionId: string) => QueuedMessage[];
|
||||
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
|
||||
}
|
||||
|
||||
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
||||
|
||||
type PersistedMessageQueueState = {
|
||||
queuedMessages?: Record<string, QueuedMessage[]>;
|
||||
quarantinedLegacyMessages?: Record<string, QueuedMessage[]>;
|
||||
followUpBehavior?: FollowUpBehavior;
|
||||
queueModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const migrateMessageQueueState = (persistedState: unknown, version: number): Partial<MessageQueueStore> => {
|
||||
const state = (persistedState ?? {}) as PersistedMessageQueueState;
|
||||
const legacyQueues = version < 2 ? (state.queuedMessages ?? {}) : {};
|
||||
return {
|
||||
queuedMessages: version < 2 ? {} : (state.queuedMessages ?? {}),
|
||||
quarantinedLegacyMessages: {
|
||||
...(state.quarantinedLegacyMessages ?? {}),
|
||||
...legacyQueues,
|
||||
},
|
||||
followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null),
|
||||
};
|
||||
};
|
||||
|
||||
export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
queuedMessages: {},
|
||||
quarantinedLegacyMessages: {},
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
|
||||
addToQueue: (sessionId, message) => {
|
||||
addToQueue: (target, message) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id,
|
||||
@@ -94,23 +140,32 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const queuedMessages = {
|
||||
...state.queuedMessages,
|
||||
[key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE),
|
||||
};
|
||||
const keys = Object.keys(queuedMessages);
|
||||
if (keys.length > MAX_QUEUE_TARGETS) {
|
||||
keys.sort((left, right) => (
|
||||
(queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0)
|
||||
));
|
||||
for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey];
|
||||
}
|
||||
return {
|
||||
queuedMessages: {
|
||||
...state.queuedMessages,
|
||||
[sessionId]: [...currentQueue, queuedMessage],
|
||||
},
|
||||
queuedMessages,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeFromQueue: (sessionId, messageId) => {
|
||||
removeFromQueue: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const newQueue = currentQueue.filter((m) => m.id !== messageId);
|
||||
|
||||
if (newQueue.length === 0) {
|
||||
const { [sessionId]: _removed, ...rest } = state.queuedMessages;
|
||||
const { [key]: _removed, ...rest } = state.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
}
|
||||
@@ -118,16 +173,17 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return {
|
||||
queuedMessages: {
|
||||
...state.queuedMessages,
|
||||
[sessionId]: newQueue,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
reorderQueue: (sessionId, fromId, toId) => {
|
||||
reorderQueue: (target, fromId, toId) => {
|
||||
if (fromId === toId) return;
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[sessionId];
|
||||
const currentQueue = state.queuedMessages[key];
|
||||
if (!currentQueue) return state;
|
||||
const fromIndex = currentQueue.findIndex((m) => m.id === fromId);
|
||||
const toIndex = currentQueue.findIndex((m) => m.id === toId);
|
||||
@@ -140,15 +196,16 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return {
|
||||
queuedMessages: {
|
||||
...state.queuedMessages,
|
||||
[sessionId]: newQueue,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
popToInput: (sessionId, messageId) => {
|
||||
popToInput: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const state = get();
|
||||
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const message = currentQueue.find((m) => m.id === messageId);
|
||||
|
||||
if (!message) {
|
||||
@@ -157,11 +214,11 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
|
||||
// Remove from queue
|
||||
set((prevState) => {
|
||||
const queue = prevState.queuedMessages[sessionId] ?? [];
|
||||
const queue = prevState.queuedMessages[key] ?? [];
|
||||
const newQueue = queue.filter((m) => m.id !== messageId);
|
||||
|
||||
if (newQueue.length === 0) {
|
||||
const { [sessionId]: _removed, ...rest } = prevState.queuedMessages;
|
||||
const { [key]: _removed, ...rest } = prevState.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
}
|
||||
@@ -169,7 +226,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return {
|
||||
queuedMessages: {
|
||||
...prevState.queuedMessages,
|
||||
[sessionId]: newQueue,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -177,9 +234,10 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return message;
|
||||
},
|
||||
|
||||
clearQueue: (sessionId) => {
|
||||
clearQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const { [sessionId]: _removed, ...rest } = state.queuedMessages;
|
||||
const { [key]: _removed, ...rest } = state.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
});
|
||||
@@ -194,25 +252,20 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
void updateDesktopSettings({ followUpBehavior: behavior });
|
||||
},
|
||||
|
||||
getQueueForSession: (sessionId) => {
|
||||
return get().queuedMessages[sessionId] ?? [];
|
||||
getQueueForTarget: (target) => {
|
||||
return get().queuedMessages[getMessageQueueKey(target)] ?? [];
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'message-queue-store',
|
||||
version: 1,
|
||||
version: 2,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({
|
||||
queuedMessages: state.queuedMessages,
|
||||
quarantinedLegacyMessages: state.quarantinedLegacyMessages,
|
||||
followUpBehavior: state.followUpBehavior,
|
||||
}),
|
||||
migrate: (persistedState) => {
|
||||
const state = (persistedState ?? {}) as PersistedMessageQueueState;
|
||||
return {
|
||||
queuedMessages: state.queuedMessages ?? {},
|
||||
followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null),
|
||||
};
|
||||
},
|
||||
migrate: migrateMessageQueueState,
|
||||
}
|
||||
),
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ const json = (value: unknown, status = 200) => new Response(JSON.stringify(value
|
||||
describe('permission store server policy', () => {
|
||||
beforeEach(() => {
|
||||
usePermissionStore.getState().reset();
|
||||
usePermissionStore.setState({ legacyCandidate: null, legacyRuntimeKey: null });
|
||||
fetchImpl = async () => json({ sessions: {} });
|
||||
});
|
||||
|
||||
@@ -51,7 +52,7 @@ describe('permission store server policy', () => {
|
||||
});
|
||||
|
||||
test('migrates a legacy local policy when the server has no policy yet', async () => {
|
||||
usePermissionStore.setState({ autoAccept: { root: true } });
|
||||
usePermissionStore.setState({ legacyCandidate: { root: true }, legacyRuntimeKey: null });
|
||||
const requests: string[] = [];
|
||||
fetchImpl = async (input) => {
|
||||
requests.push(input);
|
||||
@@ -62,5 +63,56 @@ describe('permission store server policy', () => {
|
||||
await usePermissionStore.getState().hydrate();
|
||||
expect(requests).toEqual(['/api/permission-auto-accept', '/api/permission-auto-accept/sessions/root']);
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({ root: true });
|
||||
expect(usePermissionStore.getState().legacyCandidate).toBe(null);
|
||||
});
|
||||
|
||||
test('rejects a hydration response from before reset', async () => {
|
||||
let resolveOld!: (response: Response) => void;
|
||||
const oldResponse = new Promise<Response>((resolve) => { resolveOld = resolve; });
|
||||
fetchImpl = async () => oldResponse;
|
||||
const oldHydration = usePermissionStore.getState().hydrate();
|
||||
|
||||
usePermissionStore.getState().reset();
|
||||
fetchImpl = async () => json({ sessions: { current: true }, revision: 2 });
|
||||
await usePermissionStore.getState().hydrate();
|
||||
resolveOld(json({ sessions: { stale: true }, revision: 1 }));
|
||||
await oldHydration;
|
||||
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({ current: true });
|
||||
});
|
||||
|
||||
test('rejects a mutation response from before reset', async () => {
|
||||
let resolveOld!: (response: Response) => void;
|
||||
fetchImpl = async () => new Promise<Response>((resolve) => { resolveOld = resolve; });
|
||||
const mutation = usePermissionStore.getState().setSessionAutoAccept('stale', true);
|
||||
|
||||
usePermissionStore.getState().reset();
|
||||
resolveOld(json({ sessions: { stale: true }, revision: 1 }));
|
||||
await mutation;
|
||||
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({});
|
||||
expect(usePermissionStore.getState().saving).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps the highest authoritative revision when mutations resolve out of order', async () => {
|
||||
const resolvers: Array<(response: Response) => void> = [];
|
||||
fetchImpl = async () => new Promise<Response>((resolve) => { resolvers.push(resolve); });
|
||||
const first = usePermissionStore.getState().setSessionAutoAccept('first', true);
|
||||
const second = usePermissionStore.getState().setSessionAutoAccept('second', true);
|
||||
|
||||
resolvers[1](json({ sessions: { first: true, second: true }, revision: 2 }));
|
||||
await second;
|
||||
resolvers[0](json({ sessions: { first: true }, revision: 1 }));
|
||||
await first;
|
||||
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({ first: true, second: true });
|
||||
expect(usePermissionStore.getState().saving).toBe(false);
|
||||
});
|
||||
|
||||
test('ignores an older broadcast revision', () => {
|
||||
usePermissionStore.getState().applySnapshot({ sessions: { current: true }, revision: 4 });
|
||||
usePermissionStore.getState().applySnapshot({ sessions: { stale: true }, revision: 3 });
|
||||
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({ current: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,17 +8,26 @@ import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
|
||||
import { useSessionUIStore } from "@/sync/session-ui-store";
|
||||
import { opencodeClient } from "@/lib/opencode/client";
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch";
|
||||
|
||||
type PermissionPolicySnapshot = {
|
||||
sessions: PermissionAutoAcceptMap;
|
||||
revision?: number;
|
||||
};
|
||||
|
||||
const normalizeRevision = (value: unknown): number | undefined => (
|
||||
Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : undefined
|
||||
);
|
||||
|
||||
interface PermissionStore {
|
||||
autoAccept: PermissionAutoAcceptMap;
|
||||
loaded: boolean;
|
||||
saving: boolean;
|
||||
lastAppliedRevision: number;
|
||||
legacyCandidate: PermissionAutoAcceptMap | null;
|
||||
legacyRuntimeKey: string | null;
|
||||
hydrate: () => Promise<void>;
|
||||
applySnapshot: (snapshot: PermissionPolicySnapshot) => void;
|
||||
applySnapshot: (snapshot: PermissionPolicySnapshot, expectedRuntimeKey?: string) => void;
|
||||
reset: () => void;
|
||||
isSessionAutoAccepting: (sessionId: string) => boolean;
|
||||
setSessionAutoAccept: (sessionId: string, enabled: boolean) => Promise<void>;
|
||||
@@ -34,7 +43,7 @@ const readSnapshot = async (response: Response): Promise<PermissionPolicySnapsho
|
||||
for (const [sessionId, enabled] of Object.entries(payload.sessions)) {
|
||||
if (sessionId && typeof enabled === "boolean") sessions[sessionId] = enabled;
|
||||
}
|
||||
return { sessions };
|
||||
return { sessions, revision: normalizeRevision(payload.revision) };
|
||||
};
|
||||
|
||||
const requestSnapshot = async (path: string, init?: RequestInit) => readSnapshot(await runtimeFetch(path, init));
|
||||
@@ -45,15 +54,52 @@ const isAutoAccepting = (
|
||||
sessionId: string,
|
||||
) => autoRespondsPermission({ autoAccept, sessions: [], sessionById, sessionID: sessionId });
|
||||
|
||||
type PermissionOperation = { generation: number; runtimeKey: string; sequence: number };
|
||||
let generation = 0;
|
||||
let operationSequence = 0;
|
||||
let latestStartedSequence = 0;
|
||||
const pendingSavingOperations = new Set<number>();
|
||||
|
||||
const beginOperation = (): PermissionOperation => {
|
||||
const operation = { generation, runtimeKey: getRuntimeKey(), sequence: ++operationSequence };
|
||||
latestStartedSequence = operation.sequence;
|
||||
return operation;
|
||||
};
|
||||
|
||||
const isCurrentOperation = (operation: PermissionOperation) => (
|
||||
operation.generation === generation && operation.runtimeKey === getRuntimeKey()
|
||||
);
|
||||
|
||||
const normalizeSessions = (value: unknown): PermissionAutoAcceptMap => {
|
||||
const sessions: PermissionAutoAcceptMap = {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return sessions;
|
||||
for (const [sessionId, enabled] of Object.entries(value)) {
|
||||
if (sessionId && typeof enabled === "boolean") sessions[sessionId] = enabled;
|
||||
}
|
||||
return sessions;
|
||||
};
|
||||
|
||||
export const usePermissionStore = create<PermissionStore>()(persist((set, get) => ({
|
||||
autoAccept: {},
|
||||
loaded: false,
|
||||
saving: false,
|
||||
lastAppliedRevision: -1,
|
||||
legacyCandidate: null,
|
||||
legacyRuntimeKey: null,
|
||||
|
||||
hydrate: async () => {
|
||||
const operation = beginOperation();
|
||||
const legacyCandidate = get().legacyCandidate;
|
||||
let legacyRuntimeKey = get().legacyRuntimeKey;
|
||||
if (legacyCandidate && !legacyRuntimeKey) {
|
||||
legacyRuntimeKey = operation.runtimeKey;
|
||||
set({ legacyRuntimeKey });
|
||||
}
|
||||
let snapshot = await requestSnapshot("/api/permission-auto-accept");
|
||||
const legacyEntries = Object.entries(get().autoAccept)
|
||||
.filter(([sessionId, enabled]) => !sessionId.includes("/") && typeof enabled === "boolean");
|
||||
if (!isCurrentOperation(operation)) return;
|
||||
const legacyEntries = legacyRuntimeKey === operation.runtimeKey
|
||||
? Object.entries(legacyCandidate ?? {})
|
||||
: [];
|
||||
if (Object.keys(snapshot.sessions).length === 0 && legacyEntries.length > 0) {
|
||||
for (const [sessionId, enabled] of legacyEntries) {
|
||||
if (!sessionId || typeof enabled !== "boolean") continue;
|
||||
@@ -65,19 +111,37 @@ export const usePermissionStore = create<PermissionStore>()(persist((set, get) =
|
||||
body: JSON.stringify({ enabled }),
|
||||
},
|
||||
);
|
||||
if (!isCurrentOperation(operation)) return;
|
||||
}
|
||||
}
|
||||
set({ autoAccept: snapshot.sessions, loaded: true });
|
||||
if (!isCurrentOperation(operation)) return;
|
||||
if (snapshot.revision === undefined && operation.sequence !== latestStartedSequence) return;
|
||||
get().applySnapshot(snapshot, operation.runtimeKey);
|
||||
if (legacyRuntimeKey === operation.runtimeKey) {
|
||||
set({ legacyCandidate: null, legacyRuntimeKey: null });
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set({ autoAccept: {}, loaded: false, saving: false }),
|
||||
reset: () => {
|
||||
generation += 1;
|
||||
latestStartedSequence = 0;
|
||||
pendingSavingOperations.clear();
|
||||
set({ autoAccept: {}, loaded: false, saving: false, lastAppliedRevision: -1 });
|
||||
},
|
||||
|
||||
applySnapshot: (snapshot) => {
|
||||
const sessions: PermissionAutoAcceptMap = {};
|
||||
for (const [sessionId, enabled] of Object.entries(snapshot.sessions ?? {})) {
|
||||
if (sessionId && typeof enabled === "boolean") sessions[sessionId] = enabled;
|
||||
}
|
||||
set({ autoAccept: sessions, loaded: true });
|
||||
applySnapshot: (snapshot, expectedRuntimeKey) => {
|
||||
if (expectedRuntimeKey && expectedRuntimeKey !== getRuntimeKey()) return;
|
||||
const sessions = normalizeSessions(snapshot.sessions);
|
||||
const revision = normalizeRevision(snapshot.revision);
|
||||
set((state) => {
|
||||
if (revision === undefined && state.lastAppliedRevision >= 0) return state;
|
||||
if (revision !== undefined && revision < state.lastAppliedRevision) return state;
|
||||
return {
|
||||
autoAccept: sessions,
|
||||
loaded: true,
|
||||
...(revision !== undefined ? { lastAppliedRevision: revision } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
isSessionAutoAccepting: (sessionId) => {
|
||||
@@ -89,6 +153,8 @@ export const usePermissionStore = create<PermissionStore>()(persist((set, get) =
|
||||
|
||||
setSessionAutoAccept: async (sessionId, enabled) => {
|
||||
if (!sessionId) return;
|
||||
const operation = beginOperation();
|
||||
pendingSavingOperations.add(operation.sequence);
|
||||
set({ saving: true });
|
||||
try {
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
||||
@@ -102,18 +168,40 @@ export const usePermissionStore = create<PermissionStore>()(persist((set, get) =
|
||||
body: JSON.stringify({ enabled, directory }),
|
||||
},
|
||||
);
|
||||
set({ autoAccept: snapshot.sessions, loaded: true });
|
||||
if (isVSCodeRuntime() && enabled) {
|
||||
if (!isCurrentOperation(operation)) return;
|
||||
if (snapshot.revision === undefined && operation.sequence !== latestStartedSequence) return;
|
||||
get().applySnapshot(snapshot, operation.runtimeKey);
|
||||
if (isCurrentOperation(operation) && isVSCodeRuntime() && enabled) {
|
||||
const { reconcileVSCodePendingPermissions } = await import("@/sync/vscode-permission-auto-accept");
|
||||
void reconcileVSCodePendingPermissions(directory).catch(() => undefined);
|
||||
if (isCurrentOperation(operation)) {
|
||||
void reconcileVSCodePendingPermissions(directory).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
set({ saving: false });
|
||||
if (isCurrentOperation(operation)) {
|
||||
pendingSavingOperations.delete(operation.sequence);
|
||||
set({ saving: pendingSavingOperations.size > 0 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
}), {
|
||||
name: "permission-store",
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({ autoAccept: state.autoAccept }),
|
||||
version: 2,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted && typeof persisted === "object" ? persisted as Record<string, unknown> : {};
|
||||
if (version < 2) {
|
||||
const legacyCandidate = normalizeSessions(state.autoAccept);
|
||||
return {
|
||||
legacyCandidate: Object.keys(legacyCandidate).length > 0 ? legacyCandidate : null,
|
||||
legacyRuntimeKey: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
legacyCandidate: state.legacyCandidate,
|
||||
legacyRuntimeKey: state.legacyRuntimeKey,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace";
|
||||
import { normalizePath } from "@/lib/pathNormalization";
|
||||
import { getSyncConfig, subscribeToSyncConfigChanges } from "@/sync/sync-refs";
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch";
|
||||
|
||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
|
||||
@@ -717,18 +718,57 @@ const resolveInitialDirectoryKey = (): string => {
|
||||
// We cache resolved mappings to localStorage so subsequent launches resolve the
|
||||
// project synchronously at init time. worktree→project is effectively immutable,
|
||||
// so a cached entry is safe to trust.
|
||||
const WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap';
|
||||
let _worktreeProjectMap: Record<string, string> | null = null;
|
||||
const WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap.v2';
|
||||
const LEGACY_WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap';
|
||||
const MAX_WORKTREE_PROJECT_RUNTIME_MAPS = 8;
|
||||
type WorktreeProjectMapEnvelope = {
|
||||
version: 2;
|
||||
legacyClaimed: boolean;
|
||||
runtimes: Record<string, { updatedAt: number; entries: Record<string, string> }>;
|
||||
};
|
||||
const _worktreeProjectMaps = new Map<string, Record<string, string>>();
|
||||
const readWorktreeProjectEnvelope = (): WorktreeProjectMapEnvelope => {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(WORKTREE_PROJECT_MAP_KEY) : null;
|
||||
if (!raw) return { version: 2, legacyClaimed: false, runtimes: {} };
|
||||
const parsed = JSON.parse(raw) as Partial<WorktreeProjectMapEnvelope>;
|
||||
if (parsed.version !== 2 || !parsed.runtimes || typeof parsed.runtimes !== 'object') {
|
||||
return { version: 2, legacyClaimed: false, runtimes: {} };
|
||||
}
|
||||
return { version: 2, legacyClaimed: parsed.legacyClaimed === true, runtimes: parsed.runtimes };
|
||||
} catch {
|
||||
return { version: 2, legacyClaimed: false, runtimes: {} };
|
||||
}
|
||||
};
|
||||
const writeWorktreeProjectEnvelope = (envelope: WorktreeProjectMapEnvelope): void => {
|
||||
const runtimes = Object.fromEntries(
|
||||
Object.entries(envelope.runtimes)
|
||||
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
|
||||
.slice(0, MAX_WORKTREE_PROJECT_RUNTIME_MAPS),
|
||||
);
|
||||
localStorage.setItem(WORKTREE_PROJECT_MAP_KEY, JSON.stringify({ ...envelope, runtimes }));
|
||||
};
|
||||
const getWorktreeProjectMap = (): Record<string, string> => {
|
||||
if (_worktreeProjectMap === null) {
|
||||
const runtimeKey = getRuntimeKey() || 'default';
|
||||
const existing = _worktreeProjectMaps.get(runtimeKey);
|
||||
if (existing) return existing;
|
||||
const envelope = readWorktreeProjectEnvelope();
|
||||
let map = envelope.runtimes[runtimeKey]?.entries ?? null;
|
||||
if (!map && !envelope.legacyClaimed) {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(WORKTREE_PROJECT_MAP_KEY) : null;
|
||||
_worktreeProjectMap = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(LEGACY_WORKTREE_PROJECT_MAP_KEY) : null;
|
||||
map = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
||||
envelope.legacyClaimed = true;
|
||||
envelope.runtimes[runtimeKey] = { updatedAt: Date.now(), entries: map };
|
||||
writeWorktreeProjectEnvelope(envelope);
|
||||
localStorage.removeItem(LEGACY_WORKTREE_PROJECT_MAP_KEY);
|
||||
} catch {
|
||||
_worktreeProjectMap = {};
|
||||
map = {};
|
||||
}
|
||||
}
|
||||
return _worktreeProjectMap;
|
||||
const result = map ?? {};
|
||||
_worktreeProjectMaps.set(runtimeKey, result);
|
||||
return result;
|
||||
};
|
||||
const rememberWorktreeProject = (worktree: string, project: string): void => {
|
||||
if (!worktree || !project || worktree === project) return;
|
||||
@@ -736,7 +776,12 @@ const rememberWorktreeProject = (worktree: string, project: string): void => {
|
||||
if (map[worktree] === project) return;
|
||||
map[worktree] = project;
|
||||
try {
|
||||
localStorage.setItem(WORKTREE_PROJECT_MAP_KEY, JSON.stringify(map));
|
||||
const runtimeKey = getRuntimeKey() || 'default';
|
||||
const envelope = readWorktreeProjectEnvelope();
|
||||
envelope.legacyClaimed = true;
|
||||
envelope.runtimes[runtimeKey] = { updatedAt: Date.now(), entries: map };
|
||||
writeWorktreeProjectEnvelope(envelope);
|
||||
localStorage.removeItem(LEGACY_WORKTREE_PROJECT_MAP_KEY);
|
||||
} catch {
|
||||
// localStorage quota exceeded — ignore; live resolution still works.
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type Deferred<T> = {
|
||||
};
|
||||
|
||||
const searchRequests: Array<Deferred<Array<{ path: string }>>> = [];
|
||||
let runtimeKey = 'runtime-a';
|
||||
|
||||
const createDeferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -29,12 +30,14 @@ mock.module('@/lib/opencode/client', () => ({
|
||||
searchFiles: searchFilesMock,
|
||||
},
|
||||
}));
|
||||
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => runtimeKey }));
|
||||
|
||||
const { useFileSearchStore } = await import('./useFileSearchStore');
|
||||
|
||||
describe('useFileSearchStore', () => {
|
||||
beforeEach(() => {
|
||||
searchRequests.length = 0;
|
||||
runtimeKey = 'runtime-a';
|
||||
useFileSearchStore.setState({
|
||||
cache: {},
|
||||
cacheKeys: [],
|
||||
@@ -101,4 +104,20 @@ describe('useFileSearchStore', () => {
|
||||
searchRequests[1].resolve([{ path: 'second.ts' }]);
|
||||
expect(await secondPromise).toEqual([{ path: 'second.ts' }]);
|
||||
});
|
||||
|
||||
test('isolates cache and in-flight ownership by runtime', async () => {
|
||||
const firstPromise = useFileSearchStore.getState().searchFiles('/project', 'foo');
|
||||
runtimeKey = 'runtime-b';
|
||||
const secondPromise = useFileSearchStore.getState().searchFiles('/project', 'foo');
|
||||
expect(searchRequests).toHaveLength(2);
|
||||
|
||||
searchRequests[1].resolve([{ path: 'runtime-b.ts' }]);
|
||||
expect(await secondPromise).toEqual([{ path: 'runtime-b.ts' }]);
|
||||
searchRequests[0].resolve([{ path: 'runtime-a.ts' }]);
|
||||
await firstPromise;
|
||||
|
||||
runtimeKey = 'runtime-b';
|
||||
expect(await useFileSearchStore.getState().searchFiles('/project', 'foo')).toEqual([{ path: 'runtime-b.ts' }]);
|
||||
expect(searchRequests).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { opencodeClient, type ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
const MAX_CACHE_ENTRIES = 40;
|
||||
@@ -22,9 +23,11 @@ interface FileSearchStoreState {
|
||||
options?: { includeHidden?: boolean; respectGitignore?: boolean; type?: 'file' | 'directory' }
|
||||
) => Promise<ProjectFileSearchHit[]>;
|
||||
invalidateDirectory: (directory?: string | null) => void;
|
||||
resetForRuntimeSwitch: () => void;
|
||||
}
|
||||
|
||||
const buildCacheKey = (
|
||||
runtimeKey: string,
|
||||
directory: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
@@ -34,13 +37,13 @@ const buildCacheKey = (
|
||||
) => {
|
||||
const normalizedDirectory = directory.trim();
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return JSON.stringify([normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type]);
|
||||
return JSON.stringify([runtimeKey, normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type]);
|
||||
};
|
||||
|
||||
const cacheKeyMatchesDirectory = (cacheKey: string, directory: string) => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(cacheKey);
|
||||
return Array.isArray(value) && value[0] === directory;
|
||||
return Array.isArray(value) && value[1] === directory;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -58,11 +61,12 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
|
||||
}
|
||||
|
||||
const normalizedDirectory = directory.trim();
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const includeHidden = Boolean(options?.includeHidden);
|
||||
const respectGitignore = options?.respectGitignore ?? true;
|
||||
const type = options?.type === 'directory' ? 'directory' : 'file';
|
||||
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type);
|
||||
const key = buildCacheKey(runtimeKey, normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type);
|
||||
const now = Date.now();
|
||||
const cached = get().cache[key];
|
||||
|
||||
@@ -159,6 +163,9 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
resetForRuntimeSwitch() {
|
||||
set({ cache: {}, cacheKeys: [], inFlight: {} });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'file-search-store',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useFilesViewTabsStore } from './useFilesViewTabsStore';
|
||||
|
||||
describe('useFilesViewTabsStore', () => {
|
||||
beforeEach(() => {
|
||||
useFilesViewTabsStore.setState({ byRoot: {} });
|
||||
useFilesViewTabsStore.setState({ byRoot: {}, activeRuntimeKey: 'runtime-a', runtimeSnapshots: {} });
|
||||
});
|
||||
|
||||
test('ignores runtime paths outside the requested root', () => {
|
||||
@@ -47,4 +47,16 @@ describe('useFilesViewTabsStore', () => {
|
||||
expect(state?.openPaths).toEqual(['/repo/src/index.ts']);
|
||||
expect(state?.expandedPaths).toEqual(['/repo/src', '/repo/other']);
|
||||
});
|
||||
|
||||
test('restores independent active projections across runtime switches', () => {
|
||||
useFilesViewTabsStore.getState().addOpenPath('/repo', '/repo/a.ts');
|
||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch('runtime-b');
|
||||
expect(useFilesViewTabsStore.getState().byRoot).toEqual({});
|
||||
useFilesViewTabsStore.getState().addOpenPath('/repo', '/repo/b.ts');
|
||||
|
||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch('runtime-a');
|
||||
expect(useFilesViewTabsStore.getState().byRoot['/repo']?.openPaths).toEqual(['/repo/a.ts']);
|
||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch('runtime-b');
|
||||
expect(useFilesViewTabsStore.getState().byRoot['/repo']?.openPaths).toEqual(['/repo/b.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
type RootTabsState = {
|
||||
openPaths: string[];
|
||||
@@ -12,6 +13,8 @@ type RootTabsState = {
|
||||
|
||||
type FilesViewTabsState = {
|
||||
byRoot: Record<string, RootTabsState>;
|
||||
activeRuntimeKey: string;
|
||||
runtimeSnapshots: Record<string, { byRoot: Record<string, RootTabsState>; updatedAt: number }>;
|
||||
};
|
||||
|
||||
type FilesViewTabsActions = {
|
||||
@@ -24,10 +27,18 @@ type FilesViewTabsActions = {
|
||||
toggleExpandedPath: (root: string, path: string) => void;
|
||||
expandPath: (root: string, path: string) => void;
|
||||
expandPaths: (root: string, paths: string[]) => void;
|
||||
resetForRuntimeSwitch: (runtimeKey: string) => void;
|
||||
};
|
||||
|
||||
export type FilesViewTabsStore = FilesViewTabsState & FilesViewTabsActions;
|
||||
|
||||
const MAX_ROOTS = 20;
|
||||
const MAX_RUNTIME_SNAPSHOTS = 8;
|
||||
const MAX_OPEN_PATHS_PER_ROOT = 50;
|
||||
const MAX_EXPANDED_PATHS_PER_ROOT = 500;
|
||||
const MAX_PATH_LENGTH = 4096;
|
||||
const ROOT_TTL_MS = 90 * 24 * 60 * 60_000;
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
if (!value) return '';
|
||||
|
||||
@@ -90,14 +101,16 @@ const sanitizeByRoot = (input: unknown): Record<string, RootTabsState> => {
|
||||
? Array.from(new Set(state.openPaths
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => normalizePath(value))
|
||||
.filter((value) => isPathWithinRoot(value, root))))
|
||||
.filter((value) => value.length <= MAX_PATH_LENGTH && isPathWithinRoot(value, root))))
|
||||
.slice(-MAX_OPEN_PATHS_PER_ROOT)
|
||||
: [];
|
||||
|
||||
const expandedPaths = Array.isArray(state.expandedPaths)
|
||||
? Array.from(new Set(state.expandedPaths
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => normalizePath(value))
|
||||
.filter((value) => isPathWithinRoot(value, root))))
|
||||
.filter((value) => value.length <= MAX_PATH_LENGTH && isPathWithinRoot(value, root))))
|
||||
.slice(-MAX_EXPANDED_PATHS_PER_ROOT)
|
||||
: [];
|
||||
|
||||
const selectedPathCandidate = typeof state.selectedPath === 'string'
|
||||
@@ -111,6 +124,7 @@ const sanitizeByRoot = (input: unknown): Record<string, RootTabsState> => {
|
||||
const touchedAt = typeof state.touchedAt === 'number' && Number.isFinite(state.touchedAt)
|
||||
? state.touchedAt
|
||||
: Date.now();
|
||||
if (Date.now() - touchedAt > ROOT_TTL_MS) continue;
|
||||
|
||||
const existing = next[root];
|
||||
if (existing) {
|
||||
@@ -134,7 +148,7 @@ const sanitizeByRoot = (input: unknown): Record<string, RootTabsState> => {
|
||||
};
|
||||
}
|
||||
|
||||
return next;
|
||||
return clampRoots(next, MAX_ROOTS);
|
||||
};
|
||||
|
||||
const clampRoots = (byRoot: Record<string, RootTabsState>, maxRoots: number): Record<string, RootTabsState> => {
|
||||
@@ -163,6 +177,22 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
byRoot: {},
|
||||
activeRuntimeKey: getRuntimeKey(),
|
||||
runtimeSnapshots: {},
|
||||
|
||||
resetForRuntimeSwitch: (runtimeKey) => {
|
||||
set((state) => {
|
||||
const runtimeSnapshots = {
|
||||
...state.runtimeSnapshots,
|
||||
[state.activeRuntimeKey]: { byRoot: sanitizeByRoot(state.byRoot), updatedAt: Date.now() },
|
||||
};
|
||||
return {
|
||||
activeRuntimeKey: runtimeKey,
|
||||
runtimeSnapshots,
|
||||
byRoot: sanitizeByRoot(runtimeSnapshots[runtimeKey]?.byRoot),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addOpenPath: (root, path, options) => {
|
||||
const normalizedRoot = normalizePath((root || '').trim());
|
||||
@@ -189,7 +219,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
selectedPath: nextSelectedPath,
|
||||
},
|
||||
};
|
||||
return { byRoot: clampRoots(byRoot, 20) };
|
||||
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -225,7 +255,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
touchedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
return { byRoot: clampRoots(byRoot, 20) };
|
||||
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -267,7 +297,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
},
|
||||
};
|
||||
|
||||
return { byRoot: clampRoots(byRoot, 20) };
|
||||
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -304,7 +334,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
},
|
||||
};
|
||||
|
||||
return { byRoot: clampRoots(byRoot, 20) };
|
||||
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -334,7 +364,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
selectedPath: normalizedPath,
|
||||
},
|
||||
};
|
||||
return { byRoot: clampRoots(byRoot, 20) };
|
||||
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -383,7 +413,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
expandedPaths: nextExpandedPaths,
|
||||
},
|
||||
};
|
||||
return { byRoot: clampRoots(byRoot, 20) };
|
||||
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -410,7 +440,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
expandedPaths: [...current.expandedPaths, normalizedPath],
|
||||
},
|
||||
};
|
||||
return { byRoot: clampRoots(byRoot, 20) };
|
||||
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -444,25 +474,49 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
expandedPaths: [...current.expandedPaths, ...newPaths],
|
||||
},
|
||||
};
|
||||
return { byRoot: clampRoots(byRoot, 20) };
|
||||
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'files-view-tabs-store',
|
||||
version: 2,
|
||||
version: 3,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return { byRoot: {} };
|
||||
migrate: (persistedState, version) => {
|
||||
if (version < 3 || !persistedState || typeof persistedState !== 'object') {
|
||||
return { byRoot: {}, activeRuntimeKey: getRuntimeKey(), runtimeSnapshots: {} };
|
||||
}
|
||||
|
||||
const rawByRoot = (persistedState as { byRoot?: unknown }).byRoot;
|
||||
return persistedState;
|
||||
},
|
||||
partialize: (state) => {
|
||||
const currentSnapshots = {
|
||||
...state.runtimeSnapshots,
|
||||
[state.activeRuntimeKey]: { byRoot: sanitizeByRoot(state.byRoot), updatedAt: Date.now() },
|
||||
};
|
||||
const runtimeSnapshots = Object.fromEntries(Object.entries(currentSnapshots)
|
||||
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
|
||||
.slice(0, MAX_RUNTIME_SNAPSHOTS)
|
||||
.map(([runtimeKey, snapshot]) => [runtimeKey, {
|
||||
byRoot: sanitizeByRoot(snapshot.byRoot),
|
||||
updatedAt: snapshot.updatedAt,
|
||||
}]));
|
||||
return { activeRuntimeKey: state.activeRuntimeKey, runtimeSnapshots };
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Partial<FilesViewTabsState>
|
||||
: {};
|
||||
const runtimeSnapshots = persisted.runtimeSnapshots && typeof persisted.runtimeSnapshots === 'object'
|
||||
? persisted.runtimeSnapshots
|
||||
: {};
|
||||
const activeRuntimeKey = getRuntimeKey();
|
||||
return {
|
||||
byRoot: sanitizeByRoot(rawByRoot),
|
||||
...currentState,
|
||||
activeRuntimeKey,
|
||||
runtimeSnapshots,
|
||||
byRoot: sanitizeByRoot(runtimeSnapshots[activeRuntimeKey]?.byRoot),
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({ byRoot: sanitizeByRoot(state.byRoot) }),
|
||||
}
|
||||
),
|
||||
{ name: 'files-view-tabs-store' }
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { GitHubPullRequestStatus, RuntimeAPIs } from "@/lib/api/types"
|
||||
|
||||
let runtimeKey = "runtime-a"
|
||||
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
|
||||
|
||||
const { getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => { resolve = res })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const params = (github: RuntimeAPIs["github"], branch = "main") => ({
|
||||
directory: "/repo",
|
||||
branch,
|
||||
remoteName: "origin",
|
||||
canShow: true,
|
||||
github,
|
||||
githubAuthChecked: true,
|
||||
githubConnected: true,
|
||||
})
|
||||
|
||||
describe("GitHub PR status cache ownership", () => {
|
||||
beforeEach(() => {
|
||||
runtimeKey = "runtime-a"
|
||||
useGitHubPrStatusStore.setState({ entries: {}, activeRequestCount: 0, totalRequestCount: 0 })
|
||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch()
|
||||
})
|
||||
|
||||
test("keys colliding paths by runtime and requested remote", () => {
|
||||
const originA = getGitHubPrStatusKey("/repo", "main", "origin")
|
||||
const upstreamA = getGitHubPrStatusKey("/repo", "main", "upstream")
|
||||
runtimeKey = "runtime-b"
|
||||
const originB = getGitHubPrStatusKey("/repo", "main", "origin")
|
||||
|
||||
expect(new Set([originA, upstreamA, originB]).size).toBe(3)
|
||||
})
|
||||
|
||||
test("rejects a response after params change", async () => {
|
||||
const request = deferred<GitHubPullRequestStatus>()
|
||||
const github = { prStatus: () => request.promise } as unknown as RuntimeAPIs["github"]
|
||||
const key = getGitHubPrStatusKey("/repo", "main", "origin")
|
||||
useGitHubPrStatusStore.getState().ensureEntry(key)
|
||||
useGitHubPrStatusStore.getState().setParams(key, params(github))
|
||||
const loading = useGitHubPrStatusStore.getState().refresh(key, { force: true })
|
||||
|
||||
useGitHubPrStatusStore.getState().setParams(key, params(github, "next"))
|
||||
request.resolve({ connected: true, pr: null })
|
||||
await loading
|
||||
|
||||
expect(useGitHubPrStatusStore.getState().entries[key]?.status).toBe(null)
|
||||
expect(useGitHubPrStatusStore.getState().entries[key]?.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects an old runtime response after reset", async () => {
|
||||
const request = deferred<GitHubPullRequestStatus>()
|
||||
const github = { prStatus: () => request.promise } as unknown as RuntimeAPIs["github"]
|
||||
const key = getGitHubPrStatusKey("/repo", "main", "origin")
|
||||
useGitHubPrStatusStore.getState().ensureEntry(key)
|
||||
useGitHubPrStatusStore.getState().setParams(key, params(github))
|
||||
const loading = useGitHubPrStatusStore.getState().refresh(key, { force: true })
|
||||
|
||||
runtimeKey = "runtime-b"
|
||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch()
|
||||
request.resolve({ connected: true, pr: null })
|
||||
await loading
|
||||
|
||||
expect(useGitHubPrStatusStore.getState().entries[key]?.status).toBe(null)
|
||||
expect(useGitHubPrStatusStore.getState().activeRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test("throttles repeated non-forced refreshes after a failure", async () => {
|
||||
let requestCount = 0
|
||||
const github = {
|
||||
prStatus: async () => {
|
||||
requestCount += 1
|
||||
throw new Error("GitHub rate limited")
|
||||
},
|
||||
} as unknown as RuntimeAPIs["github"]
|
||||
const key = getGitHubPrStatusKey("/repo", "main", "origin")
|
||||
useGitHubPrStatusStore.getState().ensureEntry(key)
|
||||
useGitHubPrStatusStore.getState().setParams(key, params(github))
|
||||
|
||||
await useGitHubPrStatusStore.getState().refresh(key)
|
||||
await useGitHubPrStatusStore.getState().refresh(key)
|
||||
|
||||
expect(requestCount).toBe(1)
|
||||
expect(useGitHubPrStatusStore.getState().entries[key]?.error).toBe("GitHub rate limited")
|
||||
})
|
||||
|
||||
test("does not throttle replacement params when a queued request becomes stale", async () => {
|
||||
const first = deferred<GitHubPullRequestStatus>()
|
||||
const second = deferred<GitHubPullRequestStatus>()
|
||||
let staleRequestCount = 0
|
||||
let replacementRequestCount = 0
|
||||
const firstGitHub = { prStatus: () => first.promise } as unknown as RuntimeAPIs["github"]
|
||||
const secondGitHub = { prStatus: () => second.promise } as unknown as RuntimeAPIs["github"]
|
||||
const staleGitHub = {
|
||||
prStatus: async () => {
|
||||
staleRequestCount += 1
|
||||
return { connected: true, pr: null }
|
||||
},
|
||||
} as unknown as RuntimeAPIs["github"]
|
||||
const replacementGitHub = {
|
||||
prStatus: async () => {
|
||||
replacementRequestCount += 1
|
||||
return { connected: true, pr: null }
|
||||
},
|
||||
} as unknown as RuntimeAPIs["github"]
|
||||
const firstKey = getGitHubPrStatusKey("/repo", "first", "origin")
|
||||
const secondKey = getGitHubPrStatusKey("/repo", "second", "origin")
|
||||
const queuedKey = getGitHubPrStatusKey("/repo", "queued", "origin")
|
||||
|
||||
for (const [key, github, branch] of [
|
||||
[firstKey, firstGitHub, "first"],
|
||||
[secondKey, secondGitHub, "second"],
|
||||
[queuedKey, staleGitHub, "queued"],
|
||||
] as const) {
|
||||
useGitHubPrStatusStore.getState().ensureEntry(key)
|
||||
useGitHubPrStatusStore.getState().setParams(key, params(github, branch))
|
||||
}
|
||||
|
||||
const firstRefresh = useGitHubPrStatusStore.getState().refresh(firstKey, { force: true })
|
||||
const secondRefresh = useGitHubPrStatusStore.getState().refresh(secondKey, { force: true })
|
||||
const staleRefresh = useGitHubPrStatusStore.getState().refresh(queuedKey, { force: true })
|
||||
await Promise.resolve()
|
||||
useGitHubPrStatusStore.getState().setParams(queuedKey, params(replacementGitHub, "queued"))
|
||||
first.resolve({ connected: true, pr: null })
|
||||
second.resolve({ connected: true, pr: null })
|
||||
await Promise.all([firstRefresh, secondRefresh, staleRefresh])
|
||||
|
||||
await useGitHubPrStatusStore.getState().refresh(queuedKey)
|
||||
|
||||
expect(staleRequestCount).toBe(0)
|
||||
expect(replacementRequestCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
||||
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
|
||||
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
const PR_REVALIDATE_TTL_MS = 90_000;
|
||||
const PR_REVALIDATE_INTERVAL_MS = 15_000;
|
||||
@@ -14,6 +15,7 @@ const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000;
|
||||
const PR_STATUS_REFRESH_CONCURRENCY = 4;
|
||||
const PR_PERSIST_TTL_MS = 12 * 60 * 60_000;
|
||||
const PR_STATUS_STORAGE_KEY = 'openchamber.github-pr-status';
|
||||
const PR_MAX_ENTRIES = 200;
|
||||
|
||||
const isTerminalPrState = (state: string | null | undefined): boolean => state === 'closed' || state === 'merged';
|
||||
const isPendingChecks = (status: GitHubPullRequestStatus | null): boolean => {
|
||||
@@ -24,9 +26,14 @@ const isPendingChecks = (status: GitHubPullRequestStatus | null): boolean => {
|
||||
return checks.state === 'pending' || checks.pending > 0;
|
||||
};
|
||||
|
||||
const getOpenPrRefreshInterval = (status: GitHubPullRequestStatus | null): number => {
|
||||
if (isPendingChecks(status)) return PR_OPEN_BUSY_INTERVAL_MS;
|
||||
if (status?.checks && status.checks.state !== 'pending') return PR_OPEN_STABLE_INTERVAL_MS;
|
||||
return PR_OPEN_DEFAULT_INTERVAL_MS;
|
||||
};
|
||||
|
||||
export const getGitHubPrStatusKey = (directory: string, branch: string, remoteName?: string | null): string => {
|
||||
void remoteName;
|
||||
return `${directory}::${branch}`;
|
||||
return JSON.stringify([getRuntimeKey(), directory, branch, remoteName ?? 'auto']);
|
||||
};
|
||||
|
||||
type RefreshOptions = {
|
||||
@@ -43,6 +50,7 @@ type PrTrackingTarget = {
|
||||
};
|
||||
|
||||
type PrRuntimeParams = {
|
||||
runtimeKey?: string;
|
||||
directory: string;
|
||||
branch: string;
|
||||
remoteName: string | null;
|
||||
@@ -53,6 +61,7 @@ type PrRuntimeParams = {
|
||||
};
|
||||
|
||||
type PrEntryIdentity = {
|
||||
runtimeKey: string;
|
||||
directory: string;
|
||||
branch: string;
|
||||
remoteName: string | null;
|
||||
@@ -69,6 +78,7 @@ type PrStatusEntry = {
|
||||
params: PrRuntimeParams | null;
|
||||
identity: PrEntryIdentity | null;
|
||||
resolvedRemoteName: string | null;
|
||||
paramsRevision: number;
|
||||
};
|
||||
|
||||
type PersistedPrStatusEntry = Pick<
|
||||
@@ -87,12 +97,14 @@ type GitHubPrStatusStore = {
|
||||
refresh: (key: string, options?: RefreshOptions) => Promise<void>;
|
||||
refreshTargets: (targets: PrTrackingTarget[], options?: RefreshOptions) => Promise<void>;
|
||||
updateStatus: (key: string, updater: (prev: GitHubPullRequestStatus | null) => GitHubPullRequestStatus | null) => void;
|
||||
resetForRuntimeSwitch: () => void;
|
||||
};
|
||||
|
||||
const timers = new Map<string, number>();
|
||||
const bootstrapTimers = new Map<string, number[]>();
|
||||
const inFlightBySignature = new Set<string>();
|
||||
const inFlightBySignature = new Map<string, symbol>();
|
||||
const lastRefreshBySignature = new Map<string, number>();
|
||||
let prRuntimeGeneration = 0;
|
||||
|
||||
// Global concurrency gate for PR-status network requests.
|
||||
//
|
||||
@@ -141,6 +153,7 @@ const createEntry = (): PrStatusEntry => ({
|
||||
params: null,
|
||||
identity: null,
|
||||
resolvedRemoteName: null,
|
||||
paramsRevision: 0,
|
||||
});
|
||||
|
||||
const getIdentityFromEntry = (entry: PrStatusEntry | null | undefined): PrEntryIdentity | null => {
|
||||
@@ -148,6 +161,7 @@ const getIdentityFromEntry = (entry: PrStatusEntry | null | undefined): PrEntryI
|
||||
return {
|
||||
directory: entry.params.directory,
|
||||
branch: entry.params.branch,
|
||||
runtimeKey: entry.params.runtimeKey ?? entry.identity?.runtimeKey ?? getRuntimeKey(),
|
||||
remoteName: entry.params.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null,
|
||||
};
|
||||
}
|
||||
@@ -162,7 +176,7 @@ const getSignatureFromEntry = (entry: PrStatusEntry | null | undefined): string
|
||||
if (!identity?.directory || !identity.branch) {
|
||||
return null;
|
||||
}
|
||||
return `${identity.directory}::${identity.branch}`;
|
||||
return JSON.stringify([identity.runtimeKey, identity.directory, identity.branch, identity.remoteName ?? 'auto']);
|
||||
};
|
||||
|
||||
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
|
||||
@@ -172,20 +186,35 @@ const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: s
|
||||
};
|
||||
|
||||
const mergeParams = (entry: PrStatusEntry, next: PrRuntimeParams): PrStatusEntry => {
|
||||
const runtimeKey = next.runtimeKey ?? getRuntimeKey();
|
||||
const remoteName = next.remoteName ?? entry.params?.remoteName ?? entry.resolvedRemoteName ?? entry.identity?.remoteName ?? null;
|
||||
const paramsChanged = !entry.params
|
||||
|| entry.params.runtimeKey !== runtimeKey
|
||||
|| entry.params.directory !== next.directory
|
||||
|| entry.params.branch !== next.branch
|
||||
|| entry.params.remoteName !== remoteName
|
||||
|| entry.params.canShow !== next.canShow
|
||||
|| entry.params.github !== next.github
|
||||
|| entry.params.githubAuthChecked !== next.githubAuthChecked
|
||||
|| entry.params.githubConnected !== next.githubConnected;
|
||||
return {
|
||||
...entry,
|
||||
paramsRevision: paramsChanged ? entry.paramsRevision + 1 : entry.paramsRevision,
|
||||
...(paramsChanged ? { isLoading: false, error: null } : {}),
|
||||
params: entry.params
|
||||
? {
|
||||
...entry.params,
|
||||
...next,
|
||||
runtimeKey,
|
||||
remoteName,
|
||||
}
|
||||
: {
|
||||
...next,
|
||||
runtimeKey,
|
||||
remoteName,
|
||||
},
|
||||
identity: {
|
||||
runtimeKey,
|
||||
directory: next.directory,
|
||||
branch: next.branch,
|
||||
remoteName,
|
||||
@@ -256,6 +285,20 @@ const hydrateEntry = (entry: PersistedPrStatusEntry | undefined): PrStatusEntry
|
||||
resolvedRemoteName: entry?.resolvedRemoteName ?? entry?.status?.resolvedRemoteName ?? null,
|
||||
});
|
||||
|
||||
const boundEntries = (entries: Record<string, PrStatusEntry>): Record<string, PrStatusEntry> => {
|
||||
const all = Object.entries(entries);
|
||||
if (all.length <= PR_MAX_ENTRIES) return entries;
|
||||
return Object.fromEntries(all
|
||||
.sort(([, left], [, right]) => {
|
||||
const leftProtected = left.watchers > 0 || left.isLoading;
|
||||
const rightProtected = right.watchers > 0 || right.isLoading;
|
||||
if (leftProtected !== rightProtected) return leftProtected ? -1 : 1;
|
||||
return Math.max(right.lastRefreshAt, right.lastDiscoveryPollAt)
|
||||
- Math.max(left.lastRefreshAt, left.lastDiscoveryPollAt);
|
||||
})
|
||||
.slice(0, PR_MAX_ENTRIES));
|
||||
};
|
||||
|
||||
export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -263,16 +306,36 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
activeRequestCount: 0,
|
||||
totalRequestCount: 0,
|
||||
|
||||
resetForRuntimeSwitch: () => {
|
||||
prRuntimeGeneration += 1;
|
||||
for (const timerId of timers.values()) window.clearInterval(timerId);
|
||||
for (const timerIds of bootstrapTimers.values()) timerIds.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timers.clear();
|
||||
bootstrapTimers.clear();
|
||||
inFlightBySignature.clear();
|
||||
lastRefreshBySignature.clear();
|
||||
set((state) => ({
|
||||
activeRequestCount: 0,
|
||||
entries: Object.fromEntries(Object.entries(state.entries).map(([key, entry]) => [key, {
|
||||
...entry,
|
||||
watchers: 0,
|
||||
isLoading: false,
|
||||
params: null,
|
||||
paramsRevision: entry.paramsRevision + 1,
|
||||
}])),
|
||||
}));
|
||||
},
|
||||
|
||||
ensureEntry: (key) => {
|
||||
set((state) => {
|
||||
if (state.entries[key]) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
entries: {
|
||||
entries: boundEntries({
|
||||
...state.entries,
|
||||
[key]: createEntry(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -369,11 +432,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - entry.lastRefreshAt;
|
||||
const nextInterval = isPendingChecks(entry.status)
|
||||
? PR_OPEN_BUSY_INTERVAL_MS
|
||||
: (entry.status?.checks && entry.status.checks.state !== 'pending'
|
||||
? PR_OPEN_STABLE_INTERVAL_MS
|
||||
: PR_OPEN_DEFAULT_INTERVAL_MS);
|
||||
const nextInterval = getOpenPrRefreshInterval(entry.status);
|
||||
if (elapsed < nextInterval) {
|
||||
return;
|
||||
}
|
||||
@@ -449,8 +508,17 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
inFlightBySignature.add(signature);
|
||||
lastRefreshBySignature.set(signature, Date.now());
|
||||
const requestToken = Symbol(signature);
|
||||
const runtimeGeneration = prRuntimeGeneration;
|
||||
const paramsRevision = entry.paramsRevision;
|
||||
const runtimeKey = entry.params?.runtimeKey ?? entry.identity?.runtimeKey ?? getRuntimeKey();
|
||||
const isCurrent = () => (
|
||||
runtimeGeneration === prRuntimeGeneration
|
||||
&& runtimeKey === getRuntimeKey()
|
||||
&& inFlightBySignature.get(signature) === requestToken
|
||||
&& get().entries[key]?.paramsRevision === paramsRevision
|
||||
);
|
||||
inFlightBySignature.set(signature, requestToken);
|
||||
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
@@ -461,7 +529,6 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
}
|
||||
nextEntries[signatureKey] = {
|
||||
...current,
|
||||
lastRefreshAt: Date.now(),
|
||||
isLoading: options?.silent ? current.isLoading : true,
|
||||
error: null,
|
||||
};
|
||||
@@ -472,6 +539,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
});
|
||||
|
||||
if (params.githubAuthChecked && params.githubConnected === false) {
|
||||
if (!isCurrent()) return;
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
signatureKeys.forEach((signatureKey) => {
|
||||
@@ -491,11 +559,12 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
entries: nextEntries,
|
||||
};
|
||||
});
|
||||
inFlightBySignature.delete(signature);
|
||||
if (inFlightBySignature.get(signature) === requestToken) inFlightBySignature.delete(signature);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params.github?.prStatus) {
|
||||
if (!isCurrent()) return;
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
signatureKeys.forEach((signatureKey) => {
|
||||
@@ -515,7 +584,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
entries: nextEntries,
|
||||
};
|
||||
});
|
||||
inFlightBySignature.delete(signature);
|
||||
if (inFlightBySignature.get(signature) === requestToken) inFlightBySignature.delete(signature);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -528,10 +597,16 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
await acquirePrStatusNetworkSlot();
|
||||
let next: GitHubPullRequestStatus;
|
||||
try {
|
||||
if (!isCurrent()) return;
|
||||
// Failed requests need the same non-forced cooldown as successful
|
||||
// refreshes. Record only work that reaches the network slot so a
|
||||
// stale queued request cannot suppress its replacement.
|
||||
lastRefreshBySignature.set(signature, Date.now());
|
||||
next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force });
|
||||
} finally {
|
||||
releasePrStatusNetworkSlot();
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
signatureKeys.forEach((signatureKey) => {
|
||||
@@ -563,6 +638,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
|
||||
const resolvedRemoteName = status.resolvedRemoteName ?? current.resolvedRemoteName ?? params.remoteName ?? null;
|
||||
const identity = getIdentityFromEntry(current) ?? {
|
||||
runtimeKey,
|
||||
directory: params.directory,
|
||||
branch: params.branch,
|
||||
remoteName: params.remoteName ?? null,
|
||||
@@ -574,6 +650,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
error: null,
|
||||
isLoading: options?.silent ? current.isLoading : false,
|
||||
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
||||
lastRefreshAt: Date.now(),
|
||||
resolvedRemoteName,
|
||||
identity: {
|
||||
...identity,
|
||||
@@ -587,6 +664,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
@@ -607,8 +685,10 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
inFlightBySignature.delete(signature);
|
||||
set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) }));
|
||||
if (inFlightBySignature.get(signature) === requestToken) inFlightBySignature.delete(signature);
|
||||
if (runtimeGeneration === prRuntimeGeneration) {
|
||||
set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -648,6 +728,8 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
{
|
||||
name: PR_STATUS_STORAGE_KEY,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
version: 2,
|
||||
migrate: (persistedState, version) => version < 2 ? { entries: {} } : persistedState,
|
||||
partialize: (state) => ({
|
||||
entries: Object.fromEntries(
|
||||
Object.entries(state.entries)
|
||||
@@ -657,8 +739,11 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
return false;
|
||||
}
|
||||
const freshness = Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt);
|
||||
return freshness === 0 || Date.now() - freshness < PR_PERSIST_TTL_MS;
|
||||
return freshness > 0 && Date.now() - freshness < PR_PERSIST_TTL_MS;
|
||||
})
|
||||
.sort(([, left], [, right]) => Math.max(right.lastRefreshAt, right.lastDiscoveryPollAt)
|
||||
- Math.max(left.lastRefreshAt, left.lastDiscoveryPollAt))
|
||||
.slice(0, PR_MAX_ENTRIES)
|
||||
.map(([key, entry]) => [key, toPersistedEntry(entry)]),
|
||||
),
|
||||
}),
|
||||
@@ -668,7 +753,14 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
||||
return {
|
||||
...current,
|
||||
entries: Object.fromEntries(
|
||||
Object.entries(persistedEntries).map(([key, entry]) => [key, hydrateEntry(entry)]),
|
||||
Object.entries(persistedEntries)
|
||||
.filter(([, entry]) => Boolean(
|
||||
entry.identity?.runtimeKey
|
||||
&& Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt) > 0
|
||||
&& Date.now() - Math.max(entry.lastRefreshAt, entry.lastDiscoveryPollAt) < PR_PERSIST_TTL_MS,
|
||||
))
|
||||
.slice(0, PR_MAX_ENTRIES)
|
||||
.map(([key, entry]) => [key, hydrateEntry(entry)]),
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import { useGitStore } from './useGitStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
@@ -39,7 +40,7 @@ const createDirectoryState = (status: GitStatus): DirectoryGitState => ({
|
||||
identity: null,
|
||||
diffCache: new Map(),
|
||||
indexRevision: 0,
|
||||
lastRepoCheckAt: 0,
|
||||
lastRepoCheckAt: Date.now(),
|
||||
lastStatusFetch: 0,
|
||||
lastStatusChange: 0,
|
||||
lastLogFetch: 0,
|
||||
@@ -70,13 +71,11 @@ const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({
|
||||
|
||||
describe('useGitStore', () => {
|
||||
beforeEach(() => {
|
||||
useGitStore.setState({
|
||||
directories: new Map(),
|
||||
activeDirectory: null,
|
||||
});
|
||||
useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
|
||||
});
|
||||
|
||||
test('does not reuse an in-flight light status request for full status', async () => {
|
||||
setDirectoryStatus(createStatus());
|
||||
const requests: Deferred<GitStatus>[] = [];
|
||||
const statusCalls: Array<{ directory: string; options?: { mode?: 'light' } }> = [];
|
||||
const git = createGitApi((directory, options) => {
|
||||
@@ -95,12 +94,18 @@ describe('useGitStore', () => {
|
||||
{ directory: '/repo', options: undefined },
|
||||
]);
|
||||
|
||||
requests[0].resolve(createStatus());
|
||||
requests[1].resolve(createStatus({ 'src/index.ts': { insertions: 1, deletions: 0 } }));
|
||||
await Promise.all([lightPromise, fullPromise]);
|
||||
await fullPromise;
|
||||
requests[0].resolve(createStatus());
|
||||
await lightPromise;
|
||||
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.diffStats).toEqual({
|
||||
'src/index.ts': { insertions: 1, deletions: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test('reuses an in-flight full status request for light status', async () => {
|
||||
setDirectoryStatus(createStatus());
|
||||
const requests: Deferred<GitStatus>[] = [];
|
||||
const statusCalls: Array<{ directory: string; options?: { mode?: 'light' } }> = [];
|
||||
const git = createGitApi((directory, options) => {
|
||||
@@ -121,6 +126,59 @@ describe('useGitStore', () => {
|
||||
expect(lightResult).toBe(fullResult);
|
||||
});
|
||||
|
||||
test('does not let an older status fetch undo an optimistic mutation', async () => {
|
||||
const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]);
|
||||
setDirectoryStatus(initial);
|
||||
const request = createDeferred<GitStatus>();
|
||||
const git = createGitApi(() => request.promise);
|
||||
|
||||
const loading = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
|
||||
useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
|
||||
request.resolve(initial);
|
||||
await loading;
|
||||
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([
|
||||
{ path: 'src/index.ts', index: 'M', working_dir: ' ' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects an old runtime completion after reset', async () => {
|
||||
setDirectoryStatus(createStatus());
|
||||
const request = createDeferred<GitStatus>();
|
||||
const git = createGitApi(() => request.promise);
|
||||
const loading = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
|
||||
|
||||
useGitStore.getState().resetForRuntimeSwitch('runtime-b');
|
||||
request.resolve(createStatus(undefined, [{ path: 'stale.ts', index: 'M', working_dir: ' ' }]));
|
||||
await loading;
|
||||
|
||||
expect(useGitStore.getState().runtimeKey).toBe('runtime-b');
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.status ?? null).toBe(null);
|
||||
});
|
||||
|
||||
test('rejects direct diff commits captured for another runtime', () => {
|
||||
useGitStore.getState().setDiff('/repo', 'stale.ts', { original: 'a', modified: 'b' }, 'runtime-a');
|
||||
expect(useGitStore.getState().getDiff('/repo', 'stale.ts')).toBe(null);
|
||||
});
|
||||
|
||||
test('keeps the newest branch request when completions are reversed', async () => {
|
||||
const requests = [createDeferred<Awaited<ReturnType<GitAPI['getGitBranches']>>>(), createDeferred<Awaited<ReturnType<GitAPI['getGitBranches']>>>()];
|
||||
let index = 0;
|
||||
const git = {
|
||||
...createGitApi(async () => createStatus()),
|
||||
getGitBranches: () => requests[index++].promise,
|
||||
};
|
||||
const first = useGitStore.getState().fetchBranches('/repo', git);
|
||||
const second = useGitStore.getState().fetchBranches('/repo', git);
|
||||
|
||||
requests[1].resolve({ all: ['new'], current: 'new', branches: {} });
|
||||
await second;
|
||||
requests[0].resolve({ all: ['old'], current: 'old', branches: {} });
|
||||
await first;
|
||||
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.branches?.current).toBe('new');
|
||||
});
|
||||
|
||||
test('optimistically stages modified files and preserves untouched file references', () => {
|
||||
const target = { path: 'src/index.ts', index: ' ', working_dir: 'M' };
|
||||
const untouched = { path: 'README.md', index: ' ', working_dir: 'M' };
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
GitIdentitySummary,
|
||||
} from '@/lib/api/types';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
const LOG_STALE_THRESHOLD = 10000;
|
||||
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
||||
@@ -23,6 +24,7 @@ const DIFF_PREFETCH_LARGE_FILE_THRESHOLD = 500; // skip prefetch for files with
|
||||
// Diff cache limits to prevent memory bloat with many modified files
|
||||
const DIFF_CACHE_MAX_ENTRIES = 30;
|
||||
const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
|
||||
const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200;
|
||||
type GitStatusFetchMode = 'full' | 'light';
|
||||
|
||||
interface DirectoryGitState {
|
||||
@@ -47,7 +49,7 @@ interface DirectoryGitState {
|
||||
}
|
||||
|
||||
interface GitStore {
|
||||
|
||||
runtimeKey: string;
|
||||
directories: Map<string, DirectoryGitState>;
|
||||
|
||||
activeDirectory: string | null;
|
||||
@@ -68,7 +70,7 @@ interface GitStore {
|
||||
bumpIndexRevision: (directory: string) => void;
|
||||
|
||||
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
|
||||
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
|
||||
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }, expectedRuntimeKey?: string) => void;
|
||||
clearDiffCache: (directory: string) => void;
|
||||
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
|
||||
prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => Promise<void>;
|
||||
@@ -76,6 +78,7 @@ interface GitStore {
|
||||
setLogMaxCount: (directory: string, maxCount: number) => void;
|
||||
|
||||
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
||||
resetForRuntimeSwitch: (runtimeKey: string) => void;
|
||||
}
|
||||
|
||||
interface GitFileDiffResponse {
|
||||
@@ -98,25 +101,72 @@ const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
||||
const diffFetchGenerationByDirectory = new Map<string, number>();
|
||||
const inFlightStatusFetches = new Map<string, Promise<boolean>>();
|
||||
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
|
||||
const requestGenerationByChannel = new Map<string, number>();
|
||||
const statusMutationRevisionByDirectory = new Map<string, number>();
|
||||
let gitRuntimeGeneration = 0;
|
||||
let activeGitRuntimeKey = getRuntimeKey();
|
||||
|
||||
const getStatusFetchKey = (directory: string, mode: GitStatusFetchMode): string => `${mode}:${directory}`;
|
||||
const runtimeDirectoryKey = (runtimeKey: string, directory: string) => JSON.stringify([runtimeKey, directory]);
|
||||
const getStatusFetchKey = (runtimeKey: string, directory: string, mode: GitStatusFetchMode): string =>
|
||||
JSON.stringify([runtimeKey, directory, mode]);
|
||||
const channelKey = (runtimeKey: string, directory: string, channel: string) =>
|
||||
JSON.stringify([runtimeKey, directory, channel]);
|
||||
|
||||
type GitRequestToken = {
|
||||
runtimeKey: string;
|
||||
runtimeGeneration: number;
|
||||
channelKey: string;
|
||||
requestGeneration: number;
|
||||
statusMutationRevision?: number;
|
||||
};
|
||||
|
||||
const startRequest = (directory: string, channel: string, includeStatusMutation = false): GitRequestToken => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const key = channelKey(runtimeKey, directory, channel);
|
||||
const requestGeneration = (requestGenerationByChannel.get(key) ?? 0) + 1;
|
||||
requestGenerationByChannel.set(key, requestGeneration);
|
||||
return {
|
||||
runtimeKey,
|
||||
runtimeGeneration: gitRuntimeGeneration,
|
||||
channelKey: key,
|
||||
requestGeneration,
|
||||
...(includeStatusMutation
|
||||
? { statusMutationRevision: statusMutationRevisionByDirectory.get(runtimeDirectoryKey(runtimeKey, directory)) ?? 0 }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const isRequestCurrent = (token: GitRequestToken, directory: string): boolean => (
|
||||
token.runtimeKey === getRuntimeKey()
|
||||
&& token.runtimeKey === activeGitRuntimeKey
|
||||
&& token.runtimeGeneration === gitRuntimeGeneration
|
||||
&& requestGenerationByChannel.get(token.channelKey) === token.requestGeneration
|
||||
&& (token.statusMutationRevision === undefined
|
||||
|| token.statusMutationRevision === (statusMutationRevisionByDirectory.get(runtimeDirectoryKey(token.runtimeKey, directory)) ?? 0))
|
||||
);
|
||||
|
||||
const bumpStatusMutationRevision = (runtimeKey: string, directory: string): void => {
|
||||
const key = runtimeDirectoryKey(runtimeKey, directory);
|
||||
statusMutationRevisionByDirectory.set(key, (statusMutationRevisionByDirectory.get(key) ?? 0) + 1);
|
||||
};
|
||||
|
||||
const getDiffFetchGeneration = (directory: string): number =>
|
||||
diffFetchGenerationByDirectory.get(directory) ?? 0;
|
||||
diffFetchGenerationByDirectory.get(runtimeDirectoryKey(getRuntimeKey(), directory)) ?? 0;
|
||||
|
||||
const bumpDiffFetchGeneration = (directory: string): number => {
|
||||
const next = getDiffFetchGeneration(directory) + 1;
|
||||
diffFetchGenerationByDirectory.set(directory, next);
|
||||
diffFetchGenerationByDirectory.set(runtimeDirectoryKey(getRuntimeKey(), directory), next);
|
||||
return next;
|
||||
};
|
||||
|
||||
const getInFlightDiffs = (directory: string): Set<string> => {
|
||||
const existing = inFlightDiffFetchesByDirectory.get(directory);
|
||||
const key = runtimeDirectoryKey(getRuntimeKey(), directory);
|
||||
const existing = inFlightDiffFetchesByDirectory.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = new Set<string>();
|
||||
inFlightDiffFetchesByDirectory.set(directory, created);
|
||||
inFlightDiffFetchesByDirectory.set(key, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
@@ -152,33 +202,71 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
|
||||
// branch list is cached — never status/log/diff.
|
||||
// ---------------------------------------------------------------------------
|
||||
const GIT_BRANCH_CACHE_KEY = 'oc.gitBranchCache';
|
||||
const GIT_BRANCH_CACHE_V2_KEY = 'oc.gitBranchCache.v2';
|
||||
const MAX_BRANCH_CACHE_RUNTIMES = 8;
|
||||
const MAX_BRANCH_CACHE_DIRECTORIES = 50;
|
||||
type BranchCacheEnvelope = {
|
||||
version: 2;
|
||||
legacyClaimed: boolean;
|
||||
runtimes: Record<string, { updatedAt: number; directories: Record<string, { branches: GitBranch; updatedAt: number }> }>;
|
||||
};
|
||||
|
||||
const readBranchCache = (): Record<string, GitBranch> => {
|
||||
const emptyBranchCache = (): BranchCacheEnvelope => ({ version: 2, legacyClaimed: false, runtimes: {} });
|
||||
|
||||
const readBranchCacheEnvelope = (runtimeKey: string): BranchCacheEnvelope => {
|
||||
try {
|
||||
const raw = getDeferredSafeStorage().getItem(GIT_BRANCH_CACHE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, GitBranch>;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
const storage = getDeferredSafeStorage();
|
||||
const raw = storage.getItem(GIT_BRANCH_CACHE_V2_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) as Partial<BranchCacheEnvelope> : emptyBranchCache();
|
||||
const envelope: BranchCacheEnvelope = parsed?.version === 2 && parsed.runtimes && typeof parsed.runtimes === 'object'
|
||||
? { version: 2, legacyClaimed: Boolean(parsed.legacyClaimed), runtimes: parsed.runtimes }
|
||||
: emptyBranchCache();
|
||||
if (!envelope.legacyClaimed) {
|
||||
const legacyRaw = storage.getItem(GIT_BRANCH_CACHE_KEY);
|
||||
if (legacyRaw) {
|
||||
const legacy = JSON.parse(legacyRaw) as Record<string, GitBranch>;
|
||||
const directories: BranchCacheEnvelope['runtimes'][string]['directories'] = {};
|
||||
for (const [directory, branches] of Object.entries(legacy ?? {})) {
|
||||
if (directory && branches && Array.isArray(branches.all)) directories[directory] = { branches, updatedAt: 0 };
|
||||
}
|
||||
if (Object.keys(directories).length > 0) envelope.runtimes[runtimeKey] = { updatedAt: 0, directories };
|
||||
}
|
||||
envelope.legacyClaimed = true;
|
||||
const serialized = JSON.stringify(envelope);
|
||||
storage.setItem(GIT_BRANCH_CACHE_V2_KEY, serialized);
|
||||
if (storage.getItem(GIT_BRANCH_CACHE_V2_KEY) === serialized) storage.removeItem(GIT_BRANCH_CACHE_KEY);
|
||||
}
|
||||
return envelope;
|
||||
} catch {
|
||||
return {};
|
||||
return emptyBranchCache();
|
||||
}
|
||||
};
|
||||
|
||||
const writeCachedBranches = (directory: string, branches: GitBranch): void => {
|
||||
const writeCachedBranches = (runtimeKey: string, directory: string, branches: GitBranch): void => {
|
||||
if (!directory || !branches) return;
|
||||
try {
|
||||
const cache = readBranchCache();
|
||||
cache[directory] = branches;
|
||||
getDeferredSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache));
|
||||
const envelope = readBranchCacheEnvelope(runtimeKey);
|
||||
const now = Date.now();
|
||||
const current = envelope.runtimes[runtimeKey]?.directories ?? {};
|
||||
const directories = { ...current, [directory]: { branches, updatedAt: now } };
|
||||
const boundedDirectories = Object.fromEntries(
|
||||
Object.entries(directories).sort(([, left], [, right]) => right.updatedAt - left.updatedAt).slice(0, MAX_BRANCH_CACHE_DIRECTORIES),
|
||||
);
|
||||
envelope.runtimes[runtimeKey] = { updatedAt: now, directories: boundedDirectories };
|
||||
envelope.runtimes = Object.fromEntries(
|
||||
Object.entries(envelope.runtimes).sort(([, left], [, right]) => right.updatedAt - left.updatedAt).slice(0, MAX_BRANCH_CACHE_RUNTIMES),
|
||||
);
|
||||
getDeferredSafeStorage().setItem(GIT_BRANCH_CACHE_V2_KEY, JSON.stringify(envelope));
|
||||
} catch {
|
||||
// quota / serialization — ignore; live fetch still refreshes the store
|
||||
}
|
||||
};
|
||||
|
||||
const seedDirectoriesFromBranchCache = (): Map<string, DirectoryGitState> => {
|
||||
const seedDirectoriesFromBranchCache = (runtimeKey: string): Map<string, DirectoryGitState> => {
|
||||
const directories = new Map<string, DirectoryGitState>();
|
||||
const cache = readBranchCache();
|
||||
for (const [directory, branches] of Object.entries(cache)) {
|
||||
const cache = readBranchCacheEnvelope(runtimeKey).runtimes[runtimeKey]?.directories ?? {};
|
||||
for (const [directory, entry] of Object.entries(cache)) {
|
||||
const branches = entry.branches;
|
||||
if (!directory || !branches || !Array.isArray(branches.all)) continue;
|
||||
// A cached branch list implies the directory was a git repo. Seed isGitRepo
|
||||
// so the selector's gate passes immediately; lastBranchesFetch stays 0 so the
|
||||
@@ -197,7 +285,8 @@ const evictDiffCacheIfNeeded = (
|
||||
// Calculate total size
|
||||
let totalSize = 0;
|
||||
for (const entry of diffCache.values()) {
|
||||
totalSize += (entry.original?.length ?? 0) + (entry.modified?.length ?? 0);
|
||||
totalSize += new TextEncoder().encode(entry.original ?? '').byteLength
|
||||
+ new TextEncoder().encode(entry.modified ?? '').byteLength;
|
||||
}
|
||||
|
||||
// If within limits, return as-is
|
||||
@@ -215,10 +304,11 @@ const evictDiffCacheIfNeeded = (
|
||||
// Keep entries from newest to oldest until limits are reached
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const [path, entry] = entries[i];
|
||||
const entrySize = (entry.original?.length ?? 0) + (entry.modified?.length ?? 0);
|
||||
const entrySize = new TextEncoder().encode(entry.original ?? '').byteLength
|
||||
+ new TextEncoder().encode(entry.modified ?? '').byteLength;
|
||||
|
||||
if (newCache.size >= maxEntries) break;
|
||||
if (newTotalSize + entrySize > maxTotalSize && newCache.size > 0) continue;
|
||||
if (newTotalSize + entrySize > maxTotalSize) continue;
|
||||
|
||||
newCache.set(path, entry);
|
||||
newTotalSize += entrySize;
|
||||
@@ -227,6 +317,39 @@ const evictDiffCacheIfNeeded = (
|
||||
return newCache;
|
||||
};
|
||||
|
||||
const diffEntrySize = (entry: { original: string; modified: string }): number => {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(entry.original ?? '').byteLength + encoder.encode(entry.modified ?? '').byteLength;
|
||||
};
|
||||
|
||||
const evictGlobalDiffCachesIfNeeded = (directories: Map<string, DirectoryGitState>): Map<string, DirectoryGitState> => {
|
||||
const entries: Array<{ directory: string; path: string; fetchedAt: number; size: number }> = [];
|
||||
let totalSize = 0;
|
||||
for (const [directory, state] of directories) {
|
||||
for (const [path, entry] of state.diffCache) {
|
||||
const size = diffEntrySize(entry);
|
||||
entries.push({ directory, path, fetchedAt: entry.fetchedAt, size });
|
||||
totalSize += size;
|
||||
}
|
||||
}
|
||||
if (entries.length <= DIFF_CACHE_MAX_GLOBAL_ENTRIES && totalSize <= DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) return directories;
|
||||
|
||||
const next = new Map(directories);
|
||||
entries.sort((left, right) => left.fetchedAt - right.fetchedAt);
|
||||
let count = entries.length;
|
||||
for (const entry of entries) {
|
||||
if (count <= DIFF_CACHE_MAX_GLOBAL_ENTRIES && totalSize <= DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) break;
|
||||
const state = next.get(entry.directory);
|
||||
if (!state?.diffCache.has(entry.path)) continue;
|
||||
const diffCache = new Map(state.diffCache);
|
||||
diffCache.delete(entry.path);
|
||||
next.set(entry.directory, { ...state, diffCache });
|
||||
count -= 1;
|
||||
totalSize -= entry.size;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const haveDiffStatsChanged = (
|
||||
previous?: GitStatus['diffStats'],
|
||||
next?: GitStatus['diffStats']
|
||||
@@ -418,12 +541,27 @@ const toUnstagedStatusFile = (file: GitStatus['files'][number]): GitStatus['file
|
||||
const isCleanStatusFile = (file: GitStatus['files'][number]): boolean =>
|
||||
isBlankStatusCode(file.index) && isBlankStatusCode(file.working_dir);
|
||||
|
||||
const initialGitRuntimeKey = activeGitRuntimeKey;
|
||||
|
||||
export const useGitStore = create<GitStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
directories: seedDirectoriesFromBranchCache(),
|
||||
runtimeKey: initialGitRuntimeKey,
|
||||
directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey),
|
||||
activeDirectory: null,
|
||||
|
||||
resetForRuntimeSwitch: (runtimeKey) => {
|
||||
gitRuntimeGeneration += 1;
|
||||
activeGitRuntimeKey = runtimeKey;
|
||||
requestGenerationByChannel.clear();
|
||||
statusMutationRevisionByDirectory.clear();
|
||||
inFlightStatusFetches.clear();
|
||||
inFlightEnsureAllByDirectory.clear();
|
||||
inFlightDiffFetchesByDirectory.clear();
|
||||
diffFetchGenerationByDirectory.clear();
|
||||
set({ runtimeKey, directories: seedDirectoriesFromBranchCache(runtimeKey), activeDirectory: null });
|
||||
},
|
||||
|
||||
setActiveDirectory: (directory) => {
|
||||
const { activeDirectory, directories } = get();
|
||||
if (activeDirectory === directory) return;
|
||||
@@ -450,13 +588,15 @@ export const useGitStore = create<GitStore>()(
|
||||
|
||||
fetchStatus: async (directory, git, options = {}) => {
|
||||
const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full';
|
||||
const statusFetchKey = getStatusFetchKey(directory, statusFetchMode);
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode);
|
||||
const existing = inFlightStatusFetches.get(statusFetchKey)
|
||||
?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(directory, 'full')) : undefined);
|
||||
?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const token = startRequest(directory, 'status', true);
|
||||
const fetchPromise = (async () => {
|
||||
const { silent = false } = options;
|
||||
const { directories } = get();
|
||||
@@ -484,6 +624,7 @@ export const useGitStore = create<GitStore>()(
|
||||
let isRepo = dirState.isGitRepo === true;
|
||||
if (shouldProbeRepository) {
|
||||
isRepo = await git.checkIsGitRepository(directory);
|
||||
if (!isRequestCurrent(token, directory)) return false;
|
||||
}
|
||||
|
||||
if (!isRepo) {
|
||||
@@ -502,8 +643,10 @@ export const useGitStore = create<GitStore>()(
|
||||
}
|
||||
|
||||
const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined);
|
||||
if (!isRequestCurrent(token, directory)) return false;
|
||||
|
||||
if (hasStatusChanged(dirState.status, newStatus)) {
|
||||
const latestState = get().directories.get(directory) ?? createEmptyDirectoryState();
|
||||
if (hasStatusChanged(latestState.status, newStatus)) {
|
||||
statusChanged = true;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
@@ -573,7 +716,7 @@ export const useGitStore = create<GitStore>()(
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch git status:', error);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
if (!silent && isRequestCurrent(token, directory)) {
|
||||
const newDirectories = new Map(get().directories);
|
||||
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, { ...d, isLoadingStatus: false });
|
||||
@@ -636,6 +779,8 @@ export const useGitStore = create<GitStore>()(
|
||||
return previousStatus;
|
||||
}
|
||||
|
||||
bumpStatusMutationRevision(get().runtimeKey, directory);
|
||||
|
||||
const nextDirectories = new Map(directories);
|
||||
nextDirectories.set(directory, {
|
||||
...dirState,
|
||||
@@ -659,6 +804,8 @@ export const useGitStore = create<GitStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
bumpStatusMutationRevision(get().runtimeKey, directory);
|
||||
|
||||
const nextDirectories = new Map(directories);
|
||||
nextDirectories.set(directory, {
|
||||
...dirState,
|
||||
@@ -676,6 +823,8 @@ export const useGitStore = create<GitStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
bumpStatusMutationRevision(get().runtimeKey, directory);
|
||||
|
||||
const nextDirectories = new Map(directories);
|
||||
nextDirectories.set(directory, {
|
||||
...dirState,
|
||||
@@ -685,6 +834,7 @@ export const useGitStore = create<GitStore>()(
|
||||
},
|
||||
|
||||
fetchBranches: async (directory, git) => {
|
||||
const token = startRequest(directory, 'branches');
|
||||
{
|
||||
const newDirectories = new Map(get().directories);
|
||||
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
@@ -694,13 +844,15 @@ export const useGitStore = create<GitStore>()(
|
||||
|
||||
try {
|
||||
const branches = await git.getGitBranches(directory);
|
||||
if (!isRequestCurrent(token, directory)) return;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, { ...dirState, branches, isLoadingBranches: false, lastBranchesFetch: Date.now() });
|
||||
set({ directories: newDirectories });
|
||||
writeCachedBranches(directory, branches);
|
||||
writeCachedBranches(token.runtimeKey, directory, branches);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch git branches:', error);
|
||||
if (!isRequestCurrent(token, directory)) return;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, { ...d, isLoadingBranches: false });
|
||||
@@ -709,6 +861,7 @@ export const useGitStore = create<GitStore>()(
|
||||
},
|
||||
|
||||
fetchLog: async (directory, git, maxCount) => {
|
||||
const token = startRequest(directory, 'log');
|
||||
const { directories } = get();
|
||||
const dirState = directories.get(directory);
|
||||
const effectiveMaxCount = maxCount ?? dirState?.logMaxCount ?? 25;
|
||||
@@ -722,6 +875,7 @@ export const useGitStore = create<GitStore>()(
|
||||
|
||||
try {
|
||||
const log = await git.getGitLog(directory, { maxCount: effectiveMaxCount });
|
||||
if (!isRequestCurrent(token, directory)) return;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, {
|
||||
@@ -734,6 +888,7 @@ export const useGitStore = create<GitStore>()(
|
||||
set({ directories: newDirectories });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch git log:', error);
|
||||
if (!isRequestCurrent(token, directory)) return;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, { ...d, isLoadingLog: false });
|
||||
@@ -742,6 +897,7 @@ export const useGitStore = create<GitStore>()(
|
||||
},
|
||||
|
||||
fetchIdentity: async (directory, git) => {
|
||||
const token = startRequest(directory, 'identity');
|
||||
{
|
||||
const newDirectories = new Map(get().directories);
|
||||
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
@@ -751,12 +907,14 @@ export const useGitStore = create<GitStore>()(
|
||||
|
||||
try {
|
||||
const identity = await git.getCurrentGitIdentity(directory);
|
||||
if (!isRequestCurrent(token, directory)) return;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, { ...dirState, identity, isLoadingIdentity: false, lastIdentityFetch: Date.now() });
|
||||
set({ directories: newDirectories });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch git identity:', error);
|
||||
if (!isRequestCurrent(token, directory)) return;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const d = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, { ...d, isLoadingIdentity: false });
|
||||
@@ -803,7 +961,9 @@ export const useGitStore = create<GitStore>()(
|
||||
return dirState?.diffCache.get(filePath) ?? null;
|
||||
},
|
||||
|
||||
setDiff: (directory, filePath, diff) => {
|
||||
setDiff: (directory, filePath, diff, expectedRuntimeKey) => {
|
||||
if (expectedRuntimeKey && expectedRuntimeKey !== get().runtimeKey) return;
|
||||
if (diffEntrySize(diff) > DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) return;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
const newDiffCache = new Map(dirState.diffCache);
|
||||
@@ -811,11 +971,12 @@ export const useGitStore = create<GitStore>()(
|
||||
// Apply LRU eviction to prevent memory bloat
|
||||
const evictedCache = evictDiffCacheIfNeeded(newDiffCache);
|
||||
newDirectories.set(directory, { ...dirState, diffCache: evictedCache });
|
||||
set({ directories: newDirectories });
|
||||
set({ directories: evictGlobalDiffCachesIfNeeded(newDirectories) });
|
||||
},
|
||||
|
||||
clearDiffCache: (directory) => {
|
||||
bumpDiffFetchGeneration(directory);
|
||||
startRequest(directory, 'diff');
|
||||
const newDirectories = new Map(get().directories);
|
||||
const dirState = newDirectories.get(directory);
|
||||
if (dirState) {
|
||||
@@ -835,6 +996,7 @@ export const useGitStore = create<GitStore>()(
|
||||
},
|
||||
|
||||
prefetchDiffs: async (directory, git, filePaths, options = {}) => {
|
||||
const token = startRequest(directory, 'diff');
|
||||
const dirState = get().directories.get(directory);
|
||||
if (!dirState?.status?.files || dirState.status.files.length === 0 || filePaths.length === 0) return;
|
||||
|
||||
@@ -901,7 +1063,7 @@ export const useGitStore = create<GitStore>()(
|
||||
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
if (generation !== getDiffFetchGeneration(directory)) {
|
||||
if (generation !== getDiffFetchGeneration(directory) || !isRequestCurrent(token, directory)) {
|
||||
return;
|
||||
}
|
||||
const next = takeNext();
|
||||
@@ -921,7 +1083,7 @@ export const useGitStore = create<GitStore>()(
|
||||
|
||||
limitedFilePaths.forEach((path) => inFlight.delete(path));
|
||||
|
||||
if (generation !== getDiffFetchGeneration(directory)) {
|
||||
if (generation !== getDiffFetchGeneration(directory) || !isRequestCurrent(token, directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -943,7 +1105,7 @@ export const useGitStore = create<GitStore>()(
|
||||
// Apply LRU eviction to prevent memory bloat
|
||||
const evictedCache = evictDiffCacheIfNeeded(newDiffCache);
|
||||
newDirectories.set(directory, { ...currentDirState, diffCache: evictedCache });
|
||||
set({ directories: newDirectories });
|
||||
set({ directories: evictGlobalDiffCachesIfNeeded(newDirectories) });
|
||||
},
|
||||
|
||||
setLogMaxCount: (directory, maxCount) => {
|
||||
@@ -963,7 +1125,8 @@ export const useGitStore = create<GitStore>()(
|
||||
},
|
||||
|
||||
ensureAll: (directory, git) => {
|
||||
const existing = inFlightEnsureAllByDirectory.get(directory);
|
||||
const ensureKey = runtimeDirectoryKey(getRuntimeKey(), directory);
|
||||
const existing = inFlightEnsureAllByDirectory.get(ensureKey);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = (async () => {
|
||||
@@ -993,10 +1156,10 @@ export const useGitStore = create<GitStore>()(
|
||||
if (fetches.length > 0) await Promise.all(fetches);
|
||||
})();
|
||||
|
||||
inFlightEnsureAllByDirectory.set(directory, promise);
|
||||
inFlightEnsureAllByDirectory.set(ensureKey, promise);
|
||||
promise.finally(() => {
|
||||
if (inFlightEnsureAllByDirectory.get(directory) === promise) {
|
||||
inFlightEnsureAllByDirectory.delete(directory);
|
||||
if (inFlightEnsureAllByDirectory.get(ensureKey) === promise) {
|
||||
inFlightEnsureAllByDirectory.delete(ensureKey);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1056,9 +1219,11 @@ export const useGitBranchLabel = (directory: string | null) => {
|
||||
};
|
||||
|
||||
const allBranchesCacheRef = { current: new Map<string, string | null>() };
|
||||
const EMPTY_BRANCHES = new Map<string, string | null>();
|
||||
|
||||
export const useGitAllBranches = () => {
|
||||
export const useGitAllBranches = (enabled = true) => {
|
||||
return useGitStore((state) => {
|
||||
if (!enabled) return EMPTY_BRANCHES;
|
||||
const prev = allBranchesCacheRef.current;
|
||||
let same = prev.size === state.directories.size;
|
||||
if (same) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
const deferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let activeRequest: Deferred<Session[]>
|
||||
let archivedRequest: Deferred<Session[]>
|
||||
|
||||
mock.module("@/lib/opencode/client", () => ({
|
||||
opencodeClient: { getSdkClient: () => ({}), getDirectory: () => "/source", setDirectory: () => undefined },
|
||||
}))
|
||||
mock.module("@/stores/globalSessions", () => ({
|
||||
listGlobalSessionPages: (_sdk: unknown, options: { archived?: boolean }) => (
|
||||
options.archived ? archivedRequest.promise : activeRequest.promise
|
||||
),
|
||||
}))
|
||||
|
||||
const { useGlobalSessionsStore } = await import("./useGlobalSessionsStore")
|
||||
|
||||
const session = (id: string, title = id, archived?: number): Session => ({
|
||||
id,
|
||||
title,
|
||||
time: { created: 1, updated: 1, ...(archived ? { archived } : {}) },
|
||||
} as Session)
|
||||
|
||||
describe("global session mutation reconciliation", () => {
|
||||
beforeEach(() => {
|
||||
activeRequest = deferred<Session[]>()
|
||||
archivedRequest = deferred<Session[]>()
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch()
|
||||
})
|
||||
|
||||
test("keeps a session created after a full load starts", async () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(session("created"))
|
||||
|
||||
activeRequest.resolve([])
|
||||
archivedRequest.resolve([])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"])
|
||||
})
|
||||
|
||||
test("does not resurrect a session deleted after a full load starts", async () => {
|
||||
const stale = session("deleted")
|
||||
useGlobalSessionsStore.getState().applySnapshot([stale], [])
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().removeSessions([stale.id])
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
|
||||
expect(useGlobalSessionsStore.getState().archivedSessions).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps an archive mutation newer than both list requests", async () => {
|
||||
const stale = session("archived")
|
||||
useGlobalSessionsStore.getState().applySnapshot([stale], [])
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().archiveSessions([stale.id], 10)
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
|
||||
expect(useGlobalSessionsStore.getState().archivedSessions[0]?.time.archived).toBe(10)
|
||||
})
|
||||
|
||||
test("keeps a newer title when an older response finishes last", async () => {
|
||||
const stale = session("updated", "Old")
|
||||
useGlobalSessionsStore.getState().applySnapshot([stale], [])
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(session("updated", "New"))
|
||||
|
||||
activeRequest.resolve([stale])
|
||||
archivedRequest.resolve([])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions[0]?.title).toBe("New")
|
||||
})
|
||||
|
||||
test("uses commit-time state when one side of the load fails", async () => {
|
||||
const created = session("created")
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(created)
|
||||
|
||||
activeRequest.reject(new Error("unavailable"))
|
||||
archivedRequest.resolve([])
|
||||
await loading
|
||||
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created])
|
||||
expect(useGlobalSessionsStore.getState().status).toBe("error")
|
||||
})
|
||||
|
||||
test("does not undo a move while refreshing the source directory", async () => {
|
||||
const source = { ...session("moved"), directory: "/source" } as Session
|
||||
const destination = { ...source, directory: "/destination" } as Session
|
||||
useGlobalSessionsStore.getState().applySnapshot([source], [])
|
||||
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
|
||||
useGlobalSessionsStore.getState().upsertSession(destination)
|
||||
|
||||
activeRequest.resolve([source])
|
||||
archivedRequest.resolve([])
|
||||
await refreshing
|
||||
|
||||
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined)
|
||||
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/destination")?.[0]?.id).toBe("moved")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { resolveGlobalSessionDirectory, mergeLiveSessionWithGlobalSession, useGlobalSessionsStore } from './useGlobalSessionsStore';
|
||||
import {
|
||||
isGlobalSessionRecencyOnlyUpdate,
|
||||
resolveGlobalSessionDirectory,
|
||||
mergeLiveSessionWithGlobalSession,
|
||||
useGlobalSessionsStore,
|
||||
} from './useGlobalSessionsStore';
|
||||
|
||||
type SessionExtra = Partial<Session> & {
|
||||
directory?: string | null;
|
||||
@@ -78,6 +83,44 @@ describe('useGlobalSessionsStore', () => {
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]);
|
||||
expect(resolveGlobalSessionDirectory(useGlobalSessionsStore.getState().archivedSessions[0])).toBe('/repo/app');
|
||||
});
|
||||
|
||||
test('preserves the opposite session-list reference during an upsert', () => {
|
||||
const active = buildSession('https://share.example/active');
|
||||
const archived = buildSession('https://share.example/archived', {
|
||||
id: 'ses_archived',
|
||||
time: { created: 1, updated: 2, archived: 3 },
|
||||
});
|
||||
useGlobalSessionsStore.getState().applySnapshot([active], [archived]);
|
||||
|
||||
const archivedSessions = useGlobalSessionsStore.getState().archivedSessions;
|
||||
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/active-updated', {
|
||||
time: { created: 1, updated: 3 },
|
||||
}));
|
||||
expect(useGlobalSessionsStore.getState().archivedSessions).toBe(archivedSessions);
|
||||
|
||||
const activeSessions = useGlobalSessionsStore.getState().activeSessions;
|
||||
useGlobalSessionsStore.getState().upsertSession({
|
||||
...archived,
|
||||
time: { created: 1, updated: 4, archived: 3 },
|
||||
});
|
||||
expect(useGlobalSessionsStore.getState().activeSessions).toBe(activeSessions);
|
||||
});
|
||||
|
||||
test('applies a batch of session upserts in one store publication', () => {
|
||||
let publications = 0;
|
||||
const unsubscribe = useGlobalSessionsStore.subscribe(() => {
|
||||
publications += 1;
|
||||
});
|
||||
|
||||
useGlobalSessionsStore.getState().upsertSessions([
|
||||
buildSession('https://share.example/a'),
|
||||
buildSession('https://share.example/b', { id: 'ses_2' }),
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['ses_2', 'ses_1']);
|
||||
expect(publications).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeLiveSessionWithGlobalSession', () => {
|
||||
@@ -106,3 +149,52 @@ describe('mergeLiveSessionWithGlobalSession', () => {
|
||||
expect(resolveGlobalSessionDirectory(merged)).toBe('/repo/worktree');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGlobalSessionRecencyOnlyUpdate', () => {
|
||||
test('accepts an updated timestamp while preserving omitted directory metadata', () => {
|
||||
const existing = buildSession('https://share.example/s', {
|
||||
directory: '/repo/app',
|
||||
time: { created: 1, updated: 2 },
|
||||
});
|
||||
const incoming = buildSession('https://share.example/s', {
|
||||
time: { created: 1, updated: 3 },
|
||||
});
|
||||
|
||||
expect(isGlobalSessionRecencyOnlyUpdate(existing, incoming)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects title and archive changes as structural updates', () => {
|
||||
const existing = buildSession('https://share.example/s', { time: { created: 1, updated: 2 } });
|
||||
const renamed = buildSession('https://share.example/s', {
|
||||
title: 'Renamed',
|
||||
time: { created: 1, updated: 3 },
|
||||
});
|
||||
const archived = buildSession('https://share.example/s', {
|
||||
time: { created: 1, updated: 3, archived: 4 },
|
||||
});
|
||||
|
||||
expect(isGlobalSessionRecencyOnlyUpdate(existing, renamed)).toBe(false);
|
||||
expect(isGlobalSessionRecencyOnlyUpdate(existing, archived)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects parent and slug changes as structural updates', () => {
|
||||
const existing = buildSession('https://share.example/s', {
|
||||
parentID: 'parent-a',
|
||||
slug: 'slug-a',
|
||||
time: { created: 1, updated: 2 },
|
||||
});
|
||||
const reparented = buildSession('https://share.example/s', {
|
||||
parentID: 'parent-b',
|
||||
slug: 'slug-a',
|
||||
time: { created: 1, updated: 3 },
|
||||
});
|
||||
const reslugged = buildSession('https://share.example/s', {
|
||||
parentID: 'parent-a',
|
||||
slug: 'slug-b',
|
||||
time: { created: 1, updated: 3 },
|
||||
});
|
||||
|
||||
expect(isGlobalSessionRecencyOnlyUpdate(existing, reparented)).toBe(false);
|
||||
expect(isGlobalSessionRecencyOnlyUpdate(existing, reslugged)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listGlobalSessionPages } from '@/stores/globalSessions';
|
||||
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||
|
||||
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
@@ -18,12 +19,15 @@ type GlobalSessionsState = {
|
||||
archivedSessions: Session[];
|
||||
sessionsByDirectory: Map<string, Session[]>;
|
||||
reviewTransferBySessionId: Map<string, ReviewTransferDirection>;
|
||||
mutationRevision: number;
|
||||
mutationRevisionBySessionId: Map<string, number>;
|
||||
hasLoaded: boolean;
|
||||
status: GlobalSessionsStatus;
|
||||
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
|
||||
refreshSessionsForDirectories: (directories: Iterable<string>, fallbackActive?: Session[]) => Promise<LoadResult>;
|
||||
applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void;
|
||||
upsertSession: (session: Session) => void;
|
||||
upsertSessions: (sessions: Session[]) => void;
|
||||
removeSessions: (ids: Iterable<string>) => void;
|
||||
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
|
||||
/** Drop every session from the previous runtime instance and go back to the
|
||||
@@ -32,6 +36,24 @@ type GlobalSessionsState = {
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 500;
|
||||
const DIRECTORY_SESSION_REFRESH_CONCURRENCY = 2;
|
||||
let directorySessionRefreshActive = 0;
|
||||
const directorySessionRefreshWaiters: Array<() => void> = [];
|
||||
|
||||
const withDirectorySessionRefreshSlot = async <T>(task: () => Promise<T>): Promise<T> => {
|
||||
if (directorySessionRefreshActive >= DIRECTORY_SESSION_REFRESH_CONCURRENCY) {
|
||||
await new Promise<void>((resolve) => directorySessionRefreshWaiters.push(resolve));
|
||||
} else {
|
||||
directorySessionRefreshActive += 1;
|
||||
}
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
const next = directorySessionRefreshWaiters.shift();
|
||||
if (next) next();
|
||||
else directorySessionRefreshActive = Math.max(0, directorySessionRefreshActive - 1);
|
||||
}
|
||||
};
|
||||
|
||||
let inflightLoad: Promise<LoadResult> | null = null;
|
||||
// Bumped on runtime switch: an in-flight load from the previous instance must
|
||||
@@ -133,6 +155,27 @@ const getSessionSignature = (session: Session): string => {
|
||||
].join(':');
|
||||
};
|
||||
|
||||
export const getSessionStructuralSignature = (session: Session): string => {
|
||||
const record = session as Session & { parentID?: string | null; slug?: string | null };
|
||||
return [
|
||||
session.id,
|
||||
session.title ?? '',
|
||||
record.parentID ?? '',
|
||||
record.slug ?? '',
|
||||
session.time?.created ?? 0,
|
||||
session.time?.archived ?? 0,
|
||||
session.share?.url ?? '',
|
||||
JSON.stringify((session as Session & { metadata?: unknown }).metadata ?? null),
|
||||
resolveGlobalSessionDirectory(session) ?? '',
|
||||
].join(':');
|
||||
};
|
||||
|
||||
export const isGlobalSessionRecencyOnlyUpdate = (existing: Session, incoming: Session): boolean => {
|
||||
const merged = mergeSessionDirectoryMetadata(incoming, existing);
|
||||
return existing.time?.updated !== merged.time?.updated
|
||||
&& getSessionStructuralSignature(existing) === getSessionStructuralSignature(merged);
|
||||
};
|
||||
|
||||
const sameSessionList = (prev: Session[], next: Session[]): boolean => {
|
||||
if (prev === next) {
|
||||
return true;
|
||||
@@ -211,12 +254,27 @@ const fetchDirectoryPages = async (
|
||||
directories: Set<string>,
|
||||
archived: boolean,
|
||||
): Promise<DirectoryPageResult> => {
|
||||
const results = await Promise.allSettled(
|
||||
[...directories].map(async (directory) => ({
|
||||
directory,
|
||||
sessions: await listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE }),
|
||||
})),
|
||||
);
|
||||
const currentDirectory = normalizePath(opencodeClient.getDirectory());
|
||||
const orderedDirectories = [...directories].sort((left, right) => {
|
||||
if (left === currentDirectory) return -1;
|
||||
if (right === currentDirectory) return 1;
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
const results = await mapWithConcurrency(orderedDirectories, DIRECTORY_SESSION_REFRESH_CONCURRENCY, async (directory) => {
|
||||
try {
|
||||
return {
|
||||
status: 'fulfilled' as const,
|
||||
value: {
|
||||
directory,
|
||||
sessions: await withDirectorySessionRefreshSlot(() => (
|
||||
listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE })
|
||||
)),
|
||||
},
|
||||
};
|
||||
} catch (reason) {
|
||||
return { status: 'rejected' as const, reason };
|
||||
}
|
||||
});
|
||||
|
||||
const fulfilledDirectories = new Set<string>();
|
||||
const sessions: Session[] = [];
|
||||
@@ -248,6 +306,14 @@ const upsertSessionIntoList = (sessions: Session[], session: Session): Session[]
|
||||
return next;
|
||||
};
|
||||
|
||||
const removeSessionFromList = (sessions: Session[], sessionId: string): Session[] => {
|
||||
const index = sessions.findIndex((session) => session.id === sessionId);
|
||||
if (index === -1) {
|
||||
return sessions;
|
||||
}
|
||||
return [...sessions.slice(0, index), ...sessions.slice(index + 1)];
|
||||
};
|
||||
|
||||
const mergeSessionLists = (existing: Session[], incoming?: Session[]): Session[] => {
|
||||
if (!incoming || incoming.length === 0) {
|
||||
return existing;
|
||||
@@ -328,6 +394,77 @@ const applySnapshot = (
|
||||
};
|
||||
};
|
||||
|
||||
const overlayMutationsSince = (
|
||||
state: GlobalSessionsState,
|
||||
activeSessions: Session[],
|
||||
archivedSessions: Session[],
|
||||
baselineRevision: number,
|
||||
): LoadResult => {
|
||||
const affectedIds = new Set<string>();
|
||||
for (const [sessionId, revision] of state.mutationRevisionBySessionId) {
|
||||
if (revision > baselineRevision) affectedIds.add(sessionId);
|
||||
}
|
||||
if (affectedIds.size === 0) return { activeSessions, archivedSessions };
|
||||
|
||||
const currentActive = new Map(state.activeSessions.map((session) => [session.id, session]));
|
||||
const currentArchived = new Map(state.archivedSessions.map((session) => [session.id, session]));
|
||||
let nextActive = activeSessions.filter((session) => !affectedIds.has(session.id));
|
||||
let nextArchived = archivedSessions.filter((session) => !affectedIds.has(session.id));
|
||||
for (const sessionId of affectedIds) {
|
||||
const active = currentActive.get(sessionId);
|
||||
const archived = currentArchived.get(sessionId);
|
||||
if (active) nextActive = upsertSessionIntoList(nextActive, active);
|
||||
else if (archived) nextArchived = upsertSessionIntoList(nextArchived, archived);
|
||||
}
|
||||
return { activeSessions: nextActive, archivedSessions: nextArchived };
|
||||
};
|
||||
|
||||
const mutationRevisionPatch = (state: GlobalSessionsState, ids: Iterable<string>) => {
|
||||
const mutationRevision = state.mutationRevision + 1;
|
||||
const mutationRevisionBySessionId = new Map(state.mutationRevisionBySessionId);
|
||||
for (const id of ids) mutationRevisionBySessionId.set(id, mutationRevision);
|
||||
return { mutationRevision, mutationRevisionBySessionId };
|
||||
};
|
||||
|
||||
const applySessionUpserts = (state: GlobalSessionsState, sessions: Session[]): Partial<GlobalSessionsState> => {
|
||||
const revisionPatch = mutationRevisionPatch(state, sessions.map((session) => session.id));
|
||||
let nextActiveSessions = state.activeSessions;
|
||||
let nextArchivedSessions = state.archivedSessions;
|
||||
|
||||
for (const session of sessions) {
|
||||
const existingSession = nextActiveSessions.find((candidate) => candidate.id === session.id)
|
||||
?? nextArchivedSessions.find((candidate) => candidate.id === session.id)
|
||||
?? null;
|
||||
const sessionWithMetadata = mergeSessionDirectoryMetadata(session, existingSession);
|
||||
const isArchived = Boolean(sessionWithMetadata.time?.archived);
|
||||
nextActiveSessions = isArchived
|
||||
? removeSessionFromList(nextActiveSessions, session.id)
|
||||
: upsertSessionIntoList(nextActiveSessions, sessionWithMetadata);
|
||||
nextArchivedSessions = isArchived
|
||||
? upsertSessionIntoList(nextArchivedSessions, sessionWithMetadata)
|
||||
: removeSessionFromList(nextArchivedSessions, session.id);
|
||||
}
|
||||
|
||||
if (
|
||||
nextActiveSessions === state.activeSessions
|
||||
&& nextArchivedSessions === state.archivedSessions
|
||||
) {
|
||||
return revisionPatch;
|
||||
}
|
||||
|
||||
return {
|
||||
activeSessions: nextActiveSessions,
|
||||
archivedSessions: nextArchivedSessions,
|
||||
sessionsByDirectory: nextActiveSessions === state.activeSessions
|
||||
? state.sessionsByDirectory
|
||||
: buildSessionsByDirectory(nextActiveSessions),
|
||||
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
|
||||
? state.reviewTransferBySessionId
|
||||
: buildReviewTransferMap(nextActiveSessions),
|
||||
...revisionPatch,
|
||||
};
|
||||
};
|
||||
|
||||
const buildReviewTransferMap = (sessions: Session[]): Map<string, ReviewTransferDirection> => {
|
||||
const next = new Map<string, ReviewTransferDirection>()
|
||||
const activeIds = new Set(sessions.map((s) => s.id))
|
||||
@@ -348,6 +485,8 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
archivedSessions: [],
|
||||
sessionsByDirectory: new Map(),
|
||||
reviewTransferBySessionId: new Map(),
|
||||
mutationRevision: 0,
|
||||
mutationRevisionBySessionId: new Map(),
|
||||
hasLoaded: false,
|
||||
status: 'idle',
|
||||
|
||||
@@ -363,6 +502,8 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
archivedSessions: [],
|
||||
sessionsByDirectory: new Map(),
|
||||
reviewTransferBySessionId: new Map(),
|
||||
mutationRevision: 0,
|
||||
mutationRevisionBySessionId: new Map(),
|
||||
hasLoaded: false,
|
||||
status: 'idle',
|
||||
});
|
||||
@@ -376,9 +517,8 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
set((state) => (state.status === 'loading' ? state : { status: 'loading' }));
|
||||
|
||||
const generation = loadGeneration;
|
||||
inflightLoad = (async () => {
|
||||
const current = get();
|
||||
|
||||
const baselineRevision = get().mutationRevision;
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const sdk = opencodeClient.getSdkClient();
|
||||
const [activeResult, archivedResult] = await Promise.allSettled([
|
||||
@@ -386,14 +526,6 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }),
|
||||
]);
|
||||
|
||||
const fallbackSnapshot = mergeSessionLists(current.activeSessions, fallbackActive);
|
||||
const nextActiveSessions = activeResult.status === 'fulfilled'
|
||||
? activeResult.value
|
||||
: fallbackSnapshot;
|
||||
const nextArchivedSessions = archivedResult.status === 'fulfilled'
|
||||
? archivedResult.value
|
||||
: current.archivedSessions;
|
||||
|
||||
if (activeResult.status === 'rejected') {
|
||||
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason);
|
||||
}
|
||||
@@ -409,23 +541,45 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled'
|
||||
? 'ready'
|
||||
: 'error';
|
||||
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, status));
|
||||
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
|
||||
set((state) => {
|
||||
const fetchedActive = activeResult.status === 'fulfilled'
|
||||
? activeResult.value
|
||||
: mergeSessionLists(state.activeSessions, fallbackActive);
|
||||
const fetchedArchived = archivedResult.status === 'fulfilled'
|
||||
? archivedResult.value
|
||||
: state.archivedSessions;
|
||||
const reconciled = overlayMutationsSince(state, fetchedActive, fetchedArchived, baselineRevision);
|
||||
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, status);
|
||||
});
|
||||
const committed = get();
|
||||
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
|
||||
} catch (error) {
|
||||
if (generation !== loadGeneration) {
|
||||
return { activeSessions: [], archivedSessions: [] };
|
||||
}
|
||||
const nextActiveSessions = mergeSessionLists(current.activeSessions, fallbackActive);
|
||||
const nextArchivedSessions = current.archivedSessions;
|
||||
console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error);
|
||||
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'error'));
|
||||
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
set((state) => {
|
||||
const reconciled = overlayMutationsSince(
|
||||
state,
|
||||
mergeSessionLists(state.activeSessions, fallbackActive),
|
||||
state.archivedSessions,
|
||||
baselineRevision,
|
||||
);
|
||||
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'error');
|
||||
});
|
||||
const committed = get();
|
||||
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
|
||||
}
|
||||
})();
|
||||
|
||||
return inflightLoad;
|
||||
inflightLoad = loadPromise;
|
||||
const clearInflightLoad = () => {
|
||||
if (inflightLoad === loadPromise) {
|
||||
inflightLoad = null;
|
||||
}
|
||||
};
|
||||
void loadPromise.then(clearInflightLoad, clearInflightLoad);
|
||||
return loadPromise;
|
||||
},
|
||||
|
||||
refreshSessionsForDirectories: async (directories, fallbackActive) => {
|
||||
@@ -435,12 +589,19 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
||||
}
|
||||
|
||||
const generation = loadGeneration;
|
||||
const baselineRevision = get().mutationRevision;
|
||||
const sdk = opencodeClient.getSdkClient();
|
||||
const [active, archived] = await Promise.all([
|
||||
fetchDirectoryPages(sdk, directorySet, false),
|
||||
fetchDirectoryPages(sdk, directorySet, true),
|
||||
]);
|
||||
|
||||
if (generation !== loadGeneration) {
|
||||
const state = get();
|
||||
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
||||
}
|
||||
|
||||
if (active.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]);
|
||||
}
|
||||
@@ -460,6 +621,10 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
nextArchivedSessions = state.archivedSessions;
|
||||
}
|
||||
|
||||
const reconciled = overlayMutationsSince(state, nextActiveSessions, nextArchivedSessions, baselineRevision);
|
||||
nextActiveSessions = reconciled.activeSessions;
|
||||
nextArchivedSessions = reconciled.archivedSessions;
|
||||
|
||||
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
|
||||
? state.sessionsByDirectory
|
||||
: buildSessionsByDirectory(nextActiveSessions);
|
||||
@@ -487,37 +652,12 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
},
|
||||
|
||||
upsertSession: (session) => {
|
||||
set((state) => {
|
||||
const existingSession = state.activeSessions.find((candidate) => candidate.id === session.id)
|
||||
?? state.archivedSessions.find((candidate) => candidate.id === session.id)
|
||||
?? null;
|
||||
const sessionWithMetadata = mergeSessionDirectoryMetadata(session, existingSession);
|
||||
const isArchived = Boolean(sessionWithMetadata.time?.archived);
|
||||
const nextActiveSessions = isArchived
|
||||
? state.activeSessions.filter((candidate) => candidate.id !== session.id)
|
||||
: upsertSessionIntoList(state.activeSessions, sessionWithMetadata);
|
||||
const nextArchivedSessions = isArchived
|
||||
? upsertSessionIntoList(state.archivedSessions, sessionWithMetadata)
|
||||
: state.archivedSessions.filter((candidate) => candidate.id !== session.id);
|
||||
set((state) => applySessionUpserts(state, [session]));
|
||||
},
|
||||
|
||||
if (
|
||||
nextActiveSessions === state.activeSessions
|
||||
&& nextArchivedSessions === state.archivedSessions
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
activeSessions: nextActiveSessions,
|
||||
archivedSessions: nextArchivedSessions,
|
||||
sessionsByDirectory: nextActiveSessions === state.activeSessions
|
||||
? state.sessionsByDirectory
|
||||
: buildSessionsByDirectory(nextActiveSessions),
|
||||
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
|
||||
? state.reviewTransferBySessionId
|
||||
: buildReviewTransferMap(nextActiveSessions),
|
||||
};
|
||||
});
|
||||
upsertSessions: (sessions) => {
|
||||
if (sessions.length === 0) return;
|
||||
set((state) => applySessionUpserts(state, sessions));
|
||||
},
|
||||
|
||||
removeSessions: (ids) => {
|
||||
@@ -527,6 +667,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const revisionPatch = mutationRevisionPatch(state, idSet);
|
||||
const nextActiveSessions = state.activeSessions.filter((session) => !idSet.has(session.id));
|
||||
const nextArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id));
|
||||
|
||||
@@ -534,7 +675,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
nextActiveSessions.length === state.activeSessions.length
|
||||
&& nextArchivedSessions.length === state.archivedSessions.length
|
||||
) {
|
||||
return state;
|
||||
return revisionPatch;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -542,6 +683,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
archivedSessions: nextArchivedSessions,
|
||||
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
|
||||
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
|
||||
...revisionPatch,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -553,6 +695,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const revisionPatch = mutationRevisionPatch(state, idSet);
|
||||
const movedSessions: Session[] = [];
|
||||
const nextActiveSessions = state.activeSessions.filter((session) => {
|
||||
if (!idSet.has(session.id)) {
|
||||
@@ -570,7 +713,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
});
|
||||
|
||||
if (movedSessions.length === 0) {
|
||||
return state;
|
||||
return revisionPatch;
|
||||
}
|
||||
|
||||
const remainingArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id));
|
||||
@@ -580,6 +723,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
archivedSessions: [...movedSessions, ...remainingArchivedSessions],
|
||||
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
|
||||
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
|
||||
...revisionPatch,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { useInlineCommentDraftStore } from './useInlineCommentDraftStore';
|
||||
|
||||
const selection = {
|
||||
sessionKey: 'session-1',
|
||||
source: 'terminal' as const,
|
||||
fileLabel: 'Terminal 1',
|
||||
startLine: 4,
|
||||
@@ -11,32 +10,72 @@ const selection = {
|
||||
language: 'term-1',
|
||||
text: '',
|
||||
};
|
||||
const target = { directory: '/repo', sessionKey: 'session-1' };
|
||||
|
||||
describe('terminal context drafts', () => {
|
||||
afterEach(() => { useInlineCommentDraftStore.setState({ drafts: {} }); });
|
||||
afterEach(() => { useInlineCommentDraftStore.setState({ drafts: {}, touchedAt: {} }); });
|
||||
|
||||
test('persists snapshots by chat session and deduplicates identical selections', () => {
|
||||
useInlineCommentDraftStore.getState().addDraft(selection);
|
||||
useInlineCommentDraftStore.getState().addDraft(selection);
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts('session-1');
|
||||
useInlineCommentDraftStore.getState().addDraft(target, selection);
|
||||
useInlineCommentDraftStore.getState().addDraft(target, selection);
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(target);
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect({ ...drafts[0], id: undefined, createdAt: undefined }).toEqual({ ...selection, id: undefined, createdAt: undefined });
|
||||
expect({ ...drafts[0], id: undefined, createdAt: undefined }).toEqual({ ...selection, sessionKey: 'session-1', id: undefined, createdAt: undefined });
|
||||
});
|
||||
|
||||
test('supports individual removal and ordered consume', () => {
|
||||
useInlineCommentDraftStore.getState().addDraft(selection);
|
||||
useInlineCommentDraftStore.getState().addDraft({ ...selection, startLine: 8, endLine: 8, code: 'third' });
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts('session-1');
|
||||
useInlineCommentDraftStore.getState().removeDraft('session-1', drafts[0].id);
|
||||
expect(useInlineCommentDraftStore.getState().consumeDrafts('session-1')).toHaveLength(1);
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts('session-1')).toEqual([]);
|
||||
useInlineCommentDraftStore.getState().addDraft(target, selection);
|
||||
useInlineCommentDraftStore.getState().addDraft(target, { ...selection, startLine: 8, endLine: 8, code: 'third' });
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(target);
|
||||
useInlineCommentDraftStore.getState().removeDraft(target, drafts[0].id);
|
||||
expect(useInlineCommentDraftStore.getState().consumeDrafts(target)).toHaveLength(1);
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts(target)).toEqual([]);
|
||||
});
|
||||
|
||||
test('restores consumed drafts after a failed send without duplicating them', () => {
|
||||
useInlineCommentDraftStore.getState().addDraft(selection);
|
||||
const consumed = useInlineCommentDraftStore.getState().consumeDrafts('session-1');
|
||||
useInlineCommentDraftStore.getState().restoreDrafts('session-1', consumed);
|
||||
useInlineCommentDraftStore.getState().restoreDrafts('session-1', consumed);
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts('session-1')).toEqual(consumed);
|
||||
useInlineCommentDraftStore.getState().addDraft(target, selection);
|
||||
const consumed = useInlineCommentDraftStore.getState().consumeDrafts(target);
|
||||
useInlineCommentDraftStore.getState().restoreDrafts(target, consumed);
|
||||
useInlineCommentDraftStore.getState().restoreDrafts(target, consumed);
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts(target)).toEqual(consumed);
|
||||
});
|
||||
|
||||
test('isolates identical session IDs by normalized directory', () => {
|
||||
const otherTarget = { directory: '/other', sessionKey: 'session-1' };
|
||||
useInlineCommentDraftStore.getState().addDraft(target, selection);
|
||||
useInlineCommentDraftStore.getState().addDraft(otherTarget, { ...selection, code: 'other' });
|
||||
|
||||
useInlineCommentDraftStore.getState().clearDrafts({ ...target, directory: '/repo/' });
|
||||
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts(target)).toEqual([]);
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts(otherTarget)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('returns a stable empty snapshot for absent draft buckets', () => {
|
||||
const first = useInlineCommentDraftStore.getState().getDrafts(target);
|
||||
const second = useInlineCommentDraftStore.getState().getDrafts(target);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
test('updates one draft without serializing the complete envelope on the mutation path', () => {
|
||||
useInlineCommentDraftStore.getState().addDraft(target, selection);
|
||||
const draft = useInlineCommentDraftStore.getState().getDrafts(target)[0];
|
||||
const originalStringify = JSON.stringify;
|
||||
let envelopeSerializations = 0;
|
||||
JSON.stringify = ((value: unknown, ...rest: unknown[]) => {
|
||||
if (value && typeof value === 'object' && 'drafts' in value && 'touchedAt' in value) {
|
||||
envelopeSerializations += 1;
|
||||
}
|
||||
return originalStringify(value, ...(rest as [Parameters<typeof JSON.stringify>[1], Parameters<typeof JSON.stringify>[2]]));
|
||||
}) as typeof JSON.stringify;
|
||||
|
||||
try {
|
||||
useInlineCommentDraftStore.getState().updateDraft(target, draft.id, { text: 'edited' });
|
||||
expect(envelopeSerializations).toBe(0);
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts(target)[0]?.text).toBe('edited');
|
||||
} finally {
|
||||
JSON.stringify = originalStringify;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,105 +1,175 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation' | 'terminal';
|
||||
|
||||
export type InlineCommentDraftTarget = {
|
||||
directory: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
export interface InlineCommentDraft {
|
||||
id: string;
|
||||
sessionKey: string; // sessionId or 'draft' for new sessions
|
||||
sessionKey: string;
|
||||
source: InlineCommentSource;
|
||||
fileLabel: string; // filename or 'plan'
|
||||
fileLabel: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
side?: 'original' | 'modified'; // diff only
|
||||
side?: 'original' | 'modified';
|
||||
code: string;
|
||||
language: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export const EMPTY_INLINE_COMMENT_DRAFTS: InlineCommentDraft[] = [];
|
||||
|
||||
interface InlineCommentDraftState {
|
||||
drafts: Record<string, InlineCommentDraft[]>; // sessionKey -> drafts
|
||||
drafts: Record<string, InlineCommentDraft[]>;
|
||||
touchedAt: Record<string, number>;
|
||||
}
|
||||
|
||||
interface InlineCommentDraftActions {
|
||||
addDraft: (draft: Omit<InlineCommentDraft, 'id' | 'createdAt'>) => void;
|
||||
updateDraft: (sessionKey: string, draftId: string, updates: Partial<Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>>) => void;
|
||||
removeDraft: (sessionKey: string, draftId: string) => void;
|
||||
clearDrafts: (sessionKey: string) => void;
|
||||
getDrafts: (sessionKey: string) => InlineCommentDraft[];
|
||||
consumeDrafts: (sessionKey: string) => InlineCommentDraft[];
|
||||
restoreDrafts: (sessionKey: string, drafts: InlineCommentDraft[]) => void;
|
||||
getDraftCount: (sessionKey: string) => number;
|
||||
hasDrafts: (sessionKey: string) => boolean;
|
||||
addDraft: (target: InlineCommentDraftTarget, draft: Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>) => string | null;
|
||||
updateDraft: (target: InlineCommentDraftTarget, draftId: string, updates: Partial<Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>>) => void;
|
||||
removeDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
|
||||
clearDrafts: (target: InlineCommentDraftTarget) => void;
|
||||
getDrafts: (target: InlineCommentDraftTarget) => InlineCommentDraft[];
|
||||
consumeDrafts: (target: InlineCommentDraftTarget) => InlineCommentDraft[];
|
||||
restoreDrafts: (target: InlineCommentDraftTarget, drafts: InlineCommentDraft[]) => void;
|
||||
getDraftCount: (target: InlineCommentDraftTarget) => number;
|
||||
hasDrafts: (target: InlineCommentDraftTarget) => boolean;
|
||||
clearSessionDrafts: (runtimeKey: string, directory: string, sessionId: string) => void;
|
||||
}
|
||||
|
||||
type InlineCommentDraftStore = InlineCommentDraftState & InlineCommentDraftActions;
|
||||
|
||||
const isValidSource = (value: unknown): value is InlineCommentSource =>
|
||||
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation' || value === 'terminal';
|
||||
const MAX_SESSIONS = 50;
|
||||
const MAX_DRAFTS_PER_SESSION = 20;
|
||||
const MAX_PERSISTED_BYTES = 1024 * 1024;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const isValidSide = (value: unknown): value is 'original' | 'modified' =>
|
||||
value === 'original' || value === 'modified';
|
||||
|
||||
const toPositiveLine = (value: unknown): number | null => {
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(1, Math.floor(parsed));
|
||||
type SerializedSizeIndex = {
|
||||
draftEntries: Map<string, number>;
|
||||
touchedEntries: Map<string, number>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
const sanitizeDraft = (input: unknown): InlineCommentDraft | null => {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
const draft = input as Partial<InlineCommentDraft>;
|
||||
const serializedSizeByDrafts = new WeakMap<Record<string, InlineCommentDraft[]>, SerializedSizeIndex>();
|
||||
const EMPTY_ENVELOPE_BYTES = encoder.encode(JSON.stringify({ drafts: {}, touchedAt: {} })).byteLength;
|
||||
|
||||
if (typeof draft.sessionKey !== 'string' || draft.sessionKey.trim().length === 0) return null;
|
||||
if (!isValidSource(draft.source)) return null;
|
||||
|
||||
const startLine = toPositiveLine(draft.startLine);
|
||||
const endLine = toPositiveLine(draft.endLine);
|
||||
if (!startLine || !endLine) return null;
|
||||
|
||||
const id = typeof draft.id === 'string' && draft.id.trim().length > 0
|
||||
? draft.id
|
||||
: `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const code = typeof draft.code === 'string' ? draft.code : '';
|
||||
if (draft.source === 'terminal' && !code.trim()) return null;
|
||||
return {
|
||||
id,
|
||||
sessionKey: draft.sessionKey,
|
||||
source: draft.source,
|
||||
fileLabel: typeof draft.fileLabel === 'string' ? draft.fileLabel : 'unknown',
|
||||
startLine,
|
||||
endLine,
|
||||
side: isValidSide(draft.side) ? draft.side : undefined,
|
||||
code,
|
||||
language: typeof draft.language === 'string' ? draft.language : 'text',
|
||||
text: typeof draft.text === 'string' ? draft.text : '',
|
||||
createdAt: Number.isFinite(draft.createdAt) ? Number(draft.createdAt) : Date.now(),
|
||||
};
|
||||
export const getInlineCommentDraftKey = (runtimeKey: string, directory: string, sessionKey: string): string | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!runtimeKey || !normalizedDirectory || !sessionKey) return null;
|
||||
return JSON.stringify([runtimeKey, normalizedDirectory, sessionKey]);
|
||||
};
|
||||
|
||||
const sanitizeDraftMap = (input: unknown): Record<string, InlineCommentDraft[]> => {
|
||||
if (!input || typeof input !== 'object') return {};
|
||||
const getCurrentKey = (target: InlineCommentDraftTarget): string | null =>
|
||||
getInlineCommentDraftKey(getRuntimeKey(), target.directory, target.sessionKey);
|
||||
|
||||
const entries = Object.entries(input as Record<string, unknown>);
|
||||
const result: Record<string, InlineCommentDraft[]> = {};
|
||||
const serializedEntryBytes = (key: string, value: unknown): number =>
|
||||
encoder.encode(`${JSON.stringify(key)}:${JSON.stringify(value)}`).byteLength;
|
||||
|
||||
for (const [sessionKey, sessionDrafts] of entries) {
|
||||
if (!Array.isArray(sessionDrafts)) continue;
|
||||
const sumEntryBytes = (entries: Map<string, number>): number => {
|
||||
let total = 0;
|
||||
for (const bytes of entries.values()) total += bytes;
|
||||
return total;
|
||||
};
|
||||
|
||||
const sanitized = sessionDrafts
|
||||
.map(sanitizeDraft)
|
||||
.filter((draft): draft is InlineCommentDraft => Boolean(draft))
|
||||
.filter((draft) => draft.sessionKey === sessionKey);
|
||||
const indexedTotal = (draftEntries: Map<string, number>, touchedEntries: Map<string, number>): number => (
|
||||
EMPTY_ENVELOPE_BYTES
|
||||
+ sumEntryBytes(draftEntries)
|
||||
+ Math.max(0, draftEntries.size - 1)
|
||||
+ sumEntryBytes(touchedEntries)
|
||||
+ Math.max(0, touchedEntries.size - 1)
|
||||
);
|
||||
|
||||
if (sanitized.length > 0) {
|
||||
result[sessionKey] = sanitized;
|
||||
const buildSerializedSizeIndex = (
|
||||
drafts: Record<string, InlineCommentDraft[]>,
|
||||
touchedAt: Record<string, number>,
|
||||
): SerializedSizeIndex => {
|
||||
const draftEntries = new Map<string, number>();
|
||||
const touchedEntries = new Map<string, number>();
|
||||
for (const [key, value] of Object.entries(drafts)) draftEntries.set(key, serializedEntryBytes(key, value));
|
||||
for (const [key, value] of Object.entries(touchedAt)) touchedEntries.set(key, serializedEntryBytes(key, value));
|
||||
const index = { draftEntries, touchedEntries, total: indexedTotal(draftEntries, touchedEntries) };
|
||||
serializedSizeByDrafts.set(drafts, index);
|
||||
return index;
|
||||
};
|
||||
|
||||
const updateSerializedSizeIndex = (
|
||||
previous: InlineCommentDraftState,
|
||||
drafts: Record<string, InlineCommentDraft[]>,
|
||||
touchedAt: Record<string, number>,
|
||||
changedKey: string,
|
||||
): SerializedSizeIndex => {
|
||||
const previousIndex = serializedSizeByDrafts.get(previous.drafts)
|
||||
?? buildSerializedSizeIndex(previous.drafts, previous.touchedAt);
|
||||
const draftEntries = new Map(previousIndex.draftEntries);
|
||||
const touchedEntries = new Map(previousIndex.touchedEntries);
|
||||
const bucket = drafts[changedKey];
|
||||
if (bucket) draftEntries.set(changedKey, serializedEntryBytes(changedKey, bucket));
|
||||
else draftEntries.delete(changedKey);
|
||||
const touched = touchedAt[changedKey];
|
||||
if (typeof touched === 'number') touchedEntries.set(changedKey, serializedEntryBytes(changedKey, touched));
|
||||
else touchedEntries.delete(changedKey);
|
||||
return { draftEntries, touchedEntries, total: indexedTotal(draftEntries, touchedEntries) };
|
||||
};
|
||||
|
||||
const boundState = (
|
||||
previous: InlineCommentDraftState,
|
||||
drafts: Record<string, InlineCommentDraft[]>,
|
||||
touchedAt: Record<string, number>,
|
||||
changedKey: string,
|
||||
): { drafts: Record<string, InlineCommentDraft[]>; touchedAt: Record<string, number> } | null => {
|
||||
const keys = Object.keys(drafts).sort((left, right) => (touchedAt[right] ?? 0) - (touchedAt[left] ?? 0));
|
||||
const retainedKeys = keys.slice(0, MAX_SESSIONS);
|
||||
const retainedDrafts: Record<string, InlineCommentDraft[]> = {};
|
||||
const retainedTouchedAt: Record<string, number> = {};
|
||||
for (const key of retainedKeys) {
|
||||
retainedDrafts[key] = drafts[key].length > MAX_DRAFTS_PER_SESSION
|
||||
? drafts[key].slice(-MAX_DRAFTS_PER_SESSION)
|
||||
: drafts[key];
|
||||
retainedTouchedAt[key] = touchedAt[key] ?? Date.now();
|
||||
}
|
||||
const sizeIndex = updateSerializedSizeIndex(previous, retainedDrafts, retainedTouchedAt, changedKey);
|
||||
for (const key of keys) {
|
||||
if (!(key in retainedDrafts)) {
|
||||
sizeIndex.draftEntries.delete(key);
|
||||
sizeIndex.touchedEntries.delete(key);
|
||||
} else if (retainedDrafts[key] !== drafts[key]) {
|
||||
sizeIndex.draftEntries.set(key, serializedEntryBytes(key, retainedDrafts[key]));
|
||||
}
|
||||
if (key !== changedKey && retainedTouchedAt[key] !== previous.touchedAt[key]) {
|
||||
sizeIndex.touchedEntries.set(key, serializedEntryBytes(key, retainedTouchedAt[key]));
|
||||
}
|
||||
}
|
||||
sizeIndex.total = indexedTotal(sizeIndex.draftEntries, sizeIndex.touchedEntries);
|
||||
const evictionKeys = [...retainedKeys];
|
||||
while (evictionKeys.length > 0 && sizeIndex.total > MAX_PERSISTED_BYTES) {
|
||||
const oldest = evictionKeys.pop()!;
|
||||
delete retainedDrafts[oldest];
|
||||
delete retainedTouchedAt[oldest];
|
||||
sizeIndex.draftEntries.delete(oldest);
|
||||
sizeIndex.touchedEntries.delete(oldest);
|
||||
sizeIndex.total = indexedTotal(sizeIndex.draftEntries, sizeIndex.touchedEntries);
|
||||
}
|
||||
if (sizeIndex.draftEntries.size === 0 && keys.length > 0) return null;
|
||||
serializedSizeByDrafts.set(retainedDrafts, sizeIndex);
|
||||
return { drafts: retainedDrafts, touchedAt: retainedTouchedAt };
|
||||
};
|
||||
|
||||
return result;
|
||||
const removeDraftKey = (state: InlineCommentDraftState, key: string): InlineCommentDraftState => {
|
||||
if (!(key in state.drafts)) return state;
|
||||
|
||||
const drafts = { ...state.drafts };
|
||||
const touchedAt = { ...state.touchedAt };
|
||||
delete drafts[key];
|
||||
delete touchedAt[key];
|
||||
return { drafts, touchedAt };
|
||||
};
|
||||
|
||||
export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
||||
@@ -107,143 +177,115 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
|
||||
addDraft: (draft) => {
|
||||
if (draft.source === 'terminal' && !draft.code.trim()) return;
|
||||
touchedAt: {},
|
||||
addDraft: (target, draft) => {
|
||||
const key = getCurrentKey(target);
|
||||
if (!key || (draft.source === 'terminal' && !draft.code.trim())) return null;
|
||||
const id = `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const newDraft: InlineCommentDraft = {
|
||||
...draft,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const nextDraft: InlineCommentDraft = { ...draft, sessionKey: target.sessionKey, id, createdAt: Date.now() };
|
||||
let accepted = false;
|
||||
set((state) => {
|
||||
const currentDrafts = state.drafts[draft.sessionKey] ?? [];
|
||||
if (draft.source === 'terminal' && currentDrafts.some((current) => current.source === 'terminal' && current.fileLabel === draft.fileLabel && current.startLine === draft.startLine && current.endLine === draft.endLine && current.code === draft.code)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[draft.sessionKey]: [...currentDrafts, newDraft],
|
||||
},
|
||||
};
|
||||
const current = state.drafts[key] ?? [];
|
||||
const isDuplicateTerminalDraft = draft.source === 'terminal' && current.some((item) => (
|
||||
item.source === 'terminal'
|
||||
&& item.fileLabel === draft.fileLabel
|
||||
&& item.startLine === draft.startLine
|
||||
&& item.endLine === draft.endLine
|
||||
&& item.code === draft.code
|
||||
));
|
||||
if (isDuplicateTerminalDraft) return state;
|
||||
|
||||
const bounded = boundState(
|
||||
state,
|
||||
{ ...state.drafts, [key]: [...current, nextDraft] },
|
||||
{ ...state.touchedAt, [key]: Date.now() },
|
||||
key,
|
||||
);
|
||||
if (!bounded || !bounded.drafts[key]?.some((item) => item.id === id)) return state;
|
||||
accepted = true;
|
||||
return bounded;
|
||||
});
|
||||
|
||||
return id;
|
||||
return accepted ? id : null;
|
||||
},
|
||||
|
||||
updateDraft: (sessionKey, draftId, updates) => {
|
||||
updateDraft: (target, draftId, updates) => {
|
||||
const key = getCurrentKey(target);
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const currentDrafts = state.drafts[sessionKey] ?? [];
|
||||
const newDrafts = currentDrafts.map((draft) => {
|
||||
if (draft.id !== draftId) {
|
||||
return draft;
|
||||
}
|
||||
return {
|
||||
...draft,
|
||||
...updates,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[sessionKey]: newDrafts,
|
||||
},
|
||||
};
|
||||
const current = state.drafts[key] ?? [];
|
||||
if (!current.some((draft) => draft.id === draftId)) return state;
|
||||
const bounded = boundState(
|
||||
state,
|
||||
{ ...state.drafts, [key]: current.map((draft) => draft.id === draftId ? { ...draft, ...updates } : draft) },
|
||||
{ ...state.touchedAt, [key]: Date.now() },
|
||||
key,
|
||||
);
|
||||
return bounded ?? state;
|
||||
});
|
||||
},
|
||||
|
||||
removeDraft: (sessionKey, draftId) => {
|
||||
removeDraft: (target, draftId) => {
|
||||
const key = getCurrentKey(target);
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const currentDrafts = state.drafts[sessionKey] ?? [];
|
||||
const newDrafts = currentDrafts.filter((d) => d.id !== draftId);
|
||||
const current = state.drafts[key] ?? [];
|
||||
const remaining = current.filter((draft) => draft.id !== draftId);
|
||||
if (remaining.length === current.length) return state;
|
||||
if (remaining.length === 0) return removeDraftKey(state, key);
|
||||
|
||||
if (newDrafts.length === 0) {
|
||||
const { [sessionKey]: _removed, ...rest } = state.drafts;
|
||||
void _removed;
|
||||
return { drafts: rest };
|
||||
}
|
||||
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[sessionKey]: newDrafts,
|
||||
},
|
||||
};
|
||||
const drafts = { ...state.drafts };
|
||||
const touchedAt = { ...state.touchedAt };
|
||||
drafts[key] = remaining;
|
||||
touchedAt[key] = Date.now();
|
||||
return { drafts, touchedAt };
|
||||
});
|
||||
},
|
||||
|
||||
clearDrafts: (sessionKey) => {
|
||||
set((state) => {
|
||||
const { [sessionKey]: _removed, ...rest } = state.drafts;
|
||||
void _removed;
|
||||
return { drafts: rest };
|
||||
});
|
||||
clearDrafts: (target) => {
|
||||
const key = getCurrentKey(target);
|
||||
if (!key) return;
|
||||
set((state) => removeDraftKey(state, key));
|
||||
},
|
||||
|
||||
getDrafts: (sessionKey) => {
|
||||
return get().drafts[sessionKey] ?? [];
|
||||
getDrafts: (target) => {
|
||||
const key = getCurrentKey(target);
|
||||
return key ? get().drafts[key] ?? EMPTY_INLINE_COMMENT_DRAFTS : EMPTY_INLINE_COMMENT_DRAFTS;
|
||||
},
|
||||
|
||||
consumeDrafts: (sessionKey) => {
|
||||
const drafts = get().drafts[sessionKey] ?? [];
|
||||
if (drafts.length === 0) return [];
|
||||
|
||||
// Sort by creation time to maintain order
|
||||
const sortedDrafts = [...drafts].sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
// Clear drafts after consuming
|
||||
set((state) => {
|
||||
const { [sessionKey]: _removed, ...rest } = state.drafts;
|
||||
void _removed;
|
||||
return { drafts: rest };
|
||||
});
|
||||
|
||||
return sortedDrafts;
|
||||
consumeDrafts: (target) => {
|
||||
const key = getCurrentKey(target);
|
||||
if (!key) return [];
|
||||
const drafts = [...(get().drafts[key] ?? [])].sort((left, right) => left.createdAt - right.createdAt);
|
||||
if (drafts.length > 0) set((state) => removeDraftKey(state, key));
|
||||
return drafts;
|
||||
},
|
||||
|
||||
restoreDrafts: (sessionKey, drafts) => {
|
||||
if (drafts.length === 0) return;
|
||||
restoreDrafts: (target, draftsToRestore) => {
|
||||
const key = getCurrentKey(target);
|
||||
if (!key || draftsToRestore.length === 0) return;
|
||||
set((state) => {
|
||||
const current = state.drafts[sessionKey] ?? [];
|
||||
const current = state.drafts[key] ?? [];
|
||||
const currentIds = new Set(current.map((draft) => draft.id));
|
||||
const restored = drafts.filter((draft) => draft.sessionKey === sessionKey && !currentIds.has(draft.id));
|
||||
const restored = draftsToRestore.filter((draft) => draft.sessionKey === target.sessionKey && !currentIds.has(draft.id));
|
||||
if (restored.length === 0) return state;
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[sessionKey]: [...restored, ...current].sort((a, b) => a.createdAt - b.createdAt),
|
||||
},
|
||||
};
|
||||
return boundState(
|
||||
state,
|
||||
{ ...state.drafts, [key]: [...restored, ...current].sort((left, right) => left.createdAt - right.createdAt) },
|
||||
{ ...state.touchedAt, [key]: Date.now() },
|
||||
key,
|
||||
) ?? state;
|
||||
});
|
||||
},
|
||||
|
||||
getDraftCount: (sessionKey) => {
|
||||
return (get().drafts[sessionKey] ?? []).length;
|
||||
},
|
||||
|
||||
hasDrafts: (sessionKey) => {
|
||||
return (get().drafts[sessionKey] ?? []).length > 0;
|
||||
getDraftCount: (target) => get().getDrafts(target).length,
|
||||
hasDrafts: (target) => get().getDrafts(target).length > 0,
|
||||
clearSessionDrafts: (runtimeKey, directory, sessionId) => {
|
||||
const key = getInlineCommentDraftKey(runtimeKey, directory, sessionId);
|
||||
if (!key) return;
|
||||
set((state) => removeDraftKey(state, key));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'openchamber-inline-comment-drafts',
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
version: 1,
|
||||
migrate: (persistedState: unknown) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return { drafts: {} };
|
||||
}
|
||||
|
||||
const state = persistedState as { drafts?: unknown };
|
||||
return {
|
||||
drafts: sanitizeDraftMap(state.drafts),
|
||||
};
|
||||
},
|
||||
}
|
||||
version: 2,
|
||||
partialize: (state) => ({ drafts: state.drafts, touchedAt: state.touchedAt }),
|
||||
migrate: () => ({ drafts: {}, touchedAt: {} }),
|
||||
},
|
||||
),
|
||||
{ name: 'inline-comment-draft-store' }
|
||||
)
|
||||
{ name: 'inline-comment-draft-store' },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ProjectEntry } from "@/lib/api/types"
|
||||
import type { DesktopSettings } from "@/lib/desktop"
|
||||
import { useProjectsStore } from "./useProjectsStore"
|
||||
|
||||
describe("useProjectsStore settings synchronization", () => {
|
||||
test("treats a successful empty project snapshot as authoritative", () => {
|
||||
const project = { id: "project-a", path: "/repo", label: "Repo" } as ProjectEntry
|
||||
useProjectsStore.setState({
|
||||
projects: [project],
|
||||
activeProjectId: project.id,
|
||||
manualProjectOrder: [project.id],
|
||||
})
|
||||
|
||||
useProjectsStore.getState().synchronizeFromSettings({ projects: [] } as DesktopSettings)
|
||||
|
||||
expect(useProjectsStore.getState().projects).toEqual([])
|
||||
expect(useProjectsStore.getState().activeProjectId).toBe(null)
|
||||
expect(useProjectsStore.getState().manualProjectOrder).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -916,25 +916,6 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
|
||||
const current = get();
|
||||
|
||||
// Race guard: settings load can return empty projects during app
|
||||
// rebuild/reinstall or an incomplete settings read. Don't clobber
|
||||
// a populated cache with empty — the sidebar would go blank and
|
||||
// localStorage would be overwritten, losing the list entirely.
|
||||
if (incomingProjects.length === 0 && current.projects.length > 0) {
|
||||
if (incomingActive !== current.activeProjectId) {
|
||||
// Active project may still be valid within the cached list.
|
||||
const activeExists = incomingActive
|
||||
? current.projects.some((project) => project.id === incomingActive)
|
||||
: true;
|
||||
if (activeExists) {
|
||||
set({ activeProjectId: incomingActive });
|
||||
cacheProjects(current.projects, incomingActive);
|
||||
persistManualProjectOrder(get().manualProjectOrder);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects);
|
||||
const activeChanged = current.activeProjectId !== incomingActive;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
let storageSetCount = 0;
|
||||
let runtimeKey = 'runtime-a';
|
||||
let diskResponseBody: Record<string, unknown> = { version: 1, exists: false };
|
||||
|
||||
const safeStorage = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
@@ -31,8 +33,9 @@ mock.module('@/lib/desktop', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: mock(async () => new Response('{}', { headers: { 'Content-Type': 'application/json' } })),
|
||||
runtimeFetch: mock(async () => new Response(JSON.stringify(diskResponseBody), { headers: { 'Content-Type': 'application/json' } })),
|
||||
}));
|
||||
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => runtimeKey }));
|
||||
|
||||
const { useSessionFoldersStore } = await import('./useSessionFoldersStore');
|
||||
|
||||
@@ -42,6 +45,9 @@ describe('useSessionFoldersStore folder assignments', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
storageSetCount = 0;
|
||||
runtimeKey = 'runtime-a';
|
||||
diskResponseBody = { version: 1, exists: false };
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
useSessionFoldersStore.setState({
|
||||
foldersMap: {},
|
||||
collapsedFolderIds: new Set<string>(),
|
||||
@@ -77,4 +83,54 @@ describe('useSessionFoldersStore folder assignments', () => {
|
||||
expect(useSessionFoldersStore.getState().foldersMap).toBe(before);
|
||||
expect(storageSetCount).toBe(0);
|
||||
});
|
||||
|
||||
test('restores independent folder snapshots across runtime switches', async () => {
|
||||
useSessionFoldersStore.getState().createFolder('/workspace/project', 'Runtime A');
|
||||
await waitForPersist();
|
||||
|
||||
runtimeKey = 'runtime-b';
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project')).toEqual([]);
|
||||
useSessionFoldersStore.getState().createFolder('/workspace/project', 'Runtime B');
|
||||
await waitForPersist();
|
||||
|
||||
runtimeKey = 'runtime-a';
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project').map((folder) => folder.name)).toEqual(['Runtime A']);
|
||||
});
|
||||
|
||||
test('flushes the outgoing runtime before a debounced browser write can be lost', () => {
|
||||
useSessionFoldersStore.getState().createFolder('/workspace/project', 'Runtime A pending');
|
||||
|
||||
runtimeKey = 'runtime-b';
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
runtimeKey = 'runtime-a';
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
|
||||
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project').map((folder) => folder.name)).toEqual(['Runtime A pending']);
|
||||
});
|
||||
|
||||
test('does not replace browser folders when the server has no disk snapshot', async () => {
|
||||
useSessionFoldersStore.getState().createFolder('/workspace/project', 'Browser folder');
|
||||
runtimeKey = 'runtime-b';
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
runtimeKey = 'runtime-a';
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project').map((folder) => folder.name)).toEqual(['Browser folder']);
|
||||
});
|
||||
|
||||
test('does not silently evict folder state from older runtimes', () => {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
runtimeKey = `runtime-${index}`;
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
useSessionFoldersStore.getState().createFolder('/workspace/project', `Folder ${index}`);
|
||||
}
|
||||
|
||||
runtimeKey = 'runtime-0';
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project').map((folder) => folder.name)).toEqual(['Folder 0']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { getDeferredSafeStorage } from './utils/safeStorage';
|
||||
import { getDeferredSafeStorage, getSafeStorage } from './utils/safeStorage';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -30,10 +31,11 @@ interface SessionFoldersActions {
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
||||
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
|
||||
removeSessionEverywhere: (runtimeKey: string, sessionId: string) => void;
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
toggleFolderCollapse: (folderId: string) => void;
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
|
||||
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
|
||||
resetForRuntimeSwitch: (runtimeKey: string) => void;
|
||||
}
|
||||
|
||||
type SessionFoldersStore = SessionFoldersState & SessionFoldersActions;
|
||||
@@ -42,11 +44,12 @@ type SessionFoldersStore = SessionFoldersState & SessionFoldersActions;
|
||||
|
||||
const FOLDERS_STORAGE_KEY = 'oc.sessions.folders';
|
||||
const COLLAPSED_STORAGE_KEY = 'oc.sessions.folderCollapse';
|
||||
const STORAGE_INDEX_KEY = 'oc.sessions.folders.v2.index';
|
||||
const SESSION_FOLDERS_API_PATH = '/api/session-folders';
|
||||
const DISK_WRITE_DEBOUNCE_MS = 250;
|
||||
const ARCHIVED_SCOPE_PREFIX = '__archived__:';
|
||||
|
||||
const safeStorage = getDeferredSafeStorage();
|
||||
const immediateSafeStorage = getSafeStorage();
|
||||
let diskWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let diskHydrated = false;
|
||||
let diskHydrationInFlight = false;
|
||||
@@ -54,6 +57,54 @@ let persistFoldersTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistCollapsedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingFoldersMap: SessionFoldersMap | null = null;
|
||||
let pendingCollapsedIds: Set<string> | null = null;
|
||||
let pendingBrowserRuntimeKey: string | null = null;
|
||||
let activeFolderRuntimeKey = getRuntimeKey();
|
||||
let folderRuntimeGeneration = 0;
|
||||
let folderMutationRevision = 0;
|
||||
const lastDiskUpdatedAtByRuntime = new Map<string, number>();
|
||||
|
||||
type FolderStorageIndex = {
|
||||
version: 2;
|
||||
legacyClaimed: boolean;
|
||||
runtimes: Array<{ runtimeKey: string; updatedAt: number }>;
|
||||
};
|
||||
|
||||
const runtimeStorageKey = (base: string, runtimeKey: string) => `${base}.v2:${encodeURIComponent(runtimeKey)}`;
|
||||
const readStorageIndex = (): FolderStorageIndex => {
|
||||
try {
|
||||
const parsed = JSON.parse(safeStorage.getItem(STORAGE_INDEX_KEY) ?? '') as Partial<FolderStorageIndex>;
|
||||
return parsed.version === 2 && Array.isArray(parsed.runtimes)
|
||||
? { version: 2, legacyClaimed: Boolean(parsed.legacyClaimed), runtimes: parsed.runtimes }
|
||||
: { version: 2, legacyClaimed: false, runtimes: [] };
|
||||
} catch {
|
||||
return { version: 2, legacyClaimed: false, runtimes: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const touchRuntimeStorage = (runtimeKey: string, updatedAt = Date.now(), targetStorage: Storage = safeStorage): void => {
|
||||
const index = readStorageIndex();
|
||||
const runtimes = [
|
||||
{ runtimeKey, updatedAt },
|
||||
...index.runtimes.filter((entry) => entry.runtimeKey !== runtimeKey),
|
||||
];
|
||||
targetStorage.setItem(STORAGE_INDEX_KEY, JSON.stringify({ version: 2, legacyClaimed: index.legacyClaimed, runtimes }));
|
||||
};
|
||||
|
||||
const claimLegacyStorage = (runtimeKey: string): void => {
|
||||
const index = readStorageIndex();
|
||||
if (index.legacyClaimed) return;
|
||||
const legacyFolders = safeStorage.getItem(FOLDERS_STORAGE_KEY);
|
||||
const legacyCollapsed = safeStorage.getItem(COLLAPSED_STORAGE_KEY);
|
||||
if (legacyFolders) safeStorage.setItem(runtimeStorageKey(FOLDERS_STORAGE_KEY, runtimeKey), legacyFolders);
|
||||
if (legacyCollapsed) safeStorage.setItem(runtimeStorageKey(COLLAPSED_STORAGE_KEY, runtimeKey), legacyCollapsed);
|
||||
const next = { ...index, legacyClaimed: true };
|
||||
safeStorage.setItem(STORAGE_INDEX_KEY, JSON.stringify(next));
|
||||
if (safeStorage.getItem(STORAGE_INDEX_KEY) === JSON.stringify(next)) {
|
||||
safeStorage.removeItem(FOLDERS_STORAGE_KEY);
|
||||
safeStorage.removeItem(COLLAPSED_STORAGE_KEY);
|
||||
}
|
||||
touchRuntimeStorage(runtimeKey, 0);
|
||||
};
|
||||
|
||||
const isVSCodeWebview = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -82,14 +133,19 @@ const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds
|
||||
|
||||
const foldersSnapshot = JSON.parse(JSON.stringify(foldersMap)) as SessionFoldersMap;
|
||||
const collapsedSnapshot = Array.from(collapsedFolderIds);
|
||||
const runtimeKey = activeFolderRuntimeKey;
|
||||
const generation = folderRuntimeGeneration;
|
||||
|
||||
diskWriteTimer = setTimeout(() => {
|
||||
diskWriteTimer = null;
|
||||
if (runtimeKey !== getRuntimeKey() || generation !== folderRuntimeGeneration) return;
|
||||
const updatedAt = Math.max(Date.now(), (lastDiskUpdatedAtByRuntime.get(runtimeKey) ?? 0) + 1);
|
||||
lastDiskUpdatedAtByRuntime.set(runtimeKey, updatedAt);
|
||||
const payload = {
|
||||
version: 1,
|
||||
foldersMap: foldersSnapshot,
|
||||
collapsedFolderIds: collapsedSnapshot,
|
||||
updatedAt: Date.now(),
|
||||
updatedAt,
|
||||
};
|
||||
void runtimeFetch(SESSION_FOLDERS_API_PATH, {
|
||||
method: 'POST',
|
||||
@@ -99,9 +155,10 @@ const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds
|
||||
}, DISK_WRITE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const readPersistedFolders = (): SessionFoldersMap => {
|
||||
const readPersistedFolders = (runtimeKey = activeFolderRuntimeKey): SessionFoldersMap => {
|
||||
try {
|
||||
const raw = safeStorage.getItem(FOLDERS_STORAGE_KEY);
|
||||
claimLegacyStorage(runtimeKey);
|
||||
const raw = safeStorage.getItem(runtimeStorageKey(FOLDERS_STORAGE_KEY, runtimeKey));
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
@@ -138,9 +195,10 @@ const readPersistedFolders = (): SessionFoldersMap => {
|
||||
}
|
||||
};
|
||||
|
||||
const readPersistedCollapsed = (): Set<string> => {
|
||||
const readPersistedCollapsed = (runtimeKey = activeFolderRuntimeKey): Set<string> => {
|
||||
try {
|
||||
const raw = safeStorage.getItem(COLLAPSED_STORAGE_KEY);
|
||||
claimLegacyStorage(runtimeKey);
|
||||
const raw = safeStorage.getItem(runtimeStorageKey(COLLAPSED_STORAGE_KEY, runtimeKey));
|
||||
if (!raw) {
|
||||
return new Set();
|
||||
}
|
||||
@@ -156,10 +214,13 @@ const readPersistedCollapsed = (): Set<string> => {
|
||||
|
||||
const persistFolders = (foldersMap: SessionFoldersMap): void => {
|
||||
pendingFoldersMap = foldersMap;
|
||||
pendingBrowserRuntimeKey = activeFolderRuntimeKey;
|
||||
clearTimeout(persistFoldersTimer);
|
||||
persistFoldersTimer = setTimeout(() => {
|
||||
try {
|
||||
safeStorage.setItem(FOLDERS_STORAGE_KEY, JSON.stringify(foldersMap));
|
||||
const runtimeKey = pendingBrowserRuntimeKey ?? activeFolderRuntimeKey;
|
||||
safeStorage.setItem(runtimeStorageKey(FOLDERS_STORAGE_KEY, runtimeKey), JSON.stringify(foldersMap));
|
||||
touchRuntimeStorage(runtimeKey);
|
||||
pendingFoldersMap = null;
|
||||
} catch {
|
||||
// ignored
|
||||
@@ -169,10 +230,13 @@ const persistFolders = (foldersMap: SessionFoldersMap): void => {
|
||||
|
||||
const persistCollapsed = (collapsedFolderIds: Set<string>): void => {
|
||||
pendingCollapsedIds = collapsedFolderIds;
|
||||
pendingBrowserRuntimeKey = activeFolderRuntimeKey;
|
||||
clearTimeout(persistCollapsedTimer);
|
||||
persistCollapsedTimer = setTimeout(() => {
|
||||
try {
|
||||
safeStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(Array.from(collapsedFolderIds)));
|
||||
const runtimeKey = pendingBrowserRuntimeKey ?? activeFolderRuntimeKey;
|
||||
safeStorage.setItem(runtimeStorageKey(COLLAPSED_STORAGE_KEY, runtimeKey), JSON.stringify(Array.from(collapsedFolderIds)));
|
||||
touchRuntimeStorage(runtimeKey);
|
||||
pendingCollapsedIds = null;
|
||||
} catch {
|
||||
// ignored
|
||||
@@ -180,26 +244,54 @@ const persistCollapsed = (collapsedFolderIds: Set<string>): void => {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const flushPendingBrowserPersistence = (): void => {
|
||||
if (persistFoldersTimer) clearTimeout(persistFoldersTimer);
|
||||
if (persistCollapsedTimer) clearTimeout(persistCollapsedTimer);
|
||||
persistFoldersTimer = undefined;
|
||||
persistCollapsedTimer = undefined;
|
||||
|
||||
const runtimeKey = pendingBrowserRuntimeKey ?? activeFolderRuntimeKey;
|
||||
let wrote = false;
|
||||
if (pendingFoldersMap !== null) {
|
||||
const key = runtimeStorageKey(FOLDERS_STORAGE_KEY, runtimeKey);
|
||||
const value = JSON.stringify(pendingFoldersMap);
|
||||
safeStorage.setItem(key, value);
|
||||
immediateSafeStorage.setItem(key, value);
|
||||
pendingFoldersMap = null;
|
||||
wrote = true;
|
||||
}
|
||||
if (pendingCollapsedIds !== null) {
|
||||
const key = runtimeStorageKey(COLLAPSED_STORAGE_KEY, runtimeKey);
|
||||
const value = JSON.stringify(Array.from(pendingCollapsedIds));
|
||||
safeStorage.setItem(key, value);
|
||||
immediateSafeStorage.setItem(key, value);
|
||||
pendingCollapsedIds = null;
|
||||
wrote = true;
|
||||
}
|
||||
if (wrote) {
|
||||
const updatedAt = Date.now();
|
||||
touchRuntimeStorage(runtimeKey, updatedAt);
|
||||
touchRuntimeStorage(runtimeKey, updatedAt, immediateSafeStorage);
|
||||
}
|
||||
pendingBrowserRuntimeKey = null;
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (pendingFoldersMap !== null) {
|
||||
clearTimeout(persistFoldersTimer);
|
||||
try {
|
||||
safeStorage.setItem(FOLDERS_STORAGE_KEY, JSON.stringify(pendingFoldersMap));
|
||||
} catch { /* ignored */ }
|
||||
pendingFoldersMap = null;
|
||||
}
|
||||
if (pendingCollapsedIds !== null) {
|
||||
clearTimeout(persistCollapsedTimer);
|
||||
try {
|
||||
safeStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(Array.from(pendingCollapsedIds)));
|
||||
} catch { /* ignored */ }
|
||||
pendingCollapsedIds = null;
|
||||
}
|
||||
});
|
||||
const flushPending = () => {
|
||||
try { flushPendingBrowserPersistence(); } catch { /* ignored */ }
|
||||
};
|
||||
window.addEventListener('pagehide', flushPending, { capture: true });
|
||||
window.addEventListener('beforeunload', flushPending, { capture: true });
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flushPending();
|
||||
});
|
||||
document.addEventListener('freeze', flushPending);
|
||||
}
|
||||
}
|
||||
|
||||
const persistState = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set<string>): void => {
|
||||
folderMutationRevision += 1;
|
||||
persistFolders(foldersMap);
|
||||
persistCollapsed(collapsedFolderIds);
|
||||
schedulePersistToDisk(foldersMap, collapsedFolderIds);
|
||||
@@ -232,14 +324,6 @@ const syncCollapsedAfterFolderCleanup = (
|
||||
return nextCollapsed;
|
||||
};
|
||||
|
||||
const pruneEmptyArchivedFolders = (scopeKey: string, folders: SessionFolder[]): SessionFolder[] => {
|
||||
if (!scopeKey.startsWith(ARCHIVED_SCOPE_PREFIX)) {
|
||||
return folders;
|
||||
}
|
||||
|
||||
return folders.filter((folder) => folder.sessionIds.length > 0);
|
||||
};
|
||||
|
||||
// --- Store ---
|
||||
|
||||
export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
@@ -248,6 +332,22 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
foldersMap: readPersistedFolders(),
|
||||
collapsedFolderIds: readPersistedCollapsed(),
|
||||
|
||||
resetForRuntimeSwitch: (runtimeKey: string): void => {
|
||||
try { flushPendingBrowserPersistence(); } catch { /* deferred storage retains failed writes */ }
|
||||
activeFolderRuntimeKey = runtimeKey;
|
||||
folderRuntimeGeneration += 1;
|
||||
folderMutationRevision = 0;
|
||||
diskHydrated = false;
|
||||
diskHydrationInFlight = false;
|
||||
if (diskWriteTimer) clearTimeout(diskWriteTimer);
|
||||
diskWriteTimer = null;
|
||||
set({
|
||||
foldersMap: readPersistedFolders(runtimeKey),
|
||||
collapsedFolderIds: readPersistedCollapsed(runtimeKey),
|
||||
});
|
||||
queueMicrotask(() => void hydrateSessionFoldersFromDisk());
|
||||
},
|
||||
|
||||
getFoldersForScope: (scopeKey: string): SessionFolder[] => {
|
||||
if (!scopeKey) return [];
|
||||
return get().foldersMap[scopeKey] ?? [];
|
||||
@@ -306,18 +406,12 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
}
|
||||
const nextFolders = scopeFolders.filter((folder) => !idsToDelete.has(folder.id));
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
||||
set({ foldersMap: nextMap });
|
||||
persistState(nextMap, get().collapsedFolderIds);
|
||||
|
||||
// Clean up collapsed state for all deleted folders
|
||||
const collapsed = get().collapsedFolderIds;
|
||||
const hasStale = Array.from(idsToDelete).some((id) => collapsed.has(id));
|
||||
if (hasStale) {
|
||||
const nextCollapsed = new Set(collapsed);
|
||||
idsToDelete.forEach((id) => nextCollapsed.delete(id));
|
||||
set({ collapsedFolderIds: nextCollapsed });
|
||||
persistState(nextMap, nextCollapsed);
|
||||
}
|
||||
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, collapsed);
|
||||
set(nextCollapsed
|
||||
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
||||
: { foldersMap: nextMap });
|
||||
persistState(nextMap, nextCollapsed ?? collapsed);
|
||||
},
|
||||
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string): void => {
|
||||
@@ -466,6 +560,29 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
||||
},
|
||||
|
||||
removeSessionEverywhere: (runtimeKey: string, sessionId: string): void => {
|
||||
if (!runtimeKey || runtimeKey !== activeFolderRuntimeKey || runtimeKey !== getRuntimeKey() || !sessionId) return;
|
||||
const current = get().foldersMap;
|
||||
let nextMap: SessionFoldersMap | null = null;
|
||||
|
||||
for (const [scopeKey, scopeFolders] of Object.entries(current)) {
|
||||
let scopeChanged = false;
|
||||
const nextFolders = scopeFolders.map((folder) => {
|
||||
const sessionIds = folder.sessionIds.filter((id) => id !== sessionId);
|
||||
if (sessionIds.length === folder.sessionIds.length) return folder;
|
||||
scopeChanged = true;
|
||||
return { ...folder, sessionIds };
|
||||
});
|
||||
if (!scopeChanged) continue;
|
||||
nextMap ??= { ...current };
|
||||
nextMap[scopeKey] = nextFolders;
|
||||
}
|
||||
|
||||
if (!nextMap) return;
|
||||
set({ foldersMap: nextMap });
|
||||
persistState(nextMap, get().collapsedFolderIds);
|
||||
},
|
||||
|
||||
toggleFolderCollapse: (folderId: string): void => {
|
||||
const collapsed = get().collapsedFolderIds;
|
||||
const next = new Set(collapsed);
|
||||
@@ -478,37 +595,6 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
persistState(get().foldersMap, next);
|
||||
},
|
||||
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>): void => {
|
||||
if (!scopeKey) return;
|
||||
const current = get().foldersMap;
|
||||
const scopeFolders = current[scopeKey];
|
||||
if (!scopeFolders || scopeFolders.length === 0) return;
|
||||
|
||||
let changed = false;
|
||||
const filteredFolders = scopeFolders.map((folder) => {
|
||||
const filtered = folder.sessionIds.filter((id) => existingSessionIds.has(id));
|
||||
if (filtered.length !== folder.sessionIds.length) {
|
||||
changed = true;
|
||||
return { ...folder, sessionIds: filtered };
|
||||
}
|
||||
return folder;
|
||||
});
|
||||
|
||||
const nextFolders = pruneEmptyArchivedFolders(scopeKey, filteredFolders);
|
||||
if (nextFolders.length !== filteredFolders.length) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
||||
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
|
||||
|
||||
set(nextCollapsed
|
||||
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
||||
: { foldersMap: nextMap });
|
||||
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
||||
},
|
||||
|
||||
getSessionFolderId: (scopeKey: string, sessionId: string): string | null => {
|
||||
if (!scopeKey || !sessionId) return null;
|
||||
const scopeFolders = get().foldersMap[scopeKey];
|
||||
@@ -536,6 +622,10 @@ const hydrateSessionFoldersFromDisk = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
diskHydrationInFlight = true;
|
||||
const runtimeKey = activeFolderRuntimeKey;
|
||||
const generation = folderRuntimeGeneration;
|
||||
const baselineMutationRevision = folderMutationRevision;
|
||||
let completed = false;
|
||||
|
||||
try {
|
||||
const response = await runtimeFetch(SESSION_FOLDERS_API_PATH).catch(() => null);
|
||||
@@ -544,14 +634,21 @@ const hydrateSessionFoldersFromDisk = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
const parsed = await response.json().catch(() => null) as {
|
||||
exists?: boolean;
|
||||
foldersMap?: SessionFoldersMap;
|
||||
collapsedFolderIds?: string[];
|
||||
updatedAt?: number;
|
||||
} | null;
|
||||
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.exists === false) {
|
||||
completed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const diskFolders = parsed.foldersMap && typeof parsed.foldersMap === 'object'
|
||||
? parsed.foldersMap
|
||||
: {};
|
||||
@@ -559,23 +656,26 @@ const hydrateSessionFoldersFromDisk = async (): Promise<void> => {
|
||||
? new Set(parsed.collapsedFolderIds.filter((value): value is string => typeof value === 'string'))
|
||||
: new Set<string>();
|
||||
|
||||
const hasDiskData = Object.keys(diskFolders).length > 0 || diskCollapsed.size > 0;
|
||||
if (!hasDiskData) {
|
||||
return;
|
||||
if (generation !== folderRuntimeGeneration || runtimeKey !== getRuntimeKey()) return;
|
||||
const browserUpdatedAt = readStorageIndex().runtimes.find((entry) => entry.runtimeKey === runtimeKey)?.updatedAt ?? 0;
|
||||
const diskUpdatedAt = typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0;
|
||||
if (diskUpdatedAt > 0) {
|
||||
lastDiskUpdatedAtByRuntime.set(runtimeKey, Math.max(lastDiskUpdatedAtByRuntime.get(runtimeKey) ?? 0, diskUpdatedAt));
|
||||
}
|
||||
|
||||
useSessionFoldersStore.setState({
|
||||
foldersMap: diskFolders,
|
||||
collapsedFolderIds: diskCollapsed,
|
||||
});
|
||||
|
||||
persistFolders(diskFolders);
|
||||
persistCollapsed(diskCollapsed);
|
||||
const hasDiskAuthority = parsed.exists === true || diskUpdatedAt > 0;
|
||||
if (hasDiskAuthority && folderMutationRevision === baselineMutationRevision && diskUpdatedAt >= browserUpdatedAt) {
|
||||
useSessionFoldersStore.setState({ foldersMap: diskFolders, collapsedFolderIds: diskCollapsed });
|
||||
persistFolders(diskFolders);
|
||||
persistCollapsed(diskCollapsed);
|
||||
}
|
||||
completed = true;
|
||||
} catch {
|
||||
// ignored
|
||||
} finally {
|
||||
diskHydrationInFlight = false;
|
||||
diskHydrated = true;
|
||||
if (generation === folderRuntimeGeneration && runtimeKey === getRuntimeKey()) {
|
||||
diskHydrationInFlight = false;
|
||||
if (completed) diskHydrated = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getPinnedSessionKey, isSessionPinned, useSessionPinnedStore } from './useSessionPinnedStore';
|
||||
|
||||
describe('useSessionPinnedStore', () => {
|
||||
beforeEach(() => {
|
||||
useSessionPinnedStore.setState({ ids: new Set(), touchedAt: {} });
|
||||
});
|
||||
|
||||
test('isolates identical session IDs by directory', () => {
|
||||
const store = useSessionPinnedStore.getState();
|
||||
store.toggle({ directory: '/repo-a', sessionId: 'session-1' });
|
||||
store.toggle({ directory: '/repo-b', sessionId: 'session-1' });
|
||||
|
||||
expect(isSessionPinned(useSessionPinnedStore.getState().ids, '/repo-a/', 'session-1')).toBe(true);
|
||||
expect(isSessionPinned(useSessionPinnedStore.getState().ids, '/repo-b', 'session-1')).toBe(true);
|
||||
store.toggle({ directory: '/repo-a', sessionId: 'session-1' });
|
||||
expect(isSessionPinned(useSessionPinnedStore.getState().ids, '/repo-a', 'session-1')).toBe(false);
|
||||
expect(isSessionPinned(useSessionPinnedStore.getState().ids, '/repo-b', 'session-1')).toBe(true);
|
||||
});
|
||||
|
||||
test('explicit deletion clears only the matching runtime and directory', () => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const activeKey = getPinnedSessionKey(runtimeKey, '/repo', 'session-1')!;
|
||||
const otherRuntimeKey = getPinnedSessionKey('other-runtime', '/repo', 'session-1')!;
|
||||
useSessionPinnedStore.setState({
|
||||
ids: new Set([activeKey, otherRuntimeKey]),
|
||||
touchedAt: { [activeKey]: 1, [otherRuntimeKey]: 2 },
|
||||
});
|
||||
|
||||
useSessionPinnedStore.getState().clearPinnedSession(runtimeKey, '/repo/', 'session-1');
|
||||
|
||||
expect(useSessionPinnedStore.getState().ids.has(activeKey)).toBe(false);
|
||||
expect(useSessionPinnedStore.getState().ids.has(otherRuntimeKey)).toBe(true);
|
||||
});
|
||||
|
||||
test('does not silently evict older user pins', () => {
|
||||
const store = useSessionPinnedStore.getState();
|
||||
for (let index = 0; index < 250; index += 1) {
|
||||
store.toggle({ directory: '/repo', sessionId: `session-${index}` });
|
||||
}
|
||||
|
||||
expect(useSessionPinnedStore.getState().ids.size).toBe(250);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1,121 @@
|
||||
import { create } from 'zustand';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { getDeferredSafeStorage } from './utils/safeStorage';
|
||||
|
||||
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
|
||||
const STORAGE_KEY = 'oc.sessions.pinned.v2';
|
||||
const LEGACY_STORAGE_KEY = 'oc.sessions.pinned';
|
||||
|
||||
const readPinned = (storage: Storage): Set<string> => {
|
||||
try {
|
||||
const raw = storage.getItem(SESSION_PINNED_STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.filter((item): item is string => typeof item === 'string'));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
};
|
||||
export type SessionPinnedTarget = { directory: string; sessionId: string };
|
||||
|
||||
const persistPinned = (storage: Storage, ids: Set<string>): void => {
|
||||
try {
|
||||
storage.setItem(SESSION_PINNED_STORAGE_KEY, JSON.stringify([...ids]));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
type PersistedPins = { version: 2; sessions: Record<string, number> };
|
||||
|
||||
type SessionPinnedStore = {
|
||||
type PinnedSessionState = {
|
||||
ids: Set<string>;
|
||||
setIds: (next: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||
toggle: (sessionId: string) => void;
|
||||
touchedAt: Record<string, number>;
|
||||
};
|
||||
|
||||
const safeStorage = getDeferredSafeStorage();
|
||||
type SessionPinnedStore = PinnedSessionState & {
|
||||
setIds: (next: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||
toggle: (target: SessionPinnedTarget) => void;
|
||||
clearPinnedSession: (runtimeKey: string, directory: string, sessionId: string) => void;
|
||||
};
|
||||
|
||||
const storage = getDeferredSafeStorage();
|
||||
|
||||
export const getPinnedSessionKey = (runtimeKey: string, directory: string, sessionId: string): string | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!runtimeKey || !normalizedDirectory || !sessionId) return null;
|
||||
return JSON.stringify([runtimeKey, normalizedDirectory, sessionId]);
|
||||
};
|
||||
|
||||
const parsePinnedSessionKey = (key: string): [string, string, string] | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(key) as unknown;
|
||||
if (!Array.isArray(parsed) || parsed.length !== 3) return null;
|
||||
const [runtimeKey, directory, sessionId] = parsed;
|
||||
if (typeof runtimeKey !== 'string' || typeof directory !== 'string' || typeof sessionId !== 'string') return null;
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!runtimeKey || !normalizedDirectory || normalizedDirectory !== directory || !sessionId) return null;
|
||||
return [runtimeKey, normalizedDirectory, sessionId];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isSessionPinned = (ids: Set<string>, directory: string | null | undefined, sessionId: string): boolean => {
|
||||
if (!directory) return false;
|
||||
const key = getPinnedSessionKey(getRuntimeKey(), directory, sessionId);
|
||||
return key ? ids.has(key) : false;
|
||||
};
|
||||
|
||||
const readPinned = (): PinnedSessionState => {
|
||||
storage.removeItem(LEGACY_STORAGE_KEY);
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return { ids: new Set(), touchedAt: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedPins>;
|
||||
if (parsed.version !== 2 || !parsed.sessions || typeof parsed.sessions !== 'object') return { ids: new Set(), touchedAt: {} };
|
||||
const entries = Object.entries(parsed.sessions)
|
||||
.filter(([key, touchedAt]) => parsePinnedSessionKey(key) && typeof touchedAt === 'number' && Number.isFinite(touchedAt))
|
||||
.sort((left, right) => right[1] - left[1]);
|
||||
return { ids: new Set(entries.map(([key]) => key)), touchedAt: Object.fromEntries(entries) };
|
||||
} catch {
|
||||
storage.removeItem(STORAGE_KEY);
|
||||
return { ids: new Set(), touchedAt: {} };
|
||||
}
|
||||
};
|
||||
|
||||
const boundPinnedState = (ids: Set<string>, touchedAt: Record<string, number>): PinnedSessionState => {
|
||||
const entries = [...ids]
|
||||
.filter((key) => parsePinnedSessionKey(key) !== null)
|
||||
.map((key) => [key, touchedAt[key] ?? Date.now()] as const)
|
||||
.sort((left, right) => right[1] - left[1]);
|
||||
return {
|
||||
ids: new Set(entries.map(([key]) => key)),
|
||||
touchedAt: Object.fromEntries(entries),
|
||||
};
|
||||
};
|
||||
|
||||
const persistPinned = ({ ids, touchedAt }: PinnedSessionState): void => {
|
||||
const sessions = Object.fromEntries([...ids].map((key) => [key, touchedAt[key] ?? Date.now()]));
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 2, sessions }));
|
||||
};
|
||||
|
||||
const initial = readPinned();
|
||||
|
||||
export const useSessionPinnedStore = create<SessionPinnedStore>((set, get) => ({
|
||||
ids: readPinned(safeStorage),
|
||||
ids: initial.ids,
|
||||
touchedAt: initial.touchedAt,
|
||||
setIds: (next) => {
|
||||
const current = get().ids;
|
||||
const resolved = typeof next === 'function' ? next(current) : next;
|
||||
if (resolved === current) return;
|
||||
set({ ids: resolved });
|
||||
persistPinned(safeStorage, resolved);
|
||||
const pinnedState = boundPinnedState(resolved, get().touchedAt);
|
||||
set(pinnedState);
|
||||
persistPinned(pinnedState);
|
||||
},
|
||||
toggle: (sessionId) => {
|
||||
const current = get().ids;
|
||||
const next = new Set(current);
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId);
|
||||
toggle: (target) => {
|
||||
const key = getPinnedSessionKey(getRuntimeKey(), target.directory, target.sessionId);
|
||||
if (!key) return;
|
||||
const ids = new Set(get().ids);
|
||||
const touchedAt = { ...get().touchedAt };
|
||||
if (ids.has(key)) {
|
||||
ids.delete(key);
|
||||
delete touchedAt[key];
|
||||
} else {
|
||||
next.add(sessionId);
|
||||
ids.add(key);
|
||||
touchedAt[key] = Date.now();
|
||||
}
|
||||
set({ ids: next });
|
||||
persistPinned(safeStorage, next);
|
||||
const pinnedState = boundPinnedState(ids, touchedAt);
|
||||
set(pinnedState);
|
||||
persistPinned(pinnedState);
|
||||
},
|
||||
clearPinnedSession: (runtimeKey, directory, sessionId) => {
|
||||
const key = getPinnedSessionKey(runtimeKey, directory, sessionId);
|
||||
if (!key || !get().ids.has(key)) return;
|
||||
const ids = new Set(get().ids);
|
||||
ids.delete(key);
|
||||
get().setIds(ids);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { Todo } from '@opencode-ai/sdk/v2/client';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getTodosPersistenceKey, useTodosPersistStore } from './useTodosPersistStore';
|
||||
|
||||
const todo = (content: string): Todo => ({ content, status: 'pending', priority: 'medium' });
|
||||
|
||||
describe('useTodosPersistStore', () => {
|
||||
beforeEach(() => {
|
||||
useTodosPersistStore.setState({ sessions: {} });
|
||||
});
|
||||
|
||||
test('isolates identical session IDs by directory', () => {
|
||||
const store = useTodosPersistStore.getState();
|
||||
store.setSessionTodos('/repo-a', 'session-1', [todo('a')]);
|
||||
store.setSessionTodos('/repo-b', 'session-1', [todo('b')]);
|
||||
|
||||
expect(useTodosPersistStore.getState().getSessionTodos('/repo-a', 'session-1')).toEqual([todo('a')]);
|
||||
expect(useTodosPersistStore.getState().getSessionTodos('/repo-b', 'session-1')).toEqual([todo('b')]);
|
||||
});
|
||||
|
||||
test('stores entries under the active runtime identity', () => {
|
||||
useTodosPersistStore.getState().setSessionTodos('/repo', 'session-1', [todo('active')]);
|
||||
|
||||
const key = getTodosPersistenceKey(getRuntimeKey(), '/repo', 'session-1');
|
||||
expect(useTodosPersistStore.getState().sessions[key]?.todos).toEqual([todo('active')]);
|
||||
expect(getTodosPersistenceKey('runtime-a', '/repo', 'session-1'))
|
||||
.not.toBe(getTodosPersistenceKey('runtime-b', '/repo', 'session-1'));
|
||||
});
|
||||
|
||||
test('removes only the matching composite session', () => {
|
||||
const store = useTodosPersistStore.getState();
|
||||
store.setSessionTodos('/repo-a', 'session-1', [todo('a')]);
|
||||
store.setSessionTodos('/repo-b', 'session-1', [todo('b')]);
|
||||
store.setSessionTodos('/repo-a', 'session-1', []);
|
||||
|
||||
expect(useTodosPersistStore.getState().getSessionTodos('/repo-a', 'session-1')).toBe(undefined);
|
||||
expect(useTodosPersistStore.getState().getSessionTodos('/repo-b', 'session-1')).toEqual([todo('b')]);
|
||||
});
|
||||
|
||||
test('clears an explicitly owned runtime session', () => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const store = useTodosPersistStore.getState();
|
||||
store.setSessionTodos('/repo', 'session-1', [todo('active')]);
|
||||
store.clearSessionTodos('other-runtime', '/repo', 'session-1');
|
||||
expect(useTodosPersistStore.getState().getSessionTodos('/repo', 'session-1')).toEqual([todo('active')]);
|
||||
|
||||
store.clearSessionTodos(runtimeKey, '/repo/', 'session-1');
|
||||
expect(useTodosPersistStore.getState().getSessionTodos('/repo', 'session-1')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type { Todo } from '@opencode-ai/sdk/v2/client';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
|
||||
const MAX_SESSIONS = 50;
|
||||
@@ -12,10 +14,19 @@ interface SessionTodosRecord {
|
||||
|
||||
interface TodosPersistState {
|
||||
sessions: Record<string, SessionTodosRecord>;
|
||||
setSessionTodos: (sessionId: string, todos: Todo[] | undefined) => void;
|
||||
getSessionTodos: (sessionId: string) => Todo[] | undefined;
|
||||
setSessionTodos: (directory: string, sessionId: string, todos: Todo[] | undefined) => void;
|
||||
getSessionTodos: (directory: string, sessionId: string) => Todo[] | undefined;
|
||||
clearSessionTodos: (runtimeKey: string, directory: string, sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const getTodosPersistenceKey = (runtimeKey: string, directory: string, sessionId: string): string =>
|
||||
JSON.stringify([runtimeKey, normalizePath(directory), sessionId]);
|
||||
|
||||
const getCurrentSessionKey = (directory: string, sessionId: string): string | null => {
|
||||
if (!directory || !sessionId) return null;
|
||||
return getTodosPersistenceKey(getRuntimeKey(), directory, sessionId);
|
||||
};
|
||||
|
||||
const evictOldest = (sessions: Record<string, SessionTodosRecord>): Record<string, SessionTodosRecord> => {
|
||||
const ids = Object.keys(sessions);
|
||||
if (ids.length <= MAX_SESSIONS) return sessions;
|
||||
@@ -34,29 +45,41 @@ export const useTodosPersistStore = create<TodosPersistState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sessions: {},
|
||||
setSessionTodos: (sessionId, todos) => {
|
||||
if (!sessionId) return;
|
||||
setSessionTodos: (directory, sessionId, todos) => {
|
||||
const key = getCurrentSessionKey(directory, sessionId);
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
const next = { ...state.sessions };
|
||||
if (!todos || todos.length === 0) {
|
||||
if (!(sessionId in next)) return state;
|
||||
delete next[sessionId];
|
||||
if (!(key in next)) return state;
|
||||
delete next[key];
|
||||
return { sessions: next };
|
||||
}
|
||||
next[sessionId] = { todos, touchedAt: Date.now() };
|
||||
next[key] = { todos, touchedAt: Date.now() };
|
||||
return { sessions: evictOldest(next) };
|
||||
});
|
||||
},
|
||||
getSessionTodos: (sessionId) => {
|
||||
if (!sessionId) return undefined;
|
||||
return get().sessions[sessionId]?.todos;
|
||||
getSessionTodos: (directory, sessionId) => {
|
||||
const key = getCurrentSessionKey(directory, sessionId);
|
||||
return key ? get().sessions[key]?.todos : undefined;
|
||||
},
|
||||
clearSessionTodos: (runtimeKey, directory, sessionId) => {
|
||||
if (!runtimeKey || !directory || !sessionId) return;
|
||||
const key = getTodosPersistenceKey(runtimeKey, directory, sessionId);
|
||||
set((state) => {
|
||||
if (!(key in state.sessions)) return state;
|
||||
const sessions = { ...state.sessions };
|
||||
delete sessions[key];
|
||||
return { sessions };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'openchamber-session-todos',
|
||||
version: 1,
|
||||
version: 2,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({ sessions: state.sessions }),
|
||||
migrate: () => ({ sessions: {} }),
|
||||
},
|
||||
),
|
||||
{ name: 'TodosPersistStore' },
|
||||
|
||||
@@ -158,4 +158,64 @@ describe('safeStorage', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('isolates quota failures to one key and retries durable storage later', async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const backingStorage = createFakeStorage();
|
||||
backingStorage.setItem('large', 'old');
|
||||
const originalSet = backingStorage.setItem.bind(backingStorage);
|
||||
let rejectLarge = true;
|
||||
backingStorage.setItem = (key, value) => {
|
||||
if (key === 'large' && rejectLarge) throw new DOMException('quota', 'QuotaExceededError');
|
||||
originalSet(key, value);
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { localStorage: backingStorage, sessionStorage: createFakeStorage(), addEventListener: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const { getSafeStorage } = await importSafeStorage();
|
||||
const storage = getSafeStorage();
|
||||
storage.setItem('large', 'ephemeral-new');
|
||||
storage.setItem('unrelated', 'durable');
|
||||
|
||||
expect(storage.getItem('large')).toBe('ephemeral-new');
|
||||
expect(backingStorage.getItem('large')).toBeNull();
|
||||
expect(backingStorage.getItem('unrelated')).toBe('durable');
|
||||
|
||||
rejectLarge = false;
|
||||
storage.setItem('large', 'durable-new');
|
||||
expect(backingStorage.getItem('large')).toBe('durable-new');
|
||||
expect(storage.getItem('large')).toBe('durable-new');
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
});
|
||||
|
||||
test('removes malformed persisted JSON and permits later recovery', async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const backingStorage = createFakeStorage();
|
||||
backingStorage.setItem('broken', '{not-json');
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { localStorage: backingStorage, sessionStorage: createFakeStorage(), addEventListener: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const { createDeferredSafeJSONStorage } = await importSafeStorage();
|
||||
const storage = createDeferredSafeJSONStorage<{ value: string }>();
|
||||
if (!storage) throw new Error('storage unavailable');
|
||||
|
||||
expect(storage.getItem('broken')).toBeNull();
|
||||
expect(backingStorage.getItem('broken')).toBeNull();
|
||||
storage.setItem('broken', { state: { value: 'recovered' } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(storage.getItem('broken')).toEqual({ state: { value: 'recovered' } });
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,10 +26,12 @@ const registerDeferredFlusher = (flush: () => void) => {
|
||||
try {
|
||||
window.addEventListener('pagehide', flushAll, { capture: true });
|
||||
window.addEventListener('beforeunload', flushAll, { capture: true });
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') flushAll();
|
||||
});
|
||||
window.addEventListener('freeze', flushAll);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flushAll();
|
||||
});
|
||||
document.addEventListener('freeze', flushAll);
|
||||
}
|
||||
} catch {
|
||||
// Restricted environments can reject listeners; timers still flush.
|
||||
}
|
||||
@@ -64,6 +66,7 @@ const createDeferredJSONStorage = <S>(
|
||||
storage.setItem(name, JSON.stringify(value, options?.replacer));
|
||||
} catch (error) {
|
||||
console.error('Failed to persist deferred storage value', error);
|
||||
if (!pendingWrites.has(name) && !pendingDeletes.has(name)) pendingWrites.set(name, value);
|
||||
}
|
||||
}
|
||||
for (const name of deletes) {
|
||||
@@ -71,6 +74,7 @@ const createDeferredJSONStorage = <S>(
|
||||
storage.removeItem(name);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove deferred storage value', error);
|
||||
if (!pendingWrites.has(name)) pendingDeletes.add(name);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -93,7 +97,16 @@ const createDeferredJSONStorage = <S>(
|
||||
|
||||
const parse = (value: string | null): StorageValue<S> | null => {
|
||||
if (value === null) return null;
|
||||
return JSON.parse(value, options?.reviver) as StorageValue<S>;
|
||||
try {
|
||||
return JSON.parse(value, options?.reviver) as StorageValue<S>;
|
||||
} catch {
|
||||
try {
|
||||
storage.removeItem(name);
|
||||
} catch {
|
||||
// A later hydration can retry cleanup.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const value = storage.getItem(name);
|
||||
if (value instanceof Promise) {
|
||||
@@ -137,6 +150,7 @@ const createDeferredStorage = (storage: Storage): Storage => {
|
||||
storage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist deferred storage value', error);
|
||||
if (!pendingWrites.has(key) && !pendingDeletes.has(key)) pendingWrites.set(key, value);
|
||||
}
|
||||
}
|
||||
for (const key of deletes) {
|
||||
@@ -144,6 +158,7 @@ const createDeferredStorage = (storage: Storage): Storage => {
|
||||
storage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove deferred storage value', error);
|
||||
if (!pendingWrites.has(key)) pendingDeletes.add(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -219,80 +234,84 @@ const createInMemoryStorage = (): Storage => {
|
||||
} as Storage;
|
||||
};
|
||||
|
||||
const createSafeStorage = (): Storage => {
|
||||
const baseStorage = getWindowStorage('localStorage');
|
||||
|
||||
if (!baseStorage) {
|
||||
return createInMemoryStorage();
|
||||
}
|
||||
|
||||
const createSafeStorageAdapter = (baseStorage: Storage): Storage => {
|
||||
const fallback = createInMemoryStorage();
|
||||
let storageAvailable = true;
|
||||
|
||||
const disableStorage = () => {
|
||||
storageAvailable = false;
|
||||
};
|
||||
const fallbackKeys = new Set<string>();
|
||||
const deletedKeys = new Set<string>();
|
||||
|
||||
const safeGet = (key: string): string | null => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
const value = baseStorage.getItem(key);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
if (deletedKeys.has(key)) return null;
|
||||
if (fallbackKeys.has(key)) return fallback.getItem(key);
|
||||
try {
|
||||
return baseStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return fallback.getItem(key);
|
||||
};
|
||||
|
||||
const safeSet = (key: string, value: string) => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.setItem(key, value);
|
||||
fallback.removeItem(key);
|
||||
fallbackKeys.delete(key);
|
||||
deletedKeys.delete(key);
|
||||
return;
|
||||
} catch {
|
||||
// Hide an older durable value even when quota or storage policy blocks replacement.
|
||||
try {
|
||||
baseStorage.setItem(key, value);
|
||||
fallback.removeItem(key);
|
||||
return;
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
disableStorage();
|
||||
// Prevent stale previous value from surviving when writes fail (e.g. quota).
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
// The ephemeral override remains authoritative for this adapter.
|
||||
}
|
||||
}
|
||||
fallback.setItem(key, value);
|
||||
fallbackKeys.add(key);
|
||||
deletedKeys.delete(key);
|
||||
};
|
||||
|
||||
const safeRemove = (key: string) => {
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
deletedKeys.delete(key);
|
||||
} catch {
|
||||
disableStorage();
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
fallback.removeItem(key);
|
||||
fallbackKeys.delete(key);
|
||||
};
|
||||
|
||||
const safeClear = () => {
|
||||
const knownKeys: string[] = [];
|
||||
try {
|
||||
for (let index = 0; index < baseStorage.length; index += 1) {
|
||||
const key = baseStorage.key(index);
|
||||
if (key) knownKeys.push(key);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort tombstones cover keys that could be enumerated.
|
||||
}
|
||||
try {
|
||||
baseStorage.clear();
|
||||
deletedKeys.clear();
|
||||
} catch {
|
||||
disableStorage();
|
||||
for (const key of knownKeys) deletedKeys.add(key);
|
||||
}
|
||||
fallback.clear();
|
||||
fallbackKeys.clear();
|
||||
};
|
||||
|
||||
const safeKey = (index: number): string | null => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
return baseStorage.key(index);
|
||||
} catch {
|
||||
disableStorage();
|
||||
const visibleKeys = (): string[] => {
|
||||
const keys = new Set<string>();
|
||||
try {
|
||||
for (let index = 0; index < baseStorage.length; index += 1) {
|
||||
const key = baseStorage.key(index);
|
||||
if (key && !deletedKeys.has(key) && !fallbackKeys.has(key)) keys.add(key);
|
||||
}
|
||||
} catch {
|
||||
// Ephemeral keys remain available when durable enumeration fails.
|
||||
}
|
||||
return fallback.key(index);
|
||||
for (const key of fallbackKeys) keys.add(key);
|
||||
return [...keys];
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -300,20 +319,18 @@ const createSafeStorage = (): Storage => {
|
||||
setItem: safeSet,
|
||||
removeItem: safeRemove,
|
||||
clear: safeClear,
|
||||
key: safeKey,
|
||||
key: (index) => visibleKeys()[index] ?? null,
|
||||
get length() {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
return baseStorage.length + fallback.length;
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
return fallback.length;
|
||||
return visibleKeys().length;
|
||||
},
|
||||
} as Storage;
|
||||
};
|
||||
|
||||
const createSafeStorage = (): Storage => {
|
||||
const baseStorage = getWindowStorage('localStorage');
|
||||
return baseStorage ? createSafeStorageAdapter(baseStorage) : createInMemoryStorage();
|
||||
};
|
||||
|
||||
export const getSafeStorage = (): Storage => {
|
||||
if (!safeStorageInstance) {
|
||||
safeStorageInstance = createSafeStorage();
|
||||
@@ -330,97 +347,7 @@ export const getDeferredSafeStorage = (): Storage => {
|
||||
|
||||
const createSafeSessionStorage = (): Storage => {
|
||||
const baseStorage = getWindowStorage('sessionStorage');
|
||||
|
||||
if (!baseStorage) {
|
||||
return createInMemoryStorage();
|
||||
}
|
||||
|
||||
const fallback = createInMemoryStorage();
|
||||
let storageAvailable = true;
|
||||
|
||||
const disableStorage = () => {
|
||||
storageAvailable = false;
|
||||
};
|
||||
|
||||
const safeGet = (key: string): string | null => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
const value = baseStorage.getItem(key);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
return fallback.getItem(key);
|
||||
};
|
||||
|
||||
const safeSet = (key: string, value: string) => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.setItem(key, value);
|
||||
fallback.removeItem(key);
|
||||
return;
|
||||
} catch {
|
||||
disableStorage();
|
||||
// Prevent stale previous value from surviving when writes fail (e.g. quota).
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback.setItem(key, value);
|
||||
};
|
||||
|
||||
const safeRemove = (key: string) => {
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
fallback.removeItem(key);
|
||||
};
|
||||
|
||||
const safeClear = () => {
|
||||
try {
|
||||
baseStorage.clear();
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
fallback.clear();
|
||||
};
|
||||
|
||||
const safeKey = (index: number): string | null => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
return baseStorage.key(index);
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
return fallback.key(index);
|
||||
};
|
||||
|
||||
return {
|
||||
getItem: safeGet,
|
||||
setItem: safeSet,
|
||||
removeItem: safeRemove,
|
||||
clear: safeClear,
|
||||
key: safeKey,
|
||||
get length() {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
return baseStorage.length + fallback.length;
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
return fallback.length;
|
||||
},
|
||||
} as Storage;
|
||||
return baseStorage ? createSafeStorageAdapter(baseStorage) : createInMemoryStorage();
|
||||
};
|
||||
|
||||
export const getSafeSessionStorage = (): Storage => {
|
||||
|
||||
@@ -42,6 +42,11 @@ export type StreamPerfSnapshot = {
|
||||
declare global {
|
||||
interface Window {
|
||||
__openchamberStreamPerfState?: StreamPerfState;
|
||||
__openchamberStreamPerformance?: {
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
reset: () => void;
|
||||
getSnapshot: () => StreamPerfSnapshot;
|
||||
};
|
||||
__openchamberVsCodeStreamPerfState?: {
|
||||
counters: Map<string, PerfCounter>;
|
||||
lastReportAt?: number;
|
||||
@@ -52,7 +57,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const streamPerfEnabled = (): boolean => {
|
||||
const readInitialStreamPerfEnabled = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1';
|
||||
@@ -61,6 +66,8 @@ const streamPerfEnabled = (): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
let streamPerfEnabled = readInitialStreamPerfEnabled();
|
||||
|
||||
const nowMs = (): number => {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
@@ -69,7 +76,7 @@ const nowMs = (): number => {
|
||||
};
|
||||
|
||||
const ensureStreamPerfState = (): StreamPerfState | null => {
|
||||
if (!streamPerfEnabled() || typeof window === 'undefined') {
|
||||
if (!streamPerfEnabled || typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -114,6 +121,7 @@ const updatePerfCounter = (metric: string, amount: number): void => {
|
||||
};
|
||||
|
||||
export const setStreamPerfEnabled = (enabled: boolean): void => {
|
||||
streamPerfEnabled = enabled;
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -142,7 +150,7 @@ export const resetStreamPerf = (): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamPerfEnabled()) {
|
||||
if (streamPerfEnabled) {
|
||||
window.__openchamberStreamPerfState = {
|
||||
counters: new Map<string, PerfCounter>(),
|
||||
startedAt: Date.now(),
|
||||
@@ -172,7 +180,7 @@ export const getStreamPerfSnapshot = (): StreamPerfSnapshot => {
|
||||
}
|
||||
|
||||
const state = window.__openchamberStreamPerfState;
|
||||
if (!streamPerfEnabled() || !state) {
|
||||
if (!streamPerfEnabled || !state) {
|
||||
return {
|
||||
enabled: false,
|
||||
startedAt: null,
|
||||
@@ -203,7 +211,7 @@ export const getVsCodeStreamPerfSnapshot = (): StreamPerfSnapshot => {
|
||||
}
|
||||
|
||||
const state = window.__openchamberVsCodeStreamPerfState;
|
||||
if (!streamPerfEnabled() || !state) {
|
||||
if (!streamPerfEnabled || !state) {
|
||||
return {
|
||||
enabled: false,
|
||||
startedAt: null,
|
||||
@@ -232,8 +240,15 @@ export const streamPerfObserve = (metric: string, value: number): void => {
|
||||
updatePerfCounter(metric, value);
|
||||
};
|
||||
|
||||
export const streamPerfMark = (metric: string): void => {
|
||||
if (!streamPerfEnabled || typeof performance === 'undefined' || typeof performance.mark !== 'function') {
|
||||
return;
|
||||
}
|
||||
performance.mark(`openchamber.${metric}`);
|
||||
};
|
||||
|
||||
export const streamPerfMeasure = <T>(metric: string, fn: () => T): T => {
|
||||
if (!streamPerfEnabled()) {
|
||||
if (!streamPerfEnabled) {
|
||||
return fn();
|
||||
}
|
||||
|
||||
@@ -244,3 +259,11 @@ export const streamPerfMeasure = <T>(metric: string, fn: () => T): T => {
|
||||
updatePerfCounter(metric, nowMs() - start);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__openchamberStreamPerformance = {
|
||||
setEnabled: setStreamPerfEnabled,
|
||||
reset: resetStreamPerf,
|
||||
getSnapshot: getStreamPerfSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user