tabctl 0.1.4 → 0.2.1

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.
Files changed (48) hide show
  1. package/{cli → dist/cli}/lib/commands/index.js +4 -2
  2. package/dist/cli/lib/commands/meta.js +226 -0
  3. package/dist/cli/lib/commands/params-groups.js +40 -0
  4. package/dist/cli/lib/commands/params-move.js +44 -0
  5. package/{cli → dist/cli}/lib/commands/params.js +61 -125
  6. package/{cli/lib/commands/meta.js → dist/cli/lib/commands/setup.js} +26 -222
  7. package/{cli/lib/options.js → dist/cli/lib/options-commands.js} +3 -155
  8. package/dist/cli/lib/options-groups.js +41 -0
  9. package/dist/cli/lib/options.js +125 -0
  10. package/{cli → dist/cli}/lib/output.js +5 -4
  11. package/dist/cli/lib/policy-filter.js +202 -0
  12. package/dist/cli/lib/response.js +235 -0
  13. package/{cli → dist/cli}/lib/scope.js +3 -31
  14. package/dist/cli/tabctl.js +463 -0
  15. package/dist/extension/background.js +3398 -0
  16. package/dist/extension/lib/archive.js +444 -0
  17. package/dist/extension/lib/content.js +320 -0
  18. package/dist/extension/lib/deps.js +4 -0
  19. package/dist/extension/lib/groups.js +443 -0
  20. package/dist/extension/lib/inspect.js +316 -0
  21. package/dist/extension/lib/move.js +342 -0
  22. package/dist/extension/lib/screenshot.js +367 -0
  23. package/dist/extension/lib/tabs.js +395 -0
  24. package/dist/extension/lib/undo-handlers.js +439 -0
  25. package/{extension → dist/extension}/manifest.json +2 -2
  26. package/dist/host/host.js +124 -0
  27. package/{host/host.js → dist/host/lib/handlers.js} +84 -187
  28. package/{shared → dist/shared}/version.js +2 -2
  29. package/package.json +12 -10
  30. package/cli/tabctl.js +0 -841
  31. package/extension/background.js +0 -3372
  32. package/extension/manifest.template.json +0 -22
  33. /package/{cli → dist/cli}/lib/args.js +0 -0
  34. /package/{cli → dist/cli}/lib/client.js +0 -0
  35. /package/{cli → dist/cli}/lib/commands/list.js +0 -0
  36. /package/{cli → dist/cli}/lib/commands/profile.js +0 -0
  37. /package/{cli → dist/cli}/lib/constants.js +0 -0
  38. /package/{cli → dist/cli}/lib/help.js +0 -0
  39. /package/{cli → dist/cli}/lib/pagination.js +0 -0
  40. /package/{cli → dist/cli}/lib/policy.js +0 -0
  41. /package/{cli → dist/cli}/lib/report.js +0 -0
  42. /package/{cli → dist/cli}/lib/snapshot.js +0 -0
  43. /package/{cli → dist/cli}/lib/types.js +0 -0
  44. /package/{host → dist/host}/host.sh +0 -0
  45. /package/{host → dist/host}/lib/undo.js +0 -0
  46. /package/{shared → dist/shared}/config.js +0 -0
  47. /package/{shared → dist/shared}/extension-sync.js +0 -0
  48. /package/{shared → dist/shared}/profiles.js +0 -0
