@oddessentials/agent-guild 0.25.0 → 0.27.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/README.md +44 -10
- package/bin/agent-guild.mjs +2 -0
- package/node_modules/qrcode-generator/README.md +101 -0
- package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
- package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
- package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
- package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
- package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
- package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
- package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
- package/node_modules/qrcode-generator/package.json +37 -0
- package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
- package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
- package/package.json +3 -2
- package/src/manager/access-policy.mjs +39 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/folder-opener.mjs +96 -0
- package/src/manager/folder-picker.mjs +116 -0
- package/src/manager/folder-picker.ps1 +84 -0
- package/src/manager/github-views.mjs +171 -0
- package/src/manager/github.mjs +56 -1
- package/src/manager/main.mjs +11 -6
- package/src/manager/remote-access.mjs +286 -0
- package/src/manager/server.mjs +105 -18
- package/src/manager/tailscale.mjs +201 -0
- package/web/activity-favicon.js +71 -0
- package/web/app.js +1154 -98
- package/web/index.html +205 -92
- package/web/layout.js +75 -0
- package/web/remote-access.js +236 -0
- package/web/repo-search.js +91 -0
- package/web/skins/gnomeland/skin.css +1 -1
- package/web/skins/goblinville/skin.css +2 -2
- package/web/skins/grove/skin.css +1 -1
- package/web/skins/guild/skin.css +3 -3
- package/web/skins/orbital/skin.css +1 -1
- package/web/styles.css +269 -38
package/web/app.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
|
|
5
5
|
import { TerminalCopy } from './terminal-copy.js';
|
|
6
|
+
import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
|
|
7
|
+
import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
|
|
8
|
+
import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
|
|
9
|
+
import { createRemoteAccessUI } from './remote-access.js';
|
|
6
10
|
|
|
7
11
|
const TOKEN_KEY = 'agentGuild.token';
|
|
8
12
|
const CWD_KEY = 'agentGuild.cwd';
|
|
@@ -15,15 +19,23 @@ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
|
|
|
15
19
|
const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
|
|
16
20
|
const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
|
|
17
21
|
const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
|
|
22
|
+
const GITHUB_REPO_KEY = 'agentGuild.githubRepo';
|
|
23
|
+
const GITHUB_RECENT_KEY = 'agentGuild.githubRecent';
|
|
24
|
+
const GITHUB_VIEW_KEY = 'agentGuild.githubView';
|
|
18
25
|
const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
|
|
19
26
|
const SOUND_KEY = 'agentGuild.sound';
|
|
20
27
|
const VOICE_KEY = 'agentGuild.voice';
|
|
21
28
|
const NOTES_KEY = 'agentGuild.notes';
|
|
29
|
+
const DOCK_KEY = 'agentGuild.dock';
|
|
30
|
+
const DOCK_WIDTH_KEY = 'agentGuild.dockWidth';
|
|
31
|
+
const PANES_KEY = 'agentGuild.panes';
|
|
32
|
+
const SPLIT_RATIO_KEY = 'agentGuild.splitRatio';
|
|
22
33
|
const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
|
|
23
34
|
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
24
35
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
25
36
|
const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
|
|
26
37
|
const coarsePointer = window.matchMedia('(pointer: coarse)');
|
|
38
|
+
const activityFavicon = createActivityFavicon({ link: document.querySelector('link[rel="icon"]'), reducedMotion });
|
|
27
39
|
|
|
28
40
|
const $ = (id) => document.getElementById(id);
|
|
29
41
|
const state = {
|
|
@@ -39,6 +51,8 @@ const state = {
|
|
|
39
51
|
github: null,
|
|
40
52
|
sessions: new Map(),
|
|
41
53
|
views: new Map(),
|
|
54
|
+
panes: [],
|
|
55
|
+
focusedPane: 0,
|
|
42
56
|
activeId: null,
|
|
43
57
|
eventsSocket: null,
|
|
44
58
|
eventsRetry: 0,
|
|
@@ -46,6 +60,10 @@ const state = {
|
|
|
46
60
|
managerUnavailable: false,
|
|
47
61
|
/** True while the events socket is open. */
|
|
48
62
|
connected: false,
|
|
63
|
+
folderOpener: null,
|
|
64
|
+
folderOpening: false,
|
|
65
|
+
folderPicker: null,
|
|
66
|
+
folderPicking: false,
|
|
49
67
|
/** The manager's own version check, from `hello` and `manager.upgrade`. */
|
|
50
68
|
upgrade: null,
|
|
51
69
|
/** The running manager's version and pid, from `hello`. */
|
|
@@ -92,7 +110,7 @@ function toast(message, ms = 5000, action = null) {
|
|
|
92
110
|
|
|
93
111
|
/**
|
|
94
112
|
* Handles a click, but not the second click of a double-click, which lands on whatever the first one
|
|
95
|
-
* opened or uncovered
|
|
113
|
+
* opened, closed or uncovered.
|
|
96
114
|
*/
|
|
97
115
|
const firstClick = (run) => (e) => { if (e.detail < 2) run(); };
|
|
98
116
|
|
|
@@ -100,8 +118,13 @@ function setConnection(kind, label) {
|
|
|
100
118
|
const el = $('connection');
|
|
101
119
|
el.className = `connection ${kind}`;
|
|
102
120
|
el.querySelector('.label').textContent = label;
|
|
121
|
+
el.title = label;
|
|
103
122
|
// The manager can only be stopped, restarted or upgraded while the page can reach it.
|
|
104
123
|
state.connected = kind === 'ok';
|
|
124
|
+
state.remoteAccessUI?.connectionChanged();
|
|
125
|
+
$('manager').hidden = !state.connected;
|
|
126
|
+
if (!state.connected) closeMenu($('manager-menu'));
|
|
127
|
+
renderFolderTools();
|
|
105
128
|
$('stop-manager').hidden = !state.connected;
|
|
106
129
|
$('restart-manager').hidden = !state.connected || !state.restartable;
|
|
107
130
|
renderUpgrade();
|
|
@@ -164,7 +187,7 @@ function unreadRelease() {
|
|
|
164
187
|
/** theme.js applied the saved or system theme before the first paint; this keeps the menu in step. */
|
|
165
188
|
function applyTheme(theme) {
|
|
166
189
|
document.documentElement.dataset.theme = theme;
|
|
167
|
-
document.querySelector(`#
|
|
190
|
+
document.querySelector(`#settings-menu input[name="theme"][value="${theme}"]`).checked = true;
|
|
168
191
|
}
|
|
169
192
|
|
|
170
193
|
function currentTheme() {
|
|
@@ -294,11 +317,10 @@ function managerGone() {
|
|
|
294
317
|
() => state.managerInstance === instance && !state.connected, [`unavailable.${instance}`]);
|
|
295
318
|
}
|
|
296
319
|
|
|
297
|
-
/** Places
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const box = $('appearance').getBoundingClientRect();
|
|
320
|
+
/** Places an open menu under its button, right-aligned with it and kept on screen. */
|
|
321
|
+
function placeMenu(menu, button) {
|
|
322
|
+
if (!menu.matches(':popover-open') || !button) return;
|
|
323
|
+
const box = button.getBoundingClientRect();
|
|
302
324
|
const top = Math.round(box.bottom + 6);
|
|
303
325
|
menu.style.top = `${top}px`;
|
|
304
326
|
menu.style.maxHeight = `${innerHeight - top - 8}px`;
|
|
@@ -310,6 +332,29 @@ function placeAppearanceMenu() {
|
|
|
310
332
|
}
|
|
311
333
|
}
|
|
312
334
|
|
|
335
|
+
function closeMenu(menu) {
|
|
336
|
+
if (menu.matches(':popover-open')) menu.hidePopover();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function menuItems(menu) {
|
|
340
|
+
return [...menu.querySelectorAll('.menu-item')].filter((item) => !item.hidden && !item.disabled);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Arrow keys, Home and End move between the items; Tab leaves the menu and closes it. */
|
|
344
|
+
function moveInMenu(e) {
|
|
345
|
+
if (e.key === 'Tab') return closeMenu(e.currentTarget);
|
|
346
|
+
const items = menuItems(e.currentTarget);
|
|
347
|
+
const at = items.indexOf(document.activeElement);
|
|
348
|
+
const next = e.key === 'ArrowDown' ? items[(at + 1) % items.length]
|
|
349
|
+
: e.key === 'ArrowUp' ? items[(at - 1 + items.length) % items.length]
|
|
350
|
+
: e.key === 'Home' ? items[0]
|
|
351
|
+
: e.key === 'End' ? items[items.length - 1]
|
|
352
|
+
: null;
|
|
353
|
+
if (!next) return;
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
next.focus();
|
|
356
|
+
}
|
|
357
|
+
|
|
313
358
|
// ---- notes ----------------------------------------------------------------
|
|
314
359
|
|
|
315
360
|
/** Notes share the page's storage with every other setting, so they stay far below the browser's limit for it. */
|
|
@@ -322,9 +367,8 @@ const NOTES_STATUS = {
|
|
|
322
367
|
/**
|
|
323
368
|
* `saved`: the notes as this page last read or wrote them in storage. `status`: whether the text in the
|
|
324
369
|
* panel is saved, too `long` or `refused`; `shown`: the status the line under the title shows.
|
|
325
|
-
* `pressedOutside`: the last press on the panel was on its backdrop.
|
|
326
370
|
*/
|
|
327
|
-
const notesView = { saved: '', status: 'saved', shown: 'saved'
|
|
371
|
+
const notesView = { saved: '', status: 'saved', shown: 'saved' };
|
|
328
372
|
|
|
329
373
|
/** Shows notes another tab saved since this page last read or wrote them. Saved notes win over text this page could not save. */
|
|
330
374
|
function refreshNotes() {
|
|
@@ -366,41 +410,91 @@ function notesStored(e) {
|
|
|
366
410
|
if (e.key === NOTES_KEY || e.key === null) refreshNotes();
|
|
367
411
|
}
|
|
368
412
|
|
|
369
|
-
function openNotes() {
|
|
413
|
+
function openNotes({ focus = true } = {}) {
|
|
370
414
|
hideTip();
|
|
371
415
|
refreshNotes();
|
|
372
416
|
// Saving an empty note changes nothing, but shows at once when the browser keeps nothing.
|
|
373
417
|
if (notesView.status === 'saved' && !$('notes-text').value) saveNotes();
|
|
374
|
-
|
|
375
|
-
if (
|
|
376
|
-
$('notes-text').focus();
|
|
418
|
+
showDock('notes');
|
|
419
|
+
if (focus) $('notes-text').focus();
|
|
377
420
|
}
|
|
378
421
|
|
|
379
|
-
function
|
|
380
|
-
if (
|
|
422
|
+
function toggleNotes() {
|
|
423
|
+
if (dockShows('notes')) closeDock();
|
|
424
|
+
else openNotes();
|
|
381
425
|
}
|
|
382
426
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
427
|
+
// ---- dock -----------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
const DOCK_PANELS = ['github', 'notes'];
|
|
430
|
+
const dockView = { panel: null, opener: null, width: clampDockWidth(Number.parseFloat(load(DOCK_WIDTH_KEY)), innerWidth) };
|
|
431
|
+
|
|
432
|
+
function dockShows(panel) {
|
|
433
|
+
return dockView.panel === panel;
|
|
386
434
|
}
|
|
387
435
|
|
|
388
|
-
|
|
389
|
-
const
|
|
436
|
+
function applyDockLayout() {
|
|
437
|
+
const root = document.documentElement;
|
|
438
|
+
const mode = dockMode(innerWidth);
|
|
439
|
+
const width = clampDockWidth(dockView.width, innerWidth);
|
|
440
|
+
root.classList.toggle('dock-push', mode === 'push');
|
|
441
|
+
root.classList.toggle('dock-full', mode === 'full');
|
|
442
|
+
root.style.setProperty('--dock-w', `${width}px`);
|
|
443
|
+
root.style.setProperty('--dock-space', dockView.panel && mode === 'push' ? `${width}px` : '0px');
|
|
444
|
+
root.style.setProperty('--dock-stage-space', dockView.panel && stageBesideDock(innerWidth, width) ? `${width}px` : '0px');
|
|
445
|
+
const splitter = $('dock-splitter');
|
|
446
|
+
splitter.setAttribute('aria-valuemin', String(DOCK_MIN));
|
|
447
|
+
splitter.setAttribute('aria-valuemax', String(clampDockWidth(Infinity, innerWidth)));
|
|
448
|
+
splitter.setAttribute('aria-valuenow', String(width));
|
|
449
|
+
}
|
|
390
450
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
451
|
+
function renderDock() {
|
|
452
|
+
$('dock').hidden = !dockView.panel;
|
|
453
|
+
for (const name of DOCK_PANELS) {
|
|
454
|
+
const shown = dockView.panel === name;
|
|
455
|
+
$(name).hidden = !shown;
|
|
456
|
+
$(`${name}-title`).setAttribute('aria-selected', String(shown));
|
|
457
|
+
$(`${name}-title`).tabIndex = shown || !dockView.panel ? 0 : -1;
|
|
458
|
+
}
|
|
459
|
+
$('github-toggle').setAttribute('aria-pressed', String(dockShows('github')));
|
|
460
|
+
$('notes-open').setAttribute('aria-pressed', String(dockShows('notes')));
|
|
461
|
+
applyDockLayout();
|
|
462
|
+
scheduleRuns();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function showDock(panel) {
|
|
466
|
+
if (!dockView.panel) dockView.opener = document.activeElement;
|
|
467
|
+
dockView.panel = panel;
|
|
468
|
+
save(DOCK_KEY, panel);
|
|
469
|
+
if ($('topbar-menu').matches(':popover-open')) $('topbar-menu').hidePopover();
|
|
470
|
+
renderDock();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function closeDock({ focusOpener = true } = {}) {
|
|
474
|
+
if (!dockView.panel) return;
|
|
475
|
+
const hadFocus = $('dock').contains(document.activeElement);
|
|
476
|
+
const fallback = dockShows('github') ? $('github-toggle') : $('notes-open');
|
|
477
|
+
dockView.panel = null;
|
|
478
|
+
save(DOCK_KEY, null);
|
|
479
|
+
renderDock();
|
|
480
|
+
if (focusOpener && hadFocus) {
|
|
481
|
+
[dockView.opener, fallback, $('menu-toggle')].find((el) => el?.isConnected && el.checkVisibility?.())?.focus();
|
|
482
|
+
}
|
|
483
|
+
dockView.opener = null;
|
|
395
484
|
}
|
|
396
485
|
|
|
397
|
-
function
|
|
398
|
-
|
|
486
|
+
function dockMakesWayForTerminal() {
|
|
487
|
+
if (dockView.panel && !stageBesideDock(innerWidth, clampDockWidth(dockView.width, innerWidth))) closeDock({ focusOpener: false });
|
|
399
488
|
}
|
|
400
489
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
490
|
+
function moveDockTab(e) {
|
|
491
|
+
const tabs = DOCK_PANELS.map((name) => $(`${name}-title`));
|
|
492
|
+
const at = tabs.indexOf(e.target);
|
|
493
|
+
if (at === -1 || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
|
494
|
+
e.preventDefault();
|
|
495
|
+
const next = tabs[(at + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
|
|
496
|
+
next.click();
|
|
497
|
+
next.focus();
|
|
404
498
|
}
|
|
405
499
|
|
|
406
500
|
// ---- upgrading the manager ------------------------------------------------
|
|
@@ -420,6 +514,7 @@ function renderUpgrade() {
|
|
|
420
514
|
const restart = $('restart-manager');
|
|
421
515
|
const pending = u?.pendingVersion;
|
|
422
516
|
restart.classList.toggle('pending', Boolean(pending));
|
|
517
|
+
$('manager').classList.toggle('pending', Boolean(pending) && state.restartable);
|
|
423
518
|
restart.textContent = pending ? `Restart to use v${pending}` : 'Restart manager';
|
|
424
519
|
restart.title = pending
|
|
425
520
|
? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
|
|
@@ -631,6 +726,60 @@ async function api(method, path, body) {
|
|
|
631
726
|
return data;
|
|
632
727
|
}
|
|
633
728
|
|
|
729
|
+
function renderFolderTools() {
|
|
730
|
+
const paint = (id, feature, busy, label, text) => {
|
|
731
|
+
const button = $(id);
|
|
732
|
+
button.disabled = !state.connected || !feature?.available || busy;
|
|
733
|
+
button.setAttribute('aria-label', label);
|
|
734
|
+
button.setAttribute('aria-busy', String(busy));
|
|
735
|
+
button.title = !state.connected ? text.offline
|
|
736
|
+
: busy ? text.busy
|
|
737
|
+
: !feature?.available ? feature?.reason || text.missing
|
|
738
|
+
: label;
|
|
739
|
+
};
|
|
740
|
+
paint('cwd-pick', state.folderPicker, state.folderPicking, 'Choose a folder…', {
|
|
741
|
+
offline: 'Connect to the session manager to choose folders',
|
|
742
|
+
busy: 'Choosing a folder…',
|
|
743
|
+
missing: 'Choosing folders is unavailable with this manager',
|
|
744
|
+
});
|
|
745
|
+
paint('cwd-open', state.folderOpener, state.folderOpening, `Open working folder in ${state.folderOpener?.label || 'file manager'}`, {
|
|
746
|
+
offline: 'Connect to the session manager to open folders',
|
|
747
|
+
busy: 'Opening working folder…',
|
|
748
|
+
missing: 'Opening folders is unavailable with this manager',
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function pickWorkingFolder() {
|
|
753
|
+
if (!state.connected || !state.folderPicker?.available || state.folderPicking) return;
|
|
754
|
+
state.folderPicking = true;
|
|
755
|
+
renderFolderTools();
|
|
756
|
+
try {
|
|
757
|
+
const { path } = await api('POST', '/pick-folder', { cwd: $('cwd').value.trim() });
|
|
758
|
+
if (path) useFolder(path);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
if (err instanceof AuthError) showAuth(err.message);
|
|
761
|
+
else toast(err.message || 'Could not choose a folder.');
|
|
762
|
+
} finally {
|
|
763
|
+
state.folderPicking = false;
|
|
764
|
+
renderFolderTools();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function openWorkingFolder() {
|
|
769
|
+
if (!state.connected || !state.folderOpener?.available || state.folderOpening) return;
|
|
770
|
+
state.folderOpening = true;
|
|
771
|
+
renderFolderTools();
|
|
772
|
+
try {
|
|
773
|
+
await api('POST', '/open-folder', { cwd: $('cwd').value.trim() });
|
|
774
|
+
} catch (err) {
|
|
775
|
+
if (err instanceof AuthError) showAuth(err.message);
|
|
776
|
+
else toast(err.message || 'Could not open the working folder.');
|
|
777
|
+
} finally {
|
|
778
|
+
state.folderOpening = false;
|
|
779
|
+
renderFolderTools();
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
634
783
|
function wsUrl(path) {
|
|
635
784
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
636
785
|
return `${proto}://${location.host}/api/v1${path}?token=${encodeURIComponent(state.token)}`;
|
|
@@ -2308,10 +2457,10 @@ function resumeById(event) {
|
|
|
2308
2457
|
// ---- GitHub ---------------------------------------------------------------
|
|
2309
2458
|
|
|
2310
2459
|
const GITHUB_SCOPES = {
|
|
2311
|
-
repo: 'Read and write access to
|
|
2460
|
+
repo: 'Read and write access to your repositories, private ones included, so you can browse repositories and create or edit issues.',
|
|
2312
2461
|
'write:public_key': 'Add the SSH key Agent Guild creates for this account.',
|
|
2313
2462
|
};
|
|
2314
|
-
const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null,
|
|
2463
|
+
const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, card: null, started: new Set(), announced: new Set() };
|
|
2315
2464
|
let githubLoading = null;
|
|
2316
2465
|
|
|
2317
2466
|
function el(tag, className, ...children) {
|
|
@@ -2385,7 +2534,8 @@ function setGitHub(github) {
|
|
|
2385
2534
|
? `You are already signed in as @${account.login}; that sign-in was renewed. To add another account, switch to it on github.com first.`
|
|
2386
2535
|
: `Signed in to GitHub as @${account.login}.`, done.again ? 10000 : 4000);
|
|
2387
2536
|
}
|
|
2388
|
-
if (
|
|
2537
|
+
if (!dockShows('github')) return;
|
|
2538
|
+
ensureAllRepos();
|
|
2389
2539
|
renderGitHub();
|
|
2390
2540
|
ensureRepos();
|
|
2391
2541
|
}
|
|
@@ -2479,39 +2629,42 @@ async function signOutGitHub(account) {
|
|
|
2479
2629
|
}
|
|
2480
2630
|
}
|
|
2481
2631
|
|
|
2482
|
-
function openGitHub() {
|
|
2483
|
-
const dialog = $('github');
|
|
2632
|
+
function openGitHub({ focus = true } = {}) {
|
|
2484
2633
|
$('github-parent').value = load(CLONE_PARENT_KEY) ?? $('cwd').value.trim();
|
|
2485
2634
|
$('github-filter').value = '';
|
|
2486
2635
|
githubView.card = null;
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
}
|
|
2636
|
+
githubPick.followed = null;
|
|
2637
|
+
githubPick.origins.clear();
|
|
2638
|
+
showDock('github');
|
|
2491
2639
|
renderGitHub();
|
|
2492
2640
|
if (githubAccount() && !githubAccount().needsSignIn) loadRepos();
|
|
2493
2641
|
loadGitHub();
|
|
2494
|
-
(
|
|
2642
|
+
if (githubPick.repo) {
|
|
2643
|
+
loadView('actions');
|
|
2644
|
+
if (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
|
|
2645
|
+
}
|
|
2646
|
+
followTerminal();
|
|
2647
|
+
if (focus) ($('github-card').querySelector('.btn.primary') ?? $('dock-close')).focus();
|
|
2495
2648
|
}
|
|
2496
2649
|
|
|
2497
2650
|
function closeGitHub({ focusOpener = true } = {}) {
|
|
2498
|
-
if (
|
|
2499
|
-
|
|
2500
|
-
|
|
2651
|
+
if (dockShows('github')) closeDock({ focusOpener });
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
function toggleGitHub() {
|
|
2655
|
+
if (dockShows('github')) closeDock();
|
|
2656
|
+
else openGitHub();
|
|
2501
2657
|
}
|
|
2502
2658
|
|
|
2503
2659
|
function renderGitHub() {
|
|
2504
2660
|
const github = state.github;
|
|
2505
2661
|
const account = githubAccount();
|
|
2506
2662
|
const repos = githubView.repos?.accountId === account?.id ? githubView.repos : null;
|
|
2507
|
-
const sub = !github ? 'Loading…'
|
|
2508
|
-
: !account ? 'Clone your repositories over SSH'
|
|
2509
|
-
: [`@${account.login}`, account.name, repos && `${repos.repos.length} ${repos.repos.length === 1 ? 'repository' : 'repositories'}`].filter(Boolean).join(' · ');
|
|
2510
|
-
$('github-sub').textContent = sub;
|
|
2511
2663
|
renderGitHubChips(github);
|
|
2512
2664
|
renderGitHubCard(github, account);
|
|
2513
2665
|
renderGitHubStatus(github, account);
|
|
2514
2666
|
renderGitHubRepos(github, account, repos);
|
|
2667
|
+
renderGitHubViews();
|
|
2515
2668
|
}
|
|
2516
2669
|
|
|
2517
2670
|
function githubAvatar(account) {
|
|
@@ -2528,7 +2681,6 @@ function githubAvatar(account) {
|
|
|
2528
2681
|
|
|
2529
2682
|
function renderGitHubChips(github) {
|
|
2530
2683
|
const accounts = github?.accounts ?? [];
|
|
2531
|
-
$('github-accounts').hidden = accounts.length === 0;
|
|
2532
2684
|
const host = $('github-chips');
|
|
2533
2685
|
const focused = document.activeElement?.closest?.('#github-chips .account-chip')?.dataset.account;
|
|
2534
2686
|
host.replaceChildren(...accounts.map((account) => {
|
|
@@ -2702,7 +2854,6 @@ function buildRepoRow(fullName) {
|
|
|
2702
2854
|
function repoAction(repo, control) {
|
|
2703
2855
|
const running = runningClone(repo.target);
|
|
2704
2856
|
if (running) {
|
|
2705
|
-
closeGitHub({ focusOpener: false });
|
|
2706
2857
|
openPanel(running.id);
|
|
2707
2858
|
} else if (repo.local === 'cloned') {
|
|
2708
2859
|
useFolder(repo.target);
|
|
@@ -2743,7 +2894,6 @@ function updateRepoRow(node, repo, blocker) {
|
|
|
2743
2894
|
function renderGitHubRepos(github, account, repos) {
|
|
2744
2895
|
const ready = Boolean(github && account && !account.needsSignIn);
|
|
2745
2896
|
$('github-tools').hidden = !ready;
|
|
2746
|
-
$('github-parent').closest('.github-form').hidden = !ready;
|
|
2747
2897
|
const list = $('github-list');
|
|
2748
2898
|
const filter = $('github-filter').value.trim().toLowerCase();
|
|
2749
2899
|
const shown = ready && repos ? repos.repos.filter((repo) => !filter || `${repo.fullName}\n${repo.description ?? ''}`.toLowerCase().includes(filter)) : [];
|
|
@@ -2778,7 +2928,6 @@ async function startClone(account, fullName) {
|
|
|
2778
2928
|
const { session } = await api('POST', '/github/clone', { account: account.id, repo: fullName, parent: cloneParent() });
|
|
2779
2929
|
githubView.started.add(session.id);
|
|
2780
2930
|
upsertSession(session);
|
|
2781
|
-
closeGitHub({ focusOpener: false });
|
|
2782
2931
|
openPanel(session.id);
|
|
2783
2932
|
}
|
|
2784
2933
|
|
|
@@ -2884,7 +3033,8 @@ function noticeClone(s) {
|
|
|
2884
3033
|
if (s.task !== 'clone' || s.status !== 'exited' || githubView.announced.has(s.id)) return;
|
|
2885
3034
|
githubView.announced.add(s.id);
|
|
2886
3035
|
githubView.reposFor = null;
|
|
2887
|
-
|
|
3036
|
+
githubPick.origins.clear();
|
|
3037
|
+
if (dockShows('github')) loadRepos();
|
|
2888
3038
|
if (!githubView.started.has(s.id)) return;
|
|
2889
3039
|
if (clonedPath(s)) toast(`Cloned ${s.clone.repo} into ${s.clone.path}.`, 12000, { label: 'Use folder', run: () => useFolder(s.clone.path) });
|
|
2890
3040
|
else toast(`Cloning ${s.clone?.repo ?? 'the repository'} did not finish. Its session shows why.`, 8000);
|
|
@@ -2893,10 +3043,641 @@ function noticeClone(s) {
|
|
|
2893
3043
|
function useFolder(dir) {
|
|
2894
3044
|
$('cwd').value = dir;
|
|
2895
3045
|
save(CWD_KEY, dir);
|
|
2896
|
-
if (
|
|
3046
|
+
if (dockShows('github')) renderGitHub();
|
|
2897
3047
|
toast(`New sessions start in ${dir}.`, 4000);
|
|
2898
3048
|
}
|
|
2899
3049
|
|
|
3050
|
+
// ---- GitHub repository views ------------------------------------------------
|
|
3051
|
+
|
|
3052
|
+
const GITHUB_VIEWS = ['repos', 'issues', 'actions', 'pulls'];
|
|
3053
|
+
const RUNS_POLL_MS = 6000;
|
|
3054
|
+
const RUNS_IDLE_POLL_MS = 30000;
|
|
3055
|
+
const BODY_LIMIT = 48000;
|
|
3056
|
+
const GITHUB_REQUEST_LIMIT = 64 * 1024;
|
|
3057
|
+
const PICKER_LIMIT = 200;
|
|
3058
|
+
/**
|
|
3059
|
+
* `list`: every account's repositories from /github/repos; `repo`: the picked one. `data[view]` is
|
|
3060
|
+
* `{ stamp, value, error, loading }` for the picked repository. `followed`: the session whose folder last picked the repository.
|
|
3061
|
+
*/
|
|
3062
|
+
const githubPick = {
|
|
3063
|
+
list: null, listFor: null, loadingFor: null, error: null,
|
|
3064
|
+
repo: null, query: '', active: 0, open: false, recent: [],
|
|
3065
|
+
view: 'repos', issueState: 'open', editing: null, data: {}, followed: null, origins: new Map(),
|
|
3066
|
+
};
|
|
3067
|
+
let runsTimer = 0;
|
|
3068
|
+
let reposRequest = null;
|
|
3069
|
+
|
|
3070
|
+
function usableGitHubAccounts() {
|
|
3071
|
+
return (state.github?.accounts ?? []).filter((a) => !a.needsSignIn);
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
function githubAccountsStamp() {
|
|
3075
|
+
return usableGitHubAccounts().map((a) => a.id).join(',');
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
/** The view on screen: Repositories while no account can be used or a sign-in needs the card. */
|
|
3079
|
+
function githubShownView() {
|
|
3080
|
+
const signIn = state.github?.signIn;
|
|
3081
|
+
if (!usableGitHubAccounts().length || (signIn && signIn.status !== 'done')) return 'repos';
|
|
3082
|
+
return githubPick.view;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
function repoPath(repo) {
|
|
3086
|
+
return `/github/accounts/${repo.accountId}/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`;
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
function issueAccountError(repo) {
|
|
3090
|
+
const account = state.github?.accounts.find((a) => a.id === repo.accountId);
|
|
3091
|
+
return account && !account.needsSignIn ? null
|
|
3092
|
+
: `Sign in as @${account?.login ?? repo.login} in Repositories to change issues in ${repo.fullName}.`;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
function ensureAllRepos() {
|
|
3096
|
+
const stamp = githubAccountsStamp();
|
|
3097
|
+
if (!stamp) {
|
|
3098
|
+
reposRequest = null;
|
|
3099
|
+
Object.assign(githubPick, { list: null, listFor: null, loadingFor: null });
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
if (githubPick.listFor !== stamp && githubPick.loadingFor !== stamp) loadAllRepos();
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
async function loadAllRepos({ refresh = false } = {}) {
|
|
3106
|
+
const stamp = githubAccountsStamp();
|
|
3107
|
+
const request = reposRequest = {};
|
|
3108
|
+
githubPick.loadingFor = stamp;
|
|
3109
|
+
renderGitHubViews();
|
|
3110
|
+
let list = null;
|
|
3111
|
+
let error = null;
|
|
3112
|
+
try {
|
|
3113
|
+
list = await api('GET', `/github/repos${refresh ? '?refresh=1' : ''}`);
|
|
3114
|
+
} catch (err) {
|
|
3115
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
3116
|
+
error = err.message;
|
|
3117
|
+
}
|
|
3118
|
+
if (reposRequest !== request || githubAccountsStamp() !== stamp) return;
|
|
3119
|
+
Object.assign(githubPick, { loadingFor: null, error, listFor: stamp });
|
|
3120
|
+
if (list) githubPick.list = list;
|
|
3121
|
+
restorePick();
|
|
3122
|
+
renderGitHubViews();
|
|
3123
|
+
followTerminal();
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
/** Keeps the picked repository in step with the list, or picks the one from the last visit. */
|
|
3127
|
+
function restorePick() {
|
|
3128
|
+
const repos = githubPick.list?.repos ?? [];
|
|
3129
|
+
const wanted = githubPick.repo ? repoKey(githubPick.repo) : load(GITHUB_REPO_KEY);
|
|
3130
|
+
const found = repos.find((repo) => repoKey(repo) === wanted) ?? null;
|
|
3131
|
+
if (found) pickRepo(found);
|
|
3132
|
+
// A failed or expired account must not discard its draft or switch its identity.
|
|
3133
|
+
else if (githubPick.repo && githubPick.list && !githubPick.editing && !issueAccountError(githubPick.repo)
|
|
3134
|
+
&& !githubPick.list.errors?.some((error) => error.accountId === githubPick.repo.accountId)) {
|
|
3135
|
+
Object.assign(githubPick, { repo: null, data: {} });
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
function pickRepo(repo, { chosen = false } = {}) {
|
|
3140
|
+
const same = githubPick.repo && repoKey(githubPick.repo) === repoKey(repo);
|
|
3141
|
+
githubPick.repo = repo;
|
|
3142
|
+
save(GITHUB_REPO_KEY, repoKey(repo));
|
|
3143
|
+
if (chosen) {
|
|
3144
|
+
githubPick.recent = remember(githubPick.recent, repo);
|
|
3145
|
+
save(GITHUB_RECENT_KEY, JSON.stringify(githubPick.recent));
|
|
3146
|
+
}
|
|
3147
|
+
if (!same) Object.assign(githubPick, { data: {}, editing: null });
|
|
3148
|
+
if ((!same || chosen) && githubView.accountId !== repo.accountId && state.github?.accounts.some((a) => a.id === repo.accountId)) {
|
|
3149
|
+
selectGitHubAccount(repo.accountId);
|
|
3150
|
+
githubView.card = null;
|
|
3151
|
+
if (dockShows('github')) {
|
|
3152
|
+
renderGitHub();
|
|
3153
|
+
ensureRepos();
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
if (same) return renderGitHubViews();
|
|
3157
|
+
renderGitHubViews();
|
|
3158
|
+
if (!dockShows('github')) return;
|
|
3159
|
+
loadView('actions');
|
|
3160
|
+
if (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
/** Picks the repository whose clone holds the focused terminal's folder, once for each terminal focused. */
|
|
3164
|
+
async function followTerminal() {
|
|
3165
|
+
const s = state.sessions.get(state.activeId);
|
|
3166
|
+
if (!s?.cwd || !dockShows('github') || !githubPick.list || githubPick.followed === s.id) return;
|
|
3167
|
+
githubPick.followed = s.id;
|
|
3168
|
+
let origin = githubPick.origins.get(s.cwd);
|
|
3169
|
+
if (origin === undefined) {
|
|
3170
|
+
try {
|
|
3171
|
+
origin = (await api('GET', `/github/origin?cwd=${encodeURIComponent(s.cwd)}`)).repo;
|
|
3172
|
+
} catch (err) {
|
|
3173
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
3174
|
+
origin = null;
|
|
3175
|
+
}
|
|
3176
|
+
githubPick.origins.set(s.cwd, origin);
|
|
3177
|
+
}
|
|
3178
|
+
const repo = repoForOrigin(githubPick.list?.repos ?? [], origin, githubPick.recent);
|
|
3179
|
+
if (repo && state.activeId === s.id && githubPick.followed === s.id) pickRepo(repo);
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
async function loadView(view) {
|
|
3183
|
+
const repo = githubPick.repo;
|
|
3184
|
+
if (!repo || view === 'repos') return;
|
|
3185
|
+
const query = view === 'issues' ? `?state=${githubPick.issueState}` : '';
|
|
3186
|
+
const key = repoKey(repo);
|
|
3187
|
+
const stamp = `${key}${query}`;
|
|
3188
|
+
const before = githubPick.data[view];
|
|
3189
|
+
const request = { key, stamp, value: before?.stamp === stamp ? before.value : null, error: null, loading: true };
|
|
3190
|
+
githubPick.data[view] = request;
|
|
3191
|
+
renderGitHubViews();
|
|
3192
|
+
let value = null;
|
|
3193
|
+
let error = null;
|
|
3194
|
+
try {
|
|
3195
|
+
value = await api('GET', `${repoPath(repo)}/${view}${query}`);
|
|
3196
|
+
} catch (err) {
|
|
3197
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
3198
|
+
error = err.message;
|
|
3199
|
+
}
|
|
3200
|
+
const slot = githubPick.data[view];
|
|
3201
|
+
if (slot !== request) return;
|
|
3202
|
+
githubPick.data[view] = { key, stamp, value: value ?? slot.value, error, loading: false };
|
|
3203
|
+
renderGitHubViews();
|
|
3204
|
+
if (view === 'actions') scheduleRuns();
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function viewData(view) {
|
|
3208
|
+
const slot = githubPick.data[view];
|
|
3209
|
+
const repo = githubPick.repo;
|
|
3210
|
+
return slot && repo && slot.key === repoKey(repo) ? slot : null;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
/** Runs are polled only while their tab is on screen in a visible page, faster while one is going. */
|
|
3214
|
+
function scheduleRuns() {
|
|
3215
|
+
clearTimeout(runsTimer);
|
|
3216
|
+
if (!dockShows('github') || githubShownView() !== 'actions' || !githubPick.repo || document.visibilityState !== 'visible') return;
|
|
3217
|
+
runsTimer = setTimeout(() => loadView('actions'), viewData('actions')?.value?.running ? RUNS_POLL_MS : RUNS_IDLE_POLL_MS);
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
function switchGitHubView(view, { focus = false } = {}) {
|
|
3221
|
+
if (!GITHUB_VIEWS.includes(view)) return;
|
|
3222
|
+
githubPick.view = view;
|
|
3223
|
+
save(GITHUB_VIEW_KEY, view);
|
|
3224
|
+
renderGitHubViews();
|
|
3225
|
+
if (focus) $(`github-view-${view}`).focus();
|
|
3226
|
+
if (view === 'repos') ensureRepos();
|
|
3227
|
+
else loadView(view);
|
|
3228
|
+
scheduleRuns();
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
function moveGitHubView(e) {
|
|
3232
|
+
const at = GITHUB_VIEWS.indexOf(e.target.dataset?.view);
|
|
3233
|
+
if (at === -1) return;
|
|
3234
|
+
const next = e.key === 'ArrowRight' ? (at + 1) % GITHUB_VIEWS.length
|
|
3235
|
+
: e.key === 'ArrowLeft' ? (at - 1 + GITHUB_VIEWS.length) % GITHUB_VIEWS.length
|
|
3236
|
+
: e.key === 'Home' ? 0 : e.key === 'End' ? GITHUB_VIEWS.length - 1 : -1;
|
|
3237
|
+
if (next === -1) return;
|
|
3238
|
+
e.preventDefault();
|
|
3239
|
+
switchGitHubView(GITHUB_VIEWS[next], { focus: true });
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
// The picker: a combobox over every account's repositories.
|
|
3243
|
+
|
|
3244
|
+
function pickerOptions() {
|
|
3245
|
+
const accounts = new Set(usableGitHubAccounts().map((a) => a.id));
|
|
3246
|
+
const repos = (githubPick.list?.repos ?? []).filter((repo) => accounts.has(repo.accountId));
|
|
3247
|
+
const ranked = rankRepos(repos, githubPick.query);
|
|
3248
|
+
const options = githubPick.query.trim() ? ranked : recentFirst(ranked, githubPick.recent);
|
|
3249
|
+
return { options: options.slice(0, PICKER_LIMIT), truncated: options.length > PICKER_LIMIT };
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
function setPickerOpen(open) {
|
|
3253
|
+
githubPick.open = open;
|
|
3254
|
+
const input = $('github-repo');
|
|
3255
|
+
input.setAttribute('aria-expanded', String(open));
|
|
3256
|
+
$('github-repo-list').hidden = !open;
|
|
3257
|
+
if (!open) input.removeAttribute('aria-activedescendant');
|
|
3258
|
+
else renderPickerList();
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
function highlighted(text, query) {
|
|
3262
|
+
return highlightParts(text, query).map((part) => (part.match ? el('mark', null, part.text) : part.text));
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function renderPickerList() {
|
|
3266
|
+
if (!githubPick.open) return;
|
|
3267
|
+
const list = $('github-repo-list');
|
|
3268
|
+
const input = $('github-repo');
|
|
3269
|
+
const { options, truncated } = pickerOptions();
|
|
3270
|
+
githubPick.active = Math.min(githubPick.active, Math.max(0, options.length - 1));
|
|
3271
|
+
const accounts = new Map((state.github?.accounts ?? []).map((a) => [a.id, a]));
|
|
3272
|
+
const several = accounts.size > 1;
|
|
3273
|
+
const items = options.map((repo, index) => {
|
|
3274
|
+
const account = accounts.get(repo.accountId);
|
|
3275
|
+
const meta = [several && `@${repo.login}`, repo.description, repo.language, repo.pushedAt && `pushed ${relativeTime(repo.pushedAt)}`].filter(Boolean).join(' · ');
|
|
3276
|
+
const item = el('li', 'github-option',
|
|
3277
|
+
account ? githubAvatar(account) : null,
|
|
3278
|
+
el('span', 'github-option-text', el('span', 'github-option-name', ...highlighted(repo.fullName, githubPick.query)), meta && el('span', 'github-option-meta', meta)),
|
|
3279
|
+
repo.private ? badge('', 'Private', 'Only people with access can see it') : null,
|
|
3280
|
+
repo.archived ? badge('', 'Archived', 'Read-only on GitHub') : null);
|
|
3281
|
+
item.id = `github-repo-option-${index}`;
|
|
3282
|
+
item.setAttribute('role', 'option');
|
|
3283
|
+
item.setAttribute('aria-selected', String(index === githubPick.active));
|
|
3284
|
+
item.addEventListener('pointerdown', (e) => e.preventDefault());
|
|
3285
|
+
item.addEventListener('click', () => choosePickerRepo(repo));
|
|
3286
|
+
return item;
|
|
3287
|
+
});
|
|
3288
|
+
if (!items.length) {
|
|
3289
|
+
const empty = el('li', 'github-option-empty', githubPick.list ? 'No repository matches.' : 'Loading repositories…');
|
|
3290
|
+
empty.setAttribute('role', 'presentation');
|
|
3291
|
+
items.push(empty);
|
|
3292
|
+
}
|
|
3293
|
+
if (truncated) {
|
|
3294
|
+
const hint = el('li', 'github-option-empty', `Showing ${PICKER_LIMIT} matches; refine your search.`);
|
|
3295
|
+
hint.setAttribute('role', 'presentation');
|
|
3296
|
+
items.push(hint);
|
|
3297
|
+
}
|
|
3298
|
+
list.replaceChildren(...items);
|
|
3299
|
+
const active = items[githubPick.active];
|
|
3300
|
+
if (active?.id) {
|
|
3301
|
+
input.setAttribute('aria-activedescendant', active.id);
|
|
3302
|
+
active.scrollIntoView({ block: 'nearest' });
|
|
3303
|
+
} else input.removeAttribute('aria-activedescendant');
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
function choosePickerRepo(repo) {
|
|
3307
|
+
githubPick.query = '';
|
|
3308
|
+
githubPick.followed = state.activeId;
|
|
3309
|
+
setPickerOpen(false);
|
|
3310
|
+
pickRepo(repo, { chosen: true });
|
|
3311
|
+
$('github-repo').value = repo.fullName;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
function pickerKey(e) {
|
|
3315
|
+
const { options } = pickerOptions();
|
|
3316
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
3317
|
+
e.preventDefault();
|
|
3318
|
+
if (!githubPick.open) {
|
|
3319
|
+
githubPick.active = 0;
|
|
3320
|
+
return setPickerOpen(true);
|
|
3321
|
+
}
|
|
3322
|
+
if (!options.length) return;
|
|
3323
|
+
githubPick.active = (githubPick.active + (e.key === 'ArrowDown' ? 1 : options.length - 1)) % options.length;
|
|
3324
|
+
renderPickerList();
|
|
3325
|
+
} else if (e.key === 'Enter' && githubPick.open) {
|
|
3326
|
+
e.preventDefault();
|
|
3327
|
+
if (options[githubPick.active]) choosePickerRepo(options[githubPick.active]);
|
|
3328
|
+
} else if (e.key === 'Escape' && (githubPick.open || githubPick.query)) {
|
|
3329
|
+
e.preventDefault();
|
|
3330
|
+
githubPick.query = '';
|
|
3331
|
+
setPickerOpen(false);
|
|
3332
|
+
e.target.value = githubPick.repo?.fullName ?? '';
|
|
3333
|
+
e.target.select();
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
// Rendering the views.
|
|
3338
|
+
|
|
3339
|
+
function renderPickerNote() {
|
|
3340
|
+
const note = $('github-picker-note');
|
|
3341
|
+
const lines = [];
|
|
3342
|
+
if (githubPick.loadingFor && !githubPick.list) lines.push('Loading repositories…');
|
|
3343
|
+
if (githubPick.error) lines.push(`Repositories could not be loaded: ${githubPick.error}`);
|
|
3344
|
+
for (const failed of githubPick.list?.errors ?? []) lines.push(`@${failed.login}: ${failed.message}`);
|
|
3345
|
+
if (githubPick.list?.truncated) lines.push('An account has more repositories than the 1,000 most recently pushed shown here.');
|
|
3346
|
+
if (githubPick.list && !githubPick.repo && !lines.length) lines.push(githubPick.list.repos.length ? 'Pick a repository to see its issues, Actions runs and pull requests.' : 'Your accounts have no repositories yet.');
|
|
3347
|
+
note.replaceChildren(...lines.map((line) => el('span', null, line)));
|
|
3348
|
+
note.hidden = !lines.length;
|
|
3349
|
+
note.classList.toggle('warn', Boolean(githubPick.error || githubPick.list?.errors?.length));
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
function renderGitHubViews() {
|
|
3353
|
+
const usable = usableGitHubAccounts().length > 0;
|
|
3354
|
+
const shown = githubShownView();
|
|
3355
|
+
const account = githubAccount();
|
|
3356
|
+
const repo = githubPick.repo;
|
|
3357
|
+
const repos = githubView.repos?.accountId === account?.id ? githubView.repos : null;
|
|
3358
|
+
$('github-accounts').hidden = shown !== 'repos' || !state.github?.accounts.length;
|
|
3359
|
+
$('github-sub').textContent = shown !== 'repos'
|
|
3360
|
+
? (repo ? `${repo.fullName} · Acting as @${state.github?.accounts.find((a) => a.id === repo.accountId)?.login ?? repo.login}` : 'Repositories from all your accounts')
|
|
3361
|
+
: !state.github ? 'Loading…' : !account ? 'Sign in to browse your repositories'
|
|
3362
|
+
: [`@${account.login}`, account.name, repos && `${repos.repos.length} ${repos.repos.length === 1 ? 'repository' : 'repositories'}`].filter(Boolean).join(' · ');
|
|
3363
|
+
$('github-picker').hidden = !usable;
|
|
3364
|
+
$('github-views').hidden = !usable;
|
|
3365
|
+
const input = $('github-repo');
|
|
3366
|
+
if (document.activeElement !== input) input.value = githubPick.repo?.fullName ?? '';
|
|
3367
|
+
$('github-repo-reload').disabled = Boolean(githubPick.loadingFor);
|
|
3368
|
+
if (usable) renderPickerNote();
|
|
3369
|
+
else $('github-picker-note').hidden = true;
|
|
3370
|
+
for (const view of GITHUB_VIEWS) {
|
|
3371
|
+
const tab = $(`github-view-${view}`);
|
|
3372
|
+
tab.setAttribute('aria-selected', String(view === shown));
|
|
3373
|
+
tab.tabIndex = view === shown ? 0 : -1;
|
|
3374
|
+
}
|
|
3375
|
+
$('github-repos').hidden = shown !== 'repos';
|
|
3376
|
+
$('github-issues').hidden = shown !== 'issues';
|
|
3377
|
+
$('github-runs').hidden = shown !== 'actions';
|
|
3378
|
+
$('github-pulls').hidden = shown !== 'pulls';
|
|
3379
|
+
const ready = Boolean(state.github && githubAccount() && !githubAccount().needsSignIn);
|
|
3380
|
+
$('github-clone-into').hidden = !ready || shown !== 'repos';
|
|
3381
|
+
const running = Boolean(viewData('actions')?.value?.running);
|
|
3382
|
+
$('github-running').hidden = !running;
|
|
3383
|
+
$('github-view-actions').setAttribute('aria-label', running ? 'Actions, a workflow is running' : 'Actions');
|
|
3384
|
+
renderPickerList();
|
|
3385
|
+
if (shown === 'issues') renderIssues();
|
|
3386
|
+
else if (shown === 'actions') renderRuns();
|
|
3387
|
+
else if (shown === 'pulls') renderPulls();
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
/** Rebuilds a panel and puts the focus back on the control with the same `data-key`. */
|
|
3391
|
+
function redraw(panel, children) {
|
|
3392
|
+
const key = panel.contains(document.activeElement) ? document.activeElement.dataset.key : null;
|
|
3393
|
+
panel.replaceChildren(...children.filter(Boolean));
|
|
3394
|
+
if (key) panel.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true });
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
function keyed(node, key) {
|
|
3398
|
+
node.dataset.key = key;
|
|
3399
|
+
return node;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
function viewTools(view, ...controls) {
|
|
3403
|
+
const slot = viewData(view);
|
|
3404
|
+
const refresh = keyed(button(slot?.loading ? 'Refreshing…' : 'Refresh', () => loadView(view)), 'refresh');
|
|
3405
|
+
refresh.disabled = Boolean(slot?.loading);
|
|
3406
|
+
const href = slot?.value?.url ?? `https://github.com/${githubPick.repo.fullName}/${view === 'pulls' ? 'pulls' : view}`;
|
|
3407
|
+
const open = keyed(externalLink('GitHub', href, 'btn github-out'), 'out');
|
|
3408
|
+
open.title = `Open ${githubPick.repo.fullName} on GitHub`;
|
|
3409
|
+
return el('div', 'github-view-tools', ...controls, el('span', 'github-spacer'), refresh, open);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function viewStatus(view, empty) {
|
|
3413
|
+
const slot = viewData(view);
|
|
3414
|
+
if (slot?.error) {
|
|
3415
|
+
const line = el('p', 'github-error', slot.error);
|
|
3416
|
+
line.setAttribute('role', 'alert');
|
|
3417
|
+
return line;
|
|
3418
|
+
}
|
|
3419
|
+
if (!slot?.value) return el('p', 'github-view-note', 'Loading…');
|
|
3420
|
+
if (empty) return el('p', 'github-view-note', empty);
|
|
3421
|
+
return null;
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
function noRepoNote(what) {
|
|
3425
|
+
return el('p', 'github-view-note', githubPick.list ? `Pick a repository above to see its ${what}.` : 'Loading repositories…');
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
function recordRow(key, title, meta, side, href) {
|
|
3429
|
+
const head = href ? keyed(externalLink(title, href, 'github-record-title'), `${key}:title`) : title;
|
|
3430
|
+
return el('article', 'history-row github-record', el('div', 'history-main', el('div', 'history-title', head), el('div', 'history-meta', meta)), side);
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
function renderIssues() {
|
|
3434
|
+
const panel = $('github-issues');
|
|
3435
|
+
const repo = githubPick.repo;
|
|
3436
|
+
if (!repo) return redraw(panel, [noRepoNote('issues')]);
|
|
3437
|
+
if (githubPick.editing !== null) return renderIssueEditor(panel, repo);
|
|
3438
|
+
const slot = viewData('issues');
|
|
3439
|
+
const blocked = issueAccountError(repo);
|
|
3440
|
+
const issues = slot?.value?.issues ?? [];
|
|
3441
|
+
const filter = el('div', 'github-segment', ...['open', 'closed'].map((value) => {
|
|
3442
|
+
const choice = keyed(button(value === 'open' ? 'Open' : 'Closed', () => {
|
|
3443
|
+
if (githubPick.issueState === value) return;
|
|
3444
|
+
githubPick.issueState = value;
|
|
3445
|
+
loadView('issues');
|
|
3446
|
+
}), `state:${value}`);
|
|
3447
|
+
choice.setAttribute('aria-pressed', String(githubPick.issueState === value));
|
|
3448
|
+
return choice;
|
|
3449
|
+
}));
|
|
3450
|
+
filter.setAttribute('role', 'group');
|
|
3451
|
+
filter.setAttribute('aria-label', 'Show issues');
|
|
3452
|
+
const create = keyed(button('New issue', () => editIssue('new'), 'btn primary'), 'new');
|
|
3453
|
+
create.disabled = Boolean(blocked);
|
|
3454
|
+
const rows = issues.map((issue) => {
|
|
3455
|
+
const edit = keyed(button('Edit', () => editIssue(issue.number)), `edit:${issue.number}`);
|
|
3456
|
+
edit.disabled = Boolean(blocked);
|
|
3457
|
+
edit.setAttribute('aria-label', `Edit #${issue.number} ${issue.title}`);
|
|
3458
|
+
const meta = [issue.user, issue.updatedAt && `updated ${relativeTime(issue.updatedAt)}`, issue.comments && `${issue.comments} comment${issue.comments === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
|
|
3459
|
+
return recordRow(`issue:${issue.number}`, `#${issue.number} ${issue.title}`, meta, edit, issue.url);
|
|
3460
|
+
});
|
|
3461
|
+
redraw(panel, [
|
|
3462
|
+
viewTools('issues', filter, create),
|
|
3463
|
+
blocked && el('p', 'github-error', blocked),
|
|
3464
|
+
viewStatus('issues', slot?.value && !issues.length ? `No ${githubPick.issueState} issues.` : null),
|
|
3465
|
+
rows.length ? el('div', 'history-list', ...rows) : null,
|
|
3466
|
+
slot?.value?.truncated ? el('p', 'github-view-note', 'Showing the 30 most recently updated.') : null,
|
|
3467
|
+
]);
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
function editIssue(target) {
|
|
3471
|
+
if (!githubPick.repo || issueAccountError(githubPick.repo)) return;
|
|
3472
|
+
githubPick.editing = { target };
|
|
3473
|
+
renderGitHubViews();
|
|
3474
|
+
$('github-issues').querySelector('input')?.focus();
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
function leaveIssueEditor() {
|
|
3478
|
+
if (githubPick.editing?.pending) return;
|
|
3479
|
+
githubPick.editing = null;
|
|
3480
|
+
renderGitHubViews();
|
|
3481
|
+
$('github-issues').querySelector('[data-key="new"]')?.focus();
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
function issuePayloadError(payload) {
|
|
3485
|
+
if (payload.title !== undefined) {
|
|
3486
|
+
if (!payload.title.trim()) return 'Enter a title for the issue.';
|
|
3487
|
+
if (payload.title.trim().length > 256) return 'Keep the title to 256 characters or fewer.';
|
|
3488
|
+
}
|
|
3489
|
+
if (payload.body?.length > BODY_LIMIT) return 'Keep the description to 48,000 characters or fewer, or edit it on GitHub.';
|
|
3490
|
+
if (new TextEncoder().encode(JSON.stringify(payload)).length > GITHUB_REQUEST_LIMIT) {
|
|
3491
|
+
return 'This issue is too large to send here. Shorten the description or edit it on GitHub.';
|
|
3492
|
+
}
|
|
3493
|
+
return null;
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
function renderIssueEditor(panel, repo) {
|
|
3497
|
+
const editor = githubPick.editing;
|
|
3498
|
+
if (editor.form && panel.contains(editor.form)) return editor.update();
|
|
3499
|
+
const issue = editor.target === 'new' ? null : viewData('issues')?.value?.issues.find((i) => i.number === editor.target) ?? null;
|
|
3500
|
+
if (editor.target !== 'new' && !issue) {
|
|
3501
|
+
githubPick.editing = null;
|
|
3502
|
+
return renderIssues();
|
|
3503
|
+
}
|
|
3504
|
+
const key = repoKey(repo);
|
|
3505
|
+
const title = el('input');
|
|
3506
|
+
Object.assign(title, { type: 'text', required: true, value: issue?.title ?? '', spellcheck: true, autocomplete: 'off' });
|
|
3507
|
+
const body = el('textarea', 'github-issue-body');
|
|
3508
|
+
Object.assign(body, { value: issue?.body ?? '', placeholder: 'Describe the issue (Markdown)' });
|
|
3509
|
+
// Compare the browser's initialized values: textareas normalize CRLF to LF.
|
|
3510
|
+
const initial = { title: title.value, body: body.value };
|
|
3511
|
+
const longBody = Boolean(issue && issuePayloadError({ body: initial.body }));
|
|
3512
|
+
const context = el('p', 'github-small');
|
|
3513
|
+
const accountError = el('p', 'github-error');
|
|
3514
|
+
accountError.setAttribute('role', 'alert');
|
|
3515
|
+
const error = el('p', 'github-error');
|
|
3516
|
+
error.setAttribute('role', 'alert');
|
|
3517
|
+
error.hidden = true;
|
|
3518
|
+
const form = el('form', 'github-card github-issue-form',
|
|
3519
|
+
el('h3', null, issue ? `Edit #${issue.number} in ${repo.fullName}` : `New issue in ${repo.fullName}`),
|
|
3520
|
+
context,
|
|
3521
|
+
el('div', 'github-fields', el('label', 'wide', el('span', null, 'Title'), title), el('label', 'wide', el('span', null, 'Description'), body)),
|
|
3522
|
+
longBody && el('p', 'github-small', 'This description is too long to edit here. Title and state changes keep it intact. ', externalLink('Edit on GitHub', issue.url)),
|
|
3523
|
+
accountError, error);
|
|
3524
|
+
form.noValidate = true;
|
|
3525
|
+
editor.form = form;
|
|
3526
|
+
const changes = () => {
|
|
3527
|
+
const payload = {};
|
|
3528
|
+
if (!issue || title.value !== initial.title) payload.title = title.value;
|
|
3529
|
+
if (!issue || (!longBody && body.value !== initial.body)) payload.body = body.value;
|
|
3530
|
+
return payload;
|
|
3531
|
+
};
|
|
3532
|
+
const dirty = () => title.value !== initial.title || (!longBody && body.value !== initial.body);
|
|
3533
|
+
const current = () => githubPick.editing === editor && githubPick.repo && repoKey(githubPick.repo) === key;
|
|
3534
|
+
const visible = () => dockShows('github') && githubShownView() === 'issues';
|
|
3535
|
+
const fail = (message) => {
|
|
3536
|
+
error.textContent = message;
|
|
3537
|
+
error.hidden = false;
|
|
3538
|
+
};
|
|
3539
|
+
const send = async (control, nextState) => {
|
|
3540
|
+
if (editor.pending || !current()) return;
|
|
3541
|
+
const payload = changes();
|
|
3542
|
+
if (nextState) payload.state = nextState;
|
|
3543
|
+
if (!Object.keys(payload).length) return;
|
|
3544
|
+
const invalid = issueAccountError(repo) || issuePayloadError(payload);
|
|
3545
|
+
if (invalid) return fail(invalid);
|
|
3546
|
+
const login = state.github.accounts.find((a) => a.id === repo.accountId).login;
|
|
3547
|
+
const ownedFocus = form.contains(document.activeElement);
|
|
3548
|
+
editor.pending = true;
|
|
3549
|
+
editor.update();
|
|
3550
|
+
const pendingFocus = document.activeElement;
|
|
3551
|
+
const mayFocus = () => visible() && (form.contains(document.activeElement) || (ownedFocus && document.activeElement === pendingFocus));
|
|
3552
|
+
error.hidden = true;
|
|
3553
|
+
try {
|
|
3554
|
+
const result = issue
|
|
3555
|
+
? await api('PATCH', `${repoPath(repo)}/issues/${issue.number}`, payload)
|
|
3556
|
+
: await api('POST', `${repoPath(repo)}/issues`, payload);
|
|
3557
|
+
const verb = nextState === 'closed' ? 'Closed' : nextState === 'open' ? 'Reopened' : issue ? 'Saved' : 'Created';
|
|
3558
|
+
toast(`${verb} #${result.issue.number} in ${repo.fullName} as @${login}.`, 4000);
|
|
3559
|
+
if (!current()) return;
|
|
3560
|
+
const focus = mayFocus();
|
|
3561
|
+
githubPick.editing = null;
|
|
3562
|
+
if (!issue) githubPick.issueState = 'open';
|
|
3563
|
+
delete githubPick.data.issues;
|
|
3564
|
+
if (!visible()) return;
|
|
3565
|
+
// A new editor may open while the refresh is pending. Focus only the list we drew.
|
|
3566
|
+
renderGitHubViews();
|
|
3567
|
+
const newButton = panel.querySelector('[data-key="new"]');
|
|
3568
|
+
const refreshFocus = document.activeElement;
|
|
3569
|
+
await loadView('issues');
|
|
3570
|
+
if (focus && visible() && !githubPick.editing && githubPick.repo && repoKey(githubPick.repo) === key
|
|
3571
|
+
&& (document.activeElement === refreshFocus || document.activeElement === newButton)) {
|
|
3572
|
+
panel.querySelector('[data-key="new"]')?.focus();
|
|
3573
|
+
}
|
|
3574
|
+
} catch (err) {
|
|
3575
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
3576
|
+
const message = err.code === 'too_large' ? 'This issue is too large to send here. Shorten the description or edit it on GitHub.' : err.message;
|
|
3577
|
+
if (current()) {
|
|
3578
|
+
fail(message);
|
|
3579
|
+
const focus = mayFocus();
|
|
3580
|
+
editor.pending = false;
|
|
3581
|
+
editor.update();
|
|
3582
|
+
if (focus) control.focus();
|
|
3583
|
+
} else toast(`${repo.fullName} · @${login}: ${message}`, 8000);
|
|
3584
|
+
} finally {
|
|
3585
|
+
editor.pending = false;
|
|
3586
|
+
if (current()) editor.update();
|
|
3587
|
+
}
|
|
3588
|
+
};
|
|
3589
|
+
const submit = el('button', 'btn primary', issue ? 'Save' : 'Create issue');
|
|
3590
|
+
submit.type = 'submit';
|
|
3591
|
+
const actions = el('div', 'github-actions', submit);
|
|
3592
|
+
let toggle;
|
|
3593
|
+
const closing = issue?.state === 'open';
|
|
3594
|
+
if (issue) {
|
|
3595
|
+
toggle = button('', () => send(toggle, closing ? 'closed' : 'open'), closing ? 'btn danger' : 'btn');
|
|
3596
|
+
actions.append(toggle);
|
|
3597
|
+
}
|
|
3598
|
+
actions.append(button('Cancel', leaveIssueEditor));
|
|
3599
|
+
if (issue) actions.append(externalLink('Open on GitHub', issue.url, 'btn github-out'));
|
|
3600
|
+
form.append(actions);
|
|
3601
|
+
editor.update = () => {
|
|
3602
|
+
const blocked = issueAccountError(repo);
|
|
3603
|
+
context.textContent = `Acting as @${state.github?.accounts.find((a) => a.id === repo.accountId)?.login ?? repo.login}`;
|
|
3604
|
+
if (accountError.textContent !== (blocked ?? '')) accountError.textContent = blocked ?? '';
|
|
3605
|
+
accountError.hidden = !blocked;
|
|
3606
|
+
for (const b of form.querySelectorAll('button')) b.disabled = Boolean(editor.pending);
|
|
3607
|
+
submit.disabled = Boolean(editor.pending || blocked || (issue && !dirty()));
|
|
3608
|
+
submit.textContent = editor.pending ? 'Saving…' : issue ? 'Save' : 'Create issue';
|
|
3609
|
+
title.readOnly = Boolean(editor.pending);
|
|
3610
|
+
body.readOnly = Boolean(editor.pending || longBody);
|
|
3611
|
+
form.setAttribute('aria-busy', String(Boolean(editor.pending)));
|
|
3612
|
+
if (toggle) {
|
|
3613
|
+
toggle.disabled = Boolean(editor.pending || blocked);
|
|
3614
|
+
toggle.textContent = dirty() ? (closing ? 'Save and close' : 'Save and reopen') : closing ? 'Close issue' : 'Reopen issue';
|
|
3615
|
+
}
|
|
3616
|
+
};
|
|
3617
|
+
form.addEventListener('input', () => {
|
|
3618
|
+
error.hidden = true;
|
|
3619
|
+
editor.update();
|
|
3620
|
+
});
|
|
3621
|
+
form.addEventListener('submit', (e) => {
|
|
3622
|
+
e.preventDefault();
|
|
3623
|
+
send(submit);
|
|
3624
|
+
});
|
|
3625
|
+
form.addEventListener('keydown', (e) => {
|
|
3626
|
+
if (e.key !== 'Escape') return;
|
|
3627
|
+
e.preventDefault();
|
|
3628
|
+
if (!editor.pending && !dirty()) leaveIssueEditor();
|
|
3629
|
+
});
|
|
3630
|
+
editor.update();
|
|
3631
|
+
panel.replaceChildren(form);
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
function runTone(run) {
|
|
3635
|
+
if (run.status !== 'completed') return 'run';
|
|
3636
|
+
if (run.conclusion === 'success' || run.conclusion === 'skipped' || run.conclusion === 'neutral') return 'ok';
|
|
3637
|
+
if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') return 'bad';
|
|
3638
|
+
return '';
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
function runLabel(run) {
|
|
3642
|
+
return String(run.status === 'completed' && run.conclusion ? run.conclusion : run.status).replaceAll('_', ' ');
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
function renderRuns() {
|
|
3646
|
+
const panel = $('github-runs');
|
|
3647
|
+
if (!githubPick.repo) return redraw(panel, [noRepoNote('Actions runs')]);
|
|
3648
|
+
const slot = viewData('actions');
|
|
3649
|
+
const runs = slot?.value?.runs ?? [];
|
|
3650
|
+
const banner = slot?.value ? el('p', `github-running-note${slot.value.running ? ' on' : ''}`, slot.value.running ? 'A workflow is running. This list refreshes every few seconds.' : 'No workflow is running.') : null;
|
|
3651
|
+
const rows = runs.map((run) => {
|
|
3652
|
+
const meta = [run.name !== run.title && run.name, run.branch && `on ${run.branch}`, run.event, run.runNumber && `#${run.runNumber}`, run.updatedAt && relativeTime(run.updatedAt)].filter(Boolean).join(' · ');
|
|
3653
|
+
return recordRow(`run:${run.id}`, run.title, meta, badge(runTone(run), runLabel(run), `Status: ${runLabel(run)}`), run.url);
|
|
3654
|
+
});
|
|
3655
|
+
redraw(panel, [
|
|
3656
|
+
viewTools('actions'),
|
|
3657
|
+
banner,
|
|
3658
|
+
viewStatus('actions', slot?.value && !runs.length ? 'No workflow runs yet.' : null),
|
|
3659
|
+
rows.length ? el('div', 'history-list', ...rows) : null,
|
|
3660
|
+
slot?.value?.truncated ? el('p', 'github-view-note', 'Showing the 30 latest runs.') : null,
|
|
3661
|
+
]);
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
function renderPulls() {
|
|
3665
|
+
const panel = $('github-pulls');
|
|
3666
|
+
if (!githubPick.repo) return redraw(panel, [noRepoNote('pull requests')]);
|
|
3667
|
+
const slot = viewData('pulls');
|
|
3668
|
+
const pulls = slot?.value?.pulls ?? [];
|
|
3669
|
+
const rows = pulls.map((pull) => {
|
|
3670
|
+
const meta = [pull.user, pull.head && pull.base && `${pull.head} → ${pull.base}`, pull.updatedAt && `updated ${relativeTime(pull.updatedAt)}`].filter(Boolean).join(' · ');
|
|
3671
|
+
return recordRow(`pull:${pull.number}`, `#${pull.number} ${pull.title}`, meta, pull.draft ? badge('', 'Draft', 'Not ready for review') : null, pull.url);
|
|
3672
|
+
});
|
|
3673
|
+
redraw(panel, [
|
|
3674
|
+
viewTools('pulls'),
|
|
3675
|
+
viewStatus('pulls', slot?.value && !pulls.length ? 'No open pull requests.' : null),
|
|
3676
|
+
rows.length ? el('div', 'history-list', ...rows) : null,
|
|
3677
|
+
slot?.value?.truncated ? el('p', 'github-view-note', 'Showing the 30 most recently updated.') : null,
|
|
3678
|
+
]);
|
|
3679
|
+
}
|
|
3680
|
+
|
|
2900
3681
|
// ---- session cards --------------------------------------------------------
|
|
2901
3682
|
|
|
2902
3683
|
const cards = new Map();
|
|
@@ -2929,15 +3710,15 @@ function statusText(s) {
|
|
|
2929
3710
|
if (s.signal) return `Exited (${s.signal})`;
|
|
2930
3711
|
return s.exitCode === 0 || s.exitCode === null ? 'Exited' : `Exited (${s.exitCode})`;
|
|
2931
3712
|
}
|
|
2932
|
-
return s
|
|
3713
|
+
return isSessionWorking(s) ? 'Working' : 'Running';
|
|
2933
3714
|
}
|
|
2934
3715
|
|
|
2935
3716
|
function buildCard(session) {
|
|
2936
3717
|
const node = $('session-template').content.firstElementChild.cloneNode(true);
|
|
2937
3718
|
node.dataset.id = session.id;
|
|
2938
|
-
const open = () => openPanel(session.id);
|
|
2939
|
-
node.addEventListener('click', (e) => { if (!e.target.closest('button')) open(); });
|
|
2940
|
-
node.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === node) open(); });
|
|
3719
|
+
const open = (e) => openPanel(session.id, { beside: e.ctrlKey || e.metaKey });
|
|
3720
|
+
node.addEventListener('click', (e) => { if (!e.target.closest('button')) open(e); });
|
|
3721
|
+
node.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === node) open(e); });
|
|
2941
3722
|
node.querySelector('.open').addEventListener('click', open);
|
|
2942
3723
|
node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
|
|
2943
3724
|
node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
|
|
@@ -3017,7 +3798,7 @@ function updateCard(node, s) {
|
|
|
3017
3798
|
paintIdButton(node.querySelector('.session-id'), id);
|
|
3018
3799
|
const pill = node.querySelector('.status-pill');
|
|
3019
3800
|
pill.textContent = statusText(s);
|
|
3020
|
-
pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s
|
|
3801
|
+
pill.className = `status-pill ${s.status === 'exited' ? 'exited' : isSessionWorking(s) ? 'active' : 'quiet'}`;
|
|
3021
3802
|
const model = node.querySelector('.model-pill');
|
|
3022
3803
|
model.hidden = !s.model;
|
|
3023
3804
|
model.textContent = modelText(s);
|
|
@@ -3055,6 +3836,7 @@ function updateCard(node, s) {
|
|
|
3055
3836
|
function renderSessions() {
|
|
3056
3837
|
const grid = $('sessions');
|
|
3057
3838
|
const sessions = orderSessions(state.sessions.values(), sessionOrder);
|
|
3839
|
+
activityFavicon.setWorking(sessions.some(isSessionWorking));
|
|
3058
3840
|
for (const [id, node] of cards) {
|
|
3059
3841
|
if (state.sessions.has(id)) continue;
|
|
3060
3842
|
if (drag?.id === id) releaseDrag();
|
|
@@ -3111,8 +3893,9 @@ function upsertSession(session) {
|
|
|
3111
3893
|
function dropSession(id) {
|
|
3112
3894
|
state.sessions.delete(id);
|
|
3113
3895
|
const view = state.views.get(id);
|
|
3896
|
+
const pane = state.panes.indexOf(id);
|
|
3897
|
+
if (pane !== -1) closePane(pane, { focusTerminal: paneNodes[pane].contains(document.activeElement) });
|
|
3114
3898
|
if (view) { view.dispose(); state.views.delete(id); }
|
|
3115
|
-
if (state.activeId === id) closePanel();
|
|
3116
3899
|
renderSessions();
|
|
3117
3900
|
}
|
|
3118
3901
|
|
|
@@ -3476,13 +4259,13 @@ class TerminalView {
|
|
|
3476
4259
|
}
|
|
3477
4260
|
|
|
3478
4261
|
connect() {
|
|
3479
|
-
if (this.disposed) return;
|
|
4262
|
+
if (this.disposed || state.remoteRevoked) return;
|
|
3480
4263
|
const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
|
|
3481
4264
|
this.ws = ws;
|
|
3482
4265
|
ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
|
|
3483
4266
|
ws.onmessage = (event) => this.onMessage(JSON.parse(event.data));
|
|
3484
4267
|
ws.onclose = (event) => {
|
|
3485
|
-
if (this.disposed || event.code === 4404 || event.code === 4410) return;
|
|
4268
|
+
if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
|
|
3486
4269
|
const delay = Math.min(5000, 300 * 2 ** this.retry++);
|
|
3487
4270
|
setTimeout(() => this.connect(), delay);
|
|
3488
4271
|
};
|
|
@@ -3513,17 +4296,17 @@ class TerminalView {
|
|
|
3513
4296
|
|
|
3514
4297
|
sendSize() {
|
|
3515
4298
|
const { cols, rows } = this.term;
|
|
3516
|
-
if (!this.el.isConnected || (cols === this.sent.cols && rows === this.sent.rows)) return;
|
|
4299
|
+
if (terminalSizesHeld || !this.el.isConnected || (cols === this.sent.cols && rows === this.sent.rows)) return;
|
|
3517
4300
|
this.sent = { cols, rows };
|
|
3518
4301
|
this.send({ type: 'resize', cols, rows });
|
|
3519
4302
|
}
|
|
3520
4303
|
|
|
3521
|
-
mount(host) {
|
|
4304
|
+
mount(host, { focus = true } = {}) {
|
|
3522
4305
|
host.replaceChildren(this.el);
|
|
3523
4306
|
if (!this.opened) { this.term.open(this.el); this.enableTouchScroll(); this.opened = true; }
|
|
3524
4307
|
this.resizeObserver.observe(host);
|
|
3525
4308
|
this.refit();
|
|
3526
|
-
this.term.focus();
|
|
4309
|
+
if (focus) this.term.focus();
|
|
3527
4310
|
}
|
|
3528
4311
|
|
|
3529
4312
|
/**
|
|
@@ -3601,26 +4384,162 @@ const terminalCopy = new TerminalCopy({
|
|
|
3601
4384
|
},
|
|
3602
4385
|
});
|
|
3603
4386
|
|
|
3604
|
-
|
|
4387
|
+
const paneNodes = [...document.querySelectorAll('.terminal-pane')];
|
|
4388
|
+
const sessionMenu = { mode: 'switch', invoker: null };
|
|
4389
|
+
let terminalSizesHeld = false;
|
|
4390
|
+
let panesRestored = false;
|
|
4391
|
+
let splitRatio = clampRatio(Number.parseFloat(load(SPLIT_RATIO_KEY)));
|
|
4392
|
+
|
|
4393
|
+
/**
|
|
4394
|
+
* `beside`: into the other pane instead of the focused one. Two panes never show the same session, whose terminal has one size.
|
|
4395
|
+
* `restoring`: brought back from the last visit, under a dock that was also brought back and stays on top.
|
|
4396
|
+
*/
|
|
4397
|
+
function openPanel(id, { beside = false, restoring = false } = {}) {
|
|
3605
4398
|
if (!state.sessions.has(id)) return;
|
|
4399
|
+
let index = state.panes.indexOf(id);
|
|
4400
|
+
if (index === -1) {
|
|
4401
|
+
index = !state.panes.length ? 0 : !beside ? state.focusedPane : state.panes.length < 2 ? 1 : 1 - state.focusedPane;
|
|
4402
|
+
const replaced = state.panes[index];
|
|
4403
|
+
if (replaced) state.views.get(replaced)?.unmount();
|
|
4404
|
+
state.panes[index] = id;
|
|
4405
|
+
if (!state.views.has(id)) state.views.set(id, new TerminalView(id));
|
|
4406
|
+
}
|
|
4407
|
+
$('terminal-panel').hidden = false;
|
|
4408
|
+
if (!restoring) dockMakesWayForTerminal();
|
|
4409
|
+
focusPane(index, { focusTerminal: !restoring || !dockView.panel });
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
function focusPane(index, { focusTerminal = true } = {}) {
|
|
4413
|
+
const id = state.panes[index];
|
|
4414
|
+
if (!id) return;
|
|
3606
4415
|
if (state.activeId !== id) terminalCopy.close();
|
|
3607
4416
|
if (dictation && dictation.id !== id) stopDictation();
|
|
3608
|
-
|
|
4417
|
+
state.focusedPane = index;
|
|
3609
4418
|
state.activeId = id;
|
|
3610
|
-
|
|
3611
|
-
if (!view) { view = new TerminalView(id); state.views.set(id, view); }
|
|
3612
|
-
$('terminal-panel').hidden = false;
|
|
4419
|
+
layoutPanes();
|
|
3613
4420
|
updatePanel();
|
|
3614
|
-
|
|
4421
|
+
if (focusTerminal) state.views.get(id)?.term.focus();
|
|
4422
|
+
savePanes();
|
|
4423
|
+
followTerminal();
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
function closePane(index, { focusTerminal = true } = {}) {
|
|
4427
|
+
const id = state.panes[index];
|
|
4428
|
+
if (!id) return;
|
|
4429
|
+
if (state.panes.length < 2) return closePanel();
|
|
4430
|
+
state.views.get(id)?.unmount();
|
|
4431
|
+
state.panes.splice(index, 1);
|
|
4432
|
+
focusPane(0, { focusTerminal });
|
|
3615
4433
|
}
|
|
3616
4434
|
|
|
3617
4435
|
function closePanel() {
|
|
3618
4436
|
terminalCopy.close();
|
|
3619
4437
|
stopDictation();
|
|
3620
|
-
|
|
4438
|
+
for (const id of state.panes) state.views.get(id)?.unmount();
|
|
4439
|
+
state.panes = [];
|
|
4440
|
+
state.focusedPane = 0;
|
|
3621
4441
|
state.activeId = null;
|
|
3622
4442
|
$('terminal-panel').hidden = true;
|
|
4443
|
+
closeMenu($('panel-sessions'));
|
|
3623
4444
|
renderVoice();
|
|
4445
|
+
savePanes();
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
function stageSplit() {
|
|
4449
|
+
const box = $('terminal-panel').getBoundingClientRect();
|
|
4450
|
+
return splitMode(box.width, box.height);
|
|
4451
|
+
}
|
|
4452
|
+
|
|
4453
|
+
function layoutPanes() {
|
|
4454
|
+
if ($('terminal-panel').hidden) return;
|
|
4455
|
+
const split = state.panes.length === 2 ? stageSplit() : null;
|
|
4456
|
+
const panes = $('terminal-panes');
|
|
4457
|
+
if (split) panes.dataset.split = split;
|
|
4458
|
+
else delete panes.dataset.split;
|
|
4459
|
+
panes.style.setProperty('--ratio', String(splitRatio));
|
|
4460
|
+
const splitter = $('pane-splitter');
|
|
4461
|
+
splitter.hidden = !split;
|
|
4462
|
+
splitter.setAttribute('aria-orientation', split === 'rows' ? 'horizontal' : 'vertical');
|
|
4463
|
+
splitter.setAttribute('aria-valuemin', String(Math.round(SPLIT_RATIO_MIN * 100)));
|
|
4464
|
+
splitter.setAttribute('aria-valuemax', String(Math.round((1 - SPLIT_RATIO_MIN) * 100)));
|
|
4465
|
+
splitter.setAttribute('aria-valuenow', String(Math.round(splitRatio * 100)));
|
|
4466
|
+
paneNodes.forEach((pane, index) => {
|
|
4467
|
+
const id = state.panes[index];
|
|
4468
|
+
const shown = Boolean(id) && (split !== null || index === state.focusedPane);
|
|
4469
|
+
pane.hidden = !shown;
|
|
4470
|
+
pane.classList.toggle('focused', index === state.focusedPane);
|
|
4471
|
+
const view = id ? state.views.get(id) : null;
|
|
4472
|
+
const host = pane.querySelector('.terminal-host');
|
|
4473
|
+
if (shown && view && view.el.parentNode !== host) view.mount(host, { focus: false });
|
|
4474
|
+
else if (!shown && view && view.el.parentNode === host) view.unmount();
|
|
4475
|
+
});
|
|
4476
|
+
renderPaneLabels();
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
function renderPaneLabels() {
|
|
4480
|
+
const split = $('terminal-panes').dataset.split;
|
|
4481
|
+
paneNodes.forEach((pane, index) => {
|
|
4482
|
+
const s = state.sessions.get(state.panes[index]);
|
|
4483
|
+
pane.querySelector('.pane-name').textContent = s ? `${s.name} · ${statusText(s)}` : '';
|
|
4484
|
+
pane.setAttribute('aria-label', s ? s.name : '');
|
|
4485
|
+
});
|
|
4486
|
+
const others = [...state.sessions.keys()].some((id) => !state.panes.includes(id));
|
|
4487
|
+
$('panel-split').hidden = state.panes.length !== 1 || !others || stageSplit() === null;
|
|
4488
|
+
const swap = $('panel-swap');
|
|
4489
|
+
const other = state.panes.length === 2 && !split ? state.sessions.get(state.panes[1 - state.focusedPane]) : null;
|
|
4490
|
+
swap.hidden = !other;
|
|
4491
|
+
swap.textContent = other ? `Show ${other.name}` : '';
|
|
4492
|
+
swap.title = other ? `Switch to ${other.name}, the other open terminal` : '';
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
/** The button the session menu opened from, or the switcher once that button is gone, as Split is after a split. */
|
|
4496
|
+
function sessionInvoker() {
|
|
4497
|
+
return sessionMenu.invoker?.checkVisibility?.() ? sessionMenu.invoker : $('panel-switch');
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
function renderSessionMenu() {
|
|
4501
|
+
const menu = $('panel-sessions');
|
|
4502
|
+
const beside = sessionMenu.mode === 'beside';
|
|
4503
|
+
const sessions = orderSessions(state.sessions.values(), sessionOrder).filter((s) => !beside || !state.panes.includes(s.id));
|
|
4504
|
+
const items = sessions.map((s) => {
|
|
4505
|
+
const item = el('button', 'menu-item', s.name, el('span', 'menu-note', [statusText(s), folderName(s.cwd)].filter(Boolean).join(' · ')));
|
|
4506
|
+
item.type = 'button';
|
|
4507
|
+
item.setAttribute('role', 'menuitem');
|
|
4508
|
+
if (s.id === state.activeId) item.setAttribute('aria-current', 'true');
|
|
4509
|
+
item.addEventListener('click', () => {
|
|
4510
|
+
closeMenu(menu);
|
|
4511
|
+
openPanel(s.id, { beside });
|
|
4512
|
+
});
|
|
4513
|
+
return item;
|
|
4514
|
+
});
|
|
4515
|
+
menu.replaceChildren(...(items.length ? items : [el('p', 'menu-empty', 'No other sessions')]));
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
function holdTerminalSizes() {
|
|
4519
|
+
terminalSizesHeld = true;
|
|
4520
|
+
document.documentElement.classList.add('resizing');
|
|
4521
|
+
}
|
|
4522
|
+
|
|
4523
|
+
function releaseTerminalSizes() {
|
|
4524
|
+
terminalSizesHeld = false;
|
|
4525
|
+
document.documentElement.classList.remove('resizing');
|
|
4526
|
+
for (const id of state.panes) state.views.get(id)?.refit();
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
function savePanes() {
|
|
4530
|
+
save(PANES_KEY, state.panes.length ? JSON.stringify({ panes: state.panes, focused: state.focusedPane }) : null);
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
function restorePanes() {
|
|
4534
|
+
if (panesRestored) return;
|
|
4535
|
+
panesRestored = true;
|
|
4536
|
+
let saved;
|
|
4537
|
+
try { saved = JSON.parse(load(PANES_KEY)); } catch { return; }
|
|
4538
|
+
const ids = Array.isArray(saved?.panes) ? saved.panes.filter((id) => typeof id === 'string' && state.sessions.has(id)).slice(0, 2) : [];
|
|
4539
|
+
if (!ids.length || state.panes.length) return;
|
|
4540
|
+
openPanel(ids[0], { restoring: true });
|
|
4541
|
+
if (ids[1]) openPanel(ids[1], { beside: true, restoring: true });
|
|
4542
|
+
if (saved.focused === 0 && ids[1]) focusPane(0, { focusTerminal: !dockView.panel });
|
|
3624
4543
|
}
|
|
3625
4544
|
|
|
3626
4545
|
function updatePanel() {
|
|
@@ -3635,6 +4554,7 @@ function updatePanel() {
|
|
|
3635
4554
|
renderAgents($('panel-agents'), s.agents, s.shells || []);
|
|
3636
4555
|
const stop = $('panel-stop');
|
|
3637
4556
|
stop.textContent = s.status !== 'running' ? 'Remove' : s.multiplexer ? 'Detach' : 'Stop';
|
|
4557
|
+
renderPaneLabels();
|
|
3638
4558
|
}
|
|
3639
4559
|
|
|
3640
4560
|
// ---- voice input ----------------------------------------------------------
|
|
@@ -3942,7 +4862,7 @@ function showManagerUnavailable() {
|
|
|
3942
4862
|
}
|
|
3943
4863
|
|
|
3944
4864
|
function connectEvents() {
|
|
3945
|
-
if (state.pageAway) return;
|
|
4865
|
+
if (state.pageAway || state.remoteRevoked) return;
|
|
3946
4866
|
const ws = new WebSocket(wsUrl('/events'));
|
|
3947
4867
|
state.eventsSocket = ws;
|
|
3948
4868
|
ws.onopen = () => {
|
|
@@ -3961,22 +4881,29 @@ function connectEvents() {
|
|
|
3961
4881
|
state.pid = msg.pid || null;
|
|
3962
4882
|
state.restartable = typeof msg.pid === 'number';
|
|
3963
4883
|
state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
|
|
4884
|
+
state.folderOpener = msg.folderOpener || null;
|
|
4885
|
+
state.folderPicker = msg.folderPicker || null;
|
|
4886
|
+
state.remoteAccessUI?.setAvailable(msg.remoteAccess);
|
|
3964
4887
|
renderVersion();
|
|
3965
4888
|
setConnection('ok', 'Connected to session manager');
|
|
3966
4889
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
3967
4890
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
3968
4891
|
managerConnected(msg);
|
|
3969
4892
|
renderSessions();
|
|
4893
|
+
if (!sessionsShown && load(DOCK_KEY) === 'github' && !dockView.panel) openGitHub({ focus: false });
|
|
4894
|
+
restorePanes();
|
|
3970
4895
|
sessionsShown = true;
|
|
3971
4896
|
setUpgrade(msg.upgrade, true);
|
|
3972
4897
|
loadNews();
|
|
3973
4898
|
// A changelog.updated sent while the socket was down is lost; catch up the open panel.
|
|
3974
4899
|
if ($('changelog').open) loadChangelog();
|
|
3975
|
-
if (
|
|
4900
|
+
if (dockShows('github')) loadGitHub();
|
|
4901
|
+
} else if (msg.type === 'remote-access.updated') {
|
|
4902
|
+
state.remoteAccessUI?.updated();
|
|
3976
4903
|
} else if (msg.type === 'news.updated') {
|
|
3977
4904
|
loadNews();
|
|
3978
4905
|
} else if (msg.type === 'github.updated') {
|
|
3979
|
-
if (
|
|
4906
|
+
if (dockShows('github')) loadGitHub();
|
|
3980
4907
|
} else if (msg.type === 'changelog.updated') {
|
|
3981
4908
|
if ($('changelog').open) loadChangelog();
|
|
3982
4909
|
} else if (msg.type === 'manager.upgrade') {
|
|
@@ -3999,8 +4926,15 @@ function connectEvents() {
|
|
|
3999
4926
|
scheduleStats();
|
|
4000
4927
|
}
|
|
4001
4928
|
};
|
|
4002
|
-
ws.onclose = () => {
|
|
4929
|
+
ws.onclose = (event) => {
|
|
4003
4930
|
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
4931
|
+
if (event?.code === 4403) {
|
|
4932
|
+
state.remoteRevoked = true;
|
|
4933
|
+
managerLoss.cancel();
|
|
4934
|
+
state.remoteAccessUI?.close();
|
|
4935
|
+
setConnection('down', 'Remote access changed. Your terminals are still running. Reopen an enabled address to reconnect.');
|
|
4936
|
+
return;
|
|
4937
|
+
}
|
|
4004
4938
|
if (state.managerUnavailable) {
|
|
4005
4939
|
showManagerUnavailable();
|
|
4006
4940
|
} else if (state.stopping && state.stopRemaining !== null) {
|
|
@@ -4042,6 +4976,7 @@ let statsInterval;
|
|
|
4042
4976
|
let newsTimer;
|
|
4043
4977
|
|
|
4044
4978
|
function showAuth(message = '') {
|
|
4979
|
+
state.remoteAccessUI?.setAvailable(null);
|
|
4045
4980
|
managerLoss.cancel();
|
|
4046
4981
|
closePanel();
|
|
4047
4982
|
closeModels();
|
|
@@ -4076,6 +5011,7 @@ async function boot() {
|
|
|
4076
5011
|
}
|
|
4077
5012
|
save(TOKEN_KEY, state.token);
|
|
4078
5013
|
$('app').hidden = false;
|
|
5014
|
+
if (load(DOCK_KEY) === 'notes' && !dockView.panel) openNotes({ focus: false });
|
|
4079
5015
|
connectEvents();
|
|
4080
5016
|
loadUsage();
|
|
4081
5017
|
clearInterval(usageTimer);
|
|
@@ -4095,6 +5031,32 @@ $('auth-form').addEventListener('submit', (e) => {
|
|
|
4095
5031
|
boot();
|
|
4096
5032
|
});
|
|
4097
5033
|
$('panel-close').addEventListener('click', closePanel);
|
|
5034
|
+
paneNodes.forEach((pane, index) => {
|
|
5035
|
+
pane.querySelector('.pane-close').addEventListener('click', () => closePane(index));
|
|
5036
|
+
pane.addEventListener('focusin', () => { if (state.focusedPane !== index) focusPane(index, { focusTerminal: false }); });
|
|
5037
|
+
pane.addEventListener('pointerdown', () => { if (state.focusedPane !== index) focusPane(index, { focusTerminal: false }); });
|
|
5038
|
+
});
|
|
5039
|
+
const splitAxis = () => ($('terminal-panes').dataset.split === 'rows' ? 'y' : 'x');
|
|
5040
|
+
const setSplitRatio = (ratio) => {
|
|
5041
|
+
splitRatio = clampRatio(ratio);
|
|
5042
|
+
layoutPanes();
|
|
5043
|
+
};
|
|
5044
|
+
bindSplitter($('pane-splitter'), {
|
|
5045
|
+
axis: splitAxis,
|
|
5046
|
+
start: () => splitRatio,
|
|
5047
|
+
move: (delta, from) => {
|
|
5048
|
+
holdTerminalSizes();
|
|
5049
|
+
const box = $('terminal-panes').getBoundingClientRect();
|
|
5050
|
+
setSplitRatio(from + delta / Math.max(1, (splitAxis() === 'x' ? box.width : box.height) - 8));
|
|
5051
|
+
},
|
|
5052
|
+
end: () => {
|
|
5053
|
+
save(SPLIT_RATIO_KEY, String(splitRatio));
|
|
5054
|
+
releaseTerminalSizes();
|
|
5055
|
+
},
|
|
5056
|
+
home: () => setSplitRatio(SPLIT_RATIO_MIN),
|
|
5057
|
+
endKey: () => setSplitRatio(1 - SPLIT_RATIO_MIN),
|
|
5058
|
+
});
|
|
5059
|
+
new ResizeObserver(layoutPanes).observe($('terminal-panel'));
|
|
4098
5060
|
$('panel-voice').addEventListener('click', () => {
|
|
4099
5061
|
if (dictation) {
|
|
4100
5062
|
const { id } = dictation;
|
|
@@ -4114,12 +5076,30 @@ $('history').addEventListener('close', () => {
|
|
|
4114
5076
|
historyOpener = null;
|
|
4115
5077
|
});
|
|
4116
5078
|
$('history-filter').addEventListener('input', renderHistory);
|
|
4117
|
-
$('github-open').addEventListener('click', openGitHub);
|
|
4118
|
-
$('github-
|
|
4119
|
-
$('github').addEventListener('click', (
|
|
4120
|
-
$('
|
|
4121
|
-
|
|
4122
|
-
|
|
5079
|
+
$('github-open').addEventListener('click', () => openGitHub());
|
|
5080
|
+
$('github-toggle').addEventListener('click', firstClick(toggleGitHub));
|
|
5081
|
+
$('github-title').addEventListener('click', () => { if (!dockShows('github')) openGitHub(); });
|
|
5082
|
+
$('notes-title').addEventListener('click', () => { if (!dockShows('notes')) openNotes(); });
|
|
5083
|
+
$('dock').querySelector('.dock-tabs').addEventListener('keydown', moveDockTab);
|
|
5084
|
+
$('dock-close').addEventListener('click', () => closeDock());
|
|
5085
|
+
$('dock').addEventListener('keydown', (e) => {
|
|
5086
|
+
if (e.key !== 'Escape' || e.defaultPrevented || (e.target.type === 'search' && e.target.value)) return;
|
|
5087
|
+
e.preventDefault();
|
|
5088
|
+
closeDock();
|
|
5089
|
+
});
|
|
5090
|
+
bindSplitter($('dock-splitter'), {
|
|
5091
|
+
start: () => clampDockWidth(dockView.width, innerWidth),
|
|
5092
|
+
move: (delta, from) => {
|
|
5093
|
+
holdTerminalSizes();
|
|
5094
|
+
dockView.width = clampDockWidth(from - delta, innerWidth);
|
|
5095
|
+
applyDockLayout();
|
|
5096
|
+
},
|
|
5097
|
+
end: () => {
|
|
5098
|
+
save(DOCK_WIDTH_KEY, String(dockView.width));
|
|
5099
|
+
releaseTerminalSizes();
|
|
5100
|
+
},
|
|
5101
|
+
home: () => { dockView.width = DOCK_MIN; applyDockLayout(); },
|
|
5102
|
+
endKey: () => { dockView.width = clampDockWidth(Infinity, innerWidth); applyDockLayout(); },
|
|
4123
5103
|
});
|
|
4124
5104
|
$('github-add').addEventListener('click', startGitHubSignIn);
|
|
4125
5105
|
$('github-filter').addEventListener('input', () => renderGitHub());
|
|
@@ -4134,6 +5114,37 @@ $('github-create').addEventListener('input', () => {
|
|
|
4134
5114
|
$('github-create-error').hidden = true;
|
|
4135
5115
|
renderCreate();
|
|
4136
5116
|
});
|
|
5117
|
+
const openPicker = (e) => {
|
|
5118
|
+
if (githubPick.open) return;
|
|
5119
|
+
githubPick.query = '';
|
|
5120
|
+
githubPick.active = 0;
|
|
5121
|
+
setPickerOpen(true);
|
|
5122
|
+
e.target.select();
|
|
5123
|
+
};
|
|
5124
|
+
$('github-repo').addEventListener('focus', openPicker);
|
|
5125
|
+
$('github-repo').addEventListener('click', openPicker);
|
|
5126
|
+
// Releasing a press would put the caret where it was let go; the press that opens the list keeps the name selected.
|
|
5127
|
+
let pickerPressOpens = false;
|
|
5128
|
+
$('github-repo').addEventListener('mousedown', () => { pickerPressOpens = !githubPick.open; });
|
|
5129
|
+
$('github-repo').addEventListener('mouseup', (e) => { if (pickerPressOpens) e.preventDefault(); });
|
|
5130
|
+
$('github-repo').addEventListener('input', (e) => {
|
|
5131
|
+
githubPick.query = e.target.value;
|
|
5132
|
+
githubPick.active = 0;
|
|
5133
|
+
if (githubPick.open) renderPickerList();
|
|
5134
|
+
else setPickerOpen(true);
|
|
5135
|
+
});
|
|
5136
|
+
$('github-repo').addEventListener('keydown', pickerKey);
|
|
5137
|
+
$('github-repo').addEventListener('blur', (e) => {
|
|
5138
|
+
setPickerOpen(false);
|
|
5139
|
+
githubPick.query = '';
|
|
5140
|
+
e.target.value = githubPick.repo?.fullName ?? '';
|
|
5141
|
+
});
|
|
5142
|
+
$('github-repo-reload').addEventListener('click', () => loadAllRepos({ refresh: true }));
|
|
5143
|
+
$('github-views').addEventListener('click', (e) => {
|
|
5144
|
+
const view = e.target.closest?.('[data-view]')?.dataset.view;
|
|
5145
|
+
if (view) switchGitHubView(view);
|
|
5146
|
+
});
|
|
5147
|
+
$('github-views').addEventListener('keydown', moveGitHubView);
|
|
4137
5148
|
$('github-parent').addEventListener('change', () => {
|
|
4138
5149
|
save(CLONE_PARENT_KEY, cloneParent());
|
|
4139
5150
|
loadRepos();
|
|
@@ -4171,12 +5182,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
4171
5182
|
}, true);
|
|
4172
5183
|
addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
|
|
4173
5184
|
addEventListener('resize', () => { if (tipFor) hideTip(); });
|
|
4174
|
-
$('notes-open').addEventListener('click', firstClick(
|
|
4175
|
-
$('notes-close').addEventListener('click', firstClick(closeNotes));
|
|
4176
|
-
$('notes').addEventListener('mousedown', keepFocus);
|
|
4177
|
-
$('notes').addEventListener('pointerdown', notesPressed);
|
|
4178
|
-
$('notes').addEventListener('click', notesClicked);
|
|
4179
|
-
$('notes').addEventListener('close', notesClosed);
|
|
5185
|
+
$('notes-open').addEventListener('click', firstClick(toggleNotes));
|
|
4180
5186
|
$('notes-text').addEventListener('input', saveNotes);
|
|
4181
5187
|
addEventListener('storage', notesStored);
|
|
4182
5188
|
// On load, and again for a page back from the back/forward cache, which may have missed another tab's notes.
|
|
@@ -4196,9 +5202,12 @@ $('news').addEventListener('close', () => {
|
|
|
4196
5202
|
document.addEventListener('visibilitychange', () => {
|
|
4197
5203
|
if (document.visibilityState !== 'visible') stopDictation();
|
|
4198
5204
|
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
5205
|
+
if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
|
|
5206
|
+
else scheduleRuns();
|
|
4199
5207
|
});
|
|
4200
5208
|
addEventListener('pagehide', () => {
|
|
4201
5209
|
state.pageAway = true;
|
|
5210
|
+
activityFavicon.setPaused(true);
|
|
4202
5211
|
terminalCopy.close();
|
|
4203
5212
|
stopDictation();
|
|
4204
5213
|
managerLoss.cancel();
|
|
@@ -4207,6 +5216,7 @@ addEventListener('pagehide', () => {
|
|
|
4207
5216
|
addEventListener('pageshow', (event) => {
|
|
4208
5217
|
if (!event.persisted) return;
|
|
4209
5218
|
state.pageAway = false;
|
|
5219
|
+
activityFavicon.setPaused(false);
|
|
4210
5220
|
connectEvents();
|
|
4211
5221
|
});
|
|
4212
5222
|
$('version').addEventListener('click', openChangelog);
|
|
@@ -4267,22 +5277,61 @@ $('models-more').addEventListener('click', () => {
|
|
|
4267
5277
|
renderModels();
|
|
4268
5278
|
$('models-list').children[before]?.querySelector('.model-toggle')?.focus();
|
|
4269
5279
|
});
|
|
4270
|
-
$('stop-manager').addEventListener('click', firstClick(() => stopManager()));
|
|
4271
|
-
$('restart-manager').addEventListener('click', firstClick(() => stopManager({ restart: true })));
|
|
5280
|
+
$('stop-manager').addEventListener('click', firstClick(() => { closeMenu($('manager-menu')); stopManager(); }));
|
|
5281
|
+
$('restart-manager').addEventListener('click', firstClick(() => { closeMenu($('manager-menu')); stopManager({ restart: true }); }));
|
|
4272
5282
|
$('copy-command').addEventListener('click', copyCommand);
|
|
4273
5283
|
$('upgrade').addEventListener('click', upgradeManager);
|
|
4274
|
-
$('
|
|
5284
|
+
$('settings-menu').addEventListener('change', (e) => {
|
|
4275
5285
|
if (e.target.name === 'skin') changeSkin(e.target);
|
|
4276
5286
|
else if (e.target.name === 'theme') changeTheme(e.target);
|
|
4277
5287
|
else if (e.target.name === 'sound') changeSound(e.target);
|
|
4278
5288
|
else if (e.target.name === 'voice') changeVoice(e.target);
|
|
4279
5289
|
});
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
5290
|
+
/**
|
|
5291
|
+
* Runs `opened` once a menu shows and `closed` once it hides. Chrome skips the toggle event of a menu
|
|
5292
|
+
* that closes and opens again within a task, so this follows beforetoggle, which always fires.
|
|
5293
|
+
*/
|
|
5294
|
+
function onMenu(menu, opened, closed = () => {}) {
|
|
5295
|
+
menu.addEventListener('beforetoggle', (e) => {
|
|
5296
|
+
const opening = e.newState === 'open';
|
|
5297
|
+
requestAnimationFrame(() => {
|
|
5298
|
+
if (menu.matches(':popover-open') === opening) (opening ? opened : closed)(menu);
|
|
5299
|
+
});
|
|
5300
|
+
});
|
|
5301
|
+
}
|
|
5302
|
+
/** A menu closed with its focus inside, or with nothing focused after it, hands the focus back to its button. */
|
|
5303
|
+
function returnFocus(menu, invoker) {
|
|
5304
|
+
const at = document.activeElement;
|
|
5305
|
+
if ((!at || at === document.body || menu.contains(at)) && invoker?.checkVisibility?.()) invoker.focus();
|
|
5306
|
+
}
|
|
5307
|
+
onMenu($('settings-menu'), (menu) => {
|
|
5308
|
+
placeMenu(menu, $('settings'));
|
|
5309
|
+
menu.querySelector('input:checked')?.focus();
|
|
5310
|
+
}, (menu) => returnFocus(menu, $('settings')));
|
|
5311
|
+
onMenu($('manager-menu'), (menu) => {
|
|
5312
|
+
placeMenu(menu, $('manager'));
|
|
5313
|
+
menuItems(menu)[0]?.focus();
|
|
5314
|
+
}, (menu) => returnFocus(menu, $('manager')));
|
|
5315
|
+
$('panel-sessions').addEventListener('beforetoggle', (e) => { if (e.newState === 'open') renderSessionMenu(); });
|
|
5316
|
+
onMenu($('panel-sessions'), (menu) => {
|
|
5317
|
+
placeMenu(menu, sessionInvoker());
|
|
5318
|
+
(menu.querySelector('.menu-item[aria-current="true"]') ?? menuItems(menu)[0])?.focus();
|
|
5319
|
+
}, (menu) => returnFocus(menu, sessionInvoker()));
|
|
5320
|
+
for (const menu of [$('manager-menu'), $('panel-sessions')]) menu.addEventListener('keydown', moveInMenu);
|
|
5321
|
+
// Recorded only on the click that opens the menu: a click that closes it leaves what the open menu showed.
|
|
5322
|
+
const sessionMenuFrom = (mode, invoker) => () => {
|
|
5323
|
+
if (!$('panel-sessions').matches(':popover-open')) Object.assign(sessionMenu, { mode, invoker });
|
|
5324
|
+
};
|
|
5325
|
+
$('panel-switch').addEventListener('click', sessionMenuFrom('switch', $('panel-switch')));
|
|
5326
|
+
$('panel-split').addEventListener('click', sessionMenuFrom('beside', $('panel-split')));
|
|
5327
|
+
$('panel-swap').addEventListener('click', () => focusPane(1 - state.focusedPane));
|
|
5328
|
+
window.addEventListener('resize', () => {
|
|
5329
|
+
if (topbarInline(innerWidth)) closeMenu($('topbar-menu'));
|
|
5330
|
+
applyDockLayout();
|
|
5331
|
+
placeMenu($('settings-menu'), $('settings'));
|
|
5332
|
+
placeMenu($('manager-menu'), $('manager'));
|
|
5333
|
+
placeMenu($('panel-sessions'), sessionInvoker());
|
|
4284
5334
|
});
|
|
4285
|
-
window.addEventListener('resize', placeAppearanceMenu);
|
|
4286
5335
|
$('providers').addEventListener('animationend', (e) => {
|
|
4287
5336
|
if (e.target === e.currentTarget.lastElementChild) e.currentTarget.classList.remove('deal');
|
|
4288
5337
|
});
|
|
@@ -4322,21 +5371,28 @@ $('panel-stop').addEventListener('click', () => {
|
|
|
4322
5371
|
else removeSession(s.id);
|
|
4323
5372
|
});
|
|
4324
5373
|
$('cwd').value = load(CWD_KEY) || '';
|
|
5374
|
+
$('cwd-pick').addEventListener('click', firstClick(pickWorkingFolder));
|
|
5375
|
+
$('cwd-open').addEventListener('click', firstClick(openWorkingFolder));
|
|
4325
5376
|
try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
|
|
4326
5377
|
try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
|
|
4327
5378
|
githubView.accountId = Number(load(GITHUB_ACCOUNT_KEY)) || null;
|
|
5379
|
+
try { githubPick.recent = JSON.parse(load(GITHUB_RECENT_KEY)) || []; } catch { githubPick.recent = []; }
|
|
5380
|
+
if (!Array.isArray(githubPick.recent)) githubPick.recent = [];
|
|
5381
|
+
if (GITHUB_VIEWS.includes(load(GITHUB_VIEW_KEY))) githubPick.view = load(GITHUB_VIEW_KEY);
|
|
4328
5382
|
if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
|
|
4329
5383
|
if (typeof state.shellPicks !== 'object' || Array.isArray(state.shellPicks)) state.shellPicks = {};
|
|
4330
5384
|
setInterval(renderSessions, 30000);
|
|
4331
5385
|
setInterval(tickNews, 30000);
|
|
4332
|
-
setInterval(() => { if (
|
|
5386
|
+
setInterval(() => { if (dockShows('github') && state.github) renderGitHub(); }, 30000);
|
|
4333
5387
|
|
|
4334
|
-
// The terminal panel
|
|
4335
|
-
// narrow screens; publish its height so the panel never covers its controls.
|
|
5388
|
+
// The terminal panel and the dock sit below the top bar; publish where it ends so they never cover its controls.
|
|
4336
5389
|
const topbar = document.querySelector('.topbar');
|
|
4337
|
-
const publishTopbarHeight = () => document.documentElement.style.setProperty('--topbar-h', `${topbar.
|
|
5390
|
+
const publishTopbarHeight = () => document.documentElement.style.setProperty('--topbar-h', `${Math.max(0, Math.round(topbar.getBoundingClientRect().bottom))}px`);
|
|
4338
5391
|
new ResizeObserver(publishTopbarHeight).observe(topbar);
|
|
5392
|
+
addEventListener('scroll', publishTopbarHeight, { passive: true });
|
|
4339
5393
|
publishTopbarHeight();
|
|
5394
|
+
applyDockLayout();
|
|
4340
5395
|
|
|
5396
|
+
state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
|
|
4341
5397
|
state.token = readTokenFromHash() || load(TOKEN_KEY);
|
|
4342
5398
|
boot();
|