@flrande/bak-extension 0.6.11 → 0.6.13

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/src/popup.ts CHANGED
@@ -1,5 +1,8 @@
1
+ const statusPanelEl = document.getElementById('statusPanel') as HTMLDivElement;
2
+ const statusBadgeEl = document.getElementById('statusBadge') as HTMLSpanElement;
1
3
  const statusEl = document.getElementById('status') as HTMLDivElement;
2
- const statusNoteEl = document.getElementById('statusNote') as HTMLDivElement;
4
+ const statusBodyEl = document.getElementById('statusBody') as HTMLDivElement;
5
+ const recoveryListEl = document.getElementById('recoveryList') as HTMLUListElement;
3
6
  const tokenInput = document.getElementById('token') as HTMLInputElement;
4
7
  const portInput = document.getElementById('port') as HTMLInputElement;
5
8
  const debugRichTextInput = document.getElementById('debugRichText') as HTMLInputElement;
@@ -15,7 +18,9 @@ const lastErrorEl = document.getElementById('lastError') as HTMLDivElement;
15
18
  const lastBindingUpdateEl = document.getElementById('lastBindingUpdate') as HTMLDivElement;
16
19
  const extensionVersionEl = document.getElementById('extensionVersion') as HTMLDivElement;
17
20
  const sessionSummaryEl = document.getElementById('sessionSummary') as HTMLDivElement;
18
- const sessionListEl = document.getElementById('sessionList') as HTMLUListElement;
21
+ const sessionCardsEl = document.getElementById('sessionCards') as HTMLDivElement;
22
+
23
+ type PopupTone = 'neutral' | 'success' | 'warning' | 'error';
19
24
 
20
25
  interface PopupState {
21
26
  ok: boolean;
@@ -44,31 +49,27 @@ interface PopupState {
44
49
  label: string;
45
50
  tabCount: number;
46
51
  activeTabId: number | null;
52
+ activeTabTitle: string | null;
53
+ activeTabUrl: string | null;
47
54
  windowId: number | null;
48
55
  groupId: number | null;
49
56
  detached: boolean;
57
+ lastBindingUpdateAt: number | null;
58
+ lastBindingUpdateReason: string | null;
50
59
  }>;
51
60
  };
52
61
  }
53
- let latestState: PopupState | null = null;
54
62
 
55
- function setStatus(text: string, tone: 'neutral' | 'success' | 'warning' | 'error' = 'neutral'): void {
56
- statusEl.textContent = text;
57
- if (tone === 'success') {
58
- statusEl.style.color = '#166534';
59
- return;
60
- }
61
- if (tone === 'warning') {
62
- statusEl.style.color = '#b45309';
63
- return;
64
- }
65
- if (tone === 'error') {
66
- statusEl.style.color = '#dc2626';
67
- return;
68
- }
69
- statusEl.style.color = '#0f172a';
63
+ interface StatusDescriptor {
64
+ badge: string;
65
+ title: string;
66
+ body: string;
67
+ tone: PopupTone;
68
+ recoverySteps: string[];
70
69
  }
71
70
 
71
+ let latestState: PopupState | null = null;
72
+
72
73
  function pluralize(count: number, singular: string, plural = `${singular}s`): string {
73
74
  return `${count} ${count === 1 ? singular : plural}`;
74
75
  }
@@ -89,25 +90,31 @@ function formatTimeAgo(at: number | null): string {
89
90
  return `${deltaMinutes}m ago`;
90
91
  }
91
92
  const deltaHours = Math.round(deltaMinutes / 60);
92
- return `${deltaHours}h ago`;
93
+ if (deltaHours < 48) {
94
+ return `${deltaHours}h ago`;
95
+ }
96
+ const deltaDays = Math.round(deltaHours / 24);
97
+ return `${deltaDays}d ago`;
93
98
  }
94
99
 
