Skip to content
Source
Sign inSign up

diffPanelStore.ts

225 lines8.3 KB

1import type { ThreadStructureVisibleTurnItem } from "@t3tools/client-runtime/state/shell";
2import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
3import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import type { RunId, ScopedThreadRef, ThreadId, TurnItemId } from "@t3tools/contracts";
5import { create } from "zustand";
6import { createJSONStorage, persist } from "zustand/middleware";
7​
8import { resolveStorage } from "./lib/storage";
9​
10export interface ToolCallDiffTarget {
11 readonly threadId: ThreadId;
12 readonly itemId: TurnItemId;
13 readonly revision: string;
15​
16export type DiffPanelSelection =
17 | ({ kind: "tool-call" } & ToolCallDiffTarget)
18 | { kind: "branch"; baseRef: string | null }
19 | { kind: "unstaged" }
20 | { kind: "commit"; sha: string; baseRef: string | null }
21 | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number };
22​
23const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null };
24​
25interface DiffPanelStoreState {
26 byThreadKey: Record<string, DiffPanelSelection>;
27 branchBaseRefByThreadKey: Record<string, string | null>;
28 selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void;
29 selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void;
30 selectToolCall: (ref: ScopedThreadRef, target: ToolCallDiffTarget) => void;
31 selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void;
32 selectCommit: (ref: ScopedThreadRef, sha: string) => void;
33 reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<RunId>) => void;
34 removeThread: (ref: ScopedThreadRef) => void;
36​
37function normalizeBaseRef(baseRef: string | null): string | null {
38 const normalized = baseRef?.trim();
39 return normalized ? normalized : null;
41​
42function branchBaseRef(
43 state: Pick<DiffPanelStoreState, "byThreadKey" | "branchBaseRefByThreadKey">,
44 threadKey: string,
45): string | null {
46 const previous = state.byThreadKey[threadKey];
47 return previous?.kind === "branch" || previous?.kind === "commit"
48 ? previous.baseRef
49 : (state.branchBaseRefByThreadKey[threadKey] ?? null);
51​
52export const useDiffPanelStore = create<DiffPanelStoreState>()(
53 persist(
54 (set) => ({
55 byThreadKey: {},
56 branchBaseRefByThreadKey: {},
57 selectGitScope: (ref, scope) =>
58 set((state) => {
59 const threadKey = scopedThreadKey(ref);
60 const previous = state.byThreadKey[threadKey];
61 const previousBaseRef = branchBaseRef(state, threadKey);
62 return {
63 byThreadKey: {
64 ...state.byThreadKey,
65 [threadKey]:
66 scope === "branch"
67 ? { kind: "branch", baseRef: previousBaseRef }
68 : { kind: "unstaged" },
69 },
70 branchBaseRefByThreadKey:
71 previous?.kind === "branch" || previous?.kind === "commit"
72 ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef }
73 : state.branchBaseRefByThreadKey,
74 };
75 }),
76 selectBranchBaseRef: (ref, baseRef) =>
77 set((state) => {
78 const threadKey = scopedThreadKey(ref);
79 const normalizedBaseRef = normalizeBaseRef(baseRef);
80 return {
81 byThreadKey: {
82 ...state.byThreadKey,
83 [threadKey]: { kind: "branch", baseRef: normalizedBaseRef },
84 },
85 branchBaseRefByThreadKey: {
86 ...state.branchBaseRefByThreadKey,
87 [threadKey]: normalizedBaseRef,
88 },
89 };
90 }),
91 selectToolCall: (ref, target) =>
92 set((state) => ({
93 byThreadKey: {
94 ...state.byThreadKey,
95 [scopedThreadKey(ref)]: { kind: "tool-call", ...target },
96 },
97 })),
98 selectTurn: (ref, turnId, filePath) =>
99 set((state) => {
100 const threadKey = scopedThreadKey(ref);
101 const previous = state.byThreadKey[threadKey];
102 return {
103 byThreadKey: {
104 ...state.byThreadKey,
105 [threadKey]: {
106 kind: "turn",
107 turnId,
108 filePath: filePath?.trim() || null,
109 revealRequestId: previous?.kind === "turn" ? previous.revealRequestId + 1 : 1,
110 },
111 },
112 };
113 }),
114 selectCommit: (ref, sha) =>
115 set((state) => {
116 const threadKey = scopedThreadKey(ref);
117 const baseRef = branchBaseRef(state, threadKey);
118 return {
119 byThreadKey: {
120 ...state.byThreadKey,
121 [threadKey]: { kind: "commit", sha, baseRef },
122 },
123 branchBaseRefByThreadKey: { ...state.branchBaseRefByThreadKey, [threadKey]: baseRef },
124 };
125 }),
126 reconcileTurnSelection: (ref, availableTurnIds) =>
127 set((state) => {
128 const threadKey = scopedThreadKey(ref);
129 const previous = state.byThreadKey[threadKey];
130 const latestTurnId = availableTurnIds[0];
131 if (
132 previous?.kind !== "turn" ||
133 latestTurnId === undefined ||
134 availableTurnIds.includes(previous.turnId)
135 ) {
136 return state;
137 }
138 return {
139 byThreadKey: {
140 ...state.byThreadKey,
141 [threadKey]: { ...previous, turnId: latestTurnId },
142 },
143 };
144 }),
145 removeThread: (ref) =>
146 set((state) => {
147 const threadKey = scopedThreadKey(ref);
148 if (!(threadKey in state.byThreadKey) && !(threadKey in state.branchBaseRefByThreadKey)) {
149 return state;
150 }
151 const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey;
152 const { [threadKey]: _removedBaseRef, ...branchBaseRefByThreadKey } =
153 state.branchBaseRefByThreadKey;
154 return { byThreadKey, branchBaseRefByThreadKey };
155 }),
156 }),
157 {
158 name: "t3code:diff-panel-state:v1",
159 version: 2,
160 storage: createJSONStorage(() =>
161 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
162 ),
163 partialize: (state) => ({
164 byThreadKey: state.byThreadKey,
165 branchBaseRefByThreadKey: state.branchBaseRefByThreadKey,
166 }),
167 },
168 ),
169);
170​
171interface DiffPanelSessionStoreState {
172 collapsedFileKeysByScopeKey: Record<string, ReadonlySet<string>>;
173 updateCollapsedFileKeys: (
174 scopeKey: string,
175 defaults: ReadonlySet<string>,
176 update: (current: ReadonlySet<string>) => ReadonlySet<string>,
177 ) => void;
178 // Null follows the diffIgnoreWhitespace setting until the panel toggle is used.
179 ignoreWhitespace: boolean | null;
180 setIgnoreWhitespace: (ignoreWhitespace: boolean) => void;
182​
183// Session-only so folds and the whitespace toggle survive right-panel switches without
184// changing reload defaults.
185export const useDiffPanelSessionStore = create<DiffPanelSessionStoreState>()((set) => ({
186 collapsedFileKeysByScopeKey: {},
187 updateCollapsedFileKeys: (scopeKey, defaults, update) =>
188 set((state) => {
189 const current = state.collapsedFileKeysByScopeKey[scopeKey] ?? defaults;
190 const next = update(current);
191 if (next === current) return state;
192 return {
193 collapsedFileKeysByScopeKey: { ...state.collapsedFileKeysByScopeKey, [scopeKey]: next },
194 };
195 }),
196 ignoreWhitespace: null,
197 setIgnoreWhitespace: (ignoreWhitespace) => set({ ignoreWhitespace }),
198}));
199​
200export function selectThreadDiffPanelSelection(
201 byThreadKey: Record<string, DiffPanelSelection>,
202 ref: ScopedThreadRef | null | undefined,
203): DiffPanelSelection {
204 if (!ref) return DEFAULT_SELECTION;
205 return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION;
207​
208export function selectThreadBranchBaseRef(
209 state: Pick<DiffPanelStoreState, "byThreadKey" | "branchBaseRefByThreadKey">,
210 ref: ScopedThreadRef | null | undefined,
211): string | null {
212 return ref ? branchBaseRef(state, scopedThreadKey(ref)) : null;
214​
215export function resolveToolCallDiffTarget(
216 target: ToolCallDiffTarget,
217 items: ReadonlyArray<ThreadStructureVisibleTurnItem>,
218): ToolCallDiffTarget {
219 const row = items.find(
220 (row) => row.sourceThreadId === target.threadId && row.sourceItemId === target.itemId,
221 );
222 if (row?.item.type !== "file_change") return target;
223 const revision = turnItemDetailRevision(row.item);
224 return revision === target.revision ? target : { ...target, revision };