File size: 18,872 Bytes
7c1820c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
// ---------------------------------------------------------------------------
// customer-grid / folders.ts
// Wave-8 I11c (contract C4) β€” the folder MODEL for the Views and Cohorts rails,
// pure and React-free so verify_folders.py can run it under node.
//
// One level, deliberately. Nesting brings cycle-checking, move-into-your-own-
// descendant, and recursive delete semantics with it; the owner asked for
// folders you can drag things into, and a flat model is the whole of that.
//
// THE PART THAT IS EASY TO GET WRONG β€” the echo. Folder operations ride the
// same once-by-id event log as everything else, so between the emit and the
// host's echo there is a window where the payload still describes the world as
// it was BEFORE the click. Render that naively and a just-deleted folder
// reappears for one round trip (the "delete blip"), a rename flickers back to
// the old name, and a dragged view jumps home. So this module reconciles the
// host's copy against this browser's own recent stamps, exactly as
// optimism.ts::reconcileFields does for fields β€” same ECHO_RECENT_MS window,
// same rule that a STALE stamp yields to the host (divergence is not an echo).
// ---------------------------------------------------------------------------

import { ECHO_RECENT_MS } from "./viewEcho";
import type { GridFolder } from "./types";

/** What this browser did recently, by folder id / item id. Values are epoch ms

 *  from THIS machine's clock β€” both sides of every comparison are local, so

 *  this is not the tenant-day rule (that one is about two ENGINES agreeing). */
export interface FolderStamps {
  created?: Record<string, number>;
  renamed?: Record<string, { at: number; name: string }>;
  deleted?: Record<string, number>;
  /** itemId -> {at, folderId} for a drag this browser just performed. */
  moved?: Record<string, { at: number; folderId: string | null }>;
  /**

   * WAVE 20 item 19 (C-FOLDER-REORDER) β€” the FULL folder order this browser just set.

   *

   * ONE stamp, not one per folder, because a reorder is one decision about a list: the

   * order the user dropped into is the order they want, and reconstructing it from N

   * per-folder stamps would let two of them age out at different moments and leave a

   * sequence nobody ever chose. The host answers with `order` NUMBERS on each folder

   * (that is the durable form); this is what to render until it does.

   */
  ordered?: { at: number; order: string[] };
  /**

   * ⭐ WAVE 27 Β· OWNER ITEM 5 (contract C7) β€” the FULL VIEW order this browser just set.

   *

   * A separate stamp from `ordered` above, deliberately, even though both are "a list this

   * browser dragged into shape": they age independently and they are different decisions. One

   * stamp holding both would make reordering a folder revive a view order the user had already

   * let go of, and vice versa β€” the exact "a sequence nobody ever chose" failure `ordered`'s own

   * note refuses one level down.

   */
  orderedViews?: { at: number; order: string[] };
}

export const FOLDER_STAMP_MAX = 64;

const isRecent = (t: number | undefined, now: number): boolean =>
  typeof t === "number" && now - t <= ECHO_RECENT_MS;

/** Drop stamps past the echo window so the map cannot grow without bound and a

 *  long-lived tab cannot keep asserting an edit nobody remembers. */
export function pruneFolderStamps(stamps: FolderStamps | undefined, now: number): FolderStamps {
  const out: FolderStamps = {};
  const keepNum = (rec: Record<string, number> | undefined) => {
    if (!rec) return undefined;
    const kept = Object.entries(rec).filter(([, t]) => isRecent(t, now));
    return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
  };
  const keepObj = <T extends { at: number }>(rec: Record<string, T> | undefined) => {
    if (!rec) return undefined;
    const kept = Object.entries(rec).filter(([, v]) => isRecent(v.at, now));
    return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
  };
  const created = keepNum(stamps?.created);
  const renamed = keepObj(stamps?.renamed);
  const deleted = keepNum(stamps?.deleted);
  const moved = keepObj(stamps?.moved);
  if (created) out.created = created;
  if (renamed) out.renamed = renamed;
  if (deleted) out.deleted = deleted;
  if (moved) out.moved = moved;
  // Item 19: a single stamp, so it is kept or dropped whole β€” pruning it by halves is
  // exactly the partial sequence the field's own note refuses.
  if (isRecent(stamps?.ordered?.at, now) && stamps?.ordered) out.ordered = stamps.ordered;
  // Item 5 (C7): the same rule for the VIEW order, kept or dropped whole for the same reason.
  if (isRecent(stamps?.orderedViews?.at, now) && stamps?.orderedViews)
    out.orderedViews = stamps.orderedViews;
  return out;
}

