@flrande/bak-extension 0.6.3 → 0.6.5

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
@@ -3,28 +3,140 @@ const tokenInput = document.getElementById('token') as HTMLInputElement;
3
3
  const portInput = document.getElementById('port') as HTMLInputElement;
4
4
  const debugRichTextInput = document.getElementById('debugRichText') as HTMLInputElement;
5
5
  const saveBtn = document.getElementById('save') as HTMLButtonElement;
6
+ const reconnectBtn = document.getElementById('reconnect') as HTMLButtonElement;
6
7
  const disconnectBtn = document.getElementById('disconnect') as HTMLButtonElement;
8
+ const connectionStateEl = document.getElementById('connectionState') as HTMLDivElement;
9
+ const tokenStateEl = document.getElementById('tokenState') as HTMLDivElement;
10
+ const reconnectStateEl = document.getElementById('reconnectState') as HTMLDivElement;
11
+ const connectionUrlEl = document.getElementById('connectionUrl') as HTMLDivElement;
12
+ const lastErrorEl = document.getElementById('lastError') as HTMLDivElement;
13
+ const lastBindingUpdateEl = document.getElementById('lastBindingUpdate') as HTMLDivElement;
14
+ const extensionVersionEl = document.getElementById('extensionVersion') as HTMLDivElement;
15
+ const sessionSummaryEl = document.getElementById('sessionSummary') as HTMLDivElement;
16
+ const sessionListEl = document.getElementById('sessionList') as HTMLUListElement;
17
+
18
+ interface PopupState {
19
+ ok: boolean;
20
+ connected: boolean;
21
+ connectionState: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'manual' | 'missing-token';
22
+ hasToken: boolean;
23
+ port: number;
24
+ wsUrl: string;
25
+ debugRichText: boolean;
26
+ lastError: string | null;
27
+ lastErrorAt: number | null;
28
+ lastErrorContext: string | null;
29
+ reconnectAttempt: number;
30
+ nextReconnectInMs: number | null;
31
+ manualDisconnect: boolean;
32
+ extensionVersion: string;
33
+ lastBindingUpdateAt: number | null;
34
+ lastBindingUpdateReason: string | null;
35
+ sessionBindings: {
36
+ count: number;
37
+ attachedCount: number;
38
+ detachedCount: number;
39
+ tabCount: number;
40
+ items: Array<{
41
+ id: string;
42
+ label: string;
43
+ tabCount: number;
44
+ activeTabId: number | null;
45
+ windowId: number | null;
46
+ groupId: number | null;
47
+ detached: boolean;
48
+ }>;
49
+ };
50
+ }
51
+ let latestState: PopupState | null = null;
7
52
 
8
53
  function setStatus(text: string, bad = false): void {
9
54
  statusEl.textContent = text;
10
55
  statusEl.style.color = bad ? '#dc2626' : '#0f172a';
11
56
  }
12
57
 
