dsh-paste-code-block 0.2.5 → 0.2.6
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/LICENSE +21 -21
- package/README.md +118 -118
- package/README.zh-CN.md +118 -118
- package/cordis.patch.yml +4 -4
- package/docs/design.md +373 -337
- package/docs/images/hero.svg +3 -3
- package/docs/images/languages.svg +3 -3
- package/docs/images/render.cjs +20 -20
- package/docs/images/workflow.svg +3 -3
- package/package.json +3 -2
- package/src/chip-nodes.js +46 -46
- package/src/client.js +273 -61
- package/src/i18n.js +140 -140
- package/src/index.js +11 -11
- package/src/parse.js +159 -6
- package/src/projection.js +54 -54
- package/tests/chip-nodes.test.js +71 -0
- package/tests/fixtures.js +9 -0
- package/tests/fold-smoke.test.js +642 -0
- package/tests/fold.test.js +147 -0
- package/tests/i18n.test.js +112 -0
- package/tests/lexical-chip-probe.mjs +109 -0
- package/tests/parse.test.js +183 -0
- package/tests/projection.test.js +64 -0
package/src/client.js
CHANGED
|
@@ -17,6 +17,20 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
const DETAIL_NS = 'dsh-pcb-detail'
|
|
18
18
|
const CHIP_X_CLASS = 'dsh-pcb-chip-x'
|
|
19
19
|
|
|
20
|
+
// ===== diagnostics =======================================================
|
|
21
|
+
// Off by default. Set `window.__PCB_DEBUG__ = true` in the console (then
|
|
22
|
+
// paste / send) to trace paste parsing and fold decisions. The on-screen
|
|
23
|
+
// badge that used to sit above the composer was scaffolding for one
|
|
24
|
+
// debugging session and is gone.
|
|
25
|
+
const PCB_DEBUG = (() => {
|
|
26
|
+
try { return typeof window !== 'undefined' && window.__PCB_DEBUG__ === true } catch (e) { return false }
|
|
27
|
+
})()
|
|
28
|
+
function pcbDiag(kind, info) {
|
|
29
|
+
if (!PCB_DEBUG) return
|
|
30
|
+
try { console.log('[pcb-diag]', kind + (info ? ': ' + info : '')) } catch (e) {}
|
|
31
|
+
}
|
|
32
|
+
// =========================================================================
|
|
33
|
+
|
|
20
34
|
// ----- shared chip-DOM helpers ------------------------------------------
|
|
21
35
|
// Module level on purpose: the chip sync scan and the controller's removal
|
|
22
36
|
// path both need to map a chip host element back to its label (and its
|
|
@@ -218,6 +232,25 @@ window.__ModuleLoader__.load({
|
|
|
218
232
|
return ''
|
|
219
233
|
}
|
|
220
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The single complete fence of `text`, or null when the text is not exactly
|
|
237
|
+
* one fenced block (blank lines around it tolerated, real prose not).
|
|
238
|
+
* Keep in sync with src/parse.js#wholeFence.
|
|
239
|
+
*/
|
|
240
|
+
function wholeFence(text) {
|
|
241
|
+
const segs = splitFencedSegments(text)
|
|
242
|
+
let fence = null
|
|
243
|
+
for (const seg of segs) {
|
|
244
|
+
if (seg.kind === 'fence') {
|
|
245
|
+
if (fence) return null
|
|
246
|
+
fence = seg
|
|
247
|
+
} else if (seg.text.trim() !== '') {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return fence
|
|
252
|
+
}
|
|
253
|
+
|
|
221
254
|
/**
|
|
222
255
|
* Parse pasted text into a block, or null when it is ordinary short prose
|
|
223
256
|
* that should paste plainly. `isCode` drives both the chip label
|
|
@@ -228,12 +261,42 @@ window.__ModuleLoader__.load({
|
|
|
228
261
|
const text = String(raw || '').replace(/^\uFEFF/, '')
|
|
229
262
|
if (!text) return null
|
|
230
263
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
264
|
+
// (1) exactly one complete fence: this plugin's own output shape. The
|
|
265
|
+
// fence's info string decides the type, so an explicit ```text paste is
|
|
266
|
+
// a TEXT block and round-trips instead of degrading into bare code.
|
|
267
|
+
const whole = wholeFence(text)
|
|
268
|
+
if (whole) {
|
|
269
|
+
const content = whole.body.replace(/\n+$/, '')
|
|
235
270
|
if (!content) return null
|
|
236
|
-
|
|
271
|
+
const infoLang = (whole.lang || '').toLowerCase()
|
|
272
|
+
return {
|
|
273
|
+
lang: infoLang === 'text' ? '' : whole.lang,
|
|
274
|
+
content,
|
|
275
|
+
lines: content.split(/\r?\n/),
|
|
276
|
+
isCode: infoLang !== 'text',
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// (2) a paste that OPENS with a fence but carries trailing prose — the
|
|
281
|
+
// copy-a-whole-previous-message case. Keep it as ONE block and take the
|
|
282
|
+
// type from the leading fence's info string, so the chip and the
|
|
283
|
+
// transcript agree. (0.2.5 treated it as code and re-wrapped the
|
|
284
|
+
// already-fenced text in a bare fence: two nested fences in the bubble.)
|
|
285
|
+
const segs = splitFencedSegments(text)
|
|
286
|
+
let leadFence = -1
|
|
287
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
288
|
+
if (segs[i].kind === 'fence') { leadFence = i; break }
|
|
289
|
+
if (segs[i].text.trim() !== '') break
|
|
290
|
+
}
|
|
291
|
+
if (leadFence >= 0) {
|
|
292
|
+
const leadLang = (segs[leadFence].lang || '').toLowerCase()
|
|
293
|
+
const content = text.replace(/\n+$/, '')
|
|
294
|
+
return {
|
|
295
|
+
lang: leadLang === 'text' ? '' : segs[leadFence].lang,
|
|
296
|
+
content,
|
|
297
|
+
lines: content.split(/\r?\n/),
|
|
298
|
+
isCode: leadLang !== 'text',
|
|
299
|
+
}
|
|
237
300
|
}
|
|
238
301
|
|
|
239
302
|
const multi = /\r?\n/.test(text)
|
|
@@ -277,9 +340,23 @@ window.__ModuleLoader__.load({
|
|
|
277
340
|
return n
|
|
278
341
|
}
|
|
279
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Backtick marker long enough to wrap `content` unambiguously: longer than
|
|
345
|
+
* the longest backtick run inside it. Keep in sync with
|
|
346
|
+
* src/parse.js#fenceMarkerFor.
|
|
347
|
+
*/
|
|
348
|
+
function fenceMarkerFor(content) {
|
|
349
|
+
const src = String(content == null ? '' : content)
|
|
350
|
+
let longest = 0
|
|
351
|
+
const runs = src.match(/`+/g)
|
|
352
|
+
if (runs) for (const run of runs) if (run.length > longest) longest = run.length
|
|
353
|
+
return '`'.repeat(Math.max(3, longest + 1))
|
|
354
|
+
}
|
|
355
|
+
|
|
280
356
|
function serializeBlock(block) {
|
|
281
357
|
const lang = block.isCode ? (block.lang || '') : 'text'
|
|
282
|
-
|
|
358
|
+
const marker = fenceMarkerFor(block.content)
|
|
359
|
+
return `\n${marker}${lang}\n${block.content}\n${marker}\n`
|
|
283
360
|
}
|
|
284
361
|
|
|
285
362
|
// ----- fence segmentation (embedded copy of src/parse.js) ----------------
|
|
@@ -328,6 +405,80 @@ window.__ModuleLoader__.load({
|
|
|
328
405
|
return segments
|
|
329
406
|
}
|
|
330
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Character ranges of every complete fence in `text`, in order, as
|
|
410
|
+
* { start, end } (end exclusive, both fence lines included). Same pairing
|
|
411
|
+
* rules as splitFencedSegments. Keep in sync with src/parse.js#fenceRanges.
|
|
412
|
+
*/
|
|
413
|
+
function fenceRanges(text) {
|
|
414
|
+
const src = String(text == null ? '' : text)
|
|
415
|
+
const out = []
|
|
416
|
+
if (!src) return out
|
|
417
|
+
const starts = [0]
|
|
418
|
+
for (let i = 0; i < src.length; i += 1) if (src.charCodeAt(i) === 10) starts.push(i + 1)
|
|
419
|
+
const lineEnd = (n) => (n + 1 < starts.length ? starts[n + 1] - 1 : src.length)
|
|
420
|
+
const lineText = (n) => src.slice(starts[n], lineEnd(n)).replace(/\r$/, '')
|
|
421
|
+
const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
|
|
422
|
+
let i = 0
|
|
423
|
+
while (i < starts.length) {
|
|
424
|
+
const m = OPEN.exec(lineText(i))
|
|
425
|
+
if (!m) { i += 1; continue }
|
|
426
|
+
const marker = m[1]
|
|
427
|
+
const ch = marker[0]
|
|
428
|
+
const info = m[2].trim()
|
|
429
|
+
if (ch === '`' && info.includes('`')) { i += 1; continue }
|
|
430
|
+
const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
|
|
431
|
+
let j = i + 1
|
|
432
|
+
let closed = false
|
|
433
|
+
while (j < starts.length) {
|
|
434
|
+
if (closeRe.test(lineText(j))) { closed = true; break }
|
|
435
|
+
j += 1
|
|
436
|
+
}
|
|
437
|
+
if (!closed) { i += 1; continue }
|
|
438
|
+
out.push({ start: starts[i], end: starts[j] + lineText(j).length })
|
|
439
|
+
i = j + 1
|
|
440
|
+
}
|
|
441
|
+
return out
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* How the sent-message fold scan should fold `text`, given the run texts
|
|
446
|
+
* DSH actually rendered it as. Keep in sync with src/parse.js#planFoldRuns.
|
|
447
|
+
* Returns null when nothing is foldable, else
|
|
448
|
+
* { mode:'single', segments, runs } — every fence lies inside ONE run
|
|
449
|
+
* { mode:'bubble', segments, fences } — a fence straddles run boundaries
|
|
450
|
+
*/
|
|
451
|
+
function planFoldRuns(text, runTexts) {
|
|
452
|
+
const src = String(text == null ? '' : text)
|
|
453
|
+
const segments = splitFencedSegments(src)
|
|
454
|
+
const fenceSegs = segments.filter((seg) => seg.kind === 'fence')
|
|
455
|
+
const spans = fenceRanges(src)
|
|
456
|
+
if (fenceSegs.length !== spans.length) return null
|
|
457
|
+
const fences = []
|
|
458
|
+
fenceSegs.forEach((seg, k) => { if (seg.body.trim() !== '') fences.push(spans[k]) })
|
|
459
|
+
if (!fences.length) return null
|
|
460
|
+
|
|
461
|
+
const bounds = []
|
|
462
|
+
let at = 0
|
|
463
|
+
for (const rt of (runTexts || [])) {
|
|
464
|
+
const len = String(rt == null ? '' : rt).length
|
|
465
|
+
bounds.push([at, at + len])
|
|
466
|
+
at += len
|
|
467
|
+
}
|
|
468
|
+
const runOf = (off) => {
|
|
469
|
+
for (let k = 0; k < bounds.length; k += 1) if (off >= bounds[k][0] && off < bounds[k][1]) return k
|
|
470
|
+
return -1
|
|
471
|
+
}
|
|
472
|
+
const runs = []
|
|
473
|
+
for (const f of fences) {
|
|
474
|
+
const a = runOf(f.start)
|
|
475
|
+
const b = runOf(Math.max(f.start, f.end - 1))
|
|
476
|
+
if (a < 0 || a !== b) return { mode: 'bubble', segments, fences }
|
|
477
|
+
if (!runs.includes(a)) runs.push(a)
|
|
478
|
+
}
|
|
479
|
+
return { mode: 'single', segments, runs }
|
|
480
|
+
}
|
|
481
|
+
|
|
331
482
|
// ===== Recovery mirror (draft re-seed after workspace switch / reload) ===
|
|
332
483
|
// DSH persists a session's composer draft as PLAIN TEXT
|
|
333
484
|
// (`dsh.conversation.<sid>` in localStorage) and re-seeds the editor from
|
|
@@ -515,7 +666,11 @@ window.__ModuleLoader__.load({
|
|
|
515
666
|
insertReference(sessionId, block) {
|
|
516
667
|
const { actx, shell } = this.scope(sessionId)
|
|
517
668
|
const input = shell.snapshot
|
|
518
|
-
|
|
669
|
+
// DSH's SessionInputShell.insertReference accepts both `plain` and
|
|
670
|
+
// `claimed` phases; mirror that so a paste while a command draft is
|
|
671
|
+
// still pending (claimed) folds into a chip instead of falling back to
|
|
672
|
+
// raw text. Submitting/adjudicating (mid-submit) stay rejected.
|
|
673
|
+
if (!input || (input.phase !== 'plain' && input.phase !== 'claimed')) return false
|
|
519
674
|
// Each chip contributes exactly one char to the editor's detect text
|
|
520
675
|
// (the "" placeholder) and one char to the plain `draft` projection
|
|
521
676
|
// (clipboardText = zero-width space, chosen because String#trim() does
|
|
@@ -1183,8 +1338,9 @@ window.__ModuleLoader__.load({
|
|
|
1183
1338
|
// React-side and never sees this DOM.
|
|
1184
1339
|
const FOLD_HOST_CLASS = 'dsh-pcb-fold-host'
|
|
1185
1340
|
const FOLD_CARD_CLASS = 'dsh-pcb-fold'
|
|
1186
|
-
const
|
|
1187
|
-
const
|
|
1341
|
+
const FOLD_STAMP_ATTR = 'data-dsh-pcb-stamp' // on a host: the content stamp it was built from
|
|
1342
|
+
const FOLD_HIDE_ATTR = 'data-dsh-pcb-hide' // on every run the scan hid: the same stamp
|
|
1343
|
+
const FOLD_SIG_ATTR = 'data-dsh-pcb-sig' // on the bubble: '<mode>|<stamp>' it was folded for
|
|
1188
1344
|
const FOLD_BUBBLE_SEL = [
|
|
1189
1345
|
'[data-chat-flow-kind="user"]',
|
|
1190
1346
|
'[data-chat-flow-kind="steering"]',
|
|
@@ -1423,6 +1579,7 @@ window.__ModuleLoader__.load({
|
|
|
1423
1579
|
const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
|
|
1424
1580
|
|
|
1425
1581
|
function apply(ctx) {
|
|
1582
|
+
try { pcbDiag('LOADED', 'v0.2.5-diag') } catch (e) {}
|
|
1426
1583
|
// Bump alongside package.json on every release — the load log is the
|
|
1427
1584
|
// only proof of WHICH bundle generation the browser actually loaded.
|
|
1428
1585
|
try { console.log('[dsh-paste-code-block] client loaded v0.2.5') } catch (e) { /* noop */ }
|
|
@@ -1494,25 +1651,95 @@ window.__ModuleLoader__.load({
|
|
|
1494
1651
|
}
|
|
1495
1652
|
|
|
1496
1653
|
// ----- sent-message fold: the scan ------------------------------------
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1654
|
+
// DSH renders a sent message as plain pre-wrap runs and SPLITS those runs
|
|
1655
|
+
// at every @mention chip, so one serialized block can arrive as several
|
|
1656
|
+
// sibling runs with the fence opener in one and its closer in another.
|
|
1657
|
+
// A per-run scan then sees only an unterminated half (nothing to fold) —
|
|
1658
|
+
// or, worse, a fragment that happens to look like a complete fence, which
|
|
1659
|
+
// is how a bogus card built from the tail of the message used to appear.
|
|
1660
|
+
// So fold at BUBBLE scope: rebuild the text from the runs, plan the
|
|
1661
|
+
// fences over that, and keep the per-run path only when every fence sits
|
|
1662
|
+
// inside a single run (which leaves DSH's mention chips clickable).
|
|
1663
|
+
const foldedBubbles = new WeakSet()
|
|
1664
|
+
|
|
1665
|
+
/** Hand a bubble back to DSH's own rendering. */
|
|
1666
|
+
function clearFolds(bubble) {
|
|
1667
|
+
for (const el of [...bubble.children]) {
|
|
1668
|
+
if (el.classList.contains(FOLD_HOST_CLASS)) { el.remove(); continue }
|
|
1669
|
+
if (el.getAttribute(FOLD_HIDE_ATTR) !== null) {
|
|
1670
|
+
el.style.display = ''
|
|
1671
|
+
el.removeAttribute(FOLD_HIDE_ATTR)
|
|
1672
|
+
}
|
|
1504
1673
|
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1674
|
+
bubble.removeAttribute(FOLD_SIG_ATTR)
|
|
1675
|
+
foldedBubbles.delete(bubble)
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** True when the DOM still matches what `sig` claims (React remounts runs). */
|
|
1679
|
+
function foldsIntact(bubble, mode, stamp, wanted) {
|
|
1680
|
+
const kids = [...bubble.children]
|
|
1681
|
+
const hosts = kids.filter((el) => el.classList.contains(FOLD_HOST_CLASS))
|
|
1682
|
+
if (hosts.length !== (mode === 'bubble' ? 1 : wanted)) return false
|
|
1683
|
+
for (const host of hosts) if (host.getAttribute(FOLD_STAMP_ATTR) !== stamp) return false
|
|
1684
|
+
const hidden = kids.filter((el) => el.getAttribute(FOLD_HIDE_ATTR) === stamp)
|
|
1685
|
+
if (hidden.length !== wanted) return false
|
|
1686
|
+
for (const el of hidden) if (el.style.display !== 'none') return false
|
|
1687
|
+
return true
|
|
1508
1688
|
}
|
|
1509
1689
|
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1690
|
+
function foldBubble(bubble) {
|
|
1691
|
+
const holder = bubble.closest('[data-chat-flow-key]')
|
|
1692
|
+
const flowKey = holder ? holder.getAttribute('data-chat-flow-key') || '' : ''
|
|
1693
|
+
const runs = [...bubble.children].filter((el) => !el.classList.contains(FOLD_HOST_CLASS))
|
|
1694
|
+
const texts = runs.map((el) => el.textContent || '')
|
|
1695
|
+
const full = texts.join('')
|
|
1696
|
+
const stamp = foldStamp(full)
|
|
1697
|
+
// Cheap guard first: most messages carry no fence at all.
|
|
1698
|
+
const plan = runs.length && FENCE_HINT.test(full) ? planFoldRuns(full, texts) : null
|
|
1699
|
+
let mode = plan ? plan.mode : ''
|
|
1700
|
+
let subs = []
|
|
1701
|
+
if (plan && mode === 'single') {
|
|
1702
|
+
for (const k of plan.runs) {
|
|
1703
|
+
const sub = foldPlanFor(texts[k])
|
|
1704
|
+
if (sub) subs.push([k, sub])
|
|
1705
|
+
}
|
|
1706
|
+
// A run we cannot plan for (a mention chip, a wrapper element) is not
|
|
1707
|
+
// ours to hide — rebuild the bubble instead.
|
|
1708
|
+
if (!subs.length || plan.runs.some((k) => !isFoldRun(runs[k]))) mode = 'bubble'
|
|
1709
|
+
}
|
|
1710
|
+
const sig = plan ? `${mode}|${stamp}` : ''
|
|
1711
|
+
const wanted = plan ? (mode === 'bubble' ? runs.length : subs.length) : 0
|
|
1712
|
+
if (plan && bubble.getAttribute(FOLD_SIG_ATTR) === sig && foldsIntact(bubble, mode, stamp, wanted)) return
|
|
1713
|
+
if (!plan && !foldedBubbles.has(bubble)) return
|
|
1714
|
+
clearFolds(bubble)
|
|
1715
|
+
if (!plan) return
|
|
1716
|
+
const keyBase = flowKey || 'h' + hash32(full)
|
|
1717
|
+
const fenceCount = plan.fences ? plan.fences.length : (plan.runs || []).length
|
|
1718
|
+
pcbDiag('FOLD', 'mode=' + mode + ' runs=' + runs.length + ' fences=' + fenceCount +
|
|
1719
|
+
' hide=' + (mode === 'bubble' ? 'all' : subs.map((s) => s[0]).join(',')) + ' len=' + full.length)
|
|
1720
|
+
if (mode === 'bubble') {
|
|
1721
|
+
// Hide every run and render the whole message from the plan, so the
|
|
1722
|
+
// fence that straddled them is paired again. Mention chips inside the
|
|
1723
|
+
// folded text lose their click target — they are collapsed content.
|
|
1724
|
+
const host = buildFoldHost(plan.segments, `${keyBase}#b`, t)
|
|
1725
|
+
host.setAttribute(FOLD_STAMP_ATTR, stamp)
|
|
1726
|
+
for (const el of runs) {
|
|
1727
|
+
el.style.display = 'none'
|
|
1728
|
+
el.setAttribute(FOLD_HIDE_ATTR, stamp)
|
|
1729
|
+
}
|
|
1730
|
+
bubble.appendChild(host)
|
|
1731
|
+
} else {
|
|
1732
|
+
for (const [k, sub] of subs.slice().sort((a, b) => a[0] - b[0])) {
|
|
1733
|
+
const el = runs[k]
|
|
1734
|
+
el.style.display = 'none'
|
|
1735
|
+
el.setAttribute(FOLD_HIDE_ATTR, stamp)
|
|
1736
|
+
const host = buildFoldHost(sub, `${keyBase}#${k}`, t)
|
|
1737
|
+
host.setAttribute(FOLD_STAMP_ATTR, stamp)
|
|
1738
|
+
el.insertAdjacentElement('afterend', host)
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
bubble.setAttribute(FOLD_SIG_ATTR, sig)
|
|
1742
|
+
foldedBubbles.add(bubble)
|
|
1516
1743
|
}
|
|
1517
1744
|
|
|
1518
1745
|
function scanFolds() {
|
|
@@ -1521,38 +1748,7 @@ window.__ModuleLoader__.load({
|
|
|
1521
1748
|
try { bubbles = document.querySelectorAll(FOLD_BUBBLE_SEL) } catch (e) { return }
|
|
1522
1749
|
for (const bubble of bubbles) {
|
|
1523
1750
|
if (!bubble.isConnected) continue
|
|
1524
|
-
|
|
1525
|
-
const flowKey = holder ? holder.getAttribute('data-chat-flow-key') || '' : ''
|
|
1526
|
-
const kids = [...bubble.children]
|
|
1527
|
-
for (let i = 0; i < kids.length; i += 1) {
|
|
1528
|
-
const el = kids[i]
|
|
1529
|
-
if (el.classList.contains(FOLD_HOST_CLASS)) continue
|
|
1530
|
-
if (!isFoldRun(el)) continue
|
|
1531
|
-
const text = el.textContent || ''
|
|
1532
|
-
const stamp = foldStamp(text)
|
|
1533
|
-
const marked = el.getAttribute(FOLD_RUN_ATTR) || ''
|
|
1534
|
-
if (marked === `1|${stamp}` || marked === `0|${stamp}`) continue // current
|
|
1535
|
-
const plan = foldPlanFor(text)
|
|
1536
|
-
if (!plan) {
|
|
1537
|
-
if (marked.startsWith('1|')) unfoldRun(el, stamp)
|
|
1538
|
-
else el.setAttribute(FOLD_RUN_ATTR, `0|${stamp}`)
|
|
1539
|
-
continue
|
|
1540
|
-
}
|
|
1541
|
-
// Echo/pending rows have no flow key — fall back to a content hash so
|
|
1542
|
-
// two simultaneous unkeyed rows never share one open-state slot.
|
|
1543
|
-
const keyBase = `${flowKey || 'h' + hash32(text)}#${i}`
|
|
1544
|
-
foldRun(el, plan, keyBase, stamp)
|
|
1545
|
-
}
|
|
1546
|
-
// Drop orphan hosts (e.g. React remounted the run span underneath us).
|
|
1547
|
-
for (const el of [...bubble.children]) {
|
|
1548
|
-
if (!el.classList.contains(FOLD_HOST_CLASS)) continue
|
|
1549
|
-
const prev = el.previousElementSibling
|
|
1550
|
-
const mark = prev && prev.getAttribute ? prev.getAttribute(FOLD_RUN_ATTR) : null
|
|
1551
|
-
const keep = !!mark && mark.startsWith('1|') &&
|
|
1552
|
-
prev.style.display === 'none' &&
|
|
1553
|
-
el.getAttribute(FOLD_STAMP_ATTR) === mark.slice(2)
|
|
1554
|
-
if (!keep) el.remove()
|
|
1555
|
-
}
|
|
1751
|
+
try { foldBubble(bubble) } catch (e) { /* never break DSH's render loop */ }
|
|
1556
1752
|
}
|
|
1557
1753
|
}
|
|
1558
1754
|
|
|
@@ -1586,10 +1782,11 @@ window.__ModuleLoader__.load({
|
|
|
1586
1782
|
ctx.effect(() => () => {
|
|
1587
1783
|
try {
|
|
1588
1784
|
for (const host of document.querySelectorAll(`.${FOLD_HOST_CLASS}`)) host.remove()
|
|
1589
|
-
for (const run of document.querySelectorAll(`[${
|
|
1785
|
+
for (const run of document.querySelectorAll(`[${FOLD_HIDE_ATTR}]`)) {
|
|
1590
1786
|
run.style.display = ''
|
|
1591
|
-
run.removeAttribute(
|
|
1787
|
+
run.removeAttribute(FOLD_HIDE_ATTR)
|
|
1592
1788
|
}
|
|
1789
|
+
for (const bubble of document.querySelectorAll(`[${FOLD_SIG_ATTR}]`)) bubble.removeAttribute(FOLD_SIG_ATTR)
|
|
1593
1790
|
} catch (e) { /* noop */ }
|
|
1594
1791
|
foldOpenKeys.clear()
|
|
1595
1792
|
}, 'paste-code-block: fold restore')
|
|
@@ -1651,14 +1848,15 @@ window.__ModuleLoader__.load({
|
|
|
1651
1848
|
const target = document.activeElement
|
|
1652
1849
|
if (!target || !(target instanceof Element)) return
|
|
1653
1850
|
const sid = sessionFor(target)
|
|
1654
|
-
if (!sid) return
|
|
1851
|
+
if (!sid) { pcbDiag("EXIT-SID-NULL", "activeEl=" + target.tagName + "." + (target.className || "") + " mark=" + !!document.querySelector("[data-dsh-pcb]")); return }
|
|
1655
1852
|
const block = parseBlock(text)
|
|
1656
|
-
if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
|
|
1853
|
+
if (!block) { pcbDiag("EXIT-NOT-BLOCK", "len=" + text.length); console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
|
|
1657
1854
|
if (pasteStorm.seen(text, Date.now())) {
|
|
1658
1855
|
// Consume the event (never let the raw text fall through to the
|
|
1659
1856
|
// composer) but create nothing.
|
|
1660
1857
|
ev.preventDefault()
|
|
1661
1858
|
ev.stopPropagation()
|
|
1859
|
+
pcbDiag("EXIT-STORM", "len=" + text.length);
|
|
1662
1860
|
console.log('[dsh-paste-code-block] storm paste ignored (<' + STORM_WINDOW_MS + 'ms repeat)')
|
|
1663
1861
|
return
|
|
1664
1862
|
}
|
|
@@ -1666,11 +1864,14 @@ window.__ModuleLoader__.load({
|
|
|
1666
1864
|
// trusted=false means a page script dispatched this paste (userscript,
|
|
1667
1865
|
// clipboard bridge, replay glue) rather than a real keystroke — the
|
|
1668
1866
|
// load-log line that names the source when a storm report comes in.
|
|
1867
|
+
pcbDiag("ENTER-INTERCEPT", "sid=" + sid + " lines=" + block.lines.length + " code=" + block.isCode + " trusted=" + ev.isTrusted);
|
|
1669
1868
|
console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang, 'trusted=', ev.isTrusted)
|
|
1670
1869
|
ev.preventDefault()
|
|
1671
1870
|
ev.stopPropagation()
|
|
1672
1871
|
try {
|
|
1673
1872
|
if (!controller.attach(sid, block)) {
|
|
1873
|
+
const snapX = (() => { try { const s = controller.scope(sid).shell.snapshot; return "phase=" + (s && s.phase) + " draftLen=" + (s && s.draft ? s.draft.length : -1) + " rev=" + (s && s.draftRev) } catch (e) { return "scope-throw:" + e.message } })();
|
|
1874
|
+
pcbDiag("EXIT-ATTACH-FAIL", snapX);
|
|
1674
1875
|
console.log('[dsh-paste-code-block] attach failed; plain insert instead')
|
|
1675
1876
|
const { actx, shell } = controller.scope(sid)
|
|
1676
1877
|
const input = shell.snapshot
|
|
@@ -1685,6 +1886,17 @@ window.__ModuleLoader__.load({
|
|
|
1685
1886
|
console.warn('[dsh-paste-code-block] could not insert block text')
|
|
1686
1887
|
}
|
|
1687
1888
|
}
|
|
1889
|
+
pcbDiag("OK-ATTACHED", "id=" + block.id);
|
|
1890
|
+
setTimeout(() => { try {
|
|
1891
|
+
const sc = controller.scope(sid).shell.snapshot || {};
|
|
1892
|
+
const occ = (sc.occurrences || []).filter((o) => o.source === 'paste-code-block').length;
|
|
1893
|
+
var __chip = document.querySelector('[data-composer-chip="paste-code-block"]'); var __r = __chip && __chip.getBoundingClientRect(); var __cs = __chip && getComputedStyle(__chip); var __csi = __chip && __chip.firstElementChild && getComputedStyle(__chip.firstElementChild); pcbDiag("POST-ATTACH", "phase=" + sc.phase + " draftLen=" + (sc.draft ? sc.draft.length : -1) + " occ=" + occ + " chips=" + document.querySelectorAll('[data-composer-chip="paste-code-block"]').length);
|
|
1894
|
+
var __hosts = []; var __seen = new Set();
|
|
1895
|
+
document.querySelectorAll('[data-composer-chip]').forEach(function (c) { var k = c.getAttribute('data-composer-chip') + '|' + (c.closest('[data-composer-seat]') ? 'seat' : (c.closest('[data-message-id],[data-message-id] *,.dsh-msg,.message') ? 'msg' : '?')); if (!__seen.has(k)) { __seen.add(k); __hosts.push(k) } });
|
|
1896
|
+
pcbDiag("CHIP-HOSTS", __hosts.join(',') || 'none');
|
|
1897
|
+
var __seats = [].map.call(document.querySelectorAll('[data-composer-seat]'), function (s) { return s.getBoundingClientRect().width + 'x' + Math.round(s.getBoundingClientRect().height) }).join(',');
|
|
1898
|
+
pcbDiag("SEATS", "n=" + document.querySelectorAll('[data-composer-seat]').length + " sizes=" + (__seats || '-') + " ce=" + document.querySelectorAll('[contenteditable="true"]').length); pcbDiag("CHIP-DOM", __chip ? ("rect=" + Math.round(__r.width) + "x" + Math.round(__r.height) + "@" + Math.round(__r.x) + "," + Math.round(__r.y) + " disp=" + __cs.display + " vis=" + __cs.visibility + " op=" + __cs.opacity + " innerDisp=" + (__csi ? __csi.display : "?") + " txt=(" + __chip.textContent.slice(0, 24) + ") parent=" + (__chip.parentElement ? __chip.parentElement.tagName + "." + String(__chip.parentElement.className).slice(0, 40) : "?")) : "NO-CHIP");
|
|
1899
|
+
} catch (e) { pcbDiag("POST-ATTACH-THROW", e.message) } }, 350);
|
|
1688
1900
|
} catch (err) {
|
|
1689
1901
|
console.error('[paste-code-block] attach failed:', err)
|
|
1690
1902
|
}
|