@volter/world-console 3.0.10 → 3.0.12

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.
@@ -645,6 +645,9 @@ main.app.home { max-width: none; }
645
645
  .float.compare-panel { max-height: calc(100vh - 32px); overflow-y: auto; padding: 0; }
646
646
  .compare { display: grid; gap: 12px; padding: 12px 14px; width: 900px; max-width: calc(100vw - 320px); }
647
647
  .compare-head { display: flex; align-items: center; gap: 8px; }
648
+ .compare-base { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
649
+ .compare-base select { font: inherit; max-width: 22ch; }
650
+ .compare-acts-head { margin: 12px 0 0; font-size: 12px; }
648
651
  .compare-head .mode { flex: 1; }
649
652
  .compare-sides { display: flex; gap: 16px; flex-wrap: wrap; }
650
653
  .compare-sides figure { margin: 0; display: grid; gap: 6px; }
@@ -1707,7 +1707,8 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1707
1707
  {/* its name always, as a canvas app labels its frames; what can be done to it once it is selected */}
1708
1708
  <NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
1709
1709
  <Mark vendor={frame.vendor} /><span>{frame.title}</span>
1710
- {frame.changes ? <button type="button" className="frame-changed nodrag" onClick={() => compare(key)} title={`${counted(frame.changes, 'change')} here, not in a changeset: compare before and now`}><Icon name="square-dot" size={14} />{frame.changes}</button> : null}
1710
+ {frame.changes ? <button type="button" className="frame-changed nodrag" onClick={() => compare(key)} title={`${counted(frame.changes, 'change')} here, not in a changeset: compare before and now`}><Icon name="square-dot" size={14} />{frame.changes}</button>
1711
+ : picked ? <button type="button" className="ghost nodrag" onClick={() => compare(key)} title="Compare with a branch of this World" aria-label={`Compare ${frame.title} with a branch`}><Icon name="file-diff" size={14} /></button> : null}
1711
1712
  {picked && others.length ? <select className="nodrag frame-add" value="" aria-label={`Add ${frame.title} to a board`} onChange={(e) => { if (e.target.value) addTo(e.target.value, key); }}><option value="">Add to board…</option>{others.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select> : null}
1712
1713
  {picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
1713
1714
  </NodeToolbar>
@@ -1752,55 +1753,86 @@ function Thumb({ url, title }: { url: string; title: string }): React.JSX.Elemen
1752
1753
  * was the moment before the first of them beside how it is now. "Before" is a read-only copy of the World as of then (a
1753
1754
  * branch the World makes for an hour, removed when this closes), its site read at the copy's own address. */
1754
1755
  function Compare({ name, token, frame, onClose, reads }: { name: string; token: string; frame: BoardFrame; onClose: () => void; /** the board's reads so far: its changes are read again with each */ reads: number }): React.JSX.Element {
1755
- const [before, setBefore] = useState<{ state: 'opening' } | { state: 'ok'; url: string; at: string } | { state: 'none'; why: string }>({ state: 'opening' });
1756
+ // what "before" is: the World as it was the moment before this frame's first change not yet cut (a copy made for the
1757
+ // comparison), or any branch of the World chosen here (a pull request's preview, a named branch), as a compare view
1758
+ // sets one branch beside another
1759
+ const [base, setBase] = useState<string>(frame.since ? 'before' : '');
1760
+ const [before, setBefore] = useState<{ state: 'opening' } | { state: 'ok'; url: string; at?: string } | { state: 'none'; why: string }>({ state: 'opening' });
1761
+ const [made, setMade] = useState<string | null>(null);
1762
+ // the copies this panel made, kept out of the choices after they are gone (the list is read again only every so often)
1763
+ const mine = useRef(new Set<string>());
1764
+ const branches = useDoor<{ branches: Array<{ name: string; origin?: string; at: { instant?: string; label?: string; live?: boolean } | null; expiresAt: string | null }> }>(token, `/-/${name}/branches`, reads);
1765
+ const choices = branches.state === 'ok' ? branches.data.branches.filter((b) => b.name !== made && !mine.current.has(b.name)) : [];
1756
1766
  // its changes, read for this comparison alone
1757
1767
  const listed = useDoor<{ total: number; acts: BoardAct[] }>(token, `/-/${name}/board/changes?frame=${encodeURIComponent(frameKey(frame))}`, reads);
1768
+ // a branch's page beside this World's: a site at the branch's own site address, a vendor's page on the branch's own
1769
+ // content plane through this World's branch door (a one-time read pass), never running on this World's origin
1770
+ const pageOf = async (branch: string, origin: URL | null, isClosed: () => boolean): Promise<{ url: string } | { why: string }> => {
1771
+ const shown = frame.at!;
1772
+ if ('site' in shown) {
1773
+ if (!origin) return { why: 'This World shows sites only at its own local address.' };
1774
+ const u = new URL(frame.url); return { url: `${origin.protocol}//${u.hostname}.${origin.host}${u.pathname}${u.search}` };
1775
+ }
1776
+ const beside = `${WORLDS}/-/${name}/branches/${branch}/pages?${new URLSearchParams({ land: `/${branch}/${frame.vendor}${shown.screen}` }).toString()}`;
1777
+ // the door answers a navigation with the branch's address; a host that shows no content plane says so instead
1778
+ const asked = await fetch(beside, { credentials: 'same-origin', redirect: 'manual' }).catch(() => null);
1779
+ if (isClosed()) return { why: '' };
1780
+ return asked?.type === 'opaqueredirect' ? { url: beside } : { why: `This World does not show its branches' pages${asked ? `: ${await failure(asked)}` : ''}.` };
1781
+ };
1758
1782
  useEffect(() => {
1759
- if (!frame.since) { setBefore({ state: 'none', why: 'Nothing here has changed since the World was last cut.' }); return; }
1760
1783
  if (!frame.at) { setBefore({ state: 'none', why: `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet; its changes are listed below.` }); return; }
1761
- const shown = frame.at;
1784
+ if (base === '') { setBefore({ state: 'none', why: 'Choose a branch of this World to set beside it.' }); return; }
1785
+ let closed = false;
1786
+ if (base !== 'before') {
1787
+ setBefore({ state: 'opening' });
1788
+ const row = branches.state === 'ok' ? branches.data.branches.find((b) => b.name === base) : undefined;
1789
+ // a moment's wait, so passing over a branch on the way to another opens nothing
1790
+ const wait = setTimeout(() => void pageOf(base, httpOrigin(row?.origin), () => closed).then((r) => { if (!closed) setBefore('url' in r ? { state: 'ok', url: r.url } : { state: 'none', why: r.why }); }), 300);
1791
+ return () => { closed = true; clearTimeout(wait); };
1792
+ }
1793
+ if (!frame.since) { setBefore({ state: 'none', why: 'Nothing here has changed since the World was last cut.' }); return; }
1794
+ setBefore({ state: 'opening' });
1762
1795
  const at = new Date(Date.parse(frame.since) - 1).toISOString();
1763
- let made: string | null = null; let closed = false;
1796
+ let copy: string | null = null;
1764
1797
  // a copy is made once the comparison has stayed open a moment: one passed through on the way to another makes none
1765
1798
  const wait = setTimeout(() => void (async () => {
1766
1799
  const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify({ at: { instant: at }, ttl: 3600 }), signal: AbortSignal.timeout(60000) }).catch(() => null);
1767
1800
  if (!r?.ok) { setBefore({ state: 'none', why: `The World made no copy of itself as it was then${r ? `: ${await failure(r)}` : ''}.` }); return; }
1768
- const b = (await r.json()) as { name: string; origin?: string; readToken?: string };
1769
- made = b.name;
1801
+ const b = (await r.json()) as { name: string; origin?: string };
1802
+ copy = b.name; setMade(b.name); mine.current.add(b.name);
1770
1803
  if (closed) { void door(token, `/-/${name}/branches/${b.name}`, { method: 'DELETE' }).catch(() => null); return; }
1771
- // a site at the copy's own address; a vendor's page on the copy's own content plane, opened through this World's
1772
- // branch door (a one-time read pass): framed beside this World's page, never running on this World's origin
1773
- if ('site' in shown) {
1774
- const o = httpOrigin(b.origin);
1775
- if (!o) { setBefore({ state: 'none', why: 'This World shows sites only at its own local address.' }); return; }
1776
- const u = new URL(frame.url); setBefore({ state: 'ok', url: `${o.protocol}//${u.hostname}.${o.host}${u.pathname}${u.search}`, at }); return;
1777
- }
1778
- const beside = `${WORLDS}/-/${name}/branches/${b.name}/pages?${new URLSearchParams({ land: `/${b.name}/${frame.vendor}${shown.screen}` }).toString()}`;
1779
- // the door answers a navigation with the copy's address; a host that shows no content plane says so instead
1780
- const asked = await fetch(beside, { credentials: 'same-origin', redirect: 'manual' }).catch(() => null);
1781
- if (closed) return;
1782
- if (asked?.type !== 'opaqueredirect') { setBefore({ state: 'none', why: `This World does not show its copy's pages${asked ? `: ${await failure(asked)}` : ''}.` }); return; }
1783
- setBefore({ state: 'ok', url: beside, at });
1804
+ const page = await pageOf(b.name, httpOrigin(b.origin), () => closed);
1805
+ if (!closed) setBefore('url' in page ? { state: 'ok', url: page.url, at } : { state: 'none', why: page.why });
1784
1806
  })(), 500);
1785
1807
  // the copy goes with the comparison, and with the page: a reload or a closed tab runs no cleanup of its own
1786
- const drop = (): void => { if (made) void door(token, `/-/${name}/branches/${made}`, { method: 'DELETE', keepalive: true }).catch(() => null); made = null; };
1808
+ const drop = (): void => { if (copy) void door(token, `/-/${name}/branches/${copy}`, { method: 'DELETE', keepalive: true }).catch(() => null); copy = null; };
1787
1809
  // a page kept for back and forward keeps its copy: it may be shown again
1788
1810
  const left = (e: PageTransitionEvent): void => { if (!e.persisted) drop(); };
1789
1811
  window.addEventListener('pagehide', left);
1790
- return () => { closed = true; clearTimeout(wait); window.removeEventListener('pagehide', left); drop(); };
1791
- }, [name, token, frame.vendor, frame.id, frame.since]);
1812
+ return () => { closed = true; clearTimeout(wait); window.removeEventListener('pagehide', left); drop(); setMade(null); };
1813
+ }, [name, token, frame.vendor, frame.id, frame.since, base]);
1792
1814
  const now = frameUrl(name, frame);