95
- function renderSessionBindings(state: PopupState['sessionBindings']): void {
96
- if (state.count === 0) {
97
- sessionSummaryEl.textContent = 'No remembered sessions';
98
- } else {
99
- sessionSummaryEl.textContent = `${pluralize(state.count, 'session')}, ${pluralize(state.attachedCount, 'attached binding')}, ${pluralize(state.tabCount, 'tab')}, ${pluralize(state.detachedCount, 'detached binding')}`;
100
+ function truncate(value: string, maxLength: number): string {
101
+ if (value.length <= maxLength) {
102
+ return value;
100
103
  }
101
- sessionListEl.replaceChildren();
102
- for (const item of state.items) {
103
- const li = document.createElement('li');
104
- const location = item.windowId === null ? 'no window' : `window ${item.windowId}`;
105
- const active = item.activeTabId === null ? 'no active tab' : `active ${item.activeTabId}`;
106
- li.textContent = `${item.label}: ${item.tabCount} tabs, ${location}, ${active}`;
107
- if (item.detached) {
108
- li.style.color = '#b45309';
109
- }
110
- sessionListEl.appendChild(li);
104
+ return `${value.slice(0, Math.max(0, maxLength - 1))}...`;
105
+ }
106
+
107
+ function formatUrl(url: string | null): string {
108
+ if (!url) {
109
+ return 'No active tab URL';
110
+ }
111
+ try {
112
+ const parsed = new URL(url);
113
+ const trimmedPath = parsed.pathname === '/' ? '' : parsed.pathname;
114
+ const trimmedQuery = parsed.search.length > 0 ? parsed.search : '';
115
+ return truncate(`${parsed.host}${trimmedPath}${trimmedQuery}`, 64);
116
+ } catch {
117
+ return truncate(url, 64);
111
118
  }
112
119
  }
113
120
 
@@ -129,6 +136,127 @@ function describeConnectionState(connectionState: PopupState['connectionState'])
129
136
  }
130
137
  }
131
138
 
139
+ function setStatus(descriptor: StatusDescriptor): void {
140
+ statusPanelEl.dataset.tone = descriptor.tone;
141
+ statusBadgeEl.dataset.tone = descriptor.tone;
142
+ statusBadgeEl.textContent = descriptor.badge;
143
+ statusEl.textContent = descriptor.title;
144
+ statusBodyEl.textContent = descriptor.body;
145
+ recoveryListEl.replaceChildren();
146
+ for (const step of descriptor.recoverySteps) {
147
+ const li = document.createElement('li');
148
+ li.textContent = step;
149
+ recoveryListEl.appendChild(li);
150
+ }
151
+ }
152
+
153
+ function describeStatus(state: PopupState): StatusDescriptor {
154
+ const lastError = `${state.lastErrorContext ?? ''} ${state.lastError ?? ''}`.toLowerCase();
155
+ const runtimeOffline = lastError.includes('cannot connect to bak cli');
156
+
157
+ if (state.connected) {
158
+ const body =
159
+ state.sessionBindings.detachedCount > 0
160
+ ? `${pluralize(state.sessionBindings.detachedCount, 'remembered session')} are detached. Check the cards below before you continue browser work.`
161
+ : 'The extension bridge is healthy and ready for CLI-driven browser work.';
162
+ return {
163
+ badge: 'Ready',
164
+ title: 'Connected to the local bak runtime',
165
+ body,
166
+ tone: 'success',
167
+ recoverySteps: state.sessionBindings.detachedCount > 0
168
+ ? [
169
+ 'Resume or recreate detached work from the bak CLI before sending new page commands.',
170
+ 'Use the Sessions panel below to confirm which remembered session lost its owned tabs.'
171
+ ]
172
+ : [
173
+ 'Start browser work from the bak CLI.',
174
+ 'Use Reconnect bridge only when you intentionally changed token or port settings.'
175
+ ]
176
+ };
177
+ }
178
+
179
+ if (state.connectionState === 'missing-token') {
180
+ return {
181
+ badge: 'Action needed',
182
+ title: 'Pair token is required',
183
+ body: 'This browser profile does not have a saved token yet, so the extension cannot pair with bak.',
184
+ tone: 'error',
185
+ recoverySteps: [
186
+ 'Run `bak setup` if you need a fresh token.',
187
+ `Paste the token above, keep CLI port ${state.port}, and click Save settings.`,
188
+ 'If the bridge still stays disconnected after saving, click Reconnect bridge below.'
189
+ ]
190
+ };
191
+ }
192
+
193
+ if (state.connectionState === 'manual') {
194
+ return {
195
+ badge: 'Paused',
196
+ title: 'Extension bridge is paused',
197
+ body: 'The bridge was manually disconnected. It will stay idle until you reconnect it.',
198
+ tone: 'warning',
199
+ recoverySteps: [
200
+ 'Click Reconnect bridge below when you want the extension live again.',
201
+ 'If you changed the token or port, save the new settings first.'
202
+ ]
203
+ };
204
+ }
205
+
206
+ if (runtimeOffline) {
207
+ return {
208
+ badge: 'Runtime offline',
209
+ title: 'The local bak runtime is not reachable',
210
+ body: `The extension cannot reach ${state.wsUrl}, so browser work cannot start yet.`,
211
+ tone: 'warning',
212
+ recoverySteps: [
213
+ `Run \`bak doctor --port ${state.port}\` in PowerShell 7.`,
214
+ 'If you just upgraded bak, reload the unpacked extension in chrome://extensions or edge://extensions.',
215
+ 'Leave this popup open for a moment after doctor so the bridge can retry.'
216
+ ]
217
+ };
218
+ }
219
+
220
+ if (state.connectionState === 'reconnecting') {
221
+ return {
222
+ badge: 'Retrying',
223
+ title: 'The extension is trying to reconnect',
224
+ body: 'The bridge is retrying in the background. You only need to intervene if the retry loop keeps failing.',
225
+ tone: 'warning',
226
+ recoverySteps: [
227
+ 'Wait for the current retry window to finish.',
228
+ 'If you changed the token or port, click Save settings.',
229
+ 'Use Reconnect bridge below if you want to retry immediately.'
230
+ ]
231
+ };
232
+ }
233
+
234
+ if (state.lastError) {
235
+ return {
236
+ badge: 'Check setup',
237
+ title: 'The bridge needs attention',
238
+ body: `Last error: ${state.lastError}`,
239
+ tone: 'error',
240
+ recoverySteps: [
241
+ `Confirm the saved token and CLI port ${state.port}.`,
242
+ `Run \`bak doctor --port ${state.port}\` from the bak CLI.`,
243
+ 'Reload the unpacked extension if the runtime and token both look correct.'
244
+ ]
245
+ };
246
+ }
247
+
248
+ return {
249
+ badge: 'Waiting',
250
+ title: 'Not connected yet',
251
+ body: 'The extension is ready to pair, but it is still waiting for the local bak runtime to come online.',
252
+ tone: 'neutral',
253
+ recoverySteps: [
254
+ `Run \`bak doctor --port ${state.port}\` or another bak CLI command to wake the runtime.`,
255
+ 'Return here and confirm the bridge reconnects automatically.'
256
+ ]
257
+ };
258
+ }
259
+
132
260
  function renderConnectionDetails(state: PopupState): void {
133
261
  connectionStateEl.textContent = describeConnectionState(state.connectionState);
134
262
  tokenStateEl.textContent = state.hasToken ? 'configured' : 'missing';
@@ -160,6 +288,107 @@ function renderConnectionDetails(state: PopupState): void {
160
288
  }
161
289
  }
162
290
 
291
+ function createMetaRow(label: string, value: string): HTMLDivElement {
292
+ const row = document.createElement('div');
293
+ row.className = 'session-meta-row';
294
+
295
+ const labelEl = document.createElement('span');
296
+ labelEl.className = 'session-meta-label';
297
+ labelEl.textContent = label;
298
+
299
+ const valueEl = document.createElement('span');
300
+ valueEl.className = 'session-meta-value';
301
+ valueEl.textContent = value;
302
+
303
+ row.append(labelEl, valueEl);
304
+ return row;
305
+ }
306
+
307
+ function renderSessionBindings(state: PopupState['sessionBindings']): void {
308
+ if (state.count === 0) {
309
+ sessionSummaryEl.textContent = 'No remembered sessions';
310
+ } else {
311
+ sessionSummaryEl.textContent =
312
+ `${pluralize(state.count, 'session')}, ` +
313
+ `${pluralize(state.attachedCount, 'attached binding')}, ` +
314
+ `${pluralize(state.detachedCount, 'detached binding')}, ` +
315
+ `${pluralize(state.tabCount, 'tracked tab')}`;
316
+ }
317
+
318
+ sessionCardsEl.replaceChildren();
319
+
320
+ if (state.items.length === 0) {
321
+ const empty = document.createElement('div');
322
+ empty.className = 'session-empty';
323
+ empty.textContent = 'No tracked sessions yet. Resolve a session from the bak CLI and it will appear here.';
324
+ sessionCardsEl.appendChild(empty);
325
+ return;
326
+ }
327
+
328
+ for (const item of state.items) {
329
+ const card = document.createElement('section');
330
+ card.className = 'session-card';
331
+ card.dataset.detached = item.detached ? 'true' : 'false';
332
+
333
+ const header = document.createElement('div');
334
+ header.className = 'session-card-header';
335
+
336
+ const titleWrap = document.createElement('div');
337
+ titleWrap.className = 'session-card-title-wrap';
338
+
339
+ const title = document.createElement('div');
340
+ title.className = 'session-card-title';
341
+ title.textContent = item.label;
342
+
343
+ const subtitle = document.createElement('div');
344
+ subtitle.className = 'session-card-subtitle';
345
+ subtitle.textContent = item.id;
346
+
347
+ titleWrap.append(title, subtitle);
348
+
349
+ const badge = document.createElement('span');
350
+ badge.className = 'session-badge';
351
+ badge.dataset.detached = item.detached ? 'true' : 'false';
352
+ badge.textContent = item.detached ? 'Detached' : 'Attached';
353
+
354
+ header.append(titleWrap, badge);
355
+
356
+ const activeTitle = document.createElement('div');
357
+ activeTitle.className = 'session-active-title';
358
+ activeTitle.textContent = item.activeTabTitle ? truncate(item.activeTabTitle, 72) : 'No active tab title';
359
+ activeTitle.title = item.activeTabTitle ?? '';
360
+
361
+ const activeUrl = document.createElement('div');
362
+ activeUrl.className = 'session-active-url';
363
+ activeUrl.textContent = formatUrl(item.activeTabUrl);
364
+ activeUrl.title = item.activeTabUrl ?? '';
365
+
366
+ const meta = document.createElement('div');
367
+ meta.className = 'session-meta-grid';
368
+ meta.append(
369
+ createMetaRow('Active tab', item.activeTabId === null ? 'none' : `${item.activeTabId}`),
370
+ createMetaRow('Tabs', `${item.tabCount}`),
371
+ createMetaRow('Window', item.windowId === null ? 'none' : `${item.windowId}`),
372
+ createMetaRow('Group', item.groupId === null ? 'none' : `${item.groupId}`),
373
+ createMetaRow(
374
+ 'Last binding',
375
+ item.lastBindingUpdateReason
376
+ ? `${item.lastBindingUpdateReason} (${formatTimeAgo(item.lastBindingUpdateAt)})`
377
+ : 'none this session'
378
+ )
379
+ );
380
+
381
+ const footer = document.createElement('div');
382
+ footer.className = 'session-card-footer';
383
+ footer.textContent = item.detached
384
+ ? 'bak still remembers this session, but its owned tabs or window are missing.'
385
+ : 'The saved binding still points at live browser tabs.';
386
+
387
+ card.append(header, activeTitle, activeUrl, meta, footer);
388
+ sessionCardsEl.appendChild(card);
389
+ }
390
+ }
391
+
163
392
  function parsePortValue(): number | null {
164
393
  const port = Number.parseInt(portInput.value.trim(), 10);
165
394
  return Number.isInteger(port) && port > 0 ? port : null;
@@ -194,83 +423,25 @@ function updateSaveState(state: PopupState | null): void {
194
423
  saveBtn.textContent = state?.hasToken ? 'Save settings' : 'Save token';
195
424
  }
196
425
 
197
- function describeStatus(state: PopupState): { text: string; note: string; tone: 'neutral' | 'success' | 'warning' | 'error' } {
198
- const combinedError = `${state.lastErrorContext ?? ''} ${state.lastError ?? ''}`.toLowerCase();
199
- const runtimeOffline = combinedError.includes('cannot connect to bak cli');
200
-
201
- if (state.connected) {
202
- return {
203
- text: 'Connected to local bak runtime',
204
- note: 'Use the bak CLI to start browser work. This popup is mainly for status and configuration.',
205
- tone: 'success'
206
- };
207
- }
208
-
209
- if (state.connectionState === 'missing-token') {
210
- return {
211
- text: 'Pair token is required',
212
- note: 'Paste a token once, then save it. Future reconnects happen automatically.',
213
- tone: 'error'
214
- };
215
- }
216
-
217
- if (state.connectionState === 'manual') {
218
- return {
219
- text: 'Extension bridge is paused',
220
- note: 'Normal browser work starts from the bak CLI. Open Advanced only if you need to reconnect manually.',
221
- tone: 'warning'
222
- };
223
- }
224
-
225
- if (runtimeOffline) {
226
- return {
227
- text: 'Waiting for local bak runtime',
228
- note: 'Run any bak command, such as `bak doctor`, and the extension will reconnect automatically.',
229
- tone: 'warning'
230
- };
231
- }
426
+ async function refreshState(): Promise<void> {
427
+ const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as PopupState;
232
428
 
233
- if (state.connectionState === 'reconnecting') {
234
- return {
235
- text: 'Trying to reconnect',
236
- note: 'The extension is retrying in the background. You usually do not need to press anything here.',
237
- tone: 'warning'
238
- };
429
+ if (!state.ok) {
430
+ return;
239
431
  }
240
432
 
241
- if (state.lastError) {
242
- return {
243
- text: 'Connection problem',
244
- note: 'Check the last error below. The extension keeps retrying automatically unless you disconnect it manually.',
245
- tone: 'error'
246
- };
433
+ const shouldSyncForm = !isFormDirty(latestState);
434
+ latestState = state;
435
+ if (shouldSyncForm) {
436
+ portInput.value = String(state.port);
437
+ debugRichTextInput.checked = Boolean(state.debugRichText);
438
+ tokenInput.value = '';
247
439
  }
248
440
 
249
- return {
250
- text: 'Not connected yet',
251
- note: 'Once the local bak runtime is available, the extension reconnects automatically.',
252
- tone: 'neutral'
253
- };
254
- }
255
-
256
- async function refreshState(): Promise<void> {
257
- const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as PopupState;
258
-
259
- if (state.ok) {
260
- const shouldSyncForm = !isFormDirty(latestState);
261
- latestState = state;
262
- if (shouldSyncForm) {
263
- portInput.value = String(state.port);
264
- debugRichTextInput.checked = Boolean(state.debugRichText);
265
- tokenInput.value = '';
266
- }
267
- renderConnectionDetails(state);
268
- renderSessionBindings(state.sessionBindings);
269
- updateSaveState(state);
270
- const status = describeStatus(state);
271
- setStatus(status.text, status.tone);
272
- statusNoteEl.textContent = status.note;
273
- }
441
+ renderConnectionDetails(state);
442
+ renderSessionBindings(state.sessionBindings);
443
+ updateSaveState(state);
444
+ setStatus(describeStatus(state));
274
445
  }
275
446
 
276
447
  saveBtn.addEventListener('click', async () => {
@@ -278,12 +449,24 @@ saveBtn.addEventListener('click', async () => {
278
449
  const port = parsePortValue();
279
450
 
280
451
  if (!token && latestState?.hasToken !== true) {
281
- setStatus('Pair token is required', 'error');
452
+ setStatus({
453
+ badge: 'Action needed',
454
+ title: 'Pair token is required',
455
+ body: 'Save a token before the extension can reconnect.',
456
+ tone: 'error',
457
+ recoverySteps: ['Paste a valid token above, then click Save settings.']
458
+ });
282
459
  return;
283
460
  }
284
461
 
285
462
  if (port === null) {
286
- setStatus('Port is invalid', 'error');
463
+ setStatus({
464
+ badge: 'Invalid input',
465
+ title: 'CLI port is invalid',
466
+ body: 'Use a positive integer port before saving settings.',
467
+ tone: 'error',
468
+ recoverySteps: ['Correct the port value above and try again.']
469
+ });
287
470
  return;
288
471
  }
289
472