| 1 | /** |
| 2 | * Single Zustand store for terminal UI state keyed by scoped thread identity. |
| 3 | * |
| 4 | * Terminal UI transition helpers are intentionally private to keep the public |
| 5 | * API constrained to store actions/selectors. |
| 6 | */ |
| 7 | |
| 8 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 9 | import { type ScopedThreadRef } from "@t3tools/contracts"; |
| 10 | import { create } from "zustand"; |
| 11 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 12 | import { resolveStorage } from "./lib/storage"; |
| 13 | import { |
| 14 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 15 | DEFAULT_THREAD_TERMINAL_ID, |
| 16 | MAX_TERMINALS_PER_GROUP, |
| 17 | type ThreadTerminalGroup, |
| 18 | } from "./types"; |
| 19 | |
| 20 | interface ThreadTerminalUiState { |
| 21 | terminalOpen: boolean; |
| 22 | terminalHeight: number; |
| 23 | terminalIds: string[]; |
| 24 | activeTerminalId: string; |
| 25 | terminalGroups: ThreadTerminalGroup[]; |
| 26 | activeTerminalGroupId: string; |
| 27 | } |
| 28 | |
| 29 | // Keep the old storage key so existing drawer layout preferences migrate. |
| 30 | const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; |
| 31 | |
| 32 | interface PersistedTerminalUiStateStoreState { |
| 33 | terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>; |
| 34 | terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>; |
| 35 | } |
| 36 | |
| 37 | export function migratePersistedTerminalUiStateStoreState( |
| 38 | persistedState: unknown, |
| 39 | _version: number, |
| 40 | ): PersistedTerminalUiStateStoreState { |
| 41 | if (!persistedState || typeof persistedState !== "object") { |
| 42 | return { terminalUiStateByThreadKey: {} }; |
| 43 | } |
| 44 | |
| 45 | const candidate = persistedState as PersistedTerminalUiStateStoreState; |
| 46 | const persistedUiStateByThreadKey = |
| 47 | candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {}; |
| 48 | const terminalUiStateByThreadKey = Object.fromEntries( |
| 49 | Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) => |
| 50 | parseScopedThreadKey(threadKey), |
| 51 | ), |
| 52 | ); |
| 53 | |
| 54 | return { terminalUiStateByThreadKey }; |
| 55 | } |
| 56 | |
| 57 | function createTerminalUiStateStorage() { |
| 58 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); |
| 59 | } |
| 60 | |
| 61 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 62 | const normalizedIds: string[] = []; |
| 63 | const seen = new Set<string>(); |
| 64 | for (const id of terminalIds) { |
| 65 | const trimmedId = id.trim(); |
| 66 | if (trimmedId.length === 0 || seen.has(trimmedId)) continue; |
| 67 | seen.add(trimmedId); |
| 68 | normalizedIds.push(trimmedId); |
| 69 | } |
| 70 | return normalizedIds; |
| 71 | } |
| 72 | |
| 73 | function fallbackGroupId(terminalId: string): string { |
| 74 | return `group-${terminalId}`; |
| 75 | } |
| 76 | |
| 77 | function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string { |
| 78 | let candidate = baseId; |
| 79 | let index = 2; |
| 80 | while (usedGroupIds.has(candidate)) { |
| 81 | candidate = `${baseId}-${index}`; |
| 82 | index += 1; |
| 83 | } |
| 84 | usedGroupIds.add(candidate); |
| 85 | return candidate; |
| 86 | } |
| 87 | |
| 88 | function findGroupIndexByTerminalId( |
| 89 | terminalGroups: ThreadTerminalGroup[], |
| 90 | terminalId: string, |
| 91 | ): number { |
| 92 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); |
| 93 | } |
| 94 | |
| 95 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 96 | return normalizeTerminalIds(terminalIds); |
| 97 | } |
| 98 | |
| 99 | function normalizeTerminalGroups( |
| 100 | terminalGroups: ThreadTerminalGroup[], |
| 101 | terminalIds: string[], |
| 102 | ): ThreadTerminalGroup[] { |
| 103 | if (terminalIds.length === 0) { |
| 104 | return []; |
| 105 | } |
| 106 | |
| 107 | const validTerminalIdSet = new Set(terminalIds); |
| 108 | const assignedTerminalIds = new Set<string>(); |
| 109 | const nextGroups: ThreadTerminalGroup[] = []; |
| 110 | const usedGroupIds = new Set<string>(); |
| 111 | |
| 112 | for (const group of terminalGroups) { |
| 113 | const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { |
| 114 | if (!validTerminalIdSet.has(terminalId)) return false; |
| 115 | if (assignedTerminalIds.has(terminalId)) return false; |
| 116 | return true; |
| 117 | }); |
| 118 | if (groupTerminalIds.length === 0) continue; |
| 119 | for (const terminalId of groupTerminalIds) { |
| 120 | assignedTerminalIds.add(terminalId); |
| 121 | } |
| 122 | const baseGroupId = |
| 123 | group.id.trim().length > 0 |
| 124 | ? group.id.trim() |
| 125 | : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? ""); |
| 126 | nextGroups.push({ |
| 127 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), |
| 128 | terminalIds: groupTerminalIds, |
| 129 | ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), |
| 130 | }); |
| 131 | } |
| 132 | |
| 133 | for (const terminalId of terminalIds) { |
| 134 | if (assignedTerminalIds.has(terminalId)) continue; |
| 135 | nextGroups.push({ |
| 136 | id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), |
| 137 | terminalIds: [terminalId], |
| 138 | }); |
| 139 | } |
| 140 | |
| 141 | return nextGroups; |
| 142 | } |
| 143 | |
| 144 | function arraysEqual(a: string[], b: string[]): boolean { |
| 145 | if (a.length !== b.length) return false; |
| 146 | for (let index = 0; index < a.length; index += 1) { |
| 147 | if (a[index] !== b[index]) return false; |
| 148 | } |
| 149 | return true; |
| 150 | } |
| 151 | |
| 152 | function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { |
| 153 | if (left.length !== right.length) return false; |
| 154 | for (let index = 0; index < left.length; index += 1) { |
| 155 | const leftGroup = left[index]; |
| 156 | const rightGroup = right[index]; |
| 157 | if (!leftGroup || !rightGroup) return false; |
| 158 | if (leftGroup.id !== rightGroup.id) return false; |
| 159 | if ( |
| 160 | (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal") |
| 161 | ) { |
| 162 | return false; |
| 163 | } |
| 164 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; |
| 165 | } |
| 166 | return true; |
| 167 | } |
| 168 | |
| 169 | function threadTerminalUiStateEqual( |
| 170 | left: ThreadTerminalUiState, |
| 171 | right: ThreadTerminalUiState, |
| 172 | ): boolean { |
| 173 | return ( |
| 174 | left.terminalOpen === right.terminalOpen && |
| 175 | left.terminalHeight === right.terminalHeight && |
| 176 | left.activeTerminalId === right.activeTerminalId && |
| 177 | left.activeTerminalGroupId === right.activeTerminalGroupId && |
| 178 | arraysEqual(left.terminalIds, right.terminalIds) && |
| 179 | terminalGroupsEqual(left.terminalGroups, right.terminalGroups) |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({ |
| 184 | terminalOpen: false, |
| 185 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 186 | terminalIds: [], |
| 187 | activeTerminalId: "", |
| 188 | terminalGroups: [], |
| 189 | activeTerminalGroupId: "", |
| 190 | }); |
| 191 | |
| 192 | function createDefaultThreadTerminalUiState(): ThreadTerminalUiState { |
| 193 | return { |
| 194 | ...DEFAULT_THREAD_TERMINAL_UI_STATE, |
| 195 | terminalIds: [...DEFAULT_THREAD_TERMINAL_UI_STATE.terminalIds], |
| 196 | terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_UI_STATE.terminalGroups), |
| 197 | }; |
| 198 | } |
| 199 | |
| 200 | function getDefaultThreadTerminalUiState(): ThreadTerminalUiState { |
| 201 | return DEFAULT_THREAD_TERMINAL_UI_STATE; |
| 202 | } |
| 203 | |
| 204 | function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState { |
| 205 | const nextTerminalIds = normalizeTerminalIds(state.terminalIds); |
| 206 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) |
| 207 | ? state.activeTerminalId |
| 208 | : (nextTerminalIds[0] ?? ""); |
| 209 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); |
| 210 | const activeGroupIdFromState = terminalGroups.some( |
| 211 | (group) => group.id === state.activeTerminalGroupId, |
| 212 | ) |
| 213 | ? state.activeTerminalGroupId |
| 214 | : null; |
| 215 | const activeGroupIdFromTerminal = |
| 216 | terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; |
| 217 | |
| 218 | const normalized: ThreadTerminalUiState = { |
| 219 | terminalOpen: state.terminalOpen, |
| 220 | terminalHeight: |
| 221 | Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 |
| 222 | ? state.terminalHeight |
| 223 | : DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 224 | terminalIds: nextTerminalIds, |
| 225 | activeTerminalId, |
| 226 | terminalGroups, |
| 227 | activeTerminalGroupId: |
| 228 | activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", |
| 229 | }; |
| 230 | return threadTerminalUiStateEqual(state, normalized) ? state : normalized; |
| 231 | } |
| 232 | |
| 233 | function isDefaultThreadTerminalUiState(state: ThreadTerminalUiState): boolean { |
| 234 | const normalized = normalizeThreadTerminalUiState(state); |
| 235 | return threadTerminalUiStateEqual(normalized, DEFAULT_THREAD_TERMINAL_UI_STATE); |
| 236 | } |
| 237 | |
| 238 | function isValidTerminalId(terminalId: string): boolean { |
| 239 | return terminalId.trim().length > 0; |
| 240 | } |
| 241 | |
| 242 | function terminalThreadKey(threadRef: ScopedThreadRef): string { |
| 243 | return scopedThreadKey(threadRef); |
| 244 | } |
| 245 | |
| 246 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { |
| 247 | return groups.map((group) => ({ |
| 248 | id: group.id, |
| 249 | terminalIds: [...group.terminalIds], |
| 250 | ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), |
| 251 | })); |
| 252 | } |
| 253 | |
| 254 | function upsertTerminalIntoGroups( |
| 255 | state: ThreadTerminalUiState, |
| 256 | terminalId: string, |
| 257 | mode: "split" | "new", |
| 258 | splitDirection: "horizontal" | "vertical" = "horizontal", |
| 259 | ): ThreadTerminalUiState { |
| 260 | const normalized = normalizeThreadTerminalUiState(state); |
| 261 | const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; |
| 262 | if (!isValidTerminalId(terminalId)) { |
| 263 | return normalized; |
| 264 | } |
| 265 | |
| 266 | const isNewTerminal = !normalized.terminalIds.includes(terminalId); |
| 267 | const terminalIds = isNewTerminal |
| 268 | ? [...normalized.terminalIds, terminalId] |
| 269 | : normalized.terminalIds; |
| 270 | const terminalGroups = copyTerminalGroups(normalized.terminalGroups); |
| 271 | |
| 272 | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); |
| 273 | if (existingGroupIndex >= 0) { |
| 274 | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ |
| 275 | existingGroupIndex |
| 276 | ]!.terminalIds.filter((id) => id !== terminalId); |
| 277 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { |
| 278 | terminalGroups.splice(existingGroupIndex, 1); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | if (effectiveMode === "new") { |
| 283 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); |
| 284 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); |
| 285 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); |
| 286 | return normalizeThreadTerminalUiState({ |
| 287 | ...normalized, |
| 288 | terminalOpen: true, |
| 289 | terminalIds, |
| 290 | activeTerminalId: terminalId, |
| 291 | terminalGroups, |
| 292 | activeTerminalGroupId: nextGroupId, |
| 293 | }); |
| 294 | } |
| 295 | |
| 296 | let activeGroupIndex = terminalGroups.findIndex( |
| 297 | (group) => group.id === normalized.activeTerminalGroupId, |
| 298 | ); |
| 299 | if (activeGroupIndex < 0) { |
| 300 | activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); |
| 301 | } |
| 302 | if (activeGroupIndex < 0) { |
| 303 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); |
| 304 | const nextGroupId = assignUniqueGroupId( |
| 305 | fallbackGroupId(normalized.activeTerminalId), |
| 306 | usedGroupIds, |
| 307 | ); |
| 308 | terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); |
| 309 | activeGroupIndex = terminalGroups.length - 1; |
| 310 | } |
| 311 | |
| 312 | const destinationGroup = terminalGroups[activeGroupIndex]; |
| 313 | if (!destinationGroup) { |
| 314 | return normalized; |
| 315 | } |
| 316 | const destinationTerminalIdSet = new Set(destinationGroup.terminalIds); |
| 317 | |
| 318 | if ( |
| 319 | isNewTerminal && |
| 320 | !destinationTerminalIdSet.has(terminalId) && |
| 321 | destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP |
| 322 | ) { |
| 323 | return normalized; |
| 324 | } |
| 325 | |
| 326 | if (!destinationTerminalIdSet.has(terminalId)) { |
| 327 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); |
| 328 | if (anchorIndex >= 0) { |
| 329 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); |
| 330 | } else { |
| 331 | destinationGroup.terminalIds.push(terminalId); |
| 332 | } |
| 333 | } |
| 334 | if (splitDirection === "vertical") { |
| 335 | destinationGroup.splitDirection = "vertical"; |
| 336 | } else { |
| 337 | delete destinationGroup.splitDirection; |
| 338 | } |
| 339 | |
| 340 | return normalizeThreadTerminalUiState({ |
| 341 | ...normalized, |
| 342 | terminalOpen: true, |
| 343 | terminalIds, |
| 344 | activeTerminalId: terminalId, |
| 345 | terminalGroups, |
| 346 | activeTerminalGroupId: destinationGroup.id, |
| 347 | }); |
| 348 | } |
| 349 | |
| 350 | function setThreadTerminalOpen(state: ThreadTerminalUiState, open: boolean): ThreadTerminalUiState { |
| 351 | const normalized = normalizeThreadTerminalUiState(state); |
| 352 | if (open && normalized.terminalIds.length === 0) { |
| 353 | return upsertTerminalIntoGroups(normalized, DEFAULT_THREAD_TERMINAL_ID, "new"); |
| 354 | } |
| 355 | if (normalized.terminalOpen === open) return normalized; |
| 356 | return { ...normalized, terminalOpen: open }; |
| 357 | } |
| 358 | |
| 359 | function setThreadTerminalHeight( |
| 360 | state: ThreadTerminalUiState, |
| 361 | height: number, |
| 362 | ): ThreadTerminalUiState { |
| 363 | const normalized = normalizeThreadTerminalUiState(state); |
| 364 | if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { |
| 365 | return normalized; |
| 366 | } |
| 367 | return { ...normalized, terminalHeight: height }; |
| 368 | } |
| 369 | |
| 370 | function splitThreadTerminal( |
| 371 | state: ThreadTerminalUiState, |
| 372 | terminalId: string, |
| 373 | direction: "horizontal" | "vertical" = "horizontal", |
| 374 | ): ThreadTerminalUiState { |
| 375 | return upsertTerminalIntoGroups(state, terminalId, "split", direction); |
| 376 | } |
| 377 | |
| 378 | function newThreadTerminal( |
| 379 | state: ThreadTerminalUiState, |
| 380 | terminalId: string, |
| 381 | ): ThreadTerminalUiState { |
| 382 | return upsertTerminalIntoGroups(state, terminalId, "new"); |
| 383 | } |
| 384 | |
| 385 | function setThreadActiveTerminal( |
| 386 | state: ThreadTerminalUiState, |
| 387 | terminalId: string, |
| 388 | ): ThreadTerminalUiState { |
| 389 | const normalized = normalizeThreadTerminalUiState(state); |
| 390 | if (!normalized.terminalIds.includes(terminalId)) { |
| 391 | return normalized; |
| 392 | } |
| 393 | const activeTerminalGroupId = |
| 394 | normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? |
| 395 | normalized.activeTerminalGroupId; |
| 396 | if ( |
| 397 | normalized.activeTerminalId === terminalId && |
| 398 | normalized.activeTerminalGroupId === activeTerminalGroupId |
| 399 | ) { |
| 400 | return normalized; |
| 401 | } |
| 402 | return { |
| 403 | ...normalized, |
| 404 | activeTerminalId: terminalId, |
| 405 | activeTerminalGroupId, |
| 406 | }; |
| 407 | } |
| 408 | |
| 409 | function closeThreadTerminal( |
| 410 | state: ThreadTerminalUiState, |
| 411 | terminalId: string, |
| 412 | ): ThreadTerminalUiState { |
| 413 | const normalized = normalizeThreadTerminalUiState(state); |
| 414 | if (!normalized.terminalIds.includes(terminalId)) { |
| 415 | return normalized; |
| 416 | } |
| 417 | |
| 418 | const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); |
| 419 | if (remainingTerminalIds.length === 0) { |
| 420 | return createDefaultThreadTerminalUiState(); |
| 421 | } |
| 422 | |
| 423 | const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); |
| 424 | const nextActiveTerminalId = |
| 425 | normalized.activeTerminalId === terminalId |
| 426 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? |
| 427 | remainingTerminalIds[0] ?? |
| 428 | "") |
| 429 | : normalized.activeTerminalId; |
| 430 | |
| 431 | const terminalGroups: ThreadTerminalGroup[] = []; |
| 432 | for (const group of normalized.terminalGroups) { |
| 433 | const terminalIds = group.terminalIds.filter((id) => id !== terminalId); |
| 434 | if (terminalIds.length > 0) { |
| 435 | terminalGroups.push({ ...group, terminalIds }); |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | const nextActiveTerminalGroupId = |
| 440 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? |
| 441 | terminalGroups[0]?.id ?? |
| 442 | fallbackGroupId(nextActiveTerminalId); |
| 443 | |
| 444 | return normalizeThreadTerminalUiState({ |
| 445 | terminalOpen: normalized.terminalOpen, |
| 446 | terminalHeight: normalized.terminalHeight, |
| 447 | terminalIds: remainingTerminalIds, |
| 448 | activeTerminalId: nextActiveTerminalId, |
| 449 | terminalGroups, |
| 450 | activeTerminalGroupId: nextActiveTerminalGroupId, |
| 451 | }); |
| 452 | } |
| 453 | |
| 454 | function reconcileThreadTerminalSessionIds( |
| 455 | state: ThreadTerminalUiState, |
| 456 | nextIds: string[], |
| 457 | ): ThreadTerminalUiState { |
| 458 | const normalized = normalizeThreadTerminalUiState(state); |
| 459 | if (arraysEqual(normalized.terminalIds, nextIds)) { |
| 460 | return normalized; |
| 461 | } |
| 462 | |
| 463 | const nextActiveTerminalId = nextIds.includes(normalized.activeTerminalId) |
| 464 | ? normalized.activeTerminalId |
| 465 | : (nextIds[0] ?? ""); |
| 466 | |
| 467 | const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds); |
| 468 | const activeGroupIdFromTerminal = |
| 469 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null; |
| 470 | |
| 471 | return normalizeThreadTerminalUiState({ |
| 472 | ...normalized, |
| 473 | terminalIds: nextIds, |
| 474 | activeTerminalId: nextActiveTerminalId, |
| 475 | terminalGroups, |
| 476 | activeTerminalGroupId: activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", |
| 477 | }); |
| 478 | } |
| 479 | |
| 480 | export function selectThreadTerminalUiState( |
| 481 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, |
| 482 | threadRef: ScopedThreadRef | null | undefined, |
| 483 | ): ThreadTerminalUiState { |
| 484 | if (!threadRef || threadRef.threadId.length === 0) { |
| 485 | return getDefaultThreadTerminalUiState(); |
| 486 | } |
| 487 | return ( |
| 488 | terminalUiStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalUiState() |
| 489 | ); |
| 490 | } |
| 491 | |
| 492 | function updateTerminalUiStateByThreadKey( |
| 493 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, |
| 494 | threadRef: ScopedThreadRef, |
| 495 | updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState, |
| 496 | ): Record<string, ThreadTerminalUiState> { |
| 497 | if (threadRef.threadId.length === 0) { |
| 498 | return terminalUiStateByThreadKey; |
| 499 | } |
| 500 | |
| 501 | const threadKey = terminalThreadKey(threadRef); |
| 502 | const current = selectThreadTerminalUiState(terminalUiStateByThreadKey, threadRef); |
| 503 | const next = updater(current); |
| 504 | if (next === current) { |
| 505 | return terminalUiStateByThreadKey; |
| 506 | } |
| 507 | |
| 508 | if (isDefaultThreadTerminalUiState(next)) { |
| 509 | if (terminalUiStateByThreadKey[threadKey] === undefined) { |
| 510 | return terminalUiStateByThreadKey; |
| 511 | } |
| 512 | const { [threadKey]: _removed, ...rest } = terminalUiStateByThreadKey; |
| 513 | return rest; |
| 514 | } |
| 515 | |
| 516 | return { |
| 517 | ...terminalUiStateByThreadKey, |
| 518 | [threadKey]: next, |
| 519 | }; |
| 520 | } |
| 521 | |
| 522 | function updateSuppressedTerminalId( |
| 523 | suppressedTerminalIdsByThreadKey: Record<string, string[]>, |
| 524 | threadRef: ScopedThreadRef, |
| 525 | terminalId: string, |
| 526 | suppressed: boolean, |
| 527 | ): Record<string, string[]> { |
| 528 | const normalizedTerminalId = terminalId.trim(); |
| 529 | if (normalizedTerminalId.length === 0) { |
| 530 | return suppressedTerminalIdsByThreadKey; |
| 531 | } |
| 532 | const threadKey = terminalThreadKey(threadRef); |
| 533 | const currentIds = suppressedTerminalIdsByThreadKey[threadKey] ?? []; |
| 534 | const currentlySuppressed = currentIds.includes(normalizedTerminalId); |
| 535 | if (currentlySuppressed === suppressed) { |
| 536 | return suppressedTerminalIdsByThreadKey; |
| 537 | } |
| 538 | if (suppressed) { |
| 539 | return { |
| 540 | ...suppressedTerminalIdsByThreadKey, |
| 541 | [threadKey]: [...currentIds, normalizedTerminalId], |
| 542 | }; |
| 543 | } |
| 544 | |
| 545 | const remainingIds = currentIds.filter((id) => id !== normalizedTerminalId); |
| 546 | if (remainingIds.length > 0) { |
| 547 | return { |
| 548 | ...suppressedTerminalIdsByThreadKey, |
| 549 | [threadKey]: remainingIds, |
| 550 | }; |
| 551 | } |
| 552 | return removeRecordEntry(suppressedTerminalIdsByThreadKey, threadKey); |
| 553 | } |
| 554 | |
| 555 | function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<string, T> { |
| 556 | if (record[key] === undefined) { |
| 557 | return record; |
| 558 | } |
| 559 | const { [key]: _removed, ...remaining } = record; |
| 560 | return remaining; |
| 561 | } |
| 562 | |
| 563 | interface TerminalUiStateStoreState { |
| 564 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>; |
| 565 | /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ |
| 566 | suppressedTerminalIdsByThreadKey: Record<string, string[]>; |
| 567 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; |
| 568 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; |
| 569 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 570 | splitTerminalVertical: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 571 | newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 572 | ensureTerminal: ( |
| 573 | threadRef: ScopedThreadRef, |
| 574 | terminalId: string, |
| 575 | options?: { open?: boolean; active?: boolean }, |
| 576 | ) => void; |
| 577 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 578 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 579 | reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; |
| 580 | clearTerminalUiState: (threadRef: ScopedThreadRef) => void; |
| 581 | removeTerminalUiState: (threadRef: ScopedThreadRef) => void; |
| 582 | removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void; |
| 583 | } |
| 584 | |
| 585 | export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 586 | persist( |
| 587 | (set, get) => { |
| 588 | const updateTerminal = ( |
| 589 | threadRef: ScopedThreadRef, |
| 590 | updater: ( |
| 591 | state: ThreadTerminalUiState, |
| 592 | suppressedTerminalIds: readonly string[], |
| 593 | ) => ThreadTerminalUiState, |
| 594 | suppression?: { terminalId: string; suppressed: boolean }, |
| 595 | ) => { |
| 596 | set((state) => { |
| 597 | const threadKey = terminalThreadKey(threadRef); |
| 598 | const suppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] ?? []; |
| 599 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( |
| 600 | state.terminalUiStateByThreadKey, |
| 601 | threadRef, |
| 602 | (terminalState) => updater(terminalState, suppressedTerminalIds), |
| 603 | ); |
| 604 | const nextSuppressedTerminalIdsByThreadKey = suppression |
| 605 | ? updateSuppressedTerminalId( |
| 606 | state.suppressedTerminalIdsByThreadKey, |
| 607 | threadRef, |
| 608 | suppression.terminalId, |
| 609 | suppression.suppressed, |
| 610 | ) |
| 611 | : state.suppressedTerminalIdsByThreadKey; |
| 612 | if ( |
| 613 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && |
| 614 | nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey |
| 615 | ) { |
| 616 | return state; |
| 617 | } |
| 618 | return { |
| 619 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 620 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, |
| 621 | }; |
| 622 | }); |
| 623 | }; |
| 624 | |
| 625 | return { |
| 626 | terminalUiStateByThreadKey: {}, |
| 627 | suppressedTerminalIdsByThreadKey: {}, |
| 628 | setTerminalOpen: (threadRef, open) => { |
| 629 | const terminalState = selectThreadTerminalUiState( |
| 630 | get().terminalUiStateByThreadKey, |
| 631 | threadRef, |
| 632 | ); |
| 633 | updateTerminal( |
| 634 | threadRef, |
| 635 | (state) => setThreadTerminalOpen(state, open), |
| 636 | open && terminalState.terminalIds.length === 0 |
| 637 | ? { terminalId: DEFAULT_THREAD_TERMINAL_ID, suppressed: false } |
| 638 | : undefined, |
| 639 | ); |
| 640 | }, |
| 641 | setTerminalHeight: (threadRef, height) => |
| 642 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), |
| 643 | splitTerminal: (threadRef, terminalId) => |
| 644 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId), { |
| 645 | terminalId, |
| 646 | suppressed: false, |
| 647 | }), |
| 648 | splitTerminalVertical: (threadRef, terminalId) => |
| 649 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical"), { |
| 650 | terminalId, |
| 651 | suppressed: false, |
| 652 | }), |
| 653 | newTerminal: (threadRef, terminalId) => |
| 654 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId), { |
| 655 | terminalId, |
| 656 | suppressed: false, |
| 657 | }), |
| 658 | ensureTerminal: (threadRef, terminalId, options) => |
| 659 | updateTerminal( |
| 660 | threadRef, |
| 661 | (state) => { |
| 662 | let nextState = state; |
| 663 | if (!state.terminalIds.includes(terminalId)) { |
| 664 | nextState = newThreadTerminal(nextState, terminalId); |
| 665 | } |
| 666 | if (options?.active === false) { |
| 667 | nextState = { |
| 668 | ...nextState, |
| 669 | activeTerminalId: state.activeTerminalId, |
| 670 | activeTerminalGroupId: state.activeTerminalGroupId, |
| 671 | }; |
| 672 | } |
| 673 | if (options?.active ?? true) { |
| 674 | nextState = setThreadActiveTerminal(nextState, terminalId); |
| 675 | } |
| 676 | if (options?.open) { |
| 677 | nextState = setThreadTerminalOpen(nextState, true); |
| 678 | } |
| 679 | return normalizeThreadTerminalUiState(nextState); |
| 680 | }, |
| 681 | { terminalId, suppressed: false }, |
| 682 | ), |
| 683 | setActiveTerminal: (threadRef, terminalId) => |
| 684 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), |
| 685 | closeTerminal: (threadRef, terminalId) => |
| 686 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { |
| 687 | terminalId, |
| 688 | suppressed: true, |
| 689 | }), |
| 690 | reconcileTerminalIds: (threadRef, nextIds) => |
| 691 | updateTerminal(threadRef, (state, suppressedTerminalIds) => { |
| 692 | if (suppressedTerminalIds.length === 0) { |
| 693 | return reconcileThreadTerminalSessionIds(state, nextIds); |
| 694 | } |
| 695 | const suppressedIds = new Set(suppressedTerminalIds); |
| 696 | return reconcileThreadTerminalSessionIds( |
| 697 | state, |
| 698 | nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), |
| 699 | ); |
| 700 | }), |
| 701 | clearTerminalUiState: (threadRef) => |
| 702 | set((state) => { |
| 703 | const threadKey = terminalThreadKey(threadRef); |
| 704 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( |
| 705 | state.terminalUiStateByThreadKey, |
| 706 | threadRef, |
| 707 | () => createDefaultThreadTerminalUiState(), |
| 708 | ); |
| 709 | const hadSuppressedTerminalIds = |
| 710 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; |
| 711 | if ( |
| 712 | nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && |
| 713 | !hadSuppressedTerminalIds |
| 714 | ) { |
| 715 | return state; |
| 716 | } |
| 717 | return { |
| 718 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 719 | suppressedTerminalIdsByThreadKey: removeRecordEntry( |
| 720 | state.suppressedTerminalIdsByThreadKey, |
| 721 | threadKey, |
| 722 | ), |
| 723 | }; |
| 724 | }), |
| 725 | removeTerminalUiState: (threadRef) => |
| 726 | set((state) => { |
| 727 | const threadKey = terminalThreadKey(threadRef); |
| 728 | const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; |
| 729 | const hadSuppressedTerminalIds = |
| 730 | state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; |
| 731 | if (!hadTerminalUiState && !hadSuppressedTerminalIds) { |
| 732 | return state; |
| 733 | } |
| 734 | return { |
| 735 | terminalUiStateByThreadKey: removeRecordEntry( |
| 736 | state.terminalUiStateByThreadKey, |
| 737 | threadKey, |
| 738 | ), |
| 739 | suppressedTerminalIdsByThreadKey: removeRecordEntry( |
| 740 | state.suppressedTerminalIdsByThreadKey, |
| 741 | threadKey, |
| 742 | ), |
| 743 | }; |
| 744 | }), |
| 745 | removeOrphanedTerminalUiStates: (activeThreadKeys) => |
| 746 | set((state) => { |
| 747 | const orphanedIds = new Set( |
| 748 | [ |
| 749 | ...Object.keys(state.terminalUiStateByThreadKey), |
| 750 | ...Object.keys(state.suppressedTerminalIdsByThreadKey), |
| 751 | ].filter((key) => !activeThreadKeys.has(key)), |
| 752 | ); |
| 753 | if (orphanedIds.size === 0) { |
| 754 | return state; |
| 755 | } |
| 756 | const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey }; |
| 757 | const nextSuppressedTerminalIdsByThreadKey = { |
| 758 | ...state.suppressedTerminalIdsByThreadKey, |
| 759 | }; |
| 760 | for (const id of orphanedIds) { |
| 761 | delete nextTerminalUiStateByThreadKey[id]; |
| 762 | delete nextSuppressedTerminalIdsByThreadKey[id]; |
| 763 | } |
| 764 | return { |
| 765 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, |
| 766 | suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, |
| 767 | }; |
| 768 | }), |
| 769 | }; |
| 770 | }, |
| 771 | { |
| 772 | name: TERMINAL_UI_STATE_STORAGE_KEY, |
| 773 | version: 4, |
| 774 | storage: createJSONStorage(createTerminalUiStateStorage), |
| 775 | migrate: migratePersistedTerminalUiStateStoreState, |
| 776 | partialize: (state) => ({ |
| 777 | terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, |
| 778 | }), |
| 779 | }, |
| 780 | ), |
| 781 | ); |