189 lines · 7.2 KB · TypeScript
RawDownload
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";import type { EnvironmentId } from "@t3tools/contracts"; export type ThreadMoveDestination =  | "up"  | "down"  | {      readonly targetId: string | null;      readonly section?: "pinned" | "active" | "settled";      readonly placement: "before" | "after";    }; /** Resolve against stable row identities, including rows hidden by a filter. */export function threadOrderAfterMove(  orderedIds: readonly string[],  movedId: string,  destination: ThreadMoveDestination,): string[] | null {  if (typeof destination === "object" && destination.section === "settled") return null;  const from = orderedIds.indexOf(movedId);  if (from < 0 && (typeof destination === "string" || destination.section === undefined))    return null;  const result = orderedIds.filter((id) => id !== movedId);  let to: number;  if (typeof destination === "string") {    to = from + (destination === "up" ? -1 : 1);    if (to < 0 || to >= orderedIds.length) return null;  } else {    if (destination.targetId === null) {      if (destination.section === undefined) return null;      to = destination.placement === "before" ? 0 : result.length;    } else {      const target = result.indexOf(destination.targetId);      if (target < 0) return null;      to = target + (destination.placement === "after" ? 1 : 0);    }  }  if (to === from) return null;  result.splice(to, 0, movedId);  return result;} type OrderRow = Pick<  EnvironmentThreadShell,  | "id"  | "environmentId"  | "pinOrderKey"  | "activeOrderKey"  | "createdAt"  | "unsettledAt"  | "pinnedAt">; export interface PendingThreadOrder {  readonly section: "pinned" | "active";  readonly orderedIds: readonly string[];  readonly before: ReadonlyMap<string, { readonly key: string | null; readonly anchor: string }>;  readonly assignments: ReadonlyMap<string, string>;  readonly confirmed: ReadonlySet<string>;  readonly commandsComplete: boolean;} function rowId(row: OrderRow): string {  return `${row.environmentId}:${row.id}`;} function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) {  return {    key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null,    anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt),  };} /** Keep every visible row as an anchor, but only offer plans whose key writes * are supported. Menu availability and execution use this same planner. */export function createThreadMovePlanner(input: {  readonly ordered: readonly OrderRow[];  readonly allThreads?: readonly OrderRow[];  readonly section: PendingThreadOrder["section"];  readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;}) {  const orderedIds = input.ordered.map(rowId);  const keysById = new Map(    (input.allThreads ?? input.ordered).map((row) => [      rowId(row),      rowOrder(row, input.section).key,    ]),  );  const writableIds = new Set(    (input.allThreads ?? input.ordered)      .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))      .map(rowId),  );  return (movedId: string, direction: ThreadMoveDestination) => {    if (!writableIds.has(movedId)) return null;    const nextIds = threadOrderAfterMove(orderedIds, movedId, direction);    if (nextIds === null) return null;    const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId });    return assignments === null ||      assignments.length === 0 ||      assignments.some((assignment) => !writableIds.has(assignment.id))      ? null      : assignments;  };} export function createPendingThreadOrder(input: {  readonly section: PendingThreadOrder["section"];  readonly ordered: readonly OrderRow[];  readonly movedId: string;  readonly direction: ThreadMoveDestination;  readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];}): PendingThreadOrder {  const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction);  if (orderedIds === null) throw new Error("Cannot begin an invalid thread move");  return {    section: input.section,    orderedIds,    before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])),    assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])),    confirmed: new Set(),    commandsComplete: false,  };} /** Receipts and shell updates arrive independently. Only our own key writes * may pass through the hold; membership and other arrangement changes win. */export function reconcilePendingThreadOrder(  pending: PendingThreadOrder,  ordered: readonly OrderRow[],): PendingThreadOrder | null {  if (ordered.length !== pending.before.size) return null;  const confirmed = new Set(pending.confirmed);  for (const row of ordered) {    const id = rowId(row);    const before = pending.before.get(id);    const current = rowOrder(row, pending.section);    if (before === undefined || current.anchor !== before.anchor) return null;    const assigned = pending.assignments.get(id);    if (assigned !== undefined && current.key === assigned) confirmed.add(id);    else if (current.key !== before.key || confirmed.has(id)) return null;  }  if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null;  return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed };} /** Apply the full section's pending order after search/environment filtering. */export function applyPendingThreadOrder<T extends OrderRow>(  rows: readonly T[],  section: PendingThreadOrder["section"],  pending: PendingThreadOrder | null | undefined,): T[] {  if (pending == null || pending.section !== section) return [...rows];  const rank = new Map(pending.orderedIds.map((id, index) => [id, index]));  return [...rows].sort(    (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity),  );} /** Match desktop re-entry: a pin wakes the thread on the server; Active clears * each underlying parked state before assigning its destination order key. */export function threadDropLifecycle(  thread: EnvironmentThreadShell,  section: "pinned" | "active",  now: string,) {  if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false };  return {    pin: false,    unpin: thread.pinnedAt != null,    unsettle: thread.settledOverride === "settled",    unsnooze: effectiveSnoozed(thread, { now }),  };} export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled"; /** The action shown during hover describes the lifecycle change made on drop. */export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) {  if (destination === "snoozed") return null;  if (destination === "settled") return source === "settled" ? null : "Settle";  if (source === destination) return "Reorder";  if (destination === "pinned") return "Pin";  if (source === "pinned") return "Unpin";  if (source === "settled") return "Unsettle";  return "Unsnooze";}