/**

 * ⭐ WAVE 27 Β· OWNER ITEM 5 (contract C7) β€” the rail's view order, as this browser last set it.

 *

 * β›” WHY IT IS NEEDED AT ALL: the server assembles view order (`aios_grid.py:1602-1647`) and the

 * echo is one round trip behind the drop. Without this the row springs back to its old place the

 * instant the workspace refreshes, which reads as "the drag did not work" β€” the NO-BLIP law's

 * subject, applied to a sequence instead of to a value.

 *

 * ⚠ PAST THE ECHO WINDOW THE SERVER WINS, unconditionally. That asymmetry is the whole design of

 * this module: inside the window a local drag is newer truth; outside it, a difference between

 * the copies is divergence between SESSIONS, and the durable store decides.

 *

 * β›” IDS THE STAMP DOES NOT NAME KEEP THEIR SERVER ORDER, appended after the named ones β€” the

 * same rule the host applies to a `folder_reorder` payload. A view created in another tab since

 * the drop is not evidence that the drop was wrong; dropping it would be this function deleting

 * a view from the rail to defend a sequence.

 */
export function applyViewOrder<T extends { id: string }>(
  views: T[],
  stamps: FolderStamps | undefined,
  now: number
): T[] {
  const stamp = stamps?.orderedViews;
  if (!stamp || !isRecent(stamp.at, now) || !Array.isArray(stamp.order)) return views;
  const byId = new Map(views.map((v) => [v.id, v]));
  const out: T[] = [];
  const placed = new Set<string>();
  for (const id of stamp.order) {
    const v = byId.get(id);
    if (!v || placed.has(id)) continue;
    placed.add(id);
    out.push(v);
  }
  for (const v of views) if (!placed.has(v.id)) out.push(v);
  return out;
}

/**

 * The host's folder list, corrected by what this browser just did.

 *

 *   deleted recently  -> DROP it, even though the echo still lists it

 *                        (the tombstone rule; without this a deleted folder

 *                        blinks back for one round trip)

 *   renamed recently  -> keep OUR name until the echo carries it

 *   created recently  -> keep OURS if the echo has not caught up yet

 *

 * Everything stale yields to the host: past the window, a difference between

 * the copies is divergence between sessions, and host state is the durable

 * truth. That asymmetry is the whole design.

 */
export function reconcileFolders(

  hostFolders: GridFolder[] | undefined,

  localFolders: GridFolder[] | undefined,

  stamps: FolderStamps | undefined,

  now: number

): GridFolder[] {
  const host = hostFolders ?? [];
  const out: GridFolder[] = [];
  const seen = new Set<string>();

  for (const f of host) {
    if (isRecent(stamps?.deleted?.[f.id], now)) continue; // tombstone
    seen.add(f.id);
    const rename = stamps?.renamed?.[f.id];
    out.push(isRecent(rename?.at, now) && rename ? { ...f, name: rename.name } : f);
  }

  // A folder this browser created that the echo has not yet returned. Skipped
  // when it was also deleted since β€” creating and deleting inside one window
  // must net to nothing, not to a ghost.
  for (const f of localFolders ?? []) {
    if (seen.has(f.id)) continue;
    if (!isRecent(stamps?.created?.[f.id], now)) continue;
    if (isRecent(stamps?.deleted?.[f.id], now)) continue;
    out.push(f);
  }

  out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));

  // ── WAVE 20 item 19 (C-FOLDER-REORDER): this browser's drag, until the echo carries it.
  //
  // Applied AFTER the host sort and as a SEPARATE pass, both deliberately:
  //   Β· the host's `order` numbers are the durable truth and stay the base sequence, so a
  //     folder the stamp never names keeps exactly the place the server gave it;
  //   Β· `Array.prototype.sort` is stable (ES2019), so every unnamed folder β€” one created in
  //     another tab between the drag and the echo, say β€” holds its relative position at the
  //     end instead of being flung to the front by a missing rank.
  // A stamped id that has since been DELETED needs no handling: the tombstone pass above
  // already dropped it, and `rank` is only ever consulted for folders that survived.
  const ordered = stamps?.ordered;
  if (isRecent(ordered?.at, now) && ordered) {
    const rank = new Map(ordered.order.map((id, i) => [id, i]));
    out.sort(
      (a, b) =>
        (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
        (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)
    );
  }
  return out;
}

