clavarium 0.1.5 → 0.2.0-next.2
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/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/server/api.mjs +8 -0
- package/server/conversations.mjs +37 -4
- package/server/gitpush.mjs +63 -0
- package/server/main.mjs +4 -0
- package/server/names.mjs +31 -0
- package/server/toolbox.mjs +1 -1
- package/web/app.js +575 -246
- package/web/gitgraph.js +116 -0
- package/web/index.html +150 -135
- package/web/links.js +6 -2
- package/web/style.css +201 -4
- package/web/vendor/diff2html/LICENSE +49 -0
- package/web/vendor/diff2html/diff2html-ui-slim.min.js +1 -0
- package/web/vendor/diff2html/diff2html.min.css +1 -0
package/web/app.js
CHANGED
|
@@ -650,12 +650,12 @@
|
|
|
650
650
|
function enter(keepSheets = false) {
|
|
651
651
|
if (welcoming) return;
|
|
652
652
|
const c = conversations.find((x) => x.name === current);
|
|
653
|
-
$('name').textContent = current || 'No conversation';
|
|
654
|
-
document.title = current ? `${current} - Clavarium` : 'Clavarium';
|
|
653
|
+
$('name').textContent = (c ? shownName(c) : current) || 'No conversation';
|
|
654
|
+
document.title = current ? `${c ? shownName(c) : current} - Clavarium` : 'Clavarium';
|
|
655
655
|
$('tool').hidden = !c; $('tool').textContent = c?.toolLabel ?? '';
|
|
656
656
|
paintActions();
|
|
657
657
|
$('copy-open').hidden = !current || (home && (!c || c.phase !== 'ready'));
|
|
658
|
-
if (!keepSheets) $('panel').hidden = $('files').hidden = $('copy').hidden = true;
|
|
658
|
+
if (!keepSheets) { closeRoomAssets(); $('panel').hidden = $('files').hidden = $('copy').hidden = true; }
|
|
659
659
|
paintChips();
|
|
660
660
|
$('keys').hidden = !current;
|
|
661
661
|
if (!current) { disconnect(); terminal.reset(); say('', 'ended'); show(home ? 'Choose a conversation, or create one.' : 'This address names no conversation.'); return; }
|
|
@@ -731,10 +731,20 @@
|
|
|
731
731
|
if (!r.ok) throw Object.assign(new Error(j.error || 'The request failed.'), { kind: j.kind, retry: j.retry === true });
|
|
732
732
|
return j;
|
|
733
733
|
};
|
|
734
|
+
/* The name a person reads; a conversation made before free names is read by its identifier. */
|
|
735
|
+
const shownName = (c) => (c && typeof c.title === 'string' && c.title ? c.title : c?.name ?? '');
|
|
736
|
+
/* It needs its owner: it asks or waits, its answerer asks, it failed, its machine is unwell. Said even when it is hidden. */
|
|
737
|
+
const needsYou = (c) => Boolean((c.talk && (c.talk.waiting > 0 || c.talk.state === 'waiting')) || c.answerer?.needs || c.phase === 'failed'
|
|
738
|
+
|| (c.phase === 'ready' && !c.asleep && ['warn', 'bad'].includes(c.health?.level)));
|
|
739
|
+
/* Hidden conversations are shown on a click on "N hidden", for this page only. */
|
|
740
|
+
let showHidden = false;
|
|
734
741
|
function paint() {
|
|
735
742
|
const items = $('items');
|
|
736
743
|
items.replaceChildren();
|
|
744
|
+
const hidden = conversations.filter((c) => c.hidden === true);
|
|
737
745
|
for (const c of conversations) {
|
|
746
|
+
/* The one open stays in sight, hidden or not. */
|
|
747
|
+
if (c.hidden === true && !showHidden && c.name !== current) continue;
|
|
738
748
|
const b = document.createElement('button');
|
|
739
749
|
b.type = 'button'; b.className = 'item'; b.dataset.phase = c.phase;
|
|
740
750
|
/* Ready, but its tool not there yet: it still reads as under way. */
|
|
@@ -742,7 +752,9 @@
|
|
|
742
752
|
if (c.name === current) b.setAttribute('aria-current', 'true');
|
|
743
753
|
const title = document.createElement('b');
|
|
744
754
|
const dot = document.createElement('span'); dot.className = 'dot';
|
|
745
|
-
title.append(dot, c
|
|
755
|
+
title.append(dot, shownName(c));
|
|
756
|
+
if (c.title) b.title = c.name;
|
|
757
|
+
if (c.hidden === true) b.dataset.hidden = 'true';
|
|
746
758
|
const sub = document.createElement('small');
|
|
747
759
|
/* Under way: the step the panel shows, in its words (the tool named once). */
|
|
748
760
|
const under = c.progress && ['building', 'restarting', 'ready'].includes(c.phase) ? stepNow(c.progress, c.toolLabel) : '';
|
|
@@ -761,6 +773,14 @@
|
|
|
761
773
|
b.addEventListener('click', () => select(c.name));
|
|
762
774
|
items.append(b);
|
|
763
775
|
}
|
|
776
|
+
const toggle = $('hidden-toggle');
|
|
777
|
+
toggle.hidden = DEVICE || hidden.length === 0;
|
|
778
|
+
toggle.textContent = showHidden ? `Hide ${hidden.length} hidden` : `${hidden.length} hidden`;
|
|
779
|
+
toggle.setAttribute('aria-expanded', String(showHidden));
|
|
780
|
+
const calling = hidden.some(needsYou);
|
|
781
|
+
toggle.dataset.needs = String(calling);
|
|
782
|
+
toggle.title = calling ? 'A hidden conversation needs you' : '';
|
|
783
|
+
if (calling && !showHidden) { const dot = document.createElement('span'); dot.className = 'needs-dot'; dot.setAttribute('aria-label', 'one needs you'); toggle.append(' ', dot); }
|
|
764
784
|
$('empty').hidden = conversations.length > 0;
|
|
765
785
|
paintDeviceCount();
|
|
766
786
|
}
|
|
@@ -832,16 +852,21 @@
|
|
|
832
852
|
$('create').hidden = false;
|
|
833
853
|
focusField($('create-name'));
|
|
834
854
|
}
|
|
855
|
+
$('hidden-toggle').addEventListener('click', () => { showHidden = !showHidden; paint(); });
|
|
835
856
|
$('new').addEventListener('click', () => { void openCreate(); });
|
|
836
857
|
$('create-cancel').addEventListener('click', () => { $('create').hidden = true; });
|
|
858
|
+
/* The identifier drawn from the name, as the server draws it (names.mjs), shown before it is made; the server decides. */
|
|
859
|
+
const identifierOf = (title) => String(title).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 32).replace(/-+$/, '') || 'conversation';
|
|
860
|
+
$('create-name').addEventListener('input', () => { $('create-id').placeholder = identifierOf($('create-name').value.trim()); });
|
|
837
861
|
$('create').addEventListener('submit', async (event) => {
|
|
838
862
|
event.preventDefault();
|
|
839
|
-
const
|
|
863
|
+
const title = $('create-name').value.trim();
|
|
864
|
+
const id = $('create-id').value.trim();
|
|
840
865
|
try {
|
|
841
|
-
await post(`${API}/conversations`, { name, tool: chosenTool, workOnDisk: $('create-disk').checked });
|
|
842
|
-
$('create').hidden = true; $('create-name').value = ''; $('create-disk').checked = false;
|
|
866
|
+
const made = await post(`${API}/conversations`, { title, ...(id ? { name: id } : {}), tool: chosenTool, workOnDisk: $('create-disk').checked });
|
|
867
|
+
$('create').hidden = true; $('create-name').value = ''; $('create-id').value = ''; $('create-disk').checked = false;
|
|
843
868
|
await refresh();
|
|
844
|
-
select(name);
|
|
869
|
+
select(made.name);
|
|
845
870
|
} catch (error) { $('create-error').textContent = error.message; $('create-error').hidden = false; }
|
|
846
871
|
});
|
|
847
872
|
|
|
@@ -973,20 +998,37 @@
|
|
|
973
998
|
|
|
974
999
|
/* ---------- folders ---------- */
|
|
975
1000
|
const LEVELS = [['none', 'Off'], ['read', 'Read'], ['write', 'Write']];
|
|
1001
|
+
/* THE BAR (G): Chat | Terminal | Assets N, then its settings. What was
|
|
1002
|
+
given to it is counted on "Assets", and shown there. */
|
|
1003
|
+
let assetsShown = false; let roomPick = '';
|
|
976
1004
|
function paintChips() {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1005
|
+
if (DEVICE) return;
|
|
1006
|
+
const c = conversations.find((x) => x.name === current);
|
|
1007
|
+
$('views').hidden = !c;
|
|
1008
|
+
const n = (c?.resources?.length ?? 0) + (c?.secrets?.length ?? 0) + (c?.work === 'kept' ? 1 : 0);
|
|
1009
|
+
$('assets-count').textContent = n ? String(n) : '';
|
|
1010
|
+
/* Its title names each of them, with what it may do. */
|
|
1011
|
+
$('view-assets').title = c ? [...(c.offline === true ? ['offline'] : []), ...(c.resources ?? []).map((r) => `${r.name} - ${r.access}`), ...(c.secrets ?? [])].join(', ') || 'Nothing given yet' : '';
|
|
1012
|
+
paintViews();
|
|
1013
|
+
/* Drawn again only when what it was given changed (not at each look at the list). */
|
|
1014
|
+
const sig = JSON.stringify([current, c?.resources, c?.secrets, c?.work]);
|
|
1015
|
+
if (assetsShown && sig !== roomSig) void paintRoomAssets();
|
|
1016
|
+
}
|
|
1017
|
+
let roomSig = '';
|
|
1018
|
+
function paintViews() {
|
|
1019
|
+
if (DEVICE) return;
|
|
1020
|
+
$('view-chat').setAttribute('aria-pressed', String(simple && !assetsShown));
|
|
1021
|
+
$('view-terminal').setAttribute('aria-pressed', String(!simple && !assetsShown));
|
|
1022
|
+
$('view-assets').setAttribute('aria-pressed', String(assetsShown));
|
|
1023
|
+
}
|
|
1024
|
+
function chooseView(which) {
|
|
1025
|
+
if (which === 'assets') { void openRoomAssets(); return; }
|
|
1026
|
+
closeRoomAssets();
|
|
1027
|
+
if ((which === 'chat') !== simple) toggleSimple(); else if (!simple) focusTerminal();
|
|
1028
|
+
}
|
|
1029
|
+
$('view-chat').addEventListener('click', () => chooseView('chat'));
|
|
1030
|
+
$('view-terminal').addEventListener('click', () => chooseView('terminal'));
|
|
1031
|
+
$('view-assets').addEventListener('click', () => chooseView('assets'));
|
|
990
1032
|
/* ONE RULE: the left column is the platform, the bar is this conversation.
|
|
991
1033
|
Its Access chapter what THIS conversation has: attached folders,
|
|
992
1034
|
repositories and mailboxes, secrets handed to its tool,
|
|
@@ -1380,7 +1422,7 @@
|
|
|
1380
1422
|
const facts = $('general-facts'); facts.replaceChildren();
|
|
1381
1423
|
const fact = (term, value) => { const dt = document.createElement('dt'); dt.textContent = term; const dd = document.createElement('dd'); dd.textContent = value; facts.append(dt, dd); };
|
|
1382
1424
|
if (here) {
|
|
1383
|
-
fact('
|
|
1425
|
+
fact('Identifier', here.name);
|
|
1384
1426
|
fact('Tool', here.toolLabel ?? here.tool);
|
|
1385
1427
|
if (here.createdAt) fact('Created', new Date(here.createdAt).toLocaleString());
|
|
1386
1428
|
fact('Machine', `${sizeWords(here.memoryMb)} of memory, ${here.diskGb ? `${here.diskGb} GB` : 'an unknown size of'} disk`);
|
|
@@ -1556,6 +1598,7 @@
|
|
|
1556
1598
|
const chatting = talk.on && talkOf === current;
|
|
1557
1599
|
$('simple-toggle').hidden = DEVICE && (!chatting || noTerminal());
|
|
1558
1600
|
$('simple-toggle').textContent = simple ? (chatting ? 'Terminal' : 'Its screen') : chatting ? 'Chat' : 'Simple';
|
|
1601
|
+
paintViews();
|
|
1559
1602
|
if (!simple) return;
|
|
1560
1603
|
const c = conversations.find((x) => x.name === current);
|
|
1561
1604
|
const state = $('state').textContent;
|
|
@@ -1566,14 +1609,15 @@
|
|
|
1566
1609
|
$('simple-says').textContent = saysNow() || 'Nothing on its screen yet.';
|
|
1567
1610
|
if (Date.now() - simpleFilesAt > 8000) void paintSimpleFiles();
|
|
1568
1611
|
}
|
|
1569
|
-
$('simple-toggle').addEventListener('click', () =>
|
|
1612
|
+
$('simple-toggle').addEventListener('click', () => toggleSimple());
|
|
1613
|
+
function toggleSimple() {
|
|
1570
1614
|
simple = !simple;
|
|
1571
1615
|
/* Its terminal, one click from a chat, for now: the next opening is on the chat again. */
|
|
1572
1616
|
if (!(talk.on && talkOf === current)) { simplePref = simple; try { localStorage.setItem('clavarium-simple', simple ? '1' : '0'); } catch { /* as above */ } }
|
|
1573
1617
|
simpleFilesAt = 0; paintSimple();
|
|
1574
1618
|
if (simple) void readTalk().then(paintThread);
|
|
1575
1619
|
if (!simple) focusTerminal();
|
|
1576
|
-
}
|
|
1620
|
+
}
|
|
1577
1621
|
/* ---------- talk: a conversation with the tool, shown by Parley ---------- */
|
|
1578
1622
|
/* The exchange is drawn by Parley's own library (a project of its own,
|
|
1579
1623
|
served from /vendor): this page tells it where to load from, how to
|
|
@@ -1740,6 +1784,9 @@
|
|
|
1740
1784
|
if (DEVICE) { $('talk-box').hidden = true; $('voice-mark-box').hidden = true; return; }
|
|
1741
1785
|
const here = conversations.find((x) => x.name === current);
|
|
1742
1786
|
if (document.activeElement !== $('chat-name')) $('chat-name').value = here?.chatName ?? '';
|
|
1787
|
+
if (document.activeElement !== $('title-name')) $('title-name').value = shownName(here);
|
|
1788
|
+
$('title-box').hidden = $('hide-box').hidden = DEVICE || !here;
|
|
1789
|
+
for (const b of $('hide-on').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.hidden === true)));
|
|
1743
1790
|
$('voice-mark-box').hidden = !here?.ai;
|
|
1744
1791
|
for (const b of $('voice-mark').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.voiceMark === true)));
|
|
1745
1792
|
await readTalk();
|
|
@@ -1758,6 +1805,19 @@
|
|
|
1758
1805
|
catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
|
|
1759
1806
|
});
|
|
1760
1807
|
}
|
|
1808
|
+
$('title-form').addEventListener('submit', async (event) => {
|
|
1809
|
+
event.preventDefault();
|
|
1810
|
+
$('panel-error').hidden = true;
|
|
1811
|
+
try { await post(`${API}/conversations/${encodeURIComponent(current)}/title`, { title: $('title-name').value.trim() }); $('title-name').blur(); await refresh(); enter(true); }
|
|
1812
|
+
catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
|
|
1813
|
+
});
|
|
1814
|
+
for (const b of $('hide-on').children) {
|
|
1815
|
+
b.addEventListener('click', async () => {
|
|
1816
|
+
$('panel-error').hidden = true;
|
|
1817
|
+
try { await post(`${API}/conversations/${encodeURIComponent(current)}/hidden`, { on: b.dataset.on === 'true' }); await refresh(); for (const x of $('hide-on').children) x.setAttribute('aria-pressed', String(x === b)); }
|
|
1818
|
+
catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1761
1821
|
$('chat-name-form').addEventListener('submit', async (event) => {
|
|
1762
1822
|
event.preventDefault();
|
|
1763
1823
|
$('panel-error').hidden = true;
|
|
@@ -2041,96 +2101,280 @@
|
|
|
2041
2101
|
$('chips').addEventListener('click', () => { if (!DEVICE && current) void openAccess(false); });
|
|
2042
2102
|
$('panel-close').addEventListener('click', () => { $('panel').hidden = true; focusTerminal(); });
|
|
2043
2103
|
|
|
2044
|
-
/* ----------
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
for (const kind of
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
const
|
|
2064
|
-
const
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
const
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
review.addEventListener('click', () => { void openGit(resource.name); });
|
|
2079
|
-
row.append(review);
|
|
2080
|
-
}
|
|
2081
|
-
/* Filtered on one conversation: what it may do with this, at once, without opening the card. */
|
|
2082
|
-
const only = ready.find((c) => c.name === accessFor);
|
|
2083
|
-
if (accessFor) row.append(forRow(only, kind === 'mailbox' ? [['none', 'Off'], ['read', 'Read']] : [['none', 'Off'], ['read', 'Read'], ['write', 'Write']], resource.attachments?.[accessFor] ?? 'none',
|
|
2084
|
-
async (access) => { await attachTo(accessFor, resource.name, access, 'assets-error'); await paintAssets(); }, `What ${accessFor} may do with ${resource.name}`));
|
|
2085
|
-
if (assetOpen === asset) {
|
|
2086
|
-
/* Opened: every conversation, with what it may do with this. */
|
|
2087
|
-
const inside = document.createElement('div'); inside.className = 'asset-inside';
|
|
2088
|
-
if (kind === 'git' && resource.from) {
|
|
2089
|
-
const where = document.createElement('p'); where.className = 'hint';
|
|
2090
|
-
where.textContent = `Cloned from ${resource.from}${viaText(resource.via)}.${resource.via?.hostKey ? ` Host key recorded for ${hostOf(resource.from)}: ${resource.via.hostKey}.` : ''}`;
|
|
2091
|
-
inside.append(where);
|
|
2092
|
-
}
|
|
2093
|
-
for (const c of ready.filter((x) => x.name !== accessFor)) {
|
|
2094
|
-
const line = document.createElement('div'); line.className = 'given-row'; line.dataset.conversation = c.name;
|
|
2095
|
-
const who = document.createElement('span'); who.textContent = c.name;
|
|
2096
|
-
const options = kind === 'mailbox' ? [['none', 'Off'], ['read', 'Read']] : [['none', 'Off'], ['read', 'Read'], ['write', 'Write']];
|
|
2097
|
-
line.append(who, modes(options, resource.attachments?.[c.name] ?? 'none', async (access) => { await attachTo(c.name, resource.name, access, 'assets-error'); await paintAssets(); }, `What ${c.name} may do with ${resource.name}`));
|
|
2098
|
-
inside.append(line);
|
|
2099
|
-
}
|
|
2100
|
-
if (ready.length === 0) { const none = document.createElement('p'); none.className = 'hint'; none.textContent = 'No conversation is ready to attach it to.'; inside.append(none); }
|
|
2101
|
-
const actions = document.createElement('div'); actions.className = 'row';
|
|
2102
|
-
const give = document.createElement('button'); give.type = 'button'; give.textContent = holders.length ? `Shared with ${holders.length} - Share...` : 'Share...';
|
|
2103
|
-
give.addEventListener('click', () => { void openCompose([[asset, 'r']]); });
|
|
2104
|
-
/* Always shown; when it cannot be done, it says why. */
|
|
2105
|
-
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Remove';
|
|
2106
|
-
const why = document.createElement('span'); why.className = 'hint';
|
|
2107
|
-
if (users.length > 0) { remove.disabled = true; why.textContent = `attached to ${users.length} conversation${users.length > 1 ? 's' : ''}: set ${users.length > 1 ? 'them' : 'it'} to Off first`; }
|
|
2108
|
-
remove.addEventListener('click', () => {
|
|
2109
|
-
const sure = document.createElement('button'); sure.type = 'button'; sure.className = 'danger'; sure.textContent = `Yes, remove ${resource.name} (its files are kept aside on the server)`;
|
|
2110
|
-
sure.addEventListener('click', async () => {
|
|
2111
|
-
try { await post(`${API}/resources/${resource.name}/remove`); assetOpen = ''; }
|
|
2112
|
-
catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
|
|
2113
|
-
await paintAssets();
|
|
2114
|
-
});
|
|
2115
|
-
remove.replaceWith(sure);
|
|
2116
|
-
});
|
|
2117
|
-
actions.append(give, remove, why); inside.append(actions); row.append(inside);
|
|
2104
|
+
/* ---------- ASSETS: one design, two pages (G and H) ----------
|
|
2105
|
+
The server's (a place, beside Access and Settings): everything kept
|
|
2106
|
+
here, by kind, with a search and a small "+" per kind; one opened on
|
|
2107
|
+
the right says who uses it, its state, its settings. A conversation's
|
|
2108
|
+
own (the bar's "Assets N"): what it was given, the same rows; a folder
|
|
2109
|
+
opened shows its files (the Files sheet, moved in), a repository its
|
|
2110
|
+
branches. On a phone, the list, then one asset with "All assets". */
|
|
2111
|
+
const ASSET_KINDS = [['git', 'Git repositories', 'git-branch'], ['folder', 'Folders', 'folder'], ['mailbox', 'Mailboxes', 'mail'], ['secret', 'Secrets', 'key-round']];
|
|
2112
|
+
const byKind = (items) => ASSET_KINDS.flatMap(([k]) => items.filter((i) => i.kind === k));
|
|
2113
|
+
const getJson = (path, fallback) => fetch(`${API}${path}`).then((r) => (r.ok ? r.json() : fallback)).catch(() => fallback);
|
|
2114
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
|
2115
|
+
const kindIcon = (kind) => { const name = ASSET_KINDS.find((k) => k[0] === kind)?.[2]; const holder = el('span', 'as-ic'); holder.dataset.kind = kind; const svg = name ? window.clavariumIcons?.icon(name) : null; if (svg) holder.append(svg); return holder; };
|
|
2116
|
+
const roundPlus = (label, onclick) => { const b = el('button', 'as-add', '+'); b.type = 'button'; b.setAttribute('aria-label', label); b.title = label; b.addEventListener('click', onclick); return b; };
|
|
2117
|
+
/* The rows, by kind. items: [{key, kind, name, sub, badge, badgeTitle, tone}]. */
|
|
2118
|
+
function paintAsList(holder, items, { chosen, choose, add, empty }) {
|
|
2119
|
+
holder.replaceChildren();
|
|
2120
|
+
for (const [kind, title] of ASSET_KINDS) {
|
|
2121
|
+
const mine = items.filter((i) => i.kind === kind);
|
|
2122
|
+
if (mine.length === 0 && !add) continue;
|
|
2123
|
+
const section = el('section', 'as-kind'); section.dataset.kind = kind;
|
|
2124
|
+
const head = el('div', 'as-kind-head'); head.append(el('h3', '', title));
|
|
2125
|
+
if (add) head.append(roundPlus(`Add ${{ git: 'a repository', folder: 'a folder', mailbox: 'a mailbox', secret: 'a secret' }[kind]}`, () => add(kind)));
|
|
2126
|
+
section.append(head);
|
|
2127
|
+
if (mine.length === 0) section.append(el('p', 'hint as-none', 'None yet'));
|
|
2128
|
+
for (const item of mine) {
|
|
2129
|
+
const row = el('button', 'as-row'); row.type = 'button'; row.dataset.asset = item.key; row.dataset.name = item.name;
|
|
2130
|
+
if (item.key === chosen) row.setAttribute('aria-current', 'true');
|
|
2131
|
+
const name = el('b', '', item.name);
|
|
2132
|
+
if (item.badge) { const badge = el('span', `as-badge${item.badgeTone ? ` ${item.badgeTone}` : ''}`, item.badge); if (item.badgeTitle) badge.title = item.badgeTitle; name.append(badge); }
|
|
2133
|
+
const sub = el('small', item.tone ? `as-sub ${item.tone}` : 'as-sub', item.sub ?? '');
|
|
2134
|
+
const words = el('span', 'as-words'); words.append(name, sub);
|
|
2135
|
+
row.append(kindIcon(kind), words);
|
|
2136
|
+
row.addEventListener('click', () => choose(item.key));
|
|
2137
|
+
section.append(row);
|
|
2118
2138
|
}
|
|
2119
|
-
|
|
2139
|
+
holder.append(section);
|
|
2140
|
+
}
|
|
2141
|
+
if (items.length === 0 && empty) holder.append(el('p', 'hint', empty));
|
|
2142
|
+
}
|
|
2143
|
+
/* The top of a detail: "All assets" (a phone), the icon, the name, a badge. */
|
|
2144
|
+
function asTitle(item, back) {
|
|
2145
|
+
const top = el('div', 'as-title');
|
|
2146
|
+
const all = el('button', 'as-link as-back', '‹ All assets'); all.type = 'button'; all.addEventListener('click', back);
|
|
2147
|
+
const h = el('h2'); h.append(kindIcon(item.kind), el('span', '', item.name));
|
|
2148
|
+
if (item.badge) h.append(el('span', `as-badge${item.badgeTone ? ` ${item.badgeTone}` : ''}`, item.badgeLong ?? item.badge));
|
|
2149
|
+
top.append(all, h);
|
|
2150
|
+
return top;
|
|
2151
|
+
}
|
|
2152
|
+
const chapter = (title, ...extra) => { const head = el('div', 'as-chapter'); head.append(el('h3', '', title), ...extra); return head; };
|
|
2153
|
+
const boxSub = (box) => (!box ? 'mailbox' : box.failure ? box.failure : `${box.messages} message${box.messages === 1 ? '' : 's'}`);
|
|
2154
|
+
const gitSub = (r) => (r.from ? `from ${hostOf(r.from)}` : 'repository');
|
|
2155
|
+
|
|
2156
|
+
/* A repository, on the right: its branch, what would go, the diagram. */
|
|
2157
|
+
async function gitDetail(holder, name, { place = false } = {}) {
|
|
2158
|
+
const facts = el('div', 'as-facts'); facts.append(el('p', 'hint', 'Looking at its branches...'));
|
|
2159
|
+
const acts = el('div', 'as-acts');
|
|
2160
|
+
const review = el('button', '', 'Open the review'); review.type = 'button'; review.dataset.act = 'review';
|
|
2161
|
+
const send = el('button', 'primary', 'Send...'); send.type = 'button'; send.dataset.act = 'send'; send.hidden = true;
|
|
2162
|
+
acts.append(review, send);
|
|
2163
|
+
/* In a conversation, its files too, below. */
|
|
2164
|
+
if (!place) { const files = el('button', '', 'Files'); files.type = 'button'; files.dataset.act = 'files'; files.addEventListener('click', () => { embedFiles(holder, name); files.disabled = true; }); acts.prepend(files); }
|
|
2165
|
+
const diagram = el('div', 'as-graph'); diagram.append(el('p', 'hint', 'Drawing the branches...'));
|
|
2166
|
+
holder.append(facts, chapter('Branches', acts), diagram);
|
|
2167
|
+
const back = place ? { label: 'Assets', go: () => { void paintAssets(); } } : { label: 'Assets', go: () => { void paintRoomAssets(); } };
|
|
2168
|
+
review.addEventListener('click', () => { void openGit(name, { back }); });
|
|
2169
|
+
const [out, graph] = await Promise.all([getJson(`/git/${name}/outgoing`, null), getJson(`/git/${name}/graph`, null)]);
|
|
2170
|
+
facts.replaceChildren();
|
|
2171
|
+
if (out) {
|
|
2172
|
+
const line = el('p', 'as-line');
|
|
2173
|
+
if (out.branch) { line.append('Its agent works on '); line.append(el('b', '', out.branch)); if (out.target) { line.append('; you send to '); line.append(el('b', '', out.target)); } line.append('.'); }
|
|
2174
|
+
else line.textContent = out.message ?? '';
|
|
2175
|
+
const cards = el('div', 'as-cards');
|
|
2176
|
+
const card = (label, value, tone) => { const c = el('div', 'as-card'); c.append(el('small', '', label), el('b', tone ?? '', value)); return c; };
|
|
2177
|
+
const n = out.count;
|
|
2178
|
+
cards.append(card('To send', out.state === 'ahead' ? (n === null ? 'some commits' : `${n} commit${n === 1 ? '' : 's'}`) : out.state === 'up-to-date' ? 'nothing' : '-', out.state === 'ahead' ? 'warn' : 'ok'),
|
|
2179
|
+
card('State', { ahead: 'ahead', 'up-to-date': 'up to date', diverged: 'behind: pull first', 'no-remote': 'no remote', unreachable: 'remote not answering', empty: 'empty' }[out.state] ?? out.state, out.state === 'diverged' || out.state === 'unreachable' ? 'bad' : ''),
|
|
2180
|
+
card('Remote', out.remote ? hostOf(out.remote) : 'none'));
|
|
2181
|
+
facts.append(line, cards);
|
|
2182
|
+
if (out.state === 'ahead' && out.target) { send.hidden = false; send.textContent = `Send to ${out.target}...`; send.addEventListener('click', () => { void openGit(name, { back, target: out.target, branch: out.branch ?? '' }); }); }
|
|
2183
|
+
}
|
|
2184
|
+
diagram.replaceChildren(graph ? window.clavariumGraph.draw(graph, { pick: (c) => { void openGit(name, { commit: c.id, back }); } }) : el('p', 'hint', 'The branches could not be read.'));
|
|
2185
|
+
}
|
|
2186
|
+
/* The files of a folder or a mailbox, in a conversation: the Files sheet, moved into the detail. */
|
|
2187
|
+
let filesHome = null;
|
|
2188
|
+
function embedFiles(holder, name) {
|
|
2189
|
+
const f = $('files');
|
|
2190
|
+
if (!filesHome) filesHome = { parent: f.parentNode, next: f.nextSibling };
|
|
2191
|
+
holder.append(f); f.classList.add('embedded');
|
|
2192
|
+
if (filesOf !== name) { filesOf = name; filesAt = ''; }
|
|
2193
|
+
f.hidden = false;
|
|
2194
|
+
void paintFiles();
|
|
2195
|
+
}
|
|
2196
|
+
function unembedFiles() {
|
|
2197
|
+
const f = $('files');
|
|
2198
|
+
if (!f.classList.contains('embedded') || !filesHome) return;
|
|
2199
|
+
f.classList.remove('embedded'); f.hidden = true;
|
|
2200
|
+
filesHome.parent.insertBefore(f, filesHome.next);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/* ---- a conversation's own ---- */
|
|
2204
|
+
async function paintRoomAssets() {
|
|
2205
|
+
if (!assetsShown) return;
|
|
2206
|
+
const c = conversations.find((x) => x.name === current);
|
|
2207
|
+
roomSig = JSON.stringify([current, c?.resources, c?.secrets, c?.work]);
|
|
2208
|
+
const [all, secrets, boxes] = await Promise.all([getJson('/resources', []), getJson('/secrets', []), getJson('/mailboxes', [])]);
|
|
2209
|
+
const mine = new Map((c?.resources ?? []).map((r) => [r.name, r.access]));
|
|
2210
|
+
const items = [];
|
|
2211
|
+
if (c?.work === 'kept') items.push({ key: 'folder:work', kind: 'folder', name: 'work', own: true, sub: 'where its tool works', badge: 'write', badgeTone: 'write' });
|
|
2212
|
+
for (const r of all.filter((x) => mine.has(x.name))) {
|
|
2213
|
+
const kind = kindOf(r); const access = mine.get(r.name);
|
|
2214
|
+
const box = kind === 'mailbox' ? boxes.find((m) => m.name === r.name) : undefined;
|
|
2215
|
+
items.push({ key: `${kind}:${r.name}`, kind, name: r.name, resource: r, box, badge: access, badgeTone: access, badgeLong: access === 'write' ? 'read and write' : 'read only',
|
|
2216
|
+
sub: kind === 'git' ? gitSub(r) : kind === 'mailbox' ? boxSub(box) : 'folder', tone: box?.failure ? 'bad' : '' });
|
|
2217
|
+
}
|
|
2218
|
+
for (const s2 of secrets.filter((x) => x.conversations.includes(current))) items.push({ key: `secret:${s2.name}`, kind: 'secret', name: s2.name, secret: s2, sub: 'name only · used by its agent' });
|
|
2219
|
+
const chosen = items.find((i) => i.key === roomPick) ?? (narrow() ? null : byKind(items)[0] ?? null);
|
|
2220
|
+
const as = $('cassets-as');
|
|
2221
|
+
as.dataset.open = chosen ? 'detail' : 'list';
|
|
2222
|
+
paintAsList($('cassets-rows'), items, { chosen: chosen?.key, choose: (key) => { roomPick = key; void paintRoomAssets(); }, empty: 'Nothing is given to it yet.' });
|
|
2223
|
+
const top = el('div', 'as-kind-head as-list-top'); top.append(el('h3', '', 'This conversation'), roundPlus('Attach an asset', () => { void openAccess(true); }));
|
|
2224
|
+
$('cassets-rows').prepend(top);
|
|
2225
|
+
if (c?.work === 'disk') $('cassets-rows').append(el('p', 'hint as-disk', 'Its work is on the disk of its machine: nothing of it is kept here.'));
|
|
2226
|
+
const detail = $('cassets-detail');
|
|
2227
|
+
if (!chosen) { unembedFiles(); detail.replaceChildren(); return; }
|
|
2228
|
+
const keepFiles = (chosen.kind === 'folder' || chosen.kind === 'mailbox') && $('files').classList.contains('embedded') && detail.contains($('files')) && detail.dataset.asset === chosen.key;
|
|
2229
|
+
if (!keepFiles) { unembedFiles(); detail.replaceChildren(); }
|
|
2230
|
+
detail.dataset.asset = chosen.key;
|
|
2231
|
+
const back = () => { roomPick = ''; void paintRoomAssets(); };
|
|
2232
|
+
if (keepFiles) { void paintFiles(); return; }
|
|
2233
|
+
detail.append(asTitle(chosen, back));
|
|
2234
|
+
if (chosen.kind === 'folder' || chosen.kind === 'mailbox') {
|
|
2235
|
+
if (chosen.kind === 'mailbox') detail.append(el('p', chosen.box?.failure ? 'error' : 'hint', boxSub(chosen.box)));
|
|
2236
|
+
embedFiles(detail, chosen.name);
|
|
2237
|
+
} else if (chosen.kind === 'git') await gitDetail(detail, chosen.name);
|
|
2238
|
+
else {
|
|
2239
|
+
detail.append(el('p', 'hint', 'Handed to its tool as a variable and a file, the next time it starts. Its value is never shown.'), chapter('Used by'));
|
|
2240
|
+
const list = el('div', 'as-chips');
|
|
2241
|
+
for (const who of chosen.secret.conversations) list.append(el('span', 'as-chip', shownName(conversations.find((x) => x.name === who)) || who));
|
|
2242
|
+
detail.append(list);
|
|
2120
2243
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2244
|
+
}
|
|
2245
|
+
async function openRoomAssets(pick) {
|
|
2246
|
+
if (DEVICE || !current) return;
|
|
2247
|
+
for (const id of ['panel', 'copy']) $(id).hidden = true;
|
|
2248
|
+
if (!$('files').classList.contains('embedded')) $('files').hidden = true;
|
|
2249
|
+
if (pick !== undefined) roomPick = pick;
|
|
2250
|
+
assetsShown = true; $('cassets').hidden = false; paintViews();
|
|
2251
|
+
await paintRoomAssets();
|
|
2252
|
+
}
|
|
2253
|
+
function closeRoomAssets() {
|
|
2254
|
+
if (!assetsShown) return;
|
|
2255
|
+
assetsShown = false; $('cassets').hidden = true; unembedFiles(); paintViews();
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/* ---- the server's ---- */
|
|
2259
|
+
let assetOpen = ''; let assetsAdding = ''; let assetsQuery = ''; let remoteShown = ''; let remoteEditing = false;
|
|
2260
|
+
async function paintAssets() {
|
|
2261
|
+
if (place !== 'assets' || DEVICE) return;
|
|
2262
|
+
const [all, boxes, secrets, keys] = await Promise.all([getJson('/resources', []), getJson('/mailboxes', []), getJson('/secrets', []), getJson('/git-keys', []), readDevices()]);
|
|
2124
2263
|
paintKeys(keys);
|
|
2264
|
+
const items = [];
|
|
2265
|
+
for (const r of all) {
|
|
2266
|
+
const kind = kindOf(r); const users = Object.keys(r.attachments ?? {}).length;
|
|
2267
|
+
const box = kind === 'mailbox' ? boxes.find((m) => m.name === r.name) : undefined;
|
|
2268
|
+
items.push({ key: `${kind}:${r.name}`, kind, name: r.name, resource: r, box, badge: String(users), badgeTitle: `Used by ${users} conversation${users === 1 ? '' : 's'}`, badgeTone: 'count',
|
|
2269
|
+
sub: kind === 'git' ? `${gitSub(r)}${viaText(r.via)}` : kind === 'mailbox' ? boxSub(box) : 'folder', tone: box?.failure ? 'bad' : '' });
|
|
2270
|
+
}
|
|
2271
|
+
for (const s2 of secrets) items.push({ key: `secret:${s2.name}`, kind: 'secret', name: s2.name, secret: s2, badge: String(s2.conversations.length), badgeTitle: `Used by ${s2.conversations.length} conversation${s2.conversations.length === 1 ? '' : 's'}`, badgeTone: 'count', sub: 'name only' });
|
|
2272
|
+
const shown = assetsQuery ? items.filter((i) => i.name.includes(assetsQuery.toLowerCase()) || i.name.toLowerCase().includes(assetsQuery.toLowerCase())) : items;
|
|
2273
|
+
const chosen = assetsAdding ? null : items.find((i) => i.key === assetOpen) ?? (narrow() ? null : byKind(shown)[0] ?? null);
|
|
2274
|
+
$('assets-as').dataset.open = chosen || assetsAdding ? 'detail' : 'list';
|
|
2275
|
+
paintAsList($('assets-rows'), shown, { chosen: chosen?.key, choose: (key) => { assetOpen = key; assetsAdding = ''; remoteEditing = false; remember(false); void paintAssets(); }, add: (kind) => { assetsAdding = kind; void paintAssets(); }, empty: assetsQuery ? 'Nothing by that name.' : '' });
|
|
2276
|
+
const detail = $('assets-detail');
|
|
2277
|
+
/* The forms go back to where they wait before the detail is drawn again. */
|
|
2278
|
+
for (const form of detail.querySelectorAll('.as-form, #gitview-remote-box')) $('assets-forms').append(form);
|
|
2279
|
+
detail.replaceChildren();
|
|
2280
|
+
const back = () => { assetOpen = ''; assetsAdding = ''; void paintAssets(); };
|
|
2281
|
+
if (assetsAdding) {
|
|
2282
|
+
const all2 = el('button', 'as-link as-back', '‹ All assets'); all2.type = 'button'; all2.addEventListener('click', back);
|
|
2283
|
+
const form = $(`${assetsAdding}-box`); form.hidden = false;
|
|
2284
|
+
detail.append(all2, form);
|
|
2285
|
+
if (assetsAdding === 'git') { fillKeyChoice(); showVia(); }
|
|
2286
|
+
focusField(form.querySelector('input:not([type=radio])'));
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
if (!chosen) return;
|
|
2290
|
+
detail.append(asTitle(chosen, back));
|
|
2291
|
+
const ready = conversations.filter((c) => c.phase === 'ready');
|
|
2292
|
+
const r = chosen.resource;
|
|
2293
|
+
/* State, in a line. */
|
|
2294
|
+
if (chosen.kind === 'mailbox') detail.append(el('p', chosen.box?.failure ? 'error' : 'hint', boxSub(chosen.box)));
|
|
2295
|
+
if (chosen.kind === 'secret') detail.append(el('p', 'hint', 'Its value is never shown again. A change takes effect the next time the tool starts.'));
|
|
2296
|
+
/* Used by: each conversation that has it, what it may do; "+" offers the others. */
|
|
2297
|
+
const options = chosen.kind === 'secret' ? [['off', 'Off'], ['use', 'Use']] : chosen.kind === 'mailbox' ? [['none', 'Off'], ['read', 'Read']] : [['none', 'Off'], ['read', 'Read'], ['write', 'Write']];
|
|
2298
|
+
const has = (c) => (chosen.kind === 'secret' ? (chosen.secret.conversations.includes(c.name) ? 'use' : 'off') : r.attachments?.[c.name] ?? 'none');
|
|
2299
|
+
const using = ready.filter((c) => !['none', 'off'].includes(has(c)));
|
|
2300
|
+
const others = ready.filter((c) => ['none', 'off'].includes(has(c)));
|
|
2301
|
+
const users = el('div', 'as-users');
|
|
2302
|
+
const picker = el('div', 'as-users as-picker'); picker.hidden = true;
|
|
2303
|
+
const row = (c) => {
|
|
2304
|
+
const line = el('div', 'given-row'); line.dataset.conversation = c.name;
|
|
2305
|
+
line.append(el('span', '', shownName(c)), modes(options, has(c), async (value) => {
|
|
2306
|
+
if (chosen.kind === 'secret') await handTo(c.name, chosen.name, value === 'use', 'assets-error');
|
|
2307
|
+
else await attachTo(c.name, chosen.name, value, 'assets-error');
|
|
2308
|
+
await paintAssets();
|
|
2309
|
+
}, `What ${c.name} may do with ${chosen.name}`));
|
|
2310
|
+
return line;
|
|
2311
|
+
};
|
|
2312
|
+
for (const c of using) users.append(row(c));
|
|
2313
|
+
if (using.length === 0) users.append(el('p', 'hint', 'No conversation uses it.'));
|
|
2314
|
+
for (const c of others) picker.append(row(c));
|
|
2315
|
+
if (others.length === 0) picker.append(el('p', 'hint', ready.length ? 'Every conversation has it.' : 'No conversation is ready.'));
|
|
2316
|
+
detail.append(chapter('Used by', roundPlus('Give it to a conversation', () => { picker.hidden = !picker.hidden; })), users, picker);
|
|
2317
|
+
if (chosen.kind === 'git') {
|
|
2318
|
+
const holder = el('div', 'as-git'); detail.append(holder);
|
|
2319
|
+
/* Settings: where it is sent, with what. */
|
|
2320
|
+
const settings = await getJson(`/git/${chosen.name}`, {});
|
|
2321
|
+
const edit = el('button', 'as-edit', '✎ Edit'); edit.type = 'button';
|
|
2322
|
+
const table = el('dl', 'as-settings');
|
|
2323
|
+
const fact = (k, v) => { table.append(el('dt', '', k), el('dd', '', v)); };
|
|
2324
|
+
fact('Remote', settings.remote ?? 'none');
|
|
2325
|
+
fact('Sent with', settings.hasKey ? 'a key kept for it' : settings.sshKey ? `SSH key ${settings.sshKey}` : r.via?.method && r.via.method !== 'none' ? viaText(r.via).replace(/^, with /, '') : 'what it was cloned with');
|
|
2326
|
+
if (r.from && r.via?.hostKey) fact('Host key', `${hostOf(r.from)}: ${r.via.hostKey}`);
|
|
2327
|
+
detail.append(chapter('Settings', edit), table);
|
|
2328
|
+
const box = $('gitview-remote-box');
|
|
2329
|
+
remoteShown = chosen.name;
|
|
2330
|
+
await fillRemote(chosen.name, settings);
|
|
2331
|
+
box.hidden = !remoteEditing; box.open = true;
|
|
2332
|
+
detail.append(box);
|
|
2333
|
+
edit.addEventListener('click', () => { remoteEditing = !remoteEditing; box.hidden = !remoteEditing; if (remoteEditing) focusField($('gitview-url')); });
|
|
2334
|
+
void gitDetail(holder, chosen.name, { place: true });
|
|
2335
|
+
}
|
|
2336
|
+
/* Delete, out of the way; refused while it is used, and why. */
|
|
2337
|
+
const remove = el('button', 'as-link danger-link', 'Delete...'); remove.type = 'button';
|
|
2338
|
+
const why = el('small', 'hint');
|
|
2339
|
+
const inUse = chosen.kind !== 'secret' && using.length > 0;
|
|
2340
|
+
if (inUse) { remove.disabled = true; why.textContent = ` used by ${using.length} conversation${using.length > 1 ? 's' : ''}: set ${using.length > 1 ? 'them' : 'it'} to Off first`; }
|
|
2341
|
+
remove.addEventListener('click', () => {
|
|
2342
|
+
const sure = el('button', 'danger', chosen.kind === 'secret' ? `Yes, delete ${chosen.name}` : `Yes, remove ${chosen.name} (its files are kept aside on the server)`); sure.type = 'button';
|
|
2343
|
+
sure.addEventListener('click', async () => {
|
|
2344
|
+
try { await post(chosen.kind === 'secret' ? `${API}/secrets/${chosen.name}/remove` : `${API}/resources/${chosen.name}/remove`); assetOpen = ''; }
|
|
2345
|
+
catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
|
|
2346
|
+
await paintAssets();
|
|
2347
|
+
});
|
|
2348
|
+
remove.replaceWith(sure);
|
|
2349
|
+
});
|
|
2350
|
+
/* Given to a device: from here too (the map shows who has it). */
|
|
2351
|
+
const share = el('button', 'as-link', 'Share with a device...'); share.type = 'button';
|
|
2352
|
+
share.addEventListener('click', () => { void openCompose([[chosen.key, 'r']]); });
|
|
2353
|
+
const foot = el('div', 'as-foot'); foot.append(...(chosen.kind === 'secret' ? [] : [share, el('br')]), remove, why);
|
|
2354
|
+
detail.append(foot);
|
|
2355
|
+
}
|
|
2356
|
+
/* The remote form of a repository, filled for it. */
|
|
2357
|
+
async function fillRemote(name, settings) {
|
|
2358
|
+
$('gitview-url').value = settings.remote ?? ''; $('gitview-user').value = settings.user ?? ''; $('gitview-key').value = '';
|
|
2359
|
+
const pick = $('gitview-sshkey'); pick.replaceChildren();
|
|
2360
|
+
for (const [value, text] of [['', '(none)'], ...gitKeys.map((k) => [k.name, k.name])]) { const o = document.createElement('option'); o.value = value; o.textContent = text; pick.append(o); }
|
|
2361
|
+
pick.value = settings.sshKey ?? '';
|
|
2362
|
+
$('gitview-key-state').textContent = settings.hasKey ? 'A key is kept for this repository.'
|
|
2363
|
+
: settings.sshKey ? `Over ssh, the SSH key ${settings.sshKey} is used.` : 'No key is kept for sending: what the repository was cloned with is used, for the same server only; a remote that asks for more will refuse.';
|
|
2125
2364
|
}
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2365
|
+
$('assets-search').addEventListener('input', () => { assetsQuery = $('assets-search').value.trim(); void paintAssets(); });
|
|
2366
|
+
$('assets-close').addEventListener('click', () => leavePlace());
|
|
2367
|
+
$('assets').addEventListener('keydown', (event) => { if (event.key === 'Escape' && !event.defaultPrevented && event.target.tagName !== 'INPUT') leavePlace(); });
|
|
2368
|
+
/* The Assets place, on one asset (`key`), its remote form open (`remote`). */
|
|
2369
|
+
async function openAssetsPlace(key, { remote = false, push = true } = {}) {
|
|
2370
|
+
if (DEVICE) return;
|
|
2371
|
+
if (key !== undefined) { assetOpen = key; assetsAdding = ''; }
|
|
2372
|
+
remoteEditing = remote;
|
|
2373
|
+
enterPlace('assets', push);
|
|
2374
|
+
$('assets-error').hidden = true;
|
|
2375
|
+
await paintAssets();
|
|
2133
2376
|
}
|
|
2377
|
+
async function openAssets() { await openAssetsPlace(); }
|
|
2134
2378
|
/* ---------- private repositories: how this server gets in ---------- */
|
|
2135
2379
|
/* The host of an address, whatever its form (user@host:path has no URL form). */
|
|
2136
2380
|
const hostOf = (from) => { try { return new URL(from).hostname; } catch { return /@([^:/]+):/.exec(String(from))?.[1] ?? String(from); } };
|
|
@@ -2229,50 +2473,13 @@
|
|
|
2229
2473
|
});
|
|
2230
2474
|
$('git-key-copy').addEventListener('click', () => { void copyField($('git-key-public'), $('git-key-copy')); });
|
|
2231
2475
|
/* Secrets: a name, and which conversations' tools are handed it. A value goes in and never comes back. */
|
|
2232
|
-
function paintSecrets(all, ready) {
|
|
2233
|
-
const rows = $('secret-rows');
|
|
2234
|
-
rows.replaceChildren();
|
|
2235
|
-
for (const secret of all) {
|
|
2236
|
-
const asset = `secret:${secret.name}`;
|
|
2237
|
-
const row = document.createElement('div'); row.className = 'asset'; row.dataset.name = secret.name;
|
|
2238
|
-
const head = document.createElement('button'); head.type = 'button'; head.className = 'asset-head'; head.setAttribute('aria-expanded', String(assetOpen === asset));
|
|
2239
|
-
const name = document.createElement('b'); name.textContent = secret.name;
|
|
2240
|
-
const used = document.createElement('span'); used.className = secret.conversations.length ? '' : 'hint'; used.textContent = secret.conversations.length ? secret.conversations.join(', ') : 'not used';
|
|
2241
|
-
head.append(name, used);
|
|
2242
|
-
head.addEventListener('click', () => { assetOpen = assetOpen === asset ? '' : asset; void paintAssets(); });
|
|
2243
|
-
row.append(head);
|
|
2244
|
-
if (accessFor) row.append(forRow(ready.find((c) => c.name === accessFor), [['off', 'Off'], ['use', 'Use']], secret.conversations.includes(accessFor) ? 'use' : 'off',
|
|
2245
|
-
async (value) => { await handTo(accessFor, secret.name, value === 'use', 'assets-error'); await paintAssets(); }, `Whether the tool of ${accessFor} is handed ${secret.name}`));
|
|
2246
|
-
if (assetOpen === asset) {
|
|
2247
|
-
const inside = document.createElement('div'); inside.className = 'asset-inside';
|
|
2248
|
-
for (const c of ready.filter((x) => x.name !== accessFor)) {
|
|
2249
|
-
const line = document.createElement('div'); line.className = 'given-row'; line.dataset.conversation = c.name;
|
|
2250
|
-
const who = document.createElement('span'); who.textContent = c.name;
|
|
2251
|
-
line.append(who, modes([['off', 'Off'], ['use', 'Use']], secret.conversations.includes(c.name) ? 'use' : 'off', async (value) => { await handTo(c.name, secret.name, value === 'use', 'assets-error'); await paintAssets(); }, `Whether the tool of ${c.name} is handed ${secret.name}`));
|
|
2252
|
-
inside.append(line);
|
|
2253
|
-
}
|
|
2254
|
-
const note = document.createElement('p'); note.className = 'hint'; note.textContent = 'A change takes effect the next time the tool starts (leave the tool, then "Start it again").'; inside.append(note);
|
|
2255
|
-
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Delete';
|
|
2256
|
-
remove.addEventListener('click', () => {
|
|
2257
|
-
const sure = document.createElement('button'); sure.type = 'button'; sure.className = 'danger'; sure.textContent = `Yes, delete ${secret.name}`;
|
|
2258
|
-
sure.addEventListener('click', async () => { await post(`${API}/secrets/${secret.name}/remove`).catch(() => {}); assetOpen = ''; await paintAssets(); });
|
|
2259
|
-
remove.replaceWith(sure);
|
|
2260
|
-
});
|
|
2261
|
-
inside.append(remove); row.append(inside);
|
|
2262
|
-
}
|
|
2263
|
-
rows.append(row);
|
|
2264
|
-
}
|
|
2265
|
-
$('secret-empty').hidden = all.length > 0;
|
|
2266
|
-
}
|
|
2267
2476
|
$('secret-new').addEventListener('submit', async (event) => {
|
|
2268
2477
|
event.preventDefault();
|
|
2269
2478
|
$('assets-error').hidden = true;
|
|
2270
|
-
try { await post(`${API}/secrets`, { name: $('secret-name').value.trim(), value: $('secret-value').value }); $('secret-name').value = ''; $('secret-
|
|
2479
|
+
try { await post(`${API}/secrets`, { name: $('secret-name').value.trim(), value: $('secret-value').value }); assetOpen = `secret:${$('secret-name').value.trim()}`; assetsAdding = ''; $('secret-name').value = ''; $('secret-value').value = ''; }
|
|
2271
2480
|
catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
|
|
2272
2481
|
await paintAssets();
|
|
2273
2482
|
});
|
|
2274
|
-
/* Assets are a tab of Access. */
|
|
2275
|
-
async function openAssets() { await openAccessPage('assets'); }
|
|
2276
2483
|
|
|
2277
2484
|
/* ---------- who gets what, and flows: see links.js ---------- */
|
|
2278
2485
|
/* What those sheets share with the rest of the page: the requests, the
|
|
@@ -2281,6 +2488,7 @@
|
|
|
2281
2488
|
API: () => API, refresh, focusTerminal, openAssets: () => { void openAssets(); }, newDevice: () => { void openCompose([]); }, newConversation: () => { leavePlace({ list: true }); void openCreate(); },
|
|
2282
2489
|
autoSending: (conversation, name) => autoSending(conversation, name),
|
|
2283
2490
|
partsFor: (name) => partsFor(name),
|
|
2491
|
+
deviceCard: (id) => { const d = sharedDevices.find((x) => x.id === id); return d ? deviceCard(d, '', true) : null; },
|
|
2284
2492
|
/* The map says when it turns to its flows by itself (a box's "Trace it"). */
|
|
2285
2493
|
modeIs: (mode) => { if (mode !== mapMode) { mapMode = mode; paintTabs(); remember(true); } },
|
|
2286
2494
|
toFlows: () => openAccessPage('map', { mode: 'flows' }),
|
|
@@ -2297,7 +2505,7 @@
|
|
|
2297
2505
|
find it again, and the names of the screens that came before (links,
|
|
2298
2506
|
flows, assets, sharing) lead to their tab. */
|
|
2299
2507
|
const TABS = ['map', 'devices', 'assets'];
|
|
2300
|
-
const FORMER = { access: ['map', 'map'], map: ['map', 'map'], links: ['map', 'map'], 'who-gets-what': ['map', 'map'], flows: ['map', 'flows'], devices: ['
|
|
2508
|
+
const FORMER = { access: ['map', 'map'], map: ['map', 'map'], links: ['map', 'map'], 'who-gets-what': ['map', 'map'], flows: ['map', 'flows'], devices: ['map', 'map'], sharing: ['map', 'map'] };
|
|
2301
2509
|
let place = 'conversations';
|
|
2302
2510
|
let accessTab = 'map'; let mapMode = 'map'; let accessFor = '';
|
|
2303
2511
|
let placeFrom = 'list'; /* the screen (list or room) a place goes back to */
|
|
@@ -2311,12 +2519,14 @@
|
|
|
2311
2519
|
for (const b of $('nav').querySelectorAll('button[data-place]')) { if (b.dataset.place === which) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); }
|
|
2312
2520
|
$('access').hidden = which !== 'access';
|
|
2313
2521
|
$('settings').hidden = which !== 'settings';
|
|
2314
|
-
|
|
2522
|
+
$('assets').hidden = which !== 'assets';
|
|
2523
|
+
if (which !== 'conversations') { closeRoomAssets(); for (const id of ['panel', 'files', 'copy', 'gitview']) $(id).hidden = true; view('room'); }
|
|
2315
2524
|
}
|
|
2316
2525
|
function placeAddress() {
|
|
2317
2526
|
const q = new URLSearchParams();
|
|
2318
2527
|
if (current) q.set('c', current);
|
|
2319
2528
|
if (place !== 'conversations') q.set('open', place);
|
|
2529
|
+
if (place === 'assets' && assetOpen) q.set('a', assetOpen);
|
|
2320
2530
|
if (place === 'access') { q.set('tab', accessTab === 'map' && mapMode === 'flows' ? 'flows' : accessTab); if (accessFor) q.set('for', accessFor); }
|
|
2321
2531
|
const query = q.toString();
|
|
2322
2532
|
return query ? `?${query}` : location.pathname;
|
|
@@ -2348,7 +2558,6 @@
|
|
|
2348
2558
|
for (const b of $('access-tabs').querySelectorAll('[data-tab]')) { const on = b.dataset.tab === accessTab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; }
|
|
2349
2559
|
$('access-map').hidden = accessTab !== 'map';
|
|
2350
2560
|
$('sharing').hidden = accessTab !== 'devices';
|
|
2351
|
-
$('assets').hidden = accessTab !== 'assets';
|
|
2352
2561
|
for (const b of $('map-mode').querySelectorAll('[data-mode]')) b.setAttribute('aria-pressed', String(b.dataset.mode === mapMode));
|
|
2353
2562
|
if (accessFor && !conversations.some((c) => c.name === accessFor)) accessFor = '';
|
|
2354
2563
|
$('access-for').hidden = !accessFor;
|
|
@@ -2356,8 +2565,10 @@
|
|
|
2356
2565
|
}
|
|
2357
2566
|
/* `of`: the conversation it is filtered on ('' for none; left as it is when not given).
|
|
2358
2567
|
`face`: on the devices tab, 'compose' keeps what openCompose prepared. */
|
|
2359
|
-
async function openAccessPage(tab = accessTab, { mode, of, face: shown = 'manage', push = true, fromPanel = false } = {}) {
|
|
2568
|
+
async function openAccessPage(tab = accessTab, { mode, of, face: shown = 'manage', push = true, fromPanel = false, pin } = {}) {
|
|
2360
2569
|
if (DEVICE) return;
|
|
2570
|
+
/* Assets left Access: a conversation's own, or the server's. */
|
|
2571
|
+
if (tab === 'assets') { if (of) { leavePlace({ keepView: true, panel: false }); await openRoomAssets(); } else await openAssetsPlace(undefined, { push }); return; }
|
|
2361
2572
|
if (of !== undefined) accessFor = of;
|
|
2362
2573
|
accessTab = TABS.includes(tab) ? tab : 'map';
|
|
2363
2574
|
if (mode) mapMode = mode;
|
|
@@ -2368,7 +2579,7 @@
|
|
|
2368
2579
|
if (arriving) void readDevices().then(paintDeviceCount);
|
|
2369
2580
|
if (accessTab === 'map') {
|
|
2370
2581
|
if (mapMode === 'flows') await links?.openFlows();
|
|
2371
|
-
else await links?.open({ pin: accessFor ? `conversation:${accessFor}` : null });
|
|
2582
|
+
else await links?.open({ pin: pin ?? (accessFor ? `conversation:${accessFor}` : null) });
|
|
2372
2583
|
} else if (accessTab === 'devices') {
|
|
2373
2584
|
if (shown === 'compose') { paintCompose(); face('compose'); }
|
|
2374
2585
|
else { face('manage'); await paintManage(); }
|
|
@@ -2390,6 +2601,8 @@
|
|
|
2390
2601
|
if (/^[a-z]{1,20}$/.test(at)) openChapter(`s-${at}`)?.scrollIntoView({ block: 'start' });
|
|
2391
2602
|
return;
|
|
2392
2603
|
}
|
|
2604
|
+
/* Assets, a place of its own now (it was a tab of Access). */
|
|
2605
|
+
if (open === 'assets' || (open === 'access' && q.get('tab') === 'assets')) { await openAssetsPlace(q.get('a') ?? undefined, { push: false }); return; }
|
|
2393
2606
|
const former = FORMER[open === 'access' ? (q.get('tab') ?? 'map') : open];
|
|
2394
2607
|
if (former) { await openAccessPage(former[0], { mode: former[1], of: q.get('for') ?? '', push: false }); return; }
|
|
2395
2608
|
leavePlace({ fromHistory: true });
|
|
@@ -2402,7 +2615,9 @@
|
|
|
2402
2615
|
void followAddress();
|
|
2403
2616
|
});
|
|
2404
2617
|
$('nav-conversations').addEventListener('click', () => { leavePlace({ list: true }); });
|
|
2405
|
-
$('nav-access').addEventListener('click', () => { if (place !== 'access') void openAccessPage(); });
|
|
2618
|
+
$('nav-access').addEventListener('click', () => { if (place !== 'access' || accessTab !== 'map') void openAccessPage('map'); });
|
|
2619
|
+
$('nav-assets').addEventListener('click', () => { if (place !== 'assets') void openAssetsPlace(); });
|
|
2620
|
+
$('map-new-device').addEventListener('click', () => { void openCompose(accessFor ? [[`conversation:${accessFor}`, partsFor(accessFor)]] : []); });
|
|
2406
2621
|
$('nav-settings').addEventListener('click', () => { if (place !== 'settings') void openSettings(); });
|
|
2407
2622
|
$('access-close').addEventListener('click', () => leavePlace());
|
|
2408
2623
|
$('access-for-clear').addEventListener('click', () => { accessFor = ''; void openAccessPage(accessTab, { push: false }); });
|
|
@@ -2418,8 +2633,8 @@
|
|
|
2418
2633
|
for (const b of $('map-mode').querySelectorAll('[data-mode]')) b.addEventListener('click', () => { if (b.dataset.mode !== mapMode || accessTab !== 'map') void openAccessPage('map', { mode: b.dataset.mode }); });
|
|
2419
2634
|
$('access').addEventListener('keydown', (event) => { if (event.key === 'Escape' && !event.defaultPrevented && event.target.closest?.('#links') === null) leavePlace(); });
|
|
2420
2635
|
/* From a conversation's own sheet: Access, on that conversation alone. */
|
|
2421
|
-
$('access-assets-here').addEventListener('click', () => { void
|
|
2422
|
-
$('access-devices-here').addEventListener('click', () => { void openAccessPage('
|
|
2636
|
+
$('access-assets-here').addEventListener('click', () => { void openRoomAssets(); });
|
|
2637
|
+
$('access-devices-here').addEventListener('click', () => { void openAccessPage('map', { of: current, fromPanel: true }); });
|
|
2423
2638
|
|
|
2424
2639
|
/* ---------- Settings: this device, and the platform ---------- */
|
|
2425
2640
|
async function openSettings({ push = true } = {}) {
|
|
@@ -2790,7 +3005,7 @@
|
|
|
2790
3005
|
if (made.firstSeen) {
|
|
2791
3006
|
$('git-said').textContent = `${made.name} was cloned. First contact with ${hostOf(made.from)}: its host key (${made.firstSeen.join(', ')}) was recorded. A different key later will be refused.`;
|
|
2792
3007
|
$('git-said').hidden = false;
|
|
2793
|
-
} else
|
|
3008
|
+
} else assetsAdding = '';
|
|
2794
3009
|
} catch (error) { $('git-error').textContent = error.message; $('git-error').hidden = false; }
|
|
2795
3010
|
button.disabled = false; button.textContent = 'Add';
|
|
2796
3011
|
await paintAssets();
|
|
@@ -2806,12 +3021,16 @@
|
|
|
2806
3021
|
const NEW_BRANCH = '\u0000new';
|
|
2807
3022
|
function paintChecks(result) {
|
|
2808
3023
|
const state = $('gitview-checks-state'); state.className = '';
|
|
2809
|
-
if (!result) { state.textContent = 'Running the checks in the agent\'s machine...'; $('gitview-checks-output').textContent = ''; return; }
|
|
3024
|
+
if (!result) { state.textContent = 'Running the checks in the agent\'s machine...'; $('gitview-checks-output').textContent = ''; const mini = $('gitview-numbers').querySelector('.checks-mini'); if (mini) { mini.textContent = 'Checks running...'; mini.dataset.status = 'running'; } return; }
|
|
2810
3025
|
const where = result.machine ? ` (machine of ${result.machine})` : '';
|
|
2811
3026
|
state.textContent = result.status === 'passed' ? `Checks passed in the agent's machine${where}.`
|
|
2812
3027
|
: result.status === 'failed' ? `Checks FAILED in the agent's machine${where}.`
|
|
2813
3028
|
: result.status === 'none' ? 'Nothing was checked: no pre-push hook, no check command.' : 'The checks could not run.';
|
|
2814
3029
|
if (result.status === 'passed' || result.status === 'failed') state.className = result.status;
|
|
3030
|
+
/* Said again in the numbers line and on the tab: the checks at a glance. */
|
|
3031
|
+
$('gitview-n-checks').textContent = result.status === 'passed' ? '\u2713' : result.status === 'failed' ? '\u2717' : '';
|
|
3032
|
+
const mini = $('gitview-numbers').querySelector('.checks-mini');
|
|
3033
|
+
if (mini) { mini.textContent = result.status === 'passed' ? '\u2713 Checks passed' : result.status === 'failed' ? '\u2717 Checks failed' : result.status === 'none' ? 'No checks' : 'Checks not run'; mini.dataset.status = result.status; }
|
|
2815
3034
|
$('gitview-checks-output').textContent = result.output ?? '';
|
|
2816
3035
|
}
|
|
2817
3036
|
function showSend(passed, commits) {
|
|
@@ -2906,45 +3125,130 @@
|
|
|
2906
3125
|
$('gitview-dropped').hidden = dropped.length === 0;
|
|
2907
3126
|
$('gitview-dropped').textContent = dropped.length ? `Not taken, their names cannot be sent: ${dropped.map((d) => d.name).join(', ')}.` : '';
|
|
2908
3127
|
}
|
|
3128
|
+
/* THE REVIEW PAGE (E). `gitCommit`: one commit looked at (from the branch
|
|
3129
|
+
diagram), never sent from here; otherwise what would go. The diff is
|
|
3130
|
+
drawn by diff2html from its text: each line escaped there, a subject
|
|
3131
|
+
or a path set as text here -- nothing the agent wrote becomes markup. */
|
|
3132
|
+
let gitCommit = ''; let gitBack = null; let gitLayout = 'side-by-side'; let gitTab = 'changes'; let gitFile = 0; let gitFiles = [];
|
|
3133
|
+
const reviewNarrow = () => matchMedia('(max-width: 700px)').matches;
|
|
3134
|
+
/* The files of a diff, from its text: path, lines added and removed, and what became of it. */
|
|
3135
|
+
function diffFiles(text) {
|
|
3136
|
+
const files = [];
|
|
3137
|
+
let f = null;
|
|
3138
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
3139
|
+
if (line.startsWith('diff --git ')) { const m = / b\/(.*)$/.exec(line); f = { path: m ? m[1] : line.slice(11), added: 0, removed: 0, how: 'changed' }; files.push(f); continue; }
|
|
3140
|
+
if (!f) continue;
|
|
3141
|
+
if (line.startsWith('new file mode')) f.how = 'added';
|
|
3142
|
+
else if (line.startsWith('deleted file mode')) f.how = 'deleted';
|
|
3143
|
+
else if (line.startsWith('rename to ')) { f.how = 'renamed'; f.path = line.slice(10); }
|
|
3144
|
+
else if (line.startsWith('Binary files')) f.how = f.how === 'changed' ? 'binary' : f.how;
|
|
3145
|
+
else if (line.startsWith('+') && !line.startsWith('+++ ')) f.added += 1;
|
|
3146
|
+
else if (line.startsWith('-') && !line.startsWith('--- ')) f.removed += 1;
|
|
3147
|
+
}
|
|
3148
|
+
return files;
|
|
3149
|
+
}
|
|
3150
|
+
function paintReviewTab() {
|
|
3151
|
+
for (const b of $('gitview-tabs').querySelectorAll('[data-tab]')) b.setAttribute('aria-selected', String(b.dataset.tab === gitTab));
|
|
3152
|
+
for (const tab of ['changes', 'commits', 'checks']) $(`gitview-pane-${tab}`).hidden = tab !== gitTab;
|
|
3153
|
+
$('gitview-layout').hidden = gitTab !== 'changes' || reviewNarrow();
|
|
3154
|
+
for (const b of $('gitview-layout').querySelectorAll('[data-layout]')) b.setAttribute('aria-pressed', String(b.dataset.layout === gitLayout));
|
|
3155
|
+
}
|
|
3156
|
+
/* On a phone, one file at a time: "1 / 3". */
|
|
3157
|
+
function showFile(at) {
|
|
3158
|
+
const wrappers = [...$('gitview-diff').querySelectorAll('.d2h-file-wrapper')];
|
|
3159
|
+
gitFile = Math.max(0, Math.min(at, wrappers.length - 1));
|
|
3160
|
+
const one = reviewNarrow();
|
|
3161
|
+
wrappers.forEach((w, i) => { w.hidden = one && i !== gitFile; });
|
|
3162
|
+
$('gitview-picker').hidden = !one || wrappers.length < 2;
|
|
3163
|
+
$('gitview-picker-at').textContent = `${gitFile + 1} / ${wrappers.length}`;
|
|
3164
|
+
for (const [i, b] of [...$('gitview-tree').querySelectorAll('button')].entries()) b.setAttribute('aria-current', String(i === gitFile));
|
|
3165
|
+
}
|
|
3166
|
+
function drawDiff(text) {
|
|
3167
|
+
const target = $('gitview-diff');
|
|
3168
|
+
target.replaceChildren();
|
|
3169
|
+
gitFiles = diffFiles(text);
|
|
3170
|
+
const tree = $('gitview-tree'); tree.replaceChildren();
|
|
3171
|
+
gitFiles.forEach((f, i) => {
|
|
3172
|
+
const b = document.createElement('button'); b.type = 'button'; b.dataset.how = f.how;
|
|
3173
|
+
const name = document.createElement('span'); name.className = 'rv-path'; name.textContent = f.path; name.title = f.path;
|
|
3174
|
+
const plus = document.createElement('small'); plus.className = 'plus'; plus.textContent = `+${f.added}`;
|
|
3175
|
+
const minus = document.createElement('small'); minus.className = 'minus'; minus.textContent = `−${f.removed}`;
|
|
3176
|
+
b.append(name, plus, minus);
|
|
3177
|
+
b.addEventListener('click', () => { showFile(i); if (!reviewNarrow()) target.querySelectorAll('.d2h-file-wrapper')[i]?.scrollIntoView({ block: 'start' }); });
|
|
3178
|
+
tree.append(b);
|
|
3179
|
+
});
|
|
3180
|
+
tree.hidden = gitFiles.length === 0;
|
|
3181
|
+
if (!text) { const p = document.createElement('p'); p.className = 'hint'; p.textContent = 'No change to show.'; target.append(p); showFile(0); return; }
|
|
3182
|
+
const Ui = window.Diff2HtmlUI;
|
|
3183
|
+
if (!Ui) { const pre = document.createElement('pre'); pre.textContent = text; target.append(pre); return; }
|
|
3184
|
+
const ui = new Ui(target, text, {
|
|
3185
|
+
outputFormat: reviewNarrow() ? 'line-by-line' : gitLayout, drawFileList: false, matching: 'lines', diffStyle: 'word',
|
|
3186
|
+
fileContentToggle: true, stickyFileHeaders: false, synchronisedScroll: true, highlight: true, renderNothingWhenEmpty: false,
|
|
3187
|
+
colorScheme: document.documentElement.dataset.theme === 'light' ? 'light' : 'dark',
|
|
3188
|
+
});
|
|
3189
|
+
ui.draw();
|
|
3190
|
+
try { ui.highlightCode(); } catch { /* the plain diff stays */ }
|
|
3191
|
+
showFile(gitFile);
|
|
3192
|
+
}
|
|
3193
|
+
let gitText = '';
|
|
2909
3194
|
async function paintGit() {
|
|
2910
3195
|
const name = gitShown;
|
|
2911
|
-
|
|
3196
|
+
const commit = gitCommit;
|
|
3197
|
+
$('gitview-name').textContent = name;
|
|
3198
|
+
$('gitview-back').textContent = gitBack?.label ?? 'Back';
|
|
2912
3199
|
$('gitview-error').hidden = true; $('gitview-push').hidden = true; $('gitview-check').hidden = true; $('gitview-checks').hidden = true;
|
|
2913
|
-
$('gitview-offer-row').hidden = true; $('gitview-guards').hidden = true; $('gitview-dropped').hidden = true;
|
|
2914
|
-
$('gitview-state').textContent = 'Looking at what was committed...';
|
|
2915
|
-
$('gitview-
|
|
3200
|
+
$('gitview-offer-row').hidden = true; $('gitview-guards').hidden = true; $('gitview-dropped').hidden = true; $('gitview-remote-go').hidden = true;
|
|
3201
|
+
$('gitview-state').textContent = commit ? 'Looking at this commit...' : 'Looking at what was committed...';
|
|
3202
|
+
$('gitview-numbers').replaceChildren();
|
|
3203
|
+
$('gitview-commits').replaceChildren(); $('gitview-stat').textContent = ''; $('gitview-diff').replaceChildren(); $('gitview-tree').replaceChildren();
|
|
3204
|
+
$('gitview-n-files').textContent = ''; $('gitview-n-commits').textContent = ''; $('gitview-n-checks').textContent = '';
|
|
3205
|
+
$('gitview-from').hidden = true; $('gitview-arrow').hidden = Boolean(commit);
|
|
3206
|
+
if (commit) $('gitview-target-row').hidden = $('gitview-branch-row').hidden = true;
|
|
2916
3207
|
gitHead = null; gitTarget = null; gitState = ''; gitConfirm = false;
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
const keys = await fetch(`${API}/git-keys`).then((r) => r.json()).catch(() => []);
|
|
2920
|
-
const pick = $('gitview-sshkey'); pick.replaceChildren();
|
|
2921
|
-
for (const [value, text] of [['', '(none)'], ...keys.map((k) => [k.name, k.name])]) { const o = document.createElement('option'); o.value = value; o.textContent = text; pick.append(o); }
|
|
2922
|
-
pick.value = settings.sshKey ?? '';
|
|
2923
|
-
$('gitview-key-state').textContent = settings.hasKey ? 'A key is kept for this repository.'
|
|
2924
|
-
: settings.sshKey ? `Over ssh, the SSH key ${settings.sshKey} is used.` : 'No key is kept for sending: what the repository was cloned with is used, for the same server only; a remote that asks for more will refuse.';
|
|
2925
|
-
await paintKeep();
|
|
3208
|
+
paintReviewTab();
|
|
3209
|
+
if (!commit) await paintKeep();
|
|
2926
3210
|
let out;
|
|
2927
3211
|
try {
|
|
2928
3212
|
const query = new URLSearchParams();
|
|
2929
|
-
if (
|
|
2930
|
-
if (
|
|
2931
|
-
|
|
3213
|
+
if (commit) query.set('id', commit);
|
|
3214
|
+
if (!commit && gitAsked) query.set('target', gitAsked);
|
|
3215
|
+
if (!commit && gitAskedBranch) query.set('branch', gitAskedBranch);
|
|
3216
|
+
const r = await fetch(`${API}/git/${name}/${commit ? 'commit' : 'outgoing'}${query.size ? `?${query}` : ''}`);
|
|
2932
3217
|
out = await r.json();
|
|
2933
3218
|
if (!r.ok) throw Object.assign(new Error(out.error || 'The repository could not be read.'), { offer: out.offer });
|
|
2934
3219
|
} catch (error) {
|
|
3220
|
+
if (gitShown !== name || gitCommit !== commit) return;
|
|
2935
3221
|
$('gitview-state').textContent = ''; $('gitview-error').textContent = error.message; $('gitview-error').hidden = false;
|
|
2936
3222
|
/* An fsck refusal the owner may waive (old histories only): offered, never done for them. */
|
|
2937
|
-
if (error.offer
|
|
3223
|
+
if (error.offer) { $('gitview-offer').textContent = `Accept "${error.offer}" in this repository`; $('gitview-offer').dataset.id = error.offer; $('gitview-offer-row').hidden = false; }
|
|
2938
3224
|
return;
|
|
2939
3225
|
}
|
|
2940
|
-
if (gitShown !== name) return;
|
|
2941
|
-
gitHead = out.head; gitTarget = out.target; gitState = out.state; gitCommits = out.count === undefined ? out.commits.length : out.count; gitBranch = out.branch; gitConfirm = Boolean(out.guards?.confirm);
|
|
2942
|
-
|
|
3226
|
+
if (gitShown !== name || gitCommit !== commit) return;
|
|
3227
|
+
gitHead = commit ? null : out.head; gitTarget = out.target ?? null; gitState = out.state; gitCommits = out.count === undefined ? out.commits.length : out.count; gitBranch = out.branch ?? null; gitConfirm = Boolean(out.guards?.confirm);
|
|
3228
|
+
if (commit) {
|
|
3229
|
+
const c = out.commits[0];
|
|
3230
|
+
$('gitview-from').textContent = commit.slice(0, 7); $('gitview-from').hidden = false; $('gitview-from').title = c?.subject ?? '';
|
|
3231
|
+
} else {
|
|
3232
|
+
fillBranches(out);
|
|
3233
|
+
if ($('gitview-branch-row').hidden && out.branch) { $('gitview-from').textContent = out.branch; $('gitview-from').hidden = false; }
|
|
3234
|
+
$('gitview-target-row').hidden = !['ahead', 'up-to-date', 'diverged'].includes(out.state);
|
|
3235
|
+
$('gitview-arrow').hidden = $('gitview-target-row').hidden;
|
|
3236
|
+
fillTargets(out);
|
|
3237
|
+
}
|
|
2943
3238
|
paintGuards(out);
|
|
2944
|
-
$('gitview-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
3239
|
+
$('gitview-state').textContent = commit ? (out.commits[0]?.subject ?? '') + (out.message ? ` - ${out.message}` : '') : out.message;
|
|
3240
|
+
$('gitview-remote-go').hidden = !['no-remote', 'unreachable'].includes(out.state);
|
|
3241
|
+
/* The numbers line: commits, files, lines, the weight. */
|
|
3242
|
+
gitText = out.diff + (out.cut ? '\n' : '');
|
|
3243
|
+
const files = diffFiles(out.diff);
|
|
3244
|
+
const added = files.reduce((n, f) => n + f.added, 0); const removed = files.reduce((n, f) => n + f.removed, 0);
|
|
3245
|
+
const bit = (text, cls) => { const span = document.createElement('span'); span.textContent = text; if (cls) span.className = cls; return span; };
|
|
3246
|
+
const count = out.count === null || out.count === undefined ? 'Commits (not counted)' : `${out.count} commit${out.count === 1 ? '' : 's'}`;
|
|
3247
|
+
$('gitview-numbers').append(bit(count, 'strong'), bit(` · ${files.length} file${files.length === 1 ? '' : 's'}`), bit(`+${added}`, 'plus'), bit(`−${removed}`, 'minus'),
|
|
3248
|
+
...(commit || out.state !== 'ahead' ? [] : [bit('', 'checks-mini')]),
|
|
3249
|
+
...(out.guards?.newBytes !== null && out.guards?.newBytes !== undefined ? [bit(`${sizeText(out.guards.newBytes)} of new data`, 'hint')] : []));
|
|
3250
|
+
$('gitview-n-files').textContent = String(files.length);
|
|
3251
|
+
$('gitview-n-commits').textContent = String(out.commits.length);
|
|
2948
3252
|
for (const c of out.commits) {
|
|
2949
3253
|
const line = document.createElement('div'); line.className = 'commit';
|
|
2950
3254
|
const id = document.createElement('code'); id.textContent = c.id.slice(0, 8);
|
|
@@ -2956,14 +3260,26 @@
|
|
|
2956
3260
|
$('gitview-commits').append(line);
|
|
2957
3261
|
}
|
|
2958
3262
|
$('gitview-stat').textContent = out.stat;
|
|
2959
|
-
|
|
3263
|
+
gitFile = 0;
|
|
3264
|
+
drawDiff(out.diff);
|
|
3265
|
+
if (out.cut) { const p = document.createElement('p'); p.className = 'hint'; p.textContent = '... (the rest is not shown here: too long)'; $('gitview-diff').append(p); }
|
|
2960
3266
|
/* Before Send is shown, the checks run in the machine: their result
|
|
2961
3267
|
is shown, and Send waits for them (it is refused without them only
|
|
2962
3268
|
if the owner asked for that). */
|
|
2963
|
-
if (out.state === 'ahead') await runChecks();
|
|
2964
|
-
}
|
|
2965
|
-
|
|
2966
|
-
|
|
3269
|
+
if (!commit && out.state === 'ahead') await runChecks();
|
|
3270
|
+
}
|
|
3271
|
+
/* `commit`: one commit of the diagram. `back`: {label, go} -- where the page goes back to. */
|
|
3272
|
+
async function openGit(name, { commit = '', back = null, target = '', branch = '' } = {}) {
|
|
3273
|
+
gitShown = name; gitCommit = commit; gitAsked = target; gitAskedBranch = branch; gitBack = back; gitTab = 'changes';
|
|
3274
|
+
for (const id of ['panel', 'files']) if (!$(id).classList.contains('embedded')) $(id).hidden = true;
|
|
3275
|
+
$('gitview').hidden = false; $('gitview').scrollTop = 0;
|
|
3276
|
+
await paintGit();
|
|
3277
|
+
}
|
|
3278
|
+
for (const b of $('gitview-tabs').querySelectorAll('[data-tab]')) b.addEventListener('click', () => { gitTab = b.dataset.tab; paintReviewTab(); });
|
|
3279
|
+
for (const b of $('gitview-layout').querySelectorAll('[data-layout]')) b.addEventListener('click', () => { gitLayout = b.dataset.layout; paintReviewTab(); drawDiff(gitText.replace(/\n$/, '')); });
|
|
3280
|
+
for (const b of $('gitview-picker').querySelectorAll('[data-step]')) b.addEventListener('click', () => { showFile(gitFile + Number(b.dataset.step)); $('gitview-diff').scrollIntoView({ block: 'start' }); });
|
|
3281
|
+
$('gitview-set-remote').addEventListener('click', () => { const name = gitShown; $('gitview').hidden = true; gitShown = ''; void openAssetsPlace(`git:${name}`, { remote: true }); });
|
|
3282
|
+
$('gitview-close').addEventListener('click', () => { $('gitview').hidden = true; gitShown = ''; const back = gitBack; gitBack = null; if (back?.go) back.go(); else focusTerminal(); });
|
|
2967
3283
|
$('gitview-again').addEventListener('click', () => { void paintGit(); });
|
|
2968
3284
|
$('gitview-check').addEventListener('click', () => { void runChecks(); });
|
|
2969
3285
|
$('gitview-branch').addEventListener('change', () => { gitAskedBranch = $('gitview-branch').value; gitAsked = ''; void paintGit(); });
|
|
@@ -3016,9 +3332,10 @@
|
|
|
3016
3332
|
});
|
|
3017
3333
|
$('gitview-remote').addEventListener('submit', async (event) => {
|
|
3018
3334
|
event.preventDefault();
|
|
3019
|
-
$('
|
|
3020
|
-
|
|
3021
|
-
|
|
3335
|
+
$('assets-error').hidden = true;
|
|
3336
|
+
const name = remoteShown;
|
|
3337
|
+
try { await post(`${API}/git/${name}/remote`, { url: $('gitview-url').value.trim(), user: $('gitview-user').value.trim(), key: $('gitview-key').value, ...(looksSsh($('gitview-url').value.trim()) && $('gitview-sshkey').value ? { sshKey: $('gitview-sshkey').value } : {}) }); $('gitview-key').value = ''; remoteEditing = false; await paintAssets(); }
|
|
3338
|
+
catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
|
|
3022
3339
|
});
|
|
3023
3340
|
|
|
3024
3341
|
$('mailbox-new').addEventListener('submit', async (event) => {
|
|
@@ -3028,7 +3345,7 @@
|
|
|
3028
3345
|
button.disabled = true; button.textContent = 'Reading the mailbox...';
|
|
3029
3346
|
try {
|
|
3030
3347
|
await post(`${API}/mailboxes`, { name: $('mailbox-name').value.trim(), host: $('mailbox-host').value.trim(), port: $('mailbox-port').value.trim(), user: $('mailbox-user').value.trim(), password: $('mailbox-password').value });
|
|
3031
|
-
$('mailbox-
|
|
3348
|
+
assetOpen = `mailbox:${$('mailbox-name').value.trim()}`; assetsAdding = ''; $('mailbox-new').reset();
|
|
3032
3349
|
} catch (error) { $('mailbox-error').textContent = error.message; $('mailbox-error').hidden = false; }
|
|
3033
3350
|
button.disabled = false; button.textContent = 'Add';
|
|
3034
3351
|
await paintAssets();
|
|
@@ -3036,7 +3353,7 @@
|
|
|
3036
3353
|
$('folder-new').addEventListener('submit', async (event) => {
|
|
3037
3354
|
event.preventDefault();
|
|
3038
3355
|
$('assets-error').hidden = true;
|
|
3039
|
-
try { const made = await post(`${API}/resources`, { name: $('folder-name').value.trim() }); $('folder-name').value = '';
|
|
3356
|
+
try { const made = await post(`${API}/resources`, { name: $('folder-name').value.trim() }); $('folder-name').value = ''; assetsAdding = ''; if (made?.name) assetOpen = `folder:${made.name}`; }
|
|
3040
3357
|
catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
|
|
3041
3358
|
await paintAssets();
|
|
3042
3359
|
});
|
|
@@ -4485,8 +4802,8 @@
|
|
|
4485
4802
|
function takeBack(button, question, run) {
|
|
4486
4803
|
const sure = document.createElement('button'); sure.type = 'button'; sure.className = 'danger'; sure.textContent = question;
|
|
4487
4804
|
const keep = document.createElement('button'); keep.type = 'button'; keep.textContent = 'Keep';
|
|
4488
|
-
keep.addEventListener('click', () => { void
|
|
4489
|
-
sure.addEventListener('click', async () => { await run().catch(() => {}); await
|
|
4805
|
+
keep.addEventListener('click', () => { void repaintDevices(); });
|
|
4806
|
+
sure.addEventListener('click', async () => { await run().catch(() => {}); await repaintDevices(); void refresh(); });
|
|
4490
4807
|
button.replaceWith(sure, keep);
|
|
4491
4808
|
}
|
|
4492
4809
|
function grantRow(device, g) {
|
|
@@ -4494,7 +4811,7 @@
|
|
|
4494
4811
|
const row = document.createElement('div'); row.className = 'given-row'; row.dataset.asset = g.asset;
|
|
4495
4812
|
const label = document.createElement('span'); label.textContent = name;
|
|
4496
4813
|
const kind = document.createElement('small'); kind.className = 'hint'; kind.textContent = ` ${KIND_WORD[type] ?? type}`; label.append(kind);
|
|
4497
|
-
const right = rightControl(type, name, g.rights, async (value) => { await post(`${API}/devices/${device.id}/give`, { asset: g.asset, rights: value }).catch(() => {}); await
|
|
4814
|
+
const right = rightControl(type, name, g.rights, async (value) => { await post(`${API}/devices/${device.id}/give`, { asset: g.asset, rights: value }).catch(() => {}); await repaintDevices(); }, `What ${device.label} may do with ${name}`);
|
|
4498
4815
|
const take = document.createElement('button'); take.type = 'button'; take.textContent = 'Take back';
|
|
4499
4816
|
take.addEventListener('click', () => takeBack(take, `Take ${name} back from ${device.label}`, () => post(`${API}/devices/${device.id}/take`, { asset: g.asset })));
|
|
4500
4817
|
row.append(label, right, take);
|
|
@@ -4502,6 +4819,65 @@
|
|
|
4502
4819
|
}
|
|
4503
4820
|
let manageBy = 'device';
|
|
4504
4821
|
const deviceShut = new Set(); /* the device cards folded on this page */
|
|
4822
|
+
/* After a change to a device: its list (the Devices pane) or the map, drawn again. */
|
|
4823
|
+
async function repaintDevices() {
|
|
4824
|
+
if (place === 'access' && accessTab === 'devices') { await paintManage(); return; }
|
|
4825
|
+
await readDevices(); await links?.changed();
|
|
4826
|
+
}
|
|
4827
|
+
/* One device's card: its name (renamed here), what it has, revoke it. On
|
|
4828
|
+
the map's card of the device too (`onMap`: never folded). */
|
|
4829
|
+
function deviceCard(device, only, onMap = false) {
|
|
4830
|
+
const card = document.createElement('div'); card.className = 'device card'; card.dataset.device = device.id;
|
|
4831
|
+
const name = document.createElement('b'); name.textContent = device.label;
|
|
4832
|
+
const rename = document.createElement('button'); rename.type = 'button'; rename.textContent = 'Rename';
|
|
4833
|
+
rename.addEventListener('click', () => {
|
|
4834
|
+
const field = document.createElement('input'); field.value = device.label; field.maxLength = 40; field.setAttribute('aria-label', 'Name of this device');
|
|
4835
|
+
const save = document.createElement('button'); save.type = 'button'; save.className = 'primary'; save.textContent = 'Save';
|
|
4836
|
+
const done = async () => { if (field.value.trim()) await post(`${API}/devices/${device.id}/rename`, { label: field.value.trim() }).catch(() => {}); await repaintDevices(); };
|
|
4837
|
+
save.addEventListener('click', () => { void done(); });
|
|
4838
|
+
field.addEventListener('keydown', (event) => { if (event.key === 'Enter') void done(); event.stopPropagation(); });
|
|
4839
|
+
name.replaceWith(field); rename.replaceWith(save); focusField(field); if (document.activeElement === field) field.select();
|
|
4840
|
+
});
|
|
4841
|
+
const seen = document.createElement('small'); seen.textContent = `${ago(device.seenAt)} · paired ${new Date(device.createdAt).toLocaleDateString()}`;
|
|
4842
|
+
if (!device.seenAt) seen.className = 'warn';
|
|
4843
|
+
const revoke = document.createElement('button'); revoke.type = 'button'; revoke.textContent = 'Revoke device';
|
|
4844
|
+
revoke.addEventListener('click', () => takeBack(revoke, `Revoke ${device.label}: it loses all ${device.grants.length} thing${device.grants.length > 1 ? 's' : ''}, now`, () => post(`${API}/devices/${device.id}/revoke`)));
|
|
4845
|
+
/* A card that folds, as the chapters of a conversation do: open unless folded on purpose. */
|
|
4846
|
+
const shut = !onMap && deviceShut.has(device.id);
|
|
4847
|
+
const fold = document.createElement('button'); fold.type = 'button'; fold.className = 'card-fold'; fold.textContent = shut ? 'Expand' : 'Collapse';
|
|
4848
|
+
fold.setAttribute('aria-expanded', String(!shut)); fold.setAttribute('aria-label', `${shut ? 'Expand' : 'Collapse'} ${device.label}`);
|
|
4849
|
+
fold.addEventListener('click', () => { if (shut) deviceShut.delete(device.id); else deviceShut.add(device.id); void repaintDevices(); });
|
|
4850
|
+
const count = document.createElement('small'); count.className = 'device-count'; count.textContent = `${device.grants.length} thing${device.grants.length === 1 ? '' : 's'}`;
|
|
4851
|
+
const sub = document.createElement('span'); sub.className = 'device-sub'; sub.append(count, ' · ', seen);
|
|
4852
|
+
const head = document.createElement('div'); head.className = 'device-head'; head.append(name, sub, fold);
|
|
4853
|
+
const given = document.createElement('div'); given.className = 'given';
|
|
4854
|
+
const mine = only ? device.grants.filter((g) => g.asset === only) : device.grants;
|
|
4855
|
+
for (const g of mine) given.append(grantRow(device, g));
|
|
4856
|
+
/* Several chats given while off: said once for the card, not under each. */
|
|
4857
|
+
const waiting = [...given.querySelectorAll('.parts-off')];
|
|
4858
|
+
if (waiting.length > 1) {
|
|
4859
|
+
for (const w of waiting) w.remove();
|
|
4860
|
+
const once = document.createElement('p'); once.className = 'hint parts-off'; once.title = waiting[0].title;
|
|
4861
|
+
once.textContent = `${waiting.length} chats are off: the device waits until you turn each on (the conversation's General).`;
|
|
4862
|
+
given.prepend(once);
|
|
4863
|
+
}
|
|
4864
|
+
if (only && mine.length === 0) {
|
|
4865
|
+
const row = document.createElement('div'); row.className = 'given-row'; row.dataset.asset = only;
|
|
4866
|
+
const label = document.createElement('span'); label.textContent = accessFor;
|
|
4867
|
+
const none = document.createElement('small'); none.className = 'hint'; none.textContent = ' not shared'; label.append(none);
|
|
4868
|
+
const give = document.createElement('button'); give.type = 'button'; give.className = 'primary'; give.textContent = `Give ${accessFor}`;
|
|
4869
|
+
give.addEventListener('click', async () => { give.disabled = true; await post(`${API}/devices/${device.id}/give`, { asset: only, rights: partsFor(accessFor) }).catch(() => {}); await repaintDevices(); void refresh(); });
|
|
4870
|
+
row.append(label, give); given.append(row);
|
|
4871
|
+
}
|
|
4872
|
+
/* What the filter leaves out is named, never only counted. */
|
|
4873
|
+
if (only && device.grants.length > mine.length) { const rest = document.createElement('p'); rest.className = 'hint'; rest.textContent = `It also has: ${device.grants.filter((g) => g.asset !== only).map((g) => { const [type, what] = g.asset.split(':'); return `${what} (${KIND_WORD[type] ?? type})`; }).join(', ')}.`; given.append(rest); }
|
|
4874
|
+
const more = document.createElement('button'); more.type = 'button'; more.textContent = '+ Give more';
|
|
4875
|
+
more.addEventListener('click', () => { void openCompose([], device.id); });
|
|
4876
|
+
const acts = document.createElement('div'); acts.className = 'row device-acts'; acts.append(more, rename, revoke);
|
|
4877
|
+
const body = document.createElement('div'); body.className = 'card-body'; body.hidden = shut; body.append(given, acts);
|
|
4878
|
+
card.append(head, body);
|
|
4879
|
+
return card;
|
|
4880
|
+
}
|
|
4505
4881
|
async function paintManage() {
|
|
4506
4882
|
await readDevices();
|
|
4507
4883
|
$('share-by-device').setAttribute('aria-pressed', String(manageBy === 'device')); $('share-by-thing').setAttribute('aria-pressed', String(manageBy === 'thing'));
|
|
@@ -4514,55 +4890,7 @@
|
|
|
4514
4890
|
if (manageBy === 'device' || only) {
|
|
4515
4891
|
rows.hidden = false; things.hidden = true;
|
|
4516
4892
|
for (const device of [...sharedDevices].sort((a, b2) => String(b2.seenAt ?? '').localeCompare(String(a.seenAt ?? '')))) {
|
|
4517
|
-
|
|
4518
|
-
const name = document.createElement('b'); name.textContent = device.label;
|
|
4519
|
-
const rename = document.createElement('button'); rename.type = 'button'; rename.textContent = 'Rename';
|
|
4520
|
-
rename.addEventListener('click', () => {
|
|
4521
|
-
const field = document.createElement('input'); field.value = device.label; field.maxLength = 40; field.setAttribute('aria-label', 'Name of this device');
|
|
4522
|
-
const save = document.createElement('button'); save.type = 'button'; save.className = 'primary'; save.textContent = 'Save';
|
|
4523
|
-
const done = async () => { if (field.value.trim()) await post(`${API}/devices/${device.id}/rename`, { label: field.value.trim() }).catch(() => {}); await paintManage(); };
|
|
4524
|
-
save.addEventListener('click', () => { void done(); });
|
|
4525
|
-
field.addEventListener('keydown', (event) => { if (event.key === 'Enter') void done(); event.stopPropagation(); });
|
|
4526
|
-
name.replaceWith(field); rename.replaceWith(save); focusField(field); if (document.activeElement === field) field.select();
|
|
4527
|
-
});
|
|
4528
|
-
const seen = document.createElement('small'); seen.textContent = `${ago(device.seenAt)} · paired ${new Date(device.createdAt).toLocaleDateString()}`;
|
|
4529
|
-
if (!device.seenAt) seen.className = 'warn';
|
|
4530
|
-
const revoke = document.createElement('button'); revoke.type = 'button'; revoke.textContent = 'Revoke device';
|
|
4531
|
-
revoke.addEventListener('click', () => takeBack(revoke, `Revoke ${device.label}: it loses all ${device.grants.length} thing${device.grants.length > 1 ? 's' : ''}, now`, () => post(`${API}/devices/${device.id}/revoke`)));
|
|
4532
|
-
/* A card that folds, as the chapters of a conversation do: open unless folded on purpose. */
|
|
4533
|
-
const shut = deviceShut.has(device.id);
|
|
4534
|
-
const fold = document.createElement('button'); fold.type = 'button'; fold.className = 'card-fold'; fold.textContent = shut ? 'Expand' : 'Collapse';
|
|
4535
|
-
fold.setAttribute('aria-expanded', String(!shut)); fold.setAttribute('aria-label', `${shut ? 'Expand' : 'Collapse'} ${device.label}`);
|
|
4536
|
-
fold.addEventListener('click', () => { if (shut) deviceShut.delete(device.id); else deviceShut.add(device.id); void paintManage(); });
|
|
4537
|
-
const count = document.createElement('small'); count.className = 'device-count'; count.textContent = `${device.grants.length} thing${device.grants.length === 1 ? '' : 's'}`;
|
|
4538
|
-
const sub = document.createElement('span'); sub.className = 'device-sub'; sub.append(count, ' · ', seen);
|
|
4539
|
-
const head = document.createElement('div'); head.className = 'device-head'; head.append(name, sub, fold);
|
|
4540
|
-
const given = document.createElement('div'); given.className = 'given';
|
|
4541
|
-
const mine = only ? device.grants.filter((g) => g.asset === only) : device.grants;
|
|
4542
|
-
for (const g of mine) given.append(grantRow(device, g));
|
|
4543
|
-
/* Several chats given while off: said once for the card, not under each. */
|
|
4544
|
-
const waiting = [...given.querySelectorAll('.parts-off')];
|
|
4545
|
-
if (waiting.length > 1) {
|
|
4546
|
-
for (const w of waiting) w.remove();
|
|
4547
|
-
const once = document.createElement('p'); once.className = 'hint parts-off'; once.title = waiting[0].title;
|
|
4548
|
-
once.textContent = `${waiting.length} chats are off: the device waits until you turn each on (the conversation's General).`;
|
|
4549
|
-
given.prepend(once);
|
|
4550
|
-
}
|
|
4551
|
-
if (only && mine.length === 0) {
|
|
4552
|
-
const row = document.createElement('div'); row.className = 'given-row'; row.dataset.asset = only;
|
|
4553
|
-
const label = document.createElement('span'); label.textContent = accessFor;
|
|
4554
|
-
const none = document.createElement('small'); none.className = 'hint'; none.textContent = ' not shared'; label.append(none);
|
|
4555
|
-
const give = document.createElement('button'); give.type = 'button'; give.className = 'primary'; give.textContent = `Give ${accessFor}`;
|
|
4556
|
-
give.addEventListener('click', async () => { give.disabled = true; await post(`${API}/devices/${device.id}/give`, { asset: only, rights: partsFor(accessFor) }).catch(() => {}); await paintManage(); void refresh(); });
|
|
4557
|
-
row.append(label, give); given.append(row);
|
|
4558
|
-
}
|
|
4559
|
-
/* What the filter leaves out is named, never only counted. */
|
|
4560
|
-
if (only && device.grants.length > mine.length) { const rest = document.createElement('p'); rest.className = 'hint'; rest.textContent = `It also has: ${device.grants.filter((g) => g.asset !== only).map((g) => { const [type, what] = g.asset.split(':'); return `${what} (${KIND_WORD[type] ?? type})`; }).join(', ')}.`; given.append(rest); }
|
|
4561
|
-
const more = document.createElement('button'); more.type = 'button'; more.textContent = '+ Give more';
|
|
4562
|
-
more.addEventListener('click', () => { void openCompose([], device.id); });
|
|
4563
|
-
const acts = document.createElement('div'); acts.className = 'row device-acts'; acts.append(more, rename, revoke);
|
|
4564
|
-
const body = document.createElement('div'); body.className = 'card-body'; body.hidden = shut; body.append(given, acts);
|
|
4565
|
-
card.append(head, body); rows.append(card);
|
|
4893
|
+
rows.append(deviceCard(device, only));
|
|
4566
4894
|
}
|
|
4567
4895
|
return;
|
|
4568
4896
|
}
|
|
@@ -4627,7 +4955,7 @@
|
|
|
4627
4955
|
/* Back from choosing or from the code: where it was opened from. */
|
|
4628
4956
|
$('sharing-close').addEventListener('click', () => {
|
|
4629
4957
|
face('manage'); void refresh();
|
|
4630
|
-
if (composeFrom === 'conversation') leavePlace(); else void openAccessPage(composeFrom);
|
|
4958
|
+
if (composeFrom === 'conversation') leavePlace(); else void openAccessPage(composeFrom === 'devices' ? 'map' : composeFrom);
|
|
4631
4959
|
});
|
|
4632
4960
|
|
|
4633
4961
|
$('sharing-new').addEventListener('click', async () => {
|
|
@@ -4638,7 +4966,8 @@
|
|
|
4638
4966
|
const before = new Map(sharedDevices.find((d) => d.id === shareTarget).grants.map((g) => [g.asset, g.rights]));
|
|
4639
4967
|
for (const [asset, rights] of basket) if (before.get(asset) !== rights) await post(`${API}/devices/${shareTarget}/give`, { asset, rights });
|
|
4640
4968
|
for (const asset of before.keys()) if (!basket.has(asset)) await post(`${API}/devices/${shareTarget}/take`, { asset });
|
|
4641
|
-
|
|
4969
|
+
/* Back on the map, on that device's card. */
|
|
4970
|
+
await readDevices(); await openAccessPage('map', { pin: `device:${shareTarget}` }); void refresh();
|
|
4642
4971
|
return;
|
|
4643
4972
|
}
|
|
4644
4973
|
const label = $('sharing-label').value.trim();
|
|
@@ -4669,7 +4998,7 @@
|
|
|
4669
4998
|
$('share-cancel').onclick = async () => { await post(`${API}/shares/cancel`, { code: new URL(shared.url).hash.slice(1) }).catch(() => {}); await openCompose([...basket]); $('sharing-label').value = label; sayChoice(); };
|
|
4670
4999
|
} catch (error) { $('sharing-error').textContent = error.message; $('sharing-error').hidden = false; }
|
|
4671
5000
|
});
|
|
4672
|
-
$('share-done').addEventListener('click', () => { void
|
|
5001
|
+
$('share-done').addEventListener('click', () => { void openAccessPage('map'); void refresh(); });
|
|
4673
5002
|
|
|
4674
5003
|
/* ---------- resting and archiving ---------- */
|
|
4675
5004
|
$('sleep').addEventListener('click', async () => {
|