1815
+ // nothing to choose: said, rather than an empty list
1816
+ const noBranches = !frame.at || base !== '' ? null : branches.state === 'ok' && choices.length === 0 ? 'This World has no branches to set beside it: make one under Branches.' : branches.state === 'error' ? `The World's branches could not be read: ${branches.error}` : null;
1793
1817
  return (
1794
1818
  <div className="compare" role="dialog" aria-label={`Changes to ${frame.title}`}>
1795
1819
  <div className="compare-head">
1796
1820
  <Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
1797
- <span className="mode">{counted(frame.changes, 'change')} not yet cut{frame.since ? `, since ${when(frame.since)}` : ''}</span>
1821
+ <span className="mode">{frame.changes ? `${counted(frame.changes, 'change')} not yet cut${frame.since ? `, since ${when(frame.since)}` : ''}` : 'No changes here not yet cut'}</span>
1822
+ {frame.at ? <label className="compare-base">Compare with{' '}
1823
+ <select value={base} onChange={(e) => setBase(e.target.value)}>
1824
+ {base === '' ? <option value="">Choose a branch…</option> : null}
1825
+ {frame.since || base === 'before' ? <option value="before">Before these changes</option> : null}
1826
+ {base && base !== 'before' && !choices.some((b) => b.name === base) ? <option value={base}>{base.split('/')[1]}</option> : null}
1827
+ {choices.map((b) => <option key={b.name} value={b.name}>{b.name.split('/')[1]}{b.at?.label ? ` (${b.at.label})` : b.at?.instant ? ` (as of ${when(b.at.instant)})` : b.at?.live ? ' (live)' : ''}</option>)}
1828
+ </select>
1829
+ </label> : null}
1798
1830
  <button type="button" className="ghost" onClick={onClose} aria-label="Close the comparison">✕</button>
1799
1831
  </div>
1800
1832
  <div className="compare-sides">
1801
1833
  <figure>
1802
- <figcaption>Before{before.state === 'ok' ? ` · as of ${when(before.at)}` : ''}</figcaption>
1803
- {before.state === 'ok' ? <Thumb url={before.url} title={`${frame.title} before`} /> : before.state === 'opening' ? <p className="mode compare-note" role="status">Opening the World as it was…</p> : <p className="mode compare-note">{before.why}</p>}
1834
+ <figcaption>{base === 'before' ? `Before${before.state === 'ok' && before.at ? ` · as of ${when(before.at)}` : ''}` : base ? base.split('/')[1] : 'A branch'}</figcaption>
1835
+ {noBranches ? <p className="mode compare-note">{noBranches}</p> : before.state === 'ok' ? <Thumb url={before.url} title={`${frame.title} before`} /> : before.state === 'opening' ? <p className="mode compare-note" role="status">Opening the World as it was…</p> : <p className="mode compare-note">{before.why}</p>}
1804
1836
  </figure>
1805
1837
  <figure>
1806
1838
  <figcaption>Now</figcaption>
@@ -1808,6 +1840,7 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
1808
1840
  </figure>
1809
1841
  </div>
1810
1842
  {listed.state === 'error' ? <p role="alert" className="mode">Its changes could not be read: {listed.error}</p> : null}
1843
+ {base !== 'before' && base && listed.state === 'ok' && listed.data.acts.length ? <p className="mode compare-acts-head">This World's own changes here, not yet cut:</p> : null}
1811
1844
  <ol className="compare-acts">
1812
1845
  {(listed.state === 'ok' ? listed.data.acts : []).map((a) => {
1813
1846
  const first = a.entries[0]!; const words = said({ ...first, service: frame.vendor, id: '', occurredAt: a.at } as unknown as Entry);