/**

 * Where an item actually belongs right now: this browser's recent drag wins

 * over the host's echo, and a folder that no longer exists resolves to ROOT.

 *

 * The second half matters as much as the first. `folder_delete` moves contents

 * to root host-side, but the client sees the folder vanish one render before

 * the items' `folderId` is rewritten β€” and an item pointing at a folder nobody

 * renders would simply not appear in any group. Resolving a dangling ref to

 * root is what stops a folder delete from making views look deleted too.

 */
export function resolveFolderId(

  itemId: string,

  hostFolderId: string | null | undefined,

  folders: GridFolder[],

  stamps: FolderStamps | undefined,

  now: number

): string | null {
  const moved = stamps?.moved?.[itemId];
  const id = isRecent(moved?.at, now) && moved ? moved.folderId : (hostFolderId ?? null);
  if (id == null) return null;
  // ⚠ W32-T27 β€” the RESERVED root placement is not a dangling reference. It names no folder by
  // design, so the `folders.some(...)` test below would null it and hand the item straight back
  // to the Shared bucket, re-creating item 20 one layer down from where it was fixed.
  if (id === ROOT_FOLDER_ID) return ROOT_FOLDER_ID;
  return folders.some((f) => f.id === id) ? id : null;
}

export interface FolderGroup<T> {
  folder: GridFolder | null; // null = the root group
  items: T[];
}

/**

 * WAVE 20 item 18 / WAVE 21 item 9 (ruling R12, contract C1) β€” the SYNTHETIC folder

 * that every view shared WITH you appears under.

 *

 * β›” IT IS NOT A STORED FOLDER, and nothing may ever write one with this id. It has no

 * record in `folders`, no `order`, no icon, and the rail refuses every action on it

 * (`ViewSidebar` suppresses the row menu for exactly this id): it cannot be renamed into

 * something else, duplicated into a second copy of other people's work, or deleted. It is a

 * READING of the view list β€” "these arrived by grant" β€” rendered as a group because that is

 * the only shape this rail has for "a set of views with something in common".

 *

 * Declared HERE rather than in the component (where it lived through wave 20) so the

 * synthesis below is pure, and `verify_folders.py` can run it under node like every other

 * rule in this file. A constant a gate cannot reach is a contract nobody checks.

 */
export const SHARED_FOLDER_ID = "__shared__";
export const SHARED_FOLDER_NAME = "Shared with me";

/**

 * ⭐⭐ WAVE 32 Β· T27 (owner item 20) β€” **"FILED AT ROOT", AS A VALUE.**

 *

 * Owner: *a shared View cannot be moved out of the Shared folder.* The cause is that **root was

 * represented by ABSENCE at every layer**, and absence cannot distinguish two different facts:

 *

 *   Β· `folderId == null` because the receiver never filed this view β€” it should show under

 *     "Shared with me", which is where a grant LANDS;

 *   Β· `folderId == null` because the receiver deliberately dragged it OUT of that group.

 *

 * `groupByFolder` had to guess, and it guessed "shared" β€” so filing a shared view at root put it

 * straight back where it came from. **The root bucket was unreachable for a shared view by

 * construction**, which is exactly why only folder→folder moves ever appeared to work.

 *

 * β›” THE SENTINEL IS STORED, NOT DERIVED, AND THAT IS THE WHOLE FIX. Both other layers wrote the

 * same absence and must both learn this value: `core/grid_events.py`'s `item_move` branch

 * (`if target is None: cur.pop(item_id, None)  # back to the root`) and

 * `aios_grid.clean_item_folders`, whose own docstring states the defect one level deeper β€”

 * *"nothing stores 'this item is in no folder'"*. A client-only fix is impossible; there is

 * nothing to read back.

 *

 * ⚠ It is a RESERVED id in the same namespace as real folder ids, so `resolveFolderId` must pass

 * it through rather than treating it as dangling, and `clean_item_folders` must admit it beside

 * `fid in fids`. It is deliberately NOT rendered as a group: {@link groupByFolder} maps it onto

 * the ordinary root bucket, so nothing in the rail ever shows the word.

 */
export const ROOT_FOLDER_ID = "__root__";

