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