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/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.name);
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 name = $('create-name').value.trim();
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
- const chips = $('chips');
978
- chips.replaceChildren();
979
- if (conversations.find((x) => x.name === current)?.offline === true) {
980
- const chip = document.createElement('span'); chip.className = 'chip'; chip.dataset.access = 'offline'; chip.textContent = 'offline';
981
- chips.append(chip);
982
- }
983
- for (const r of conversations.find((x) => x.name === current)?.resources ?? []) {
984
- const chip = document.createElement('span');
985
- chip.className = 'chip'; chip.dataset.access = r.access;
986
- chip.textContent = `${r.name} - ${r.access}`;
987
- chips.append(chip);
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('Name', here.name);
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
- /* ---------- Assets: everything kept here ---------- */
2045
- let assetsKind = ''; let assetOpen = '';
2046
- async function paintAssets() {
2047
- const [all, boxes, secrets, keys] = await Promise.all([
2048
- fetch(`${API}/resources`).then((r) => r.json()), fetch(`${API}/mailboxes`).then((r) => r.json()).catch(() => []), fetch(`${API}/secrets`).then((r) => r.json()).catch(() => []), fetch(`${API}/git-keys`).then((r) => r.json()).catch(() => []), readDevices()]);
2049
- const ready = conversations.filter((c) => c.phase === 'ready');
2050
- const count = { folder: 0, git: 0, mailbox: 0, secret: secrets.length };
2051
- for (const r of all) count[kindOf(r)] += 1;
2052
- /* The kinds, as chips with how many there are. */
2053
- const filters = $('assets-filters'); filters.replaceChildren();
2054
- for (const [kind, text] of [['', `All ${all.length + secrets.length}`], ['folder', `Folders ${count.folder}`], ['git', `Repositories ${count.git}`], ['mailbox', `Mailboxes ${count.mailbox}`], ['secret', `Secrets ${count.secret}`]]) {
2055
- const b = document.createElement('button'); b.type = 'button'; b.textContent = text; b.setAttribute('aria-pressed', String(assetsKind === kind));
2056
- b.addEventListener('click', () => { assetsKind = kind; void paintAssets(); });
2057
- filters.append(b);
2058
- }
2059
- for (const section of $('assets').querySelectorAll('section')) section.hidden = assetsKind !== '' && section.dataset.kind !== assetsKind;
2060
- for (const kind of ['folder', 'git', 'mailbox']) $(`assets-${kind}`).replaceChildren();
2061
- for (const resource of all) {
2062
- const kind = kindOf(resource); const asset = `${kind}:${resource.name}`;
2063
- const users = Object.entries(resource.attachments ?? {});
2064
- const holders = devicesWith(asset);
2065
- const row = document.createElement('div'); row.className = 'asset'; row.dataset.name = resource.name;
2066
- const head = document.createElement('button'); head.type = 'button'; head.className = 'asset-head'; head.setAttribute('aria-expanded', String(assetOpen === asset));
2067
- const name = document.createElement('b'); name.textContent = resource.name;
2068
- const used = document.createElement('span'); used.className = users.length ? '' : 'hint';
2069
- used.textContent = users.length ? users.map(([c, access]) => `${c} (${access})`).join(', ') : 'not used';
2070
- const given = document.createElement('span'); given.className = 'hint';
2071
- const box = kind === 'mailbox' ? boxes.find((m) => m.name === resource.name) : undefined;
2072
- given.textContent = [holders.length ? `on ${holders.map((d) => d.label).join(', ')}` : '', box ? (box.failure ? box.failure : `${box.messages} message(s)`) : '', kind === 'git' && resource.from ? `from ${hostOf(resource.from)}${viaText(resource.via)}` : ''].filter(Boolean).join(' - ');
2073
- head.append(name, used, given);
2074
- head.addEventListener('click', () => { assetOpen = assetOpen === asset ? '' : asset; void paintAssets(); });
2075
- row.append(head);
2076
- if (kind === 'git') {
2077
- const review = document.createElement('button'); review.type = 'button'; review.textContent = 'Review & send';
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
- $(`assets-${kind}`).append(row);
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
- for (const kind of ['folder', 'git', 'mailbox']) $(`assets-${kind}-title`).textContent = `${{ folder: 'Folders', git: 'Repositories', mailbox: 'Mailboxes' }[kind]} (${count[kind]})`;
2122
- $('assets-secret-title').textContent = `Secrets (${count.secret})`;
2123
- paintSecrets(secrets, ready);
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
- /* The line of the conversation Access is filtered on, under an asset's name: its control, or why there is none. */
2127
- function forRow(conversation, options, chosen, pick, label) {
2128
- const line = document.createElement('div'); line.className = 'given-row for-row'; line.dataset.conversation = accessFor;
2129
- const who = document.createElement('span'); who.textContent = accessFor;
2130
- if (!conversation) { const why = document.createElement('small'); why.className = 'hint'; why.textContent = ' not ready yet'; who.append(why); line.append(who); return line; }
2131
- line.append(who, modes(options, chosen, pick, label));
2132
- return line;
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-value').value = ''; $('secret-box').open = false; }
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: ['devices'], sharing: ['devices'], assets: ['assets'] };
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
- if (which !== 'conversations') { for (const id of ['panel', 'files', 'copy', 'gitview']) $(id).hidden = true; view('room'); }
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 openAccessPage('assets', { of: current, fromPanel: true }); });
2422
- $('access-devices-here').addEventListener('click', () => { void openAccessPage('devices', { of: current, fromPanel: true }); });
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 $('git-box').open = false;
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
- $('gitview-name').textContent = `${name} - git repository`;
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-commits').replaceChildren(); $('gitview-stat').textContent = ''; $('gitview-diff').textContent = '';
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
- const settings = await (await fetch(`${API}/git/${name}`)).json().catch(() => ({}));
2918
- $('gitview-url').value = settings.remote ?? ''; $('gitview-user').value = settings.user ?? '';
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 (gitAsked) query.set('target', gitAsked);
2930
- if (gitAskedBranch) query.set('branch', gitAskedBranch);
2931
- const r = await fetch(`${API}/git/${name}/outgoing${query.size ? `?${query}` : ''}`);
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 && gitShown === name) { $('gitview-offer').textContent = `Accept "${error.offer}" in this repository`; $('gitview-offer').dataset.id = error.offer; $('gitview-offer-row').hidden = false; }
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
- fillBranches(out);
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-target-row').hidden = !['ahead', 'up-to-date', 'diverged'].includes(out.state);
2945
- fillTargets(out);
2946
- $('gitview-state').textContent = out.message;
2947
- $('gitview-remote-box').open = out.state === 'no-remote' || out.state === 'unreachable';
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
- $('gitview-diff').textContent = out.diff + (out.cut ? '\n... (the rest is not shown here: too long)' : '');
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
- async function openGit(name) { gitShown = name; gitAsked = ''; gitAskedBranch = ''; for (const id of ['panel', 'settings', 'files']) $(id).hidden = true; $('gitview').hidden = false; await paintGit(); }
2966
- $('gitview-close').addEventListener('click', () => { $('gitview').hidden = true; gitShown = ''; focusTerminal(); });
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
- $('gitview-error').hidden = true;
3020
- try { await post(`${API}/git/${gitShown}/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 = ''; await paintGit(); }
3021
- catch (error) { $('gitview-error').textContent = error.message; $('gitview-error').hidden = false; }
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-new').reset(); $('mailbox-box').open = false;
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 = ''; $('folder-box').open = false; if (made?.name) assetOpen = `folder:${made.name}`; }
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 paintManage(); });
4489
- sure.addEventListener('click', async () => { await run().catch(() => {}); await paintManage(); void refresh(); });
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 paintManage(); }, `What ${device.label} may do with ${name}`);
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
- const card = document.createElement('div'); card.className = 'device card'; card.dataset.device = device.id;
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
- await openManage(); void refresh();
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 openManage(); void refresh(); });
5001
+ $('share-done').addEventListener('click', () => { void openAccessPage('map'); void refresh(); });
4673
5002
 
4674
5003
  /* ---------- resting and archiving ---------- */
4675
5004
  $('sleep').addEventListener('click', async () => {