| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { |
| 3 | generateSpreadPinOrderKeys, |
| 4 | pinOrderKeyBetween, |
| 5 | planPinnedReorder, |
| 6 | } from "@t3tools/client-runtime/state/thread-sort"; |
| 7 | import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; |
| 8 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 9 | |
| 10 | export type ThreadMoveDestination = |
| 11 | | "up" |
| 12 | | "down" |
| 13 | | { |
| 14 | readonly targetId: string | null; |
| 15 | readonly section?: "pinned" | "active" | "settled"; |
| 16 | readonly placement: "before" | "after"; |
| 17 | }; |
| 18 | |
| 19 | /** Resolve against stable row identities, including rows hidden by a filter. */ |
| 20 | export function threadOrderAfterMove( |
| 21 | orderedIds: readonly string[], |
| 22 | movedId: string, |
| 23 | destination: ThreadMoveDestination, |
| 24 | ): string[] | null { |
| 25 | if (typeof destination === "object" && destination.section === "settled") return null; |
| 26 | const from = orderedIds.indexOf(movedId); |
| 27 | if (from < 0 && (typeof destination === "string" || destination.section === undefined)) |
| 28 | return null; |
| 29 | const result = orderedIds.filter((id) => id !== movedId); |
| 30 | let to: number; |
| 31 | if (typeof destination === "string") { |
| 32 | to = from + (destination === "up" ? -1 : 1); |
| 33 | if (to < 0 || to >= orderedIds.length) return null; |
| 34 | } else { |
| 35 | if (destination.targetId === null) { |
| 36 | if (destination.section === undefined) return null; |
| 37 | to = destination.placement === "before" ? 0 : result.length; |
| 38 | } else { |
| 39 | const target = result.indexOf(destination.targetId); |
| 40 | if (target < 0) return null; |
| 41 | to = target + (destination.placement === "after" ? 1 : 0); |
| 42 | } |
| 43 | } |
| 44 | if (to === from) return null; |
| 45 | result.splice(to, 0, movedId); |
| 46 | return result; |
| 47 | } |
| 48 | |
| 49 | export type OrderRow = Pick< |
| 50 | EnvironmentThreadShell, |
| 51 | | "id" |
| 52 | | "environmentId" |
| 53 | | "pinOrderKey" |
| 54 | | "activeOrderKey" |
| 55 | | "createdAt" |
| 56 | | "unsettledAt" |
| 57 | | "pinnedAt" |
| 58 | >; |
| 59 | |
| 60 | export interface PendingThreadOrder { |
| 61 | readonly section: "pinned" | "active"; |
| 62 | readonly orderedIds: readonly string[]; |
| 63 | readonly before: ReadonlyMap<string, { readonly key: string | null; readonly anchor: string }>; |
| 64 | readonly assignments: ReadonlyMap<string, string>; |
| 65 | readonly confirmed: ReadonlySet<string>; |
| 66 | readonly commandsComplete: boolean; |
| 67 | } |
| 68 | |
| 69 | function rowId(row: OrderRow): string { |
| 70 | return `${row.environmentId}:${row.id}`; |
| 71 | } |
| 72 | |
| 73 | function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) { |
| 74 | return { |
| 75 | key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null, |
| 76 | anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt), |
| 77 | }; |
| 78 | } |
| 79 | |
| 80 | /** Keep every visible row as an anchor, but only offer plans whose key writes |
| 81 | * are supported. Menu availability and execution use this same planner. */ |
| 82 | export function createThreadMovePlanner(input: { |
| 83 | readonly ordered: readonly OrderRow[]; |
| 84 | readonly allThreads?: readonly OrderRow[]; |
| 85 | readonly section: PendingThreadOrder["section"]; |
| 86 | readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>; |
| 87 | }) { |
| 88 | const orderedIds = input.ordered.map(rowId); |
| 89 | const keysById = new Map( |
| 90 | (input.allThreads ?? input.ordered).map((row) => [ |
| 91 | rowId(row), |
| 92 | rowOrder(row, input.section).key, |
| 93 | ]), |
| 94 | ); |
| 95 | const writableIds = new Set( |
| 96 | (input.allThreads ?? input.ordered) |
| 97 | .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) |
| 98 | .map(rowId), |
| 99 | ); |
| 100 | return (movedId: string, direction: ThreadMoveDestination) => { |
| 101 | if (!writableIds.has(movedId)) return null; |
| 102 | const nextIds = threadOrderAfterMove(orderedIds, movedId, direction); |
| 103 | if (nextIds === null) return null; |
| 104 | const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId }); |
| 105 | return assignments === null || |
| 106 | assignments.length === 0 || |
| 107 | assignments.some((assignment) => !writableIds.has(assignment.id)) |
| 108 | ? null |
| 109 | : assignments; |
| 110 | }; |
| 111 | } |
| 112 | |
| 113 | export interface ThreadMoveAvailability { |
| 114 | readonly canMoveUp: boolean; |
| 115 | readonly canMoveDown: boolean; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Batch form of "call `createThreadMovePlanner` once per card": one pass over |
| 120 | * the section answers up/down availability for every ordered row, so list |
| 121 | * construction stays linear instead of one full planner probe (array copies, |
| 122 | * hidden-key rescans) per row on the minute-tick rebuild path. The mirror of |
| 123 | * the planner's plan rules lives in the body below; the reference-parity test |
| 124 | * pins them row-by-row, including adversarial keys and hidden reservations. |
| 125 | */ |
| 126 | export function computeThreadMoveAvailability(input: { |
| 127 | readonly ordered: readonly OrderRow[]; |
| 128 | readonly allThreads?: readonly OrderRow[]; |
| 129 | readonly section: PendingThreadOrder["section"]; |
| 130 | readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>; |
| 131 | readonly pendingOrder?: PendingThreadOrder | null; |
| 132 | }): Map<string, ThreadMoveAvailability> { |
| 133 | const result = new Map<string, ThreadMoveAvailability>(); |
| 134 | // A reorder in flight locks the whole list until its receipt lands. |
| 135 | if (input.pendingOrder != null) return result; |
| 136 | const rows = input.ordered; |
| 137 | // With no neighbor there is no up/down move, so skip the allThreads indexes. |
| 138 | if (rows.length < 2) { |
| 139 | for (const row of rows) result.set(rowId(row), { canMoveUp: false, canMoveDown: false }); |
| 140 | return result; |
| 141 | } |
| 142 | // Plain loops: this runs per section on every Home rebuild over all shells, |
| 143 | // and the map/filter/spread chains it replaces dominated it with garbage. |
| 144 | const orderedIds: string[] = []; |
| 145 | const indexById = new Map<string, number>(); |
| 146 | for (const row of rows) { |
| 147 | const id = rowId(row); |
| 148 | indexById.set(id, orderedIds.length); |
| 149 | orderedIds.push(id); |
| 150 | } |
| 151 | const keysById = new Map<string, string | null>(); |
| 152 | const writableIds = new Set<string>(); |
| 153 | for (const row of input.allThreads ?? input.ordered) { |
| 154 | const id = rowId(row); |
| 155 | keysById.set(id, rowOrder(row, input.section).key); |
| 156 | if (input.reorderableEnvironmentIds.has(row.environmentId)) writableIds.add(id); |
| 157 | } |
| 158 | // Read after the loop so a duplicate id reserves only its final key. |
| 159 | const reservedKeys = new Set<string>(); |
| 160 | keysById.forEach((key, id) => { |
| 161 | if (key != null && !indexById.has(id)) reservedKeys.add(key); |
| 162 | }); |
| 163 | // Mirror of `planPinnedReorder` for adjacent swaps, hoisted so every row is |
| 164 | // answered in O(1) amortized instead of one planner probe per row: |
| 165 | // |
| 166 | // Fast path (both neighbors keyed): the fresh key between the landing |
| 167 | // neighbors, walking forward while hidden reserved keys block it. The walk |
| 168 | // depends only on the neighbor key pair - each adjacency is probed by at |
| 169 | // most two rows (down of the left member, up of the right member) - so the |
| 170 | // memo keeps even a fully adversarial reserved-key layout linear per build. |
| 171 | // |
| 172 | // Rewrite path (keyless/unusable neighbor, or key space exhausted): fresh |
| 173 | // spread keys for every row; only positions whose current key differs get |
| 174 | // written. A swap permutes two rows without changing the multiset, so the |
| 175 | // assignment set differs from the unswapped baseline at at most those two |
| 176 | // positions, and mismatch/writability tallies computed once per section |
| 177 | // answer each row with a constant-size delta. |
| 178 | const midpoints = new Map<string, string | null>(); |
| 179 | const fastPathKey = (beforeKey: string | null, afterKey: string | null): string | null => { |
| 180 | const memoKey = `${beforeKey ?? ""}\u0000${afterKey ?? ""}`; |
| 181 | const cached = midpoints.get(memoKey); |
| 182 | if (cached !== undefined) return cached; |
| 183 | let key = pinOrderKeyBetween(beforeKey, afterKey); |
| 184 | while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); |
| 185 | midpoints.set(memoKey, key); |
| 186 | return key; |
| 187 | }; |
| 188 | const spreadKeys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) |
| 189 | .filter((key) => !reservedKeys.has(key)) |
| 190 | .slice(0, orderedIds.length); |
| 191 | const currentKeys = orderedIds.map((id) => keysById.get(id) ?? null); |
| 192 | const writableRow = orderedIds.map((id) => writableIds.has(id)); |
| 193 | let baselineWrites = 0; |
| 194 | let baselineUnwritableWrites = 0; |
| 195 | for (let position = 0; position < orderedIds.length; position += 1) { |
| 196 | if (currentKeys[position] === spreadKeys[position]) continue; |
| 197 | baselineWrites += 1; |
| 198 | if (!writableRow[position]) baselineUnwritableWrites += 1; |
| 199 | } |
| 200 | // `movedId` swaps with its neighbor; ids at the two swapped positions change, |
| 201 | // so only their tally contributions are recomputed. |
| 202 | const rewriteViable = (index: number): boolean => { |
| 203 | let writes = baselineWrites; |
| 204 | let unwritableWrites = baselineUnwritableWrites; |
| 205 | for (const position of [index, index + 1]) { |
| 206 | const other = position === index ? index + 1 : index; |
| 207 | if (currentKeys[position] !== spreadKeys[position]) { |
| 208 | writes -= 1; |
| 209 | if (!writableRow[position]) unwritableWrites -= 1; |
| 210 | } |
| 211 | // After the swap this position holds the row that was at `other`. |
| 212 | if (currentKeys[other] !== spreadKeys[position]) { |
| 213 | writes += 1; |
| 214 | if (!writableRow[other]) unwritableWrites += 1; |
| 215 | } |
| 216 | } |
| 217 | return writes > 0 && unwritableWrites === 0; |
| 218 | }; |
| 219 | for (const row of rows) { |
| 220 | const movedId = rowId(row); |
| 221 | const denied = { canMoveUp: false, canMoveDown: false }; |
| 222 | if (!writableIds.has(movedId)) { |
| 223 | result.set(movedId, denied); |
| 224 | continue; |
| 225 | } |
| 226 | const index = indexById.get(movedId); |
| 227 | if (index === undefined) { |
| 228 | result.set(movedId, denied); |
| 229 | continue; |
| 230 | } |
| 231 | const adjacentAvailable = (towardUp: boolean): boolean => { |
| 232 | const shifted = index + (towardUp ? -1 : 1); |
| 233 | if (shifted < 0 || shifted >= orderedIds.length) return false; |
| 234 | // The swap exchanges the row with its neighbor; afterwards the moved row |
| 235 | // sits at `shifted` between `beforeIndex` and `afterIndex` of the OLD |
| 236 | // order: moving up it lands between old(index-2) and old(index-1), |
| 237 | // moving down between old(index+1) and old(index+2). |
| 238 | const beforeIndex = towardUp ? index - 2 : index + 1; |
| 239 | const afterIndex = towardUp ? index - 1 : index + 2; |
| 240 | const beforeId = beforeIndex < 0 ? null : (orderedIds[beforeIndex] ?? null); |
| 241 | const afterId = afterIndex >= orderedIds.length ? null : (orderedIds[afterIndex] ?? null); |
| 242 | const beforeKey = beforeId === null ? null : (keysById.get(beforeId) ?? null); |
| 243 | const afterKey = afterId === null ? null : (keysById.get(afterId) ?? null); |
| 244 | if ((beforeId === null || beforeKey != null) && (afterId === null || afterKey != null)) { |
| 245 | const key = fastPathKey(beforeKey, afterKey); |
| 246 | // A fresh key is a single-write plan for the (writable) moved row. |
| 247 | if (key !== null) return true; |
| 248 | } |
| 249 | // Keyless neighbor or exhausted key space: the section rewrite runs. |
| 250 | return rewriteViable(Math.min(index, shifted)); |
| 251 | }; |
| 252 | result.set(movedId, { |
| 253 | canMoveUp: adjacentAvailable(true), |
| 254 | canMoveDown: adjacentAvailable(false), |
| 255 | }); |
| 256 | } |
| 257 | return result; |
| 258 | } |
| 259 | |
| 260 | export function createPendingThreadOrder(input: { |
| 261 | readonly section: PendingThreadOrder["section"]; |
| 262 | readonly ordered: readonly OrderRow[]; |
| 263 | readonly movedId: string; |
| 264 | readonly direction: ThreadMoveDestination; |
| 265 | readonly assignments: readonly { readonly id: string; readonly orderKey: string }[]; |
| 266 | }): PendingThreadOrder { |
| 267 | const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction); |
| 268 | if (orderedIds === null) throw new Error("Cannot begin an invalid thread move"); |
| 269 | return { |
| 270 | section: input.section, |
| 271 | orderedIds, |
| 272 | before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])), |
| 273 | assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])), |
| 274 | confirmed: new Set(), |
| 275 | commandsComplete: false, |
| 276 | }; |
| 277 | } |
| 278 | |
| 279 | /** Receipts and shell updates arrive independently. Only our own key writes |
| 280 | * may pass through the hold; membership and other arrangement changes win. */ |
| 281 | export function reconcilePendingThreadOrder( |
| 282 | pending: PendingThreadOrder, |
| 283 | ordered: readonly OrderRow[], |
| 284 | ): PendingThreadOrder | null { |
| 285 | if (ordered.length !== pending.before.size) return null; |
| 286 | const confirmed = new Set(pending.confirmed); |
| 287 | for (const row of ordered) { |
| 288 | const id = rowId(row); |
| 289 | const before = pending.before.get(id); |
| 290 | const current = rowOrder(row, pending.section); |
| 291 | if (before === undefined || current.anchor !== before.anchor) return null; |
| 292 | const assigned = pending.assignments.get(id); |
| 293 | if (assigned !== undefined && current.key === assigned) confirmed.add(id); |
| 294 | else if (current.key !== before.key || confirmed.has(id)) return null; |
| 295 | } |
| 296 | if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null; |
| 297 | return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed }; |
| 298 | } |
| 299 | |
| 300 | /** Apply the full section's pending order after search/environment filtering. */ |
| 301 | export function applyPendingThreadOrder<T extends OrderRow>( |
| 302 | rows: readonly T[], |
| 303 | section: PendingThreadOrder["section"], |
| 304 | pending: PendingThreadOrder | null | undefined, |
| 305 | ): T[] { |
| 306 | if (pending == null || pending.section !== section) return [...rows]; |
| 307 | const rank = new Map(pending.orderedIds.map((id, index) => [id, index])); |
| 308 | return [...rows].sort( |
| 309 | (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity), |
| 310 | ); |
| 311 | } |
| 312 | |
| 313 | /** Match desktop re-entry: a pin wakes the thread on the server; Active clears |
| 314 | * each underlying parked state before assigning its destination order key. */ |
| 315 | export function threadDropLifecycle( |
| 316 | thread: EnvironmentThreadShell, |
| 317 | section: "pinned" | "active", |
| 318 | now: string, |
| 319 | ) { |
| 320 | if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false }; |
| 321 | return { |
| 322 | pin: false, |
| 323 | unpin: thread.pinnedAt != null, |
| 324 | unsettle: thread.settledOverride === "settled", |
| 325 | unsnooze: effectiveSnoozed(thread, { now }), |
| 326 | }; |
| 327 | } |
| 328 | |
| 329 | export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled"; |
| 330 | |
| 331 | /** The action shown during hover describes the lifecycle change made on drop. */ |
| 332 | export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) { |
| 333 | if (destination === "snoozed") return null; |
| 334 | if (destination === "settled") return source === "settled" ? null : "Settle"; |
| 335 | if (source === destination) return "Reorder"; |
| 336 | if (destination === "pinned") return "Pin"; |
| 337 | if (source === "pinned") return "Unpin"; |
| 338 | if (source === "settled") return "Unsettle"; |
| 339 | return "Unsnooze"; |
| 340 | } |