| 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 2 | import type { RunId, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { create } from "zustand"; |
| 4 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 5 | |
| 6 | import { resolveStorage } from "./lib/storage"; |
| 7 | |
| 8 | export type DiffPanelSelection = |
| 9 | | { kind: "branch"; baseRef: string | null } |
| 10 | | { kind: "unstaged" } |
| 11 | | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number }; |
| 12 | |
| 13 | // "branch" is the Changes view: everything this checkout changed since its base. |
| 14 | const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; |
| 15 | |
| 16 | interface DiffPanelStoreState { |
| 17 | byThreadKey: Record<string, DiffPanelSelection>; |
| 18 | branchBaseRefByThreadKey: Record<string, string | null>; |
| 19 | selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; |
| 20 | selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; |
| 21 | selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; |
| 22 | reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<RunId>) => void; |
| 23 | removeThread: (ref: ScopedThreadRef) => void; |
| 24 | } |
| 25 | |
| 26 | function normalizeBaseRef(baseRef: string | null): string | null { |
| 27 | const normalized = baseRef?.trim(); |
| 28 | return normalized ? normalized : null; |
| 29 | } |
| 30 | |
| 31 | export const useDiffPanelStore = create<DiffPanelStoreState>()( |
| 32 | persist( |
| 33 | (set) => ({ |
| 34 | byThreadKey: {}, |
| 35 | branchBaseRefByThreadKey: {}, |
| 36 | selectGitScope: (ref, scope) => |
| 37 | set((state) => { |
| 38 | const threadKey = scopedThreadKey(ref); |
| 39 | const previous = state.byThreadKey[threadKey]; |
| 40 | const previousBaseRef = |
| 41 | previous?.kind === "branch" |
| 42 | ? previous.baseRef |
| 43 | : (state.branchBaseRefByThreadKey[threadKey] ?? null); |
| 44 | return { |
| 45 | byThreadKey: { |
| 46 | ...state.byThreadKey, |
| 47 | [threadKey]: |
| 48 | scope === "branch" |
| 49 | ? { kind: "branch", baseRef: previousBaseRef } |
| 50 | : { kind: "unstaged" }, |
| 51 | }, |
| 52 | branchBaseRefByThreadKey: |
| 53 | previous?.kind === "branch" |
| 54 | ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef } |
| 55 | : state.branchBaseRefByThreadKey, |
| 56 | }; |
| 57 | }), |
| 58 | selectBranchBaseRef: (ref, baseRef) => |
| 59 | set((state) => { |
| 60 | const threadKey = scopedThreadKey(ref); |
| 61 | const normalizedBaseRef = normalizeBaseRef(baseRef); |
| 62 | return { |
| 63 | byThreadKey: { |
| 64 | ...state.byThreadKey, |
| 65 | [threadKey]: { kind: "branch", baseRef: normalizedBaseRef }, |
| 66 | }, |
| 67 | branchBaseRefByThreadKey: { |
| 68 | ...state.branchBaseRefByThreadKey, |
| 69 | [threadKey]: normalizedBaseRef, |
| 70 | }, |
| 71 | }; |
| 72 | }), |
| 73 | selectTurn: (ref, turnId, filePath) => |
| 74 | set((state) => { |
| 75 | const threadKey = scopedThreadKey(ref); |
| 76 | const previous = state.byThreadKey[threadKey]; |
| 77 | return { |
| 78 | byThreadKey: { |
| 79 | ...state.byThreadKey, |
| 80 | [threadKey]: { |
| 81 | kind: "turn", |
| 82 | turnId, |
| 83 | filePath: filePath?.trim() || null, |
| 84 | revealRequestId: previous?.kind === "turn" ? previous.revealRequestId + 1 : 1, |
| 85 | }, |
| 86 | }, |
| 87 | }; |
| 88 | }), |
| 89 | reconcileTurnSelection: (ref, availableTurnIds) => |
| 90 | set((state) => { |
| 91 | const threadKey = scopedThreadKey(ref); |
| 92 | const previous = state.byThreadKey[threadKey]; |
| 93 | const latestTurnId = availableTurnIds[0]; |
| 94 | if ( |
| 95 | previous?.kind !== "turn" || |
| 96 | latestTurnId === undefined || |
| 97 | availableTurnIds.includes(previous.turnId) |
| 98 | ) { |
| 99 | return state; |
| 100 | } |
| 101 | return { |
| 102 | byThreadKey: { |
| 103 | ...state.byThreadKey, |
| 104 | [threadKey]: { ...previous, turnId: latestTurnId }, |
| 105 | }, |
| 106 | }; |
| 107 | }), |
| 108 | removeThread: (ref) => |
| 109 | set((state) => { |
| 110 | const threadKey = scopedThreadKey(ref); |
| 111 | if (!(threadKey in state.byThreadKey) && !(threadKey in state.branchBaseRefByThreadKey)) { |
| 112 | return state; |
| 113 | } |
| 114 | const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey; |
| 115 | const { [threadKey]: _removedBaseRef, ...branchBaseRefByThreadKey } = |
| 116 | state.branchBaseRefByThreadKey; |
| 117 | return { byThreadKey, branchBaseRefByThreadKey }; |
| 118 | }), |
| 119 | }), |
| 120 | { |
| 121 | name: "t3code:diff-panel-state:v1", |
| 122 | version: 2, |
| 123 | storage: createJSONStorage(() => |
| 124 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), |
| 125 | ), |
| 126 | partialize: (state) => ({ |
| 127 | byThreadKey: state.byThreadKey, |
| 128 | branchBaseRefByThreadKey: state.branchBaseRefByThreadKey, |
| 129 | }), |
| 130 | }, |
| 131 | ), |
| 132 | ); |
| 133 | |
| 134 | export function selectThreadDiffPanelSelection( |
| 135 | byThreadKey: Record<string, DiffPanelSelection>, |
| 136 | ref: ScopedThreadRef | null | undefined, |
| 137 | ): DiffPanelSelection { |
| 138 | if (!ref) return DEFAULT_SELECTION; |
| 139 | return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION; |
| 140 | } |