chromium-tabs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,2084 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ AddTabFlags: () => AddTabFlags,
24
+ CloseTabFlags: () => CloseTabFlags,
25
+ GROUP_COLOR_VALUES: () => GROUP_COLOR_VALUES,
26
+ GroupHeader: () => GroupHeader,
27
+ ListSelectionModel: () => ListSelectionModel,
28
+ NO_TAB: () => NO_TAB,
29
+ TAB_GROUP_COLORS: () => TAB_GROUP_COLORS,
30
+ TabItem: () => TabItem,
31
+ TabLifecycleManager: () => TabLifecycleManager,
32
+ TabPanels: () => TabPanels,
33
+ TabStrip: () => TabStrip,
34
+ TabStripModel: () => TabStripModel,
35
+ Tabs: () => Tabs,
36
+ useTabDrag: () => useTabDrag,
37
+ useTabStrip: () => useTabStrip,
38
+ useTabStripModel: () => useTabStripModel,
39
+ useTabVisibility: () => useTabVisibility
40
+ });
41
+ module.exports = __toCommonJS(src_exports);
42
+
43
+ // src/core/list-selection-model.ts
44
+ function indexAfterInsertion(insertPosition, originalIndex) {
45
+ return originalIndex >= insertPosition ? originalIndex + 1 : originalIndex;
46
+ }
47
+ function indexAfterRemoval(removePosition, originalIndex) {
48
+ if (originalIndex === removePosition) return null;
49
+ return originalIndex > removePosition ? originalIndex - 1 : originalIndex;
50
+ }
51
+ function indexAfterMove(sourcePosition, destinationPosition, rangeSize, originalIndex) {
52
+ if (originalIndex === null) return null;
53
+ if (destinationPosition <= originalIndex && originalIndex < sourcePosition + rangeSize) {
54
+ if (originalIndex < sourcePosition) {
55
+ return originalIndex + rangeSize;
56
+ }
57
+ return originalIndex - (sourcePosition - destinationPosition);
58
+ }
59
+ return originalIndex;
60
+ }
61
+ var ListSelectionModel = class _ListSelectionModel {
62
+ selectedIndices_ = /* @__PURE__ */ new Set();
63
+ active_ = null;
64
+ anchor_ = null;
65
+ get anchor() {
66
+ return this.anchor_;
67
+ }
68
+ setAnchor(anchor) {
69
+ this.anchor_ = anchor;
70
+ }
71
+ get active() {
72
+ return this.active_;
73
+ }
74
+ setActive(active) {
75
+ this.active_ = active;
76
+ }
77
+ /** True if nothing is selected. */
78
+ get empty() {
79
+ return this.selectedIndices_.size === 0;
80
+ }
81
+ get size() {
82
+ return this.selectedIndices_.size;
83
+ }
84
+ /** Selected indices in ascending order. */
85
+ selectedIndices() {
86
+ return [...this.selectedIndices_].sort((a, b) => a - b);
87
+ }
88
+ /**
89
+ * Increments all indices >= index. Used when a new item is inserted.
90
+ * list_selection_model.cc:112
91
+ */
92
+ incrementFrom(index) {
93
+ const next = /* @__PURE__ */ new Set();
94
+ for (const i of this.selectedIndices_) next.add(indexAfterInsertion(index, i));
95
+ this.selectedIndices_ = next;
96
+ this.anchor_ = this.anchor_ === null ? null : indexAfterInsertion(index, this.anchor_);
97
+ this.active_ = this.active_ === null ? null : indexAfterInsertion(index, this.active_);
98
+ }
99
+ /**
100
+ * Shifts all indices > index down by 1; index itself is removed from the
101
+ * selection. Used when an item is removed. list_selection_model.cc:122
102
+ */
103
+ decrementFrom(index) {
104
+ const next = /* @__PURE__ */ new Set();
105
+ for (const i of this.selectedIndices_) {
106
+ const v = indexAfterRemoval(index, i);
107
+ if (v !== null) next.add(v);
108
+ }
109
+ this.selectedIndices_ = next;
110
+ this.anchor_ = this.anchor_ === null ? null : indexAfterRemoval(index, this.anchor_);
111
+ this.active_ = this.active_ === null ? null : indexAfterRemoval(index, this.active_);
112
+ }
113
+ /** Sets the anchor, active and selection to index. */
114
+ setSelectedIndex(index) {
115
+ this.anchor_ = index;
116
+ this.active_ = index;
117
+ this.selectedIndices_.clear();
118
+ if (index !== null) this.selectedIndices_.add(index);
119
+ }
120
+ isSelected(index) {
121
+ return this.selectedIndices_.has(index);
122
+ }
123
+ /** Adds index to the selection without changing active or anchor. */
124
+ addIndexToSelection(index) {
125
+ this.selectedIndices_.add(index);
126
+ }
127
+ /** Adds [indexStart, indexEnd] inclusive without changing active or anchor. */
128
+ addIndexRangeToSelection(indexStart, indexEnd) {
129
+ if (indexStart > indexEnd) throw new RangeError("indexStart must be <= indexEnd");
130
+ for (let i = indexStart; i <= indexEnd; i++) this.selectedIndices_.add(i);
131
+ }
132
+ /** Removes index from the selection without changing active or anchor. */
133
+ removeIndexFromSelection(index) {
134
+ this.selectedIndices_.delete(index);
135
+ }
136
+ /**
137
+ * Sets the selection to the range anchor..index. If there is no anchor,
138
+ * behaves like setSelectedIndex. list_selection_model.cc:171
139
+ */
140
+ setSelectionFromAnchorTo(index) {
141
+ if (this.anchor_ === null) {
142
+ this.setSelectedIndex(index);
143
+ return;
144
+ }
145
+ this.selectedIndices_.clear();
146
+ const min = Math.min(index, this.anchor_);
147
+ const max = Math.max(index, this.anchor_);
148
+ for (let i = min; i <= max; i++) this.selectedIndices_.add(i);
149
+ this.active_ = index;
150
+ }
151
+ /**
152
+ * Makes sure anchor..index are selected, adding to the existing selection.
153
+ * list_selection_model.cc:186
154
+ */
155
+ addSelectionFromAnchorTo(index) {
156
+ if (this.anchor_ === null) {
157
+ this.setSelectedIndex(index);
158
+ return;
159
+ }
160
+ const min = Math.min(index, this.anchor_);
161
+ const max = Math.max(index, this.anchor_);
162
+ for (let i = min; i <= max; i++) this.selectedIndices_.add(i);
163
+ this.active_ = index;
164
+ }
165
+ /**
166
+ * Invoked when `length` items move from oldIndex to newIndex. If moving to
167
+ * a greater index, newIndex is the index *after* removing the moved range.
168
+ * list_selection_model.cc:199
169
+ */
170
+ move(oldIndex, newIndex, length) {
171
+ if (oldIndex === newIndex) throw new RangeError("oldIndex must differ from newIndex");
172
+ if (length <= 0) throw new RangeError("length must be > 0");
173
+ if (newIndex > oldIndex) {
174
+ this.move(oldIndex + length, oldIndex, newIndex - oldIndex);
175
+ return;
176
+ }
177
+ this.anchor_ = indexAfterMove(oldIndex, newIndex, length, this.anchor_);
178
+ this.active_ = indexAfterMove(oldIndex, newIndex, length, this.active_);
179
+ const next = /* @__PURE__ */ new Set();
180
+ for (const i of this.selectedIndices_) {
181
+ const v = indexAfterMove(oldIndex, newIndex, length, i);
182
+ if (v !== null) next.add(v);
183
+ }
184
+ this.selectedIndices_ = next;
185
+ }
186
+ /** Clears the selection, anchor and active. */
187
+ clear() {
188
+ this.anchor_ = null;
189
+ this.active_ = null;
190
+ this.selectedIndices_.clear();
191
+ }
192
+ clone() {
193
+ const copy = new _ListSelectionModel();
194
+ copy.selectedIndices_ = new Set(this.selectedIndices_);
195
+ copy.active_ = this.active_;
196
+ copy.anchor_ = this.anchor_;
197
+ return copy;
198
+ }
199
+ equals(other) {
200
+ if (this.active_ !== other.active_ || this.anchor_ !== other.anchor_) return false;
201
+ if (this.selectedIndices_.size !== other.selectedIndices_.size) return false;
202
+ for (const i of this.selectedIndices_) {
203
+ if (!other.selectedIndices_.has(i)) return false;
204
+ }
205
+ return true;
206
+ }
207
+ /** 'active=X anchor=X selection=X X X...' — matches the C++ ToString. */
208
+ toString() {
209
+ const opt = (v) => v === null ? "<none>" : String(v);
210
+ return `active=${opt(this.active_)} anchor=${opt(this.anchor_)} selection=${this.selectedIndices().join(" ")}`;
211
+ }
212
+ };
213
+
214
+ // src/core/types.ts
215
+ var NO_TAB = -1;
216
+ var AddTabFlags = {
217
+ NONE: 0,
218
+ /** The tab should become the active tab. */
219
+ ACTIVE: 1 << 0,
220
+ /** The tab should be pinned. */
221
+ PINNED: 1 << 1,
222
+ /**
223
+ * Use the caller-supplied index rather than letting the model determine
224
+ * the position from the open cause and opener relationships.
225
+ */
226
+ FORCE_INDEX: 1 << 2,
227
+ /** Set the new tab's opener to the currently active tab. */
228
+ INHERIT_OPENER: 1 << 3
229
+ };
230
+ var CloseTabFlags = {
231
+ NONE: 0,
232
+ /** The close was triggered directly by a user gesture. */
233
+ USER_GESTURE: 1 << 0
234
+ };
235
+ var TAB_GROUP_COLORS = [
236
+ "grey",
237
+ "blue",
238
+ "red",
239
+ "yellow",
240
+ "green",
241
+ "pink",
242
+ "purple",
243
+ "cyan",
244
+ "orange"
245
+ ];
246
+
247
+ // src/core/tab-strip-model.ts
248
+ function defaultGenerateId() {
249
+ return `t${Math.random().toString(36).slice(2, 10)}`;
250
+ }
251
+ var TabStripModel = class {
252
+ tabs_ = [];
253
+ groups_ = /* @__PURE__ */ new Map();
254
+ // Selection is tracked by tab identity, mirroring Chrome's
255
+ // TabStripModelSelectionState; index views are derived on demand.
256
+ selectedTabs_ = /* @__PURE__ */ new Set();
257
+ activeTab_ = null;
258
+ anchorTab_ = null;
259
+ observers_ = /* @__PURE__ */ new Set();
260
+ closingAll_ = false;
261
+ reentrancyGuard_ = false;
262
+ canCloseTab_;
263
+ supportsGroups_;
264
+ generateId_;
265
+ constructor(options = {}) {
266
+ this.canCloseTab_ = options.canCloseTab ?? (() => true);
267
+ this.supportsGroups_ = options.supportsGroups ?? true;
268
+ this.generateId_ = options.generateId ?? defaultGenerateId;
269
+ }
270
+ // Basic queries ////////////////////////////////////////////////////////////
271
+ get count() {
272
+ return this.tabs_.length;
273
+ }
274
+ get empty() {
275
+ return this.tabs_.length === 0;
276
+ }
277
+ /** True while closeAllTabs is in progress. */
278
+ get closingAll() {
279
+ return this.closingAll_;
280
+ }
281
+ containsIndex(index) {
282
+ return index >= 0 && index < this.tabs_.length;
283
+ }
284
+ getTabAt(index) {
285
+ const tab = this.tabs_[index];
286
+ if (!tab) throw new RangeError(`no tab at index ${index}`);
287
+ return tab;
288
+ }
289
+ indexOfTab(tab) {
290
+ if (!tab) return NO_TAB;
291
+ return this.tabs_.indexOf(tab);
292
+ }
293
+ getTabById(id) {
294
+ return this.tabs_.find((t) => t.id === id) ?? null;
295
+ }
296
+ /** Snapshot of the tabs in strip order. */
297
+ getTabs() {
298
+ return [...this.tabs_];
299
+ }
300
+ get activeTab() {
301
+ return this.activeTab_;
302
+ }
303
+ get activeIndex() {
304
+ return this.activeTab_ ? this.indexOfTab(this.activeTab_) : NO_TAB;
305
+ }
306
+ /** Index of the first non-pinned tab; count if all pinned. (cc:1418 area) */
307
+ indexOfFirstNonPinnedTab() {
308
+ const i = this.tabs_.findIndex((t) => !t.pinned);
309
+ return i === -1 ? this.tabs_.length : i;
310
+ }
311
+ isTabPinned(index) {
312
+ return this.getTabAt(index).pinned;
313
+ }
314
+ isTabBlocked(index) {
315
+ return this.getTabAt(index).blocked;
316
+ }
317
+ isTabSelected(index) {
318
+ return this.selectedTabs_.has(this.getTabAt(index));
319
+ }
320
+ /** Derived index-based view of the selection (Chrome: GetListSelectionModel). */
321
+ selectionModel() {
322
+ const model = new ListSelectionModel();
323
+ for (const tab of this.selectedTabs_) {
324
+ const i = this.indexOfTab(tab);
325
+ if (i !== NO_TAB) model.addIndexToSelection(i);
326
+ }
327
+ model.setActive(this.activeTab_ ? this.indexOfTab(this.activeTab_) : null);
328
+ model.setAnchor(this.anchorTab_ ? this.indexOfTab(this.anchorTab_) : null);
329
+ return model;
330
+ }
331
+ // Group queries //////////////////////////////////////////////////////////
332
+ get supportsTabGroups() {
333
+ return this.supportsGroups_;
334
+ }
335
+ getTabGroupForTab(index) {
336
+ if (!this.containsIndex(index)) return null;
337
+ return this.tabs_[index].group;
338
+ }
339
+ getGroups() {
340
+ return [...this.groups_.entries()].map(([id, visualData]) => ({ id, visualData }));
341
+ }
342
+ getGroupVisualData(group) {
343
+ return this.groups_.get(group) ?? null;
344
+ }
345
+ containsGroup(group) {
346
+ return this.groups_.has(group);
347
+ }
348
+ /** [start, end) range of the group's tabs. Mirrors TabGroup::ListTabs. */
349
+ listTabsInGroup(group) {
350
+ let start = -1;
351
+ let end = -1;
352
+ for (let i = 0; i < this.tabs_.length; i++) {
353
+ if (this.tabs_[i].group === group) {
354
+ if (start === -1) start = i;
355
+ end = i + 1;
356
+ }
357
+ }
358
+ if (start === -1) throw new Error(`no such group: ${group}`);
359
+ return { start, end };
360
+ }
361
+ isGroupCollapsed(group) {
362
+ return this.groups_.get(group)?.isCollapsed ?? false;
363
+ }
364
+ /** True if the tab is inside a collapsed group. (cc:1423) */
365
+ isTabCollapsed(index) {
366
+ const group = this.getTabGroupForTab(index);
367
+ return group !== null && this.isGroupCollapsed(group);
368
+ }
369
+ /**
370
+ * If a tab inserted at index would land inside a group, returns that group.
371
+ * Returns null at the first index of a group (a tab there sits between
372
+ * groups, not inside one). Mirrors GetSurroundingTabGroup.
373
+ */
374
+ getSurroundingTabGroup(index) {
375
+ const before = this.getTabGroupForTab(index - 1);
376
+ const at = this.getTabGroupForTab(index);
377
+ return before !== null && before === at ? before : null;
378
+ }
379
+ // Observers //////////////////////////////////////////////////////////////
380
+ addObserver(observer) {
381
+ this.observers_.add(observer);
382
+ return () => this.observers_.delete(observer);
383
+ }
384
+ removeObserver(observer) {
385
+ this.observers_.delete(observer);
386
+ }
387
+ // Add / insert ///////////////////////////////////////////////////////////
388
+ /**
389
+ * Command-level add: picks the position from the open cause and opener
390
+ * relationships, then inserts. Port of AddTab (cc:1715-1828).
391
+ */
392
+ addTab(data, options = {}) {
393
+ this.checkReentrancy_();
394
+ const cause = options.cause ?? "other";
395
+ const flags = options.flags ?? 0;
396
+ let index = options.index ?? NO_TAB;
397
+ let group = options.group ?? null;
398
+ let inheritOpener = (flags & AddTabFlags.INHERIT_OPENER) !== 0;
399
+ if (cause === "link" && (flags & AddTabFlags.FORCE_INDEX) === 0) {
400
+ index = this.determineInsertionIndex(cause, (flags & AddTabFlags.ACTIVE) !== 0);
401
+ inheritOpener = true;
402
+ if (group === null) {
403
+ group = this.getTabGroupForTab(this.activeIndex);
404
+ }
405
+ } else if (index < 0 || index > this.count) {
406
+ index = this.count;
407
+ }
408
+ if (this.supportsGroups_) {
409
+ if (group !== null && this.groups_.has(group)) {
410
+ const range = this.listTabsInGroup(group);
411
+ index = Math.min(Math.max(index, range.start), range.end);
412
+ } else if (group === null && this.getTabGroupForTab(index - 1) !== null && this.getTabGroupForTab(index - 1) === this.getTabGroupForTab(index)) {
413
+ group = this.getTabGroupForTab(index);
414
+ }
415
+ if (flags & AddTabFlags.PINNED) group = null;
416
+ } else {
417
+ group = null;
418
+ }
419
+ if (cause === "typed" && index === this.count) {
420
+ inheritOpener = true;
421
+ }
422
+ const tab = this.createTab_(data, options.id);
423
+ this.insertTabAtImpl_(
424
+ index,
425
+ tab,
426
+ flags | (inheritOpener ? AddTabFlags.INHERIT_OPENER : 0),
427
+ group
428
+ );
429
+ if (inheritOpener && cause === "typed") {
430
+ tab.resetOpenerOnActiveTabChange = true;
431
+ }
432
+ return tab;
433
+ }
434
+ /** Adds a tab at the end of the strip. Port of AppendTab. */
435
+ appendTab(data, foreground = true) {
436
+ return this.addTab(data, {
437
+ index: this.count,
438
+ cause: "other",
439
+ flags: AddTabFlags.FORCE_INDEX | (foreground ? AddTabFlags.ACTIVE : 0)
440
+ });
441
+ }
442
+ /**
443
+ * Inserts at the given index, only adjusting it to keep pinned tabs at the
444
+ * front. Does NOT consult the order controller. Port of InsertWebContentsAt.
445
+ * Returns the index actually used.
446
+ */
447
+ insertTabAt(index, data, options = {}) {
448
+ this.checkReentrancy_();
449
+ const group = options.group ?? null;
450
+ if (group !== null && !this.groups_.has(group)) {
451
+ throw new Error(`no such group: ${group}`);
452
+ }
453
+ const tab = this.createTab_(data, options.id);
454
+ this.insertTabAtImpl_(index, tab, options.flags ?? 0, group);
455
+ return tab;
456
+ }
457
+ // Activate / selection ///////////////////////////////////////////////////
458
+ /** Makes the tab at index active. Port of ActivateTabAt (cc:1022). */
459
+ activateTabAt(index, options = {}) {
460
+ this.checkReentrancy_();
461
+ if (!this.containsIndex(index)) throw new RangeError(`no tab at index ${index}`);
462
+ const tab = this.tabs_[index];
463
+ this.setSelection_(
464
+ () => this.setSelectedTab_(tab),
465
+ options.userGesture ? "userGesture" : "none"
466
+ );
467
+ }
468
+ /** Extends the selection from the anchor to index. Port of cc:1514. */
469
+ extendSelectionTo(index) {
470
+ this.checkReentrancy_();
471
+ const tab = this.getTabAt(index);
472
+ this.setSelection_(() => {
473
+ if (!this.anchorTab_) {
474
+ this.setSelectedTab_(tab);
475
+ return;
476
+ }
477
+ const anchorIndex = this.indexOfTab(this.anchorTab_);
478
+ const lo = Math.min(anchorIndex, index);
479
+ const hi = Math.max(anchorIndex, index);
480
+ this.selectedTabs_ = new Set(this.tabs_.slice(lo, hi + 1));
481
+ this.activeTab_ = tab;
482
+ }, "none");
483
+ }
484
+ /** Adds the tab at index to the selection and makes it active+anchor. (cc:1545) */
485
+ selectTabAt(index) {
486
+ this.checkReentrancy_();
487
+ const tab = this.getTabAt(index);
488
+ this.setSelection_(() => {
489
+ this.selectedTabs_.add(tab);
490
+ this.anchorTab_ = tab;
491
+ this.activeTab_ = tab;
492
+ }, "none");
493
+ }
494
+ /** Removes the tab at index from the selection. No-op if it's the last one. (cc:1570) */
495
+ deselectTabAt(index) {
496
+ this.checkReentrancy_();
497
+ const tab = this.getTabAt(index);
498
+ if (!this.selectedTabs_.has(tab)) return;
499
+ if (this.selectedTabs_.size === 1) return;
500
+ this.setSelection_(() => {
501
+ this.selectedTabs_.delete(tab);
502
+ const first = this.firstSelectedTab_();
503
+ if (!this.activeTab_ || this.activeTab_ === tab) this.activeTab_ = first;
504
+ if (!this.anchorTab_ || this.anchorTab_ === tab) this.anchorTab_ = first;
505
+ }, "none");
506
+ }
507
+ /** Selects anchor..index, adding to the current selection. (cc:1616) */
508
+ addSelectionFromAnchorTo(index) {
509
+ this.checkReentrancy_();
510
+ const tab = this.getTabAt(index);
511
+ this.setSelection_(() => {
512
+ if (!this.anchorTab_) {
513
+ this.setSelectedTab_(tab);
514
+ return;
515
+ }
516
+ const anchorIndex = this.indexOfTab(this.anchorTab_);
517
+ const lo = Math.min(anchorIndex, index);
518
+ const hi = Math.max(anchorIndex, index);
519
+ for (const t of this.tabs_.slice(lo, hi + 1)) this.selectedTabs_.add(t);
520
+ this.activeTab_ = tab;
521
+ }, "none");
522
+ }
523
+ /** Replaces the selection with the given index-based model. */
524
+ setSelectionFromModel(source) {
525
+ this.checkReentrancy_();
526
+ if (source.active === null) throw new Error("selection must have an active index");
527
+ this.setSelection_(() => {
528
+ this.selectedTabs_ = new Set(source.selectedIndices().map((i) => this.getTabAt(i)));
529
+ this.activeTab_ = this.getTabAt(source.active);
530
+ this.anchorTab_ = source.anchor === null ? null : this.getTabAt(source.anchor);
531
+ }, "none");
532
+ }
533
+ /**
534
+ * Activates the next/previous tab, wrapping around and skipping collapsed
535
+ * groups. Port of SelectRelativeTab (cc:3951).
536
+ */
537
+ selectNextTab(options = {}) {
538
+ this.selectRelativeTab_(1, options);
539
+ }
540
+ selectPreviousTab(options = {}) {
541
+ this.selectRelativeTab_(-1, options);
542
+ }
543
+ selectLastTab(options = {}) {
544
+ if (this.empty) return;
545
+ this.activateTabAt(this.count - 1, options);
546
+ }
547
+ selectRelativeTab_(delta, options) {
548
+ if (this.empty) return;
549
+ const startIndex = this.activeIndex;
550
+ let index = (startIndex + this.count + delta) % this.count;
551
+ let group = this.getTabGroupForTab(index);
552
+ while (group !== null && this.isGroupCollapsed(group)) {
553
+ index = (index + this.count + delta) % this.count;
554
+ group = this.getTabGroupForTab(index);
555
+ }
556
+ this.activateTabAt(index, options);
557
+ }
558
+ // Move ///////////////////////////////////////////////////////////////////
559
+ /**
560
+ * Moves the tab at index to toPosition (clamped so pinned tabs stay
561
+ * together). Group membership adjusts to keep groups contiguous. Port of
562
+ * MoveWebContentsAt (cc:1053). Returns the final index.
563
+ */
564
+ moveTabTo(index, toPosition, selectAfterMove = false) {
565
+ this.checkReentrancy_();
566
+ if (!this.containsIndex(index)) throw new RangeError(`no tab at index ${index}`);
567
+ const pinned = this.isTabPinned(index);
568
+ toPosition = this.constrainMoveIndex_(toPosition, pinned);
569
+ if (index === toPosition) return toPosition;
570
+ const group = this.getGroupToAssign_(index, toPosition);
571
+ this.moveTabToIndexImpl_(index, toPosition, group, pinned, selectAfterMove);
572
+ return toPosition;
573
+ }
574
+ /**
575
+ * Moves the selected tabs to index, pinned tabs first as a chunk, then
576
+ * unpinned. `index` is interpreted as if the strip did not contain the
577
+ * selected tabs. Port of MoveSelectedTabsTo (cc:1089).
578
+ */
579
+ moveSelectedTabsTo(index, group = null) {
580
+ this.checkReentrancy_();
581
+ const pinnedTabCount = this.indexOfFirstNonPinnedTab();
582
+ const pinnedSelected = this.selectedIndices_().filter((i) => i < pinnedTabCount);
583
+ const unpinnedSelected = this.selectedIndices_().filter((i) => i >= pinnedTabCount);
584
+ const lastPinnedIndex = clamp(
585
+ index + pinnedSelected.length - 1,
586
+ pinnedSelected.length - 1,
587
+ pinnedTabCount - 1
588
+ );
589
+ this.moveTabsToIndexImpl_(pinnedSelected, lastPinnedIndex - pinnedSelected.length + 1, "keep");
590
+ const firstUnpinnedIndex = clamp(
591
+ index + pinnedSelected.length,
592
+ pinnedTabCount,
593
+ this.count - unpinnedSelected.length
594
+ );
595
+ this.moveTabsToIndexImpl_(unpinnedSelected, firstUnpinnedIndex, group);
596
+ }
597
+ /** Moves all tabs of a group to toIndex. Port of MoveGroupTo (cc:1117). */
598
+ moveGroupTo(group, toIndex) {
599
+ this.checkReentrancy_();
600
+ if (!this.groups_.has(group)) throw new Error(`no such group: ${group}`);
601
+ toIndex = this.constrainMoveIndex_(toIndex, false);
602
+ const range = this.listTabsInGroup(group);
603
+ if (range.start === toIndex) return;
604
+ const indices = [];
605
+ for (let i = range.start; i < range.end; i++) indices.push(i);
606
+ const length = indices.length;
607
+ const dest = clamp(
608
+ toIndex > range.start ? toIndex - length + 1 : toIndex,
609
+ this.indexOfFirstNonPinnedTab() - countLessThan(indices, this.indexOfFirstNonPinnedTab()),
610
+ this.count - length
611
+ );
612
+ this.moveTabsToIndexImpl_(indices, dest, "keep");
613
+ this.notifyAll_((o) => o.onTabGroupChanged?.({ type: "moved", groupId: group }));
614
+ }
615
+ /**
616
+ * Moves the active tab one slot right/left. At a group boundary the tab
617
+ * first changes group membership without moving; collapsed neighbor groups
618
+ * are hopped over entirely. Port of MoveTabRelative (cc:3976).
619
+ */
620
+ moveTabNext() {
621
+ this.moveTabRelative_(1);
622
+ }
623
+ moveTabPrevious() {
624
+ this.moveTabRelative_(-1);
625
+ }
626
+ moveTabRelative_(delta) {
627
+ this.checkReentrancy_();
628
+ const start = this.activeIndex;
629
+ if (start === NO_TAB) throw new Error("no active tab");
630
+ let targetIndex = start;
631
+ const neighborIndex = delta === 1 ? start + 1 : start - 1;
632
+ if (this.containsIndex(neighborIndex) && this.isTabPinned(start) === this.isTabPinned(neighborIndex)) {
633
+ targetIndex += delta;
634
+ }
635
+ const currentGroup = this.getTabGroupForTab(start);
636
+ let targetGroup = targetIndex === start ? null : this.getTabGroupForTab(neighborIndex);
637
+ if (this.supportsGroups_ && currentGroup !== targetGroup) {
638
+ if (currentGroup !== null) {
639
+ targetIndex = start;
640
+ targetGroup = null;
641
+ } else if (targetGroup !== null) {
642
+ if (this.isGroupCollapsed(targetGroup)) {
643
+ const range = this.listTabsInGroup(targetGroup);
644
+ targetIndex = delta === 1 ? range.end - 1 : range.start;
645
+ targetGroup = null;
646
+ } else {
647
+ targetIndex = start;
648
+ }
649
+ }
650
+ }
651
+ this.moveTabsToIndexImpl_([start], targetIndex, targetGroup);
652
+ }
653
+ // Pinning ////////////////////////////////////////////////////////////////
654
+ /**
655
+ * Pins or unpins the tab, moving it to the pinned/unpinned boundary.
656
+ * Pinning removes the tab from its group. Returns the final index.
657
+ * Port of SetTabPinned (cc:1407) + SetTabPinnedImpl (cc:5052).
658
+ */
659
+ setTabPinned(index, pinned) {
660
+ this.checkReentrancy_();
661
+ if (!this.containsIndex(index)) throw new RangeError(`no tab at index ${index}`);
662
+ if (this.isTabPinned(index) === pinned) return index;
663
+ const finalIndex = pinned ? this.indexOfFirstNonPinnedTab() : this.indexOfFirstNonPinnedTab() - 1;
664
+ this.moveTabToIndexImpl_(index, finalIndex, null, pinned, false);
665
+ return finalIndex;
666
+ }
667
+ // Close //////////////////////////////////////////////////////////////////
668
+ /** Closes the tab at index. Returns true if it closed (not vetoed). */
669
+ closeTabAt(index) {
670
+ return this.closeTabs_([this.getTabAt(index)]);
671
+ }
672
+ /** Closes the tabs at the given indices. */
673
+ closeTabsAt(indices) {
674
+ return this.closeTabs_(indices.map((i) => this.getTabAt(i)));
675
+ }
676
+ /** Port of CloseSelectedTabs. */
677
+ closeSelectedTabs() {
678
+ return this.closeTabs_([...this.selectedTabs_]);
679
+ }
680
+ /** Port of CloseAllTabs (cc:455). */
681
+ closeAllTabs() {
682
+ return this.closeTabs_([...this.tabs_]);
683
+ }
684
+ /** Context-menu style helper: close every tab except the one at index. */
685
+ closeOtherTabs(index) {
686
+ const keep = this.getTabAt(index);
687
+ return this.closeTabs_(this.tabs_.filter((t) => t !== keep && !t.pinned));
688
+ }
689
+ /** Context-menu style helper: close unpinned tabs to the right of index. */
690
+ closeTabsToRight(index) {
691
+ return this.closeTabs_(this.tabs_.slice(index + 1).filter((t) => !t.pinned));
692
+ }
693
+ /** Closes all tabs in a group. Port of CloseAllTabsInGroup. */
694
+ closeAllTabsInGroup(group) {
695
+ const range = this.listTabsInGroup(group);
696
+ return this.closeTabs_(this.tabs_.slice(range.start, range.end));
697
+ }
698
+ closeTabs_(tabs, options = {}) {
699
+ this.checkReentrancy_();
700
+ const closable = [];
701
+ for (const tab of tabs) {
702
+ if (options.bypassVeto || this.canCloseTab_(tab)) {
703
+ closable.push(tab);
704
+ } else {
705
+ this.notifyAll_((o) => o.onTabCloseCancelled?.(tab));
706
+ }
707
+ }
708
+ if (closable.length === 0) return false;
709
+ const closingAll = closable.length === this.count;
710
+ if (closingAll) {
711
+ this.closingAll_ = true;
712
+ this.notifyAll_((o) => o.willCloseAllTabs?.());
713
+ }
714
+ const oldActive = this.activeTab_;
715
+ const oldModel = this.selectionModel();
716
+ const removed = [];
717
+ const groupNotifications = [];
718
+ for (const tab of closable) {
719
+ const index = this.indexOfTab(tab);
720
+ if (index === NO_TAB) continue;
721
+ this.removeTabFromIndexImpl_(index);
722
+ removed.push({ tab, index });
723
+ if (tab.group !== null) {
724
+ groupNotifications.push({ tab, index, group: tab.group });
725
+ tab.group = null;
726
+ }
727
+ }
728
+ this.validate_();
729
+ const selection = this.buildSelectionChange_(oldActive, oldModel, "none");
730
+ this.notifyAll_(
731
+ (o) => o.onTabStripModelChanged?.({ type: "removed", contents: removed }, selection)
732
+ );
733
+ for (const { tab, index, group } of groupNotifications) {
734
+ this.notifyAll_((o) => o.onTabGroupedStateChanged?.(group, null, tab, index));
735
+ }
736
+ for (const { group } of groupNotifications) {
737
+ if (this.groups_.has(group) && !this.tabs_.some((t) => t.group === group)) {
738
+ this.groups_.delete(group);
739
+ this.notifyAll_((o) => o.onTabGroupChanged?.({ type: "closed", groupId: group }));
740
+ }
741
+ }
742
+ this.handleActiveTabChanged_(selection);
743
+ if (closingAll) {
744
+ this.closingAll_ = false;
745
+ this.notifyAll_((o) => o.closeAllTabsStopped?.("completed"));
746
+ }
747
+ return true;
748
+ }
749
+ /**
750
+ * Removes the tab and fixes up the selection. Port of
751
+ * RemoveTabFromIndexImpl (cc:4551). Caller batches notifications.
752
+ */
753
+ removeTabFromIndexImpl_(index) {
754
+ const tab = this.tabs_[index];
755
+ const nextSelectedIndex = this.determineNewSelectedIndex_(index);
756
+ this.fixOpeners_(index);
757
+ this.tabs_.splice(index, 1);
758
+ this.selectedTabs_.delete(tab);
759
+ if (this.anchorTab_ === tab) this.anchorTab_ = null;
760
+ if (this.empty) {
761
+ this.selectedTabs_.clear();
762
+ this.activeTab_ = null;
763
+ this.anchorTab_ = null;
764
+ } else if (this.activeTab_ === tab) {
765
+ if (this.selectedTabs_.size > 0) {
766
+ const first = this.firstSelectedTab_();
767
+ this.activeTab_ = first;
768
+ this.anchorTab_ = first;
769
+ } else {
770
+ if (nextSelectedIndex === null) throw new Error("invariant: no next tab to select");
771
+ this.setSelectedTab_(this.tabs_[nextSelectedIndex]);
772
+ }
773
+ }
774
+ return tab;
775
+ }
776
+ // External-state reconciliation //////////////////////////////////////////
777
+ /**
778
+ * Converges the strip to an external source-of-truth list with minimal
779
+ * mutations (no remove-all/re-add), so an app whose canonical tab state
780
+ * lives elsewhere (a router, a store, another window) can mirror it into
781
+ * the model without losing tab identity, content state, or discard status.
782
+ *
783
+ * - tabs absent from `desired` are removed, bypassing `canCloseTab` (the
784
+ * external state has already decided)
785
+ * - missing tabs are inserted at their position under the given id
786
+ * - `data` is swapped via setTabData where `dataEquals` reports a change
787
+ * - pinned state and order converge to the desired list; pass a
788
+ * pinned-first-consistent list or Chrome's clamping rules win
789
+ * - `activeId`, when provided and present, is activated last
790
+ *
791
+ * Observers fire for each underlying mutation as usual; reconcile-driven
792
+ * activations carry reason 'none', so a consumer that also writes model
793
+ * changes back to the external store can tell them from user gestures.
794
+ * Groups are not reconciled. No Chrome equivalent: this is integration
795
+ * surface for embedding apps.
796
+ */
797
+ reconcile(desired, options = {}) {
798
+ this.checkReentrancy_();
799
+ const dataEquals = options.dataEquals ?? Object.is;
800
+ const desiredIds = /* @__PURE__ */ new Set();
801
+ for (const want of desired) {
802
+ if (desiredIds.has(want.id)) throw new Error(`duplicate tab id in reconcile: ${want.id}`);
803
+ desiredIds.add(want.id);
804
+ }
805
+ const toRemove = this.tabs_.filter((tab) => !desiredIds.has(tab.id));
806
+ if (toRemove.length > 0) this.closeTabs_(toRemove, { bypassVeto: true });
807
+ desired.forEach((want, position) => {
808
+ const existing = this.getTabById(want.id);
809
+ const pinned = want.pinned ?? false;
810
+ if (!existing) {
811
+ this.insertTabAt(position, want.data, {
812
+ id: want.id,
813
+ flags: pinned ? AddTabFlags.PINNED : AddTabFlags.NONE
814
+ });
815
+ return;
816
+ }
817
+ if (!dataEquals(existing.data, want.data)) {
818
+ this.setTabData(this.indexOfTab(existing), want.data);
819
+ }
820
+ if (existing.pinned !== pinned) {
821
+ this.setTabPinned(this.indexOfTab(existing), pinned);
822
+ }
823
+ const index = this.indexOfTab(existing);
824
+ if (index !== position) {
825
+ this.moveTabTo(index, position);
826
+ }
827
+ });
828
+ if (options.activeId != null) {
829
+ const activeIndex = this.indexOfTab(this.getTabById(options.activeId));
830
+ if (activeIndex !== NO_TAB && activeIndex !== this.activeIndex) {
831
+ this.activateTabAt(activeIndex);
832
+ }
833
+ }
834
+ }
835
+ // Openers ////////////////////////////////////////////////////////////////
836
+ getOpenerOfTabAt(index) {
837
+ return this.getTabAt(index).opener;
838
+ }
839
+ setOpenerOfTabAt(index, opener) {
840
+ const tab = this.getTabAt(index);
841
+ if (opener === tab) throw new Error("a tab cannot be its own opener");
842
+ if (opener && this.indexOfTab(opener) === NO_TAB) {
843
+ throw new Error("opener must be in this tab strip");
844
+ }
845
+ tab.opener = opener;
846
+ }
847
+ /** Port of ForgetAllOpeners (cc:3376). */
848
+ forgetAllOpeners() {
849
+ for (const tab of this.tabs_) tab.opener = null;
850
+ }
851
+ forgetOpener(tab) {
852
+ tab.opener = null;
853
+ }
854
+ /**
855
+ * Call when the user navigates a tab. Typed-style navigations reset all
856
+ * opener relationships (the user started a new task), except in a fresh
857
+ * end-of-strip tab. Port of TabNavigating (cc:1378).
858
+ */
859
+ tabNavigating(tab, cause) {
860
+ if (cause !== "typed") return;
861
+ const isNewTabAtEnd = tab === this.tabs_[this.tabs_.length - 1] && tab.resetOpenerOnActiveTabChange;
862
+ if (!isNewTabAtEnd) this.forgetAllOpeners();
863
+ }
864
+ /**
865
+ * Index of the last tab opened (transitively) by the tab at startIndex,
866
+ * scanning right, skipping pinned tabs, stopping at the first unrelated
867
+ * unpinned tab. Port of GetIndexOfLastWebContentsOpenedBy (cc:1351).
868
+ */
869
+ getIndexOfLastTabOpenedBy(opener, startIndex) {
870
+ const openerAndDescendants = /* @__PURE__ */ new Set([opener]);
871
+ let lastIndex = NO_TAB;
872
+ for (let i = startIndex + 1; i < this.count; i++) {
873
+ const tab = this.tabs_[i];
874
+ if (!tab.opener || !openerAndDescendants.has(tab.opener)) {
875
+ if (tab.pinned) continue;
876
+ break;
877
+ }
878
+ openerAndDescendants.add(tab);
879
+ lastIndex = i;
880
+ }
881
+ return lastIndex;
882
+ }
883
+ // Groups /////////////////////////////////////////////////////////////////
884
+ /**
885
+ * Creates a group containing the tabs at indices (ascending). Tabs are
886
+ * unpinned and made contiguous without splitting other groups. Returns the
887
+ * group id. Port of AddToNewGroup (cc:671) + AddToNewGroupImpl (cc:4344).
888
+ */
889
+ addToNewGroup(indices, visualData) {
890
+ this.checkReentrancy_();
891
+ this.requireGroups_();
892
+ assertAscending(indices);
893
+ if (indices.length === 0) throw new Error("indices must not be empty");
894
+ const groupId = this.generateId_();
895
+ this.groups_.set(groupId, {
896
+ title: visualData?.title ?? "",
897
+ color: visualData?.color ?? this.nextGroupColor_(),
898
+ isCollapsed: visualData?.isCollapsed ?? false
899
+ });
900
+ this.notifyAll_((o) => o.onTabGroupChanged?.({ type: "created", groupId }));
901
+ const firstGroup = this.getTabGroupForTab(indices[0]);
902
+ let destinationIndex = -1;
903
+ for (let i = indices[0]; i <= this.count; i++) {
904
+ if (!this.containsIndex(i)) {
905
+ destinationIndex = i;
906
+ break;
907
+ }
908
+ if (this.isTabPinned(i)) continue;
909
+ const destinationGroup = this.getTabGroupForTab(i);
910
+ if (destinationGroup === null || destinationGroup !== firstGroup) {
911
+ destinationIndex = i;
912
+ break;
913
+ }
914
+ }
915
+ this.moveTabsAndSetProperties_(indices, destinationIndex, groupId, false);
916
+ const range = this.listTabsInGroup(groupId);
917
+ for (let i = range.start; i < range.end; i++) {
918
+ if (this.activeIndex !== i && this.isTabSelected(i)) this.deselectTabAt(i);
919
+ }
920
+ return groupId;
921
+ }
922
+ /**
923
+ * Adds the tabs at indices (ascending) to an existing group. Tabs left of
924
+ * the group move to its start, tabs right of it to its end; addToEnd sends
925
+ * everything to the end. Port of AddToExistingGroup (cc:4415).
926
+ */
927
+ addToExistingGroup(indices, group, addToEnd = false) {
928
+ this.checkReentrancy_();
929
+ this.requireGroups_();
930
+ assertAscending(indices);
931
+ if (!this.groups_.has(group)) return;
932
+ const range = this.listTabsInGroup(group);
933
+ const firstTabIndex = range.start;
934
+ const lastTabIndex = range.end - 1;
935
+ const tabsLeftOfGroup = indices.filter((i) => i < firstTabIndex);
936
+ const tabsRightOfGroup = indices.filter((i) => i > lastTabIndex);
937
+ if (addToEnd) {
938
+ this.moveTabsAndSetProperties_(
939
+ [...tabsLeftOfGroup, ...tabsRightOfGroup],
940
+ lastTabIndex + 1,
941
+ group,
942
+ false
943
+ );
944
+ } else {
945
+ this.moveTabsAndSetProperties_(tabsLeftOfGroup, firstTabIndex, group, false);
946
+ this.moveTabsAndSetProperties_(tabsRightOfGroup, lastTabIndex + 1, group, false);
947
+ }
948
+ }
949
+ /**
950
+ * Removes the tabs at indices (ascending) from their groups. Tabs in the
951
+ * first half of a group exit left of it, the rest exit right. Port of
952
+ * RemoveFromGroup (cc:4253 area) + SeparateTabsByVisualPosition.
953
+ */
954
+ removeFromGroup(indices) {
955
+ this.checkReentrancy_();
956
+ this.requireGroups_();
957
+ assertAscending(indices);
958
+ const indicesPerGroup = /* @__PURE__ */ new Map();
959
+ for (const index of indices) {
960
+ const group = this.getTabGroupForTab(index);
961
+ if (group !== null) {
962
+ if (!indicesPerGroup.has(group)) indicesPerGroup.set(group, []);
963
+ indicesPerGroup.get(group).push(index);
964
+ }
965
+ }
966
+ for (const [group, groupIndices] of indicesPerGroup) {
967
+ const range = this.listTabsInGroup(group);
968
+ const firstTabIndex = range.start;
969
+ const lastTabIndex = range.end - 1;
970
+ const midpoint = Math.floor((range.end - range.start) / 2);
971
+ const leftOfGroup = groupIndices.filter((i) => i - firstTabIndex < midpoint);
972
+ const rightOfGroup = groupIndices.filter((i) => i - firstTabIndex >= midpoint);
973
+ this.moveTabsAndSetProperties_(leftOfGroup, firstTabIndex, null, false);
974
+ this.moveTabsAndSetProperties_(rightOfGroup, lastTabIndex + 1, null, false);
975
+ }
976
+ }
977
+ /** Updates a group's title/color/collapsed state. Port of ChangeTabGroupVisuals. */
978
+ updateGroupVisuals(group, visuals) {
979
+ const old = this.groups_.get(group);
980
+ if (!old) throw new Error(`no such group: ${group}`);
981
+ const next = { ...old, ...visuals };
982
+ if (old.isCollapsed !== next.isCollapsed && next.isCollapsed) {
983
+ const range = this.listTabsInGroup(group);
984
+ const active = this.activeIndex;
985
+ if (active >= range.start && active < range.end) {
986
+ this.groups_.set(group, next);
987
+ const fallback = this.getNextExpandedActiveTab_(range.start, range.end);
988
+ if (fallback === null) {
989
+ this.groups_.set(group, old);
990
+ throw new Error("cannot collapse the only expanded tabs in the strip");
991
+ }
992
+ this.activateTabAt(fallback);
993
+ this.notifyAll_(
994
+ (o) => o.onTabGroupChanged?.({ type: "visualsChanged", groupId: group, oldVisuals: old, newVisuals: next })
995
+ );
996
+ return;
997
+ }
998
+ }
999
+ this.groups_.set(group, next);
1000
+ this.notifyAll_(
1001
+ (o) => o.onTabGroupChanged?.({ type: "visualsChanged", groupId: group, oldVisuals: old, newVisuals: next })
1002
+ );
1003
+ }
1004
+ setGroupCollapsed(group, collapsed) {
1005
+ this.updateGroupVisuals(group, { isCollapsed: collapsed });
1006
+ }
1007
+ /** Chrome's TabGroupModel::GetNextColor: least-used color, in palette order. */
1008
+ nextGroupColor_() {
1009
+ const usage = /* @__PURE__ */ new Map();
1010
+ for (const color of TAB_GROUP_COLORS) usage.set(color, 0);
1011
+ for (const { color } of this.groups_.values()) {
1012
+ usage.set(color, (usage.get(color) ?? 0) + 1);
1013
+ }
1014
+ let best = TAB_GROUP_COLORS[0];
1015
+ for (const color of TAB_GROUP_COLORS) {
1016
+ if (usage.get(color) < usage.get(best)) best = color;
1017
+ }
1018
+ return best;
1019
+ }
1020
+ // Tab data / state ///////////////////////////////////////////////////////
1021
+ /** Swaps the tab's data payload. Emits a 'replaced' change (Chrome: Replace). */
1022
+ setTabData(index, data) {
1023
+ const tab = this.getTabAt(index);
1024
+ const oldData = tab.data;
1025
+ tab.data = data;
1026
+ const selection = this.buildSelectionChange_(this.activeTab_, this.selectionModel(), "none");
1027
+ this.notifyAll_(
1028
+ (o) => o.onTabStripModelChanged?.(
1029
+ { type: "replaced", tab, oldData, newData: data, index },
1030
+ selection
1031
+ )
1032
+ );
1033
+ }
1034
+ /** Notify observers the tab changed in place (after mutating tab.data). */
1035
+ notifyTabChanged(index) {
1036
+ const tab = this.getTabAt(index);
1037
+ this.notifyAll_((o) => o.onTabChanged?.(tab, index));
1038
+ }
1039
+ /** Port of SetTabBlocked (cc:1397). */
1040
+ setTabBlocked(index, blocked) {
1041
+ const tab = this.getTabAt(index);
1042
+ if (tab.blocked === blocked) return;
1043
+ tab.blocked = blocked;
1044
+ this.notifyAll_((o) => o.onTabChanged?.(tab, index));
1045
+ }
1046
+ // Lifecycle (discarding) /////////////////////////////////////////////////
1047
+ /**
1048
+ * Drops the tab's content to save memory while keeping the tab in the
1049
+ * strip. The active tab cannot be discarded (it's visible). Content
1050
+ * remounts fresh on the next activation, like Chrome's reload-on-focus.
1051
+ * Port of TabLifecycleUnit::Discard (tab_lifecycle_unit.cc:346) +
1052
+ * TabStripModel::DiscardWebContentsAt semantics. Returns true on success.
1053
+ */
1054
+ discardTabAt(index) {
1055
+ const tab = this.getTabAt(index);
1056
+ if (tab.discarded) return false;
1057
+ if (tab === this.activeTab_) return false;
1058
+ tab.discarded = true;
1059
+ this.notifyAll_((o) => o.onTabDiscardedStateChanged?.(tab, index, true));
1060
+ return true;
1061
+ }
1062
+ /**
1063
+ * Restores a discarded tab without activating it (Chrome: reloading a
1064
+ * background discarded tab, DidStartLoading path).
1065
+ */
1066
+ restoreTabAt(index) {
1067
+ const tab = this.getTabAt(index);
1068
+ if (!tab.discarded) return false;
1069
+ this.restoreTab_(tab);
1070
+ return true;
1071
+ }
1072
+ /** Per-tab opt-out from automatic discarding (extensions setAutoDiscardable). */
1073
+ setTabAutoDiscardable(index, autoDiscardable) {
1074
+ this.getTabAt(index).autoDiscardable = autoDiscardable;
1075
+ }
1076
+ isTabDiscarded(index) {
1077
+ return this.getTabAt(index).discarded;
1078
+ }
1079
+ /** Number of tabs whose content is currently live (not discarded). */
1080
+ get loadedTabCount() {
1081
+ return this.tabs_.reduce((n, t) => n + (t.discarded ? 0 : 1), 0);
1082
+ }
1083
+ restoreTab_(tab) {
1084
+ tab.discarded = false;
1085
+ const index = this.indexOfTab(tab);
1086
+ this.notifyAll_((o) => o.onTabDiscardedStateChanged?.(tab, index, false));
1087
+ }
1088
+ // Order controller ///////////////////////////////////////////////////////
1089
+ /**
1090
+ * Where to place a newly opened tab. Port of DetermineInsertionIndex
1091
+ * (cc:5329).
1092
+ */
1093
+ determineInsertionIndex(cause, foreground) {
1094
+ if (this.count === 0) return 0;
1095
+ if (cause === "link" && this.activeIndex !== NO_TAB) {
1096
+ if (foreground) {
1097
+ return this.activeIndex + 1;
1098
+ }
1099
+ const opener = this.activeTab_;
1100
+ const index = this.getIndexOfLastTabOpenedBy(opener, this.activeIndex);
1101
+ if (index === NO_TAB) return this.activeIndex + 1;
1102
+ const openerGroup = this.getTabGroupForTab(this.activeIndex);
1103
+ for (let i = this.activeIndex + 1; i <= index; i++) {
1104
+ if (this.getTabGroupForTab(i) !== openerGroup) return i;
1105
+ }
1106
+ return index + 1;
1107
+ }
1108
+ return this.count;
1109
+ }
1110
+ /**
1111
+ * Which tab should become active after the tab at `index` closes.
1112
+ * Port of DetermineNewSelectedIndex (cc:5377), single-tab block, with the
1113
+ * "parent collection" preference specialized to groups. Returns the index
1114
+ * in post-close coordinates, or null if this is the last tab.
1115
+ */
1116
+ determineNewSelectedIndex_(index) {
1117
+ if (this.count === 1) return null;
1118
+ const blockStart = index;
1119
+ const blockEnd = index + 1;
1120
+ const afterClosing = (i) => i > blockEnd - 1 ? i - 1 : i;
1121
+ let next = this.getIndexOfNextTabOpenedBy_(blockStart, blockEnd);
1122
+ if (next !== NO_TAB && !this.isTabCollapsed(next)) return afterClosing(next);
1123
+ next = this.getIndexOfNextTabOpenedByOpenerOf_(blockStart, blockEnd);
1124
+ if (next !== NO_TAB && !this.isTabCollapsed(next)) return afterClosing(next);
1125
+ const opener = this.tabs_[index].opener;
1126
+ if (opener) {
1127
+ const openerIndex = this.indexOfTab(opener);
1128
+ if (openerIndex !== NO_TAB && openerIndex !== index && !this.isTabCollapsed(openerIndex)) {
1129
+ return afterClosing(openerIndex);
1130
+ }
1131
+ }
1132
+ const group = this.tabs_[index].group;
1133
+ if (group !== null) {
1134
+ const range = this.listTabsInGroup(group);
1135
+ if (range.end !== blockEnd) return afterClosing(blockEnd);
1136
+ if (range.start !== blockStart) return afterClosing(blockStart - 1);
1137
+ }
1138
+ const nextAvailable = this.getNextExpandedActiveTab_(blockStart, blockEnd);
1139
+ if (nextAvailable !== null) return afterClosing(nextAvailable);
1140
+ if (blockEnd - 1 >= this.count - 1) return blockStart - 1;
1141
+ return blockEnd - 1;
1142
+ }
1143
+ /** Port of GetIndexOfNextWebContentsOpenedBy (cc:3286). */
1144
+ getIndexOfNextTabOpenedBy_(blockStart, blockEnd) {
1145
+ const blockTabs = new Set(this.tabs_.slice(blockStart, blockEnd));
1146
+ for (let i = blockEnd; i < this.count; i++) {
1147
+ const opener = this.tabs_[i].opener;
1148
+ if (opener && blockTabs.has(opener)) return i;
1149
+ }
1150
+ for (let i = blockStart - 1; i >= 0; i--) {
1151
+ const opener = this.tabs_[i].opener;
1152
+ if (opener && blockTabs.has(opener)) return i;
1153
+ }
1154
+ return NO_TAB;
1155
+ }
1156
+ /** Port of GetIndexOfNextWebContentsOpenedByOpenerOf (cc:3312). */
1157
+ getIndexOfNextTabOpenedByOpenerOf_(blockStart, blockEnd) {
1158
+ const blockOpeners = /* @__PURE__ */ new Set();
1159
+ for (let i = blockStart; i < blockEnd; i++) {
1160
+ const opener = this.tabs_[i].opener;
1161
+ if (opener) blockOpeners.add(opener);
1162
+ }
1163
+ if (blockOpeners.size === 0) return NO_TAB;
1164
+ for (let i = blockEnd; i < this.count; i++) {
1165
+ const opener = this.tabs_[i].opener;
1166
+ if (opener && blockOpeners.has(opener)) return i;
1167
+ }
1168
+ for (let i = blockStart - 1; i >= 0; i--) {
1169
+ const opener = this.tabs_[i].opener;
1170
+ if (opener && blockOpeners.has(opener)) return i;
1171
+ }
1172
+ return NO_TAB;
1173
+ }
1174
+ /** Port of GetNextExpandedActiveTab (cc:3346): right of block, then left. */
1175
+ getNextExpandedActiveTab_(blockStart, blockEnd) {
1176
+ for (let i = blockEnd; i < this.count; i++) {
1177
+ if (!this.isTabCollapsed(i)) return i;
1178
+ }
1179
+ for (let i = blockStart - 1; i >= 0; i--) {
1180
+ if (!this.isTabCollapsed(i)) return i;
1181
+ }
1182
+ return null;
1183
+ }
1184
+ // Internal mechanics /////////////////////////////////////////////////////
1185
+ createTab_(data, id) {
1186
+ return {
1187
+ id: id ?? this.generateId_(),
1188
+ data,
1189
+ opener: null,
1190
+ resetOpenerOnActiveTabChange: false,
1191
+ pinned: false,
1192
+ group: null,
1193
+ blocked: false,
1194
+ discarded: false,
1195
+ lastActiveAt: Date.now(),
1196
+ autoDiscardable: true
1197
+ };
1198
+ }
1199
+ /** Port of ConstrainInsertionIndex (cc:3408). */
1200
+ constrainInsertionIndex_(index, pinned) {
1201
+ return pinned ? clamp(index, 0, this.indexOfFirstNonPinnedTab()) : clamp(index, this.indexOfFirstNonPinnedTab(), this.count);
1202
+ }
1203
+ /** Port of ConstrainMoveIndex (cc:3413). */
1204
+ constrainMoveIndex_(index, pinned) {
1205
+ return pinned ? clamp(index, 0, this.indexOfFirstNonPinnedTab() - 1) : clamp(index, this.indexOfFirstNonPinnedTab(), this.count - 1);
1206
+ }
1207
+ /** Port of InsertTabAtImpl (cc:3575). Returns the index actually used. */
1208
+ insertTabAtImpl_(index, tab, flags, group) {
1209
+ const active = (flags & AddTabFlags.ACTIVE) !== 0 || this.empty;
1210
+ const pin = (flags & AddTabFlags.PINNED) !== 0;
1211
+ index = this.constrainInsertionIndex_(index, pin);
1212
+ const activeTab = this.activeTab_;
1213
+ if ((flags & AddTabFlags.INHERIT_OPENER) !== 0 && activeTab) {
1214
+ if (active) this.forgetAllOpeners();
1215
+ tab.opener = activeTab;
1216
+ }
1217
+ tab.pinned = pin;
1218
+ tab.group = pin ? null : group;
1219
+ const oldActive = this.activeTab_;
1220
+ this.tabs_.splice(index, 0, tab);
1221
+ const oldModel = this.selectionModel();
1222
+ if (active) this.setSelectedTab_(tab);
1223
+ this.validate_();
1224
+ const selection = this.buildSelectionChange_(oldActive, oldModel, "none");
1225
+ this.notifyAll_(
1226
+ (o) => o.onTabStripModelChanged?.(
1227
+ { type: "inserted", contents: [{ tab, index }] },
1228
+ selection
1229
+ )
1230
+ );
1231
+ if (tab.group !== null) {
1232
+ this.notifyAll_((o) => o.onTabGroupedStateChanged?.(null, tab.group, tab, index));
1233
+ }
1234
+ this.handleActiveTabChanged_(selection);
1235
+ return index;
1236
+ }
1237
+ /**
1238
+ * Single-tab move with explicit final group/pin state. Port of
1239
+ * MoveTabToIndexImpl (cc:4617).
1240
+ */
1241
+ moveTabToIndexImpl_(initialIndex, finalIndex, group, pin, selectAfterMove) {
1242
+ const tab = this.tabs_[initialIndex];
1243
+ const initialPinned = tab.pinned;
1244
+ const initialGroup = tab.group;
1245
+ if (initialIndex === finalIndex && group === initialGroup && pin === initialPinned) return;
1246
+ if (initialIndex !== finalIndex) this.fixOpeners_(initialIndex);
1247
+ const oldActive = this.activeTab_;
1248
+ const oldModel = this.selectionModel();
1249
+ this.tabs_.splice(initialIndex, 1);
1250
+ this.tabs_.splice(finalIndex, 0, tab);
1251
+ tab.pinned = pin;
1252
+ tab.group = pin ? null : group;
1253
+ if (selectAfterMove) this.setSelectedTab_(tab);
1254
+ this.validate_();
1255
+ const selection = this.buildSelectionChange_(oldActive, oldModel, "none");
1256
+ if (initialIndex !== finalIndex) {
1257
+ this.notifyAll_(
1258
+ (o) => o.onTabStripModelChanged?.(
1259
+ { type: "moved", tab, fromIndex: initialIndex, toIndex: finalIndex },
1260
+ selection
1261
+ )
1262
+ );
1263
+ }
1264
+ if (initialPinned !== tab.pinned) {
1265
+ this.notifyAll_((o) => o.onTabPinnedStateChanged?.(tab, finalIndex));
1266
+ }
1267
+ this.emitGroupStateChange_(tab, finalIndex, initialGroup, tab.group);
1268
+ this.handleActiveTabChanged_(selection);
1269
+ }
1270
+ /**
1271
+ * Block move: removes the tabs at `indices`, reinserts them contiguously at
1272
+ * `destination` (post-removal coordinates), assigning the given group and
1273
+ * the pinned state of the first moving tab. Port of MoveTabsToIndexImpl
1274
+ * (cc:4698) + MoveTabsWithNotifications (cc:5081).
1275
+ */
1276
+ moveTabsToIndexImpl_(indices, destination, group) {
1277
+ if (indices.length === 0) return;
1278
+ assertAscending(indices);
1279
+ const pin = this.tabs_[indices[0]].pinned;
1280
+ const moving = indices.map((i) => this.tabs_[i]);
1281
+ const initial = moving.map((tab, k) => ({
1282
+ tab,
1283
+ index: indices[k],
1284
+ group: tab.group,
1285
+ pinned: tab.pinned
1286
+ }));
1287
+ const oldActive = this.activeTab_;
1288
+ const oldModel = this.selectionModel();
1289
+ for (const i of indices) this.fixOpeners_(i);
1290
+ const movingSet = new Set(moving);
1291
+ const remaining = this.tabs_.filter((t) => !movingSet.has(t));
1292
+ remaining.splice(destination, 0, ...moving);
1293
+ this.tabs_ = remaining;
1294
+ for (const tab of moving) {
1295
+ tab.pinned = pin;
1296
+ if (group !== "keep") tab.group = pin ? null : group;
1297
+ }
1298
+ this.validate_();
1299
+ const selection = this.buildSelectionChange_(oldActive, oldModel, "none");
1300
+ for (const note of initial) {
1301
+ const finalIndex = this.indexOfTab(note.tab);
1302
+ if (note.index !== finalIndex) {
1303
+ this.notifyAll_(
1304
+ (o) => o.onTabStripModelChanged?.(
1305
+ { type: "moved", tab: note.tab, fromIndex: note.index, toIndex: finalIndex },
1306
+ selection
1307
+ )
1308
+ );
1309
+ }
1310
+ if (note.pinned !== note.tab.pinned) {
1311
+ this.notifyAll_((o) => o.onTabPinnedStateChanged?.(note.tab, finalIndex));
1312
+ }
1313
+ this.emitGroupStateChange_(note.tab, finalIndex, note.group, note.tab.group);
1314
+ }
1315
+ this.handleActiveTabChanged_(selection);
1316
+ }
1317
+ /**
1318
+ * Port of MoveTabsAndSetPropertiesImpl (cc:4469): destination is given in
1319
+ * pre-removal coordinates and adjusted here.
1320
+ */
1321
+ moveTabsAndSetProperties_(indices, destinationIndex, group, pinned) {
1322
+ if (indices.length === 0) return;
1323
+ let numTabsLeftOfDestination = 0;
1324
+ for (const i of indices) {
1325
+ if (i >= destinationIndex) break;
1326
+ numTabsLeftOfDestination++;
1327
+ }
1328
+ void pinned;
1329
+ this.moveTabsToIndexImpl_(indices, destinationIndex - numTabsLeftOfDestination, group);
1330
+ }
1331
+ /**
1332
+ * Group to assign when a tab moves from index to toPosition so groups stay
1333
+ * contiguous. Port of GetGroupToAssign (cc:5195).
1334
+ */
1335
+ getGroupToAssign_(index, toPosition) {
1336
+ const tab = this.tabs_[index];
1337
+ if (!this.supportsGroups_) return null;
1338
+ let newLeftGroup = null;
1339
+ let newRightGroup = null;
1340
+ if (toPosition > index) {
1341
+ newLeftGroup = this.getTabGroupForTab(toPosition);
1342
+ newRightGroup = this.getTabGroupForTab(toPosition + 1);
1343
+ } else if (toPosition < index) {
1344
+ newLeftGroup = this.getTabGroupForTab(toPosition - 1);
1345
+ newRightGroup = this.getTabGroupForTab(toPosition);
1346
+ }
1347
+ if (tab.group !== newLeftGroup && tab.group !== newRightGroup) {
1348
+ if (newLeftGroup === newRightGroup && newLeftGroup !== null) {
1349
+ return newLeftGroup;
1350
+ }
1351
+ if (tab.group !== null && this.tabs_.filter((t) => t.group === tab.group).length > 1) {
1352
+ return null;
1353
+ }
1354
+ }
1355
+ return tab.group;
1356
+ }
1357
+ /**
1358
+ * Re-points the openers of any tab that referenced the tab at index at that
1359
+ * tab's own opener. Port of FixOpeners (cc:5171).
1360
+ */
1361
+ fixOpeners_(index) {
1362
+ const oldTab = this.tabs_[index];
1363
+ const newOpener = oldTab.opener;
1364
+ for (const tab of this.tabs_) {
1365
+ if (tab.opener !== oldTab) continue;
1366
+ tab.opener = newOpener === tab ? null : newOpener;
1367
+ }
1368
+ }
1369
+ /** Selection helpers (Chrome: TabStripModelSelectionState). */
1370
+ setSelectedTab_(tab) {
1371
+ this.selectedTabs_ = /* @__PURE__ */ new Set([tab]);
1372
+ this.activeTab_ = tab;
1373
+ this.anchorTab_ = tab;
1374
+ }
1375
+ firstSelectedTab_() {
1376
+ let best = null;
1377
+ let bestIndex = Infinity;
1378
+ for (const tab of this.selectedTabs_) {
1379
+ const i = this.indexOfTab(tab);
1380
+ if (i !== NO_TAB && i < bestIndex) {
1381
+ bestIndex = i;
1382
+ best = tab;
1383
+ }
1384
+ }
1385
+ return best;
1386
+ }
1387
+ selectedIndices_() {
1388
+ return [...this.selectedTabs_].map((t) => this.indexOfTab(t)).filter((i) => i !== NO_TAB).sort((a, b) => a - b);
1389
+ }
1390
+ /**
1391
+ * Wraps a selection mutation in change tracking + notification. Mirrors
1392
+ * SetSelection (cc:1178).
1393
+ */
1394
+ setSelection_(mutate, reason) {
1395
+ const oldActive = this.activeTab_;
1396
+ const oldModel = this.selectionModel();
1397
+ mutate();
1398
+ const selection = this.buildSelectionChange_(oldActive, oldModel, reason);
1399
+ if (selection.activeTabChanged || selection.selectionChanged) {
1400
+ this.notifyAll_(
1401
+ (o) => o.onTabStripModelChanged?.({ type: "selectionOnly" }, selection)
1402
+ );
1403
+ this.handleActiveTabChanged_(selection);
1404
+ }
1405
+ }
1406
+ buildSelectionChange_(oldTab, oldModel, reason) {
1407
+ const newTab = this.activeTab_;
1408
+ const newModel = this.selectionModel();
1409
+ return {
1410
+ oldTab,
1411
+ newTab,
1412
+ oldModel,
1413
+ newModel,
1414
+ reason,
1415
+ get activeTabChanged() {
1416
+ return oldTab !== newTab;
1417
+ },
1418
+ get selectionChanged() {
1419
+ return !oldModel.equals(newModel);
1420
+ }
1421
+ };
1422
+ }
1423
+ /**
1424
+ * Opener and lifecycle bookkeeping when the active tab changes. Port of
1425
+ * OnActiveTabChanged (cc:5255) plus TabLifecycleUnit::SetFocused
1426
+ * (tab_lifecycle_unit.cc:135).
1427
+ */
1428
+ handleActiveTabChanged_(selection) {
1429
+ if (!selection.activeTabChanged || this.empty) return;
1430
+ const oldTab = selection.oldTab;
1431
+ const newTab = selection.newTab;
1432
+ let oldOpener = null;
1433
+ if (oldTab && this.indexOfTab(oldTab) !== NO_TAB) {
1434
+ oldTab.lastActiveAt = Date.now();
1435
+ }
1436
+ if (newTab) {
1437
+ newTab.lastActiveAt = Infinity;
1438
+ if (newTab.discarded) this.restoreTab_(newTab);
1439
+ }
1440
+ if (oldTab && this.indexOfTab(oldTab) !== NO_TAB) {
1441
+ oldOpener = oldTab.opener;
1442
+ if (oldTab.resetOpenerOnActiveTabChange) {
1443
+ oldTab.opener = null;
1444
+ oldTab.resetOpenerOnActiveTabChange = false;
1445
+ }
1446
+ }
1447
+ const newOpener = newTab?.opener ?? null;
1448
+ if (selection.reason === "userGesture" && newOpener !== oldOpener && newOpener !== oldTab && oldOpener !== newTab) {
1449
+ this.forgetAllOpeners();
1450
+ }
1451
+ }
1452
+ emitGroupStateChange_(tab, index, oldGroup, newGroup) {
1453
+ if (oldGroup === newGroup) return;
1454
+ this.notifyAll_((o) => o.onTabGroupedStateChanged?.(oldGroup, newGroup, tab, index));
1455
+ if (oldGroup !== null && this.groups_.has(oldGroup) && !this.tabs_.some((t) => t.group === oldGroup)) {
1456
+ this.groups_.delete(oldGroup);
1457
+ this.notifyAll_((o) => o.onTabGroupChanged?.({ type: "closed", groupId: oldGroup }));
1458
+ }
1459
+ }
1460
+ notifyAll_(fn) {
1461
+ this.reentrancyGuard_ = true;
1462
+ try {
1463
+ for (const observer of [...this.observers_]) fn(observer);
1464
+ } finally {
1465
+ this.reentrancyGuard_ = false;
1466
+ }
1467
+ }
1468
+ requireGroups_() {
1469
+ if (!this.supportsGroups_) throw new Error("tab groups are disabled for this model");
1470
+ }
1471
+ checkReentrancy_() {
1472
+ if (this.reentrancyGuard_) {
1473
+ throw new Error("TabStripModel is not re-entrant; do not mutate it from an observer");
1474
+ }
1475
+ }
1476
+ /**
1477
+ * Invariant validation. Port of CompleteModelUpdateTransaction; Chrome
1478
+ * CHECKs, we throw.
1479
+ */
1480
+ validate_() {
1481
+ const firstNonPinned = this.indexOfFirstNonPinnedTab();
1482
+ for (let i = 0; i < this.tabs_.length; i++) {
1483
+ const tab = this.tabs_[i];
1484
+ if (tab.pinned !== i < firstNonPinned) {
1485
+ throw new Error(`invariant violated: pinned tab at index ${i} after unpinned tabs`);
1486
+ }
1487
+ if (tab.pinned && tab.group !== null) {
1488
+ throw new Error(`invariant violated: pinned tab at index ${i} is grouped`);
1489
+ }
1490
+ }
1491
+ const seenGroups = /* @__PURE__ */ new Set();
1492
+ let currentGroup = null;
1493
+ for (const tab of this.tabs_) {
1494
+ if (tab.group !== currentGroup) {
1495
+ if (tab.group !== null && seenGroups.has(tab.group)) {
1496
+ throw new Error(`invariant violated: group ${tab.group} is not contiguous`);
1497
+ }
1498
+ if (tab.group !== null) seenGroups.add(tab.group);
1499
+ currentGroup = tab.group;
1500
+ }
1501
+ }
1502
+ if (!this.empty && (!this.activeTab_ || this.indexOfTab(this.activeTab_) === NO_TAB)) {
1503
+ throw new Error("invariant violated: no valid active tab");
1504
+ }
1505
+ }
1506
+ };
1507
+ function clamp(value, lo, hi) {
1508
+ return Math.min(Math.max(value, lo), hi);
1509
+ }
1510
+ function countLessThan(sorted, threshold) {
1511
+ let n = 0;
1512
+ for (const v of sorted) {
1513
+ if (v < threshold) n++;
1514
+ else break;
1515
+ }
1516
+ return n;
1517
+ }
1518
+ function assertAscending(indices) {
1519
+ for (let i = 1; i < indices.length; i++) {
1520
+ if (indices[i] <= indices[i - 1]) {
1521
+ throw new Error("indices must be sorted in ascending order");
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ // src/core/tab-lifecycle-manager.ts
1527
+ var DEFAULT_MAX_LOADED_TABS = 10;
1528
+ var DEFAULT_RECENTLY_ACTIVE_PROTECTION_MS = 10 * 60 * 1e3;
1529
+ var TabLifecycleManager = class {
1530
+ model_;
1531
+ maxLoadedTabs_;
1532
+ recentlyActiveProtectionMs_;
1533
+ protectPinnedTabs_;
1534
+ canDiscardTab_;
1535
+ onBeforeDiscard_;
1536
+ exclusiveContentKey_;
1537
+ detach_ = null;
1538
+ enforcePending_ = false;
1539
+ constructor(model, options = {}) {
1540
+ this.model_ = model;
1541
+ this.maxLoadedTabs_ = options.maxLoadedTabs === void 0 ? DEFAULT_MAX_LOADED_TABS : options.maxLoadedTabs;
1542
+ this.recentlyActiveProtectionMs_ = options.recentlyActiveProtectionMs ?? DEFAULT_RECENTLY_ACTIVE_PROTECTION_MS;
1543
+ this.protectPinnedTabs_ = options.protectPinnedTabs ?? true;
1544
+ this.canDiscardTab_ = options.canDiscardTab ?? null;
1545
+ this.onBeforeDiscard_ = options.onBeforeDiscard ?? null;
1546
+ this.exclusiveContentKey_ = options.exclusiveContentKey ?? null;
1547
+ }
1548
+ /**
1549
+ * Starts observing the model and enforcing the loaded-tab budget. Returns
1550
+ * a stop function. Discards run on a microtask after model changes, never
1551
+ * re-entrantly (Chrome posts discard tasks for the same reason).
1552
+ */
1553
+ start() {
1554
+ if (this.detach_) return () => this.stop();
1555
+ const schedule = () => this.scheduleEnforce_();
1556
+ this.detach_ = this.model_.addObserver({
1557
+ onTabStripModelChanged: (change) => {
1558
+ if (change.type === "inserted" || change.type === "selectionOnly" || change.type === "replaced") {
1559
+ schedule();
1560
+ }
1561
+ },
1562
+ onTabChanged: () => schedule(),
1563
+ onTabDiscardedStateChanged: (_tab, _index, discarded) => {
1564
+ if (!discarded) schedule();
1565
+ }
1566
+ });
1567
+ this.scheduleEnforce_();
1568
+ return () => this.stop();
1569
+ }
1570
+ stop() {
1571
+ this.detach_?.();
1572
+ this.detach_ = null;
1573
+ }
1574
+ /**
1575
+ * Tri-state discard eligibility for one tab. Port of
1576
+ * DiscardEligibilityPolicy::CanDiscard.
1577
+ */
1578
+ canDiscard(tab) {
1579
+ const reasons = [];
1580
+ let result = "eligible";
1581
+ const disallow = (r) => {
1582
+ reasons.push(r);
1583
+ result = "disallowed";
1584
+ };
1585
+ const protect = (r) => {
1586
+ reasons.push(r);
1587
+ if (result === "eligible") result = "protected";
1588
+ };
1589
+ if (tab.discarded) disallow("alreadyDiscarded");
1590
+ if (tab === this.model_.activeTab) disallow("activeTab");
1591
+ if (!tab.autoDiscardable) disallow("optedOut");
1592
+ if (this.canDiscardTab_ && !this.canDiscardTab_(tab)) disallow("appVeto");
1593
+ if (this.protectPinnedTabs_ && tab.pinned) protect("pinnedTab");
1594
+ if (Date.now() - tab.lastActiveAt < this.recentlyActiveProtectionMs_) {
1595
+ protect("recentlyActive");
1596
+ }
1597
+ return { result, reasons };
1598
+ }
1599
+ /**
1600
+ * Discard candidates in Chrome's importance order, least important first:
1601
+ * eligible before protected, each group least-recently-active first,
1602
+ * disallowed never. Port of PageNodeSortProxy::operator<
1603
+ * (discard_eligibility_policy.h:95) with focused/visible folded into
1604
+ * 'activeTab' (a single-window strip has one visible tab).
1605
+ */
1606
+ getDiscardCandidates(includeProtected) {
1607
+ const eligible = [];
1608
+ const protectedTabs = [];
1609
+ for (const tab of this.model_.getTabs()) {
1610
+ const decision = this.canDiscard(tab);
1611
+ if (decision.result === "eligible") eligible.push(tab);
1612
+ else if (decision.result === "protected") protectedTabs.push(tab);
1613
+ }
1614
+ const byLeastRecentlyActive = (a, b) => a.lastActiveAt - b.lastActiveAt;
1615
+ eligible.sort(byLeastRecentlyActive);
1616
+ protectedTabs.sort(byLeastRecentlyActive);
1617
+ return includeProtected ? [...eligible, ...protectedTabs] : eligible;
1618
+ }
1619
+ /**
1620
+ * Discards the least important discardable tab. 'urgent' may take
1621
+ * protected tabs (Chrome: urgent discarding under memory pressure);
1622
+ * 'proactive' and 'external' only take eligible ones. Port of
1623
+ * PageDiscardingHelper::DiscardAPage. Returns the discarded tab or null.
1624
+ */
1625
+ discardLeastImportant(reason = "proactive") {
1626
+ const candidates = this.getDiscardCandidates(reason === "urgent");
1627
+ const tab = candidates[0];
1628
+ if (!tab) return null;
1629
+ this.discardTab_(tab);
1630
+ return tab;
1631
+ }
1632
+ /**
1633
+ * Discards tabs until the loaded count fits the budget. Eligible tabs go
1634
+ * first; protected tabs are taken only if the budget still isn't met
1635
+ * (matching the sort order Chrome walks when reclaiming memory).
1636
+ * Disallowed tabs are never discarded, so the budget can be exceeded when
1637
+ * everything left is active/opted-out/vetoed.
1638
+ */
1639
+ enforceBudget() {
1640
+ if (this.maxLoadedTabs_ === null) return 0;
1641
+ let discarded = 0;
1642
+ const overBudget = () => this.model_.loadedTabCount > this.maxLoadedTabs_;
1643
+ if (!overBudget()) return 0;
1644
+ for (const tab of this.getDiscardCandidates(true)) {
1645
+ if (!overBudget()) break;
1646
+ this.discardTab_(tab);
1647
+ discarded++;
1648
+ }
1649
+ return discarded;
1650
+ }
1651
+ /**
1652
+ * Enforces `exclusiveContentKey`: for every key shared by more than one
1653
+ * loaded tab, keep the active tab (else the most recently active) and
1654
+ * discard the rest. Returns the number of tabs discarded. Runs before the
1655
+ * budget pass, so duplicates count toward freed budget first.
1656
+ */
1657
+ enforceExclusiveContent() {
1658
+ if (!this.exclusiveContentKey_) return 0;
1659
+ const groups = /* @__PURE__ */ new Map();
1660
+ for (const tab of this.model_.getTabs()) {
1661
+ if (tab.discarded) continue;
1662
+ const key = this.exclusiveContentKey_(tab);
1663
+ if (key === null || key === void 0) continue;
1664
+ const group = groups.get(key);
1665
+ if (group) group.push(tab);
1666
+ else groups.set(key, [tab]);
1667
+ }
1668
+ const activeTab = this.model_.activeTab;
1669
+ let discarded = 0;
1670
+ for (const tabs of groups.values()) {
1671
+ if (tabs.length < 2) continue;
1672
+ const keep = tabs.includes(activeTab) ? activeTab : tabs.reduce((a, b) => b.lastActiveAt > a.lastActiveAt ? b : a);
1673
+ for (const tab of tabs) {
1674
+ if (tab === keep) continue;
1675
+ this.discardTab_(tab);
1676
+ discarded++;
1677
+ }
1678
+ }
1679
+ return discarded;
1680
+ }
1681
+ discardTab_(tab) {
1682
+ const index = this.model_.indexOfTab(tab);
1683
+ if (index === -1) return;
1684
+ this.onBeforeDiscard_?.(tab);
1685
+ this.model_.discardTabAt(index);
1686
+ }
1687
+ scheduleEnforce_() {
1688
+ if (this.enforcePending_) return;
1689
+ this.enforcePending_ = true;
1690
+ queueMicrotask(() => {
1691
+ this.enforcePending_ = false;
1692
+ if (this.detach_) {
1693
+ this.enforceExclusiveContent();
1694
+ this.enforceBudget();
1695
+ }
1696
+ });
1697
+ }
1698
+ };
1699
+
1700
+ // src/react/use-tab-strip.ts
1701
+ var import_react = require("react");
1702
+ function useTabStripModel(init, options) {
1703
+ const ref = (0, import_react.useRef)(null);
1704
+ if (ref.current === null) {
1705
+ ref.current = new TabStripModel(options);
1706
+ init?.(ref.current);
1707
+ }
1708
+ return ref.current;
1709
+ }
1710
+ function useTabStrip(model) {
1711
+ const store = (0, import_react.useMemo)(() => {
1712
+ let version = 0;
1713
+ let snapshotVersion = -1;
1714
+ let snapshot = null;
1715
+ return {
1716
+ subscribe(onStoreChange) {
1717
+ return model.addObserver({
1718
+ onTabStripModelChanged: () => {
1719
+ version++;
1720
+ onStoreChange();
1721
+ },
1722
+ onTabPinnedStateChanged: () => {
1723
+ version++;
1724
+ onStoreChange();
1725
+ },
1726
+ onTabGroupedStateChanged: () => {
1727
+ version++;
1728
+ onStoreChange();
1729
+ },
1730
+ onTabGroupChanged: () => {
1731
+ version++;
1732
+ onStoreChange();
1733
+ },
1734
+ onTabChanged: () => {
1735
+ version++;
1736
+ onStoreChange();
1737
+ },
1738
+ onTabDiscardedStateChanged: () => {
1739
+ version++;
1740
+ onStoreChange();
1741
+ }
1742
+ });
1743
+ },
1744
+ getSnapshot() {
1745
+ if (snapshot === null || snapshotVersion !== version) {
1746
+ snapshot = {
1747
+ tabs: model.getTabs(),
1748
+ activeTab: model.activeTab,
1749
+ activeIndex: model.activeIndex,
1750
+ selectedIndices: model.selectionModel().selectedIndices(),
1751
+ groups: model.getGroups()
1752
+ };
1753
+ snapshotVersion = version;
1754
+ }
1755
+ return snapshot;
1756
+ }
1757
+ };
1758
+ }, [model]);
1759
+ return (0, import_react.useSyncExternalStore)(store.subscribe, store.getSnapshot, store.getSnapshot);
1760
+ }
1761
+
1762
+ // src/react/use-tab-drag.ts
1763
+ var import_react2 = require("react");
1764
+ var DRAG_THRESHOLD_PX = 4;
1765
+ function useTabDrag(model, containerRef) {
1766
+ const [draggingTabId, setDraggingTabId] = (0, import_react2.useState)(null);
1767
+ const drag = (0, import_react2.useRef)(null);
1768
+ const onTabPointerDown = (0, import_react2.useCallback)(
1769
+ (event, tabId) => {
1770
+ if (event.button !== 0) return;
1771
+ drag.current = { tabId, startX: event.clientX, started: false };
1772
+ const target = event.currentTarget;
1773
+ target.setPointerCapture(event.pointerId);
1774
+ const onMove = (e) => {
1775
+ const state = drag.current;
1776
+ if (!state) return;
1777
+ if (!state.started) {
1778
+ if (Math.abs(e.clientX - state.startX) < DRAG_THRESHOLD_PX) return;
1779
+ state.started = true;
1780
+ setDraggingTabId(state.tabId);
1781
+ }
1782
+ const container = containerRef.current;
1783
+ if (!container) return;
1784
+ const tab = model.getTabById(state.tabId);
1785
+ if (!tab) return;
1786
+ const currentIndex = model.indexOfTab(tab);
1787
+ const elements = [...container.querySelectorAll("[data-tab-id]")];
1788
+ let targetIndex = 0;
1789
+ for (const el of elements) {
1790
+ if (el.dataset["tabId"] === state.tabId) continue;
1791
+ const rect = el.getBoundingClientRect();
1792
+ if (e.clientX > rect.left + rect.width / 2) targetIndex++;
1793
+ }
1794
+ if (targetIndex !== currentIndex) {
1795
+ model.moveTabTo(currentIndex, targetIndex);
1796
+ }
1797
+ };
1798
+ const onUp = () => {
1799
+ drag.current = null;
1800
+ setDraggingTabId(null);
1801
+ target.removeEventListener("pointermove", onMove);
1802
+ target.removeEventListener("pointerup", onUp);
1803
+ target.removeEventListener("pointercancel", onUp);
1804
+ };
1805
+ target.addEventListener("pointermove", onMove);
1806
+ target.addEventListener("pointerup", onUp);
1807
+ target.addEventListener("pointercancel", onUp);
1808
+ },
1809
+ [model, containerRef]
1810
+ );
1811
+ return { draggingTabId, onTabPointerDown };
1812
+ }
1813
+
1814
+ // src/react/tab-panels.tsx
1815
+ var import_react3 = require("react");
1816
+ var import_jsx_runtime = require("react/jsx-runtime");
1817
+ var TabVisibilityContext = (0, import_react3.createContext)("visible");
1818
+ function useTabVisibility() {
1819
+ return (0, import_react3.useContext)(TabVisibilityContext);
1820
+ }
1821
+ function TabPanels({ model, children, className, hideMode = "display-none" }) {
1822
+ const snapshot = useTabStrip(model);
1823
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: ["ctabs-panels", className].filter(Boolean).join(" "), children: snapshot.tabs.map((tab) => {
1824
+ if (tab.discarded) return null;
1825
+ const visible = tab === snapshot.activeTab;
1826
+ const style = hideMode === "display-none" ? { display: visible ? void 0 : "none" } : {
1827
+ visibility: visible ? void 0 : "hidden",
1828
+ position: visible ? void 0 : "absolute",
1829
+ inset: visible ? void 0 : 0
1830
+ };
1831
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1832
+ "div",
1833
+ {
1834
+ role: "tabpanel",
1835
+ "data-tab-panel-id": tab.id,
1836
+ className: "ctabs-panel",
1837
+ style,
1838
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TabVisibilityContext.Provider, { value: visible ? "visible" : "hidden", children: children(tab) })
1839
+ },
1840
+ tab.id
1841
+ );
1842
+ }) });
1843
+ }
1844
+
1845
+ // src/react/tab-strip.tsx
1846
+ var import_react4 = require("react");
1847
+
1848
+ // src/react/group-header.tsx
1849
+ var import_jsx_runtime2 = require("react/jsx-runtime");
1850
+ var GROUP_COLOR_VALUES = {
1851
+ grey: "#5f6368",
1852
+ blue: "#1a73e8",
1853
+ red: "#d93025",
1854
+ yellow: "#f9ab00",
1855
+ green: "#188038",
1856
+ pink: "#d01884",
1857
+ purple: "#a142f4",
1858
+ cyan: "#007b83",
1859
+ orange: "#fa903e"
1860
+ };
1861
+ function GroupHeader({ group, onToggleCollapsed }) {
1862
+ const color = GROUP_COLOR_VALUES[group.visualData.color] ?? GROUP_COLOR_VALUES["grey"];
1863
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1864
+ "button",
1865
+ {
1866
+ type: "button",
1867
+ className: [
1868
+ "ctabs-group-header",
1869
+ group.visualData.isCollapsed && "ctabs-group-header--collapsed"
1870
+ ].filter(Boolean).join(" "),
1871
+ style: { ["--ctabs-group-color"]: color },
1872
+ onClick: () => onToggleCollapsed(group.id),
1873
+ title: group.visualData.isCollapsed ? "Expand group" : "Collapse group",
1874
+ children: group.visualData.title || "\xA0"
1875
+ }
1876
+ );
1877
+ }
1878
+
1879
+ // src/react/tab-item.tsx
1880
+ var import_jsx_runtime3 = require("react/jsx-runtime");
1881
+ function TabItem({
1882
+ tab,
1883
+ index,
1884
+ active,
1885
+ selected,
1886
+ dragging,
1887
+ groupColor,
1888
+ renderContent,
1889
+ onPointerDown,
1890
+ onActivate,
1891
+ onClose,
1892
+ onContextMenu
1893
+ }) {
1894
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
1895
+ "div",
1896
+ {
1897
+ role: "tab",
1898
+ "aria-selected": active,
1899
+ "data-tab-id": tab.id,
1900
+ className: [
1901
+ "ctabs-tab",
1902
+ active && "ctabs-tab--active",
1903
+ selected && !active && "ctabs-tab--selected",
1904
+ tab.pinned && "ctabs-tab--pinned",
1905
+ tab.discarded && "ctabs-tab--discarded",
1906
+ dragging && "ctabs-tab--dragging",
1907
+ groupColor && "ctabs-tab--grouped"
1908
+ ].filter(Boolean).join(" "),
1909
+ style: groupColor ? { ["--ctabs-group-color"]: groupColor } : void 0,
1910
+ onPointerDown: (e) => onPointerDown(e, tab.id),
1911
+ onMouseDown: (e) => {
1912
+ if (e.button === 1) {
1913
+ e.preventDefault();
1914
+ onClose(index);
1915
+ }
1916
+ },
1917
+ onClick: (e) => onActivate(index, e),
1918
+ onContextMenu: (e) => onContextMenu?.(index, e),
1919
+ children: [
1920
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "ctabs-tab__content", children: renderContent(tab) }),
1921
+ !tab.pinned && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1922
+ "button",
1923
+ {
1924
+ type: "button",
1925
+ className: "ctabs-tab__close",
1926
+ "aria-label": "Close tab",
1927
+ onClick: (e) => {
1928
+ e.stopPropagation();
1929
+ onClose(index);
1930
+ },
1931
+ onPointerDown: (e) => e.stopPropagation(),
1932
+ children: "\xD7"
1933
+ }
1934
+ )
1935
+ ]
1936
+ }
1937
+ );
1938
+ }
1939
+
1940
+ // src/react/tab-strip.tsx
1941
+ var import_jsx_runtime4 = require("react/jsx-runtime");
1942
+ function TabStrip({
1943
+ model,
1944
+ renderTab = (tab) => String(tab.data),
1945
+ onNewTab,
1946
+ onTabContextMenu,
1947
+ className
1948
+ }) {
1949
+ const snapshot = useTabStrip(model);
1950
+ const containerRef = (0, import_react4.useRef)(null);
1951
+ const { draggingTabId, onTabPointerDown } = useTabDrag(model, containerRef);
1952
+ const onActivate = (index, event) => {
1953
+ if (event.shiftKey) {
1954
+ model.extendSelectionTo(index);
1955
+ } else if (event.metaKey || event.ctrlKey) {
1956
+ if (model.isTabSelected(index)) model.deselectTabAt(index);
1957
+ else model.selectTabAt(index);
1958
+ } else {
1959
+ model.activateTabAt(index, { userGesture: true });
1960
+ }
1961
+ };
1962
+ const onKeyDown = (event) => {
1963
+ const move = event.metaKey || event.ctrlKey;
1964
+ if (event.key === "ArrowRight") {
1965
+ move ? model.moveTabNext() : model.selectNextTab({ userGesture: true });
1966
+ event.preventDefault();
1967
+ } else if (event.key === "ArrowLeft") {
1968
+ move ? model.moveTabPrevious() : model.selectPreviousTab({ userGesture: true });
1969
+ event.preventDefault();
1970
+ }
1971
+ };
1972
+ const selected = new Set(snapshot.selectedIndices);
1973
+ const groupById = new Map(snapshot.groups.map((g) => [g.id, g]));
1974
+ const items = [];
1975
+ let previousGroup = null;
1976
+ snapshot.tabs.forEach((tab, index) => {
1977
+ if (tab.group !== null && tab.group !== previousGroup) {
1978
+ const group = groupById.get(tab.group);
1979
+ if (group) {
1980
+ items.push(
1981
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1982
+ GroupHeader,
1983
+ {
1984
+ group,
1985
+ onToggleCollapsed: (id) => model.setGroupCollapsed(id, !model.isGroupCollapsed(id))
1986
+ },
1987
+ `group-${group.id}`
1988
+ )
1989
+ );
1990
+ }
1991
+ }
1992
+ previousGroup = tab.group;
1993
+ if (tab.group !== null && model.isGroupCollapsed(tab.group)) return;
1994
+ const groupColor = tab.group ? GROUP_COLOR_VALUES[groupById.get(tab.group)?.visualData.color ?? "grey"] ?? null : null;
1995
+ items.push(
1996
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1997
+ TabItem,
1998
+ {
1999
+ tab,
2000
+ index,
2001
+ active: index === snapshot.activeIndex,
2002
+ selected: selected.has(index),
2003
+ dragging: tab.id === draggingTabId,
2004
+ groupColor,
2005
+ renderContent: renderTab,
2006
+ onPointerDown: onTabPointerDown,
2007
+ onActivate,
2008
+ onClose: (i) => model.closeTabAt(i),
2009
+ onContextMenu: onTabContextMenu
2010
+ },
2011
+ tab.id
2012
+ )
2013
+ );
2014
+ });
2015
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
2016
+ "div",
2017
+ {
2018
+ ref: containerRef,
2019
+ role: "tablist",
2020
+ tabIndex: 0,
2021
+ className: ["ctabs-strip", className].filter(Boolean).join(" "),
2022
+ onKeyDown,
2023
+ children: [
2024
+ items,
2025
+ onNewTab && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2026
+ "button",
2027
+ {
2028
+ type: "button",
2029
+ className: "ctabs-new-tab",
2030
+ "aria-label": "New tab",
2031
+ onClick: onNewTab,
2032
+ children: "+"
2033
+ }
2034
+ )
2035
+ ]
2036
+ }
2037
+ );
2038
+ }
2039
+
2040
+ // src/react/tabs.tsx
2041
+ var import_jsx_runtime5 = require("react/jsx-runtime");
2042
+ function Tabs({
2043
+ model,
2044
+ renderTab,
2045
+ children,
2046
+ onNewTab,
2047
+ onTabContextMenu,
2048
+ hideMode,
2049
+ className
2050
+ }) {
2051
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: ["ctabs", className].filter(Boolean).join(" "), children: [
2052
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
2053
+ TabStrip,
2054
+ {
2055
+ model,
2056
+ renderTab,
2057
+ onNewTab,
2058
+ onTabContextMenu
2059
+ }
2060
+ ),
2061
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(TabPanels, { model, hideMode, children })
2062
+ ] });
2063
+ }
2064
+ // Annotate the CommonJS export names for ESM import in node:
2065
+ 0 && (module.exports = {
2066
+ AddTabFlags,
2067
+ CloseTabFlags,
2068
+ GROUP_COLOR_VALUES,
2069
+ GroupHeader,
2070
+ ListSelectionModel,
2071
+ NO_TAB,
2072
+ TAB_GROUP_COLORS,
2073
+ TabItem,
2074
+ TabLifecycleManager,
2075
+ TabPanels,
2076
+ TabStrip,
2077
+ TabStripModel,
2078
+ Tabs,
2079
+ useTabDrag,
2080
+ useTabStrip,
2081
+ useTabStripModel,
2082
+ useTabVisibility
2083
+ });
2084
+ //# sourceMappingURL=index.cjs.map