@flrande/bak-extension 0.2.4 → 0.3.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/.bak-e2e-build-stamp +1 -1
- package/dist/background.global.js +973 -147
- package/dist/manifest.json +1 -1
- package/package.json +9 -11
- package/public/manifest.json +1 -1
- package/src/background.ts +550 -153
- package/src/workspace.ts +626 -0
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
export const DEFAULT_WORKSPACE_ID = 'default';
|
|
2
|
+
export const DEFAULT_WORKSPACE_LABEL = 'bak agent';
|
|
3
|
+
export const DEFAULT_WORKSPACE_COLOR = 'blue';
|
|
4
|
+
export const DEFAULT_WORKSPACE_URL = 'about:blank';
|
|
5
|
+
|
|
6
|
+
export type WorkspaceColor = 'grey' | 'blue' | 'red' | 'yellow' | 'green' | 'pink' | 'purple' | 'cyan' | 'orange';
|
|
7
|
+
|
|
8
|
+
export interface WorkspaceTab {
|
|
9
|
+
id: number;
|
|
10
|
+
title: string;
|
|
11
|
+
url: string;
|
|
12
|
+
active: boolean;
|
|
13
|
+
windowId: number;
|
|
14
|
+
groupId: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WorkspaceWindow {
|
|
18
|
+
id: number;
|
|
19
|
+
focused: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkspaceGroup {
|
|
23
|
+
id: number;
|
|
24
|
+
windowId: number;
|
|
25
|
+
title: string;
|
|
26
|
+
color: WorkspaceColor;
|
|
27
|
+
collapsed: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WorkspaceRecord {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
color: WorkspaceColor;
|
|
34
|
+
windowId: number | null;
|
|
35
|
+
groupId: number | null;
|
|
36
|
+
tabIds: number[];
|
|
37
|
+
activeTabId: number | null;
|
|
38
|
+
primaryTabId: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WorkspaceInfo extends WorkspaceRecord {
|
|
42
|
+
tabs: WorkspaceTab[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WorkspaceEnsureResult {
|
|
46
|
+
workspace: WorkspaceInfo;
|
|
47
|
+
created: boolean;
|
|
48
|
+
repaired: boolean;
|
|
49
|
+
repairActions: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WorkspaceTargetResolution {
|
|
53
|
+
tab: WorkspaceTab;
|
|
54
|
+
workspace: WorkspaceInfo | null;
|
|
55
|
+
resolution: 'explicit-tab' | 'explicit-workspace' | 'default-workspace' | 'browser-active';
|
|
56
|
+
createdWorkspace: boolean;
|
|
57
|
+
repaired: boolean;
|
|
58
|
+
repairActions: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface WorkspaceStorage {
|
|
62
|
+
load(): Promise<WorkspaceRecord | null>;
|
|
63
|
+
save(state: WorkspaceRecord | null): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WorkspaceBrowser {
|
|
67
|
+
getTab(tabId: number): Promise<WorkspaceTab | null>;
|
|
68
|
+
getActiveTab(): Promise<WorkspaceTab | null>;
|
|
69
|
+
listTabs(filter?: { windowId?: number }): Promise<WorkspaceTab[]>;
|
|
70
|
+
createTab(options: { windowId?: number; url?: string; active?: boolean }): Promise<WorkspaceTab>;
|
|
71
|
+
updateTab(tabId: number, options: { active?: boolean; url?: string }): Promise<WorkspaceTab>;
|
|
72
|
+
closeTab(tabId: number): Promise<void>;
|
|
73
|
+
getWindow(windowId: number): Promise<WorkspaceWindow | null>;
|
|
74
|
+
createWindow(options: { url?: string; focused?: boolean }): Promise<WorkspaceWindow>;
|
|
75
|
+
updateWindow(windowId: number, options: { focused?: boolean }): Promise<WorkspaceWindow>;
|
|
76
|
+
closeWindow(windowId: number): Promise<void>;
|
|
77
|
+
getGroup(groupId: number): Promise<WorkspaceGroup | null>;
|
|
78
|
+
groupTabs(tabIds: number[], groupId?: number): Promise<number>;
|
|
79
|
+
updateGroup(groupId: number, options: { title?: string; color?: WorkspaceColor; collapsed?: boolean }): Promise<WorkspaceGroup>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface WorkspaceEnsureOptions {
|
|
83
|
+
workspaceId?: string;
|
|
84
|
+
focus?: boolean;
|
|
85
|
+
initialUrl?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface WorkspaceOpenTabOptions {
|
|
89
|
+
workspaceId?: string;
|
|
90
|
+
url?: string;
|
|
91
|
+
active?: boolean;
|
|
92
|
+
focus?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface WorkspaceResolveTargetOptions {
|
|
96
|
+
tabId?: number;
|
|
97
|
+
workspaceId?: string;
|
|
98
|
+
createIfMissing?: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class WorkspaceManager {
|
|
102
|
+
private readonly storage: WorkspaceStorage;
|
|
103
|
+
private readonly browser: WorkspaceBrowser;
|
|
104
|
+
|
|
105
|
+
constructor(storage: WorkspaceStorage, browser: WorkspaceBrowser) {
|
|
106
|
+
this.storage = storage;
|
|
107
|
+
this.browser = browser;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async getWorkspaceInfo(workspaceId = DEFAULT_WORKSPACE_ID): Promise<WorkspaceInfo | null> {
|
|
111
|
+
const state = await this.loadWorkspaceRecord(workspaceId);
|
|
112
|
+
if (!state) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const repaired = await this.ensureWorkspace({ workspaceId, focus: false, initialUrl: DEFAULT_WORKSPACE_URL });
|
|
116
|
+
return repaired.workspace;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async ensureWorkspace(options: WorkspaceEnsureOptions = {}): Promise<WorkspaceEnsureResult> {
|
|
120
|
+
const workspaceId = this.normalizeWorkspaceId(options.workspaceId);
|
|
121
|
+
const repairActions: string[] = [];
|
|
122
|
+
const initialUrl = options.initialUrl ?? DEFAULT_WORKSPACE_URL;
|
|
123
|
+
const persisted = await this.storage.load();
|
|
124
|
+
const created = !persisted;
|
|
125
|
+
let state = this.normalizeState(persisted, workspaceId);
|
|
126
|
+
|
|
127
|
+
let window = state.windowId !== null ? await this.browser.getWindow(state.windowId) : null;
|
|
128
|
+
let tabs: WorkspaceTab[] = [];
|
|
129
|
+
if (!window) {
|
|
130
|
+
const createdWindow = await this.browser.createWindow({
|
|
131
|
+
url: initialUrl,
|
|
132
|
+
focused: options.focus === true
|
|
133
|
+
});
|
|
134
|
+
state.windowId = createdWindow.id;
|
|
135
|
+
state.groupId = null;
|
|
136
|
+
state.tabIds = [];
|
|
137
|
+
state.activeTabId = null;
|
|
138
|
+
state.primaryTabId = null;
|
|
139
|
+
window = createdWindow;
|
|
140
|
+
tabs = await this.waitForWindowTabs(createdWindow.id);
|
|
141
|
+
state.tabIds = tabs.map((tab) => tab.id);
|
|
142
|
+
if (state.primaryTabId === null) {
|
|
143
|
+
state.primaryTabId = tabs[0]?.id ?? null;
|
|
144
|
+
}
|
|
145
|
+
if (state.activeTabId === null) {
|
|
146
|
+
state.activeTabId = tabs.find((tab) => tab.active)?.id ?? tabs[0]?.id ?? null;
|
|
147
|
+
}
|
|
148
|
+
repairActions.push(created ? 'created-window' : 'recreated-window');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
tabs = tabs.length > 0 ? tabs : await this.readTrackedTabs(state.tabIds, state.windowId);
|
|
152
|
+
const recoveredTabs = await this.recoverWorkspaceTabs(state, tabs);
|
|
153
|
+
if (recoveredTabs.length > tabs.length) {
|
|
154
|
+
tabs = recoveredTabs;
|
|
155
|
+
repairActions.push('recovered-tracked-tabs');
|
|
156
|
+
}
|
|
157
|
+
if (tabs.length !== state.tabIds.length) {
|
|
158
|
+
repairActions.push('pruned-missing-tabs');
|
|
159
|
+
}
|
|
160
|
+
state.tabIds = tabs.map((tab) => tab.id);
|
|
161
|
+
|
|
162
|
+
if (tabs.length === 0) {
|
|
163
|
+
const primary = await this.createWorkspaceTab({
|
|
164
|
+
windowId: state.windowId,
|
|
165
|
+
url: initialUrl,
|
|
166
|
+
active: true
|
|
167
|
+
});
|
|
168
|
+
tabs = [primary];
|
|
169
|
+
state.tabIds = [primary.id];
|
|
170
|
+
state.primaryTabId = primary.id;
|
|
171
|
+
state.activeTabId = primary.id;
|
|
172
|
+
repairActions.push('created-primary-tab');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (state.primaryTabId === null || !tabs.some((tab) => tab.id === state.primaryTabId)) {
|
|
176
|
+
state.primaryTabId = tabs[0]?.id ?? null;
|
|
177
|
+
repairActions.push('reassigned-primary-tab');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (state.activeTabId === null || !tabs.some((tab) => tab.id === state.activeTabId)) {
|
|
181
|
+
state.activeTabId = state.primaryTabId ?? tabs[0]?.id ?? null;
|
|
182
|
+
repairActions.push('reassigned-active-tab');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let group = state.groupId !== null ? await this.browser.getGroup(state.groupId) : null;
|
|
186
|
+
if (!group || group.windowId !== state.windowId) {
|
|
187
|
+
const groupId = await this.browser.groupTabs(tabs.map((tab) => tab.id));
|
|
188
|
+
group = await this.browser.updateGroup(groupId, {
|
|
189
|
+
title: state.label,
|
|
190
|
+
color: state.color,
|
|
191
|
+
collapsed: false
|
|
192
|
+
});
|
|
193
|
+
state.groupId = group.id;
|
|
194
|
+
repairActions.push('recreated-group');
|
|
195
|
+
} else {
|
|
196
|
+
await this.browser.updateGroup(group.id, {
|
|
197
|
+
title: state.label,
|
|
198
|
+
color: state.color,
|
|
199
|
+
collapsed: false
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const ungroupedIds = tabs.filter((tab) => tab.groupId !== state.groupId).map((tab) => tab.id);
|
|
204
|
+
if (ungroupedIds.length > 0) {
|
|
205
|
+
await this.browser.groupTabs(ungroupedIds, state.groupId ?? undefined);
|
|
206
|
+
repairActions.push('regrouped-tabs');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
|
|
210
|
+
tabs = await this.recoverWorkspaceTabs(state, tabs);
|
|
211
|
+
const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId) : null;
|
|
212
|
+
if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
|
|
213
|
+
tabs = [...tabs, activeTab];
|
|
214
|
+
}
|
|
215
|
+
state.tabIds = [...new Set(tabs.map((tab) => tab.id))];
|
|
216
|
+
|
|
217
|
+
if (options.focus === true && state.activeTabId !== null) {
|
|
218
|
+
await this.browser.updateTab(state.activeTabId, { active: true });
|
|
219
|
+
window = await this.browser.updateWindow(state.windowId!, { focused: true });
|
|
220
|
+
void window;
|
|
221
|
+
repairActions.push('focused-window');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await this.storage.save(state);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
workspace: {
|
|
228
|
+
...state,
|
|
229
|
+
tabs
|
|
230
|
+
},
|
|
231
|
+
created,
|
|
232
|
+
repaired: repairActions.length > 0,
|
|
233
|
+
repairActions
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async openTab(options: WorkspaceOpenTabOptions = {}): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab }> {
|
|
238
|
+
const ensured = await this.ensureWorkspace({
|
|
239
|
+
workspaceId: options.workspaceId,
|
|
240
|
+
focus: false,
|
|
241
|
+
initialUrl: options.url ?? DEFAULT_WORKSPACE_URL
|
|
242
|
+
});
|
|
243
|
+
const state = { ...ensured.workspace };
|
|
244
|
+
const active = options.active === true;
|
|
245
|
+
const desiredUrl = options.url ?? DEFAULT_WORKSPACE_URL;
|
|
246
|
+
const reusablePrimaryTab =
|
|
247
|
+
(ensured.created || ensured.repairActions.includes('recreated-window') || ensured.repairActions.includes('created-primary-tab')) &&
|
|
248
|
+
state.tabs.length === 1 &&
|
|
249
|
+
state.primaryTabId !== null
|
|
250
|
+
? state.tabs.find((tab) => tab.id === state.primaryTabId) ?? null
|
|
251
|
+
: null;
|
|
252
|
+
|
|
253
|
+
const createdTab = reusablePrimaryTab
|
|
254
|
+
? await this.browser.updateTab(reusablePrimaryTab.id, {
|
|
255
|
+
url: desiredUrl,
|
|
256
|
+
active
|
|
257
|
+
})
|
|
258
|
+
: await this.createWorkspaceTab({
|
|
259
|
+
windowId: state.windowId,
|
|
260
|
+
url: desiredUrl,
|
|
261
|
+
active
|
|
262
|
+
});
|
|
263
|
+
const nextTabIds = [...new Set([...state.tabIds, createdTab.id])];
|
|
264
|
+
const groupId = await this.browser.groupTabs([createdTab.id], state.groupId ?? undefined);
|
|
265
|
+
await this.browser.updateGroup(groupId, {
|
|
266
|
+
title: state.label,
|
|
267
|
+
color: state.color,
|
|
268
|
+
collapsed: false
|
|
269
|
+
});
|
|
270
|
+
const nextState: WorkspaceRecord = {
|
|
271
|
+
id: state.id,
|
|
272
|
+
label: state.label,
|
|
273
|
+
color: state.color,
|
|
274
|
+
windowId: state.windowId,
|
|
275
|
+
groupId,
|
|
276
|
+
tabIds: nextTabIds,
|
|
277
|
+
activeTabId: createdTab.id,
|
|
278
|
+
primaryTabId: state.primaryTabId ?? createdTab.id
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (options.focus === true) {
|
|
282
|
+
await this.browser.updateTab(createdTab.id, { active: true });
|
|
283
|
+
await this.browser.updateWindow(state.windowId!, { focused: true });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await this.storage.save(nextState);
|
|
287
|
+
const tabs = await this.readTrackedTabs(nextState.tabIds, nextState.windowId);
|
|
288
|
+
const tab = tabs.find((item) => item.id === createdTab.id) ?? createdTab;
|
|
289
|
+
return {
|
|
290
|
+
workspace: {
|
|
291
|
+
...nextState,
|
|
292
|
+
tabs
|
|
293
|
+
},
|
|
294
|
+
tab
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async listTabs(workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ workspace: WorkspaceInfo; tabs: WorkspaceTab[] }> {
|
|
299
|
+
const ensured = await this.ensureWorkspace({ workspaceId });
|
|
300
|
+
return {
|
|
301
|
+
workspace: ensured.workspace,
|
|
302
|
+
tabs: ensured.workspace.tabs
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async getActiveTab(workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab | null }> {
|
|
307
|
+
const ensured = await this.ensureWorkspace({ workspaceId });
|
|
308
|
+
return {
|
|
309
|
+
workspace: ensured.workspace,
|
|
310
|
+
tab: ensured.workspace.tabs.find((tab) => tab.id === ensured.workspace.activeTabId) ?? null
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async setActiveTab(tabId: number, workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab }> {
|
|
315
|
+
const ensured = await this.ensureWorkspace({ workspaceId });
|
|
316
|
+
if (!ensured.workspace.tabIds.includes(tabId)) {
|
|
317
|
+
throw new Error(`Tab ${tabId} does not belong to workspace ${workspaceId}`);
|
|
318
|
+
}
|
|
319
|
+
const nextState: WorkspaceRecord = {
|
|
320
|
+
id: ensured.workspace.id,
|
|
321
|
+
label: ensured.workspace.label,
|
|
322
|
+
color: ensured.workspace.color,
|
|
323
|
+
windowId: ensured.workspace.windowId,
|
|
324
|
+
groupId: ensured.workspace.groupId,
|
|
325
|
+
tabIds: [...ensured.workspace.tabIds],
|
|
326
|
+
activeTabId: tabId,
|
|
327
|
+
primaryTabId: ensured.workspace.primaryTabId ?? tabId
|
|
328
|
+
};
|
|
329
|
+
await this.storage.save(nextState);
|
|
330
|
+
const tabs = await this.readTrackedTabs(nextState.tabIds, nextState.windowId);
|
|
331
|
+
const tab = tabs.find((item) => item.id === tabId);
|
|
332
|
+
if (!tab) {
|
|
333
|
+
throw new Error(`Tab ${tabId} is missing from workspace ${workspaceId}`);
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
workspace: {
|
|
337
|
+
...nextState,
|
|
338
|
+
tabs
|
|
339
|
+
},
|
|
340
|
+
tab
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async focus(workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ ok: true; workspace: WorkspaceInfo }> {
|
|
345
|
+
const ensured = await this.ensureWorkspace({ workspaceId, focus: false });
|
|
346
|
+
if (ensured.workspace.activeTabId !== null) {
|
|
347
|
+
await this.browser.updateTab(ensured.workspace.activeTabId, { active: true });
|
|
348
|
+
}
|
|
349
|
+
if (ensured.workspace.windowId !== null) {
|
|
350
|
+
await this.browser.updateWindow(ensured.workspace.windowId, { focused: true });
|
|
351
|
+
}
|
|
352
|
+
const refreshed = await this.ensureWorkspace({ workspaceId, focus: false });
|
|
353
|
+
return { ok: true, workspace: refreshed.workspace };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async reset(options: WorkspaceEnsureOptions = {}): Promise<WorkspaceEnsureResult> {
|
|
357
|
+
await this.close(options.workspaceId);
|
|
358
|
+
return this.ensureWorkspace(options);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async close(workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ ok: true }> {
|
|
362
|
+
const state = await this.storage.load();
|
|
363
|
+
if (!state || state.id !== workspaceId) {
|
|
364
|
+
await this.storage.save(null);
|
|
365
|
+
return { ok: true };
|
|
366
|
+
}
|
|
367
|
+
if (state.windowId !== null) {
|
|
368
|
+
const existingWindow = await this.browser.getWindow(state.windowId);
|
|
369
|
+
if (existingWindow) {
|
|
370
|
+
await this.browser.closeWindow(state.windowId);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
await this.storage.save(null);
|
|
374
|
+
return { ok: true };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async resolveTarget(options: WorkspaceResolveTargetOptions = {}): Promise<WorkspaceTargetResolution> {
|
|
378
|
+
if (typeof options.tabId === 'number') {
|
|
379
|
+
const explicitTab = await this.browser.getTab(options.tabId);
|
|
380
|
+
if (!explicitTab) {
|
|
381
|
+
throw new Error(`No tab with id ${options.tabId}`);
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
tab: explicitTab,
|
|
385
|
+
workspace: null,
|
|
386
|
+
resolution: 'explicit-tab',
|
|
387
|
+
createdWorkspace: false,
|
|
388
|
+
repaired: false,
|
|
389
|
+
repairActions: []
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const explicitWorkspaceId = typeof options.workspaceId === 'string' ? this.normalizeWorkspaceId(options.workspaceId) : undefined;
|
|
394
|
+
if (explicitWorkspaceId) {
|
|
395
|
+
const ensured = await this.ensureWorkspace({
|
|
396
|
+
workspaceId: explicitWorkspaceId,
|
|
397
|
+
focus: false
|
|
398
|
+
});
|
|
399
|
+
return this.buildWorkspaceResolution(ensured, 'explicit-workspace');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const existingWorkspace = await this.loadWorkspaceRecord(DEFAULT_WORKSPACE_ID);
|
|
403
|
+
if (existingWorkspace) {
|
|
404
|
+
const ensured = await this.ensureWorkspace({
|
|
405
|
+
workspaceId: existingWorkspace.id,
|
|
406
|
+
focus: false
|
|
407
|
+
});
|
|
408
|
+
return this.buildWorkspaceResolution(ensured, 'default-workspace');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (options.createIfMissing !== true) {
|
|
412
|
+
const activeTab = await this.browser.getActiveTab();
|
|
413
|
+
if (!activeTab) {
|
|
414
|
+
throw new Error('No active tab');
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
tab: activeTab,
|
|
418
|
+
workspace: null,
|
|
419
|
+
resolution: 'browser-active',
|
|
420
|
+
createdWorkspace: false,
|
|
421
|
+
repaired: false,
|
|
422
|
+
repairActions: []
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const ensured = await this.ensureWorkspace({
|
|
427
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
428
|
+
focus: false
|
|
429
|
+
});
|
|
430
|
+
return this.buildWorkspaceResolution(ensured, 'default-workspace');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private normalizeWorkspaceId(workspaceId?: string): string {
|
|
434
|
+
const candidate = workspaceId?.trim();
|
|
435
|
+
if (!candidate) {
|
|
436
|
+
return DEFAULT_WORKSPACE_ID;
|
|
437
|
+
}
|
|
438
|
+
if (candidate !== DEFAULT_WORKSPACE_ID) {
|
|
439
|
+
throw new Error(`Unsupported workspace id: ${candidate}`);
|
|
440
|
+
}
|
|
441
|
+
return candidate;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private normalizeState(state: WorkspaceRecord | null, workspaceId: string): WorkspaceRecord {
|
|
445
|
+
return {
|
|
446
|
+
id: workspaceId,
|
|
447
|
+
label: state?.label ?? DEFAULT_WORKSPACE_LABEL,
|
|
448
|
+
color: state?.color ?? DEFAULT_WORKSPACE_COLOR,
|
|
449
|
+
windowId: state?.windowId ?? null,
|
|
450
|
+
groupId: state?.groupId ?? null,
|
|
451
|
+
tabIds: state?.tabIds ?? [],
|
|
452
|
+
activeTabId: state?.activeTabId ?? null,
|
|
453
|
+
primaryTabId: state?.primaryTabId ?? null
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async loadWorkspaceRecord(workspaceId = DEFAULT_WORKSPACE_ID): Promise<WorkspaceRecord | null> {
|
|
458
|
+
const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
|
|
459
|
+
const state = await this.storage.load();
|
|
460
|
+
if (!state || state.id !== normalizedWorkspaceId) {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
return this.normalizeState(state, normalizedWorkspaceId);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async buildWorkspaceResolution(
|
|
467
|
+
ensured: WorkspaceEnsureResult,
|
|
468
|
+
resolution: 'explicit-workspace' | 'default-workspace'
|
|
469
|
+
): Promise<WorkspaceTargetResolution> {
|
|
470
|
+
const tab = ensured.workspace.tabs.find((item) => item.id === ensured.workspace.activeTabId) ?? ensured.workspace.tabs[0] ?? null;
|
|
471
|
+
if (tab) {
|
|
472
|
+
return {
|
|
473
|
+
tab,
|
|
474
|
+
workspace: ensured.workspace,
|
|
475
|
+
resolution,
|
|
476
|
+
createdWorkspace: ensured.created,
|
|
477
|
+
repaired: ensured.repaired,
|
|
478
|
+
repairActions: ensured.repairActions
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (ensured.workspace.activeTabId !== null) {
|
|
483
|
+
const activeWorkspaceTab = await this.waitForTrackedTab(ensured.workspace.activeTabId, ensured.workspace.windowId);
|
|
484
|
+
if (activeWorkspaceTab) {
|
|
485
|
+
return {
|
|
486
|
+
tab: activeWorkspaceTab,
|
|
487
|
+
workspace: ensured.workspace,
|
|
488
|
+
resolution,
|
|
489
|
+
createdWorkspace: ensured.created,
|
|
490
|
+
repaired: ensured.repaired,
|
|
491
|
+
repairActions: ensured.repairActions
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const activeTab = await this.browser.getActiveTab();
|
|
497
|
+
if (!activeTab) {
|
|
498
|
+
throw new Error('No active tab');
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
tab: activeTab,
|
|
502
|
+
workspace: null,
|
|
503
|
+
resolution: 'browser-active',
|
|
504
|
+
createdWorkspace: ensured.created,
|
|
505
|
+
repaired: ensured.repaired,
|
|
506
|
+
repairActions: ensured.repairActions
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private async readTrackedTabs(tabIds: number[], windowId: number | null): Promise<WorkspaceTab[]> {
|
|
511
|
+
const tabs = (
|
|
512
|
+
await Promise.all(
|
|
513
|
+
tabIds.map(async (tabId) => {
|
|
514
|
+
const tab = await this.browser.getTab(tabId);
|
|
515
|
+
if (!tab) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
if (windowId !== null && tab.windowId !== windowId) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
return tab;
|
|
522
|
+
})
|
|
523
|
+
)
|
|
524
|
+
).filter((tab): tab is WorkspaceTab => tab !== null);
|
|
525
|
+
return tabs;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private async recoverWorkspaceTabs(state: WorkspaceRecord, existingTabs: WorkspaceTab[]): Promise<WorkspaceTab[]> {
|
|
529
|
+
if (state.windowId === null) {
|
|
530
|
+
return existingTabs;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const candidates = await this.waitForWindowTabs(state.windowId, 500);
|
|
534
|
+
if (candidates.length === 0) {
|
|
535
|
+
return existingTabs;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const trackedIds = new Set(state.tabIds);
|
|
539
|
+
const trackedTabs = candidates.filter((tab) => trackedIds.has(tab.id));
|
|
540
|
+
if (trackedTabs.length > existingTabs.length) {
|
|
541
|
+
return trackedTabs;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (state.groupId !== null) {
|
|
545
|
+
const groupedTabs = candidates.filter((tab) => tab.groupId === state.groupId);
|
|
546
|
+
if (groupedTabs.length > 0) {
|
|
547
|
+
return groupedTabs;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const preferredIds = new Set([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number'));
|
|
552
|
+
const preferredTabs = candidates.filter((tab) => preferredIds.has(tab.id));
|
|
553
|
+
if (preferredTabs.length > existingTabs.length) {
|
|
554
|
+
return preferredTabs;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return existingTabs;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private async createWorkspaceTab(options: { windowId: number | null; url: string; active: boolean }): Promise<WorkspaceTab> {
|
|
561
|
+
if (options.windowId === null) {
|
|
562
|
+
throw new Error('Workspace window is unavailable');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const deadline = Date.now() + 1_500;
|
|
566
|
+
let lastError: Error | null = null;
|
|
567
|
+
|
|
568
|
+
while (Date.now() < deadline) {
|
|
569
|
+
const window = await this.browser.getWindow(options.windowId);
|
|
570
|
+
if (!window) {
|
|
571
|
+
lastError = new Error(`No window with id: ${options.windowId}.`);
|
|
572
|
+
await this.delay(50);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
return await this.browser.createTab({
|
|
578
|
+
windowId: options.windowId,
|
|
579
|
+
url: options.url,
|
|
580
|
+
active: options.active
|
|
581
|
+
});
|
|
582
|
+
} catch (error) {
|
|
583
|
+
if (!this.isMissingWindowError(error)) {
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
587
|
+
await this.delay(50);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
throw lastError ?? new Error(`No window with id: ${options.windowId}.`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private async waitForTrackedTab(tabId: number, windowId: number | null, timeoutMs = 1_000): Promise<WorkspaceTab | null> {
|
|
595
|
+
const deadline = Date.now() + timeoutMs;
|
|
596
|
+
while (Date.now() < deadline) {
|
|
597
|
+
const tab = await this.browser.getTab(tabId);
|
|
598
|
+
if (tab && (windowId === null || tab.windowId === windowId)) {
|
|
599
|
+
return tab;
|
|
600
|
+
}
|
|
601
|
+
await this.delay(50);
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async waitForWindowTabs(windowId: number, timeoutMs = 1_000): Promise<WorkspaceTab[]> {
|
|
607
|
+
const deadline = Date.now() + timeoutMs;
|
|
608
|
+
while (Date.now() < deadline) {
|
|
609
|
+
const tabs = await this.browser.listTabs({ windowId });
|
|
610
|
+
if (tabs.length > 0) {
|
|
611
|
+
return tabs;
|
|
612
|
+
}
|
|
613
|
+
await this.delay(50);
|
|
614
|
+
}
|
|
615
|
+
return [];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private async delay(ms: number): Promise<void> {
|
|
619
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private isMissingWindowError(error: unknown): boolean {
|
|
623
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
624
|
+
return message.toLowerCase().includes('no window with id');
|
|
625
|
+
}
|
|
626
|
+
}
|