@shawnowen/comet-mcp 2.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/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/cdp-client.d.ts +118 -0
- package/dist/cdp-client.d.ts.map +1 -0
- package/dist/cdp-client.js +867 -0
- package/dist/cdp-client.js.map +1 -0
- package/dist/comet-ai.d.ts +35 -0
- package/dist/comet-ai.d.ts.map +1 -0
- package/dist/comet-ai.js +396 -0
- package/dist/comet-ai.js.map +1 -0
- package/dist/http-server.d.ts +3 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +463 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1174 -0
- package/dist/index.js.map +1 -0
- package/dist/tab-group-archive.d.ts +13 -0
- package/dist/tab-group-archive.d.ts.map +1 -0
- package/dist/tab-group-archive.js +128 -0
- package/dist/tab-group-archive.js.map +1 -0
- package/dist/tab-groups.d.ts +86 -0
- package/dist/tab-groups.d.ts.map +1 -0
- package/dist/tab-groups.js +250 -0
- package/dist/tab-groups.js.map +1 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/extension/background.js +1094 -0
- package/extension/manifest.json +22 -0
- package/extension/sidepanel.css +1068 -0
- package/extension/sidepanel.html +145 -0
- package/extension/sidepanel.js +1186 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
// ─── Comet Session Manager — Side Panel ───────────────────────────────────────
|
|
2
|
+
// T008: Base module structure with message helper, DOM references,
|
|
3
|
+
// section toggle logic, and initialization entry point.
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
// ─── Message Helper ───────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Send a typed message to background.js and return response data.
|
|
11
|
+
* Follows contract: { type, payload } → { ok, data, error }
|
|
12
|
+
* @param {string} type - Message type (e.g., 'getGroups')
|
|
13
|
+
* @param {object} [payload={}] - Optional payload
|
|
14
|
+
* @returns {Promise<any>} Response data
|
|
15
|
+
*/
|
|
16
|
+
async function sendMessage(type, payload = {}) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
chrome.runtime.sendMessage({ type, payload }, (response) => {
|
|
19
|
+
if (chrome.runtime.lastError) {
|
|
20
|
+
reject(new Error(chrome.runtime.lastError.message));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!response) {
|
|
24
|
+
reject(new Error(`No response for message type "${type}"`));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (response.ok) {
|
|
28
|
+
resolve(response.data);
|
|
29
|
+
} else {
|
|
30
|
+
reject(new Error(response.error || `Handler error for "${type}"`));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── DOM References ───────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const $ = (sel) => document.querySelector(sel);
|
|
39
|
+
const $$ = (sel) => document.querySelectorAll(sel);
|
|
40
|
+
|
|
41
|
+
const DOM = {
|
|
42
|
+
// Search
|
|
43
|
+
searchInput: $('#search-input'),
|
|
44
|
+
searchClear: $('#search-clear'),
|
|
45
|
+
|
|
46
|
+
// Sections
|
|
47
|
+
sectionHeaders: $$('.section-header'),
|
|
48
|
+
liveContent: $('#live-content'),
|
|
49
|
+
archivedContent: $('#archived-content'),
|
|
50
|
+
recentContent: $('#recent-content'),
|
|
51
|
+
liveCount: $('#live-count'),
|
|
52
|
+
archivedCount: $('#archived-count'),
|
|
53
|
+
recentCount: $('#recent-count'),
|
|
54
|
+
|
|
55
|
+
// Empty states
|
|
56
|
+
liveEmpty: $('#live-empty'),
|
|
57
|
+
archivedEmpty: $('#archived-empty'),
|
|
58
|
+
recentEmpty: $('#recent-empty'),
|
|
59
|
+
|
|
60
|
+
// Status bar
|
|
61
|
+
statusDot: $('#status-dot'),
|
|
62
|
+
statusText: $('#status-text'),
|
|
63
|
+
btnReconnect: $('#btn-reconnect'),
|
|
64
|
+
|
|
65
|
+
// Toolbar
|
|
66
|
+
btnSaveAll: $('#btn-save-all'),
|
|
67
|
+
btnImport: $('#btn-import'),
|
|
68
|
+
btnExport: $('#btn-export'),
|
|
69
|
+
|
|
70
|
+
// Import modal
|
|
71
|
+
importOverlay: $('#import-overlay'),
|
|
72
|
+
importTextarea: $('#import-textarea'),
|
|
73
|
+
importPreview: $('#import-preview'),
|
|
74
|
+
importDuplicates: $('#import-duplicates'),
|
|
75
|
+
importDuplicateText: $('#import-duplicate-text'),
|
|
76
|
+
importCancel: $('#import-cancel'),
|
|
77
|
+
importConfirm: $('#import-confirm'),
|
|
78
|
+
importClose: $('#import-close'),
|
|
79
|
+
|
|
80
|
+
// Container
|
|
81
|
+
sectionsContainer: $('#sections-container'),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const state = {
|
|
87
|
+
groups: [], // Live TabGroup[]
|
|
88
|
+
tabs: [], // Live Tab[]
|
|
89
|
+
archived: [], // ArchiveEntry[]
|
|
90
|
+
recentlyClosed: [], // Session[]
|
|
91
|
+
expandedGroups: new Set(), // Group IDs currently expanded
|
|
92
|
+
expandedSections: new Set(['live']), // Section names currently expanded
|
|
93
|
+
searchQuery: '',
|
|
94
|
+
cdpConnected: false,
|
|
95
|
+
refreshTimer: null,
|
|
96
|
+
healthTimer: null,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ─── Utility Helpers ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Format a timestamp to a relative time string (e.g., "2 min ago")
|
|
103
|
+
*/
|
|
104
|
+
function timeAgo(ts) {
|
|
105
|
+
const seconds = Math.floor((Date.now() - ts) / 1000);
|
|
106
|
+
if (seconds < 60) return 'just now';
|
|
107
|
+
const minutes = Math.floor(seconds / 60);
|
|
108
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
109
|
+
const hours = Math.floor(minutes / 60);
|
|
110
|
+
if (hours < 24) return `${hours}h ago`;
|
|
111
|
+
const days = Math.floor(hours / 24);
|
|
112
|
+
return `${days}d ago`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract hostname from a URL for display
|
|
117
|
+
*/
|
|
118
|
+
function hostname(url) {
|
|
119
|
+
try {
|
|
120
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
121
|
+
} catch {
|
|
122
|
+
return url;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Escape HTML to prevent XSS in dynamic content
|
|
128
|
+
*/
|
|
129
|
+
function escapeHtml(str) {
|
|
130
|
+
const div = document.createElement('div');
|
|
131
|
+
div.textContent = str;
|
|
132
|
+
return div.innerHTML;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create an element with attributes and children
|
|
137
|
+
*/
|
|
138
|
+
function el(tag, attrs = {}, ...children) {
|
|
139
|
+
const elem = document.createElement(tag);
|
|
140
|
+
for (const [key, val] of Object.entries(attrs)) {
|
|
141
|
+
if (key === 'className') elem.className = val;
|
|
142
|
+
else if (key === 'dataset') Object.assign(elem.dataset, val);
|
|
143
|
+
else if (key.startsWith('on')) elem.addEventListener(key.slice(2).toLowerCase(), val);
|
|
144
|
+
else elem.setAttribute(key, val);
|
|
145
|
+
}
|
|
146
|
+
for (const child of children) {
|
|
147
|
+
if (typeof child === 'string') elem.appendChild(document.createTextNode(child));
|
|
148
|
+
else if (child) elem.appendChild(child);
|
|
149
|
+
}
|
|
150
|
+
return elem;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Section Toggle Logic ─────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function initSectionToggles() {
|
|
156
|
+
DOM.sectionHeaders.forEach((header) => {
|
|
157
|
+
const sectionName = header.dataset.section;
|
|
158
|
+
const content = $(`#${sectionName}-content`);
|
|
159
|
+
if (!content) return;
|
|
160
|
+
|
|
161
|
+
// Set initial state
|
|
162
|
+
if (state.expandedSections.has(sectionName)) {
|
|
163
|
+
header.classList.add('expanded');
|
|
164
|
+
content.classList.remove('collapsed');
|
|
165
|
+
} else {
|
|
166
|
+
header.classList.remove('expanded');
|
|
167
|
+
content.classList.add('collapsed');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
header.addEventListener('click', () => {
|
|
171
|
+
const isExpanded = state.expandedSections.has(sectionName);
|
|
172
|
+
if (isExpanded) {
|
|
173
|
+
state.expandedSections.delete(sectionName);
|
|
174
|
+
header.classList.remove('expanded');
|
|
175
|
+
content.classList.add('collapsed');
|
|
176
|
+
} else {
|
|
177
|
+
state.expandedSections.add(sectionName);
|
|
178
|
+
header.classList.add('expanded');
|
|
179
|
+
content.classList.remove('collapsed');
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Search Logic (T024-T026) ─────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
function initSearch() {
|
|
188
|
+
let debounceTimer = null;
|
|
189
|
+
|
|
190
|
+
DOM.searchInput.addEventListener('input', () => {
|
|
191
|
+
clearTimeout(debounceTimer);
|
|
192
|
+
const query = DOM.searchInput.value.trim();
|
|
193
|
+
|
|
194
|
+
DOM.searchClear.classList.toggle('hidden', query.length === 0);
|
|
195
|
+
|
|
196
|
+
debounceTimer = setTimeout(() => {
|
|
197
|
+
state.searchQuery = query.toLowerCase();
|
|
198
|
+
applySearchFilter();
|
|
199
|
+
}, 150);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
DOM.searchClear.addEventListener('click', () => {
|
|
203
|
+
DOM.searchInput.value = '';
|
|
204
|
+
DOM.searchClear.classList.add('hidden');
|
|
205
|
+
state.searchQuery = '';
|
|
206
|
+
applySearchFilter();
|
|
207
|
+
DOM.searchInput.focus();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function applySearchFilter() {
|
|
212
|
+
if (!state.searchQuery) {
|
|
213
|
+
// Show all items
|
|
214
|
+
document.querySelectorAll('.group-node, .archive-item, .recent-item, .tab-item, .window-header')
|
|
215
|
+
.forEach(el => el.style.display = '');
|
|
216
|
+
const noResults = document.querySelector('.no-results');
|
|
217
|
+
if (noResults) noResults.remove();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Basic search implementation — filter visible items
|
|
222
|
+
const query = state.searchQuery;
|
|
223
|
+
let matchCount = 0;
|
|
224
|
+
|
|
225
|
+
// Filter live groups and tabs
|
|
226
|
+
document.querySelectorAll('.group-node').forEach(node => {
|
|
227
|
+
const title = (node.dataset.title || '').toLowerCase();
|
|
228
|
+
const hasMatch = title.includes(query);
|
|
229
|
+
// Check child tabs
|
|
230
|
+
let childMatch = false;
|
|
231
|
+
node.querySelectorAll('.tab-item').forEach(tab => {
|
|
232
|
+
const tabTitle = (tab.dataset.title || '').toLowerCase();
|
|
233
|
+
const tabUrl = (tab.dataset.url || '').toLowerCase();
|
|
234
|
+
const tabMatch = tabTitle.includes(query) || tabUrl.includes(query);
|
|
235
|
+
tab.style.display = tabMatch ? '' : 'none';
|
|
236
|
+
if (tabMatch) childMatch = true;
|
|
237
|
+
});
|
|
238
|
+
const visible = hasMatch || childMatch;
|
|
239
|
+
node.style.display = visible ? '' : 'none';
|
|
240
|
+
if (visible) matchCount++;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Filter archived items
|
|
244
|
+
document.querySelectorAll('.archive-item').forEach(item => {
|
|
245
|
+
const title = (item.dataset.title || '').toLowerCase();
|
|
246
|
+
const visible = title.includes(query);
|
|
247
|
+
item.style.display = visible ? '' : 'none';
|
|
248
|
+
if (visible) matchCount++;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Filter recently closed items
|
|
252
|
+
document.querySelectorAll('.recent-item').forEach(item => {
|
|
253
|
+
const title = (item.dataset.title || '').toLowerCase();
|
|
254
|
+
const url = (item.dataset.url || '').toLowerCase();
|
|
255
|
+
const visible = title.includes(query) || url.includes(query);
|
|
256
|
+
item.style.display = visible ? '' : 'none';
|
|
257
|
+
if (visible) matchCount++;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Show/hide no results
|
|
261
|
+
let noResults = document.querySelector('.no-results');
|
|
262
|
+
if (matchCount === 0 && !noResults) {
|
|
263
|
+
noResults = el('div', { className: 'no-results' },
|
|
264
|
+
el('span', { className: 'no-results-icon' }, '🔍'),
|
|
265
|
+
el('span', {}, 'No results found')
|
|
266
|
+
);
|
|
267
|
+
DOM.sectionsContainer.appendChild(noResults);
|
|
268
|
+
} else if (matchCount > 0 && noResults) {
|
|
269
|
+
noResults.remove();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Connection Status (T012) ─────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
async function checkHealth() {
|
|
276
|
+
try {
|
|
277
|
+
const result = await sendMessage('healthCheck');
|
|
278
|
+
state.cdpConnected = result.cdpConnected;
|
|
279
|
+
updateStatusUI(true, result.version);
|
|
280
|
+
} catch {
|
|
281
|
+
state.cdpConnected = false;
|
|
282
|
+
updateStatusUI(false);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function updateStatusUI(connected, version = '') {
|
|
287
|
+
if (connected) {
|
|
288
|
+
DOM.statusDot.className = 'status-dot connected';
|
|
289
|
+
DOM.statusText.textContent = `Connected v${version}`;
|
|
290
|
+
DOM.btnReconnect.classList.add('hidden');
|
|
291
|
+
} else {
|
|
292
|
+
DOM.statusDot.className = 'status-dot disconnected';
|
|
293
|
+
DOM.statusText.textContent = 'Disconnected';
|
|
294
|
+
DOM.btnReconnect.classList.remove('hidden');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Tree Rendering (T009) ───────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
async function fetchAndRenderLiveTree() {
|
|
301
|
+
try {
|
|
302
|
+
const [groups, tabs] = await Promise.all([
|
|
303
|
+
sendMessage('getGroups'),
|
|
304
|
+
sendMessage('getTabs'),
|
|
305
|
+
]);
|
|
306
|
+
state.groups = groups;
|
|
307
|
+
state.tabs = tabs;
|
|
308
|
+
renderLiveTree(groups, tabs);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error('[Comet] Failed to fetch live tree:', err);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function renderLiveTree(groups, tabs) {
|
|
315
|
+
// Group tabs by windowId → groupId
|
|
316
|
+
const windowMap = new Map(); // windowId → { groups: Map<groupId, group>, tabs: Map<groupId, tab[]> }
|
|
317
|
+
|
|
318
|
+
for (const group of groups) {
|
|
319
|
+
if (!windowMap.has(group.windowId)) {
|
|
320
|
+
windowMap.set(group.windowId, { groups: new Map(), ungroupedTabs: [] });
|
|
321
|
+
}
|
|
322
|
+
windowMap.get(group.windowId).groups.set(group.id, group);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const tab of tabs) {
|
|
326
|
+
if (!windowMap.has(tab.windowId)) {
|
|
327
|
+
windowMap.set(tab.windowId, { groups: new Map(), ungroupedTabs: [] });
|
|
328
|
+
}
|
|
329
|
+
const win = windowMap.get(tab.windowId);
|
|
330
|
+
if (tab.groupId === -1) {
|
|
331
|
+
// Track ungrouped tabs per window
|
|
332
|
+
win.ungroupedTabs.push(tab);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (!win.groups.has(tab.groupId)) continue; // skip tabs without known group
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Build DOM
|
|
339
|
+
const container = DOM.liveContent;
|
|
340
|
+
const existingEmpty = DOM.liveEmpty;
|
|
341
|
+
|
|
342
|
+
// Clear existing tree (preserve empty state element)
|
|
343
|
+
const children = Array.from(container.children);
|
|
344
|
+
children.forEach(child => {
|
|
345
|
+
if (child !== existingEmpty) child.remove();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
let totalGroups = 0;
|
|
349
|
+
const sortedWindows = [...windowMap.entries()].sort((a, b) => a[0] - b[0]);
|
|
350
|
+
|
|
351
|
+
for (const [windowId, winData] of sortedWindows) {
|
|
352
|
+
if (winData.groups.size === 0) continue;
|
|
353
|
+
|
|
354
|
+
// Window header
|
|
355
|
+
if (sortedWindows.length > 1) {
|
|
356
|
+
container.appendChild(el('div', { className: 'window-header' }, `Window ${windowId}`));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Groups in this window
|
|
360
|
+
for (const [groupId, group] of winData.groups) {
|
|
361
|
+
totalGroups++;
|
|
362
|
+
const groupTabs = tabs.filter(t => t.groupId === groupId);
|
|
363
|
+
const isExpanded = state.expandedGroups.has(String(groupId));
|
|
364
|
+
|
|
365
|
+
const groupNode = createGroupNode(group, groupTabs, isExpanded);
|
|
366
|
+
container.appendChild(groupNode);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Ungrouped tabs section per window (T009 edge case)
|
|
370
|
+
if (winData.ungroupedTabs.length > 0) {
|
|
371
|
+
const ungroupedId = `ungrouped-${windowId}`;
|
|
372
|
+
const isExpanded = state.expandedGroups.has(ungroupedId);
|
|
373
|
+
const ungroupedGroup = {
|
|
374
|
+
id: ungroupedId,
|
|
375
|
+
title: 'Ungrouped',
|
|
376
|
+
color: 'grey',
|
|
377
|
+
collapsed: !isExpanded,
|
|
378
|
+
windowId,
|
|
379
|
+
};
|
|
380
|
+
const ungroupedNode = createGroupNode(ungroupedGroup, winData.ungroupedTabs, isExpanded);
|
|
381
|
+
container.appendChild(ungroupedNode);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Update count and empty state
|
|
386
|
+
DOM.liveCount.textContent = String(totalGroups);
|
|
387
|
+
existingEmpty.classList.toggle('hidden', totalGroups > 0);
|
|
388
|
+
|
|
389
|
+
// Re-apply search filter if active
|
|
390
|
+
if (state.searchQuery) applySearchFilter();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function createGroupNode(group, groupTabs, isExpanded) {
|
|
394
|
+
const node = el('div', {
|
|
395
|
+
className: 'group-node',
|
|
396
|
+
dataset: { groupId: String(group.id), title: group.title }
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Group header
|
|
400
|
+
const header = el('div', {
|
|
401
|
+
className: `group-header${isExpanded ? ' expanded' : ''}`,
|
|
402
|
+
onClick: (e) => {
|
|
403
|
+
if (e.target.closest('.action-btn')) return;
|
|
404
|
+
toggleGroupExpand(group.id, header, tabList);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
el('span', { className: 'group-chevron' }, '▸'),
|
|
408
|
+
el('span', { className: `group-color-badge badge-${group.color}` }),
|
|
409
|
+
el('span', { className: 'group-title', title: group.title }, group.title || 'Untitled'),
|
|
410
|
+
el('span', { className: 'group-tab-count' }, `${groupTabs.length}`),
|
|
411
|
+
el('div', { className: 'group-actions' },
|
|
412
|
+
el('button', {
|
|
413
|
+
className: 'action-btn archive-btn',
|
|
414
|
+
title: 'Archive group',
|
|
415
|
+
onClick: () => archiveGroupAction(group.id, group.title)
|
|
416
|
+
}, '📦'),
|
|
417
|
+
el('button', {
|
|
418
|
+
className: 'action-btn close-btn',
|
|
419
|
+
title: 'Close group',
|
|
420
|
+
onClick: () => closeGroupAction(group.id)
|
|
421
|
+
}, '×')
|
|
422
|
+
)
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
// Tab list
|
|
426
|
+
const tabList = el('div', {
|
|
427
|
+
className: `tab-list${isExpanded ? '' : ' collapsed'}`
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
for (const tab of groupTabs) {
|
|
431
|
+
tabList.appendChild(createTabItem(tab));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
node.appendChild(header);
|
|
435
|
+
node.appendChild(tabList);
|
|
436
|
+
return node;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function createTabItem(tab) {
|
|
440
|
+
const favicon = tab.favIconUrl
|
|
441
|
+
? el('img', { className: 'tab-favicon', src: tab.favIconUrl, alt: '' })
|
|
442
|
+
: el('span', { className: 'tab-favicon-placeholder' }, '●');
|
|
443
|
+
|
|
444
|
+
// Handle favicon load errors
|
|
445
|
+
if (favicon.tagName === 'IMG') {
|
|
446
|
+
favicon.onerror = () => {
|
|
447
|
+
const placeholder = el('span', { className: 'tab-favicon-placeholder' }, '●');
|
|
448
|
+
favicon.replaceWith(placeholder);
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return el('div', {
|
|
453
|
+
className: `tab-item${tab.active ? ' active' : ''}`,
|
|
454
|
+
dataset: { tabId: String(tab.id), title: tab.title, url: tab.url },
|
|
455
|
+
onClick: (e) => {
|
|
456
|
+
if (e.target.closest('.tab-close')) return;
|
|
457
|
+
focusTabAction(tab.id, tab.windowId);
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
favicon,
|
|
461
|
+
el('span', { className: 'tab-title', title: tab.title }, tab.title || 'Untitled'),
|
|
462
|
+
el('span', { className: 'tab-url' }, hostname(tab.url)),
|
|
463
|
+
el('button', {
|
|
464
|
+
className: 'tab-close',
|
|
465
|
+
title: 'Close tab',
|
|
466
|
+
onClick: () => closeTabAction(tab.id)
|
|
467
|
+
}, '×')
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function toggleGroupExpand(groupId, header, tabList) {
|
|
472
|
+
const key = String(groupId);
|
|
473
|
+
const isExpanded = state.expandedGroups.has(key);
|
|
474
|
+
|
|
475
|
+
if (isExpanded) {
|
|
476
|
+
state.expandedGroups.delete(key);
|
|
477
|
+
header.classList.remove('expanded');
|
|
478
|
+
tabList.classList.add('collapsed');
|
|
479
|
+
} else {
|
|
480
|
+
state.expandedGroups.add(key);
|
|
481
|
+
header.classList.add('expanded');
|
|
482
|
+
tabList.classList.remove('collapsed');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Persist expanded state
|
|
486
|
+
chrome.storage.local.set({ expandedGroups: [...state.expandedGroups] });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ─── Archived Sessions Rendering (T018) ──────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
async function fetchAndRenderArchived() {
|
|
492
|
+
try {
|
|
493
|
+
const archived = await sendMessage('getArchivedGroups');
|
|
494
|
+
state.archived = archived || [];
|
|
495
|
+
renderArchived(state.archived);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
console.error('[Comet] Failed to fetch archived groups:', err);
|
|
498
|
+
state.archived = [];
|
|
499
|
+
renderArchived([]);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function renderArchived(entries) {
|
|
504
|
+
const container = DOM.archivedContent;
|
|
505
|
+
const existingEmpty = DOM.archivedEmpty;
|
|
506
|
+
|
|
507
|
+
// Clear existing
|
|
508
|
+
Array.from(container.children).forEach(child => {
|
|
509
|
+
if (child !== existingEmpty) child.remove();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
DOM.archivedCount.textContent = String(entries.length);
|
|
513
|
+
existingEmpty.classList.toggle('hidden', entries.length > 0);
|
|
514
|
+
|
|
515
|
+
for (const entry of entries) {
|
|
516
|
+
const archiveKey = `archive-${entry.taskThreadId}`;
|
|
517
|
+
const isExpanded = state.expandedGroups.has(archiveKey);
|
|
518
|
+
const urls = entry.urls || [];
|
|
519
|
+
|
|
520
|
+
const archiveNode = el('div', {
|
|
521
|
+
className: 'group-node archive-node',
|
|
522
|
+
dataset: { threadId: entry.taskThreadId, title: entry.title }
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Expandable tab list showing individual URLs
|
|
526
|
+
const tabList = el('div', {
|
|
527
|
+
className: `tab-list${isExpanded ? '' : ' collapsed'}`
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
for (const tabEntry of urls) {
|
|
531
|
+
tabList.appendChild(el('div', {
|
|
532
|
+
className: 'tab-item archive-tab-item',
|
|
533
|
+
dataset: { title: tabEntry.title || '', url: tabEntry.url || '' },
|
|
534
|
+
onClick: () => restoreSingleTabAction(entry.taskThreadId, tabEntry.url)
|
|
535
|
+
},
|
|
536
|
+
el('span', { className: 'tab-favicon-placeholder' }, '🔗'),
|
|
537
|
+
el('span', { className: 'tab-title', title: tabEntry.title || tabEntry.url }, tabEntry.title || hostname(tabEntry.url)),
|
|
538
|
+
el('span', { className: 'tab-url' }, hostname(tabEntry.url || ''))
|
|
539
|
+
));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Archive group header — clickable to expand/collapse
|
|
543
|
+
const header = el('div', {
|
|
544
|
+
className: `group-header archive-item${isExpanded ? ' expanded' : ''}`,
|
|
545
|
+
onClick: (e) => {
|
|
546
|
+
if (e.target.closest('.action-btn') || e.target.closest('.rename-input')) return;
|
|
547
|
+
const nowExpanded = state.expandedGroups.has(archiveKey);
|
|
548
|
+
if (nowExpanded) {
|
|
549
|
+
state.expandedGroups.delete(archiveKey);
|
|
550
|
+
header.classList.remove('expanded');
|
|
551
|
+
tabList.classList.add('collapsed');
|
|
552
|
+
} else {
|
|
553
|
+
state.expandedGroups.add(archiveKey);
|
|
554
|
+
header.classList.add('expanded');
|
|
555
|
+
tabList.classList.remove('collapsed');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
el('span', { className: 'group-chevron' }, '▸'),
|
|
560
|
+
el('span', { className: `group-color-badge badge-${entry.color || 'grey'}` }),
|
|
561
|
+
el('span', {
|
|
562
|
+
className: 'group-title archive-title',
|
|
563
|
+
title: 'Double-click to rename',
|
|
564
|
+
onDblclick: (e) => startRename(e.target, entry.taskThreadId)
|
|
565
|
+
}, entry.title || 'Untitled'),
|
|
566
|
+
el('span', { className: 'group-tab-count' }, `${urls.length}`),
|
|
567
|
+
el('span', { className: 'archive-timestamp' }, entry.archivedAt ? timeAgo(new Date(entry.archivedAt).getTime()) : ''),
|
|
568
|
+
el('div', { className: 'group-actions' },
|
|
569
|
+
el('button', {
|
|
570
|
+
className: 'action-btn restore-btn',
|
|
571
|
+
title: 'Restore all tabs',
|
|
572
|
+
onClick: () => restoreGroupAction(entry.taskThreadId)
|
|
573
|
+
}, '↩'),
|
|
574
|
+
el('button', {
|
|
575
|
+
className: 'action-btn rename-btn',
|
|
576
|
+
title: 'Rename group',
|
|
577
|
+
onClick: (e) => {
|
|
578
|
+
const titleEl = header.querySelector('.archive-title');
|
|
579
|
+
if (titleEl) startRename(titleEl, entry.taskThreadId);
|
|
580
|
+
}
|
|
581
|
+
}, '✏️'),
|
|
582
|
+
el('button', {
|
|
583
|
+
className: 'action-btn delete-btn',
|
|
584
|
+
title: 'Delete archive',
|
|
585
|
+
onClick: () => deleteArchivedAction(entry.taskThreadId)
|
|
586
|
+
}, '🗑')
|
|
587
|
+
)
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
archiveNode.appendChild(header);
|
|
591
|
+
archiveNode.appendChild(tabList);
|
|
592
|
+
container.appendChild(archiveNode);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (state.searchQuery) applySearchFilter();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─── Recently Closed Rendering (T022) ────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
async function fetchAndRenderRecent() {
|
|
601
|
+
try {
|
|
602
|
+
const sessions = await sendMessage('getRecentlyClosed', { maxResults: 25 });
|
|
603
|
+
state.recentlyClosed = sessions || [];
|
|
604
|
+
renderRecentlyClosed(state.recentlyClosed);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
console.error('[Comet] Failed to fetch recently closed:', err);
|
|
607
|
+
state.recentlyClosed = [];
|
|
608
|
+
renderRecentlyClosed([]);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function renderRecentlyClosed(sessions) {
|
|
613
|
+
const container = DOM.recentContent;
|
|
614
|
+
const existingEmpty = DOM.recentEmpty;
|
|
615
|
+
|
|
616
|
+
// Clear existing
|
|
617
|
+
Array.from(container.children).forEach(child => {
|
|
618
|
+
if (child !== existingEmpty) child.remove();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
DOM.recentCount.textContent = String(sessions.length);
|
|
622
|
+
existingEmpty.classList.toggle('hidden', sessions.length > 0);
|
|
623
|
+
|
|
624
|
+
for (const session of sessions) {
|
|
625
|
+
if (session.tab) {
|
|
626
|
+
const tab = session.tab;
|
|
627
|
+
const item = el('div', {
|
|
628
|
+
className: 'recent-item',
|
|
629
|
+
dataset: { sessionId: session.sessionId || '', title: tab.title || '', url: tab.url || '' }
|
|
630
|
+
},
|
|
631
|
+
el('span', { className: 'recent-type-icon' }, '📄'),
|
|
632
|
+
el('span', { className: 'tab-title', title: tab.title }, tab.title || 'Untitled'),
|
|
633
|
+
el('span', { className: 'tab-url' }, hostname(tab.url || '')),
|
|
634
|
+
el('span', { className: 'recent-timestamp' }, session.lastModified ? timeAgo(session.lastModified * 1000) : ''),
|
|
635
|
+
el('div', { className: 'group-actions' },
|
|
636
|
+
el('button', {
|
|
637
|
+
className: 'action-btn restore-btn',
|
|
638
|
+
title: 'Restore tab',
|
|
639
|
+
onClick: () => restoreClosedAction(session.sessionId)
|
|
640
|
+
}, '↩')
|
|
641
|
+
)
|
|
642
|
+
);
|
|
643
|
+
container.appendChild(item);
|
|
644
|
+
} else if (session.window) {
|
|
645
|
+
// T023: Expandable window entries in Recently Closed
|
|
646
|
+
const win = session.window;
|
|
647
|
+
const winTabs = win.tabs || [];
|
|
648
|
+
const tabCount = winTabs.length;
|
|
649
|
+
const windowKey = `recent-win-${session.sessionId}`;
|
|
650
|
+
const isExpanded = state.expandedGroups.has(windowKey);
|
|
651
|
+
|
|
652
|
+
const windowNode = el('div', {
|
|
653
|
+
className: 'group-node recent-window-node',
|
|
654
|
+
dataset: { sessionId: session.sessionId || '', title: `Window (${tabCount} tabs)` }
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Individual tabs list (expandable)
|
|
658
|
+
const tabList = el('div', {
|
|
659
|
+
className: `tab-list${isExpanded ? '' : ' collapsed'}`
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
for (const wTab of winTabs) {
|
|
663
|
+
tabList.appendChild(el('div', {
|
|
664
|
+
className: 'tab-item',
|
|
665
|
+
dataset: { title: wTab.title || '', url: wTab.url || '' }
|
|
666
|
+
},
|
|
667
|
+
wTab.favIconUrl
|
|
668
|
+
? el('img', { className: 'tab-favicon', src: wTab.favIconUrl, alt: '' })
|
|
669
|
+
: el('span', { className: 'tab-favicon-placeholder' }, '●'),
|
|
670
|
+
el('span', { className: 'tab-title', title: wTab.title }, wTab.title || 'Untitled'),
|
|
671
|
+
el('span', { className: 'tab-url' }, hostname(wTab.url || ''))
|
|
672
|
+
));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Window header (clickable to expand/collapse)
|
|
676
|
+
const header = el('div', {
|
|
677
|
+
className: `group-header recent-item${isExpanded ? ' expanded' : ''}`,
|
|
678
|
+
onClick: (e) => {
|
|
679
|
+
if (e.target.closest('.action-btn')) return;
|
|
680
|
+
const nowExpanded = state.expandedGroups.has(windowKey);
|
|
681
|
+
if (nowExpanded) {
|
|
682
|
+
state.expandedGroups.delete(windowKey);
|
|
683
|
+
header.classList.remove('expanded');
|
|
684
|
+
tabList.classList.add('collapsed');
|
|
685
|
+
} else {
|
|
686
|
+
state.expandedGroups.add(windowKey);
|
|
687
|
+
header.classList.add('expanded');
|
|
688
|
+
tabList.classList.remove('collapsed');
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
el('span', { className: 'group-chevron' }, '▸'),
|
|
693
|
+
el('span', { className: 'recent-type-icon' }, '🪟'),
|
|
694
|
+
el('span', { className: 'tab-title' }, `Window (${tabCount} tabs)`),
|
|
695
|
+
el('span', { className: 'recent-timestamp' }, session.lastModified ? timeAgo(session.lastModified * 1000) : ''),
|
|
696
|
+
el('div', { className: 'group-actions' },
|
|
697
|
+
el('button', {
|
|
698
|
+
className: 'action-btn restore-btn',
|
|
699
|
+
title: 'Restore entire window',
|
|
700
|
+
onClick: () => restoreClosedAction(session.sessionId)
|
|
701
|
+
}, '↩')
|
|
702
|
+
)
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
windowNode.appendChild(header);
|
|
706
|
+
windowNode.appendChild(tabList);
|
|
707
|
+
container.appendChild(windowNode);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (state.searchQuery) applySearchFilter();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ─── Actions (T012, T019, T035) ──────────────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
async function archiveGroupAction(groupId, title) {
|
|
717
|
+
try {
|
|
718
|
+
await sendMessage('archiveGroup', { groupId, title });
|
|
719
|
+
await Promise.all([fetchAndRenderLiveTree(), fetchAndRenderArchived()]);
|
|
720
|
+
} catch (err) {
|
|
721
|
+
console.error('[Comet] Archive failed:', err);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function restoreGroupAction(taskThreadId) {
|
|
726
|
+
try {
|
|
727
|
+
await sendMessage('restoreGroup', { taskThreadId });
|
|
728
|
+
await Promise.all([fetchAndRenderLiveTree(), fetchAndRenderArchived()]);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
console.error('[Comet] Restore failed:', err);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function deleteArchivedAction(taskThreadId) {
|
|
735
|
+
try {
|
|
736
|
+
await sendMessage('deleteArchived', { taskThreadId });
|
|
737
|
+
await fetchAndRenderArchived();
|
|
738
|
+
} catch (err) {
|
|
739
|
+
console.error('[Comet] Delete archived failed:', err);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function focusTabAction(tabId, windowId) {
|
|
744
|
+
try {
|
|
745
|
+
await sendMessage('focusTab', { tabId, windowId });
|
|
746
|
+
} catch (err) {
|
|
747
|
+
console.error('[Comet] Focus tab failed:', err);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function closeTabAction(tabId) {
|
|
752
|
+
try {
|
|
753
|
+
await sendMessage('closeTab', { tabId });
|
|
754
|
+
await fetchAndRenderLiveTree();
|
|
755
|
+
} catch (err) {
|
|
756
|
+
console.error('[Comet] Close tab failed:', err);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function closeGroupAction(groupId) {
|
|
761
|
+
try {
|
|
762
|
+
await sendMessage('closeGroup', { groupId });
|
|
763
|
+
await fetchAndRenderLiveTree();
|
|
764
|
+
} catch (err) {
|
|
765
|
+
console.error('[Comet] Close group failed:', err);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function restoreClosedAction(sessionId) {
|
|
770
|
+
try {
|
|
771
|
+
await sendMessage('restoreClosed', { sessionId });
|
|
772
|
+
await Promise.all([fetchAndRenderLiveTree(), fetchAndRenderRecent()]);
|
|
773
|
+
} catch (err) {
|
|
774
|
+
console.error('[Comet] Restore closed failed:', err);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ─── Save All Action ─────────────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
async function saveAllTabsAction() {
|
|
781
|
+
const btn = DOM.btnSaveAll;
|
|
782
|
+
const originalText = btn.textContent;
|
|
783
|
+
btn.disabled = true;
|
|
784
|
+
btn.textContent = ' Saving…';
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
const result = await sendMessage('saveAllTabs');
|
|
788
|
+
btn.textContent = ` ✓ Saved ${result.totalTabs} tabs`;
|
|
789
|
+
setTimeout(() => {
|
|
790
|
+
btn.textContent = originalText;
|
|
791
|
+
btn.disabled = false;
|
|
792
|
+
}, 2000);
|
|
793
|
+
await Promise.all([fetchAndRenderLiveTree(), fetchAndRenderArchived()]);
|
|
794
|
+
} catch (err) {
|
|
795
|
+
console.error('[Comet] Save all failed:', err);
|
|
796
|
+
btn.textContent = originalText;
|
|
797
|
+
btn.disabled = false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ─── Rename Archive Action ───────────────────────────────────────────────────
|
|
802
|
+
|
|
803
|
+
function startRename(titleEl, taskThreadId) {
|
|
804
|
+
// Replace title span with an input
|
|
805
|
+
const currentTitle = titleEl.textContent;
|
|
806
|
+
const input = el('input', {
|
|
807
|
+
className: 'rename-input',
|
|
808
|
+
type: 'text',
|
|
809
|
+
value: currentTitle,
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
titleEl.style.display = 'none';
|
|
813
|
+
titleEl.parentElement.insertBefore(input, titleEl.nextSibling);
|
|
814
|
+
input.focus();
|
|
815
|
+
input.select();
|
|
816
|
+
|
|
817
|
+
const finishRename = async () => {
|
|
818
|
+
const newTitle = input.value.trim();
|
|
819
|
+
input.remove();
|
|
820
|
+
titleEl.style.display = '';
|
|
821
|
+
|
|
822
|
+
if (newTitle && newTitle !== currentTitle) {
|
|
823
|
+
try {
|
|
824
|
+
await sendMessage('renameArchived', { taskThreadId, newTitle });
|
|
825
|
+
titleEl.textContent = newTitle;
|
|
826
|
+
// Update in state
|
|
827
|
+
const entry = state.archived.find(e => e.taskThreadId === taskThreadId);
|
|
828
|
+
if (entry) entry.title = newTitle;
|
|
829
|
+
} catch (err) {
|
|
830
|
+
console.error('[Comet] Rename failed:', err);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
input.addEventListener('blur', finishRename);
|
|
836
|
+
input.addEventListener('keydown', (e) => {
|
|
837
|
+
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
|
838
|
+
if (e.key === 'Escape') { input.value = currentTitle; input.blur(); }
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ─── Single Tab Restore ──────────────────────────────────────────────────────
|
|
843
|
+
|
|
844
|
+
async function restoreSingleTabAction(taskThreadId, url) {
|
|
845
|
+
try {
|
|
846
|
+
await sendMessage('restoreSingleTab', { taskThreadId, url });
|
|
847
|
+
} catch (err) {
|
|
848
|
+
console.error('[Comet] Single tab restore failed:', err);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ─── Import/Export (T029-T030, T037) ─────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
function initImportExport() {
|
|
855
|
+
// Save All button
|
|
856
|
+
DOM.btnSaveAll.addEventListener('click', saveAllTabsAction);
|
|
857
|
+
|
|
858
|
+
// Import button opens modal
|
|
859
|
+
DOM.btnImport.addEventListener('click', () => {
|
|
860
|
+
DOM.importOverlay.classList.remove('hidden');
|
|
861
|
+
DOM.importTextarea.value = '';
|
|
862
|
+
DOM.importPreview.classList.add('hidden');
|
|
863
|
+
DOM.importPreview.innerHTML = '';
|
|
864
|
+
DOM.importDuplicates.classList.add('hidden');
|
|
865
|
+
DOM.importConfirm.disabled = true;
|
|
866
|
+
DOM.importTextarea.focus();
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// Close modal
|
|
870
|
+
const closeModal = () => DOM.importOverlay.classList.add('hidden');
|
|
871
|
+
DOM.importClose.addEventListener('click', closeModal);
|
|
872
|
+
DOM.importCancel.addEventListener('click', closeModal);
|
|
873
|
+
DOM.importOverlay.addEventListener('click', (e) => {
|
|
874
|
+
if (e.target === DOM.importOverlay) closeModal();
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// Parse on input
|
|
878
|
+
DOM.importTextarea.addEventListener('input', () => {
|
|
879
|
+
const text = DOM.importTextarea.value.trim();
|
|
880
|
+
if (!text) {
|
|
881
|
+
DOM.importPreview.classList.add('hidden');
|
|
882
|
+
DOM.importConfirm.disabled = true;
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const parsed = parseOneTabExport(text);
|
|
886
|
+
renderImportPreview(parsed);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Confirm import
|
|
890
|
+
DOM.importConfirm.addEventListener('click', async () => {
|
|
891
|
+
const groups = collectImportGroups();
|
|
892
|
+
if (!groups.length) return;
|
|
893
|
+
|
|
894
|
+
DOM.importConfirm.disabled = true;
|
|
895
|
+
DOM.importConfirm.textContent = 'Importing…';
|
|
896
|
+
|
|
897
|
+
try {
|
|
898
|
+
await sendMessage('importUrls', { groups });
|
|
899
|
+
closeModal();
|
|
900
|
+
await fetchAndRenderArchived();
|
|
901
|
+
} catch (err) {
|
|
902
|
+
console.error('[Comet] Import failed:', err);
|
|
903
|
+
} finally {
|
|
904
|
+
DOM.importConfirm.textContent = 'Import';
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// Export button
|
|
909
|
+
DOM.btnExport.addEventListener('click', async () => {
|
|
910
|
+
try {
|
|
911
|
+
const data = await sendMessage('exportAll');
|
|
912
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
913
|
+
const url = URL.createObjectURL(blob);
|
|
914
|
+
const a = document.createElement('a');
|
|
915
|
+
a.href = url;
|
|
916
|
+
a.download = `comet-sessions-${new Date().toISOString().slice(0, 10)}.json`;
|
|
917
|
+
a.click();
|
|
918
|
+
URL.revokeObjectURL(url);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
console.error('[Comet] Export failed:', err);
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ─── OneTab Parser (T029) ────────────────────────────────────────────────────
|
|
926
|
+
|
|
927
|
+
function parseOneTabExport(text) {
|
|
928
|
+
const lines = text.split('\n');
|
|
929
|
+
const groups = [];
|
|
930
|
+
let currentGroup = { name: '', urls: [] };
|
|
931
|
+
|
|
932
|
+
for (const line of lines) {
|
|
933
|
+
const trimmed = line.trim();
|
|
934
|
+
if (!trimmed) {
|
|
935
|
+
// Blank line = group separator
|
|
936
|
+
if (currentGroup.urls.length > 0) {
|
|
937
|
+
groups.push(currentGroup);
|
|
938
|
+
currentGroup = { name: '', urls: [] };
|
|
939
|
+
}
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Parse: URL | Title or just URL
|
|
944
|
+
const pipeIdx = trimmed.indexOf(' | ');
|
|
945
|
+
let url, title;
|
|
946
|
+
if (pipeIdx !== -1) {
|
|
947
|
+
url = trimmed.substring(0, pipeIdx).trim();
|
|
948
|
+
title = trimmed.substring(pipeIdx + 3).trim();
|
|
949
|
+
} else {
|
|
950
|
+
url = trimmed;
|
|
951
|
+
title = '';
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Basic URL validation
|
|
955
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
956
|
+
currentGroup.urls.push({ url, title });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Push last group
|
|
961
|
+
if (currentGroup.urls.length > 0) {
|
|
962
|
+
groups.push(currentGroup);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Auto-name unnamed groups
|
|
966
|
+
groups.forEach((g, i) => {
|
|
967
|
+
if (!g.name) {
|
|
968
|
+
g.name = `Imported Group ${i + 1}`;
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
return groups;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function renderImportPreview(groups) {
|
|
976
|
+
DOM.importPreview.innerHTML = '';
|
|
977
|
+
|
|
978
|
+
if (!groups.length) {
|
|
979
|
+
DOM.importPreview.classList.add('hidden');
|
|
980
|
+
DOM.importConfirm.disabled = true;
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
DOM.importPreview.classList.remove('hidden');
|
|
985
|
+
DOM.importConfirm.disabled = false;
|
|
986
|
+
|
|
987
|
+
// Count duplicates across groups
|
|
988
|
+
const allUrls = groups.flatMap(g => g.urls.map(u => u.url));
|
|
989
|
+
const urlCounts = {};
|
|
990
|
+
allUrls.forEach(u => { urlCounts[u] = (urlCounts[u] || 0) + 1; });
|
|
991
|
+
const duplicateCount = Object.values(urlCounts).filter(c => c > 1).length;
|
|
992
|
+
|
|
993
|
+
if (duplicateCount > 0) {
|
|
994
|
+
DOM.importDuplicates.classList.remove('hidden');
|
|
995
|
+
DOM.importDuplicateText.textContent = `${duplicateCount} duplicate URL(s) detected across groups`;
|
|
996
|
+
} else {
|
|
997
|
+
DOM.importDuplicates.classList.add('hidden');
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
for (const group of groups) {
|
|
1001
|
+
const groupEl = el('div', { className: 'import-preview-group' },
|
|
1002
|
+
el('div', { className: 'import-group-name' },
|
|
1003
|
+
el('input', { type: 'text', value: group.name, dataset: { groupName: 'true' } }),
|
|
1004
|
+
el('span', { className: 'import-group-count' }, `${group.urls.length} tabs`)
|
|
1005
|
+
)
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
// Show first 3 URLs
|
|
1009
|
+
const showCount = Math.min(group.urls.length, 3);
|
|
1010
|
+
for (let i = 0; i < showCount; i++) {
|
|
1011
|
+
groupEl.appendChild(el('div', { className: 'import-group-url' }, group.urls[i].url));
|
|
1012
|
+
}
|
|
1013
|
+
if (group.urls.length > 3) {
|
|
1014
|
+
groupEl.appendChild(el('div', { className: 'import-group-url' }, `… and ${group.urls.length - 3} more`));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
DOM.importPreview.appendChild(groupEl);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function collectImportGroups() {
|
|
1022
|
+
const groups = [];
|
|
1023
|
+
const text = DOM.importTextarea.value.trim();
|
|
1024
|
+
if (!text) return groups;
|
|
1025
|
+
|
|
1026
|
+
const parsed = parseOneTabExport(text);
|
|
1027
|
+
const nameInputs = DOM.importPreview.querySelectorAll('input[data-group-name]');
|
|
1028
|
+
|
|
1029
|
+
parsed.forEach((group, i) => {
|
|
1030
|
+
const name = nameInputs[i] ? nameInputs[i].value.trim() : group.name;
|
|
1031
|
+
groups.push({ name: name || group.name, urls: group.urls });
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
return groups;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ─── Auto-Refresh (T011) ─────────────────────────────────────────────────────
|
|
1038
|
+
|
|
1039
|
+
function startAutoRefresh() {
|
|
1040
|
+
// Poll every 3 seconds for changes
|
|
1041
|
+
state.refreshTimer = setInterval(async () => {
|
|
1042
|
+
await Promise.all([
|
|
1043
|
+
fetchAndRenderLiveTree(),
|
|
1044
|
+
fetchAndRenderArchived(),
|
|
1045
|
+
fetchAndRenderRecent(),
|
|
1046
|
+
]);
|
|
1047
|
+
}, 3000);
|
|
1048
|
+
|
|
1049
|
+
// Health check every 10 seconds
|
|
1050
|
+
state.healthTimer = setInterval(checkHealth, 10000);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function stopAutoRefresh() {
|
|
1054
|
+
if (state.refreshTimer) {
|
|
1055
|
+
clearInterval(state.refreshTimer);
|
|
1056
|
+
state.refreshTimer = null;
|
|
1057
|
+
}
|
|
1058
|
+
if (state.healthTimer) {
|
|
1059
|
+
clearInterval(state.healthTimer);
|
|
1060
|
+
state.healthTimer = null;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// ─── Reconnect Button ────────────────────────────────────────────────────────
|
|
1065
|
+
|
|
1066
|
+
function initReconnect() {
|
|
1067
|
+
DOM.btnReconnect.addEventListener('click', async () => {
|
|
1068
|
+
DOM.statusText.textContent = 'Reconnecting…';
|
|
1069
|
+
await checkHealth();
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ─── Keyboard Navigation (T039) ──────────────────────────────────────────────
|
|
1074
|
+
|
|
1075
|
+
function initKeyboardNav() {
|
|
1076
|
+
document.addEventListener('keydown', (e) => {
|
|
1077
|
+
// Don't intercept when typing in inputs/textareas
|
|
1078
|
+
const isInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
|
|
1079
|
+
|
|
1080
|
+
// Escape closes search or modal
|
|
1081
|
+
if (e.key === 'Escape') {
|
|
1082
|
+
if (!DOM.importOverlay.classList.contains('hidden')) {
|
|
1083
|
+
DOM.importOverlay.classList.add('hidden');
|
|
1084
|
+
} else if (DOM.searchInput.value) {
|
|
1085
|
+
DOM.searchInput.value = '';
|
|
1086
|
+
DOM.searchClear.classList.add('hidden');
|
|
1087
|
+
state.searchQuery = '';
|
|
1088
|
+
applySearchFilter();
|
|
1089
|
+
DOM.searchInput.blur();
|
|
1090
|
+
}
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Ctrl/Cmd+F focuses search
|
|
1095
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
1096
|
+
e.preventDefault();
|
|
1097
|
+
DOM.searchInput.focus();
|
|
1098
|
+
DOM.searchInput.select();
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Arrow key navigation only when not in an input field
|
|
1103
|
+
if (isInput) return;
|
|
1104
|
+
|
|
1105
|
+
// Gather all focusable tree items
|
|
1106
|
+
const focusable = [...document.querySelectorAll(
|
|
1107
|
+
'.group-header, .tab-item, .archive-item, .recent-item, .section-header'
|
|
1108
|
+
)].filter(el => el.offsetParent !== null); // only visible
|
|
1109
|
+
|
|
1110
|
+
if (!focusable.length) return;
|
|
1111
|
+
|
|
1112
|
+
const current = document.querySelector('.kb-focused');
|
|
1113
|
+
let idx = current ? focusable.indexOf(current) : -1;
|
|
1114
|
+
|
|
1115
|
+
if (e.key === 'ArrowDown') {
|
|
1116
|
+
e.preventDefault();
|
|
1117
|
+
idx = Math.min(idx + 1, focusable.length - 1);
|
|
1118
|
+
setKeyboardFocus(focusable, idx);
|
|
1119
|
+
} else if (e.key === 'ArrowUp') {
|
|
1120
|
+
e.preventDefault();
|
|
1121
|
+
idx = Math.max(idx - 1, 0);
|
|
1122
|
+
setKeyboardFocus(focusable, idx);
|
|
1123
|
+
} else if (e.key === 'Enter' && current) {
|
|
1124
|
+
e.preventDefault();
|
|
1125
|
+
current.click();
|
|
1126
|
+
} else if (e.key === 'Tab') {
|
|
1127
|
+
// Tab/Shift+Tab jumps between section headers
|
|
1128
|
+
e.preventDefault();
|
|
1129
|
+
const headers = [...document.querySelectorAll('.section-header')].filter(el => el.offsetParent !== null);
|
|
1130
|
+
if (!headers.length) return;
|
|
1131
|
+
const curHeader = current?.closest('.section')?.querySelector('.section-header');
|
|
1132
|
+
let hIdx = curHeader ? headers.indexOf(curHeader) : -1;
|
|
1133
|
+
hIdx = e.shiftKey
|
|
1134
|
+
? (hIdx <= 0 ? headers.length - 1 : hIdx - 1)
|
|
1135
|
+
: (hIdx >= headers.length - 1 ? 0 : hIdx + 1);
|
|
1136
|
+
setKeyboardFocus(focusable, focusable.indexOf(headers[hIdx]));
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function setKeyboardFocus(items, idx) {
|
|
1142
|
+
// Remove previous focus
|
|
1143
|
+
document.querySelectorAll('.kb-focused').forEach(el => el.classList.remove('kb-focused'));
|
|
1144
|
+
if (idx >= 0 && idx < items.length) {
|
|
1145
|
+
items[idx].classList.add('kb-focused');
|
|
1146
|
+
items[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// ─── Initialization ──────────────────────────────────────────────────────────
|
|
1151
|
+
|
|
1152
|
+
async function init() {
|
|
1153
|
+
// Load persisted state
|
|
1154
|
+
try {
|
|
1155
|
+
const stored = await chrome.storage.local.get(['expandedGroups', 'expandedSections']);
|
|
1156
|
+
if (stored.expandedGroups) {
|
|
1157
|
+
state.expandedGroups = new Set(stored.expandedGroups);
|
|
1158
|
+
}
|
|
1159
|
+
if (stored.expandedSections) {
|
|
1160
|
+
state.expandedSections = new Set(stored.expandedSections);
|
|
1161
|
+
}
|
|
1162
|
+
} catch {
|
|
1163
|
+
// Ignore storage errors
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Initialize UI components
|
|
1167
|
+
initSectionToggles();
|
|
1168
|
+
initSearch();
|
|
1169
|
+
initImportExport();
|
|
1170
|
+
initReconnect();
|
|
1171
|
+
initKeyboardNav();
|
|
1172
|
+
|
|
1173
|
+
// Perform initial data fetch
|
|
1174
|
+
await checkHealth();
|
|
1175
|
+
await Promise.all([
|
|
1176
|
+
fetchAndRenderLiveTree(),
|
|
1177
|
+
fetchAndRenderArchived(),
|
|
1178
|
+
fetchAndRenderRecent(),
|
|
1179
|
+
]);
|
|
1180
|
+
|
|
1181
|
+
// Start auto-refresh
|
|
1182
|
+
startAutoRefresh();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Start when DOM is ready
|
|
1186
|
+
document.addEventListener('DOMContentLoaded', init);
|