@leftoff/cli 0.9.8-beta.8 → 0.9.8
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/CHANGELOG.md +40 -3
- package/bin/leftoff.js +405 -361
- package/docs/SECURITY.md +14 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +10 -0
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +447 -14
- package/ui/app.js +1082 -85
- package/ui/attach.js +211 -0
- package/ui/composer.js +46 -0
- package/ui/diff-view.js +278 -0
- package/ui/lightbox.js +452 -0
- package/ui/live-status.js +2 -1
- package/ui/live-view.js +22 -5
- package/ui/live-wake.js +96 -0
- package/ui/machines.js +17 -0
- package/ui/navhide.js +35 -0
- package/ui/pdfview.js +510 -0
- package/ui/prs.js +10 -5
- package/ui/relay-client.js +14 -4
- package/ui/restore-gate.js +20 -0
- package/ui/tabs.js +9 -5
- package/ui/tour.css +63 -0
- package/ui/tour.js +364 -0
- package/ui/update-bar.js +158 -0
- package/ui/vendor/PDFJS-LICENSE.txt +179 -0
- package/ui/vendor/pdfjs/pdf.min.js +27 -0
- package/ui/vendor/pdfjs/pdf.worker.min.js +27 -0
- package/ui/what-it-did.js +149 -0
package/ui/attach.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Attachments in the Live composer (0.9.8). A paperclip in the box (the
|
|
2
|
+
// phone's own picker: Photo Library, Take Photo, Choose File), and on a
|
|
3
|
+
// desktop drag and drop or paste. Each file is uploaded right away to the Mac
|
|
4
|
+
// that runs the session (src/uploads.js), in base64 chunks every path can
|
|
5
|
+
// carry (the relay takes ~1 MB a message); your message then tells Claude
|
|
6
|
+
// where each file is. Big photos are scaled down first (a 2048 px long edge,
|
|
7
|
+
// JPEG 0.85) and HEIC becomes JPEG, so Claude can read it.
|
|
8
|
+
//
|
|
9
|
+
// The pure pieces are unit tested (test/unit/attach-098.test.mjs); the rest
|
|
10
|
+
// needs a browser (canvas, FileReader) and is checked by test:attach-ui.
|
|
11
|
+
|
|
12
|
+
export const ATTACH_MAX_FILES = 10 // src/uploads.js UPLOAD_MAX_FILES
|
|
13
|
+
export const ATTACH_MAX_BYTES = 20 * 1024 * 1024 // UPLOAD_MAX_BYTES
|
|
14
|
+
export const ATTACH_CHUNK = 384 * 1024 // UPLOAD_CHUNK (the daemon says so too)
|
|
15
|
+
export const LONG_EDGE = 2048
|
|
16
|
+
export const JPEG_QUALITY = 0.85
|
|
17
|
+
export const BIG_PHOTO = 4 * 1024 * 1024 // a photo this big is re-encoded even when it fits the edge
|
|
18
|
+
export const THUMB_EDGE = 320
|
|
19
|
+
const THUMB_BYTES = 60 * 1024 // under the daemon's 64 KB
|
|
20
|
+
|
|
21
|
+
const IMG_EXT = /\.(png|jpe?g|gif|webp|heic|heif|bmp|tiff?)$/i
|
|
22
|
+
export function isHeic(f) { return /^image\/hei[cf]/i.test(String((f && f.type) || '')) || /\.hei[cf]$/i.test(String((f && f.name) || '')) }
|
|
23
|
+
export function attachKind(f) {
|
|
24
|
+
const t = String((f && f.type) || '').toLowerCase(), n = String((f && f.name) || '').toLowerCase()
|
|
25
|
+
if (/^image\//.test(t) || IMG_EXT.test(n)) return 'image'
|
|
26
|
+
if (t === 'application/pdf' || n.endsWith('.pdf')) return 'pdf'
|
|
27
|
+
if (/^text\//.test(t) || /json|xml|javascript|yaml|csv/.test(t) || /\.(txt|md|json|csv|log|ya?ml|xml|html?|css|js|mjs|ts|tsx|jsx|py|rb|go|rs|java|swift|kt|sh|sql|diff|patch)$/.test(n)) return 'text'
|
|
28
|
+
return 'file'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fmtSize(n) {
|
|
32
|
+
const b = Math.max(0, Number(n) || 0)
|
|
33
|
+
if (b < 1024) return b + ' B'
|
|
34
|
+
if (b < 1024 * 1024) return Math.round(b / 1024) + ' KB'
|
|
35
|
+
return (b / 1024 / 1024).toFixed(b < 10 * 1024 * 1024 ? 1 : 0) + ' MB'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A long name with its end kept: "Screenshot 2026-10…5.20.24 PM.png".
|
|
39
|
+
export function shortName(name, max = 18) {
|
|
40
|
+
const n = String(name || '')
|
|
41
|
+
if (n.length <= max) return n
|
|
42
|
+
const m = n.match(/(\.[A-Za-z0-9]{1,5})$/)
|
|
43
|
+
const tail = Math.min(n.length - 1, Math.max(m ? m[1].length + 3 : 4, Math.floor(max / 2.5)))
|
|
44
|
+
return n.slice(0, max - tail - 1) + '…' + n.slice(n.length - tail)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Which of the dropped/picked files to take, given how many are attached.
|
|
48
|
+
// -> { take: [file], skipped: [{ name, why: 'too_many' | 'too_large' }] }
|
|
49
|
+
export function attachPlan(files, have = 0, { maxFiles = ATTACH_MAX_FILES, maxBytes = ATTACH_MAX_BYTES } = {}) {
|
|
50
|
+
const take = [], skipped = []
|
|
51
|
+
for (const f of Array.from(files || [])) {
|
|
52
|
+
if (have + take.length >= maxFiles) { skipped.push({ name: f.name, why: 'too_many' }); continue }
|
|
53
|
+
// an image over the limit may still fit once scaled down (not a GIF or a format we can't draw)
|
|
54
|
+
const shrinks = attachKind(f) === 'image' && !/gif/i.test(f.type || '')
|
|
55
|
+
if (f.size > maxBytes && !(shrinks && f.size <= maxBytes * 3)) { skipped.push({ name: f.name, why: 'too_large' }); continue }
|
|
56
|
+
take.push(f)
|
|
57
|
+
}
|
|
58
|
+
return { take, skipped }
|
|
59
|
+
}
|
|
60
|
+
export function skippedText(skipped, { maxFiles = ATTACH_MAX_FILES, maxBytes = ATTACH_MAX_BYTES } = {}) {
|
|
61
|
+
const big = skipped.filter((x) => x.why === 'too_large').map((x) => x.name), many = skipped.filter((x) => x.why === 'too_many').length
|
|
62
|
+
return [big.length ? (big.length === 1 ? big[0] + ' is' : big.length + ' files are') + ' over ' + fmtSize(maxBytes) : '', many ? 'up to ' + maxFiles + ' files a message' : ''].filter(Boolean).join(' · ')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function chunkRanges(size, chunk = ATTACH_CHUNK) {
|
|
66
|
+
const n = Math.max(0, Number(size) || 0), c = Math.max(1, Number(chunk) || ATTACH_CHUNK)
|
|
67
|
+
if (!n) return [[0, 0]]
|
|
68
|
+
const out = []
|
|
69
|
+
for (let a = 0; a < n; a += c) out.push([a, Math.min(n, a + c)])
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function fitSize(w, h, max = LONG_EDGE) {
|
|
74
|
+
const W = Math.max(1, Number(w) || 1), H = Math.max(1, Number(h) || 1)
|
|
75
|
+
const long = Math.max(W, H)
|
|
76
|
+
if (long <= max) return { w: W, h: H }
|
|
77
|
+
const k = max / long
|
|
78
|
+
return { w: Math.max(1, Math.round(W * k)), h: Math.max(1, Math.round(H * k)) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Re-encode as JPEG? HEIC always (Claude Code reads PNG, JPEG, GIF and WebP);
|
|
82
|
+
// a photo or screenshot past the long edge, or a very big one; never a GIF.
|
|
83
|
+
export function shouldConvert(f, dims) {
|
|
84
|
+
if (isHeic(f)) return true
|
|
85
|
+
const t = String((f && f.type) || '').toLowerCase()
|
|
86
|
+
if (!/^image\/(jpeg|png|webp|bmp|tiff)$/.test(t)) return false
|
|
87
|
+
if (dims && Math.max(dims.w, dims.h) > LONG_EDGE) return true
|
|
88
|
+
return (Number(f.size) || 0) > BIG_PHOTO
|
|
89
|
+
}
|
|
90
|
+
export function jpegName(name) {
|
|
91
|
+
const n = String(name || 'photo')
|
|
92
|
+
return /\.[A-Za-z0-9]{1,5}$/.test(n) ? n.replace(/\.[A-Za-z0-9]{1,5}$/, '.jpg') : n + '.jpg'
|
|
93
|
+
}
|
|
94
|
+
// A pasted image is called image.png by every browser: give it a time.
|
|
95
|
+
export function pastedName(type, now = new Date()) {
|
|
96
|
+
const ext = (String(type || '').split('/')[1] || 'png').replace('jpeg', 'jpg').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'png'
|
|
97
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
98
|
+
return 'pasted-' + now.getFullYear() + p(now.getMonth() + 1) + p(now.getDate()) + '-' + p(now.getHours()) + p(now.getMinutes()) + p(now.getSeconds()) + '.' + ext
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// "Attached: /…/checkpoints/uploads/<session>/<id> (image, 412 KB)" lines in a
|
|
102
|
+
// message the transcript gave back: chips instead of paths (Leftoff's own
|
|
103
|
+
// uploads only; any other line stays text).
|
|
104
|
+
const ATT_LINE = /^Attached: (\/.*\/checkpoints\/uploads\/([A-Za-z0-9][A-Za-z0-9_-]{0,99})\/(\d{8}T\d{6}-[a-f0-9]{6}-([^/\n]+?))) \((image|PDF|pdf|text|file), ([^)\n]+)\)$/
|
|
105
|
+
export function parseAttachedLines(text) {
|
|
106
|
+
const atts = []
|
|
107
|
+
const keep = []
|
|
108
|
+
for (const line of String(text || '').split('\n')) {
|
|
109
|
+
const m = line.match(ATT_LINE)
|
|
110
|
+
if (m) { const kind = m[5].toLowerCase(); atts.push({ id: m[3], sessionId: m[2], name: m[4], kind, sizeLabel: m[6], thumb: kind === 'image' || kind === 'pdf' }); continue }
|
|
111
|
+
keep.push(line)
|
|
112
|
+
}
|
|
113
|
+
return { text: keep.join('\n').trim(), attachments: atts }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --------------------------------------------------------- in the browser
|
|
117
|
+
|
|
118
|
+
export function bytesToBase64(u8) {
|
|
119
|
+
let s = ''
|
|
120
|
+
for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000))
|
|
121
|
+
return btoa(s)
|
|
122
|
+
}
|
|
123
|
+
export function base64ToBytes(b64) {
|
|
124
|
+
const s = atob(b64 || '')
|
|
125
|
+
const u8 = new Uint8Array(s.length)
|
|
126
|
+
for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i)
|
|
127
|
+
return u8
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function decode(file) {
|
|
131
|
+
if (typeof createImageBitmap === 'function') {
|
|
132
|
+
try { const b = await createImageBitmap(file, { imageOrientation: 'from-image' }); return { src: b, w: b.width, h: b.height, close: () => b.close && b.close() } } catch {}
|
|
133
|
+
}
|
|
134
|
+
const url = URL.createObjectURL(file)
|
|
135
|
+
try {
|
|
136
|
+
const img = await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); i.onerror = () => reject(new Error('undecodable')); i.src = url })
|
|
137
|
+
return { src: img, w: img.naturalWidth, h: img.naturalHeight, close: () => URL.revokeObjectURL(url) }
|
|
138
|
+
} catch (e) { URL.revokeObjectURL(url); return null }
|
|
139
|
+
}
|
|
140
|
+
function draw(d, w, h) {
|
|
141
|
+
const c = document.createElement('canvas'); c.width = w; c.height = h
|
|
142
|
+
const g = c.getContext('2d')
|
|
143
|
+
g.fillStyle = '#fff'; g.fillRect(0, 0, w, h) // a transparent PNG becomes JPEG on white, not black
|
|
144
|
+
g.drawImage(d.src, 0, 0, w, h)
|
|
145
|
+
return c
|
|
146
|
+
}
|
|
147
|
+
const toBlob = (c, type, q) => new Promise((resolve) => c.toBlob((b) => resolve(b), type, q))
|
|
148
|
+
|
|
149
|
+
// What to upload for a picked file: { file, thumb (base64 JPEG or null),
|
|
150
|
+
// converted, note }. An image the browser can't draw (HEIC outside Safari)
|
|
151
|
+
// goes as it is, with a note.
|
|
152
|
+
export async function prepareFile(file) {
|
|
153
|
+
if (attachKind(file) !== 'image' || /gif/i.test(file.type || '')) return { file, thumb: null, converted: false }
|
|
154
|
+
const d = await decode(file)
|
|
155
|
+
if (!d) return { file, thumb: null, converted: false, note: isHeic(file) ? 'heic' : null }
|
|
156
|
+
try {
|
|
157
|
+
let out = file, converted = false
|
|
158
|
+
if (shouldConvert(file, { w: d.w, h: d.h })) {
|
|
159
|
+
const { w, h } = fitSize(d.w, d.h, LONG_EDGE)
|
|
160
|
+
const b = await toBlob(draw(d, w, h), 'image/jpeg', JPEG_QUALITY)
|
|
161
|
+
if (b && (b.size < file.size || isHeic(file) || Math.max(d.w, d.h) > LONG_EDGE)) { out = new File([b], jpegName(file.name), { type: 'image/jpeg', lastModified: file.lastModified || Date.now() }); converted = true }
|
|
162
|
+
}
|
|
163
|
+
let thumb = null
|
|
164
|
+
const t = fitSize(d.w, d.h, THUMB_EDGE)
|
|
165
|
+
for (const q of [0.72, 0.5, 0.3]) {
|
|
166
|
+
const tb = await toBlob(draw(d, t.w, t.h), 'image/jpeg', q)
|
|
167
|
+
if (tb && tb.size <= THUMB_BYTES) { thumb = bytesToBase64(new Uint8Array(await tb.arrayBuffer())); break }
|
|
168
|
+
}
|
|
169
|
+
return { file: out, thumb, converted, w: d.w, h: d.h }
|
|
170
|
+
} finally { d.close() }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Upload one prepared file to a session: begin, then chunks; a lost answer is
|
|
174
|
+
// picked up from what the Mac says it has. onProgress(sent, total).
|
|
175
|
+
export async function uploadFile({ api, sessionId, file, thumb = null, onProgress = () => {}, isCancelled = () => false }) {
|
|
176
|
+
const base = '/v1/sessions/' + encodeURIComponent(sessionId) + '/uploads'
|
|
177
|
+
const st = await api('POST', base, { name: file.name || 'file', size: file.size, type: file.type || '' })
|
|
178
|
+
if (st.done) { onProgress(file.size, file.size); return st.file }
|
|
179
|
+
const chunk = Math.max(16 * 1024, Math.min(ATTACH_CHUNK, Number(st.chunk) || ATTACH_CHUNK))
|
|
180
|
+
let off = 0, first = true, out = null, tries = 0
|
|
181
|
+
while (!out || !out.done) {
|
|
182
|
+
if (isCancelled()) throw Object.assign(new Error('removed'), { code: 'removed' })
|
|
183
|
+
const piece = new Uint8Array(await file.slice(off, Math.min(file.size, off + chunk)).arrayBuffer())
|
|
184
|
+
try {
|
|
185
|
+
out = await api('POST', base + '/' + encodeURIComponent(st.id), { offset: off, data: bytesToBase64(piece), ...(first && thumb ? { thumb } : {}) }, { timeoutMs: 60000 })
|
|
186
|
+
tries = 0
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (++tries > 2 || (e.status && e.status < 500 && e.status !== 408 && e.status !== 429)) throw e
|
|
189
|
+
await new Promise((r) => setTimeout(r, 800 * tries)); continue
|
|
190
|
+
}
|
|
191
|
+
first = false
|
|
192
|
+
off = Number(out.received) || 0
|
|
193
|
+
onProgress(off, file.size)
|
|
194
|
+
}
|
|
195
|
+
return out.file
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Read an attachment back from the Mac (in chunks: the relay carries ~1 MB a
|
|
199
|
+
// message) -> a Blob.
|
|
200
|
+
export async function fetchAttachment({ api, sessionId, id, thumb = false }) {
|
|
201
|
+
const base = '/v1/sessions/' + encodeURIComponent(sessionId) + '/uploads/' + encodeURIComponent(id)
|
|
202
|
+
const parts = []
|
|
203
|
+
let off = 0, size = null, type = 'application/octet-stream'
|
|
204
|
+
for (;;) {
|
|
205
|
+
const r = await api('GET', base + '?' + new URLSearchParams(thumb ? { thumb: '1' } : { offset: String(off), length: String(ATTACH_CHUNK) }), null, { timeoutMs: 60000 })
|
|
206
|
+
const b = base64ToBytes(r.data)
|
|
207
|
+
parts.push(b); off += b.length; size = r.size; type = r.type || type
|
|
208
|
+
if (thumb || off >= size || !b.length) break
|
|
209
|
+
}
|
|
210
|
+
return new Blob(parts, { type })
|
|
211
|
+
}
|
package/ui/composer.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The Live composer (0.9.8): one rounded box like a chat app's prompt input.
|
|
2
|
+
// It starts at one line and grows with what you type up to COMPOSER_MAX_LINES,
|
|
3
|
+
// then scrolls inside; a round ↑ button in its corner sends, dim until there's
|
|
4
|
+
// something to send. On a Mac (a fine pointer) Enter sends and Shift+Enter is a
|
|
5
|
+
// new line; on a phone or tablet Return is a new line and the button sends.
|
|
6
|
+
// ⌘↵ / Ctrl+↵ sends everywhere. Pure functions here (shared with the unit
|
|
7
|
+
// tests); app.js wires them to the page.
|
|
8
|
+
|
|
9
|
+
export const COMPOSER_MAX_LINES = 8
|
|
10
|
+
|
|
11
|
+
// Nothing worth sending: empty or only whitespace.
|
|
12
|
+
export const isBlank = (text) => !String(text == null ? '' : text).trim()
|
|
13
|
+
|
|
14
|
+
// What a keydown in the composer means: 'send', or null to let the browser do
|
|
15
|
+
// its own thing (a new line, nothing). ev: { key, shiftKey, altKey, metaKey,
|
|
16
|
+
// ctrlKey, isComposing, keyCode }. touch: Return types a new line (a phone's
|
|
17
|
+
// keyboard has no Shift+Return worth asking for).
|
|
18
|
+
export function composerKey(ev, { touch = false } = {}) {
|
|
19
|
+
if (!ev || ev.key !== 'Enter') return null
|
|
20
|
+
if (ev.isComposing || ev.keyCode === 229) return null // an IME is choosing a word: Enter confirms it
|
|
21
|
+
if (ev.metaKey || ev.ctrlKey) return ev.altKey || ev.shiftKey ? null : 'send'
|
|
22
|
+
if (ev.shiftKey || ev.altKey) return null
|
|
23
|
+
return touch ? null : 'send'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Is this a touch-first screen (phone, tablet)? matchMedia is the page's.
|
|
27
|
+
// No fine pointer, or a coarse one as the primary pointer, counts as touch.
|
|
28
|
+
export function isTouchUi(matchMedia) {
|
|
29
|
+
if (typeof matchMedia !== 'function') return false
|
|
30
|
+
try {
|
|
31
|
+
if (matchMedia('(pointer: coarse)').matches) return true
|
|
32
|
+
return !matchMedia('(pointer: fine)').matches
|
|
33
|
+
} catch { return false }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The height the box should be for its content: scrollHeight is the
|
|
37
|
+
// textarea's at height:auto (padding included), lineHeight and padY in px.
|
|
38
|
+
// -> { height, scroll } where scroll says the content is taller than the cap.
|
|
39
|
+
export function composerHeight({ scrollHeight = 0, lineHeight = 22, padY = 0, maxLines = COMPOSER_MAX_LINES } = {}) {
|
|
40
|
+
const lh = Number(lineHeight) > 0 ? Number(lineHeight) : 22
|
|
41
|
+
const pad = Math.max(0, Number(padY) || 0)
|
|
42
|
+
const min = Math.round(lh + pad)
|
|
43
|
+
const max = Math.round(lh * maxLines + pad)
|
|
44
|
+
const want = Math.ceil(Number(scrollHeight) || 0)
|
|
45
|
+
return { height: Math.max(min, Math.min(want, max)), scroll: want > max + 1 }
|
|
46
|
+
}
|
package/ui/diff-view.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// 0.9.8 What it did: a file's changes drawn the way GitHub draws them (unified
|
|
2
|
+
// view): two line-number gutters (old | new), a +/− column, added rows pale
|
|
3
|
+
// green and removed rows pale red with a darker gutter, @@ bars with the
|
|
4
|
+
// function they're in, "⋯ N unchanged lines" between hunks, and the words that
|
|
5
|
+
// changed inside a paired −/+ line in a darker tint. Long lines wrap past the
|
|
6
|
+
// gutters. The parsing is pure (checked without a browser); the drawing builds
|
|
7
|
+
// DOM nodes and sets textContent only, so nothing from a file is ever parsed as
|
|
8
|
+
// HTML.
|
|
9
|
+
|
|
10
|
+
export const WORD_MAX = 500 // longer lines: no word highlight
|
|
11
|
+
const CELLS_MAX = 40000 // token grid budget for one −/+ pair
|
|
12
|
+
|
|
13
|
+
// "@@ -12,7 +12,9 @@ function foo()" -> { oldStart, oldCount, newStart, newCount, ctx }
|
|
14
|
+
export function parseHunkHeader(line) {
|
|
15
|
+
const m = /^@@+ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@+ ?(.*)$/.exec(String(line || ''))
|
|
16
|
+
if (!m) return null
|
|
17
|
+
return { oldStart: +m[1], oldCount: m[2] == null ? 1 : +m[2], newStart: +m[3], newCount: m[4] == null ? 1 : +m[4], ctx: m[5] || '' }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// How much of a line its changed ranges cover (spaces not counted).
|
|
21
|
+
function changedShare(s, ranges) {
|
|
22
|
+
let n = 0
|
|
23
|
+
for (const [a, b] of ranges) n += s.slice(a, b).replace(/\s+/g, '').length
|
|
24
|
+
return n / (s.replace(/\s+/g, '').length || 1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TOKEN = /[A-Za-z0-9_]+|\s+|[^A-Za-z0-9_\s]/g
|
|
28
|
+
const tokens = (s) => s.match(TOKEN) || []
|
|
29
|
+
|
|
30
|
+
// The changed parts of a removed line `a` and the added line `b` that replaced
|
|
31
|
+
// it, as [start, end) character ranges: [rangesInA, rangesInB]. null when
|
|
32
|
+
// there's nothing worth marking (too long, identical, or the whole line
|
|
33
|
+
// changed, where a highlight says nothing the row colour doesn't).
|
|
34
|
+
export function wordRanges(a, b) {
|
|
35
|
+
a = String(a == null ? '' : a); b = String(b == null ? '' : b)
|
|
36
|
+
if (a === b || a.length > WORD_MAX || b.length > WORD_MAX) return null
|
|
37
|
+
const ta = tokens(a), tb = tokens(b)
|
|
38
|
+
let p = 0
|
|
39
|
+
while (p < ta.length && p < tb.length && ta[p] === tb[p]) p++
|
|
40
|
+
let s = 0
|
|
41
|
+
while (s < ta.length - p && s < tb.length - p && ta[ta.length - 1 - s] === tb[tb.length - 1 - s]) s++
|
|
42
|
+
const ma = ta.slice(p, ta.length - s), mb = tb.slice(p, tb.length - s)
|
|
43
|
+
const keepA = new Uint8Array(ma.length), keepB = new Uint8Array(mb.length)
|
|
44
|
+
if (ma.length && mb.length && ma.length * mb.length <= CELLS_MAX) {
|
|
45
|
+
// longest common subsequence of the middle tokens
|
|
46
|
+
const n = ma.length, m = mb.length, w = m + 1
|
|
47
|
+
const L = new Uint16Array((n + 1) * w)
|
|
48
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i * w + j] = ma[i] === mb[j] ? L[(i + 1) * w + j + 1] + 1 : Math.max(L[(i + 1) * w + j], L[i * w + j + 1])
|
|
49
|
+
let i = 0, j = 0
|
|
50
|
+
while (i < n && j < m) {
|
|
51
|
+
if (ma[i] === mb[j]) { keepA[i++] = 1; keepB[j++] = 1 } else if (L[(i + 1) * w + j] >= L[i * w + j + 1]) i++
|
|
52
|
+
else j++
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const ranges = (all, mid, keep) => {
|
|
56
|
+
const out = []
|
|
57
|
+
let at = 0
|
|
58
|
+
for (let k = 0; k < p; k++) at += all[k].length
|
|
59
|
+
for (let k = 0; k < mid.length; k++) {
|
|
60
|
+
const len = mid[k].length
|
|
61
|
+
if (!keep[k]) {
|
|
62
|
+
const last = out[out.length - 1]
|
|
63
|
+
if (last && last[1] === at) last[1] = at + len
|
|
64
|
+
else out.push([at, at + len])
|
|
65
|
+
}
|
|
66
|
+
at += len
|
|
67
|
+
}
|
|
68
|
+
// a lone kept space between two changed spans joins them
|
|
69
|
+
for (let k = out.length - 2; k >= 0; k--) {
|
|
70
|
+
const gap = (all === ta ? a : b).slice(out[k][1], out[k + 1][0])
|
|
71
|
+
if (gap.length <= 1 && /^\s*$/.test(gap)) { out[k][1] = out[k + 1][1]; out.splice(k + 1, 1) }
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
const ra = ranges(ta, ma, keepA), rb = ranges(tb, mb, keepB)
|
|
76
|
+
if (!ra.length && !rb.length) return null
|
|
77
|
+
// most of both lines changed: no highlight (GitHub does the same)
|
|
78
|
+
if (changedShare(a, ra) > 0.7 && changedShare(b, rb) > 0.7) return null
|
|
79
|
+
return [ra, rb]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A file's patch (hunks only, as src/diff.js sends it) -> rows to draw:
|
|
83
|
+
// { k: 'gap', n } unchanged lines git left out
|
|
84
|
+
// { k: 'hunk', text, ctx, range } the @@ bar
|
|
85
|
+
// { k: 'ctx' | 'add' | 'del', o, n, t, w } a line; w: changed ranges
|
|
86
|
+
// { k: 'note', t } "", "… more lines"
|
|
87
|
+
// maxLine: the biggest line number (sizes the gutters).
|
|
88
|
+
export function parsePatch(text) {
|
|
89
|
+
const lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n')
|
|
90
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop()
|
|
91
|
+
const rows = []
|
|
92
|
+
let o = 0, n = 0, maxLine = 0, prevEnd = 1, inHunk = false
|
|
93
|
+
let dels = [], adds = []
|
|
94
|
+
// a run of removed lines then added ones: each removed line pairs with the
|
|
95
|
+
// next added line it resembles (in order), so an inserted line between
|
|
96
|
+
// doesn't throw the pairs off; big runs pair by position
|
|
97
|
+
const pair = () => {
|
|
98
|
+
const near = dels.length * adds.length <= 400
|
|
99
|
+
let j = 0
|
|
100
|
+
for (let i = 0; i < dels.length && j < adds.length; i++) {
|
|
101
|
+
if (!near) { const w = wordRanges(dels[i].t, adds[j].t); if (w) { dels[i].w = w[0]; adds[j].w = w[1] } j++; continue }
|
|
102
|
+
let best = null, bk = -1, bs = Infinity
|
|
103
|
+
for (let k = j; k < adds.length; k++) {
|
|
104
|
+
const w = wordRanges(dels[i].t, adds[k].t)
|
|
105
|
+
if (!w) continue
|
|
106
|
+
const sc = changedShare(dels[i].t, w[0]) + changedShare(adds[k].t, w[1])
|
|
107
|
+
if (sc < bs) { best = w; bk = k; bs = sc }
|
|
108
|
+
}
|
|
109
|
+
if (best) { dels[i].w = best[0]; adds[bk].w = best[1]; j = bk + 1 }
|
|
110
|
+
}
|
|
111
|
+
dels = []; adds = []
|
|
112
|
+
}
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
const h = line.startsWith('@@') ? parseHunkHeader(line) : null
|
|
115
|
+
if (h) {
|
|
116
|
+
pair()
|
|
117
|
+
const gap = h.oldStart - prevEnd
|
|
118
|
+
if (gap > 0) rows.push({ k: 'gap', n: gap })
|
|
119
|
+
const m = /^(@@+ [^@]*@@+)/.exec(line)
|
|
120
|
+
rows.push({ k: 'hunk', text: line, range: m ? m[1] : line, ctx: h.ctx })
|
|
121
|
+
o = h.oldStart; n = h.newStart
|
|
122
|
+
prevEnd = h.oldStart + h.oldCount
|
|
123
|
+
maxLine = Math.max(maxLine, h.oldStart + h.oldCount, h.newStart + h.newCount)
|
|
124
|
+
inHunk = true
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (!inHunk) continue // header lines before the first hunk
|
|
128
|
+
const c = line[0]
|
|
129
|
+
if (c === '+') { const r = { k: 'add', o: null, n: n++, t: line.slice(1) }; rows.push(r); adds.push(r) }
|
|
130
|
+
else if (c === '-') { if (adds.length) pair(); const r = { k: 'del', o: o++, n: null, t: line.slice(1) }; rows.push(r); dels.push(r) }
|
|
131
|
+
else if (c === ' ' || line === '') { pair(); rows.push({ k: 'ctx', o: o++, n: n++, t: line.slice(1) }) }
|
|
132
|
+
else { pair(); rows.push({ k: 'note', t: c === '\\' ? line.slice(1).trim() : line }) }
|
|
133
|
+
}
|
|
134
|
+
pair()
|
|
135
|
+
return { rows, maxLine }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A unified patch from two texts (an Edit tool's old_string -> new_string),
|
|
139
|
+
// hunks with 3 lines of context, numbered from `start` (1 when unknown). A
|
|
140
|
+
// plain line LCS; null past maxLines on either side.
|
|
141
|
+
export function makePatch(a, b, { start = 1, context = 3, maxLines = 2000 } = {}) {
|
|
142
|
+
const A = String(a == null ? '' : a).split('\n'), B = String(b == null ? '' : b).split('\n')
|
|
143
|
+
if (A.length > maxLines || B.length > maxLines) return null
|
|
144
|
+
if (A.length > 1 && B.length > 1 && A[A.length - 1] === '' && B[B.length - 1] === '') { A.pop(); B.pop() } // both end in a newline
|
|
145
|
+
// trim the common ends first, LCS on the middle only
|
|
146
|
+
let p = 0
|
|
147
|
+
while (p < A.length && p < B.length && A[p] === B[p]) p++
|
|
148
|
+
let q = 0
|
|
149
|
+
while (q < A.length - p && q < B.length - p && A[A.length - 1 - q] === B[B.length - 1 - q]) q++
|
|
150
|
+
const ma = A.slice(p, A.length - q), mb = B.slice(p, B.length - q)
|
|
151
|
+
const n = ma.length, m = mb.length
|
|
152
|
+
const ops = [] // [' ' | '-' | '+', text]
|
|
153
|
+
for (let i = 0; i < p; i++) ops.push([' ', A[i]])
|
|
154
|
+
if (n * m > 4e6) { for (const x of ma) ops.push(['-', x]); for (const x of mb) ops.push(['+', x]) } else {
|
|
155
|
+
const w = m + 1, L = new Uint32Array((n + 1) * w)
|
|
156
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i * w + j] = ma[i] === mb[j] ? L[(i + 1) * w + j + 1] + 1 : Math.max(L[(i + 1) * w + j], L[i * w + j + 1])
|
|
157
|
+
let i = 0, j = 0
|
|
158
|
+
while (i < n || j < m) {
|
|
159
|
+
if (i < n && j < m && ma[i] === mb[j]) { ops.push([' ', ma[i]]); i++; j++ } else if (j < m && (i >= n || L[i * w + j + 1] >= L[(i + 1) * w + j])) { ops.push(['+', mb[j]]); j++ } else { ops.push(['-', ma[i]]); i++ }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (let i = A.length - q; i < A.length; i++) ops.push([' ', A[i]])
|
|
163
|
+
// removed lines before added ones inside each change, like git
|
|
164
|
+
for (let k = 0; k < ops.length; k++) {
|
|
165
|
+
if (ops[k][0] === ' ') continue
|
|
166
|
+
let e = k; while (e < ops.length && ops[e][0] !== ' ') e++
|
|
167
|
+
const run = ops.slice(k, e); const sorted = [...run.filter((o) => o[0] === '-'), ...run.filter((o) => o[0] === '+')]
|
|
168
|
+
ops.splice(k, e - k, ...sorted); k = e - 1
|
|
169
|
+
}
|
|
170
|
+
const changed = ops.map((o, k) => (o[0] !== ' ' ? k : -1)).filter((k) => k >= 0)
|
|
171
|
+
if (!changed.length) return ''
|
|
172
|
+
// group changes into hunks: ranges of ops with `context` lines around them
|
|
173
|
+
const hunks = []
|
|
174
|
+
for (const k of changed) {
|
|
175
|
+
const lo = Math.max(0, k - context), hi = Math.min(ops.length - 1, k + context)
|
|
176
|
+
const last = hunks[hunks.length - 1]
|
|
177
|
+
if (last && lo <= last[1] + 1) last[1] = hi
|
|
178
|
+
else hunks.push([lo, hi])
|
|
179
|
+
}
|
|
180
|
+
let out = ''
|
|
181
|
+
let o = start, nn = start, at = 0
|
|
182
|
+
for (const [lo, hi] of hunks) {
|
|
183
|
+
for (; at < lo; at++) { if (ops[at][0] !== '+') o++; if (ops[at][0] !== '-') nn++ }
|
|
184
|
+
let oc = 0, nc = 0, body = ''
|
|
185
|
+
for (let k = lo; k <= hi; k++) { const [c, t] = ops[k]; if (c !== '+') oc++; if (c !== '-') nc++; body += c + t + '\n' }
|
|
186
|
+
out += '@@ -' + (oc ? o : o - 1) + ',' + oc + ' +' + (nc ? nn : nn - 1) + ',' + nc + ' @@\n' + body
|
|
187
|
+
for (; at <= hi; at++) { if (ops[at][0] !== '+') o++; if (ops[at][0] !== '-') nn++ }
|
|
188
|
+
}
|
|
189
|
+
return out
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// GitHub's little 5-block bar for +add −del: 'a' green, 'd' red, 'n' grey.
|
|
193
|
+
export function diffBlocks(add, del) {
|
|
194
|
+
add = Math.max(0, Number(add) || 0); del = Math.max(0, Number(del) || 0)
|
|
195
|
+
const total = add + del
|
|
196
|
+
const shown = Math.min(5, total)
|
|
197
|
+
if (!shown) return ['n', 'n', 'n', 'n', 'n']
|
|
198
|
+
let a = Math.round(shown * add / total)
|
|
199
|
+
if (add && !a) a = 1
|
|
200
|
+
if (del && a === shown && shown > 1) a = shown - 1
|
|
201
|
+
const d = shown - a
|
|
202
|
+
return [...Array(a).fill('a'), ...Array(d).fill('d'), ...Array(5 - shown).fill('n')]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// "src/lib/retry.js" -> ['src/lib/', 'retry.js']
|
|
206
|
+
export function splitPath(p) {
|
|
207
|
+
const s = String(p == null ? '' : p)
|
|
208
|
+
const i = s.lastIndexOf('/')
|
|
209
|
+
return i < 0 ? ['', s] : [s.slice(0, i + 1), s.slice(i + 1)]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const MARK = { add: '+', del: '−', ctx: ' ' }
|
|
213
|
+
|
|
214
|
+
// Draw a patch. doc: the document (anything with createElement). Returns a
|
|
215
|
+
// div.gd; every piece of the file goes in through textContent.
|
|
216
|
+
export function renderDiff(doc, text, { label = '', numbers = true } = {}) {
|
|
217
|
+
const { rows, maxLine } = parsePatch(text)
|
|
218
|
+
const el = (tag, cls, txt) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (txt != null && txt !== '') e.textContent = txt; return e }
|
|
219
|
+
const root = el('div', 'gd gd-w' + (numbers ? Math.min(6, Math.max(2, String(maxLine || 1).length)) : 1))
|
|
220
|
+
if (label) root.setAttribute('aria-label', label)
|
|
221
|
+
for (const r of rows) {
|
|
222
|
+
const row = el('div', 'gd-row gd-' + r.k)
|
|
223
|
+
if (r.k === 'gap') {
|
|
224
|
+
row.appendChild(el('span', 'gd-gut gd-span', '⋯'))
|
|
225
|
+
row.appendChild(el('span', 'gd-c', r.n + ' unchanged line' + (r.n === 1 ? '' : 's')))
|
|
226
|
+
} else if (r.k === 'hunk') {
|
|
227
|
+
row.appendChild(el('span', 'gd-gut gd-span'))
|
|
228
|
+
const c = el('span', 'gd-c')
|
|
229
|
+
c.appendChild(el('span', 'gd-range', r.range))
|
|
230
|
+
if (r.ctx) { c.appendChild(doc.createTextNode(' ')); c.appendChild(el('span', 'gd-ctx', r.ctx)) }
|
|
231
|
+
row.appendChild(c)
|
|
232
|
+
} else if (r.k === 'note') {
|
|
233
|
+
row.appendChild(el('span', 'gd-gut gd-span'))
|
|
234
|
+
row.appendChild(el('span', 'gd-c', r.t))
|
|
235
|
+
} else {
|
|
236
|
+
row.appendChild(el('span', 'gd-gut gd-o', r.o == null || !numbers ? '' : String(r.o)))
|
|
237
|
+
row.appendChild(el('span', 'gd-gut gd-n', r.n == null || !numbers ? '' : String(r.n)))
|
|
238
|
+
const mk = el('span', 'gd-m', MARK[r.k])
|
|
239
|
+
mk.setAttribute('aria-label', r.k === 'add' ? 'added' : r.k === 'del' ? 'removed' : 'unchanged')
|
|
240
|
+
row.appendChild(mk)
|
|
241
|
+
const c = el('span', 'gd-c')
|
|
242
|
+
if (r.w && r.w.length) {
|
|
243
|
+
let at = 0
|
|
244
|
+
for (const [s, e] of r.w) {
|
|
245
|
+
if (s > at) c.appendChild(doc.createTextNode(r.t.slice(at, s)))
|
|
246
|
+
c.appendChild(el(r.k === 'add' ? 'ins' : 'del', 'gd-x', r.t.slice(s, e)))
|
|
247
|
+
at = e
|
|
248
|
+
}
|
|
249
|
+
if (at < r.t.length) c.appendChild(doc.createTextNode(r.t.slice(at)))
|
|
250
|
+
} else if (r.t) c.textContent = r.t
|
|
251
|
+
else c.textContent = '\u200b' // keeps an empty line's height
|
|
252
|
+
row.appendChild(c)
|
|
253
|
+
}
|
|
254
|
+
root.appendChild(row)
|
|
255
|
+
}
|
|
256
|
+
return root
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Fill each placeholder (<div class="gd-slot" data-gd-path="…">) the board's
|
|
260
|
+
// HTML left for a file's lines. getFile(path) -> { lines } | null. A drawn
|
|
261
|
+
// diff is kept per file answer, so a redraw of the page moves the same nodes
|
|
262
|
+
// back in instead of drawing them again.
|
|
263
|
+
const drawn = new WeakMap()
|
|
264
|
+
export function hydrateDiffs(root, getFile) {
|
|
265
|
+
if (!root || !root.querySelectorAll) return 0
|
|
266
|
+
let k = 0
|
|
267
|
+
for (const slot of root.querySelectorAll('.gd-slot[data-gd-path]')) {
|
|
268
|
+
if (slot.firstChild) continue
|
|
269
|
+
const f = getFile(slot.getAttribute('data-gd-path'))
|
|
270
|
+
const text = f && typeof f === 'object' ? f.lines || f.patch : null
|
|
271
|
+
if (!text) continue
|
|
272
|
+
let node = drawn.get(f)
|
|
273
|
+
if (!node) { node = renderDiff(slot.ownerDocument || document, text, { label: 'Changes in ' + (f.path || f.what || ''), numbers: !f.patch || f.patchAt != null }); drawn.set(f, node) }
|
|
274
|
+
slot.appendChild(node)
|
|
275
|
+
k++
|
|
276
|
+
}
|
|
277
|
+
return k
|
|
278
|
+
}
|