@flrande/bak-extension 0.3.5 → 0.3.7

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.
@@ -1 +1 @@
1
- 2026-03-09T10:54:43.175Z
1
+ 2026-03-09T13:25:43.147Z
@@ -96,6 +96,16 @@
96
96
  repairActions.push("pruned-missing-tabs");
97
97
  }
98
98
  state.tabIds = tabs.map((tab) => tab.id);
99
+ if (state.windowId !== null) {
100
+ const ownership = await this.inspectWorkspaceWindowOwnership(state, state.windowId);
101
+ if (ownership.foreignTabs.length > 0) {
102
+ const migrated = await this.moveWorkspaceIntoDedicatedWindow(state, ownership, initialUrl);
103
+ window = migrated.window;
104
+ tabs = migrated.tabs;
105
+ state.tabIds = tabs.map((tab) => tab.id);
106
+ repairActions.push("migrated-dirty-window");
107
+ }
108
+ }
99
109
  if (tabs.length === 0) {
100
110
  const primary = await this.createWorkspaceTab({
101
111
  windowId: state.windowId,
@@ -189,7 +199,7 @@
189
199
  const desiredUrl = options.url ?? DEFAULT_WORKSPACE_URL;
190
200
  let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
191
201
  state,
192
- ensured.created || ensured.repairActions.includes("recreated-window") || ensured.repairActions.includes("created-primary-tab")
202
+ ensured.created || ensured.repairActions.includes("recreated-window") || ensured.repairActions.includes("created-primary-tab") || ensured.repairActions.includes("migrated-dirty-window")
193
203
  );
194
204
  let createdTab;
195
205
  try {
@@ -527,6 +537,79 @@
527
537
  }
528
538
  return null;
529
539
  }
540
+ async inspectWorkspaceWindowOwnership(state, windowId) {
541
+ const windowTabs = await this.waitForWindowTabs(windowId, 500);
542
+ const trackedIds = new Set(this.collectCandidateTabIds(state));
543
+ return {
544
+ workspaceTabs: windowTabs.filter((tab) => trackedIds.has(tab.id) || state.groupId !== null && tab.groupId === state.groupId),
545
+ foreignTabs: windowTabs.filter((tab) => !trackedIds.has(tab.id) && (state.groupId === null || tab.groupId !== state.groupId))
546
+ };
547
+ }
548
+ async moveWorkspaceIntoDedicatedWindow(state, ownership, initialUrl) {
549
+ const sourceTabs = this.orderWorkspaceTabsForMigration(state, ownership.workspaceTabs);
550
+ const seedUrl = sourceTabs[0]?.url ?? initialUrl;
551
+ const window = await this.browser.createWindow({
552
+ url: seedUrl || DEFAULT_WORKSPACE_URL,
553
+ focused: false
554
+ });
555
+ const recreatedTabs = await this.waitForWindowTabs(window.id);
556
+ const firstTab = recreatedTabs[0] ?? null;
557
+ const tabIdMap = /* @__PURE__ */ new Map();
558
+ if (sourceTabs[0] && firstTab) {
559
+ tabIdMap.set(sourceTabs[0].id, firstTab.id);
560
+ }
561
+ for (const sourceTab of sourceTabs.slice(1)) {
562
+ const recreated = await this.createWorkspaceTab({
563
+ windowId: window.id,
564
+ url: sourceTab.url,
565
+ active: false
566
+ });
567
+ recreatedTabs.push(recreated);
568
+ tabIdMap.set(sourceTab.id, recreated.id);
569
+ }
570
+ const nextPrimaryTabId = (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : void 0) ?? firstTab?.id ?? recreatedTabs[0]?.id ?? null;
571
+ const nextActiveTabId = (state.activeTabId !== null ? tabIdMap.get(state.activeTabId) : void 0) ?? nextPrimaryTabId ?? recreatedTabs[0]?.id ?? null;
572
+ if (nextActiveTabId !== null) {
573
+ await this.browser.updateTab(nextActiveTabId, { active: true });
574
+ }
575
+ state.windowId = window.id;
576
+ state.groupId = null;
577
+ state.tabIds = recreatedTabs.map((tab) => tab.id);
578
+ state.primaryTabId = nextPrimaryTabId;
579
+ state.activeTabId = nextActiveTabId;
580
+ for (const workspaceTab of ownership.workspaceTabs) {
581
+ await this.browser.closeTab(workspaceTab.id);
582
+ }
583
+ return {
584
+ window,
585
+ tabs: await this.readTrackedTabs(state.tabIds, state.windowId)
586
+ };
587
+ }
588
+ orderWorkspaceTabsForMigration(state, tabs) {
589
+ const ordered = [];
590
+ const seen = /* @__PURE__ */ new Set();
591
+ const pushById = (tabId) => {
592
+ if (typeof tabId !== "number") {
593
+ return;
594
+ }
595
+ const tab = tabs.find((candidate) => candidate.id === tabId);
596
+ if (!tab || seen.has(tab.id)) {
597
+ return;
598
+ }
599
+ ordered.push(tab);
600
+ seen.add(tab.id);
601
+ };
602
+ pushById(state.primaryTabId);
603
+ pushById(state.activeTabId);
604
+ for (const tab of tabs) {
605
+ if (seen.has(tab.id)) {
606
+ continue;
607
+ }
608
+ ordered.push(tab);
609
+ seen.add(tab.id);
610
+ }
611
+ return ordered;
612
+ }
530
613
  async recoverWorkspaceTabs(state, existingTabs) {
531
614
  if (state.windowId === null) {
532
615
  return existingTabs;
@@ -965,13 +1048,14 @@
965
1048
  });
966
1049
  }
