tabctl 0.3.0 → 0.4.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/README.md +7 -5
- package/dist/cli/lib/client.js +23 -2
- package/dist/cli/lib/commands/index.js +2 -1
- package/dist/cli/lib/commands/meta.js +16 -13
- package/dist/cli/lib/commands/params-groups.js +8 -0
- package/dist/cli/lib/commands/params.js +6 -3
- package/dist/cli/lib/commands/setup.js +104 -126
- package/dist/cli/lib/options-commands.js +13 -1
- package/dist/cli/lib/output.js +36 -1
- package/dist/cli/lib/policy-filter.js +1 -1
- package/dist/cli/lib/policy.js +3 -3
- package/dist/cli/lib/response.js +9 -9
- package/dist/cli/tabctl.js +9 -1
- package/dist/extension/background.js +429 -43
- package/dist/extension/lib/groups.js +89 -3
- package/dist/extension/lib/screenshot.js +2 -2
- package/dist/extension/lib/tabs.js +97 -36
- package/dist/extension/lib/undo-handlers.js +8 -0
- package/dist/extension/manifest.json +2 -2
- package/dist/host/host.bundle.js +669 -0
- package/dist/host/host.js +30 -11
- package/dist/host/launcher/go.mod +3 -0
- package/dist/host/launcher/main.go +109 -0
- package/dist/host/lib/handlers.js +7 -5
- package/dist/host/lib/undo.js +6 -6
- package/dist/shared/config.js +36 -13
- package/dist/shared/extension-sync.js +59 -15
- package/dist/shared/profiles.js +5 -5
- package/dist/shared/version.js +2 -2
- package/package.json +11 -4
|
@@ -11,10 +11,15 @@ exports.listGroups = listGroups;
|
|
|
11
11
|
exports.groupUpdate = groupUpdate;
|
|
12
12
|
exports.groupUngroup = groupUngroup;
|
|
13
13
|
exports.groupAssign = groupAssign;
|
|
14
|
+
exports.groupGather = groupGather;
|
|
14
15
|
function getGroupTabs(windowSnapshot, groupId) {
|
|
15
16
|
return windowSnapshot.tabs
|
|
16
17
|
.filter((tab) => tab.groupId === groupId)
|
|
17
|
-
.sort((a, b) =>
|
|
18
|
+
.sort((a, b) => {
|
|
19
|
+
const ai = Number(a.index);
|
|
20
|
+
const bi = Number(b.index);
|
|
21
|
+
return (Number.isFinite(ai) ? ai : 0) - (Number.isFinite(bi) ? bi : 0);
|
|
22
|
+
});
|
|
18
23
|
}
|
|
19
24
|
function listGroupSummaries(snapshot, buildWindowLabels, windowId) {
|
|
20
25
|
const windowLabels = buildWindowLabels(snapshot);
|
|
@@ -83,8 +88,8 @@ function resolveGroupByTitle(snapshot, buildWindowLabels, groupTitle, windowId)
|
|
|
83
88
|
if (matches.length > 1) {
|
|
84
89
|
return {
|
|
85
90
|
error: {
|
|
86
|
-
message:
|
|
87
|
-
hint: "Use --window to
|
|
91
|
+
message: `Ambiguous group title: found ${matches.length} groups named "${groupTitle}". Use group-gather to merge duplicates, --group-id to target by ID, or --window to narrow scope.`,
|
|
92
|
+
hint: "Use group-gather to merge duplicates, --group-id to target by ID, or --window to narrow scope.",
|
|
88
93
|
matches: matches.map((match) => summarizeGroupMatch(match, windowLabels)),
|
|
89
94
|
availableGroups,
|
|
90
95
|
},
|
|
@@ -441,3 +446,84 @@ async function groupAssign(params, deps) {
|
|
|
441
446
|
txid: params.txid || null,
|
|
442
447
|
};
|
|
443
448
|
}
|
|
449
|
+
async function groupGather(params, deps) {
|
|
450
|
+
const snapshot = await deps.getTabSnapshot();
|
|
451
|
+
const windows = snapshot.windows;
|
|
452
|
+
const windowIdParam = params.windowId != null ? deps.resolveWindowIdFromParams(snapshot, params.windowId) : null;
|
|
453
|
+
if (windowIdParam && !windows.some((win) => win.windowId === windowIdParam)) {
|
|
454
|
+
throw new Error("Window not found");
|
|
455
|
+
}
|
|
456
|
+
const groupTitleFilter = typeof params.groupTitle === "string" ? params.groupTitle.trim() : "";
|
|
457
|
+
const merged = [];
|
|
458
|
+
const undoEntries = [];
|
|
459
|
+
for (const win of windows) {
|
|
460
|
+
if (windowIdParam && win.windowId !== windowIdParam)
|
|
461
|
+
continue;
|
|
462
|
+
const byTitle = new Map();
|
|
463
|
+
for (const group of win.groups) {
|
|
464
|
+
const title = typeof group.title === "string" ? group.title : "";
|
|
465
|
+
if (!title)
|
|
466
|
+
continue;
|
|
467
|
+
if (groupTitleFilter && title !== groupTitleFilter)
|
|
468
|
+
continue;
|
|
469
|
+
if (!byTitle.has(title))
|
|
470
|
+
byTitle.set(title, []);
|
|
471
|
+
byTitle.get(title).push(group);
|
|
472
|
+
}
|
|
473
|
+
for (const [title, titleGroups] of byTitle) {
|
|
474
|
+
if (titleGroups.length < 2)
|
|
475
|
+
continue;
|
|
476
|
+
const groupsWithIndex = titleGroups.map((g) => {
|
|
477
|
+
const tabs = win.tabs.filter((t) => t.groupId === g.groupId);
|
|
478
|
+
const minIndex = Math.min(...tabs.map((t) => {
|
|
479
|
+
const idx = Number(t.index);
|
|
480
|
+
return Number.isFinite(idx) ? idx : Infinity;
|
|
481
|
+
}));
|
|
482
|
+
return { group: g, tabs, minIndex };
|
|
483
|
+
});
|
|
484
|
+
groupsWithIndex.sort((a, b) => a.minIndex - b.minIndex);
|
|
485
|
+
const primary = groupsWithIndex[0];
|
|
486
|
+
const duplicates = groupsWithIndex.slice(1);
|
|
487
|
+
let movedTabs = 0;
|
|
488
|
+
for (const dup of duplicates) {
|
|
489
|
+
const tabIds = dup.tabs
|
|
490
|
+
.map((t) => t.tabId)
|
|
491
|
+
.filter((id) => typeof id === "number");
|
|
492
|
+
if (tabIds.length > 0) {
|
|
493
|
+
for (const tab of dup.tabs) {
|
|
494
|
+
undoEntries.push({
|
|
495
|
+
tabId: tab.tabId,
|
|
496
|
+
windowId: win.windowId,
|
|
497
|
+
index: tab.index,
|
|
498
|
+
groupId: tab.groupId,
|
|
499
|
+
groupTitle: tab.groupTitle,
|
|
500
|
+
groupColor: tab.groupColor,
|
|
501
|
+
groupCollapsed: dup.group.collapsed ?? null,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
await chrome.tabs.group({ groupId: primary.group.groupId, tabIds });
|
|
505
|
+
movedTabs += tabIds.length;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
merged.push({
|
|
509
|
+
windowId: win.windowId,
|
|
510
|
+
groupTitle: title,
|
|
511
|
+
primaryGroupId: primary.group.groupId,
|
|
512
|
+
mergedGroupCount: duplicates.length,
|
|
513
|
+
movedTabs,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
merged,
|
|
519
|
+
summary: {
|
|
520
|
+
mergedGroups: merged.reduce((sum, m) => sum + m.mergedGroupCount, 0),
|
|
521
|
+
movedTabs: merged.reduce((sum, m) => sum + m.movedTabs, 0),
|
|
522
|
+
},
|
|
523
|
+
undo: {
|
|
524
|
+
action: "group-gather",
|
|
525
|
+
tabs: undoEntries,
|
|
526
|
+
},
|
|
527
|
+
txid: params.txid || null,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
@@ -15,7 +15,7 @@ exports.scrollToPosition = scrollToPosition;
|
|
|
15
15
|
exports.captureTabTiles = captureTabTiles;
|
|
16
16
|
exports.screenshotTabs = screenshotTabs;
|
|
17
17
|
exports.SCREENSHOT_TILE_MAX_DIM = 2000;
|
|
18
|
-
exports.SCREENSHOT_MAX_BYTES =
|
|
18
|
+
exports.SCREENSHOT_MAX_BYTES = 2_000_000;
|
|
19
19
|
exports.SCREENSHOT_QUALITY = 80;
|
|
20
20
|
exports.SCREENSHOT_SCROLL_DELAY_MS = 150;
|
|
21
21
|
exports.SCREENSHOT_CAPTURE_DELAY_MS = 350;
|
|
@@ -288,7 +288,7 @@ async function screenshotTabs(params, requestId, deps) {
|
|
|
288
288
|
const adjustedTileMaxDim = tileMaxDim < 50 ? 50 : tileMaxDim;
|
|
289
289
|
const maxBytesRaw = Number(params.maxBytes);
|
|
290
290
|
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? Math.floor(maxBytesRaw) : exports.SCREENSHOT_MAX_BYTES;
|
|
291
|
-
const adjustedMaxBytes = maxBytes <
|
|
291
|
+
const adjustedMaxBytes = maxBytes < 50_000 ? 50_000 : maxBytes;
|
|
292
292
|
const progressEnabled = params.progress === true;
|
|
293
293
|
const tabs = selection.tabs;
|
|
294
294
|
const entries = [];
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// Tab operations — extracted from background.ts (pure structural refactor).
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
3
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
7
|
exports.getMostRecentFocusedWindowId = getMostRecentFocusedWindowId;
|
|
5
8
|
exports.normalizeUrl = normalizeUrl;
|
|
@@ -8,6 +11,7 @@ exports.resolveOpenWindow = resolveOpenWindow;
|
|
|
8
11
|
exports.focusTab = focusTab;
|
|
9
12
|
exports.refreshTabs = refreshTabs;
|
|
10
13
|
exports.openTabs = openTabs;
|
|
14
|
+
const normalize_url_1 = __importDefault(require("normalize-url"));
|
|
11
15
|
function getMostRecentFocusedWindowId(windows) {
|
|
12
16
|
let bestWindowId = null;
|
|
13
17
|
let bestFocusedAt = -Infinity;
|
|
@@ -29,42 +33,26 @@ function normalizeUrl(rawUrl) {
|
|
|
29
33
|
if (!rawUrl || typeof rawUrl !== "string") {
|
|
30
34
|
return null;
|
|
31
35
|
}
|
|
32
|
-
let url;
|
|
33
36
|
try {
|
|
34
|
-
|
|
37
|
+
return (0, normalize_url_1.default)(rawUrl, {
|
|
38
|
+
stripHash: true,
|
|
39
|
+
removeQueryParameters: [
|
|
40
|
+
/^utm_\w+$/i,
|
|
41
|
+
"fbclid",
|
|
42
|
+
"gclid",
|
|
43
|
+
"igshid",
|
|
44
|
+
"mc_cid",
|
|
45
|
+
"mc_eid",
|
|
46
|
+
"ref",
|
|
47
|
+
"ref_src",
|
|
48
|
+
"ref_url",
|
|
49
|
+
"si",
|
|
50
|
+
],
|
|
51
|
+
});
|
|
35
52
|
}
|
|
36
53
|
catch {
|
|
37
54
|
return null;
|
|
38
55
|
}
|
|
39
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
url.hash = "";
|
|
43
|
-
const dropKeys = new Set([
|
|
44
|
-
"fbclid",
|
|
45
|
-
"gclid",
|
|
46
|
-
"igshid",
|
|
47
|
-
"mc_cid",
|
|
48
|
-
"mc_eid",
|
|
49
|
-
"ref",
|
|
50
|
-
"ref_src",
|
|
51
|
-
"ref_url",
|
|
52
|
-
"utm_campaign",
|
|
53
|
-
"utm_content",
|
|
54
|
-
"utm_medium",
|
|
55
|
-
"utm_source",
|
|
56
|
-
"utm_term",
|
|
57
|
-
"utm_name",
|
|
58
|
-
"si",
|
|
59
|
-
]);
|
|
60
|
-
for (const key of Array.from(url.searchParams.keys())) {
|
|
61
|
-
if (key.startsWith("utm_") || dropKeys.has(key)) {
|
|
62
|
-
url.searchParams.delete(key);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
const search = url.searchParams.toString();
|
|
66
|
-
url.search = search ? `?${search}` : "";
|
|
67
|
-
return url.toString();
|
|
68
56
|
}
|
|
69
57
|
function normalizeTabIndex(value) {
|
|
70
58
|
const index = Number(value);
|
|
@@ -200,6 +188,8 @@ async function openTabs(params, deps) {
|
|
|
200
188
|
throw new Error("Only one target position is allowed");
|
|
201
189
|
}
|
|
202
190
|
const newWindow = params.newWindow === true;
|
|
191
|
+
const forceNewGroup = params.newGroup === true;
|
|
192
|
+
const allowDuplicates = params.allowDuplicates === true;
|
|
203
193
|
if (!urls.length && !newWindow) {
|
|
204
194
|
throw new Error("No URLs provided");
|
|
205
195
|
}
|
|
@@ -287,6 +277,13 @@ async function openTabs(params, deps) {
|
|
|
287
277
|
}
|
|
288
278
|
const snapshot = await deps.getTabSnapshot();
|
|
289
279
|
let openParams = params;
|
|
280
|
+
// Auto-resolve window by group name when no explicit window selector is provided
|
|
281
|
+
if (groupTitle && !forceNewGroup && openParams.windowId == null && !openParams.windowGroupTitle && !openParams.windowTabId && !openParams.windowUrl) {
|
|
282
|
+
const groupWindows = snapshot.windows.filter((win) => win.groups.some((g) => g.title === groupTitle));
|
|
283
|
+
if (groupWindows.length === 1) {
|
|
284
|
+
openParams = { ...openParams, windowId: groupWindows[0].windowId };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
290
287
|
if (params.windowId == null && (beforeTabId != null || afterTabId != null)) {
|
|
291
288
|
const anchorId = beforeTabId != null ? beforeTabId : afterTabId;
|
|
292
289
|
const anchorWindow = snapshot.windows
|
|
@@ -304,6 +301,25 @@ async function openTabs(params, deps) {
|
|
|
304
301
|
if (!windowSnapshot) {
|
|
305
302
|
throw new Error("Window snapshot unavailable");
|
|
306
303
|
}
|
|
304
|
+
// Resolve existing group for reuse
|
|
305
|
+
let existingGroupId = null;
|
|
306
|
+
const existingUrlSet = new Set();
|
|
307
|
+
if (groupTitle && !forceNewGroup) {
|
|
308
|
+
const matchingGroups = windowSnapshot.groups.filter((g) => g.title === groupTitle);
|
|
309
|
+
if (matchingGroups.length > 1) {
|
|
310
|
+
throw new Error(`Ambiguous group title "${groupTitle}": found ${matchingGroups.length} groups with the same name. Use --new-group to force a new group, group-gather to merge, or --group-id to target by ID.`);
|
|
311
|
+
}
|
|
312
|
+
if (matchingGroups.length === 1) {
|
|
313
|
+
existingGroupId = matchingGroups[0].groupId;
|
|
314
|
+
const existingTabs = windowSnapshot.tabs.filter((tab) => tab.groupId === existingGroupId);
|
|
315
|
+
for (const tab of existingTabs) {
|
|
316
|
+
const norm = normalizeUrl(tab.url);
|
|
317
|
+
if (norm) {
|
|
318
|
+
existingUrlSet.add(norm);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
307
323
|
const created = [];
|
|
308
324
|
const skipped = [];
|
|
309
325
|
let insertIndex = null;
|
|
@@ -339,8 +355,25 @@ async function openTabs(params, deps) {
|
|
|
339
355
|
}
|
|
340
356
|
insertIndex = beforeTabId != null ? anchorIndex : anchorIndex + 1;
|
|
341
357
|
}
|
|
358
|
+
// Default insert position: append after existing group tabs
|
|
359
|
+
if (existingGroupId != null && insertIndex == null && beforeTabId == null && afterTabId == null) {
|
|
360
|
+
const groupTabs = windowSnapshot.tabs.filter((tab) => tab.groupId === existingGroupId);
|
|
361
|
+
const indices = groupTabs
|
|
362
|
+
.map((tab) => normalizeTabIndex(tab.index))
|
|
363
|
+
.filter((value) => value != null);
|
|
364
|
+
if (indices.length) {
|
|
365
|
+
insertIndex = Math.max(...indices) + 1;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
342
368
|
let nextIndex = insertIndex;
|
|
343
369
|
for (const url of urls) {
|
|
370
|
+
if (!allowDuplicates && existingGroupId != null) {
|
|
371
|
+
const norm = normalizeUrl(url);
|
|
372
|
+
if (norm && existingUrlSet.has(norm)) {
|
|
373
|
+
skipped.push({ url, reason: "duplicate" });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
344
377
|
try {
|
|
345
378
|
const createOptions = { windowId, url, active: false };
|
|
346
379
|
if (nextIndex != null) {
|
|
@@ -365,12 +398,18 @@ async function openTabs(params, deps) {
|
|
|
365
398
|
try {
|
|
366
399
|
const tabIds = created.map((tab) => tab.tabId).filter((id) => typeof id === "number");
|
|
367
400
|
if (tabIds.length > 0) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
401
|
+
if (existingGroupId != null) {
|
|
402
|
+
// Reuse existing group
|
|
403
|
+
groupId = await chrome.tabs.group({ groupId: existingGroupId, tabIds });
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId } });
|
|
407
|
+
const update = { title: groupTitle };
|
|
408
|
+
if (groupColor) {
|
|
409
|
+
update.color = groupColor;
|
|
410
|
+
}
|
|
411
|
+
await chrome.tabGroups.update(groupId, update);
|
|
372
412
|
}
|
|
373
|
-
await chrome.tabGroups.update(groupId, update);
|
|
374
413
|
}
|
|
375
414
|
}
|
|
376
415
|
catch (error) {
|
|
@@ -378,6 +417,28 @@ async function openTabs(params, deps) {
|
|
|
378
417
|
groupId = null;
|
|
379
418
|
}
|
|
380
419
|
}
|
|
420
|
+
// All-dupes case: report existing group even when no new tabs were created
|
|
421
|
+
if (groupTitle && created.length === 0 && existingGroupId != null) {
|
|
422
|
+
groupId = existingGroupId;
|
|
423
|
+
}
|
|
424
|
+
// Reorder: groups before ungrouped tabs
|
|
425
|
+
try {
|
|
426
|
+
const freshTabs = await chrome.tabs.query({ windowId });
|
|
427
|
+
freshTabs.sort((a, b) => a.index - b.index);
|
|
428
|
+
const firstUngroupedIndex = freshTabs.findIndex(t => (t.groupId ?? -1) === -1);
|
|
429
|
+
if (firstUngroupedIndex >= 0) {
|
|
430
|
+
const groupedAfterUngrouped = freshTabs.filter((t, i) => i > firstUngroupedIndex && (t.groupId ?? -1) !== -1);
|
|
431
|
+
if (groupedAfterUngrouped.length > 0) {
|
|
432
|
+
const tabIdsToMove = groupedAfterUngrouped.map(t => t.id).filter(id => typeof id === "number");
|
|
433
|
+
if (tabIdsToMove.length > 0) {
|
|
434
|
+
await chrome.tabs.move(tabIdsToMove, { index: firstUngroupedIndex });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
deps.log("Failed to reorder groups before ungrouped tabs", err);
|
|
441
|
+
}
|
|
381
442
|
return {
|
|
382
443
|
windowId,
|
|
383
444
|
groupId,
|
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.undoGroupUpdate = undoGroupUpdate;
|
|
5
5
|
exports.undoGroupUngroup = undoGroupUngroup;
|
|
6
6
|
exports.undoGroupAssign = undoGroupAssign;
|
|
7
|
+
exports.undoGroupGather = undoGroupGather;
|
|
7
8
|
exports.undoMoveTab = undoMoveTab;
|
|
8
9
|
exports.undoMoveGroup = undoMoveGroup;
|
|
9
10
|
exports.undoMergeWindow = undoMergeWindow;
|
|
@@ -197,6 +198,10 @@ async function undoGroupAssign(undo, deps) {
|
|
|
197
198
|
const tabs = undo.tabs || [];
|
|
198
199
|
return await restoreTabsFromUndo(tabs, deps);
|
|
199
200
|
}
|
|
201
|
+
async function undoGroupGather(undo, deps) {
|
|
202
|
+
const tabs = undo.tabs || [];
|
|
203
|
+
return await restoreTabsFromUndo(tabs, deps);
|
|
204
|
+
}
|
|
200
205
|
async function undoMoveTab(undo, deps) {
|
|
201
206
|
const from = undo.from || {};
|
|
202
207
|
const entry = {
|
|
@@ -426,6 +431,9 @@ async function undoTransaction(params, deps) {
|
|
|
426
431
|
if (undo.action === "group-assign") {
|
|
427
432
|
return await undoGroupAssign(undo, deps);
|
|
428
433
|
}
|
|
434
|
+
if (undo.action === "group-gather") {
|
|
435
|
+
return await undoGroupGather(undo, deps);
|
|
436
|
+
}
|
|
429
437
|
if (undo.action === "move-tab") {
|
|
430
438
|
return await undoMoveTab(undo, deps);
|
|
431
439
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Tab Control",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "Archive and manage browser tabs with CLI support",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
@@ -19,5 +19,5 @@
|
|
|
19
19
|
"background": {
|
|
20
20
|
"service_worker": "background.js"
|
|
21
21
|
},
|
|
22
|
-
"version_name": "0.
|
|
22
|
+
"version_name": "0.4.0"
|
|
23
23
|
}
|