@@ -0,0 +1,316 @@
1
+ "use strict";
2
+ // Analysis/inspection pipeline — extracted from tabs.ts (pure structural refactor).
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.SELECTOR_VALUE_MAX_LENGTH = exports.DESCRIPTION_MAX_LENGTH = exports.DEFAULT_STALE_DAYS = void 0;
5
+ exports.analyzeTabs = analyzeTabs;
6
+ exports.inspectTabs = inspectTabs;
7
+ const content = require("./content");
8
+ const { isScriptableUrl, isGitHubIssueOrPr, detectGitHubState, extractPageMeta, extractSelectorSignal, waitForSettle, waitForTabReady } = content;
9
+ const tabs = require("./tabs");
10
+ const { normalizeUrl } = tabs;
11
+ exports.DEFAULT_STALE_DAYS = 30;
12
+ exports.DESCRIPTION_MAX_LENGTH = 250;
13
+ exports.SELECTOR_VALUE_MAX_LENGTH = 500;
14
+ async function analyzeTabs(params, requestId, deps) {
15
+ const staleDays = Number.isFinite(params.staleDays) ? params.staleDays : exports.DEFAULT_STALE_DAYS;
16
+ const checkGitHub = params.checkGitHub === true;
17
+ const githubConcurrencyRaw = Number(params.githubConcurrency);
18
+ const githubConcurrency = Number.isFinite(githubConcurrencyRaw) && githubConcurrencyRaw > 0
19
+ ? Math.min(10, Math.floor(githubConcurrencyRaw))
20
+ : 4;
21
+ const githubTimeoutRaw = Number(params.githubTimeoutMs);
22
+ const githubTimeoutMs = Number.isFinite(githubTimeoutRaw) && githubTimeoutRaw > 0
23
+ ? Math.floor(githubTimeoutRaw)
24
+ : 4000;
25
+ const progressEnabled = params.progress === true;
26
+ const snapshot = await deps.getTabSnapshot();
27
+ const selection = deps.selectTabsByScope(snapshot, params);
28
+ if (selection.error) {
29
+ throw selection.error;
30
+ }
31
+ const selectedTabs = selection.tabs;
32
+ const scopeTabs = selectedTabs;
33
+ const now = Date.now();
34
+ const startedAt = Date.now();
35
+ let githubChecked = 0;
36
+ let githubTotal = 0;
37
+ let githubMatched = 0;
38
+ const normalizedMap = new Map();
39
+ const duplicates = new Map();
40
+ for (const tab of scopeTabs) {
41
+ const normalized = normalizeUrl(tab.url);
42
+ if (!normalized) {
43
+ continue;
44
+ }
45
+ if (normalizedMap.has(normalized)) {
46
+ const existing = normalizedMap.get(normalized);
47
+ duplicates.set(tab.tabId, existing.tabId);
48
+ }
49
+ else {
50
+ normalizedMap.set(normalized, tab);
51
+ }
52
+ }
53
+ const candidateMap = new Map();
54
+ const addReason = (tab, reason) => {
55
+ const tabId = tab.tabId;
56
+ const entry = candidateMap.get(tabId) || { tab, reasons: [] };
57
+ entry.reasons.push(reason);
58
+ candidateMap.set(tabId, entry);
59
+ };
60
+ for (const tab of selectedTabs) {
61
+ if (duplicates.has(tab.tabId)) {
62
+ addReason(tab, {
63
+ type: "duplicate",
64
+ detail: `Matches tab ${duplicates.get(tab.tabId)}`,
65
+ });
66
+ }
67
+ if (tab.lastFocusedAt) {
68
+ const ageDays = (now - tab.lastFocusedAt) / (24 * 60 * 60 * 1000);
69
+ if (ageDays >= staleDays) {
70
+ addReason(tab, {
71
+ type: "stale",
72
+ detail: `Last focused ${Math.floor(ageDays)} days ago`,
73
+ });
74
+ }
75
+ }
76
+ }
77
+ const githubTabs = checkGitHub
78
+ ? selectedTabs.filter((tab) => isGitHubIssueOrPr(tab.url) && isScriptableUrl(tab.url))
79
+ : [];
80
+ githubTotal = githubTabs.length;
81
+ if (checkGitHub && githubTabs.length > 0) {
82
+ let index = 0;
83
+ const total = githubTabs.length;
84
+ const workers = Array.from({ length: Math.min(githubConcurrency, total) }, async () => {
85
+ while (true) {
86
+ const currentIndex = index;
87
+ if (currentIndex >= total) {
88
+ return;
89
+ }
90
+ index += 1;
91
+ const tab = githubTabs[currentIndex];
92
+ const state = await detectGitHubState(tab.tabId, githubTimeoutMs);
93
+ githubChecked += 1;
94
+ if (state === "closed" || state === "merged") {
95
+ githubMatched += 1;
96
+ addReason(tab, {
97
+ type: "closed_issue",
98
+ detail: `GitHub state: ${state}`,
99
+ });
100
+ }
101
+ if (progressEnabled) {
102
+ deps.sendProgress(requestId, {
103
+ phase: "github",
104
+ processed: githubChecked,
105
+ total,
106
+ matched: githubMatched,
107
+ tabId: tab.tabId,
108
+ timeoutMs: githubTimeoutMs,
109
+ });
110
+ }
111
+ }
112
+ });
113
+ await Promise.all(workers);
114
+ }
115
+ const candidates = Array.from(candidateMap.values()).map((entry) => {
116
+ const reasons = entry.reasons;
117
+ const severity = reasons.some((reason) => reason.type === "duplicate" || reason.type === "closed_issue")
118
+ ? "high"
119
+ : "medium";
120
+ return {
121
+ tabId: entry.tab.tabId,
122
+ windowId: entry.tab.windowId,
123
+ groupId: entry.tab.groupId,
124
+ url: entry.tab.url,
125
+ title: entry.tab.title,
126
+ lastFocusedAt: entry.tab.lastFocusedAt,
127
+ reasons,
128
+ severity,
129
+ };
130
+ });
131
+ return {
132
+ generatedAt: Date.now(),
133
+ staleDays,
134
+ totals: {
135
+ tabs: scopeTabs.length,
136
+ analyzed: selectedTabs.length,
137
+ candidates: candidates.length,
138
+ },
139
+ meta: {
140
+ durationMs: Date.now() - startedAt,
141
+ githubChecked,
142
+ githubTotal,
143
+ githubMatched,
144
+ githubTimeoutMs,
145
+ },
146
+ candidates,
147
+ };
148
+ }
149
+ async function inspectTabs(params, requestId, deps) {
150
+ const signalList = Array.isArray(params.signals) && params.signals.length > 0
151
+ ? params.signals.map(String)
152
+ : ["page-meta"];
153
+ const signalConcurrencyRaw = Number(params.signalConcurrency);
154
+ const signalConcurrency = Number.isFinite(signalConcurrencyRaw) && signalConcurrencyRaw > 0
155
+ ? Math.min(10, Math.floor(signalConcurrencyRaw))
156
+ : 4;
157
+ const signalTimeoutRaw = Number(params.signalTimeoutMs);
158
+ const signalTimeoutMs = Number.isFinite(signalTimeoutRaw) && signalTimeoutRaw > 0
159
+ ? Math.floor(signalTimeoutRaw)
160
+ : 4000;
161
+ const progressEnabled = params.progress === true;
162
+ // For settle mode with specific tab IDs, wait BEFORE taking snapshot
163
+ // This ensures the tab URL is available for isScriptableUrl() checks
164
+ const waitFor = typeof params.waitFor === "string" ? params.waitFor.trim().toLowerCase() : "";
165
+ if (waitFor === "settle" && Array.isArray(params.tabIds) && params.tabIds.length > 0) {
166
+ const waitTimeoutRaw = Number(params.waitTimeoutMs);
167
+ const waitTimeoutMs = Number.isFinite(waitTimeoutRaw) && waitTimeoutRaw > 0 ? Math.floor(waitTimeoutRaw) : signalTimeoutMs;
168
+ const tabIds = params.tabIds.map(Number).filter(Number.isFinite);
169
+ await Promise.all(tabIds.map((id) => waitForSettle(id, waitTimeoutMs)));
170
+ }
171
+ const snapshot = await deps.getTabSnapshot();
172
+ const selection = deps.selectTabsByScope(snapshot, params);
173
+ if (selection.error) {
174
+ throw selection.error;
175
+ }
176
+ const tabs = selection.tabs;
177
+ const startedAt = Date.now();
178
+ const selectorSpecs = [];
179
+ if (Array.isArray(params.selectorSpecs)) {
180
+ selectorSpecs.push(...params.selectorSpecs);
181
+ }
182
+ if (params.signalConfig && typeof params.signalConfig === "object") {
183
+ const config = params.signalConfig;
184
+ if (Array.isArray(config.selectors)) {
185
+ selectorSpecs.push(...config.selectors);
186
+ }
187
+ if (config.signals && typeof config.signals === "object") {
188
+ const signals = config.signals;
189
+ const selectorConfig = signals.selector;
190
+ if (selectorConfig && Array.isArray(selectorConfig.selectors)) {
191
+ selectorSpecs.push(...selectorConfig.selectors);
192
+ }
193
+ }
194
+ }
195
+ const normalizedSelectors = selectorSpecs
196
+ .filter((spec) => spec && typeof spec.selector === "string" && spec.selector.length > 0)
197
+ .map((spec) => ({
198
+ name: typeof spec.name === "string" ? spec.name : undefined,
199
+ selector: spec.selector,
200
+ attr: typeof spec.attr === "string" ? spec.attr : "text",
201
+ all: Boolean(spec.all),
202
+ text: typeof spec.text === "string" && spec.text.trim() ? spec.text.trim() : undefined,
203
+ textMode: typeof spec.textMode === "string" ? spec.textMode.trim().toLowerCase() : undefined,
204
+ }));
205
+ const selectorWarnings = normalizedSelectors
206
+ .filter((spec) => typeof spec.selector === "string" && spec.selector.includes(":contains("))
207
+ .map((spec) => ({
208
+ name: spec.name || spec.selector,
209
+ hint: "CSS :contains() is not supported; use selector text filters or a different selector.",
210
+ }));
211
+ const signalDefs = [];
212
+ for (const signalId of signalList) {
213
+ if (signalId === "github-state") {
214
+ signalDefs.push({
215
+ id: signalId,
216
+ match: (tab) => isGitHubIssueOrPr(tab.url) && isScriptableUrl(tab.url),
217
+ run: async (tabId) => {
218
+ const state = await detectGitHubState(tabId, signalTimeoutMs);
219
+ return state ? { state } : null;
220
+ },
221
+ });
222
+ }
223
+ else if (signalId === "page-meta") {
224
+ signalDefs.push({
225
+ id: signalId,
226
+ match: (tab) => isScriptableUrl(tab.url),
227
+ run: async (tabId) => extractPageMeta(tabId, signalTimeoutMs, exports.DESCRIPTION_MAX_LENGTH),
228
+ });
229
+ }
230
+ else if (signalId === "selector") {
231
+ signalDefs.push({
232
+ id: signalId,
233
+ match: (tab) => isScriptableUrl(tab.url),
234
+ run: async (tabId) => extractSelectorSignal(tabId, normalizedSelectors, signalTimeoutMs, exports.SELECTOR_VALUE_MAX_LENGTH),
235
+ });
236
+ }
237
+ }
238
+ const tasks = [];
239
+ for (const tab of tabs) {
240
+ for (const signal of signalDefs) {
241
+ if (signal.match(tab)) {
242
+ tasks.push({ tab, signal });
243
+ }
244
+ }
245
+ }
246
+ const totalTasks = tasks.length;
247
+ let completedTasks = 0;
248
+ const entryMap = new Map();
249
+ const workerCount = Math.min(signalConcurrency, totalTasks || 1);
250
+ let index = 0;
251
+ const workers = Array.from({ length: workerCount }, async () => {
252
+ while (true) {
253
+ const currentIndex = index;
254
+ if (currentIndex >= totalTasks) {
255
+ return;
256
+ }
257
+ index += 1;
258
+ const task = tasks[currentIndex];
259
+ const tabId = task.tab.tabId;
260
+ let result = null;
261
+ let error = null;
262
+ const started = Date.now();
263
+ try {
264
+ await waitForTabReady(tabId, params, signalTimeoutMs);
265
+ result = await task.signal.run(tabId);
266
+ }
267
+ catch (err) {
268
+ const message = err instanceof Error ? err.message : "signal_error";
269
+ error = message;
270
+ }
271
+ const durationMs = Date.now() - started;
272
+ const entry = entryMap.get(tabId) || { tab: task.tab, signals: {} };
273
+ entry.signals[task.signal.id] = {
274
+ ok: error === null,
275
+ durationMs,
276
+ data: result,
277
+ error,
278
+ };
279
+ entryMap.set(tabId, entry);
280
+ completedTasks += 1;
281
+ if (progressEnabled) {
282
+ deps.sendProgress(requestId, {
283
+ phase: "inspect",
284
+ processed: completedTasks,
285
+ total: totalTasks,
286
+ signalId: task.signal.id,
287
+ tabId,
288
+ });
289
+ }
290
+ }
291
+ });
292
+ await Promise.all(workers);
293
+ const entries = Array.from(entryMap.values()).map((entry) => ({
294
+ tabId: entry.tab.tabId,
295
+ windowId: entry.tab.windowId,
296
+ groupId: entry.tab.groupId,
297
+ url: entry.tab.url,
298
+ title: entry.tab.title,
299
+ signals: entry.signals,
300
+ }));
301
+ return {
302
+ generatedAt: Date.now(),
303
+ totals: {
304
+ tabs: tabs.length,
305
+ signals: signalDefs.length,
306
+ tasks: totalTasks,
307
+ },
308
+ meta: {
309
+ durationMs: Date.now() - startedAt,
310
+ signalTimeoutMs,
311
+ selectorCount: normalizedSelectors.length,
312
+ selectorWarnings: selectorWarnings.length > 0 ? selectorWarnings : undefined,
313
+ },
314
+ entries,
315
+ };
316
+ }
@@ -0,0 +1,342 @@
1
+ "use strict";
2
+ // Tab/group movement — extracted from tabs.ts (pure structural refactor).
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.resolveMoveTarget = resolveMoveTarget;
5
+ exports.moveTab = moveTab;
6
+ exports.moveGroup = moveGroup;
7
+ const tabs = require("./tabs");
8
+ const { normalizeTabIndex } = tabs;
9
+ function resolveMoveTarget(snapshot, params, deps) {
10
+ const beforeTabId = Number(params.beforeTabId);
11
+ const afterTabId = Number(params.afterTabId);
12
+ const beforeGroupTitle = typeof params.beforeGroupTitle === "string" ? params.beforeGroupTitle.trim() : "";
13
+ const afterGroupTitle = typeof params.afterGroupTitle === "string" ? params.afterGroupTitle.trim() : "";
14
+ const targets = [
15
+ Number.isFinite(beforeTabId) ? "before-tab" : null,
16
+ Number.isFinite(afterTabId) ? "after-tab" : null,
17
+ beforeGroupTitle ? "before-group" : null,
18
+ afterGroupTitle ? "after-group" : null,
19
+ ].filter(Boolean);
20
+ if (targets.length === 0) {
21
+ return { error: { message: "Missing target position (--before/--after)" } };
22
+ }
23
+ if (targets.length > 1) {
24
+ return { error: { message: "Only one target position is allowed" } };
25
+ }
26
+ const windows = snapshot.windows;
27
+ const findTab = (tabId) => {
28
+ for (const win of windows) {
29
+ const tab = win.tabs.find((entry) => entry.tabId === tabId);
30
+ if (tab) {
31
+ return { tab, windowId: win.windowId };
32
+ }
33
+ }
34
+ return null;
35
+ };
36
+ if (targets[0] === "before-tab" || targets[0] === "after-tab") {
37
+ const tabId = targets[0] === "before-tab" ? beforeTabId : afterTabId;
38
+ if (!Number.isFinite(tabId)) {
39
+ return { error: { message: "Invalid tab target" } };
40
+ }
41
+ const match = findTab(tabId);
42
+ if (!match) {
43
+ return { error: { message: "Target tab not found" } };
44
+ }
45
+ const index = normalizeTabIndex(match.tab.index);
46
+ if (!Number.isFinite(index)) {
47
+ return { error: { message: "Target tab index unavailable" } };
48
+ }
49
+ return {
50
+ windowId: match.windowId,
51
+ index: targets[0] === "before-tab" ? index : index + 1,
52
+ anchor: { type: "tab", tabId },
53
+ };
54
+ }
55
+ const groupTitle = targets[0] === "before-group" ? beforeGroupTitle : afterGroupTitle;
56
+ const windowId = Number.isFinite(params.windowId) ? Number(params.windowId) : undefined;
57
+ const resolved = deps.resolveGroupByTitle(snapshot, groupTitle, windowId);
58
+ if (resolved.error) {
59
+ return resolved;
60
+ }
61
+ const match = resolved.match;
62
+ if (!match.tabs.length) {
63
+ return { error: { message: "Target group has no tabs" } };
64
+ }
65
+ const indices = match.tabs
66
+ .map((tab) => normalizeTabIndex(tab.index))
67
+ .filter((value) => value != null);
68
+ if (!indices.length) {
69
+ return { error: { message: "Target group indices unavailable" } };
70
+ }
71
+ const minIndex = Math.min(...indices);
72
+ const maxIndex = Math.max(...indices);
73
+ return {
74
+ windowId: match.windowId,
75
+ index: targets[0] === "before-group" ? minIndex : maxIndex + 1,
76
+ anchor: { type: "group", groupId: match.group.groupId, groupTitle },
77
+ };
78
+ }
79
+ async function moveTab(params, deps) {
80
+ const tabIds = Array.isArray(params.tabIds) ? params.tabIds.map(Number) : [];
81
+ const tabId = Number.isFinite(params.tabId)
82
+ ? Number(params.tabId)
83
+ : tabIds.length
84
+ ? Number(tabIds[0])
85
+ : null;
86
+ if (!tabId) {
87
+ throw new Error("Missing tabId");
88
+ }
89
+ const snapshot = await deps.getTabSnapshot();
90
+ const windows = snapshot.windows;
91
+ const sourceWindow = windows.find((win) => win.tabs.some((tab) => tab.tabId === tabId));
92
+ if (!sourceWindow) {
93
+ throw new Error("Source tab not found");
94
+ }
95
+ const sourceTab = sourceWindow.tabs.find((tab) => tab.tabId === tabId);
96
+ if (!sourceTab) {
97
+ throw new Error("Source tab not found");
98
+ }
99
+ const newWindow = params.newWindow === true;
100
+ const hasTarget = Number.isFinite(params.beforeTabId)
101
+ || Number.isFinite(params.afterTabId)
102
+ || (typeof params.beforeGroupTitle === "string" && params.beforeGroupTitle.trim())
103
+ || (typeof params.afterGroupTitle === "string" && params.afterGroupTitle.trim());
104
+ if (newWindow) {
105
+ if (hasTarget) {
106
+ throw new Error("Cannot combine --new-window with --before/--after");
107
+ }
108
+ const createdWindow = await chrome.windows.create({ tabId, focused: false });
109
+ const targetWindowId = createdWindow.id;
110
+ let targetIndex = 0;
111
+ const createdTab = createdWindow.tabs?.find((tab) => tab.id === tabId) || null;
112
+ if (createdTab && Number.isFinite(createdTab.index)) {
113
+ targetIndex = createdTab.index;
114
+ }
115
+ else {
116
+ try {
117
+ const updated = await chrome.tabs.get(tabId);
118
+ targetIndex = updated.index;
119
+ }
120
+ catch {
121
+ targetIndex = 0;
122
+ }
123
+ }
124
+ return {
125
+ tabId,
126
+ from: { windowId: sourceWindow.windowId, index: sourceTab.index },
127
+ to: { windowId: targetWindowId, index: targetIndex },
128
+ summary: { movedTabs: 1 },
129
+ undo: {
130
+ action: "move-tab",
131
+ tabId,
132
+ from: {
133
+ windowId: sourceWindow.windowId,
134
+ index: sourceTab.index,
135
+ groupId: sourceTab.groupId,
136
+ groupTitle: sourceTab.groupTitle,
137
+ groupColor: sourceTab.groupColor,
138
+ groupCollapsed: sourceTab.groupCollapsed ?? null,
139
+ },
140
+ to: {
141
+ windowId: targetWindowId,
142
+ index: targetIndex,
143
+ },
144
+ },
145
+ txid: params.txid || null,
146
+ };
147
+ }
148
+ let normalizedParams = params;
149
+ if (params.windowId != null) {
150
+ const resolvedWindowId = deps.resolveWindowIdFromParams(snapshot, params.windowId);
151
+ normalizedParams = { ...params, windowId: resolvedWindowId ?? undefined };
152
+ }
153
+ const target = resolveMoveTarget(snapshot, normalizedParams, deps);
154
+ if (target.error) {
155
+ throw target.error;
156
+ }
157
+ const targetWindowId = target.windowId;
158
+ let targetIndex = target.index;
159
+ const sourceIndex = normalizeTabIndex(sourceTab.index);
160
+ if (Number.isFinite(sourceIndex) && sourceWindow.windowId === targetWindowId && sourceIndex < targetIndex) {
161
+ targetIndex -= 1;
162
+ }
163
+ const moved = await chrome.tabs.move(tabId, { windowId: targetWindowId, index: targetIndex });
164
+ return {
165
+ tabId,
166
+ from: { windowId: sourceWindow.windowId, index: sourceTab.index },
167
+ to: { windowId: targetWindowId, index: moved.index },
168
+ summary: { movedTabs: 1 },
169
+ undo: {
170
+ action: "move-tab",
171
+ tabId,
172
+ from: {
173
+ windowId: sourceWindow.windowId,
174
+ index: sourceTab.index,
175
+ groupId: sourceTab.groupId,
176
+ groupTitle: sourceTab.groupTitle,
177
+ groupColor: sourceTab.groupColor,
178
+ groupCollapsed: sourceTab.groupCollapsed ?? null,
179
+ },
180
+ to: {
181
+ windowId: targetWindowId,
182
+ index: moved.index,
183
+ },
184
+ },
185
+ txid: params.txid || null,
186
+ };
187
+ }
188
+ async function moveGroup(params, deps) {
189
+ const groupId = Number.isFinite(params.groupId) ? Number(params.groupId) : null;
190
+ const groupTitle = typeof params.groupTitle === "string" ? params.groupTitle.trim() : "";
191
+ if (!groupId && !groupTitle) {
192
+ throw new Error("Missing group identifier");
193
+ }
194
+ const snapshot = await deps.getTabSnapshot();
195
+ const windowIdParam = params.windowId != null ? deps.resolveWindowIdFromParams(snapshot, params.windowId) ?? undefined : undefined;
196
+ const resolvedGroup = groupId != null
197
+ ? deps.resolveGroupById(snapshot, groupId)
198
+ : deps.resolveGroupByTitle(snapshot, groupTitle, windowIdParam);
199
+ if (resolvedGroup.error) {
200
+ throw resolvedGroup.error;
201
+ }
202
+ const source = resolvedGroup.match;
203
+ if (!source.tabs.length) {
204
+ throw new Error("Group has no tabs to move");
205
+ }
206
+ const newWindow = params.newWindow === true;
207
+ const hasTarget = Number.isFinite(params.beforeTabId)
208
+ || Number.isFinite(params.afterTabId)
209
+ || (typeof params.beforeGroupTitle === "string" && params.beforeGroupTitle.trim())
210
+ || (typeof params.afterGroupTitle === "string" && params.afterGroupTitle.trim());
211
+ if (newWindow) {
212
+ if (hasTarget) {
213
+ throw new Error("Cannot combine --new-window with --before/--after");
214
+ }
215
+ const tabIds = source.tabs.map((tab) => tab.tabId).filter((id) => typeof id === "number");
216
+ const [firstTabId, ...restTabIds] = tabIds;
217
+ if (!firstTabId) {
218
+ throw new Error("Group has no tabs to move");
219
+ }
220
+ const createdWindow = await chrome.windows.create({ tabId: firstTabId, focused: false });
221
+ const targetWindowId = createdWindow.id;
222
+ if (restTabIds.length > 0) {
223
+ await chrome.tabs.move(restTabIds, { windowId: targetWindowId, index: -1 });
224
+ }
225
+ let newGroupId = null;
226
+ try {
227
+ newGroupId = await chrome.tabs.group({ tabIds, createProperties: { windowId: targetWindowId } });
228
+ await chrome.tabGroups.update(newGroupId, {
229
+ title: source.group.title || "",
230
+ color: source.group.color || "grey",
231
+ collapsed: source.group.collapsed || false,
232
+ });
233
+ }
234
+ catch (error) {
235
+ deps.log("Failed to regroup tabs", error);
236
+ }
237
+ const undoTabs = source.tabs
238
+ .map((tab) => ({
239
+ tabId: tab.tabId,
240
+ windowId: tab.windowId,
241
+ index: tab.index,
242
+ groupId: tab.groupId,
243
+ groupTitle: tab.groupTitle,
244
+ groupColor: tab.groupColor,
245
+ groupCollapsed: source.group.collapsed ?? null,
246
+ }))
247
+ .filter((tab) => typeof tab.tabId === "number");
248
+ return {
249
+ groupId: source.group.groupId,
250
+ windowId: source.windowId,
251
+ movedToWindowId: targetWindowId,
252
+ newGroupId,
253
+ summary: { movedTabs: tabIds.length },
254
+ undo: {
255
+ action: "move-group",
256
+ groupId: source.group.groupId,
257
+ windowId: source.windowId,
258
+ movedToWindowId: targetWindowId,
259
+ groupTitle: source.group.title ?? null,
260
+ groupColor: source.group.color ?? null,
261
+ groupCollapsed: source.group.collapsed ?? null,
262
+ tabs: undoTabs,
263
+ },
264
+ txid: params.txid || null,
265
+ };
266
+ }
267
+ const target = resolveMoveTarget(snapshot, params, deps);
268
+ if (target.error) {
269
+ throw target.error;
270
+ }
271
+ if (target.anchor?.type === "tab") {
272
+ const anchorTabId = target.anchor.tabId;
273
+ if (source.tabs.some((tab) => tab.tabId === anchorTabId)) {
274
+ throw new Error("Target tab is within the source group");
275
+ }
276
+ }
277
+ if (target.anchor?.type === "group") {
278
+ const anchorGroupId = target.anchor.groupId;
279
+ if (anchorGroupId === source.group.groupId && source.windowId === target.windowId) {
280
+ throw new Error("Target group matches source group");
281
+ }
282
+ }
283
+ const tabIds = source.tabs.map((tab) => tab.tabId).filter((id) => typeof id === "number");
284
+ const indices = source.tabs
285
+ .map((tab) => normalizeTabIndex(tab.index))
286
+ .filter((value) => value != null);
287
+ const minIndex = Math.min(...indices);
288
+ const maxIndex = Math.max(...indices);
289
+ const targetWindowId = target.windowId;
290
+ let targetIndex = target.index;
291
+ if (source.windowId === targetWindowId && targetIndex > maxIndex) {
292
+ targetIndex -= tabIds.length;
293
+ }
294
+ const moved = await chrome.tabs.move(tabIds, { windowId: targetWindowId, index: targetIndex });
295
+ const movedList = Array.isArray(moved) ? moved : [moved];
296
+ let newGroupId = null;
297
+ if (targetWindowId !== source.windowId) {
298
+ try {
299
+ const movedIds = movedList.map((tab) => tab.id).filter((id) => typeof id === "number");
300
+ if (movedIds.length > 0) {
301
+ newGroupId = await chrome.tabs.group({ tabIds: movedIds, createProperties: { windowId: targetWindowId } });
302
+ await chrome.tabGroups.update(newGroupId, {
303
+ title: source.group.title || "",
304
+ color: source.group.color || "grey",
305
+ collapsed: source.group.collapsed || false,
306
+ });
307
+ }
308
+ }
309
+ catch (error) {
310
+ deps.log("Failed to regroup tabs", error);
311
+ }
312
+ }
313
+ const undoTabs = source.tabs
314
+ .map((tab) => ({
315
+ tabId: tab.tabId,
316
+ windowId: tab.windowId,
317
+ index: tab.index,
318
+ groupId: tab.groupId,
319
+ groupTitle: tab.groupTitle,
320
+ groupColor: tab.groupColor,
321
+ groupCollapsed: source.group.collapsed ?? null,
322
+ }))
323
+ .filter((tab) => typeof tab.tabId === "number");
324
+ return {
325
+ groupId: source.group.groupId,
326
+ windowId: source.windowId,
327
+ movedToWindowId: targetWindowId,
328
+ newGroupId,
329
+ summary: { movedTabs: tabIds.length },
330
+ undo: {
331
+ action: "move-group",
332
+ groupId: source.group.groupId,
333
+ windowId: source.windowId,
334
+ movedToWindowId: targetWindowId,
335
+ groupTitle: source.group.title ?? null,
336
+ groupColor: source.group.color ?? null,
337
+ groupCollapsed: source.group.collapsed ?? null,
338
+ tabs: undoTabs,
339
+ },
340
+ txid: params.txid || null,
341
+ };
342
+ }