58
+ function formatTimeAgo(at: number | null): string {
59
+ if (typeof at !== 'number') {
60
+ return 'never';
61
+ }
62
+ const deltaSeconds = Math.max(0, Math.round((Date.now() - at) / 1000));
63
+ if (deltaSeconds < 5) {
64
+ return 'just now';
65
+ }
66
+ if (deltaSeconds < 60) {
67
+ return `${deltaSeconds}s ago`;
68
+ }
69
+ const deltaMinutes = Math.round(deltaSeconds / 60);
70
+ if (deltaMinutes < 60) {
71
+ return `${deltaMinutes}m ago`;
72
+ }
73
+ const deltaHours = Math.round(deltaMinutes / 60);
74
+ return `${deltaHours}h ago`;
75
+ }
76
+
77
+ function renderSessionBindings(state: PopupState['sessionBindings']): void {
78
+ sessionSummaryEl.textContent = `${state.count} sessions, ${state.attachedCount} attached, ${state.tabCount} tabs, ${state.detachedCount} detached`;
79
+ sessionListEl.replaceChildren();
80
+ for (const item of state.items) {
81
+ const li = document.createElement('li');
82
+ const location = item.windowId === null ? 'no window' : `window ${item.windowId}`;
83
+ const active = item.activeTabId === null ? 'no active tab' : `active ${item.activeTabId}`;
84
+ li.textContent = `${item.label}: ${item.tabCount} tabs, ${location}, ${active}`;
85
+ if (item.detached) {
86
+ li.style.color = '#b45309';
87
+ }
88
+ sessionListEl.appendChild(li);
89
+ }
90
+ }
91
+
92
+ function renderConnectionDetails(state: PopupState): void {
93
+ connectionStateEl.textContent = state.connectionState;
94
+ tokenStateEl.textContent = state.hasToken ? 'configured' : 'missing';
95
+ connectionUrlEl.textContent = state.wsUrl;
96
+ extensionVersionEl.textContent = state.extensionVersion;
97
+
98
+ if (state.manualDisconnect) {
99
+ reconnectStateEl.textContent = 'manual disconnect';
100
+ } else if (typeof state.nextReconnectInMs === 'number') {
101
+ const seconds = Math.max(0, Math.ceil(state.nextReconnectInMs / 100) / 10);
102
+ reconnectStateEl.textContent = `attempt ${state.reconnectAttempt}, retry in ${seconds}s`;
103
+ } else if (state.connected) {
104
+ reconnectStateEl.textContent = 'connected';
105
+ } else {
106
+ reconnectStateEl.textContent = 'idle';
107
+ }
108
+
109
+ if (state.lastError) {
110
+ const context = state.lastErrorContext ? `${state.lastErrorContext}: ` : '';
111
+ lastErrorEl.textContent = `${context}${state.lastError} (${formatTimeAgo(state.lastErrorAt)})`;
112
+ } else {
113
+ lastErrorEl.textContent = 'none';
114
+ }
115
+
116
+ if (state.lastBindingUpdateReason) {
117
+ lastBindingUpdateEl.textContent = `${state.lastBindingUpdateReason} (${formatTimeAgo(state.lastBindingUpdateAt)})`;
118
+ } else {
119
+ lastBindingUpdateEl.textContent = 'none';
120
+ }
121
+ }
122
+
13
123
  async function refreshState(): Promise<void> {
14
- const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as {
15
- ok: boolean;
16
- connected: boolean;
17
- hasToken: boolean;
18
- port: number;
19
- debugRichText: boolean;
20
- lastError: string | null;
21
- };
124
+ const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as PopupState;
22
125
 
23
126
  if (state.ok) {
127
+ latestState = state;
24
128
  portInput.value = String(state.port);
25
129
  debugRichTextInput.checked = Boolean(state.debugRichText);
130
+ renderConnectionDetails(state);
131
+ renderSessionBindings(state.sessionBindings);
26
132
  if (state.connected) {
27
133
  setStatus('Connected to bak CLI');
134
+ } else if (state.connectionState === 'missing-token') {
135
+ setStatus('Pair token is required', true);
136
+ } else if (state.connectionState === 'manual') {
137
+ setStatus('Disconnected manually');
138
+ } else if (state.connectionState === 'reconnecting') {
139
+ setStatus('Reconnecting to bak CLI', true);
28
140
  } else if (state.lastError) {
29
141
  setStatus(`Disconnected: ${state.lastError}`, true);
30
142
  } else {
@@ -37,7 +149,7 @@ saveBtn.addEventListener('click', async () => {
37
149
  const token = tokenInput.value.trim();
38
150
  const port = Number.parseInt(portInput.value.trim(), 10);
39
151
 
40
- if (!token) {
152
+ if (!token && latestState?.hasToken !== true) {
41
153
  setStatus('Pair token is required', true);
42
154
  return;
43
155
  }
@@ -49,11 +161,17 @@ saveBtn.addEventListener('click', async () => {
49
161
 
50
162
  await chrome.runtime.sendMessage({
51
163
  type: 'bak.updateConfig',
52
- token,
164
+ ...(token ? { token } : {}),
53
165
  port,
54
166
  debugRichText: debugRichTextInput.checked
55
167
  });
56
168
 
169
+ tokenInput.value = '';
170
+ await refreshState();
171
+ });
172
+
173
+ reconnectBtn.addEventListener('click', async () => {
174
+ await chrome.runtime.sendMessage({ type: 'bak.reconnectNow' });
57
175
  await refreshState();
58
176
  });
59
177
 
@@ -62,4 +180,10 @@ disconnectBtn.addEventListener('click', async () => {
62
180
  await refreshState();
63
181
  });
64
182
 
65
- void refreshState();
183
+ void refreshState();
184
+ const refreshInterval = window.setInterval(() => {
185
+ void refreshState();
186
+ }, 1000);
187
+ window.addEventListener('unload', () => {
188
+ window.clearInterval(refreshInterval);
189
+ });
@@ -88,18 +88,20 @@ interface SessionBindingWindowOwnership {
88
88
  foreignTabs: SessionBindingTab[];
89
89
  }
90
90
 
91
- export interface SessionBindingEnsureOptions {
92
- bindingId?: string;
93
- focus?: boolean;
94
- initialUrl?: string;
95
- }
96
-
97
- export interface SessionBindingOpenTabOptions {
98
- bindingId?: string;
99
- url?: string;
100
- active?: boolean;
101
- focus?: boolean;
102
- }
91
+ export interface SessionBindingEnsureOptions {
92
+ bindingId?: string;
93
+ focus?: boolean;
94
+ initialUrl?: string;
95
+ label?: string;
96
+ }
97
+
98
+ export interface SessionBindingOpenTabOptions {
99
+ bindingId?: string;
100
+ url?: string;
101
+ active?: boolean;
102
+ focus?: boolean;
103
+ label?: string;
104
+ }
103
105
 
104
106
  export interface SessionBindingResolveTargetOptions {
105
107
  tabId?: number;
@@ -120,13 +122,13 @@ class SessionBindingManager {
120
122
  return this.inspectBinding(bindingId);
121
123
  }
122
124
 
123
- async ensureBinding(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
124
- const bindingId = this.normalizeBindingId(options.bindingId);
125
- const repairActions: string[] = [];
126
- const initialUrl = options.initialUrl ?? DEFAULT_SESSION_BINDING_URL;
127
- const persisted = await this.storage.load(bindingId);
128
- const created = !persisted;
129
- let state = this.normalizeState(persisted, bindingId);
125
+ async ensureBinding(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
126
+ const bindingId = this.normalizeBindingId(options.bindingId);
127
+ const repairActions: string[] = [];
128
+ const initialUrl = options.initialUrl ?? DEFAULT_SESSION_BINDING_URL;
129
+ const persisted = await this.storage.load(bindingId);
130
+ const created = !persisted;
131
+ let state = this.normalizeState(persisted, bindingId, options.label);
130
132
 
131
133
  const originalWindowId = state.windowId;
132
134
  let window = state.windowId !== null ? await this.waitForWindow(state.windowId) : null;
@@ -266,14 +268,15 @@ class SessionBindingManager {
266
268
  };
267
269
  }
268
270
 
269
- async openTab(options: SessionBindingOpenTabOptions = {}): Promise<{ binding: SessionBindingInfo; tab: SessionBindingTab }> {
270
- const bindingId = this.normalizeBindingId(options.bindingId);
271
- const hadBinding = (await this.loadBindingRecord(bindingId)) !== null;
272
- const ensured = await this.ensureBinding({
273
- bindingId,
274
- focus: false,
275
- initialUrl: hadBinding ? options.url ?? DEFAULT_SESSION_BINDING_URL : DEFAULT_SESSION_BINDING_URL
276
- });
271
+ async openTab(options: SessionBindingOpenTabOptions = {}): Promise<{ binding: SessionBindingInfo; tab: SessionBindingTab }> {
272
+ const bindingId = this.normalizeBindingId(options.bindingId);
273
+ const hadBinding = (await this.loadBindingRecord(bindingId)) !== null;
274
+ const ensured = await this.ensureBinding({
275
+ bindingId,
276
+ focus: false,
277
+ initialUrl: hadBinding ? options.url ?? DEFAULT_SESSION_BINDING_URL : DEFAULT_SESSION_BINDING_URL,
278
+ label: options.label
279
+ });
277
280
  let state = { ...ensured.binding, tabIds: [...ensured.binding.tabIds], tabs: [...ensured.binding.tabs] };
278
281
  if (state.windowId !== null && state.tabs.length === 0) {
279
282
  const rebound = await this.rebindBindingWindow(state);
@@ -309,11 +312,12 @@ class SessionBindingManager {
309
312
  if (!this.isMissingWindowError(error)) {
310
313
  throw error;
311
314
  }
312
- const repaired = await this.ensureBinding({
313
- bindingId,
314
- focus: false,
315
- initialUrl: desiredUrl
316
- });
315
+ const repaired = await this.ensureBinding({
316
+ bindingId,
317
+ focus: false,
318
+ initialUrl: desiredUrl,
319
+ label: options.label
320
+ });
317
321
  state = { ...repaired.binding };
318
322
  reusablePrimaryTab = await this.resolveReusablePrimaryTab(state, true);
319
323
  createdTab = reusablePrimaryTab
@@ -421,44 +425,94 @@ class SessionBindingManager {
421
425
  };
422
426
  }
423
427
 
424
- async focus(bindingId: string): Promise<{ ok: true; binding: SessionBindingInfo }> {
425
- const ensured = await this.ensureBinding({ bindingId, focus: false });
426
- if (ensured.binding.activeTabId !== null) {
427
- await this.browser.updateTab(ensured.binding.activeTabId, { active: true });
428
- }
428
+ async focus(bindingId: string): Promise<{ ok: true; binding: SessionBindingInfo }> {
429
+ const ensured = await this.ensureBinding({ bindingId, focus: false });
430
+ if (ensured.binding.activeTabId !== null) {
431
+ await this.browser.updateTab(ensured.binding.activeTabId, { active: true });
432
+ }
429
433
  if (ensured.binding.windowId !== null) {
430
434
  await this.browser.updateWindow(ensured.binding.windowId, { focused: true });
431
435
  }
432
- const refreshed = await this.ensureBinding({ bindingId, focus: false });
433
- return { ok: true, binding: refreshed.binding };
434
- }
435
-
436
- async reset(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
437
- const bindingId = this.normalizeBindingId(options.bindingId);
438
- await this.close(bindingId);
436
+ const refreshed = await this.ensureBinding({ bindingId, focus: false });
437
+ return { ok: true, binding: refreshed.binding };
438
+ }
439
+
440
+ async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
441
+ const ensured = await this.ensureBinding({ bindingId, focus: false });
442
+ const resolvedTabId =
443
+ typeof tabId === 'number'
444
+ ? tabId
445
+ : ensured.binding.activeTabId ?? ensured.binding.primaryTabId ?? ensured.binding.tabs[0]?.id;
446
+ if (typeof resolvedTabId !== 'number' || !ensured.binding.tabIds.includes(resolvedTabId)) {
447
+ throw new Error(`Tab ${tabId ?? 'active'} does not belong to binding ${bindingId}`);
448
+ }
449
+
450
+ await this.browser.closeTab(resolvedTabId);
451
+ const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
452
+
453
+ if (remainingTabIds.length === 0) {
454
+ await this.storage.delete(ensured.binding.id);
455
+ return {
456
+ binding: null,
457
+ closedTabId: resolvedTabId
458
+ };
459
+ }
460
+
461
+ const tabs = await this.readLooseTrackedTabs(remainingTabIds);
462
+ const nextPrimaryTabId =
463
+ ensured.binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : ensured.binding.primaryTabId;
464
+ const nextActiveTabId =
465
+ ensured.binding.activeTabId === resolvedTabId
466
+ ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
467
+ : ensured.binding.activeTabId;
468
+ const nextState: SessionBindingRecord = {
469
+ id: ensured.binding.id,
470
+ label: ensured.binding.label,
471
+ color: ensured.binding.color,
472
+ windowId: tabs[0]?.windowId ?? ensured.binding.windowId,
473
+ groupId: tabs[0]?.groupId ?? ensured.binding.groupId,
474
+ tabIds: tabs.map((candidate) => candidate.id),
475
+ activeTabId: nextActiveTabId,
476
+ primaryTabId: nextPrimaryTabId
477
+ };
478
+ await this.storage.save(nextState);
479
+ return {
480
+ binding: {
481
+ ...nextState,
482
+ tabs
483
+ },
484
+ closedTabId: resolvedTabId
485
+ };
486
+ }
487
+
488
+ async reset(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
489
+ const bindingId = this.normalizeBindingId(options.bindingId);
490
+ await this.close(bindingId);
439
491
  return this.ensureBinding({
440
492
  ...options,
441
493
  bindingId
442
494
  });
443
495
  }
444
496
 
445
- async close(bindingId: string): Promise<{ ok: true }> {
446
- const state = await this.loadBindingRecord(bindingId);
447
- if (!state) {
448
- await this.storage.delete(bindingId);
449
- return { ok: true };
450
- }
451
- // Clear persisted state before closing the window so tab/window removal
452
- // listeners cannot race and resurrect an empty binding record.
453
- await this.storage.delete(bindingId);
454
- if (state.windowId !== null) {
455
- const existingWindow = await this.browser.getWindow(state.windowId);
456
- if (existingWindow) {
457
- await this.browser.closeWindow(state.windowId);
458
- }
459
- }
460
- return { ok: true };
461
- }
497
+ async close(bindingId: string): Promise<{ ok: true }> {
498
+ const state = await this.loadBindingRecord(bindingId);
499
+ if (!state) {
500
+ await this.storage.delete(bindingId);
501
+ return { ok: true };
502
+ }
503
+ // Clear persisted state before closing the window so tab/window removal
504
+ // listeners cannot race and resurrect an empty binding record.
505
+ await this.storage.delete(bindingId);
506
+ const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
507
+ for (const trackedTab of trackedTabs) {
508
+ try {
509
+ await this.browser.closeTab(trackedTab.id);
510
+ } catch {
511
+ // Ignore tabs that were already removed before explicit close.
512
+ }
513
+ }
514
+ return { ok: true };
515
+ }
462
516
 
463
517
  async resolveTarget(options: SessionBindingResolveTargetOptions = {}): Promise<SessionBindingTargetResolution> {
464
518
  if (typeof options.tabId === 'number') {
@@ -511,13 +565,13 @@ class SessionBindingManager {
511
565
  return candidate;
512
566
  }
513
567
 
514
- private normalizeState(state: SessionBindingRecord | null, bindingId: string): SessionBindingRecord {
515
- return {
516
- id: bindingId,
517
- label: state?.label ?? DEFAULT_SESSION_BINDING_LABEL,
518
- color: state?.color ?? DEFAULT_SESSION_BINDING_COLOR,
519
- windowId: state?.windowId ?? null,
520
- groupId: state?.groupId ?? null,
568
+ private normalizeState(state: SessionBindingRecord | null, bindingId: string, label?: string): SessionBindingRecord {
569
+ return {
570
+ id: bindingId,
571
+ label: label?.trim() ? label.trim() : state?.label ?? DEFAULT_SESSION_BINDING_LABEL,
572
+ color: state?.color ?? DEFAULT_SESSION_BINDING_COLOR,
573
+ windowId: state?.windowId ?? null,
574
+ groupId: state?.groupId ?? null,
521
575
  tabIds: state?.tabIds ?? [],
522
576
  activeTabId: state?.activeTabId ?? null,
523
577
  primaryTabId: state?.primaryTabId ?? null
@@ -710,12 +764,16 @@ class SessionBindingManager {
710
764
  await this.browser.updateTab(nextActiveTabId, { active: true });
711
765
  }
712
766
 
713
- state.windowId = window.id;
714
- state.groupId = null;
715
- state.tabIds = recreatedTabs.map((tab) => tab.id);
716
- state.primaryTabId = nextPrimaryTabId;
717
- state.activeTabId = nextActiveTabId;
718
-
767
+ state.windowId = window.id;
768
+ state.groupId = null;
769
+ state.tabIds = recreatedTabs.map((tab) => tab.id);
770
+ state.primaryTabId = nextPrimaryTabId;
771
+ state.activeTabId = nextActiveTabId;
772
+ await this.storage.save({
773
+ ...state,
774
+ tabIds: [...state.tabIds]
775
+ });
776
+
719
777
  for (const bindingTab of ownership.bindingTabs) {
720
778
  await this.browser.closeTab(bindingTab.id);
721
779
  }