@leftoff/cli 0.8.8 → 0.8.9

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "Joel"
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
29
 
30
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.8.8' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.8.9' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -110,6 +110,14 @@ h2 .count { color: var(--faint); font-weight: 500; }
110
110
  .card .row1 { display: flex; align-items: flex-start; gap: 10px; }
111
111
  .card .title { font-size: 15px; font-weight: 600; line-height: 1.35; flex: 1; }
112
112
  .card .title a:hover { text-decoration: underline; text-decoration-color: var(--faint); }
113
+ .s-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
114
+ .s-title h1 { min-width: 0; }
115
+ .s-title-text { cursor: text; border-radius: 6px; outline-offset: 2px; }
116
+ .s-title-text:hover { background: var(--subtle); box-shadow: 0 0 0 4px var(--subtle); }
117
+ .s-title .rename-pen { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; border: 1px solid transparent; background: none; color: var(--muted); cursor: pointer; }
118
+ .s-title .rename-pen:hover, .s-title .rename-pen:focus-visible { color: var(--accent); border-color: var(--line); background: var(--bg2); }
119
+ .s-title .rename-pen svg { width: 16px; height: 16px; }
120
+ .s-title input { flex: 1 1 auto; width: min(560px, calc(100vw - 80px)); min-width: 0; font-family: inherit; font-weight: 700; font-size: 22px; line-height: 1.25; color: var(--text); background: var(--field); border: 1px solid var(--accent); border-radius: 8px; padding: 4px 8px; margin: 6px 0; }
113
121
  .card .title input { width: 100%; background: var(--bg); border: 1px solid var(--accent); border-radius: 6px; color: var(--text); padding: 3px 6px; font: inherit; }
114
122
  .mt { margin-top: 20px; }
115
123
  .icon { background: none; border: 0; color: var(--faint); cursor: pointer; padding: 0 4px; margin-left: 4px; font-size: 13px; opacity: 0; transition: opacity .15s; }
@@ -1432,6 +1440,17 @@ a.cpr:hover { border-color: var(--line-strong); }
1432
1440
  .prs-q { width: 100%; max-width: 480px; font-size: 16px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--text); }
1433
1441
  .prs-chips { margin-top: 8px; }
1434
1442
  .prs-chips .rchip.ck .rl::before { content: '◇ '; color: var(--faint); }
1443
+ /* 0.8.9: the buckets, each with its color dot (chips) and heading color (groups) */
1444
+ .prs-buckets .rchip .rl::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; background: var(--b, var(--gray)); }
1445
+ .prs-buckets .rchip { font-family: inherit; }
1446
+ .rchip.b-review, .prgroup.b-review { --b: var(--accent); }
1447
+ .rchip.b-fix, .prgroup.b-fix { --b: var(--red); }
1448
+ .rchip.b-ready, .prgroup.b-ready { --b: var(--green); }
1449
+ .rchip.b-waiting, .prgroup.b-waiting { --b: var(--yellow); }
1450
+ .rchip.b-merged, .prgroup.b-merged { --b: var(--machine); }
1451
+ .rchip.b-closed, .prgroup.b-closed { --b: var(--gray); }
1452
+ .prgroup[class*="b-"] > h2 { color: var(--b); }
1453
+ .prgroup[class*="b-"] > h2::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 7px; vertical-align: 1px; background: var(--b); }
1435
1454
  .pr-why { color: var(--muted); }
1436
1455
  .prrow { cursor: pointer; }
1437
1456
  .prrow.open { border-color: var(--line-strong); }
@@ -1556,30 +1575,97 @@ ul.files-list li + li { border-top: 1px solid var(--line); }
1556
1575
  .doc-viewer { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.55); display: flex; align-items: stretch; justify-content: center; padding: max(16px, env(safe-area-inset-top)) 12px 12px; }
1557
1576
  .doc-viewer[hidden] { display: none; }
1558
1577
  html.dv-open, html.dv-open body { overflow: hidden; }
1559
- .dv-sheet { width: 100%; max-width: 820px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; overflow: hidden; }
1560
- .dv-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
1578
+ .dv-sheet { width: min(1100px, 92vw); max-width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; overflow: hidden; box-shadow: var(--toast-shadow); }
1579
+ .dv-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px 10px 16px; border-bottom: 1px solid var(--line); }
1561
1580
  .dv-title { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1562
1581
  .dv-x { flex: none; width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); color: var(--text); font-size: 20px; line-height: 1; cursor: pointer; }