/**

 * Group items into folders + a root bucket, in folder order, root LAST.

 *

 * Root last because the rails are read top-down and folders are the structure

 * the user made; ungrouped items are the leftovers. Every item appears exactly

 * once β€” a grouping that can drop an item would make a view look deleted.

 *

 * ⭐ WAVE 21 item 9 (R12/C1) β€” `isShared` adds the synthetic "Shared with me" group,

 * AFTER root, and three things about it are deliberate:

 *

 *   Β· **After root, not before it.** C1 says LAST in as many words. It reads correctly

 *     too: the rail is "my folders, my loose views, and then other people's".

 *   Β· **A shared view the receiver has FILED still goes to their folder.** The `__shared__`

 *     group is where a grant LANDS, not a cage it stays in β€” the rail's own note calls

 *     moving out "per-receiver placement" and that must keep working. So the synthetic

 *     group collects only the shared views that resolved to ROOT.

 *   Β· **An empty group does not render.** A folder head with nothing under it says "here is

 *     something you cannot reach" β€” the same reason `foldNav` drops empty nav folders.

 *

 * Omitting `isShared` leaves the function byte-identical to the pre-wave-21 one, which is

 * what every existing caller (the cohort rail, the tests) still gets.

 */
export function groupByFolder<T>(
  items: T[],
  folders: GridFolder[],
  folderIdOf: (item: T) => string | null,
  isShared?: (item: T) => boolean
): FolderGroup<T>[] {
  const buckets = new Map<string, T[]>(folders.map((f) => [f.id, []]));
  const root: T[] = [];
  const shared: T[] = [];
  for (const item of items) {
    const id = folderIdOf(item);
    const bucket = id == null ? undefined : buckets.get(id);
    if (bucket) bucket.push(item);
    // ⭐⭐ W32-T27 (owner item 20) β€” THE ROOT BUCKET IS REACHABLE FOR A SHARED VIEW NOW.
    // `ROOT_FOLDER_ID` is the receiver saying "I filed this at the top level"; absence still
    // means "this arrived by grant and I have not filed it". Before this line the two were one
    // value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on
    // the next render β€” the owner's item 20, in one branch.
    else if (id === ROOT_FOLDER_ID) root.push(item);
    else if (isShared?.(item)) shared.push(item);
    else root.push(item);
  }
  const out: FolderGroup<T>[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] }));
  out.push({ folder: null, items: root });
  if (shared.length)
    out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared });
  return out;
}

/**

 * WAVE 20 item 19 (C-FOLDER-REORDER) β€” where a dragged folder lands: the full order with

 * `draggedId` moved to sit immediately BEFORE `beforeId`, or last when that is null (the

 * drop on the ungrouped section below every folder).

 *

 * Here rather than inside the rail because it is the only part of the drag a test can hold:

 * the drop handler is DOM, the emit is the caller's, and this is the arithmetic that decides

 * what the user sees. `null` means "emit nothing" β€” an unknown id, or a drop that changes

 * nothing. Returning the unchanged array instead would be worse than useless: the caller

 * cannot tell it apart from a real reorder, so every no-op drag would write the store, bump

 * every reader's payload, and reconcile to the identical list.

 *

 * ⚠ The dragged id is REMOVED BEFORE the target index is read. Taking the index first and

 * splicing after is the classic off-by-one here: dragging a folder DOWNWARD would land it one

 * place short of where it was dropped, and only in that direction β€” the shape of bug that

 * survives a demo and gets reported as "it sometimes doesn't move".

 */
export function reorderFolderIds(

  ids: string[],

  draggedId: string,

  beforeId: string | null

): string[] | null {
  if (!ids.includes(draggedId)) return null;
  // β›” DROPPED ON ITSELF. Without this the id is filtered out, `indexOf` cannot find its own
  // target, and the "not found" branch sends the folder to the END β€” so releasing a drag over
  // the folder you picked up would quietly move it to the bottom of the rail. Found by this
  // function's own gate the minute the arithmetic left the component; the drop handler's
  // indicator suppresses the same case visually, which is exactly why it would never have
  // been noticed there.
  if (beforeId === draggedId) return null;
  const rest = ids.filter((id) => id !== draggedId);
  const found = beforeId ? rest.indexOf(beforeId) : -1;
  const at = found < 0 ? rest.length : found;
  const next = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
  if (next.length === ids.length && next.every((id, i) => id === ids[i])) return null;
  return next;
}

/** A fresh folder id. Client-generated, like every other id in this component. */
export function newFolderId(): string {
  const rand =
    typeof crypto !== "undefined" && "randomUUID" in crypto
      ? crypto.randomUUID().slice(0, 8)
      : Math.random().toString(36).slice(2, 10);
  return `fld_${rand}`;
}