heyquilt 0.3.121 → 0.3.138
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/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/ui/session.js
CHANGED
|
@@ -6,7 +6,7 @@ import { TOKEN, I, state, $, esc, basename, bytes, clock, ago, avatar, colorFor,
|
|
|
6
6
|
import { openInvite, renderTabs, markRead, go } from './app.js'
|
|
7
7
|
import { renderFeed } from './feed.js'
|
|
8
8
|
import { renderLoom, resetLoom, focusFile, showsLoom } from './loom.js'
|
|
9
|
-
import { buildLoom, foldAiLanes } from './loom-model.js'
|
|
9
|
+
import { buildLoom, foldAiLanes, aiWorking } from './loom-model.js'
|
|
10
10
|
import { railModel } from './rail-model.js'
|
|
11
11
|
import { renderRail, openLocks, closeLocks } from './rail.js'
|
|
12
12
|
import { feedbackDockHtml, bindFeedback } from './feedback.js'
|
|
@@ -14,6 +14,7 @@ import { conversations } from './feed-convs.js'
|
|
|
14
14
|
import { closeTreeMenu } from './tree.js'
|
|
15
15
|
import { renderFileView, renderFileTabs } from './fileview.js'
|
|
16
16
|
import { rolledOff, diffLines } from './diffview.js'
|
|
17
|
+
import { searchMarkup, searchTabMarkup, liveByPerson, fileReadsNeeded, SEARCH_ICON } from './quiltview.js'
|
|
17
18
|
import { changesMarkup, bindChanges, unbindChanges, changesChanged, fileChanges, loadChanges, changedCount, unseenChanges, markChangesSeen } from './changes.js'
|
|
18
19
|
import { chipCounts } from './changes-seen.js'
|
|
19
20
|
import { quiltMark } from './mark.js'
|
|
@@ -25,10 +26,10 @@ import { accessFormValues, accessSaveBody, grantsLoading, grantsLoaded, grantsFa
|
|
|
25
26
|
import { workspaceFilePicker } from './files.js'
|
|
26
27
|
import { sessionAgentsHtml } from './agent-place.js'
|
|
27
28
|
import { renderCatchUp, bindCatchUp } from './catchup.js'
|
|
28
|
-
import { branchMenuHtml, upstreamText, needsHand } from './branches.js'
|
|
29
29
|
import { boxSummary, boxesOf, unplaced, boxesSig, boxListHtml, boxFoldHtml } from './boxes.js'
|
|
30
30
|
import { gitControl, gitStatusParts, gitSegHtml } from './git-rule.js'
|
|
31
|
-
import {
|
|
31
|
+
import { inSeam, makeQuiltWords, seamChip, squareCard, squarePanelHtml, seamSettingsHtml, openSquareButton, squaresSideHtml, SEAM_SIDE_HINT } from './seam-model.js'
|
|
32
|
+
import { callButtonMarkup, callPinMarkup, screenStageMarkup, mountCall, unmountCall, renderCall, callCards } from './voice.js'
|
|
32
33
|
|
|
33
34
|
let current = null // session id being shown
|
|
34
35
|
let timers = []
|
|
@@ -55,7 +56,9 @@ function ws (id) {
|
|
|
55
56
|
convSel: {}, // person -> pinned conversation id (absent: follow the newest)
|
|
56
57
|
fileTabs: [],
|
|
57
58
|
fileSel: null,
|
|
58
|
-
fileTab: {}, // file path -> 'changes' when
|
|
59
|
+
fileTab: {}, // file path -> 'changes' or 'blame' when that tab is shown (absent: the file)
|
|
60
|
+
searchTabs: [], // code searches open as tabs, in a session linked to a quilt (quiltview.js)
|
|
61
|
+
searchSel: null,
|
|
59
62
|
expanded: {},
|
|
60
63
|
boxesCollapsed: false, // the Boxes pane (who is working on what) starts open; folded, it is a rail of locks (rail.js)
|
|
61
64
|
stale: {}, // file path -> changed while not visible
|
|
@@ -137,6 +140,7 @@ export function mountSession (id) {
|
|
|
137
140
|
<button class="pop-item" role="menuitem" id="leave-btn">Leave this session</button>
|
|
138
141
|
</div>
|
|
139
142
|
</div>
|
|
143
|
+
<form class="qt-topsearch" id="qt-topsearch" role="search" hidden>${SEARCH_ICON}<input id="qt-topsearch-q" name="q" placeholder="Search the code on main" aria-label="Search the code on main" autocomplete="off" spellcheck="false"></form>
|
|
140
144
|
<span class="spacer"></span>
|
|
141
145
|
<span class="relay-problem" id="relay-problem" role="status" hidden></span>
|
|
142
146
|
<span class="access-pill" id="access-pill" hidden></span>
|
|
@@ -183,12 +187,14 @@ export function mountSession (id) {
|
|
|
183
187
|
</aside>
|
|
184
188
|
<main class="ws-main">
|
|
185
189
|
<div class="requests catchup" id="catchup" role="region" aria-label="While you were away" hidden></div>
|
|
190
|
+
<div class="requests square" id="square-card" role="status" aria-label="Seam" hidden></div>
|
|
186
191
|
<div class="requests github" id="github-card" role="region" aria-label="GitHub" hidden></div>
|
|
187
192
|
<div class="requests" id="requests" hidden></div>
|
|
188
193
|
<div class="ws-mainbar" id="mainbar" hidden>
|
|
189
194
|
<div class="ws-tabs" id="main-tabs" role="tablist"></div>
|
|
190
195
|
</div>
|
|
191
196
|
<div class="ws-content" id="main"></div>
|
|
197
|
+
${screenStageMarkup()}
|
|
192
198
|
</main>
|
|
193
199
|
<aside class="ws-chat" id="chat-pane" aria-label="Chat">
|
|
194
200
|
<div class="chat-resize" id="chat-resize" role="separator" aria-orientation="vertical" aria-label="Resize chat" title="Drag to resize · double-click to collapse" tabindex="0"></div>
|
|
@@ -228,6 +234,7 @@ export function mountSession (id) {
|
|
|
228
234
|
// Report a bug / Suggest a feature at the bottom of the Boxes pane (feedback.js): onto this session's board.
|
|
229
235
|
bindFeedback($('.ws-boxes'), { session: () => current, where: () => state.sessions.get(current)?.status?.sessionName || 'a session' })
|
|
230
236
|
bindGithubCard()
|
|
237
|
+
bindSquareCard()
|
|
231
238
|
bindMain()
|
|
232
239
|
bindTreeEvents()
|
|
233
240
|
applyTreeCollapsed()
|
|
@@ -390,6 +397,15 @@ function bindMenu (btn, menu, onPick) {
|
|
|
390
397
|
}
|
|
391
398
|
|
|
392
399
|
function bindTop () {
|
|
400
|
+
$('#qt-topsearch').onsubmit = (e) => {
|
|
401
|
+
e.preventDefault()
|
|
402
|
+
const box = $('#qt-topsearch-q')
|
|
403
|
+
const q = box.value.trim()
|
|
404
|
+
if (!q) return
|
|
405
|
+
box.value = ''
|
|
406
|
+
box.blur()
|
|
407
|
+
openSearch(q)
|
|
408
|
+
}
|
|
393
409
|
$('#tasks-btn').onclick = () => {
|
|
394
410
|
const w = ws(current)
|
|
395
411
|
if (w.mode === 'tasks') w.mode = w.aiSel ? 'ai' : w.fileSel ? 'files' : 'ai'
|
|
@@ -420,6 +436,8 @@ function bindTop () {
|
|
|
420
436
|
value: st.agentCommits || 'branches',
|
|
421
437
|
rule: st.agentCommitsBy && st.agentCommitsBy.mode ? st.agentCommitsBy : null,
|
|
422
438
|
words: st.gitForEveryone && st.gitForEveryone.words,
|
|
439
|
+
// In Seam the card says so instead of git's choice (git-rule.js thisSessionGitHtml).
|
|
440
|
+
...(inSeam(st) ? { seam: st.quilt.slug || st.quilt.id } : {}),
|
|
423
441
|
open: (tab) => openSessionSettings(tab)
|
|
424
442
|
}
|
|
425
443
|
})
|
|
@@ -478,7 +496,7 @@ function bindTop () {
|
|
|
478
496
|
/** The Session settings window's controls: your AI chat's sharing, and (for the owner) who can get in and what they may do. */
|
|
479
497
|
function bindSessionSettings (root) {
|
|
480
498
|
root.addEventListener('change', (e) => { if (e.target.matches('[data-github-sync], [data-large-to-relay]')) gitSettingChange(e) })
|
|
481
|
-
root.addEventListener('click', (e) => { if (e.target.closest('[data-gh-connect]')) connectGithub(); else if (e.target.closest('[data-agent-commits]')) gitSettingChange(e) })
|
|
499
|
+
root.addEventListener('click', (e) => { if (e.target.closest('[data-gh-connect]')) connectGithub(); else if (e.target.closest('[data-make-quilt]')) makeQuilt(e.target.closest('[data-make-quilt]')); else if (e.target.closest('[data-agent-commits]')) gitSettingChange(e) })
|
|
482
500
|
root.addEventListener('change', async (e) => {
|
|
483
501
|
if (e.target.matches('[data-share]')) {
|
|
484
502
|
const on = e.target.checked
|
|
@@ -637,19 +655,99 @@ function agentLine (p) {
|
|
|
637
655
|
// ------------------------------------------------------------- branches --
|
|
638
656
|
let branchSyncing = false
|
|
639
657
|
|
|
658
|
+
/** One line on how a branch stands against its upstream ('' without one). */
|
|
659
|
+
export function upstreamText (u) {
|
|
660
|
+
if (!u || !u.name) return ''
|
|
661
|
+
const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
|
|
662
|
+
// A failed fetch: what git last knew is stale, so never "up to date".
|
|
663
|
+
if (u.fetchOk === false) return `can't fetch ${u.name}: ${u.fetchError || 'git fetch failed'}`
|
|
664
|
+
const by = u.mergedBy ? `; being merged by ${u.clashMine ? 'your AI' : u.mergedBy}` : ''
|
|
665
|
+
if (u.diverged) return `diverged from ${u.name}: ${n(u.ahead, 'commit')} here, ${n(u.behind, 'commit')} there${by}`
|
|
666
|
+
const clash = Array.isArray(u.conflicts) ? u.conflicts.length : u.conflicts
|
|
667
|
+
if (clash) return `${n(u.behind, 'commit')} behind ${u.name}; ${n(clash, 'file')} clash with the session's work${by}`
|
|
668
|
+
if (u.waiting) return `${n(u.behind, 'commit')} behind ${u.name}; waiting: ${u.waiting}`
|
|
669
|
+
if (u.behind) return `${n(u.behind, 'commit')} behind ${u.name}`
|
|
670
|
+
if (u.ahead) return `${n(u.ahead, 'commit')} ahead of ${u.name}`
|
|
671
|
+
return `up to date with ${u.name}`
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Whether the label should draw the eye: this folder is behind and Quilt can't bring it in by itself. */
|
|
675
|
+
export function needsHand (u) {
|
|
676
|
+
return !!(u && (u.diverged || u.fetchOk === false || (Array.isArray(u.conflicts) ? u.conflicts.length : u.conflicts)))
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The git panel's head in a session still on git: this folder's branch, how it stands against its
|
|
681
|
+
* upstream, and Check. The list of every branch (the branch menu, src/ui/branches.js) went in 16's R2:
|
|
682
|
+
* which branch a folder is on is git's, and new sessions are quilts.
|
|
683
|
+
*/
|
|
684
|
+
export function folderBranchHtml ({ git, syncing = false }) {
|
|
685
|
+
if (!git) return ''
|
|
686
|
+
const u = git.upstream
|
|
687
|
+
return `<div class="br-here br-compact">
|
|
688
|
+
<div class="br-line">${I.branch}<b class="br-name">${esc(git.key)}</b>${u ? `<span class="br-sub${needsHand(u) ? ' warn' : ''}" title="${esc(upstreamText(u))}">${esc(upstreamText(u))}</span>` : ''}${u ? `<button type="button" class="btn sm ghost br-sync" data-branch-sync ${syncing ? 'disabled' : ''} title="Look for new commits on ${esc(u.name || 'the upstream')}">${I.refresh}<span>${syncing ? 'Checking' : 'Check'}</span></button>` : ''}</div>
|
|
689
|
+
${u && Array.isArray(u.conflicts) && u.conflicts.length ? `<ul class="br-clash">${u.conflicts.slice(0, 6).map((c) => `<li><code>${esc(c.path)}</code> ${esc(c.why)}</li>`).join('')}</ul><div class="br-hint">Ask your AI to pull and resolve these: Quilt brings nothing in until they're settled.</div>` : ''}
|
|
690
|
+
${u && u.diverged ? '<div class="br-hint">Quilt never merges git history: ask your AI to pull or rebase, and the session takes in the result.</div>' : ''}
|
|
691
|
+
</div>`
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
let squareBusy = '' // 'commit' or 'merge' while the square panel's button runs (layout 3)
|
|
695
|
+
|
|
640
696
|
function renderBranchMenu () {
|
|
641
697
|
const st = sum().status
|
|
642
698
|
const menu = $('#branch-menu')
|
|
643
|
-
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
699
|
+
// In Seam: the square this folder follows, and committing and merging it. The branch documents kept
|
|
700
|
+
// for a move back are read-only, and git is set aside here: none of them is listed.
|
|
701
|
+
if (inSeam(st)) {
|
|
702
|
+
menu.hidden = false
|
|
703
|
+
const html = squarePanelHtml(st, { busy: squareBusy })
|
|
704
|
+
if (menu.dataset.html === html) return
|
|
705
|
+
// A commit message being typed survives the redraw.
|
|
706
|
+
const typing = menu.querySelector('[data-square-commit] input')
|
|
707
|
+
const kept = typing ? { value: typing.value, focused: document.activeElement === typing } : null
|
|
708
|
+
menu.dataset.html = html
|
|
709
|
+
menu.innerHTML = html
|
|
710
|
+
const box = kept && menu.querySelector('[data-square-commit] input')
|
|
711
|
+
if (box) { box.value = kept.value; if (kept.focused) box.focus() }
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
delete menu.dataset.html
|
|
715
|
+
menu.hidden = !st.git
|
|
716
|
+
menu.innerHTML = folderBranchHtml({ git: st.git, syncing: branchSyncing })
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** The square panel's Commit and Merge onto main (layout 3): Seam's own tools, as this member (control.js seamTool). */
|
|
720
|
+
async function squareAct (tool, message = '') {
|
|
721
|
+
const id = current
|
|
722
|
+
squareBusy = tool === 'seam_merge' ? 'merge' : tool === 'open_square' ? 'open' : 'commit'
|
|
723
|
+
renderBranchMenu()
|
|
724
|
+
renderSquareCard()
|
|
725
|
+
try {
|
|
726
|
+
const r = await api('POST', `/api/sessions/${id}/seam`, { tool, args: message ? { message } : {} })
|
|
727
|
+
toast(r.text, 8000)
|
|
728
|
+
return true
|
|
729
|
+
} catch (err) { toast(err.message, 8000); return false } finally {
|
|
730
|
+
squareBusy = ''
|
|
731
|
+
if (id === current && !$('#git-panel').hidden) renderBranchMenu()
|
|
732
|
+
if (id === current) renderSquareCard()
|
|
733
|
+
}
|
|
648
734
|
}
|
|
649
735
|
|
|
650
736
|
function bindBranchMenu () {
|
|
651
737
|
const menu = $('#branch-menu')
|
|
738
|
+
menu.addEventListener('submit', async (e) => {
|
|
739
|
+
const form = e.target.closest('[data-square-commit]')
|
|
740
|
+
if (!form) return
|
|
741
|
+
e.preventDefault()
|
|
742
|
+
const input = form.querySelector('input')
|
|
743
|
+
const message = input.value.trim()
|
|
744
|
+
if (!message || squareBusy) return
|
|
745
|
+
if (await squareAct('seam_commit', message)) { input.value = ''; const now = menu.querySelector('[data-square-commit] input'); if (now) now.value = '' }
|
|
746
|
+
})
|
|
652
747
|
menu.addEventListener('click', async (e) => {
|
|
748
|
+
// Merge commits what isn't committed yet first: with the words typed in the box, if any.
|
|
749
|
+
if (e.target.closest('[data-square-open]')) { if (!squareBusy) squareAct('open_square'); return }
|
|
750
|
+
if (e.target.closest('[data-square-merge]')) { if (!squareBusy) squareAct('seam_merge', (menu.querySelector('[data-square-commit] input')?.value || '').trim()); return }
|
|
653
751
|
if (!e.target.closest('[data-branch-sync]') || branchSyncing) return
|
|
654
752
|
branchSyncing = true
|
|
655
753
|
renderBranchMenu()
|
|
@@ -665,9 +763,21 @@ function renderTop () {
|
|
|
665
763
|
if (!current || !$('#people-btn')) return
|
|
666
764
|
renderTabs()
|
|
667
765
|
const st = sum().status
|
|
766
|
+
// Code search, only in a session linked to a quilt (the relay reads Seam for it).
|
|
767
|
+
const search = $('#qt-topsearch')
|
|
768
|
+
if (search) search.hidden = !st.quilt
|
|
668
769
|
const g = st.git
|
|
669
770
|
const label = $('#git-branch')
|
|
670
|
-
|
|
771
|
+
const seam = inSeam(st) ? seamChip(st) : null
|
|
772
|
+
// In Seam nobody asks a person with git to commit: whoever is on a square commits it.
|
|
773
|
+
$('#ask-commit').hidden = !!seam
|
|
774
|
+
if (label && seam) {
|
|
775
|
+
// In Seam: the square this folder follows, by the name Seam gives it.
|
|
776
|
+
label.hidden = !seam.label
|
|
777
|
+
label.innerHTML = seam.label ? `<span class="branch-name">${esc(seam.label)}</span>` : ''
|
|
778
|
+
label.dataset.title = seam.title
|
|
779
|
+
if (!$('#git-panel').hidden) renderBranchMenu()
|
|
780
|
+
} else if (label) {
|
|
671
781
|
label.hidden = !g
|
|
672
782
|
if (g) {
|
|
673
783
|
// Moving to another branch: said at once. Git busy: only once it has lasted a moment.
|
|
@@ -700,6 +810,7 @@ function renderTop () {
|
|
|
700
810
|
renderCall()
|
|
701
811
|
renderAccess()
|
|
702
812
|
renderCatchUp($('#catchup'), st.catchUp, { colors: new Map([st.me, ...st.peers].map((p) => [p.name, p.color])) })
|
|
813
|
+
renderSquareCard()
|
|
703
814
|
renderGithubCard()
|
|
704
815
|
renderGitChip()
|
|
705
816
|
$('#sess-name').textContent = st.sessionName || basename(sum().dir)
|
|
@@ -868,12 +979,27 @@ function renderGitChip () {
|
|
|
868
979
|
const dot = $('#git-dot')
|
|
869
980
|
dot.hidden = !!say || !(live && live.dot)
|
|
870
981
|
dot.className = `sd${live && live.dot ? ` ${live.dot}` : ''}`
|
|
982
|
+
const branch = $('#git-branch')
|
|
983
|
+
const seam = inSeam(st) ? seamChip(st) : null
|
|
984
|
+
// In Seam the session's own record of changes stopped at the switch (its branch document is kept read-only):
|
|
985
|
+
// the number is the square's work not committed yet, and the panel lists that instead.
|
|
986
|
+
const chg = $('#chg-panel')
|
|
987
|
+
if (chg) chg.hidden = !!seam
|
|
988
|
+
if (seam) {
|
|
989
|
+
$('#chg-count').textContent = seam.count
|
|
990
|
+
$('#git-chg').hidden = !seam.count
|
|
991
|
+
$('#git-sep').hidden = branch.hidden || !seam.count
|
|
992
|
+
chip.title = [seam.title, seam.countLine, 'Click for your square.'].filter(Boolean).join('\n')
|
|
993
|
+
chip.setAttribute('aria-label', seam.aria)
|
|
994
|
+
$('#git-panel').setAttribute('aria-label', 'Square')
|
|
995
|
+
if (!$('#git-panel').hidden) renderCommitPanel()
|
|
996
|
+
return
|
|
997
|
+
}
|
|
871
998
|
// The number is your own work not committed (once that's known; before that, every file changed in
|
|
872
999
|
// the session). What others changed since you last opened this panel goes in the tooltip, not the
|
|
873
1000
|
// number, with the session's total.
|
|
874
1001
|
const own = mineCount()
|
|
875
1002
|
const { n, lines: counts } = chipCounts({ own, total: changedCount(), fresh: unseenChanges() })
|
|
876
|
-
const branch = $('#git-branch')
|
|
877
1003
|
$('#chg-count').textContent = n
|
|
878
1004
|
$('#git-chg').hidden = !n
|
|
879
1005
|
$('#git-sep').hidden = branch.hidden || !n
|
|
@@ -882,6 +1008,7 @@ function renderGitChip () {
|
|
|
882
1008
|
...counts,
|
|
883
1009
|
open.length ? open.map((r) => `${shownName(authorOf(r), owners(sum()))}: ${r.message}`).join('\n') : live ? live.line : gh ? githubWords(gh) : ''
|
|
884
1010
|
].filter(Boolean)
|
|
1011
|
+
$('#git-panel').setAttribute('aria-label', 'Branch, GitHub and changes')
|
|
885
1012
|
chip.title = [...lines, 'Click for branches, GitHub and every change.'].join('\n')
|
|
886
1013
|
chip.setAttribute('aria-label', `Source control${branch.hidden ? '' : `: ${st.git.key}`}${n ? own === null ? `, ${n} changed` : `, ${n} of yours not committed` : ''}${say ? `, ${say}` : live ? `, ${live.chip}` : ''}`)
|
|
887
1014
|
if (!$('#git-panel').hidden) renderCommitPanel()
|
|
@@ -934,6 +1061,7 @@ function myBranchOf (st) {
|
|
|
934
1061
|
|
|
935
1062
|
/** Where this branch stands with GitHub (GitHub as a node: relay-sync.js syncStatus), in a few words, with how to read it. */
|
|
936
1063
|
function syncWords (st) {
|
|
1064
|
+
if (inSeam(st)) return null
|
|
937
1065
|
const b = myBranchOf(st)
|
|
938
1066
|
const r = b && b.relay
|
|
939
1067
|
const s = r && r.sync
|
|
@@ -1019,6 +1147,45 @@ function gitCardHtml (st) {
|
|
|
1019
1147
|
}
|
|
1020
1148
|
|
|
1021
1149
|
/** Session settings, for the owner: syncing with GitHub, large files, and git for this session. */
|
|
1150
|
+
/**
|
|
1151
|
+
* "Make this a quilt" (Seam's plans/quilt-source-control/16, flag 9), for the session's owner while its
|
|
1152
|
+
* code is still on git: one button. With no git remote the relay makes the quilt and moves the files
|
|
1153
|
+
* into its squares at once; with one, it becomes a quilt with its history (makeQuiltWords).
|
|
1154
|
+
*/
|
|
1155
|
+
function makeQuiltHtml (st) {
|
|
1156
|
+
if (inSeam(st)) return ''
|
|
1157
|
+
// Everyone hears that live sync stopped at the forced date; only the owner gets the button.
|
|
1158
|
+
if (!st.access?.owner) return st.force && st.force.stopped ? `<div class="pm-section"><div class="pm-title">Seam</div><div class="pm-card git-card"><p class="git-line warn">${esc(st.force.stopped.why || '')}</p></div></div>` : ''
|
|
1159
|
+
const words = makeQuiltWords({ quilt: st.quilt || null, remote: !!githubStateOf(st) || !!(st.git && st.git.upstream), moving: !!st.moving, force: st.force || null, importing: st.importing || null, shadow: st.shadow || null })
|
|
1160
|
+
return `<div class="pm-section"><div class="pm-title">Seam</div><div class="pm-card git-card">
|
|
1161
|
+
<div class="git-head"><h4>Make this a quilt</h4><span class="pill">Seam</span></div>
|
|
1162
|
+
${words.note ? `<p class="git-line warn">${esc(words.note)}</p>` : ''}
|
|
1163
|
+
<p class="git-line">${esc(words.text)}</p>
|
|
1164
|
+
${makeQuiltSteps ? `<div class="git-line"><p>${esc(makeQuiltSteps[0])}</p>${makeQuiltSteps.length > 1 ? `<ol>${makeQuiltSteps.slice(1).map((l) => `<li>${esc(l.replace(/^\d+\. /, ''))}</li>`).join('')}</ol>` : ''}</div>` : ''}
|
|
1165
|
+
<button type="button" class="btn primary" data-make-quilt ${words.disabled ? 'disabled' : ''}>${esc(words.button)}</button>
|
|
1166
|
+
</div></div>`
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** The last answer's steps (lines), shown on the card: what the owner or the repository's GitHub owner does next. */
|
|
1170
|
+
let makeQuiltSteps = null
|
|
1171
|
+
|
|
1172
|
+
async function makeQuilt (btn) {
|
|
1173
|
+
makeQuiltSteps = null
|
|
1174
|
+
btn.disabled = true
|
|
1175
|
+
btn.textContent = 'Moving into Seam…'
|
|
1176
|
+
try {
|
|
1177
|
+
const r = await api('POST', `/api/sessions/${current}/quilt-make`, {})
|
|
1178
|
+
const lines = String(r.text || 'The session is a quilt now.').split('\n')
|
|
1179
|
+
// Steps for someone to take (the GitHub owner's ruleset) stay on the card until the next click; a plain answer is a toast.
|
|
1180
|
+
if (lines.length > 2 && r.quilt && r.quilt.stage) makeQuiltSteps = lines
|
|
1181
|
+
toast(lines[0])
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
const lines = String(err.message).split('\n')
|
|
1184
|
+
if (lines.length > 1) makeQuiltSteps = lines
|
|
1185
|
+
toast(lines[0])
|
|
1186
|
+
} finally { renderSessionSettings({ force: true }) }
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1022
1189
|
function githubSettingsHtml (st) {
|
|
1023
1190
|
if (!st.access?.owner) return ''
|
|
1024
1191
|
const sync = st.access.githubSync
|
|
@@ -1041,6 +1208,7 @@ async function openLiveSession (room) {
|
|
|
1041
1208
|
|
|
1042
1209
|
/** The session's repository on GitHub and whether a member's connection lets agents commit there, from the relay's branch list. */
|
|
1043
1210
|
function githubStateOf (st) {
|
|
1211
|
+
if (inSeam(st)) return null // GitHub only receives Seam's export of main: nothing to connect or sync here
|
|
1044
1212
|
const list = st.branches || []
|
|
1045
1213
|
const b = (st.git && list.find((x) => x.name === st.git.key)) || list.find((x) => x.default) || null
|
|
1046
1214
|
const r = b && b.relay
|
|
@@ -1066,6 +1234,22 @@ const GH_DISMISS_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
1066
1234
|
const ghDismissKey = (repo) => `quilt.github.notnow.${repo}`
|
|
1067
1235
|
let ghConnecting = null // the repo a Connect click is waiting on (polling the account)
|
|
1068
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* In a session that is a quilt at layout 3: one plain line on where this folder's work goes (the
|
|
1239
|
+
* square it follows, and how to commit it; session-follow.js), or why an edit reached nobody.
|
|
1240
|
+
*/
|
|
1241
|
+
function renderSquareCard () {
|
|
1242
|
+
const el = $('#square-card')
|
|
1243
|
+
if (!el) return
|
|
1244
|
+
const st = sum().status
|
|
1245
|
+
const card = squareCard(st)
|
|
1246
|
+
el.hidden = !card
|
|
1247
|
+
const html = card ? `<div class="gh-card"><div class="gh-text"><b>${esc(card.title)}</b><span>${esc(card.text)}</span></div>${card.open && st.access?.role !== 'viewer' ? `<div class="gh-acts">${openSquareButton(st, squareBusy)}</div>` : ''}</div>` : ''
|
|
1248
|
+
if (el._html === html) return
|
|
1249
|
+
el._html = html
|
|
1250
|
+
el.innerHTML = html
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1069
1253
|
function renderGithubCard () {
|
|
1070
1254
|
const el = $('#github-card')
|
|
1071
1255
|
if (!el) return
|
|
@@ -1086,6 +1270,12 @@ function renderGithubCard () {
|
|
|
1086
1270
|
el.innerHTML = html
|
|
1087
1271
|
}
|
|
1088
1272
|
|
|
1273
|
+
/** The square card's button: a folder whose branch has no square opens one (layout 3). */
|
|
1274
|
+
function bindSquareCard () {
|
|
1275
|
+
const el = $('#square-card')
|
|
1276
|
+
if (el) el.addEventListener('click', (e) => { if (e.target.closest('[data-square-open]') && !squareBusy) squareAct('open_square') })
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1089
1279
|
function bindGithubCard () {
|
|
1090
1280
|
const el = $('#github-card')
|
|
1091
1281
|
if (!el) return
|
|
@@ -1472,7 +1662,7 @@ function peopleRows (st, { fold = false } = {}) {
|
|
|
1472
1662
|
return person(`<div class="pm-row">
|
|
1473
1663
|
<button class="pm-open" data-person="${esc(p.name)}" title="Open ${p.isMe ? 'your' : `${esc(p.name)}'s`} AI chat">${avatar(p.name, p.color, p.online)}
|
|
1474
1664
|
<span class="pm-main">
|
|
1475
|
-
<span class="pm-name">${esc(p.isMe ? `${p.name} (you)` : p.name)}${p.voice ? `<span class="tag call-tag" title="In the voice call${p.voice.muted ? ', muted' : ''}">${p.voice.muted ? I.micOff : I.mic}call</span>` : ''}${p.kind === 'agent' ? `<span class="tag bot">${I.bot}agent</span>` : ''}${toolsOf(p).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span>
|
|
1665
|
+
<span class="pm-name">${esc(p.isMe ? `${p.name} (you)` : p.name)}${p.voice ? `<span class="tag call-tag" title="In the voice call${p.voice.muted ? ', muted' : ''}${p.voice.sharing ? ', sharing a screen' : ''}">${p.voice.sharing ? I.screenShare : p.voice.muted ? I.micOff : I.mic}call</span>` : ''}${p.kind === 'agent' ? `<span class="tag bot">${I.bot}agent</span>` : ''}${toolsOf(p).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span>
|
|
1476
1666
|
${p.focus && !p.isMe ? `<span class="pm-sub pm-what">${esc(p.focus)}</span>` : ''}
|
|
1477
1667
|
${editing}
|
|
1478
1668
|
${agentLine(p) ? `<span class="pm-sub">${agentLine(p)}</span>` : ''}
|
|
@@ -1542,15 +1732,20 @@ function renderBoxesSide () {
|
|
|
1542
1732
|
if (!el || !s) return
|
|
1543
1733
|
const st = s.status
|
|
1544
1734
|
const { selfRow, myAiRow, otherRows, awayRows } = peopleRows(st, { fold: true })
|
|
1545
|
-
|
|
1735
|
+
// In Seam there are no boxes (every edit is live in its square): the pane shows the squares and who is on each.
|
|
1736
|
+
const seam = inSeam(st)
|
|
1737
|
+
const title = el.closest('.ws-boxes')?.querySelector('.pane-title')
|
|
1738
|
+
if (title) title.textContent = seam ? 'Squares' : 'Boxes'
|
|
1739
|
+
el.closest('.ws-boxes')?.setAttribute('aria-label', seam ? 'Squares: who is on which, and what is not committed' : 'Boxes: who is working on what')
|
|
1740
|
+
const n = seam ? 0 : boxView.data.boxes.length
|
|
1546
1741
|
const count = $('#box-count')
|
|
1547
1742
|
if (count) { count.textContent = n ? String(n) : ''; count.title = n ? `${n} file${n === 1 ? '' : 's'} boxed: changed, not in everyone's folder yet` : '' }
|
|
1548
|
-
const html =
|
|
1743
|
+
const html = `${seam ? squaresSideHtml(st) : ''}<div class="pm-list bs-list">
|
|
1549
1744
|
${selfRow}${myAiRow}
|
|
1550
1745
|
${otherRows}
|
|
1551
1746
|
${awayRows.length ? `<div class="pm-sep"></div><div class="bs-head">Away</div>${awayRows.join('')}` : ''}
|
|
1552
1747
|
</div>
|
|
1553
|
-
<p class="bs-hint">${boxView.data.on ? 'A box is a file someone is changing that isn\'t in everyone\'s folder yet. Click someone\'s boxes to see the files, and a file to see its change.' : 'What everyone is working on. Their unfinished files show here as boxes.'}</p>`
|
|
1748
|
+
<p class="bs-hint">${seam ? SEAM_SIDE_HINT : boxView.data.on ? 'A box is a file someone is changing that isn\'t in everyone\'s folder yet. Click someone\'s boxes to see the files, and a file to see its change.' : 'What everyone is working on. Their unfinished files show here as boxes.'}</p>`
|
|
1554
1749
|
if (el.dataset.html === html) return
|
|
1555
1750
|
const top = el.scrollTop
|
|
1556
1751
|
el.innerHTML = html
|
|
@@ -1713,7 +1908,8 @@ function renderSessionSettings ({ force = false } = {}) {
|
|
|
1713
1908
|
const active = box.contains(document.activeElement) && document.activeElement.closest('.pm-member') ? document.activeElement : null
|
|
1714
1909
|
if (!force && active && box.querySelector('.pm-member.edit')) return
|
|
1715
1910
|
const refocus = active && active.name ? [active.closest('.pm-member').dataset.key, active.name] : null
|
|
1716
|
-
|
|
1911
|
+
// In Seam the git and GitHub settings are put away (the relay keeps them for a move back): where the history is instead.
|
|
1912
|
+
const vc = inSeam(st) ? seamSettingsHtml(st) : makeQuiltHtml(st) + githubSettingsHtml(st) + githubTokenHtml(st.access)
|
|
1717
1913
|
box.innerHTML = ssTab === 'chat' ? sharingHtml(st)
|
|
1718
1914
|
: ssTab === 'vc' ? (vc || '<div class="pm-empty">Only the session owner changes how this session works with GitHub.</div>')
|
|
1719
1915
|
: membersHtml(st, ssTab)
|
|
@@ -1727,6 +1923,12 @@ function bindMain () {
|
|
|
1727
1923
|
const tab = e.target.closest('[data-tab]')
|
|
1728
1924
|
const w = ws(current)
|
|
1729
1925
|
if (tab && tab.dataset.kind === 'loom') { openLoom(); return }
|
|
1926
|
+
if (close && close.dataset.kind === 'search') {
|
|
1927
|
+
e.stopPropagation()
|
|
1928
|
+
closeSearch(close.dataset.close)
|
|
1929
|
+
return
|
|
1930
|
+
}
|
|
1931
|
+
if (tab && tab.dataset.kind === 'search') { openSearch(tab.dataset.tab); return }
|
|
1730
1932
|
if (close) {
|
|
1731
1933
|
e.stopPropagation()
|
|
1732
1934
|
const key = close.dataset.close
|
|
@@ -1763,10 +1965,27 @@ function bindMain () {
|
|
|
1763
1965
|
if (c) pickConv(c.dataset.conv)
|
|
1764
1966
|
const t = e.target.closest('[data-fvtab]')
|
|
1765
1967
|
if (t) showFileTab(t.dataset.fvtab)
|
|
1968
|
+
// A file's History and Blame, and search hits, in a session linked to a quilt.
|
|
1969
|
+
const qp = e.target.closest('[data-qt-patch]')
|
|
1970
|
+
if (qp) togglePatch(ws(current).fileSel, qp.dataset.qtPatch)
|
|
1971
|
+
const qb = e.target.closest('[data-qt-blame]')
|
|
1972
|
+
if (qb) showPatch(ws(current).fileSel, qb.dataset.qtBlame)
|
|
1973
|
+
const qf = e.target.closest('[data-qt-file]')
|
|
1974
|
+
if (qf) openFile(qf.dataset.qtFile, { tab: 'changes' })
|
|
1975
|
+
const qh = e.target.closest('[data-qt-hit]')
|
|
1976
|
+
if (qh) openFile(qh.dataset.qtHit, { tab: 'file', line: Number(qh.dataset.line) || null })
|
|
1766
1977
|
const f = e.target.closest('[data-fv]')
|
|
1767
1978
|
if (f && f.dataset.fv === 'claim') claimPath(ws(current).fileSel, '')
|
|
1768
1979
|
if (f && f.dataset.fv === 'release') releasePattern(f.dataset.pattern)
|
|
1769
1980
|
})
|
|
1981
|
+
// Searching again from a search tab's own field.
|
|
1982
|
+
$('#main').addEventListener('submit', (e) => {
|
|
1983
|
+
const form = e.target.closest('[data-qt-search-form]')
|
|
1984
|
+
if (!form) return
|
|
1985
|
+
e.preventDefault()
|
|
1986
|
+
const q = String(new FormData(form).get('q') || '').trim()
|
|
1987
|
+
if (q) openSearch(q, { replace: ws(current).searchSel })
|
|
1988
|
+
})
|
|
1770
1989
|
}
|
|
1771
1990
|
|
|
1772
1991
|
function openLoom () {
|
|
@@ -1805,8 +2024,8 @@ function pickConv (conv) {
|
|
|
1805
2024
|
renderMain()
|
|
1806
2025
|
}
|
|
1807
2026
|
|
|
1808
|
-
/** Opens a file tab; `tab: 'changes'` shows its Changes tab, otherwise the tab it was on. */
|
|
1809
|
-
function openFile (path, { tab } = {}) {
|
|
2027
|
+
/** Opens a file tab; `tab: 'changes'` shows its Changes tab, otherwise the tab it was on. `line`: bring that line into view (a search hit). */
|
|
2028
|
+
function openFile (path, { tab, line = null } = {}) {
|
|
1810
2029
|
const w = ws(current)
|
|
1811
2030
|
if (!w.fileTabs.includes(path)) w.fileTabs.push(path)
|
|
1812
2031
|
w.fileSel = path
|
|
@@ -1814,7 +2033,10 @@ function openFile (path, { tab } = {}) {
|
|
|
1814
2033
|
if (!w.fileTab) w.fileTab = {}
|
|
1815
2034
|
if (tab === 'changes') w.fileTab[path] = 'changes'
|
|
1816
2035
|
else if (tab === 'file') delete w.fileTab[path]
|
|
2036
|
+
if (line) goLine.set(fileKey(path), line)
|
|
2037
|
+
else goLine.delete(fileKey(path))
|
|
1817
2038
|
loadHistory(path)
|
|
2039
|
+
if (fileTabOf(path) === 'blame') loadBlame(path)
|
|
1818
2040
|
const wasStale = w.stale[path]
|
|
1819
2041
|
delete w.stale[path]
|
|
1820
2042
|
saveWs(current)
|
|
@@ -1833,22 +2055,24 @@ function renderMainBar () {
|
|
|
1833
2055
|
const fileOn = (path) => w.mode === 'files' && w.fileSel === path
|
|
1834
2056
|
const loomOn = w.mode === 'ai' && !w.aiSel
|
|
1835
2057
|
const st = sum().status
|
|
1836
|
-
const anyWorking = [st.me, ...st.peers].some((p) => p
|
|
2058
|
+
const anyWorking = [st.me, ...st.peers].some((p) => !!p && aiWorking(p))
|
|
1837
2059
|
const loomTab = `<div class="ws-tab loom-tab${loomOn ? ' on' : ''}" role="tab" aria-selected="${loomOn}" tabindex="0" data-kind="loom" data-tab="loom" title="Everyone's AI conversations, chat and task notes at once">
|
|
1838
2060
|
<span class="ico">${I.users}</span><span class="nm">Everyone</span>${anyWorking ? '<span class="pulse" title="An AI is working"></span>' : ''}</div>`
|
|
1839
2061
|
el.innerHTML = loomTab + (w.aiTabs.length ? '<span class="ws-tab-sep"></span>' : '') + w.aiTabs.map((name) => {
|
|
1840
2062
|
const p = personInfo(name)
|
|
1841
|
-
const working = p
|
|
2063
|
+
const working = aiWorking(p)
|
|
1842
2064
|
return `<div class="ws-tab${aiOn(name) ? ' on' : ''}" role="tab" aria-selected="${aiOn(name)}" tabindex="0" data-kind="ai" data-tab="${esc(name)}" title="${esc(p.isMe ? 'Your AI chat' : `${name}'s AI chat`)}">
|
|
1843
2065
|
${avatar(name, p.color, p.online)}<span class="nm">${esc(p.isMe ? 'Your AI' : `${name}'s AI`)}</span>${working ? '<span class="pulse" title="AI is working"></span>' : ''}
|
|
1844
2066
|
<button class="x" data-kind="ai" data-close="${esc(name)}" aria-label="Close ${esc(name)}">${I.x}</button></div>`
|
|
1845
2067
|
}).join('') + (w.fileTabs.length ? '<span class="ws-tab-sep"></span>' : '') +
|
|
1846
2068
|
w.fileTabs.map((path) => `<div class="ws-tab${fileOn(path) ? ' on' : ''}" role="tab" aria-selected="${fileOn(path)}" tabindex="0" data-kind="file" data-tab="${esc(path)}" title="${esc(path)}">
|
|
1847
2069
|
<span class="ico">${I.file}</span><span class="nm">${esc(basename(path))}</span>${w.stale[path] ? '<span class="changed" title="Changed"></span>' : ''}
|
|
1848
|
-
<button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('')
|
|
2070
|
+
<button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('') +
|
|
2071
|
+
((w.searchTabs || []).length && !w.fileTabs.length ? '<span class="ws-tab-sep"></span>' : '') +
|
|
2072
|
+
(w.searchTabs || []).map((q) => searchTabMarkup(q, w.mode === 'search' && w.searchSel === q)).join('')
|
|
1849
2073
|
// On Everyone with nothing else open, the row would only name the view you are in: hide it until
|
|
1850
2074
|
// there is a tab to switch to (or, on the Tasks board, Everyone to go back to).
|
|
1851
|
-
$('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length
|
|
2075
|
+
$('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length && !(w.searchTabs || []).length
|
|
1852
2076
|
}
|
|
1853
2077
|
|
|
1854
2078
|
// A card being renamed, or a drag in progress, must not be rebuilt under the pointer.
|
|
@@ -2490,6 +2714,17 @@ function renderMain () {
|
|
|
2490
2714
|
return
|
|
2491
2715
|
}
|
|
2492
2716
|
if (w.mode === 'merge') w.mode = 'ai' // a compare tab from before Quilt merged on its own
|
|
2717
|
+
if (w.mode === 'search' && !(w.searchSel && (w.searchTabs || []).includes(w.searchSel))) w.mode = w.fileSel ? 'files' : 'ai'
|
|
2718
|
+
if (w.mode === 'search') {
|
|
2719
|
+
if (!searches.has(`${current}\n${w.searchSel}`)) loadSearch(w.searchSel) // a tab put back after a reload
|
|
2720
|
+
const html = searchMarkup({ q: w.searchSel, result: searches.get(`${current}\n${w.searchSel}`) || null })
|
|
2721
|
+
// Status ticks redraw the main area: an unchanged search keeps its field, focus and scroll.
|
|
2722
|
+
if (el.firstElementChild && el.firstElementChild.classList.contains('qt-search') && el.dataset.qtSearch === html) return
|
|
2723
|
+
el.innerHTML = html
|
|
2724
|
+
el.dataset.qtSearch = html
|
|
2725
|
+
return
|
|
2726
|
+
}
|
|
2727
|
+
delete el.dataset.qtSearch
|
|
2493
2728
|
if (w.mode === 'ai') {
|
|
2494
2729
|
if (!w.aiSel) {
|
|
2495
2730
|
const people = st.peers
|
|
@@ -2522,11 +2757,17 @@ function renderMain () {
|
|
|
2522
2757
|
} else {
|
|
2523
2758
|
if (!w.fileSel) {
|
|
2524
2759
|
el.innerHTML = `<div class="main-empty"><div class="ill">${I.file}</div><h3>Open a file</h3>
|
|
2525
|
-
<p class="hint"
|
|
2760
|
+
<p class="hint">${inSeam(sum().status) ? 'Search the code at the top and open a file from there, or open one from the Boxes list.' : 'Open one from the git panel\'s changes, or a box\'s change from the Boxes list. Lines your partners change light up as they happen.'}</p></div>`
|
|
2526
2761
|
return
|
|
2527
2762
|
}
|
|
2528
2763
|
const cached = state.files.get(fileKey(w.fileSel))
|
|
2529
2764
|
if (!cached) loadFileOnce(w.fileSel) // a tab put back after a reload has nothing read yet
|
|
2765
|
+
// A tab put back after a reload, on any segment, reads what that segment shows.
|
|
2766
|
+
const fk = fileKey(w.fileSel)
|
|
2767
|
+
for (const need of fileReadsNeeded({ tab: fileTabOf(w.fileSel), linked: quiltLinked(), history: histories.get(fk), blame: blames.has(fk) ? blames.get(fk) : undefined, busy: historyLoads.has(fk) ? new Set(['history']) : undefined })) {
|
|
2768
|
+
if (need === 'history') loadHistory(w.fileSel)
|
|
2769
|
+
else loadBlame(w.fileSel)
|
|
2770
|
+
}
|
|
2530
2771
|
renderFileView(el, {
|
|
2531
2772
|
path: w.fileSel,
|
|
2532
2773
|
file: cached ? cached.file : null,
|
|
@@ -2535,6 +2776,8 @@ function renderMain () {
|
|
|
2535
2776
|
prevText: cached ? cached.prevText : null,
|
|
2536
2777
|
tab: fileTabOf(w.fileSel),
|
|
2537
2778
|
history: histories.get(fileKey(w.fileSel)) || null,
|
|
2779
|
+
quilt: quiltFileView(w.fileSel),
|
|
2780
|
+
line: takeLine(w.fileSel, cached),
|
|
2538
2781
|
summary: fileChanges(w.fileSel),
|
|
2539
2782
|
people: { who: (name) => name === me() ? 'you' : name, colorOf: (name) => personInfo(name).color },
|
|
2540
2783
|
chatHref: treeMeta(w.fileSel)?.chat ? fileCardHref(current, treeMeta(w.fileSel).chat.id, TOKEN) : null,
|
|
@@ -2547,18 +2790,147 @@ function renderMain () {
|
|
|
2547
2790
|
/** Links to a session's file bytes for the preview, or to download them (GET /api/sessions/<id>/raw). */
|
|
2548
2791
|
const rawHrefFor = (id) => (path, download = false) => `/api/sessions/${encodeURIComponent(id)}/raw?path=${encodeURIComponent(path)}&t=${encodeURIComponent(TOKEN || '')}${download ? '&download=1' : ''}`
|
|
2549
2792
|
const fileKey = (path) => `${current}\n${path}`
|
|
2550
|
-
const fileTabOf = (path) =>
|
|
2793
|
+
const fileTabOf = (path) => {
|
|
2794
|
+
const t = (ws(current).fileTab || {})[path]
|
|
2795
|
+
return t === 'changes' ? 'changes' : t === 'blame' && quiltLinked() ? 'blame' : 'file'
|
|
2796
|
+
}
|
|
2551
2797
|
|
|
2552
2798
|
function showFileTab (tab) {
|
|
2553
2799
|
const w = ws(current)
|
|
2554
2800
|
if (!w.fileSel) return
|
|
2555
2801
|
if (!w.fileTab) w.fileTab = {}
|
|
2556
|
-
if (tab === 'changes') w.fileTab[w.fileSel] =
|
|
2802
|
+
if (tab === 'changes' || tab === 'blame') w.fileTab[w.fileSel] = tab
|
|
2557
2803
|
else delete w.fileTab[w.fileSel]
|
|
2558
2804
|
saveWs(current)
|
|
2559
2805
|
renderMain()
|
|
2560
2806
|
$('#main .fv-tabs [aria-selected="true"]')?.focus()
|
|
2561
2807
|
if (tab === 'changes') loadHistory(w.fileSel)
|
|
2808
|
+
if (tab === 'blame') loadBlame(w.fileSel)
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// ------------------------------------- History, Blame and search (a linked quilt) --
|
|
2812
|
+
// In a session linked to a quilt (Seam), the relay reads it for the app: a file's patches, who
|
|
2813
|
+
// wrote each line, and code search over main (src/server.js quiltBlame, quiltSearch, quiltShow).
|
|
2814
|
+
const quiltLinked = () => !!sum().status.quilt
|
|
2815
|
+
const patchLists = new Map() // fileKey -> { patches, quilt, note } (absent: the session isn't linked)
|
|
2816
|
+
const openPatches = new Map() // fileKey -> the History row opened: a commit, or live:<name>
|
|
2817
|
+
const patchData = new Map() // `${session}\n${commit}\n${path}` -> { files, diff } or { error }
|
|
2818
|
+
const blames = new Map() // fileKey -> the relay's blame answer
|
|
2819
|
+
const blameTimers = new Map()
|
|
2820
|
+
const searches = new Map() // `${session}\n${q}` -> the relay's search answer
|
|
2821
|
+
const goLine = new Map() // fileKey -> a line to bring into view once (a search hit)
|
|
2822
|
+
|
|
2823
|
+
/** What the file view shows of a linked file's History and Blame, or null when the session isn't linked. */
|
|
2824
|
+
function quiltFileView (path) {
|
|
2825
|
+
if (!quiltLinked()) return null
|
|
2826
|
+
const list = patchLists.get(fileKey(path)) || { patches: null, quilt: sum().status.quilt }
|
|
2827
|
+
const open = openPatches.get(fileKey(path)) || null
|
|
2828
|
+
const live = liveByPerson(histories.get(fileKey(path)) || [])
|
|
2829
|
+
return {
|
|
2830
|
+
...list,
|
|
2831
|
+
open,
|
|
2832
|
+
shown: open && !open.startsWith('live:') ? patchData.get(`${current}\n${open}\n${path}`) || null : null,
|
|
2833
|
+
blame: blames.get(fileKey(path)) || null,
|
|
2834
|
+
liveBy: live.length ? live[0].by : (treeMeta(path)?.edited?.by || '')
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/** The line a search hit opened the file at, given once its text is there. */
|
|
2839
|
+
function takeLine (path, cached) {
|
|
2840
|
+
const line = goLine.get(fileKey(path))
|
|
2841
|
+
if (!line || !cached || !cached.file || fileTabOf(path) !== 'file') return null
|
|
2842
|
+
goLine.delete(fileKey(path))
|
|
2843
|
+
return line
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/** Opens or closes a row of a file's History; a patch's diff is read the first time. */
|
|
2847
|
+
function togglePatch (path, which) {
|
|
2848
|
+
if (!path) return
|
|
2849
|
+
const key = fileKey(path)
|
|
2850
|
+
if (openPatches.get(key) === which) openPatches.delete(key)
|
|
2851
|
+
else openPatches.set(key, which)
|
|
2852
|
+
renderMain()
|
|
2853
|
+
if (openPatches.get(key) === which && !which.startsWith('live:')) loadPatch(path, which)
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
/** Blame's patch number: that patch, opened in the file's History. */
|
|
2857
|
+
function showPatch (path, commit) {
|
|
2858
|
+
if (!path) return
|
|
2859
|
+
openPatches.set(fileKey(path), commit)
|
|
2860
|
+
showFileTab('changes')
|
|
2861
|
+
loadPatch(path, commit)
|
|
2862
|
+
requestAnimationFrame(() => $('#main .qt-patch.on')?.scrollIntoView({ block: 'nearest' }))
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
async function loadPatch (path, commit) {
|
|
2866
|
+
const id = current
|
|
2867
|
+
const k = `${id}\n${commit}\n${path}`
|
|
2868
|
+
if (patchData.has(k)) return
|
|
2869
|
+
let shown
|
|
2870
|
+
try {
|
|
2871
|
+
const r = await api('GET', `/api/sessions/${id}/patch?commit=${encodeURIComponent(commit)}&path=${encodeURIComponent(path)}`)
|
|
2872
|
+
shown = r.patch ? r.patch : r.working ? null : { error: r.quilt === null ? 'This session is no longer linked to a quilt.' : 'Seam did not send this patch.' }
|
|
2873
|
+
if (!shown) { setTimeout(() => { if (id === current && openPatches.get(`${id}\n${path}`) === commit) loadPatch(path, commit) }, 2000); return }
|
|
2874
|
+
} catch (err) { shown = { error: err.message } }
|
|
2875
|
+
patchData.set(k, shown)
|
|
2876
|
+
if (id === current && ws(id).fileSel === path && fileTabOf(path) === 'changes') renderMain()
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
/** Who wrote each line of a file: asked again every 2 s while Seam works it out the first time. */
|
|
2880
|
+
async function loadBlame (path, tries = 0) {
|
|
2881
|
+
const id = current
|
|
2882
|
+
const key = fileKey(path)
|
|
2883
|
+
clearTimeout(blameTimers.get(key))
|
|
2884
|
+
if (!blames.has(key)) blames.set(key, null) // being read
|
|
2885
|
+
let r
|
|
2886
|
+
try { r = await api('GET', `/api/sessions/${id}/blame?path=${encodeURIComponent(path)}`) } catch (err) { r = { error: err.message } }
|
|
2887
|
+
if (id !== current) return
|
|
2888
|
+
if (r.quilt === null) r = { error: 'This session is no longer linked to a quilt.' }
|
|
2889
|
+
blames.set(key, r)
|
|
2890
|
+
if (r.working && tries < 60) blameTimers.set(key, setTimeout(() => { if (id === current && ws(id).fileSel === path && fileTabOf(path) === 'blame') loadBlame(path, tries + 1) }, 2000))
|
|
2891
|
+
if (ws(id).fileSel === path && fileTabOf(path) === 'blame') renderMain()
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/** Opens a code search as a tab (in place of `replace`, a search searched again from its own field). */
|
|
2895
|
+
async function openSearch (q, { replace = null } = {}) {
|
|
2896
|
+
const w = ws(current)
|
|
2897
|
+
if (!w.searchTabs) w.searchTabs = []
|
|
2898
|
+
const at = replace ? w.searchTabs.indexOf(replace) : -1
|
|
2899
|
+
if (!w.searchTabs.includes(q)) { if (at >= 0) w.searchTabs.splice(at, 1, q); else w.searchTabs.push(q) } else if (at >= 0 && replace !== q) w.searchTabs.splice(at, 1)
|
|
2900
|
+
w.searchSel = q
|
|
2901
|
+
w.mode = 'search'
|
|
2902
|
+
saveWs(current)
|
|
2903
|
+
renderMainBar()
|
|
2904
|
+
renderMain()
|
|
2905
|
+
renderTreePane()
|
|
2906
|
+
loadSearch(q)
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
async function loadSearch (q, tries = 0) {
|
|
2910
|
+
const id = current
|
|
2911
|
+
const k = `${id}\n${q}`
|
|
2912
|
+
if (!tries && searches.has(k) && !searches.get(k)?.error) return
|
|
2913
|
+
if (!tries) searches.set(k, null) // being read
|
|
2914
|
+
let r
|
|
2915
|
+
try { r = await api('GET', `/api/sessions/${id}/search?q=${encodeURIComponent(q)}`) } catch (err) { r = { error: err.message } }
|
|
2916
|
+
searches.set(k, r)
|
|
2917
|
+
if (r.working && tries < 30) setTimeout(() => { if (ws(id).searchTabs?.includes(q)) loadSearch(q, tries + 1) }, 2000)
|
|
2918
|
+
if (id === current && ws(id).mode === 'search' && ws(id).searchSel === q) renderMain()
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
function closeSearch (q) {
|
|
2922
|
+
const w = ws(current)
|
|
2923
|
+
const i = (w.searchTabs || []).indexOf(q)
|
|
2924
|
+
if (i !== -1) w.searchTabs.splice(i, 1)
|
|
2925
|
+
searches.delete(`${current}\n${q}`)
|
|
2926
|
+
if (w.searchSel === q) {
|
|
2927
|
+
w.searchSel = w.searchTabs[Math.min(i, w.searchTabs.length - 1)] || null
|
|
2928
|
+
if (!w.searchSel && w.mode === 'search') w.mode = w.fileSel ? 'files' : 'ai'
|
|
2929
|
+
}
|
|
2930
|
+
saveWs(current)
|
|
2931
|
+
renderMainBar()
|
|
2932
|
+
renderMain()
|
|
2933
|
+
renderTreePane()
|
|
2562
2934
|
}
|
|
2563
2935
|
|
|
2564
2936
|
// ------------------------------------------------------- a file's changes --
|
|
@@ -2571,17 +2943,27 @@ function scheduleHistory (path) {
|
|
|
2571
2943
|
historyTimers.set(path, setTimeout(() => { historyTimers.delete(path); loadHistory(path) }, 500))
|
|
2572
2944
|
}
|
|
2573
2945
|
|
|
2946
|
+
const historyLoads = new Set() // fileKey -> its history is being read (or failed just now: asked again 5 s later)
|
|
2574
2947
|
async function loadHistory (path) {
|
|
2575
2948
|
const id = current
|
|
2949
|
+
const key = `${id}\n${path}`
|
|
2950
|
+
historyLoads.add(key)
|
|
2951
|
+
let read = false
|
|
2576
2952
|
try {
|
|
2577
|
-
const { entries } = await api('GET', `/api/sessions/${id}/history?path=${encodeURIComponent(path)}`)
|
|
2953
|
+
const { entries, patches, quilt, note } = await api('GET', `/api/sessions/${id}/history?path=${encodeURIComponent(path)}`)
|
|
2578
2954
|
if (id !== current) return
|
|
2579
2955
|
histories.set(`${id}\n${path}`, entries)
|
|
2956
|
+
read = true
|
|
2957
|
+
if (patches) patchLists.set(`${id}\n${path}`, { patches, quilt, ...(note ? { note } : {}) })
|
|
2958
|
+
else patchLists.delete(`${id}\n${path}`)
|
|
2580
2959
|
const w = ws(id)
|
|
2581
2960
|
if (w.mode !== 'files' || w.fileSel !== path) return
|
|
2582
|
-
if (fileTabOf(path)
|
|
2583
|
-
else renderFileTabs($('#main'), { tab: 'file', history: entries, summary: fileChanges(path) }) // the count only: freshly changed lines stay lit
|
|
2584
|
-
} catch {}
|
|
2961
|
+
if (fileTabOf(path) !== 'file') renderMain()
|
|
2962
|
+
else renderFileTabs($('#main'), { tab: 'file', history: entries, summary: fileChanges(path), quilt: quiltFileView(path) }) // the count only: freshly changed lines stay lit
|
|
2963
|
+
} catch {} finally {
|
|
2964
|
+
if (read) historyLoads.delete(key)
|
|
2965
|
+
else setTimeout(() => historyLoads.delete(key), 5000)
|
|
2966
|
+
}
|
|
2585
2967
|
}
|
|
2586
2968
|
|
|
2587
2969
|
function treeMeta (path) {
|
|
@@ -3201,7 +3583,7 @@ function bindMentions (input) {
|
|
|
3201
3583
|
const update = () => {
|
|
3202
3584
|
const s = sum()
|
|
3203
3585
|
if (!s) return close()
|
|
3204
|
-
const asks = relayPrompts(input.value, { owner: !!s.status.access?.owner })
|
|
3586
|
+
const asks = relayPrompts(input.value, { owner: !!s.status.access?.owner, seam: inSeam(s.status) })
|
|
3205
3587
|
if (asks.length) { at = null; return show(asks.map((q) => ({ question: q.text }))) }
|
|
3206
3588
|
const found = mentionAt(input.value, input.selectionStart)
|
|
3207
3589
|
if (!found) return close()
|