1563
- .dv-sub { padding: 6px 14px 0; font-size: 12px; }
1564
- .dv-body { flex: 1; overflow: auto; padding: 8px 16px 24px; -webkit-overflow-scrolling: touch; }
1565
- .doc-md { font-size: 15px; line-height: 1.55; color: var(--text2); overflow-wrap: anywhere; }
1566
- .doc-md h2, .doc-md h3, .doc-md h4 { color: var(--text); margin: 18px 0 8px; }
1567
- .doc-md h2, .doc-md h3, .doc-md h4 { text-transform: none; letter-spacing: normal; }
1568
- .doc-md h2 { font-size: 20px; } .doc-md h3 { font-size: 17px; } .doc-md h4 { font-size: 15px; }
1569
- .doc-md code { font-family: var(--mono); font-size: 13px; background: var(--bg2); border-radius: 5px; padding: 1px 5px; }
1570
- .doc-md pre.doc-code, .doc-pre { background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.45; white-space: pre; }
1571
- .doc-md pre.doc-code code { background: none; padding: 0; }
1572
- .doc-md blockquote { margin: 10px 0; padding: 2px 12px; border-left: 3px solid var(--line-strong); color: var(--muted); }
1582
+ .dv-dl { flex: none; display: inline-flex; align-items: center; gap: 6px; }
1583
+ .dv-dl svg { width: 15px; height: 15px; }
1584
+ .dv-dl .chev { font-size: 10px; opacity: .7; margin-left: 1px; }
1585
+ .dv-dl.copied { color: var(--green); border-color: var(--green); }
1586
+ .dv-ptitle { display: none; }
1587
+ .dv-sub { padding: 6px 16px 0; font-size: 12px; }
1588
+ .dv-body { flex: 1; overflow: auto; padding: 10px 24px 32px; -webkit-overflow-scrolling: touch; }
1589
+ /* 0.8.9: a phone gets the whole screen (a sheet), not a card with margins */
1590
+ @media (max-width: 599.98px) {
1591
+ .doc-viewer { padding: 0; }
1592
+ .dv-sheet { width: 100%; border: 0; border-radius: 0; box-shadow: none; padding-top: env(safe-area-inset-top); }
1593
+ .dv-head { padding: 8px 8px 8px 14px; }
1594
+ .dv-body { padding: 8px 16px calc(28px + env(safe-area-inset-bottom)); }
1595
+ .dv-sub { padding: 6px 14px 0; }
1596
+ .dv-dl .dl-word { display: none; }
1597
+ }
1598
+ html.dv-open .lo-backdrop { z-index: 61; }
1599
+ html.dv-open .lo-menu { z-index: 62; }
1600
+
1601
+ /* The rendered markdown (0.8.9: colors). Prose keeps a reading measure (~80
1602
+ characters); tables and code blocks may use the whole width. */
1603
+ .doc-md { font-size: 15px; line-height: 1.6; color: var(--text2); overflow-wrap: anywhere; }
1604
+ .doc-md > p, .doc-md > ul, .doc-md > ol, .doc-md > blockquote, .doc-md > h2, .doc-md > h3, .doc-md > h4, .doc-md > h5, .doc-md > h6 { max-width: 80ch; }
1605
+ .doc-md p { margin: 0 0 12px; }
1606
+ .doc-md h2, .doc-md h3, .doc-md h4, .doc-md h5, .doc-md h6 { text-transform: none; letter-spacing: normal; line-height: 1.3; }
1607
+ .doc-md h2 { font-size: 24px; font-weight: 700; color: var(--text); margin: 22px 0 12px; padding-bottom: 8px; border-bottom: 2px solid color-mix(in srgb, var(--accent) 45%, var(--line)); }
1608
+ .doc-md h2:first-child { margin-top: 6px; }
1609
+ .doc-md h3 { font-size: 19px; font-weight: 650; color: var(--accent); margin: 22px 0 8px; }
1610
+ .doc-md h4 { font-size: 16px; font-weight: 650; color: var(--cyan); margin: 18px 0 6px; }
1611
+ .doc-md h5, .doc-md h6 { font-size: 13px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 16px 0 6px; }
1612
+ .doc-md b, .doc-md strong { color: var(--text); font-weight: 650; }
1613
+ .doc-md em { font-style: italic; }
1614
+ .doc-md del { color: var(--muted); }
1615
+ .doc-md a { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); text-underline-offset: 2px; }
1616
+ .doc-md a:hover { text-decoration-color: var(--accent); }
1617
+ .doc-md code { font-family: var(--mono); font-size: .87em; color: var(--you); background: var(--tint-red); border-radius: 5px; padding: 1px 5px; }
1618
+ .doc-md pre.doc-code, .doc-pre { background: var(--card2); border: 1px solid var(--line); border-left: 3px solid color-mix(in srgb, var(--accent) 55%, var(--line)); border-radius: 10px; padding: 12px 14px; margin: 0 0 14px; overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.5; white-space: pre; color: var(--text); }
1619
+ .doc-md pre.doc-code code { background: none; padding: 0; color: inherit; font-size: inherit; }
1620
+ .doc-md .tk-c { color: var(--faint); font-style: italic; }
1621
+ .doc-md .tk-s { color: var(--green); }
1622
+ .doc-md .tk-n { color: var(--yellow); }
1623
+ .doc-md .tk-k { color: var(--accent); font-weight: 600; }
1624
+ .doc-md .tk-add { color: var(--green); background: var(--tint-green); display: inline-block; min-width: 100%; }
1625
+ .doc-md .tk-del { color: var(--red); background: var(--tint-red); display: inline-block; min-width: 100%; }
1626
+ .doc-md blockquote { margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--accent); background: var(--tint-accent); border-radius: 0 8px 8px 0; color: var(--text2); }
1627
+ .doc-md blockquote > :last-child { margin-bottom: 0; }
1628
+ .doc-md ul, .doc-md ol { margin: 0 0 12px; padding-left: 22px; }
1629
+ .doc-md li { margin: 3px 0; }
1630
+ .doc-md li::marker { color: var(--accent); }
1631
+ .doc-md ol > li::marker { font-weight: 600; }
1573
1632
  .doc-md li.sub { margin-left: 18px; }
