Repository navigation
Expand file tree
/
Copy pathbackground.js
More file actions
762 lines (718 loc) · 23.2 KB
/
Copy pathbackground.js
File metadata and controls
762 lines (718 loc) · 23.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
/* ===== CONSTANTS & GLOBALS ===== */
/**
* Debounce delay in milliseconds.
* @constant {number}
*/
const DEBOUNCE_DELAY = 800;
/** @type {Set<number>} Set of window IDs pending an update */
let pendingUpdates = new Set();
/** @type {number|null} Timeout identifier for debounced updates */
let updateTimer = null;
/** @type {Object<number, number>} Mapping of window IDs to last active timestamp */
let windowLastActive = {};
/* ===== STORAGE HELPERS ===== */
/**
* Retrieves workspaces and the next available ID from storage.
* @returns {Promise<{workspaces: Object, nextId: number}>} The stored workspaces and next available ID.
*/
async function getWorkspaces() {
try {
const data = await browser.storage.local.get(["workspaces", "nextId"]);
return {
workspaces: data.workspaces || {},
nextId: data.nextId || 1,
};
} catch (error) {
console.error("Error fetching workspaces from storage:", error);
return {workspaces: {}, nextId: 1};
}
}
/**
* Saves workspaces and nextId to storage.
* @param {Object} workspaces - The workspaces object.
* @param {number} nextId - The next available ID.
* @returns {Promise<void>}
*/
async function setWorkspaces(workspaces, nextId) {
try {
await browser.storage.local.set({workspaces, nextId});
} catch (error) {
console.error("Error saving workspaces to storage:", error);
}
}
/**
* Unsets the windowId for all workspaces associated with a closed window.
* This marks those workspaces as not currently open in any window.
* @param {number} closedWindowId - The ID of the window that was closed.
* @returns {Promise<void>}
*/
async function unsetWindowIdForClosedWorkspaces(closedWindowId) {
try {
const {workspaces, nextId} = await getWorkspaces();
let anyWorkspaceUpdated = false;
Object.keys(workspaces).forEach((workspaceId) => {
if (workspaces[workspaceId].windowId === closedWindowId) {
workspaces[workspaceId].windowId = null;
anyWorkspaceUpdated = true;
}
});
if (anyWorkspaceUpdated) {
await setWorkspaces(workspaces, nextId);
console.info(
"Unset windowId for workspace(s) associated with closed window",
closedWindowId,
);
}
} catch (error) {
console.error(
"Error unsetting windowId for closed window",
closedWindowId,
error,
);
}
}
/* ===== TAB GROUP HELPER ===== */
/**
* Queries tab groups for a window and returns an array of group range descriptors.
* @param {number} windowId - The window to query groups for.
* @param {Array<Object>} tabs - The tabs already queried for that window.
* @returns {Promise<Array<{start: number, end: number, title: string, color: string, collapsed: boolean}>>}
*/
async function queryGroupRanges(windowId, tabs) {
const groupRanges = [];
if (browser.tabGroups) {
try {
const groups = await browser.tabGroups.query({windowId});
for (const group of groups) {
const groupTabs = tabs.filter(
(tab) => tab.groupId === group.id,
);
if (groupTabs.length > 0) {
const indices = groupTabs.map((tab) => tab.index);
groupRanges.push({
start: Math.min(...indices),
end: Math.max(...indices),
title: group.title || "",
color: group.color || "",
collapsed: group.collapsed || false,
});
}
}
} catch (e) {
console.warn("Could not query tabGroups:", e);
}
}
return groupRanges;
}
/* ===== URL HELPER ===== */
/**
* Sanitizes an array of URL strings.
* Allows only 'http://', 'https://', or the exact value 'about:blank'.
* @param {Array<string>} urls - The array of URL strings.
* @returns {Array<string>} The sanitized URLs.
*/
function sanitizeUrls(urls) {
return urls.map((url) => {
// If the URL starts with an allowed scheme or is exactly 'about:blank', return it.
if (
url.startsWith("http://") ||
url.startsWith("https://") ||
url === "about:blank"
) {
return url;
}
// Otherwise, warn and replace with "about:blank".
console.warn("Blocked URL, replaced with about:blank:", url);
return "about:blank";
});
}
/* ===== DEBOUNCING LOGIC ===== */
/**
* Schedules a workspace update for the specified window using debouncing.
* Uses an early return if the windowId is invalid.
* @param {number} windowId - The window ID that requires an update.
*/
function scheduleWorkspaceUpdate(windowId) {
if (typeof windowId !== "number" || windowId < 0) return; // (Rule 2 & 7: Early return & validation)
pendingUpdates.add(windowId);
// Clear any existing timer to avoid scheduling duplicate updates.
if (updateTimer) {
clearTimeout(updateTimer);
}
updateTimer = setTimeout(processPendingUpdates, DEBOUNCE_DELAY);
}
/**
* Processes all pending workspace updates in a single batch.
* Retrieves the latest workspace data, updates each pending window with current tab info, and persists changes.
* @returns {Promise<void>}
*/
async function processPendingUpdates() {
const {workspaces, nextId} = await getWorkspaces();
for (const winId of pendingUpdates) {
try {
const tabs = await browser.tabs.query({windowId: winId});
await updateWorkspaceForWindow(workspaces, winId, tabs);
console.info("Updated workspace for window", winId);
} catch (error) {
console.error("Error updating workspace for window", winId, error);
}
}
await setWorkspaces(workspaces, nextId);
pendingUpdates.clear();
updateTimer = null;
}
/**
* Updates the workspace data for a given window with the current tab information.
* Only updates workspaces for the specified window.
* @param {Object} workspaces - An object containing workspace entries.
* @param {number} winId - The window ID to update.
* @param {Array<Object>} tabs - The array of tab objects.
*/
async function updateWorkspaceForWindow(workspaces, winId, tabs) {
if (!tabs.length) return; // (Rule 2: Use early return for empty arrays)
// Determine the active tab (or fall back to the last tab if none is active)
const activeTab = tabs.find((tab) => tab.active) || tabs[tabs.length - 1];
const groupRanges = await queryGroupRanges(winId, tabs);
Object.keys(workspaces).forEach((wsId) => {
if (workspaces[wsId].windowId === winId) {
workspaces[wsId].tabs = tabs.map((tab) => tab.url);
workspaces[wsId].groupRanges = groupRanges;
// Only update title if no custom title has been set.
if (!workspaces[wsId].customTitle) {
workspaces[wsId].title = activeTab ? activeTab.title : "";
}
}
});
}
/* ===== MESSAGE HANDLERS ===== */
/**
* Retrieves the current state—including saved and unsaved windows—and sends it in the response.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function handleGetState(sendResponse) {
try {
const {workspaces} = await getWorkspaces();
const openWindows = await browser.windows.getAll({populate: true});
const savedWorkspaces = Object.values(workspaces);
const savedWindowIds = savedWorkspaces
.map((ws) => ws.windowId)
.filter((id) => id !== null);
const unsavedWindows = openWindows
.filter((win) => !savedWindowIds.includes(win.id))
.map((win) => {
const activeTab =
win.tabs.find((tab) => tab.active) || win.tabs[0];
return {
windowId: win.id,
title:
activeTab && activeTab.title ?
activeTab.title
: "(No Tabs)",
lastActive: windowLastActive[win.id] || 0,
tabs: win.tabs || [],
};
});
// Sort unsaved windows by most recent activity.
unsavedWindows.sort((a, b) => b.lastActive - a.lastActive);
sendResponse({
success: true,
saved: savedWorkspaces,
unsaved: unsavedWindows,
});
} catch (error) {
console.error("Error retrieving state:", error);
sendResponse({success: false, error: error.message});
}
}
/**
* Saves the specified window as a new workspace and sends the response.
* @param {number} windowId - The ID of the window to save.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function handleSaveWindow(windowId, sendResponse) {
try {
const tabs = await browser.tabs.query({windowId});
if (!tabs || tabs.length === 0) {
return sendResponse({success: false, error: "Window has no tabs."});
}
const groupRanges = await queryGroupRanges(windowId, tabs);
const {workspaces, nextId} = await getWorkspaces();
const activeTab = tabs.find((tab) => tab.active) || tabs[0];
const newWorkspace = {
id: nextId,
windowId,
tabs: tabs.map((tab) => tab.url),
title: activeTab && activeTab.title ? activeTab.title : "",
groupRanges,
};
workspaces[nextId] = newWorkspace;
await setWorkspaces(workspaces, nextId + 1);
console.info(
`Saved window ${windowId} as workspace ${newWorkspace.id}`,
);
sendResponse({success: true, workspace: newWorkspace});
} catch (error) {
console.error("Error saving window", windowId, error);
sendResponse({success: false, error: error.message});
}
}
/**
* Opens a saved workspace by attempting to focus its existing window or creating a new one.
* @param {number} workspaceId - The workspace's unique identifier.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function handleOpenWorkspace(workspaceId, sendResponse) {
try {
const {workspaces, nextId} = await getWorkspaces();
const workspace = workspaces[workspaceId];
if (!workspace) {
return sendResponse({
success: false,
error: "Workspace not found.",
});
}
// Attempt to focus the window if it already exists.
if (workspace.windowId) {
try {
await browser.windows.update(workspace.windowId, {
focused: true,
});
console.info("Focused existing window", workspace.windowId);
return sendResponse({
success: true,
message: "Window focused.",
});
} catch (focusError) {
console.warn(
"Could not focus window, opening a new one instead:",
focusError,
);
}
}
// Sanitize URLs and open a new window.
const safeTabs = Array.isArray(workspace.tabs) ? workspace.tabs : [];
const sanitizedUrls = sanitizeUrls(safeTabs);
const newWindow = await browser.windows.create({url: sanitizedUrls});
workspace.windowId = newWindow.id;
workspaces[workspaceId] = workspace;
// Wait for all tabs to be ready
let tabs;
for (let i = 0; i < 10; ++i) {
tabs = await browser.tabs.query({windowId: newWindow.id});
if (tabs.length === sanitizedUrls.length) break;
await new Promise((res) => setTimeout(res, 200));
}
// Reapply tab groups
if (browser.tabGroups && Array.isArray(workspace.groupRanges)) {
for (const group of workspace.groupRanges) {
const groupTabs = tabs.filter(
(tab) => tab.index >= group.start && tab.index <= group.end,
);
if (groupTabs.length > 1) {
const tabIds = groupTabs.map((tab) => tab.id);
const groupId = await browser.tabs.group({tabIds});
await browser.tabGroups.update(groupId, {
title: group.title,
color: group.color,
collapsed: group.collapsed,
});
}
}
}
await new Promise((res) => setTimeout(res, 1000));
await setWindowTitlePrefaceForWorkspace(workspace, newWindow.id);
await setWorkspaces(workspaces, nextId);
console.info(
`Opened workspace ${workspaceId} in new window ${newWindow.id}`,
);
sendResponse({
success: true,
message: "New window opened.",
windowId: newWindow.id,
});
} catch (error) {
console.error("Error opening workspace", workspaceId, error);
sendResponse({success: false, error: error.message});
}
}
/**
* Removes the specified workspace from storage.
* @param {number} workspaceId - The workspace's ID to remove.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function handleUnsaveWorkspace(workspaceId, sendResponse) {
try {
const {workspaces, nextId} = await getWorkspaces();
if (!workspaces[workspaceId]) {
return sendResponse({
success: false,
error: "Workspace not found.",
});
}
delete workspaces[workspaceId];
await setWorkspaces(workspaces, nextId);
console.info("Removed workspace", workspaceId);
sendResponse({success: true});
} catch (error) {
console.error("Error removing workspace", workspaceId, error);
sendResponse({success: false, error: error.message});
}
}
/**
* Renames a workspace by setting a custom title and updates the window's title.
* @param {number} workspaceId - The workspace's ID.
* @param {string} newTitle - The new title for the workspace.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function handleRenameWorkspace(workspaceId, newTitle, sendResponse) {
try {
const {workspaces, nextId} = await getWorkspaces();
if (!workspaces[workspaceId]) {
return sendResponse({
success: false,
error: "Workspace not found.",
});
}
// Update the workspace title
workspaces[workspaceId].customTitle = newTitle;
workspaces[workspaceId].title = newTitle;
// If the workspace has an associated window, update the window's title
if (workspaces[workspaceId].windowId) {
await setWindowTitlePrefaceForWorkspace(
workspaces[workspaceId],
workspaces[workspaceId].windowId,
);
}
await setWorkspaces(workspaces, nextId);
console.info(`Renamed workspace ${workspaceId} to ${newTitle}`);
sendResponse({success: true});
} catch (error) {
console.error("Error renaming workspace", workspaceId, error);
sendResponse({success: false, error: error.message});
}
}
/**
* Updates the order of workspaces based on the supplied new order.
* @param {Array<number>} newOrder - An array of workspace IDs representing the desired order.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function handleUpdateOrder(newOrder, sendResponse) {
try {
const {workspaces, nextId} = await getWorkspaces();
// Update workspaces included in the new order.
newOrder.forEach((wsId, index) => {
if (workspaces[wsId]) {
workspaces[wsId].order = index;
}
});
// For any workspace not in newOrder, assign a sequential order.
let order = newOrder.length;
Object.keys(workspaces).forEach((wsId) => {
if (!newOrder.includes(Number(wsId))) {
workspaces[wsId].order = order++;
}
});
await setWorkspaces(workspaces, nextId);
console.info("Updated workspace order:", newOrder);
sendResponse({success: true});
} catch (error) {
console.error("Error updating workspace order:", error);
sendResponse({success: false, error: error.message});
}
}
/**
* Focuses the window with the specified ID.
* @param {number} windowId - The ID of the window to focus.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function focusWindow(windowId, sendResponse) {
try {
await browser.windows.update(windowId, {focused: true});
console.info("Focused window", windowId);
sendResponse({success: true});
} catch (error) {
console.error("Error focusing window", windowId, error);
sendResponse({success: false, error: error.message});
}
}
/**
* Exports the current workspaces and nextId.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function handleExportWorkspaces(sendResponse) {
try {
const {workspaces, nextId} = await getWorkspaces();
sendResponse({success: true, data: {workspaces, nextId}});
} catch (error) {
console.error("Error exporting workspaces:", error);
sendResponse({success: false, error: error.message});
}
}
/**
* Imports workspace data into storage.
* Validates the data structure before saving.
* @param {Object} msg - The message containing the import data.
* @param {Object} msg.data - The imported workspaces and nextId.
* @param {Function} sendResponse - Callback to send the response.
* @returns {Promise<void>}
*/
async function handleImportWorkspace(msg, sendResponse) {
try {
const importedData = msg.data;
if (
!importedData ||
typeof importedData !== "object" ||
!importedData.workspaces ||
typeof importedData.workspaces !== "object" ||
Array.isArray(importedData.workspaces) ||
typeof importedData.nextId !== "number" ||
importedData.nextId < 1
) {
return sendResponse({
success: false,
error: "Invalid import data.",
});
}
// Sanitize all URLs in imported workspaces
for (const wsId of Object.keys(importedData.workspaces)) {
const ws = importedData.workspaces[wsId];
if (ws && Array.isArray(ws.tabs)) {
ws.tabs = sanitizeUrls(ws.tabs);
}
}
await setWorkspaces(importedData.workspaces, importedData.nextId);
console.info("Imported workspaces successfully.");
sendResponse({success: true});
} catch (error) {
console.error("Error importing workspaces:", error);
sendResponse({success: false, error: error.message});
}
}
/* ===== MESSAGE ROUTER ===== */
/**
* Routes incoming messages to the corresponding handler.
*/
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
switch (msg.action) {
case "getState":
handleGetState(sendResponse);
break;
case "saveWindow":
handleSaveWindow(msg.windowId, sendResponse);
break;
case "openWorkspace":
handleOpenWorkspace(msg.workspaceId, sendResponse);
break;
case "focusWindow":
focusWindow(msg.windowId, sendResponse);
break;
case "unsaveWorkspace":
handleUnsaveWorkspace(msg.workspaceId, sendResponse);
break;
case "renameWorkspace":
handleRenameWorkspace(msg.workspaceId, msg.newTitle, sendResponse);
break;
case "updateOrder":
handleUpdateOrder(msg.newOrder, sendResponse);
break;
case "exportWorkspaces":
handleExportWorkspaces(sendResponse);
break;
case "importWorkspaces":
handleImportWorkspace(msg, sendResponse);
break;
default:
console.warn("Unknown action:", msg.action);
sendResponse({success: false, error: "Unknown action"});
}
// Return true to indicate asynchronous response handling.
return true;
});
/* ===== EVENT LISTENERS ===== */
/**
* Registers tab-related event listeners to schedule workspace updates.
*/
function registerTabListeners() {
browser.tabs.onCreated.addListener((tab) =>
scheduleWorkspaceUpdate(tab.windowId),
);
browser.tabs.onRemoved.addListener((tabId, info) =>
scheduleWorkspaceUpdate(info.windowId),
);
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.title) scheduleWorkspaceUpdate(tab.windowId);
});
browser.tabs.onMoved.addListener((tabId, info) =>
scheduleWorkspaceUpdate(info.windowId),
);
browser.tabs.onAttached.addListener((tabId, info) =>
scheduleWorkspaceUpdate(info.newWindowId),
);
browser.tabs.onDetached.addListener((tabId, info) =>
scheduleWorkspaceUpdate(info.oldWindowId),
);
browser.tabs.onActivated.addListener((info) =>
scheduleWorkspaceUpdate(info.windowId),
);
}
/**
* Registers window-related event listeners for creation, removal, and focus changes.
*/
function registerWindowListeners() {
browser.windows.onRemoved.addListener((windowId) => {
unsetWindowIdForClosedWorkspaces(windowId);
});
browser.windows.onFocusChanged.addListener((windowId) => {
if (windowId > 0) {
windowLastActive[windowId] = Date.now();
//console.info("Updated last active time for window", windowId);
}
});
}
/**
* Registers tabGroups event listeners to schedule workspace updates for all relevant tab group changes.
*/
function registerTabGroupListeners() {
if (!browser.tabGroups) return; // Defensive: not all browsers support tabGroups
browser.tabGroups.onCreated.addListener((group) => {
if (group.windowId != null) scheduleWorkspaceUpdate(group.windowId);
});
browser.tabGroups.onUpdated.addListener((group) => {
if (group.windowId != null) scheduleWorkspaceUpdate(group.windowId);
});
browser.tabGroups.onMoved.addListener((group) => {
if (group.windowId != null) scheduleWorkspaceUpdate(group.windowId);
});
browser.tabGroups.onRemoved.addListener((group) => {
if (group.windowId != null) scheduleWorkspaceUpdate(group.windowId);
});
}
// Register all event listeners.
registerTabListeners();
registerWindowListeners();
registerTabGroupListeners();
// ===== TAB MOVER CONTEXT MENU FEATURE =====
// Helper to create the main menu item
function createMainMenu(title = "Move tab to another Window") {
try {
browser.contextMenus.create({
id: "move-tabs",
title,
contexts: ["tab"],
});
} catch (e) {
// Ignore if already exists
}
}
// 1. Create the main context menu item on install/startup
browser.runtime.onInstalled.addListener(() => {
createMainMenu();
});
browser.runtime.onStartup.addListener(() => {
createMainMenu();
});
// Track last submenu window IDs for click handling
let lastSubmenuWindows = [];
// 2. Dynamically update the menu label and submenus based on selection count and open windows
browser.contextMenus.onShown.addListener(async (info, tab) => {
// Remove all menu items to rebuild cleanly
await browser.contextMenus.removeAll().catch(() => {});
// Determine which tabs are selected
let tabs = [];
if (tab && tab.windowId) {
tabs = await browser.tabs.query({
windowId: tab.windowId,
highlighted: true,
});
// If the clicked tab is not highlighted, fallback to just that tab
if (!tabs.some((t) => t.id === tab.id)) {
tabs = [tab];
}
}
const count = tabs.length;
const title =
count > 1 ?
`Move ${count} Tabs to another Window`
: "Move Tab to another Window";
createMainMenu(title);
// Get all other windows and build submenus
let windows = await browser.windows.getAll({populate: true});
lastSubmenuWindows = [];
for (let w of windows) {
if (!tab || w.id === tab.windowId) continue;
// Get window title from its active tab
let winTitle = "(No Title)";
if (w.tabs && w.tabs.length > 0) {
const activeTab = w.tabs.find((t) => t.active) || w.tabs[0];
winTitle =
activeTab && activeTab.title ? activeTab.title : "(No Title)";
}
// Count tabs in this window
const tabCount = w.tabs ? w.tabs.length : 0;
const label = `${winTitle} (${tabCount} Tab${tabCount === 1 ? "" : "s"})`;
browser.contextMenus.create({
id: `move-to-${w.id}`,
parentId: "move-tabs",
title: label,
contexts: ["tab"],
});
lastSubmenuWindows.push(w.id);
}
browser.contextMenus.refresh();
});
// 3. Handle menu clicks: move tabs if submenu clicked
browser.contextMenus.onClicked.addListener(async (info, tab) => {
if (!tab || !tab.windowId) return;
if (info.menuItemId && info.menuItemId.startsWith("move-to-")) {
const destWindowId = parseInt(info.menuItemId.split("move-to-")[1], 10);
if (!Number.isInteger(destWindowId)) return;
// Find all highlighted tabs in the source window, or fallback to clicked tab
let tabsToMove = await browser.tabs.query({
windowId: tab.windowId,
highlighted: true,
});
if (!tabsToMove.some((t) => t.id === tab.id)) {
tabsToMove = [tab];
}
const tabIds = tabsToMove.map((t) => t.id);
if (tabIds.length > 0) {
try {
await browser.tabs.move(tabIds, {
windowId: destWindowId,
index: -1,
});
} catch (e) {
console.error("Failed to move tabs:", e);
}
}
}
});
// Utility: Set window titlePreface if customTitle exists
async function setWindowTitlePrefaceForWorkspace(workspace, windowId) {
if (
workspace &&
workspace.customTitle &&
workspace.customTitle.trim() !== ""
) {
const titlePreface = `${workspace.customTitle} - `;
try {
await browser.windows.update(windowId, {titlePreface});
console.info(
`Set window title for workspace ${workspace.id} to "${titlePreface}"`,
);
} catch (e) {
console.warn(
`Failed to set window title for workspace ${workspace.id}:`,
e,
);
}
}
}