967
1050
  async function waitForTabUrl(tabId, expectedUrl, timeoutMs = 1e4) {
1051
+ const normalizedExpectedUrl = normalizeComparableTabUrl(expectedUrl);
968
1052
  const deadline = Date.now() + timeoutMs;
969
1053
  while (Date.now() < deadline) {
970
1054
  try {
971
1055
  const tab = await chrome.tabs.get(tabId);
972
1056
  const currentUrl = tab.url ?? "";
973
1057
  const pendingUrl = "pendingUrl" in tab && typeof tab.pendingUrl === "string" ? tab.pendingUrl : "";
974
- if (currentUrl === expectedUrl || pendingUrl === expectedUrl) {
1058
+ if (normalizeComparableTabUrl(currentUrl) === normalizedExpectedUrl || normalizeComparableTabUrl(pendingUrl) === normalizedExpectedUrl) {
975
1059
  return;
976
1060
  }
977
1061
  } catch {
@@ -980,6 +1064,19 @@
980
1064
  }
981
1065
  throw new Error(`tab url timeout: ${tabId} -> ${expectedUrl}`);
982
1066
  }
1067
+ function normalizeComparableTabUrl(url) {
1068
+ const raw = url.trim();
1069
+ if (!raw) {
1070
+ return raw;
1071
+ }
1072
+ try {
1073
+ const parsed = new URL(raw);
1074
+ parsed.hash = "";
1075
+ return parsed.href;
1076
+ } catch {
1077
+ return raw;
1078
+ }
1079
+ }
983
1080
  async function finalizeOpenedWorkspaceTab(opened, expectedUrl) {
984
1081
  if (expectedUrl && expectedUrl !== "about:blank") {
985
1082
  await waitForTabUrl(opened.tab.id, expectedUrl).catch(() => void 0);
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@flrande/bak-extension",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@flrande/bak-protocol": "0.3.5"
6
+ "@flrande/bak-protocol": "0.3.7"
7
7
  },
8
8
  "devDependencies": {
9
9
  "@types/chrome": "^0.1.14",
package/src/background.ts CHANGED
@@ -372,13 +372,17 @@ async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TI
372
372
  }
373
373
 
374
374
  async function waitForTabUrl(tabId: number, expectedUrl: string, timeoutMs = 10_000): Promise<void> {
375
+ const normalizedExpectedUrl = normalizeComparableTabUrl(expectedUrl);
375
376
  const deadline = Date.now() + timeoutMs;
376
377
  while (Date.now() < deadline) {
377
378
  try {
378
379
  const tab = await chrome.tabs.get(tabId);
379
380
  const currentUrl = tab.url ?? '';
380
381
  const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
381
- if (currentUrl === expectedUrl || pendingUrl === expectedUrl) {
382
+ if (
383
+ normalizeComparableTabUrl(currentUrl) === normalizedExpectedUrl ||
384
+ normalizeComparableTabUrl(pendingUrl) === normalizedExpectedUrl
385
+ ) {
382
386
  return;
383
387
  }
384
388
  } catch {
@@ -390,6 +394,20 @@ async function waitForTabUrl(tabId: number, expectedUrl: string, timeoutMs = 10_
390
394
  throw new Error(`tab url timeout: ${tabId} -> ${expectedUrl}`);
391
395
  }
392
396
 
397
+ function normalizeComparableTabUrl(url: string): string {
398
+ const raw = url.trim();
399
+ if (!raw) {
400
+ return raw;
401
+ }
402
+ try {
403
+ const parsed = new URL(raw);
404
+ parsed.hash = '';
405
+ return parsed.href;
406
+ } catch {
407
+ return raw;
408
+ }
409
+ }
410
+
393
411
  async function finalizeOpenedWorkspaceTab(
394
412
  opened: Awaited<ReturnType<WorkspaceManager['openTab']>>,
395
413
  expectedUrl?: string
package/src/workspace.ts CHANGED
@@ -79,6 +79,11 @@ export interface WorkspaceBrowser {
79
79
  updateGroup(groupId: number, options: { title?: string; color?: WorkspaceColor; collapsed?: boolean }): Promise<WorkspaceGroup>;
80
80
  }
81
81
 
82
+ interface WorkspaceWindowOwnership {
83
+ workspaceTabs: WorkspaceTab[];
84
+ foreignTabs: WorkspaceTab[];
85
+ }
86
+
82
87
  export interface WorkspaceEnsureOptions {
83
88
  workspaceId?: string;
84
89
  focus?: boolean;
@@ -165,6 +170,17 @@ export class WorkspaceManager {
165
170
  }
166
171
  state.tabIds = tabs.map((tab) => tab.id);
167
172
 
173
+ if (state.windowId !== null) {
174
+ const ownership = await this.inspectWorkspaceWindowOwnership(state, state.windowId);
175
+ if (ownership.foreignTabs.length > 0) {
176
+ const migrated = await this.moveWorkspaceIntoDedicatedWindow(state, ownership, initialUrl);
177
+ window = migrated.window;
178
+ tabs = migrated.tabs;
179
+ state.tabIds = tabs.map((tab) => tab.id);
180
+ repairActions.push('migrated-dirty-window');
181
+ }
182
+ }
183
+
168
184
  if (tabs.length === 0) {
169
185
  const primary = await this.createWorkspaceTab({
170
186
  windowId: state.windowId,
@@ -267,7 +283,10 @@ export class WorkspaceManager {
267
283
  const desiredUrl = options.url ?? DEFAULT_WORKSPACE_URL;
268
284
  let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
269
285
  state,
270
- ensured.created || ensured.repairActions.includes('recreated-window') || ensured.repairActions.includes('created-primary-tab')
286
+ ensured.created ||
287
+ ensured.repairActions.includes('recreated-window') ||
288
+ ensured.repairActions.includes('created-primary-tab') ||
289
+ ensured.repairActions.includes('migrated-dirty-window')
271
290
  );
272
291
 
273
292
  let createdTab: WorkspaceTab;
@@ -647,6 +666,97 @@ export class WorkspaceManager {
647
666
  return null;
648
667
  }
649
668
 
669
+ private async inspectWorkspaceWindowOwnership(state: WorkspaceRecord, windowId: number): Promise<WorkspaceWindowOwnership> {
670
+ const windowTabs = await this.waitForWindowTabs(windowId, 500);
671
+ const trackedIds = new Set(this.collectCandidateTabIds(state));
672
+ return {
673
+ workspaceTabs: windowTabs.filter((tab) => trackedIds.has(tab.id) || (state.groupId !== null && tab.groupId === state.groupId)),
674
+ foreignTabs: windowTabs.filter((tab) => !trackedIds.has(tab.id) && (state.groupId === null || tab.groupId !== state.groupId))
675
+ };
676
+ }
677
+
678
+ private async moveWorkspaceIntoDedicatedWindow(
679
+ state: WorkspaceRecord,
680
+ ownership: WorkspaceWindowOwnership,
681
+ initialUrl: string
682
+ ): Promise<{ window: WorkspaceWindow; tabs: WorkspaceTab[] }> {
683
+ const sourceTabs = this.orderWorkspaceTabsForMigration(state, ownership.workspaceTabs);
684
+ const seedUrl = sourceTabs[0]?.url ?? initialUrl;
685
+ const window = await this.browser.createWindow({
686
+ url: seedUrl || DEFAULT_WORKSPACE_URL,
687
+ focused: false
688
+ });
689
+ const recreatedTabs = await this.waitForWindowTabs(window.id);
690
+ const firstTab = recreatedTabs[0] ?? null;
691
+ const tabIdMap = new Map<number, number>();
692
+ if (sourceTabs[0] && firstTab) {
693
+ tabIdMap.set(sourceTabs[0].id, firstTab.id);
694
+ }
695
+
696
+ for (const sourceTab of sourceTabs.slice(1)) {
697
+ const recreated = await this.createWorkspaceTab({
698
+ windowId: window.id,
699
+ url: sourceTab.url,
700
+ active: false
701
+ });
702
+ recreatedTabs.push(recreated);
703
+ tabIdMap.set(sourceTab.id, recreated.id);
704
+ }
705
+
706
+ const nextPrimaryTabId =
707
+ (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : undefined) ??
708
+ firstTab?.id ??
709
+ recreatedTabs[0]?.id ??
710
+ null;
711
+ const nextActiveTabId =
712
+ (state.activeTabId !== null ? tabIdMap.get(state.activeTabId) : undefined) ?? nextPrimaryTabId ?? recreatedTabs[0]?.id ?? null;
713
+ if (nextActiveTabId !== null) {
714
+ await this.browser.updateTab(nextActiveTabId, { active: true });
715
+ }
716
+
717
+ state.windowId = window.id;
718
+ state.groupId = null;
719
+ state.tabIds = recreatedTabs.map((tab) => tab.id);
720
+ state.primaryTabId = nextPrimaryTabId;
721
+ state.activeTabId = nextActiveTabId;
722
+
723
+ for (const workspaceTab of ownership.workspaceTabs) {
724
+ await this.browser.closeTab(workspaceTab.id);
725
+ }
726
+
727
+ return {
728
+ window,
729
+ tabs: await this.readTrackedTabs(state.tabIds, state.windowId)
730
+ };
731
+ }
732
+
733
+ private orderWorkspaceTabsForMigration(state: WorkspaceRecord, tabs: WorkspaceTab[]): WorkspaceTab[] {
734
+ const ordered: WorkspaceTab[] = [];
735
+ const seen = new Set<number>();
736
+ const pushById = (tabId: number | null): void => {
737
+ if (typeof tabId !== 'number') {
738
+ return;
739
+ }
740
+ const tab = tabs.find((candidate) => candidate.id === tabId);
741
+ if (!tab || seen.has(tab.id)) {
742
+ return;
743
+ }
744
+ ordered.push(tab);
745
+ seen.add(tab.id);
746
+ };
747
+
748
+ pushById(state.primaryTabId);
749
+ pushById(state.activeTabId);
750
+ for (const tab of tabs) {
751
+ if (seen.has(tab.id)) {
752
+ continue;
753
+ }
754
+ ordered.push(tab);
755
+ seen.add(tab.id);
756
+ }
757
+ return ordered;
758
+ }
759
+
650
760
  private async recoverWorkspaceTabs(state: WorkspaceRecord, existingTabs: WorkspaceTab[]): Promise<WorkspaceTab[]> {
651
761
  if (state.windowId === null) {
652
762
  return existingTabs;