1574
1633
  .doc-md li.task { list-style: none; margin-left: -18px; }
1575
1634
  .doc-page { max-width: 760px; padding-bottom: 32px; } /* 0.8.1: #/docs/<name> */
1576
- .doc-md .tbox { display: inline-block; width: 13px; height: 13px; border: 1.5px solid var(--line-strong); border-radius: 3px; margin-right: 7px; vertical-align: -1px; }
1635
+ .doc-md .tbox { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; border: 1.5px solid var(--line-strong); border-radius: 4px; margin-right: 8px; vertical-align: -2px; background: var(--field); }
1577
1636
  .doc-md .tbox.on { background: var(--green); border-color: var(--green); }
1578
- .doc-table { overflow-x: auto; }
1579
- .doc-md table { border-collapse: collapse; font-size: 13.5px; }
1637
+ .doc-md .tbox.on::after { content: ''; width: 4px; height: 8px; border: solid var(--on-accent); border-width: 0 2px 2px 0; transform: translateY(-1px) rotate(45deg); }
1638
+ .doc-md li.task:has(.tbox.on) { color: var(--muted); }
1639
+ .doc-md hr { border: 0; height: 2px; margin: 22px 0; background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 10%, transparent)); opacity: .55; }
1640
+ .doc-table { overflow-x: auto; margin: 0 0 14px; border: 1px solid var(--line); border-radius: 10px; }
1641
+ .doc-md table { border-collapse: collapse; font-size: 13.5px; width: max-content; min-width: 100%; }
1580
1642
  .doc-md table { overflow-wrap: normal; word-break: normal; }
1581
1643
  .doc-md th { white-space: nowrap; }
1582
- .doc-md th, .doc-md td { border: 1px solid var(--line); padding: 5px 8px; text-align: left; }
1644
+ .doc-md th, .doc-md td { border-bottom: 1px solid var(--line); padding: 7px 12px; text-align: left; vertical-align: top; }
1645
+ .doc-md th + th, .doc-md td + td { border-left: 1px solid var(--line); }
1646
+ .doc-md thead th { background: var(--tint-accent); color: var(--text); font-weight: 650; border-bottom: 2px solid color-mix(in srgb, var(--accent) 40%, var(--line)); }
1647
+ .doc-md tbody tr:nth-child(even) td { background: var(--bg2); }
1648
+ .doc-md tbody tr:last-child td { border-bottom: 0; }
1649
+
1650
+ /* 0.8.9: Download as PDF prints just the file (the browser's "Save as PDF"):
1651
+ white paper, the light theme's colors, no modal around it. */
1652
+ @media print {
1653
+ html.dv-printing body > *:not(#docViewer) { display: none !important; }
1654
+ html.dv-printing, html.dv-printing body { overflow: visible !important; height: auto !important; background: #fff !important; }
1655
+ html.dv-printing #docViewer { position: static !important; display: block !important; background: none !important; padding: 0 !important; }
1656
+ html.dv-printing .dv-sheet { display: block; width: auto; max-width: none; border: 0; border-radius: 0; box-shadow: none; background: #fff; overflow: visible; }
1657
+ html.dv-printing .dv-head, html.dv-printing .dv-sub, html.dv-printing .lo-menu, html.dv-printing .lo-backdrop { display: none !important; }
1658
+ html.dv-printing .dv-ptitle { display: block; font: 600 12px/1.4 var(--mono); color: #687185; border-bottom: 1px solid #e1e5ed; padding-bottom: 6px; margin-bottom: 14px; }
1659
+ html.dv-printing .dv-body { overflow: visible; padding: 0; }
1660
+ html.dv-printing .doc-md { font-size: 11.5pt; }
1661
+ html.dv-printing .doc-md > * { max-width: none; }
1662
+ html.dv-printing .doc-md pre.doc-code, html.dv-printing .doc-pre { white-space: pre-wrap; overflow: visible; break-inside: avoid; }
1663
+ html.dv-printing .doc-table { overflow: visible; }
1664
+ html.dv-printing .doc-md table { width: 100%; }
1665
+ html.dv-printing .doc-md tr, html.dv-printing .doc-md blockquote { break-inside: avoid; }
1666
+ html.dv-printing .doc-md h2, html.dv-printing .doc-md h3, html.dv-printing .doc-md h4 { break-after: avoid; }
1667
+ html.dv-printing * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
1668
+ }
1583
1669
  .doc-img { text-align: center; padding-top: 10px; }
1584
1670
  .doc-img img { max-width: 100%; height: auto; border-radius: 8px; background: #fff; }
1585
1671
 
package/ui/app.js CHANGED
@@ -11,7 +11,7 @@ import { mdInline, renderSection } from './changelog-md.js'
11
11
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
12
12
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
13
13
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
14
- import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
14
+ import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName } from './handoffs.js'
15
15
  const $app = document.getElementById('app')
16
16
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
17
17
  const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
@@ -996,7 +996,11 @@ async function viewSession(id) {
996
996
  const branch = s.repo && s.repo.branch
997
997
  const spv = await api('GET', '/v1/spaces').catch(() => null)
998
998
  let html = '<a class="back" href="#/">← All sessions</a>'
999
- html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + wtChips(s) + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
999
+ // 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
1000
+ const canRename = s.isLocal !== false && !s.agent && !/^agent:/.test(String(s.id || ''))
1001
+ html += '<div class="pagehead"><div>' + (canRename
1002
+ ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(s.title || 'Untitled session') + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
1003
+ : '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + wtChips(s) + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
1000
1004
  '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
1001
1005
  html += '<div class="facts">' +
1002
1006
  fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
@@ -1102,7 +1106,7 @@ async function togglePr(url, { retry = false } = {}) {
1102
1106
  const d = prsUi.data[prsUi.view]
1103
1107
  const r = prRows(d).find((x) => x.pr.url === url)
1104
1108
  const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
1105
- if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, open: prsUi.open.has(url), detail: prsUi.details[url] })
1109
+ if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, me: d.me || null, open: prsUi.open.has(url), detail: prsUi.details[url] })
1106
1110
  }
1107
1111
  const have = prsUi.details[url]
1108
1112
  if (!prsUi.open.has(url) || (have && !retry && (have.loading || (have.ok && Date.now() - have.at < 60000)))) return repaint()
@@ -1199,7 +1203,7 @@ function renderMd(md, { spaces = [] } = {}) {
1199
1203
  }
1200
1204
  if (/^\s{2,}\S/.test(line) && inList) {
1201
1205
  // "Stopped at: … Next: …" is two lines, each with its label.
1202
- const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
1206
+ const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
1203
1207
  if (sm) out.push('<span class="sub"><span class="k">' + sm[1] + '</span> ' + inline(sm[2]) + '</span>' + (sm[3] ? '<span class="sub"><span class="k">' + sm[3] + '</span> ' + inline(sm[4]) + '</span>' : ''))
1204
1208
  else out.push('<span class="sub">' + inline(t) + '</span>')
1205
1209
  continue
@@ -3022,6 +3026,8 @@ document.addEventListener('input', (ev) => {
3022
3026
  document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
3023
3027
 
3024
3028
  window.addEventListener('hashchange', () => closeActionMenu())
3029
+ // 0.8.9: a session page's title is a button too (Enter or Space renames it)
3030
+ document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"]')) { e.preventDefault(); t.click() } })
3025
3031
  // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
3026
3032
  document.addEventListener('keydown', (ev) => {
3027
3033
  const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
@@ -3045,7 +3051,7 @@ document.addEventListener('click', async (ev) => {
3045
3051
  if (action === 'prs-filter') {
3046
3052
  const f = { ...prsUi.filter }, k = btn.dataset.k, v = btn.dataset.v || ''
3047
3053
  if (k === 'repo') f.repos = v ? toggleRepo(f.repos || [], { key: btn.dataset.key || v, label: v }, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
3048
- else if (k === 'state' || k === 'checks') f[k] = f[k] === v ? null : v
3054
+ else if (k === 'state') f.state = f.state === v ? null : v
3049
3055
  return setPrsFilter(f)
3050
3056
  }
3051
3057
  if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
@@ -3327,6 +3333,8 @@ document.addEventListener('click', async (ev) => {
3327
3333
  }
3328
3334
  if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
3329
3335
  if (action === 'doc-close') return closeDoc()
3336
+ if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
3337
+ if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
3330
3338
  if (action === 'need-restore' || action === 'need-restore-all') {
3331
3339
  btn.disabled = true
3332
3340
  state.dismissedOpen = true
@@ -3370,8 +3378,8 @@ document.addEventListener('click', async (ev) => {
3370
3378
  } else if (action === 'rename') {
3371
3379
  const box = btn.closest('.title')
3372
3380
  if (!box || box.querySelector('input')) return
3373
- const current = s ? s.title || '' : ''
3374
- box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title">'
3381
+ const current = s ? s.title || '' : btn.dataset.title || ''
3382
+ box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title" maxlength="120" enterkeyhint="done">'
3375
3383
  const input = box.querySelector('input')
3376
3384
  input.focus(); input.select()
3377
3385
  let finished = false
@@ -3379,12 +3387,14 @@ document.addEventListener('click', async (ev) => {
3379
3387
  if (finished) return
3380
3388
  finished = true
3381
3389
  if (save && input.value.trim() && input.value.trim() !== current) {
3382
- await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { title: input.value.trim() })
3383
- toast('Renamed. With the mod, the session and its tab title follow within a minute')
3390
+ try {
3391
+ await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { title: input.value.trim() })
3392
+ toast('Renamed. With the mod, the session and its tab title follow within a minute')
3393
+ } catch (e) { toast(e.message, 5000) }
3384
3394
  }
3385
3395
  render()
3386
3396
  }
3387
- input.addEventListener('keydown', (e) => { if (e.key === 'Enter') done(true); if (e.key === 'Escape') done(false) })
3397
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); done(true) } if (e.key === 'Escape') { e.stopPropagation(); done(false) } })
3388
3398
  input.addEventListener('blur', () => done(true))
3389
3399
  }
3390
3400
  } catch (e) { toast(e.message); btn.disabled = false }
@@ -3507,6 +3517,7 @@ async function todoDoneChange(cb) {
3507
3517
  // The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
3508
3518
  // that owns it (through another Mac or the relay from a phone). Markdown is
3509
3519
  // rendered from escaped text; HTML files show as source; anything else downloads.
3520
+ const docUi = { r: null, name: '', printing: null }
3510
3521
  function docViewerEl() {
3511
3522
  let el = document.getElementById('docViewer')
3512
3523
  if (el) return el
@@ -3517,12 +3528,15 @@ function docViewerEl() {
3517
3528
  el.setAttribute('role', 'dialog')
3518
3529
  el.setAttribute('aria-modal', 'true')
3519
3530
  el.setAttribute('aria-labelledby', 'docViewerTitle')
3520
- el.innerHTML = '<div class="dv-sheet"><div class="dv-head"><div class="dv-title" id="docViewerTitle"></div><a class="btn sm dv-dl" hidden>Download</a><button type="button" class="dv-x" data-action="doc-close" aria-label="Close">×</button></div><div class="dv-sub faint"></div><div class="dv-body"></div></div>'
3531
+ // 0.8.9: Download is a small menu (Copy / Download as Markdown / Download as PDF…), our own, not a native one
3532
+ el.innerHTML = '<div class="dv-sheet"><div class="dv-head"><div class="dv-title" id="docViewerTitle"></div>' +
3533
+ '<button type="button" class="btn sm dv-dl" data-action="doc-dl" aria-haspopup="menu" aria-expanded="false" hidden>' + icon('download') + '<span class="dl-word">Download</span><span class="chev" aria-hidden="true">▾</span></button>' +
3534
+ '<button type="button" class="dv-x" data-action="doc-close" aria-label="Close">×</button></div><div class="dv-sub faint"></div><div class="dv-ptitle" aria-hidden="true"></div><div class="dv-body"></div></div>'
3521
3535
  el.addEventListener('click', (ev) => { if (ev.target === el) closeDoc() })
3522
3536
  document.body.appendChild(el)
3523
3537
  return el
3524
3538
  }
3525
- function closeDoc() { const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } document.documentElement.classList.remove('dv-open') }
3539
+ function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } docUi.r = null; document.documentElement.classList.remove('dv-open') }
3526
3540
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
3527
3541
  async function openDoc(sessionId, { path, plan } = {}) {
3528
3542
  if (!sessionId || (!path && !plan)) return
@@ -3531,7 +3545,8 @@ async function openDoc(sessionId, { path, plan } = {}) {
3531
3545
  el.querySelector('.dv-title').textContent = name
3532
3546
  el.querySelector('.dv-sub').textContent = ''
3533
3547
  el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
3534
- const dl = el.querySelector('.dv-dl'); dl.hidden = true; dl.removeAttribute('href')
3548
+ const dl = el.querySelector('.dv-dl'); dl.hidden = true
3549
+ docUi.r = null; docUi.name = name
3535
3550
  el.hidden = false
3536
3551
  document.documentElement.classList.add('dv-open')
3537
3552
  const qs = new URLSearchParams(plan ? { plan } : { path })
@@ -3541,12 +3556,88 @@ async function openDoc(sessionId, { path, plan } = {}) {
3541
3556
  el.querySelector('.dv-title').textContent = r.name || name
3542
3557
  el.querySelector('.dv-sub').textContent = [r.plan ? 'Plan' : r.kind === 'new' ? 'New' : r.kind === 'edited' ? 'Edited' : '', r.size ? (r.size < 1024 ? r.size + ' B' : Math.round(r.size / 1024) + ' KB') : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
3543
3558
  el.querySelector('.dv-body').innerHTML = docViewerBody(r)
3544
- const href = docDownloadHref(r)
3545
- if (href) { dl.href = href; dl.download = String(r.name || name).split('/').pop() + (r.plan ? '.md' : ''); dl.hidden = false }
3559
+ el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
3560
+ docUi.r = r; docUi.name = name
3561
+ const items = docMenuItems(r)
3562
+ if (items.length) {
3563
+ dl.hidden = false
3564
+ const menu = items.length > 1
3565
+ dl.setAttribute('aria-haspopup', menu ? 'menu' : 'false')
3566
+ dl.querySelector('.chev').hidden = !menu
3567
+ dl.title = menu ? 'Copy or download' : 'Download ' + docFileName(r, name)
3568
+ dl.setAttribute('aria-label', menu ? 'Copy or download' : 'Download')
3569
+ }
3546
3570
  } catch (e) {
3547
3571
  el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
3548
3572
  }
3549
3573
  }
3574
+ function openDocMenu(btn) {
3575
+ const r = docUi.r
3576
+ const items = docMenuItems(r)
3577
+ if (!items.length) return
3578
+ if (items.length === 1) return docAction(items[0].key)
3579
+ const ic = { copy: 'copy', download: 'download', pdf: 'file-text' }
3580
+ openActionMenu(btn, items.map((it) => ({ label: it.label, icon: icon(ic[it.key] || 'download'), attrs: { 'data-action': 'doc-' + it.key, title: it.title } })), { title: docFileName(r, docUi.name) })
3581
+ }
3582
+ async function copyRaw(text) {
3583
+ if (await clipWrite(text)) return true
3584
+ const ta = document.createElement('textarea')
3585
+ ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;font-size:16px'
3586
+ document.body.appendChild(ta); ta.select()
3587
+ let ok = false
3588
+ try { ok = document.execCommand('copy') } catch {}
3589
+ ta.remove()
3590
+ return ok
3591
+ }
3592
+ async function docAction(key) {
3593
+ const r = docUi.r
3594
+ if (!r) return
3595
+ const btn = document.querySelector('#docViewer .dv-dl')
3596
+ if (key === 'copy') {
3597
+ const ok = await copyRaw(String(r.text || ''))
3598
+ if (!ok) { toast('Couldn’t copy here. Download it instead.', 4000); return }
3599
+ toast('Copied ' + docFileName(r, docUi.name))
3600
+ if (btn) {
3601
+ const word = btn.querySelector('.dl-word')
3602
+ btn.classList.add('copied'); if (word) word.textContent = 'Copied'
3603
+ clearTimeout(docUi.copiedTimer)
3604
+ docUi.copiedTimer = setTimeout(() => { btn.classList.remove('copied'); if (word) word.textContent = 'Download' }, 1500)
3605
+ }
3606
+ return
3607
+ }
3608
+ if (key === 'download') {
3609
+ const a = document.createElement('a')
3610
+ a.href = docDownloadHref(r); a.download = docFileName(r, docUi.name); a.rel = 'noopener'
3611
+ document.body.appendChild(a); a.click(); a.remove()
3612
+ return
3613
+ }
3614
+ if (key === 'pdf') return printDoc()
3615
+ }
3616
+ // Download as PDF: the browser's print dialog over just this file (print
3617
+ // styles in app.css), in the light theme, titled with the file's name, so
3618
+ // "Save as PDF" makes a clean PDF. No PDF library.
3619
+ function printDoc() {
3620
+ const r = docUi.r
3621
+ if (!r || r.type !== 'markdown') return
3622
+ endDocPrint()
3623
+ const root = document.documentElement
3624
+ docUi.printing = { title: document.title, theme: root.getAttribute('data-theme') }
3625
+ root.setAttribute('data-theme', 'light')
3626
+ root.classList.add('dv-printing')
3627
+ document.title = docFileName(r, docUi.name).replace(/\.(md|markdown)$/i, '')
3628
+ window.addEventListener('afterprint', endDocPrint, { once: true })
3629
+ toast('Choose “Save as PDF” in the print dialog', 5000)
3630
+ setTimeout(() => { try { window.print() } catch { endDocPrint() } }, 60)
3631
+ }
3632
+ function endDocPrint() {
3633
+ const p = docUi.printing
3634
+ if (!p) return
3635
+ docUi.printing = null
3636
+ const root = document.documentElement
3637
+ root.classList.remove('dv-printing')
3638
+ if (p.theme) root.setAttribute('data-theme', p.theme); else root.removeAttribute('data-theme')
3639
+ document.title = p.title
3640
+ }
3550
3641
 
3551
3642
  // Dismiss a need (0.6.5): gone from every card and count at once, kept on
3552
3643
  // the daemon (so your other devices and the relay see it too), Undo for 5 s.
@@ -15,11 +15,13 @@ const anchor = (href, inner) => '<a href="' + esc(href) + '" target="_blank" rel
15
15
  const docLink = (u) => /^#\/docs\/[a-z-]+$/.test(String(u || '').trim())
16
16
 
17
17
  // One line of inline markdown -> HTML. Everything that isn't markup is escaped.
18
- export function mdInline(s, { links = true } = {}) {
18
+ export function mdInline(s, { links = true, em = false } = {}) {
19
19
  const held = []
20
20
  const hold = (html) => '\u0000' + (held.push(html) - 1) + '\u0000'
21
21
  const back = (t) => t.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)])
22
- const marks = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
22
+ // 0.8.9 (em: the file viewer): *italic*, _italic_ (not inside snake_case) and ~~struck~~ too
23
+ const emph = (t) => !em ? t : t.replace(/~~([^~\s][^~]*?)~~/g, '<del>$1</del>').replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, '$1<em>$2</em>').replace(/(^|[^\w])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>')
24
+ const marks = (t) => emph(esc(t).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'))
23
25
  let t = String(s == null ? '' : s)
24
26
  // `code` first, so nothing inside it is read as markup.
25
27
  t = t.replace(/`([^`]+)`/g, (_, c) => hold('<code>' + esc(c) + '</code>'))
@@ -28,6 +30,8 @@ export function mdInline(s, { links = true } = {}) {
28
30
  const inner = back(marks(text))
29
31
  return hold(links && httpsUrl(url) ? anchor(url, inner) : links && docLink(url) ? '<a href="' + esc(url.trim()) + '">' + inner + '</a>' : inner)
30
32
  })
33
+ // with em on, bare links are held first so an _ or * in a URL stays a URL
34
+ if (links && em) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + hold(anchor(url, esc(url))))
31
35
  t = marks(t)
32
36
  if (links) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + anchor(url.replace(/&amp;/g, '&'), url))
33
37
  return back(t)
package/ui/handoffs.js CHANGED
@@ -103,14 +103,52 @@ export function sessionFilesSection(s, { agoFn = null } = {}) {
103
103
 
104
104
  // ------------------------------------------------------------------ viewer
105
105
 
106
+ // 0.8.9: a light tint for code blocks, not a real highlighter: comments,
107
+ // strings, numbers and common keywords get a class (.tk-c/.tk-s/.tk-n/.tk-k),
108
+ // everything escaped. Plain text, diffs and unknown languages: diff lines
109
+ // tinted, nothing else.
110
+ const KEYWORDS = new Set(('if else for while do switch case break continue return function const let var class new this import export from default async await try catch finally throw typeof instanceof in of yield ' +
111
+ 'def lambda elif pass with as is not and or None True False self raise except global nonlocal ' +
112
+ 'fn pub mut impl struct enum trait match use mod crate where loop ' +
113
+ 'func package type interface go defer chan select range map ' +
114
+ 'true false null undefined nil void public private protected static final extends implements ' +
115
+ 'echo then fi esac done local export readonly ' +
116
+ 'SELECT FROM WHERE JOIN LEFT RIGHT INNER OUTER ON GROUP BY ORDER LIMIT INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE INDEX AND OR NOT AS WITH').split(' '))
117
+ const PROSE_LANGS = new Set(['', 'text', 'txt', 'plain', 'plaintext', 'markdown', 'md', 'output', 'console', 'log'])
118
+ export function tintCode(code, lang = '') {
119
+ const src = String(code == null ? '' : code)
120
+ lang = String(lang || '').toLowerCase()
121
+ if (lang === 'diff' || lang === 'patch') return src.split('\n').map((l) => /^\+(?!\+\+)/.test(l) ? '<span class="tk-add">' + esc(l) + '</span>' : /^-(?!--)/.test(l) ? '<span class="tk-del">' + esc(l) + '</span>' : /^@@/.test(l) ? '<span class="tk-k">' + esc(l) + '</span>' : esc(l)).join('\n')
122
+ if (PROSE_LANGS.has(lang)) return esc(src)
123
+ const hash = /^(sh|bash|zsh|shell|py|python|rb|ruby|yaml|yml|toml|ini|conf|dockerfile|make|makefile|r|perl|pl|elixir|ex)$/.test(lang)
124
+ const dash = /^(sql|lua|haskell|hs)$/.test(lang)
125
+ const re = new RegExp([
126
+ hash ? '(#[^\\n]*)' : dash ? '(--[^\\n]*)' : '(\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)',
127
+ '("(?:[^"\\\\\\n]|\\\\.)*"|\'(?:[^\'\\\\\\n]|\\\\.)*\'|`(?:[^`\\\\]|\\\\.)*`)',
128
+ '\\b(\\d+(?:\\.\\d+)?)\\b',
129
+ '\\b([A-Za-z_][A-Za-z0-9_]*)\\b',
130
+ ].join('|'), 'g')
131
+ let out = '', last = 0, m
132
+ while ((m = re.exec(src))) {
133
+ out += esc(src.slice(last, m.index))
134
+ if (m[1] != null) out += '<span class="tk-c">' + esc(m[1]) + '</span>'
135
+ else if (m[2] != null) out += '<span class="tk-s">' + esc(m[2]) + '</span>'
136
+ else if (m[3] != null) out += '<span class="tk-n">' + esc(m[3]) + '</span>'
137
+ else out += KEYWORDS.has(m[4]) ? '<span class="tk-k">' + esc(m[4]) + '</span>' : esc(m[4])
138
+ last = re.lastIndex
139
+ }
140
+ return out + esc(src.slice(last))
141
+ }
142
+
106
143
  // Markdown -> safe HTML: headings, paragraphs, lists, task boxes, quotes,
107
144
  // fenced code, tables, rules. Inline markup via mdInline (escaped; https links only).
145
+ const mdi = (t) => mdInline(t, { em: true })
108
146
  export function renderDoc(md) {
109
147
  const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '').split('\n')
110
148
  const out = []
111
149
  let i = 0
112
150
  const para = []
113
- const flush = () => { if (para.length) { out.push('<p>' + para.map((x) => mdInline(x)).join('<br>') + '</p>'); para.length = 0 } }
151
+ const flush = () => { if (para.length) { out.push('<p>' + para.map((x) => mdi(x)).join('<br>') + '</p>'); para.length = 0 } }
114
152
  while (i < lines.length) {
115
153
  const ln = lines[i]
116
154
  let m
@@ -120,11 +158,12 @@ export function renderDoc(md) {
120
158
  i++
121
159
  while (i < lines.length && !lines[i].trim().startsWith(fence)) body.push(lines[i++])
122
160
  i++
123
- out.push('<pre class="doc-code"><code>' + esc(body.join('\n')) + '</code></pre>')
161
+ const lang = (m[2] || '').toLowerCase()
162
+ out.push('<pre class="doc-code"' + (lang ? ' data-lang="' + esc(lang) + '"' : '') + '><code>' + tintCode(body.join('\n'), lang) + '</code></pre>')
124
163
  continue
125
164
  }
126
165
  if (!ln.trim()) { flush(); i++; continue }
127
- if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' + mdInline(m[2].replace(/\s+#+\s*$/, '')) + '</h' + n + '>'); i++; continue }
166
+ if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' + mdi(m[2].replace(/\s+#+\s*$/, '')) + '</h' + n + '>'); i++; continue }
128
167
  if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(ln)) { flush(); out.push('<hr>'); i++; continue }
129
168
  if (/^\s*>/.test(ln)) {
130
169
  flush(); const q = []
@@ -137,7 +176,7 @@ export function renderDoc(md) {
137
176
  const head = cells(ln); i += 2
138
177
  const rows = []
139
178
  while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]))
140
- out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>' + rows.map((r) => '<tr>' + r.map((c) => '<td>' + mdInline(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>')
179
+ out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' + mdi(c) + '</th>').join('') + '</tr></thead><tbody>' + rows.map((r) => '<tr>' + r.map((c) => '<td>' + mdi(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>')
141
180
  continue
142
181
  }
143
182
  if (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(ln)) {
@@ -150,7 +189,7 @@ export function renderDoc(md) {
150
189
  if (mm) items.push({ depth: Math.floor(mm[1].replace(/\t/g, ' ').length / 2), text: mm[2] })
151
190
  else items[items.length - 1].text += ' ' + l.trim()
152
191
  }
153
- const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' + mdInline(tm[2]) + '</li>' : '<li>' + mdInline(t) + '</li>' }
192
+ const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' + mdi(tm[2]) + '</li>' : '<li>' + mdi(t) + '</li>' }
154
193
  out.push('<' + (ordered ? 'ol' : 'ul') + '>' + items.map((it) => (it.depth ? li(it.text).replace('<li', '<li class="sub"') : li(it.text))).join('') + '</' + (ordered ? 'ol' : 'ul') + '>')
155
194
  continue
156
195
  }
@@ -174,6 +213,24 @@ export function docViewerBody(r) {
174
213
  if (r.type === 'text' || r.type === 'html') return '<pre class="doc-pre">' + esc(r.text || '') + '</pre>'
175
214
  return '<div class="empty">No preview for this kind of file.</div>'
176
215
  }
216
+ // 0.8.9: what the viewer's Download menu offers for a file: Copy (the raw text),
217
+ // the file itself, and for markdown a PDF (the browser's print dialog, "Save as
218
+ // PDF"). Images and other binaries keep a plain Download (no menu).
219
+ export const PDF_HINT = 'Opens the print dialog: choose “Save as PDF”'
220
+ export function docMenuItems(r) {
221
+ if (!r || r.missing || r.tooBig || !docDownloadHref(r)) return []
222
+ const text = typeof r.text === 'string' && !r.base64 && r.type !== 'image'
223
+ const items = []
224
+ if (text) items.push({ key: 'copy', label: r.type === 'markdown' ? 'Copy' : 'Copy text', title: 'Copy the file’s text' })
225
+ items.push({ key: 'download', label: r.type === 'markdown' ? 'Download as Markdown' : 'Download', title: 'Save ' + docFileName(r) })
226
+ if (r.type === 'markdown') items.push({ key: 'pdf', label: 'Download as PDF…', title: PDF_HINT })
227
+ return items
228
+ }
229
+ // The name a download is saved as (a plan has no extension of its own).
230
+ export function docFileName(r, fallback = 'file') {
231
+ const n = String((r && r.name) || fallback).split('/').pop() || fallback
232
+ return r && r.plan && !/\.md$/i.test(n) ? n + '.md' : n
233
+ }
177
234
  // A download for anything (an HTML file is offered as a file, never rendered live).
178
235
  export function docDownloadHref(r) {
179
236
  if (!r || r.missing || r.tooBig) return ''
package/ui/icons.js CHANGED
@@ -32,6 +32,8 @@ export const ICONS = {
32
32
  "chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
33
33
  "mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
34
34
  "search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
35
+ "file-text": "<path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\"/> <path d=\"M14 2v4a2 2 0 0 0 2 2h4\"/> <path d=\"M10 9H8\"/> <path d=\"M16 13H8\"/> <path d=\"M16 17H8\"/>",
36
+ "pencil": "<path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\"/> <path d=\"m15 5 4 4\"/>",
35
37
  "copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/>",
36
38
  "ellipsis": "<circle cx=\"12\" cy=\"12\" r=\"1\"/> <circle cx=\"19\" cy=\"12\" r=\"1\"/> <circle cx=\"5\" cy=\"12\" r=\"1\"/>",
37
39
  "pin": "<path d=\"M12 17v5\"/> <path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\"/>",