dsh-paste-code-block 0.1.3 → 0.2.5
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/README.md +14 -8
- package/README.zh-CN.md +14 -8
- package/docs/design.md +133 -1
- package/package.json +3 -3
- package/src/client.js +635 -7
- package/src/i18n.js +19 -0
- package/src/parse.js +64 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Cherry Studio‑style text/code block pasting for DeepSeek Harness Web**
|
|
6
6
|
|
|
7
|
-
Paste long code or text into the DSH Web composer → it lands as a **bordered, collapsible, language‑tagged card** instead of a wall of text. Names follow the DSH interface language (中文 / English). On send, the original content is restored as a fenced code block.
|
|
7
|
+
Paste long code or text into the DSH Web composer → it lands as a **bordered, collapsible, language‑tagged card** instead of a wall of text. Names follow the DSH interface language (中文 / English). On send, the original content is restored as a fenced code block — and in the conversation it **stays folded** into click‑to‑expand cards, so your bubbles never become text walls again.
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-paste-code-block)
|
|
10
10
|
[](./LICENSE)
|
|
@@ -28,6 +28,8 @@ Copying a 200‑line stack trace, an API response, or a long log into a chat inp
|
|
|
28
28
|
- 🈯 **Localized names** — chips are named in the current DSH language: `代码块 1` / `Code #1`, `文本块 N` / `Text #N`. A leading glyph and tint tells the two apart at a glance — `</>` blue for code, `Aa` gray for text. Switching **Settings → Language** live-retitles every chip.
|
|
29
29
|
- × **One-click remove** — every chip carries its own `×` delete button (the detail card has one too); a removed number is recycled immediately.
|
|
30
30
|
- 📤 **Fenced restore on send** — each card expands back into a ```` ```lang … ``` ```` block when you send; a failed send automatically refills the input.
|
|
31
|
+
- 📦 **Folded after send** — sent blocks keep folding in the transcript: every fenced block inside your own messages renders as a collapsed card (`python · 128 行` / `python · 128 lines`). Click the header to expand, again to collapse; the card's copy button grabs the original fenced text verbatim. Bare ```` ``` ```` fences fold as code (only an explicit ```` ```text ```` folds as a text block).
|
|
32
|
+
- 💾 **Mid-draft safe** — DSH persists a composer draft as plain text, so pasted blocks are automatically re-attached when the composer is rebuilt (workspace switch on the new-session page, page reload); hand-deleted blocks are never "resurrected".
|
|
31
33
|
- 🔢 **Smart numbering** — code and text count apart; the smallest free number is always reused.
|
|
32
34
|
- 📱 **Every surface** — pure client plugin; works on desktop & mobile web, follows light/dark themes.
|
|
33
35
|
|
|
@@ -68,6 +70,7 @@ dsh plugin --profile web add file:/path/to/dsh-paste-code-block # from a local
|
|
|
68
70
|
2. Paste into the DSH input → it collapses into a chip such as `代码块 1` / `Code #1`.
|
|
69
71
|
3. Click the chip to open the card: read, edit inline, copy, collapse. Click the chip's (or the card's) `×` to delete that block.
|
|
70
72
|
4. Send — the full original text goes out as a proper fenced code block.
|
|
73
|
+
5. In the conversation it stays folded: each block sits in the bubble as a collapsed card — click the header to expand and read, click again to collapse.
|
|
71
74
|
|
|
72
75
|
Detection threshold: **contains a newline / ≥ 2 lines / length ≥ 96 / indented & brace-dense / fenced** — any one turns the paste into a block.
|
|
73
76
|
|
|
@@ -78,21 +81,24 @@ Detection threshold: **contains a newline / ≥ 2 lines / length ≥ 96 / indent
|
|
|
78
81
|
- Removing a chip any other way (e.g. `Backspace`) also frees its number — plugin state is reconciled against the editor continuously.
|
|
79
82
|
- Deleting one block never touches the others, and removal is an ordinary editor edit — `Ctrl+Z` brings it back.
|
|
80
83
|
- Creating a block leaves **no stray space**: DSH appends a separating space after a freshly inserted chip, and the plugin removes exactly that one character, so text typed after a block does not start with a space and pasting several blocks never piles spaces into the message. Spaces you typed yourself are never touched by pasting or deleting.
|
|
84
|
+
- Post-send folding is **display-only**: it restyles your own message bubbles (and steering rows / the send echo) in the DOM. Stored messages are never modified, the bubble's own copy / edit / delete bar keeps working on the original text, and assistant messages are left alone.
|
|
85
|
+
- Draft-rebuild recovery keeps a small localStorage mirror (block payloads plus a draft fingerprint; written while you edit, cleared when you send, expired after two weeks). If the browser blocks local storage (private mode), blocks still survive a same-tab workspace switch from memory — just not a full page reload.
|
|
81
86
|
|
|
82
87
|
## Project layout
|
|
83
88
|
|
|
84
89
|
```
|
|
85
90
|
dsh-paste-code-block/
|
|
86
91
|
├── src/
|
|
87
|
-
│ ├── client.js # Web (browser) half — paste capture, chips, detail card, × delete
|
|
92
|
+
│ ├── client.js # Web (browser) half — paste capture, chips, detail card, × delete, sent-message fold, draft-rebuild recovery
|
|
88
93
|
│ ├── index.js # Host (node) half — intentionally empty (pure UI plugin)
|
|
89
|
-
│ ├── parse.js # Pure block-detection logic (canonical, unit-tested)
|
|
90
|
-
│ └── i18n.js # Locale dictionaries
|
|
94
|
+
│ ├── parse.js # Pure block-detection + fence-splitting logic (canonical, unit-tested)
|
|
95
|
+
│ └── i18n.js # Locale dictionaries, label & fold-title builders (canonical, unit-tested)
|
|
91
96
|
├── tests/
|
|
92
|
-
│ ├── parse.test.js
|
|
93
|
-
│
|
|
97
|
+
│ ├── parse.test.js # node:test unit tests for src/parse.js
|
|
98
|
+
│ ├── i18n.test.js # node:test unit tests for src/i18n.js
|
|
99
|
+
│ └── fold-smoke.test.js # end-to-end fold scan against a DOM shim (runs the real client bundle)
|
|
94
100
|
├── docs/
|
|
95
|
-
│ ├── design.md # Design rationale (numbering, anchors, codec, i18n, chip
|
|
101
|
+
│ ├── design.md # Design rationale (numbering, anchors, codec, i18n, chip ×, sent-message fold, draft-rebuild recovery)
|
|
96
102
|
│ └── images/ # README mockups (PNG + editable SVG sources)
|
|
97
103
|
├── cordis.patch.yml # DSH bundle patch declaring the host plugin
|
|
98
104
|
├── package.json # Package + DSH plugin manifest
|
|
@@ -104,7 +110,7 @@ dsh-paste-code-block/
|
|
|
104
110
|
|
|
105
111
|
```sh
|
|
106
112
|
npm run check # syntax-check the JS halves
|
|
107
|
-
npm test # run unit tests (node:test,
|
|
113
|
+
npm test # run unit tests (node:test, 61 tests)
|
|
108
114
|
```
|
|
109
115
|
|
|
110
116
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Cherry Studio 式的文本块/代码块粘贴效果(DeepSeek Harness Web)**
|
|
6
6
|
|
|
7
|
-
把复制的**长代码 / 长文本**粘到 DSH Web 输入框时,不再糊成一长串普通文字,而是收成一张**带边框、可折叠、带语言标签的卡片**;块名称自动跟随 DSH 界面语言(中文 / English);发送时自动还原成真正的 ```` ```lang … ``` ````
|
|
7
|
+
把复制的**长代码 / 长文本**粘到 DSH Web 输入框时,不再糊成一长串普通文字,而是收成一张**带边框、可折叠、带语言标签的卡片**;块名称自动跟随 DSH 界面语言(中文 / English);发送时自动还原成真正的 ```` ```lang … ``` ```` 围栏代码块——**发送后在对话里也保持折叠**,点击即可展开阅读。
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-paste-code-block)
|
|
10
10
|
[](./LICENSE)
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
- 🈯 **名称本地化**:芯片按当前 DSH 界面语言命名——`代码块 1` / `Code #1`、`文本块 N` / `Text #N`;前缀图标与配色一眼区分两类块(代码 `</>` 蓝色、文本 `Aa` 灰色);在「设置 → 语言」切换后所有芯片即时改名。
|
|
29
29
|
- × **一键删除**:每个芯片自带 `×` 删除按钮(详情卡上也有);删掉的块编号立刻回收复用。
|
|
30
30
|
- 📤 **发送还原**:发送时每个卡片展开还原为 ```` ```lang … ``` ```` 围栏代码块;发送失败自动回填输入框。
|
|
31
|
+
- 📦 **发送后仍折叠**:发出去的消息在对话里继续折叠成卡片(如 `python · 128 行`),气泡不再变成文字墙;点标题展开、再点收起,卡片上的复制按钮原样复制带围栏的完整代码块。裸 ```` ``` ```` 围栏按代码块折叠(只有显式 ```` ```text ```` 才折叠为文本块)。
|
|
32
|
+
- 💾 **草稿中途不丢**:DSH 只把输入框草稿按纯文本持久化,输入框被重建时(新会话页切换工作区、刷新页面)已粘贴的块会自动重新挂回草稿;手动删掉的块绝不会被“复活”。
|
|
31
33
|
- 🔢 **智能编号**:代码与文本分开计数,永远分配最小可用号。
|
|
32
34
|
- 📱 **全端适配**:纯客户端插件,PC 与手机 Web 通用,实时跟随深浅主题。
|
|
33
35
|
|
|
@@ -68,6 +70,7 @@ dsh plugin --profile web add file:/path/to/dsh-paste-code-block # 从本地目
|
|
|
68
70
|
2. 在 DSH 输入框粘贴 → 折叠成 `代码块 1` 这样的芯片。
|
|
69
71
|
3. 点芯片打开详情卡:阅读、就地编辑、复制、折叠;点芯片(或卡片)上的 `×` 删除该块。
|
|
70
72
|
4. 发送——完整原文以围栏代码块形式发出。
|
|
73
|
+
5. 发送后对话里仍保持折叠:每个块在气泡中显示为一张卡片,点标题展开阅读、再点收起。
|
|
71
74
|
|
|
72
75
|
识别阈值:**含换行 / 行数 ≥ 2 / 长度 ≥ 96 / 缩进且符号密集 / 带围栏**,任一命中即成块。
|
|
73
76
|
|
|
@@ -78,21 +81,24 @@ dsh plugin --profile web add file:/path/to/dsh-paste-code-block # 从本地目
|
|
|
78
81
|
- 用其他方式删除芯片(如 `Backspace`)同样会释放编号——插件状态与编辑器持续对账。
|
|
79
82
|
- 删除单个块绝不影响其它块;删除是一次普通的编辑器编辑,可用 `Ctrl+Z` 撤销找回。
|
|
80
83
|
- 创建块**不会残留空格**:DSH 会在新插入的芯片后自动补一个分隔空格,插件只把这一个字符删掉——因此块后面接着输入的文字不会顶着一个前导空格,连续粘贴多个块也不会在正文里堆出多余空格;你自己敲的空格在粘贴/删除时都不会被动到。
|
|
84
|
+
- 发送后折叠**只是显示层**的改动:它只重排你自己消息(含插话与发送回显)气泡的 DOM,**不改任何存储的消息数据**;气泡自带的复制/编辑/删除操作照常作用于原文;助手消息完全不受影响。
|
|
85
|
+
- 草稿恢复依赖一个小型 localStorage 镜像(只存块内容与草稿指纹;编辑时写入、发送即清、两周过期)。浏览器禁用本地存储(如隐身模式)时,同一页面内的工作区切换仍可从内存恢复,但整页刷新不能。
|
|
81
86
|
|
|
82
87
|
## 目录结构
|
|
83
88
|
|
|
84
89
|
```
|
|
85
90
|
dsh-paste-code-block/
|
|
86
91
|
├── src/
|
|
87
|
-
│ ├── client.js # Web(浏览器侧)—— 粘贴拦截、芯片、详情卡片、×
|
|
92
|
+
│ ├── client.js # Web(浏览器侧)—— 粘贴拦截、芯片、详情卡片、× 删除、发送后折叠、草稿恢复
|
|
88
93
|
│ ├── index.js # Host(Node 侧)—— 有意为空(纯 UI 插件)
|
|
89
|
-
│ ├── parse.js #
|
|
90
|
-
│ └── i18n.js #
|
|
94
|
+
│ ├── parse.js # 纯块识别 + 围栏切分逻辑(规范版,含单元测试)
|
|
95
|
+
│ └── i18n.js # 词典、块名称与折叠卡标题(规范版,含单元测试)
|
|
91
96
|
├── tests/
|
|
92
|
-
│ ├── parse.test.js
|
|
93
|
-
│
|
|
97
|
+
│ ├── parse.test.js # node:test 单元测试(src/parse.js)
|
|
98
|
+
│ ├── i18n.test.js # node:test 单元测试(src/i18n.js)
|
|
99
|
+
│ └── fold-smoke.test.js # 用 DOM 垫片端到端跑真实 client.js:折叠扫描 + 草稿恢复
|
|
94
100
|
├── docs/
|
|
95
|
-
│ ├── design.md # 设计说明(编号复用、锚点稳定、codec、i18n、芯片
|
|
101
|
+
│ ├── design.md # 设计说明(编号复用、锚点稳定、codec、i18n、芯片 ×、发送后折叠)
|
|
96
102
|
│ └── images/ # README 示意图(PNG + 可编辑 SVG 源)
|
|
97
103
|
├── cordis.patch.yml # DSH bundle patch,声明宿主插件
|
|
98
104
|
├── package.json # 包与 DSH 插件清单
|
|
@@ -104,7 +110,7 @@ dsh-paste-code-block/
|
|
|
104
110
|
|
|
105
111
|
```sh
|
|
106
112
|
npm run check # 语法检查 JS 半端
|
|
107
|
-
npm test # 运行单元测试(node:test,
|
|
113
|
+
npm test # 运行单元测试(node:test,61 项)
|
|
108
114
|
```
|
|
109
115
|
|
|
110
116
|
## License
|
package/docs/design.md
CHANGED
|
@@ -202,4 +202,136 @@ for us) while the scoped edit verbs address detect coordinates (a chip collapses
|
|
|
202
202
|
placeholder character). `detectOffsetOf()` converts with
|
|
203
203
|
`detect = clipboard - Σ(len_i - 1)` over preceding occurrences, so blocks stay correctly
|
|
204
204
|
addressed even when other plugins' chips (file references and the like) sit in the same draft.
|
|
205
|
-
Both helpers are pure and unit-tested (8 cases in tests/projection.test.js).
|
|
205
|
+
Both helpers are pure and unit-tested (8 cases in tests/projection.test.js).
|
|
206
|
+
|
|
207
|
+
## 12. Sent-message folding (0.2.0)
|
|
208
|
+
|
|
209
|
+
The composer fold was only half the story: on send the chips expand back into fenced text, and
|
|
210
|
+
DSH renders user bubbles as **plain pre-wrap text** (`projectUserText` → `span.plainRun` runs
|
|
211
|
+
plus `data-ref-chip` badges), so the sent message reappeared in the transcript as the very wall
|
|
212
|
+
of text the plugin exists to prevent. §12 folds *sent* blocks back down.
|
|
213
|
+
|
|
214
|
+
**Where the DOM hooks are.** Every conversation row carries `data-chat-flow-kind`
|
|
215
|
+
(`user` / `steering` / `agent` / …) and `data-chat-flow-key` on its wrapper; the right-aligned
|
|
216
|
+
user bubble lives under the same row and contains the `plainRun` spans. The send echo
|
|
217
|
+
(`data-submission-echo`) and pending steering (`data-pending-steering`) render the same bubble
|
|
218
|
+
markup one frame before the durable node exists, so scanning all four gives an instant
|
|
219
|
+
collapsed view from the moment of send. Assistant output is deliberately not touched: its
|
|
220
|
+
markdown renderer already styles code blocks its own way.
|
|
221
|
+
|
|
222
|
+
**Never fight React.** The bubble's children belong to React; removing them would make the
|
|
223
|
+
next reconciliation throw. So the fold *hides* a qualifying `plainRun` span (`display:none` —
|
|
224
|
+
the node stays a child, React keeps patching it harmlessly) and inserts a
|
|
225
|
+
`div.dsh-pcb-fold-host` **after** it. The host is ours: prose segments re-render as
|
|
226
|
+
`span.dsh-pcb-fold-prose`, each fence as a collapsed card (glyph `</>`/`Aa` + localized title
|
|
227
|
+
`python · 128 行` / `Text · 128 lines`, copy button copying the **raw fenced text** exactly as
|
|
228
|
+
sent, click-to-expand `<pre>`). Since hiding a child does not affect `textContent`, the
|
|
229
|
+
message action bar (React-side copy/edit/delete) is entirely unaffected.
|
|
230
|
+
|
|
231
|
+
**What folds.** `splitFencedSegments()` (canonical in `src/parse.js`, embedded copy in
|
|
232
|
+
`client.js`) walks the run line by line with GFM fence rules (``` or ~~~, opener indented ≤ 3
|
|
233
|
+
spaces, closer ≥ opener length alone on its line; backticks in a ``` info string kill that
|
|
234
|
+
opener; an unterminated opener stays prose). Any fence segment with a non-empty body folds;
|
|
235
|
+
everything else — prose, whitespace between blocks, empty fences — renders verbatim around
|
|
236
|
+
the cards. Fence-only (not "any long text") is deliberate: composer-created blocks always
|
|
237
|
+
travel as fences, while unfenced long messages may have been *typed*, and silently folding
|
|
238
|
+
typed prose would be a nasty surprise.
|
|
239
|
+
|
|
240
|
+
**Idempotence and refresh.** Each scanned run gets
|
|
241
|
+
`data-dsh-pcb-run = "1|<stamp>"` (folded) or `"0|<stamp>"` (checked, nothing to fold), where
|
|
242
|
+
the stamp folds `foldRev`, text length, and an FNV-1a hash. The shared chip-sync
|
|
243
|
+
MutationObserver coalesces to one rAF scan; a run whose stamp matches is skipped, so steady
|
|
244
|
+
state costs attribute reads. A locale switch bumps `foldRev`, which invalidates every stamp —
|
|
245
|
+
the next scan rebuilds each host and re-titles cards from the live translator. Rebuilds
|
|
246
|
+
preserve who was expanded via a stable `flowKey#runIndex:segIndex` open set, and an orphan
|
|
247
|
+
sweep drops any host whose predecessor is no longer its hidden, stamp-matching run (React can
|
|
248
|
+
remount the run underneath us). Disposal (HMR / plugin removal) removes all hosts and unhides
|
|
249
|
+
every run — the transcript hands back clean.
|
|
250
|
+
|
|
251
|
+
`tests/fold-smoke.test.js` exercises the real bundle against a dependency-free DOM shim:
|
|
252
|
+
fold → marker/card/prose structure, steering rows, non-fenced messages untouched, retitle
|
|
253
|
+
across locale switches with open-state kept, text edits re-fold and unfold, orphan sweep,
|
|
254
|
+
and dispose restoration.
|
|
255
|
+
|
|
256
|
+
**Bare fences are code (0.2.1).** Card type follows the composer round-trip convention: a
|
|
257
|
+
plain-text block always serializes as an explicit ` ```text ` fence while a code block whose
|
|
258
|
+
language was not detected serializes as a **bare** ` ``` ` fence — so only an explicit `text`
|
|
259
|
+
info string folds as a 文本块, and a bare fence folds as 代码块, re-guessing a language from
|
|
260
|
+
the body for the title (`python · 2 行`, else `代码块 · N 行`). Manually typed bare fences
|
|
261
|
+
follow the same rule; that is also the CommonMark reading. (0.2.0 shipped the inverse and a
|
|
262
|
+
sent 代码块 chip could reappear in the transcript as 文本块.)
|
|
263
|
+
|
|
264
|
+
## 13. Draft rebuild recovery (0.2.1)
|
|
265
|
+
|
|
266
|
+
**Typing-window guard and retired hygiene (0.2.2).** The recovery rewrite is a whole-
|
|
267
|
+
document `setDraft`: the caret drops at the end and an in-flight IME composition is
|
|
268
|
+
destroyed. Run in the middle of typing, that ate the user's first character and bounced
|
|
269
|
+
the view on every pass (reported). Two keystroke-time paths existed: sweeping a stray
|
|
270
|
+
U+200B that belonged to no restorable block (external invisible copy from WeChat / web
|
|
271
|
+
pages — the signature is NOT exclusive to rebuilds after all), and restoring blocks while
|
|
272
|
+
the draft had already moved past the re-seeded text. The hygiene path is retired outright:
|
|
273
|
+
external residue is left exactly where the user put it. Both paths now share one gate —
|
|
274
|
+
`recover()` only acts when the draft is IDENTICAL to the last draft observed for that
|
|
275
|
+
session in this page view (or was never observed: fresh boot, or the composer only ever
|
|
276
|
+
seen hydrating empty). A draft that MOVED between passes is live typing, never a rebuild.
|
|
277
|
+
`attach()` records the shell's post-insertion draft as that fingerprint immediately, and
|
|
278
|
+
the reconcile prune drops the mirror synchronously, so a hand-deleted block cannot be
|
|
279
|
+
resurrected from stale mirror data through the empty-prior window.
|
|
280
|
+
|
|
281
|
+
**Paste-storm guard (0.2.3).** Even with the typing-window guard deployed (load log
|
|
282
|
+
`client loaded v0.2.2` verified live), the console showed the SAME clipboard text (571
|
|
283
|
+
chars) intercepted dozens of times in seconds — an external automation (a Tampermonkey
|
|
284
|
+
AutoClicker was running on the page) re-dispatching paste. Each accepted paste inserts a
|
|
285
|
+
chip and re-renders the dock, so a storm reproduces the exact bounce-and-eat-the-first-
|
|
286
|
+
character experience through a different door. Identical clipboard text arriving
|
|
287
|
+
faster than a human can act collapses into one paste (`pasteStorm.seen`, 1200 ms
|
|
288
|
+
sliding window refreshed on every matching event, so a held repeat stays
|
|
289
|
+
suppressed while it continues); suppressed events are still `preventDefault`ed so
|
|
290
|
+
raw text never falls through to the composer. The plugin cannot suppress the
|
|
291
|
+
automation itself — users hitting this should disable AutoClicker-style userscripts
|
|
292
|
+
on the DSH page.
|
|
293
|
+
|
|
294
|
+
**Burst-only collapse (0.2.5).** 0.2.4 tried a content-liveness rule: while a block
|
|
295
|
+
with the same text was still attached, re-pasting identical text was consumed as
|
|
296
|
+
"not a new intent." That over-suppressed — pasting the same snippet twice (a
|
|
297
|
+
completely normal action) yielded a single block and confused users, and in the
|
|
298
|
+
end the reported bounces were traced to a *different* plugin entirely, not to
|
|
299
|
+
paste storms. So 0.2.5 reverts to a pure burst window: identical text pasted after
|
|
300
|
+
the 1200 ms window is a fresh intent and gets its own block (`sourceText` is
|
|
301
|
+
recorded per block and carried through the recovery mirror); only machine-paced
|
|
302
|
+
repeats *within* the window collapse. The `intercept` load log still carries
|
|
303
|
+
`trusted=` — `false` names a page-script dispatch (userscript glue, or the
|
|
304
|
+
`dsh-vision-router` image-paste replay, which carries the original text and lands
|
|
305
|
+
seconds after the trusted paste) rather than a real Ctrl+V, so any future storm
|
|
306
|
+
report identifies its own source.
|
|
307
|
+
|
|
308
|
+
Two DSH paths strand pasted blocks mid-draft, and both share one signature. (a) A page
|
|
309
|
+
reload re-seeds the composer from DSH's persisted per-session draft
|
|
310
|
+
(`dsh.conversation.<sid>`), which is **plain text** — Lexical chips cannot round-trip
|
|
311
|
+
through it (§7). (b) A workspace switch on the new-session page *carries the draft text
|
|
312
|
+
over* to the target workspace's blank session (`inputHub`: `next.setDraft(from.snapshot
|
|
313
|
+
.draft)`, then `from.setDraft("")`) — prose follows the composer, chips and controller
|
|
314
|
+
state do not, so blocks were silently orphaned under the old session id (user-reported).
|
|
315
|
+
Typed prose comes back either way; the blocks did not.
|
|
316
|
+
|
|
317
|
+
The signature — as 0.2.1 read it: every chip contributes exactly one U+200B to the clipboard
|
|
318
|
+
projection and nothing scrubs it from persisted/carried text (DSH's placeholder-stripping
|
|
319
|
+
regex covers private-use chars and U+FFFC, not ZWSPs). Stray U+200Bs with **no** live
|
|
320
|
+
occurrence of ours can therefore only mean the draft was rebuilt from text; conversely
|
|
321
|
+
deleting a chip — ✕, Backspace, or prune — removes its ZWSP together with the node, so
|
|
322
|
+
deliberate deletion never looks like a rebuild and cannot be "resurrected".
|
|
323
|
+
|
|
324
|
+
`BlockController.recover()` runs in the dock's reconcile effect, ahead of the reconcile,
|
|
325
|
+
and resolves blocks in this order: own-session memory → the session's own localStorage
|
|
326
|
+
mirror → **carry-over donor**: the unmounted session whose last published draft equals the
|
|
327
|
+
re-seeded text character for character (the mirror stores that same fingerprint, so the
|
|
328
|
+
donor is even found after a reload wiped memory) → nothing. An ambiguous fingerprint aborts
|
|
329
|
+
the donor step — no guessing. On a hit the mirror moves to the new sid. (0.2.1 also stripped
|
|
330
|
+
strays when nothing could be restored; the typing-window guard above retired that — see the
|
|
331
|
+
0.2.2 note.) The mirror holds just the block payloads plus that draft fingerprint, is written
|
|
332
|
+
synchronously on attach (and debounced on detail-card edits), drops on a send attempt, and
|
|
333
|
+
expires after two weeks. The recovery pass then *skips* its reconcile — the snapshot it
|
|
334
|
+
would reconcile against is stale; the draft mutation bumps `draftRev` and the next pass
|
|
335
|
+
reconciles real occurrences. Restored chips get re-derived labels and numbers, so a
|
|
336
|
+
restored draft is indistinguishable from one that never left. Mirror writes are best
|
|
337
|
+
effort: private mode / quota only lose reload durability, never the feature itself.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-paste-code-block",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Cherry Studio-style paste for DSH Web: pasted text/code becomes a bordered, collapsible, language-tagged card with localized names (中文/English) and a one-click remove button; restored as a fenced code block on send.",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Cherry Studio-style paste for DSH Web: pasted text/code becomes a bordered, collapsible, language-tagged card with localized names (中文/English) and a one-click remove button; restored as a fenced code block on send — and folded back into collapsed, expandable cards in the conversation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "DSH paste-code-block contributors",
|
|
7
7
|
"repository": {
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"node": ">=20"
|
|
86
86
|
},
|
|
87
87
|
"scripts": {
|
|
88
|
-
"check": "node --check src/index.js && node --check src/i18n.js && node --check src/chip-nodes.js && node --check src/projection.js && node --check src/client.js",
|
|
88
|
+
"check": "node --check src/index.js && node --check src/parse.js && node --check src/i18n.js && node --check src/chip-nodes.js && node --check src/projection.js && node --check src/client.js",
|
|
89
89
|
"test": "node --test 'tests/**/*.test.js'",
|
|
90
90
|
"render:images": "node docs/images/render.cjs"
|
|
91
91
|
}
|
package/src/client.js
CHANGED
|
@@ -109,6 +109,9 @@ window.__ModuleLoader__.load({
|
|
|
109
109
|
'edit.aria': '编辑代码块内容',
|
|
110
110
|
'error.stale': '代码块已失效,请重新粘贴',
|
|
111
111
|
'error.remove': '无法删除该块,请重试',
|
|
112
|
+
'fold.code': '代码块',
|
|
113
|
+
'fold.text': '文本块',
|
|
114
|
+
'fold.aria': '展开或折叠该块',
|
|
112
115
|
},
|
|
113
116
|
en: {
|
|
114
117
|
'block.code': 'Code #{n}',
|
|
@@ -126,6 +129,9 @@ window.__ModuleLoader__.load({
|
|
|
126
129
|
'edit.aria': 'Edit block content',
|
|
127
130
|
'error.stale': 'This block is no longer valid — paste it again',
|
|
128
131
|
'error.remove': 'Could not remove the block — please retry',
|
|
132
|
+
'fold.code': 'Code',
|
|
133
|
+
'fold.text': 'Text',
|
|
134
|
+
'fold.aria': 'Expand or collapse this block',
|
|
129
135
|
},
|
|
130
136
|
}
|
|
131
137
|
|
|
@@ -138,6 +144,13 @@ window.__ModuleLoader__.load({
|
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
// Sent-message fold card title (embedded copy of src/i18n.js#foldTitle).
|
|
148
|
+
function foldTitle(t, type, lang, lines) {
|
|
149
|
+
const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
|
|
150
|
+
const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
|
|
151
|
+
return `${name} · ${meta}`
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
function detectBrowserLocale() {
|
|
142
155
|
try {
|
|
143
156
|
const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
|
|
@@ -269,6 +282,134 @@ window.__ModuleLoader__.load({
|
|
|
269
282
|
return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
|
|
270
283
|
}
|
|
271
284
|
|
|
285
|
+
// ----- fence segmentation (embedded copy of src/parse.js) ----------------
|
|
286
|
+
// Split a message text into ordered prose/fence segments (GFM-style ``` or
|
|
287
|
+
// ~~~ fences, opener indented <=3 spaces, closer >= opener length alone on
|
|
288
|
+
// its line). Unterminated openers stay prose. Keep in sync with parse.js.
|
|
289
|
+
function splitFencedSegments(text) {
|
|
290
|
+
const src = String(text == null ? '' : text)
|
|
291
|
+
if (!src) return []
|
|
292
|
+
const lines = src.split(/\r?\n/)
|
|
293
|
+
const segments = []
|
|
294
|
+
const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
|
|
295
|
+
let proseStart = 0
|
|
296
|
+
let i = 0
|
|
297
|
+
const flushProse = (end) => {
|
|
298
|
+
if (end > proseStart) segments.push({ kind: 'prose', text: lines.slice(proseStart, end).join('\n') })
|
|
299
|
+
}
|
|
300
|
+
while (i < lines.length) {
|
|
301
|
+
const m = OPEN.exec(lines[i])
|
|
302
|
+
if (!m) { i += 1; continue }
|
|
303
|
+
const marker = m[1]
|
|
304
|
+
const ch = marker[0]
|
|
305
|
+
const info = m[2].trim()
|
|
306
|
+
if (ch === '`' && info.includes('`')) { i += 1; continue }
|
|
307
|
+
const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
|
|
308
|
+
let j = i + 1
|
|
309
|
+
let closed = false
|
|
310
|
+
while (j < lines.length) {
|
|
311
|
+
if (closeRe.test(lines[j])) { closed = true; break }
|
|
312
|
+
j += 1
|
|
313
|
+
}
|
|
314
|
+
if (!closed) { i += 1; continue }
|
|
315
|
+
flushProse(i)
|
|
316
|
+
const bodyLines = lines.slice(i + 1, j)
|
|
317
|
+
segments.push({
|
|
318
|
+
kind: 'fence',
|
|
319
|
+
lang: info ? info.split(/\s+/)[0] : '',
|
|
320
|
+
info,
|
|
321
|
+
body: bodyLines.join('\n'),
|
|
322
|
+
raw: lines.slice(i, j + 1).join('\n'),
|
|
323
|
+
})
|
|
324
|
+
i = j + 1
|
|
325
|
+
proseStart = i
|
|
326
|
+
}
|
|
327
|
+
flushProse(lines.length)
|
|
328
|
+
return segments
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ===== Recovery mirror (draft re-seed after workspace switch / reload) ===
|
|
332
|
+
// DSH persists a session's composer draft as PLAIN TEXT
|
|
333
|
+
// (`dsh.conversation.<sid>` in localStorage) and re-seeds the editor from
|
|
334
|
+
// it — but Lexical chip nodes cannot round-trip through text (design.md
|
|
335
|
+
// §7), so switching workspaces mid-draft left every pasted block behind:
|
|
336
|
+
// the seeded draft keeps only one invisible U+200B per lost chip. The
|
|
337
|
+
// plugin therefore mirrors its block CONTENT to localStorage itself and
|
|
338
|
+
// re-attaches the chips when it recognizes that stray-ZWSP signature
|
|
339
|
+
// (BlockDock mount effect → BlockController.recover). Intentional chip
|
|
340
|
+
// deletion removes the ZWSPs along with the nodes, so it never looks
|
|
341
|
+
// like a re-seed; the mirror clears itself on send and once no blocks
|
|
342
|
+
// remain, and stale entries expire after two weeks.
|
|
343
|
+
const RECOVERY_PREFIX = 'dsh-pcb-recovery.'
|
|
344
|
+
const RECOVERY_TTL_MS = 14 * 24 * 60 * 60 * 1000
|
|
345
|
+
const RECOVERY_MAX_CHARS = 3_000_000
|
|
346
|
+
const RECOVERY_MAX_DRAFT = 200_000
|
|
347
|
+
|
|
348
|
+
function recoverySave(sid, blocks, draftText) {
|
|
349
|
+
const key = RECOVERY_PREFIX + sid
|
|
350
|
+
try {
|
|
351
|
+
if (!blocks || blocks.length === 0) { localStorage.removeItem(key); return }
|
|
352
|
+
const draft = typeof draftText === 'string' && draftText.length <= RECOVERY_MAX_DRAFT ? draftText : ''
|
|
353
|
+
const json = JSON.stringify({ at: Date.now(), draft, blocks })
|
|
354
|
+
if (json.length > RECOVERY_MAX_CHARS) { console.warn('[dsh-pcb] draft too large to mirror; chips will not survive a reload'); return }
|
|
355
|
+
localStorage.setItem(key, json)
|
|
356
|
+
} catch (e) { /* quota / private mode: best effort */ }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function recoveryLoad(sid) {
|
|
360
|
+
const key = RECOVERY_PREFIX + sid
|
|
361
|
+
try {
|
|
362
|
+
const raw = localStorage.getItem(key)
|
|
363
|
+
if (!raw) return null
|
|
364
|
+
let data = null
|
|
365
|
+
try { data = JSON.parse(raw) } catch (e) { data = null }
|
|
366
|
+
if (!data || typeof data.at !== 'number' || Date.now() - data.at > RECOVERY_TTL_MS) { localStorage.removeItem(key); return null }
|
|
367
|
+
return data
|
|
368
|
+
} catch (e) { return null }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function recoveryClear(sid) {
|
|
372
|
+
try { localStorage.removeItem(RECOVERY_PREFIX + sid) } catch (e) { /* noop */ }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Mirror-side carry-over donor: a store whose mirrored draft matches the
|
|
377
|
+
* re-seeded one exactly and still holds blocks (after a page reload there
|
|
378
|
+
* is no live memory to match against). Returns { sid, blocks } on a
|
|
379
|
+
* UNIQUE match, null when absent or ambiguous.
|
|
380
|
+
*/
|
|
381
|
+
function recoveryScanDonor(targetSid, draft) {
|
|
382
|
+
try {
|
|
383
|
+
let hit = null
|
|
384
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
385
|
+
const key = localStorage.key(i)
|
|
386
|
+
if (!key || !key.startsWith(RECOVERY_PREFIX)) continue
|
|
387
|
+
const sid = key.slice(RECOVERY_PREFIX.length)
|
|
388
|
+
if (sid === targetSid) continue
|
|
389
|
+
const data = recoveryLoad(sid)
|
|
390
|
+
if (!data || data.draft !== draft || !Array.isArray(data.blocks)) continue
|
|
391
|
+
if (!data.blocks.some((b) => b && typeof b.content === 'string' && b.content !== '')) continue
|
|
392
|
+
if (hit) return null // ambiguous — refuse to guess
|
|
393
|
+
hit = { sid, blocks: data.blocks }
|
|
394
|
+
}
|
|
395
|
+
return hit
|
|
396
|
+
} catch (e) { return null }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function recoveryPruneExpired() {
|
|
400
|
+
try {
|
|
401
|
+
const doomed = []
|
|
402
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
403
|
+
const k = localStorage.key(i)
|
|
404
|
+
if (!k || !k.startsWith(RECOVERY_PREFIX)) continue
|
|
405
|
+
let at = 0
|
|
406
|
+
try { at = (JSON.parse(localStorage.getItem(k) || '{}').at) || 0 } catch (e) { /* fall through */ }
|
|
407
|
+
if (!at || Date.now() - at > RECOVERY_TTL_MS) doomed.push(k)
|
|
408
|
+
}
|
|
409
|
+
for (const k of doomed) localStorage.removeItem(k)
|
|
410
|
+
} catch (e) { /* noop */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
272
413
|
// ===== Draft controller ==================================================
|
|
273
414
|
class BlockController {
|
|
274
415
|
/**
|
|
@@ -289,6 +430,8 @@ window.__ModuleLoader__.load({
|
|
|
289
430
|
this.expiry = new Map()
|
|
290
431
|
this.serializing = new Set()
|
|
291
432
|
this.listeners = new Map()
|
|
433
|
+
this.recoverTimer = new Map() // sid -> debounce handle for the recovery mirror
|
|
434
|
+
this.lastInput = new Map() // sid -> draft text seen by the last recover() pass
|
|
292
435
|
this.counter = 0
|
|
293
436
|
}
|
|
294
437
|
|
|
@@ -445,6 +588,16 @@ window.__ModuleLoader__.load({
|
|
|
445
588
|
this.refIndex.set(block.id, { sessionId: k, block })
|
|
446
589
|
this.labelIndex.set(block.label, block.id)
|
|
447
590
|
this.publish(sessionId)
|
|
591
|
+
// The shell just rewrote the draft (chip + placeholder char); until
|
|
592
|
+
// the next dock pass runs, this is the session's ground truth. Keep
|
|
593
|
+
// the typing-window fingerprint and the carry-over donor match fresh
|
|
594
|
+
// so the pass right after a paste cannot mistake the new draft for a
|
|
595
|
+
// rebuild.
|
|
596
|
+
try {
|
|
597
|
+
const after = this.scope(k).shell.snapshot
|
|
598
|
+
if (after && typeof after.draft === 'string') this.lastInput.set(k, after.draft)
|
|
599
|
+
} catch (e) { /* session vanished — the next pass re-observes */ }
|
|
600
|
+
this.mirrorNow(k) // attach is a key moment — persist synchronously
|
|
448
601
|
return true
|
|
449
602
|
}
|
|
450
603
|
|
|
@@ -570,6 +723,7 @@ window.__ModuleLoader__.load({
|
|
|
570
723
|
for (const [label, r] of this.labelIndex) if (r === ref) this.labelIndex.delete(label)
|
|
571
724
|
this.freeLabel(sessionId, entry.type, entry.labelNumber)
|
|
572
725
|
this.publish(sessionId)
|
|
726
|
+
this.mirror(k)
|
|
573
727
|
}
|
|
574
728
|
|
|
575
729
|
/** Update a block's content while editing it in the detail card. */
|
|
@@ -578,23 +732,31 @@ window.__ModuleLoader__.load({
|
|
|
578
732
|
if (!record) return
|
|
579
733
|
record.block.content = content
|
|
580
734
|
record.block.lines = content.split(/\r?\n/)
|
|
735
|
+
this.mirror(record.sessionId)
|
|
581
736
|
}
|
|
582
737
|
|
|
583
738
|
clearInFlight(sessionId) {
|
|
584
739
|
const k = String(sessionId)
|
|
585
740
|
const expiry = this.expiry.get(k)
|
|
586
741
|
if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
|
|
742
|
+
const had = this.inFlight.has(k)
|
|
587
743
|
for (const b of this.inFlight.get(k) || []) {
|
|
588
744
|
this.refIndex.delete(b.id)
|
|
589
745
|
this.freeLabel(sessionId, b.type, b.labelNumber)
|
|
590
746
|
for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
|
|
591
747
|
}
|
|
592
748
|
this.inFlight.delete(k)
|
|
749
|
+
if (had) this.mirrorNow(k)
|
|
593
750
|
}
|
|
594
751
|
|
|
595
752
|
markSerializing(ref) {
|
|
596
753
|
const record = this.refIndex.get(ref)
|
|
597
|
-
if (record && !this.serializing.has(record.sessionId))
|
|
754
|
+
if (record && !this.serializing.has(record.sessionId)) {
|
|
755
|
+
this.serializing.add(record.sessionId)
|
|
756
|
+
// A send is starting: the content leaves the draft, the mirror may go.
|
|
757
|
+
// (A failed send re-mirrors via restoreFailed.)
|
|
758
|
+
recoveryClear(record.sessionId)
|
|
759
|
+
}
|
|
598
760
|
}
|
|
599
761
|
|
|
600
762
|
async serialize(ref) {
|
|
@@ -640,6 +802,12 @@ window.__ModuleLoader__.load({
|
|
|
640
802
|
if (next.length > 0) this.list.set(k, next)
|
|
641
803
|
else this.list.delete(k)
|
|
642
804
|
this.publish(sessionId)
|
|
805
|
+
// Hand-deletion is final. Persist the pruned state synchronously so a
|
|
806
|
+
// later reload/carry-over cannot resurrect a deleted chip from a
|
|
807
|
+
// mirror the draft has already outlived — and so the recovery
|
|
808
|
+
// restore-path can never fire off invisible external residue once the
|
|
809
|
+
// list for this session is empty.
|
|
810
|
+
this.mirrorNow(k)
|
|
643
811
|
}
|
|
644
812
|
|
|
645
813
|
restoreFailed(sessionId) {
|
|
@@ -661,10 +829,168 @@ window.__ModuleLoader__.load({
|
|
|
661
829
|
}
|
|
662
830
|
if (survived.length) this.list.set(k, [...this.listFor(k), ...survived])
|
|
663
831
|
this.publish(sessionId)
|
|
832
|
+
if (survived.length) this.mirror(k)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ----- recovery mirror -------------------------------------------------
|
|
836
|
+
/** Debounced persistence of the session's current block payloads. */
|
|
837
|
+
mirror(sessionId) {
|
|
838
|
+
const k = String(sessionId)
|
|
839
|
+
const prev = this.recoverTimer.get(k)
|
|
840
|
+
if (prev) clearTimeout(prev)
|
|
841
|
+
this.recoverTimer.set(k, setTimeout(() => {
|
|
842
|
+
this.recoverTimer.delete(k)
|
|
843
|
+
this.mirrorNow(k)
|
|
844
|
+
}, 250))
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
mirrorNow(sessionId) {
|
|
848
|
+
const k = String(sessionId)
|
|
849
|
+
const t = this.recoverTimer.get(k)
|
|
850
|
+
if (t) { clearTimeout(t); this.recoverTimer.delete(k) }
|
|
851
|
+
recoverySave(k, this.listFor(k).map((b) => ({ isCode: !!b.isCode, lang: b.lang || '', content: b.content, src: b.sourceText || '' })), this.lastInput.get(k))
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Restore the chips a text-only draft rebuild dropped. Two DSH paths
|
|
856
|
+
* strand them: (a) the persisted per-session draft re-seeds the composer
|
|
857
|
+
* after a page reload, and (b) a workspace switch on the new-session
|
|
858
|
+
* page CARRIES the draft plain-text to the target workspace's blank
|
|
859
|
+
* session (inputHub: next.setDraft(from.snapshot.draft)) — the chips,
|
|
860
|
+
* which are Lexical nodes plus controller state, never move. Both leave
|
|
861
|
+
* the same signature: stray U+200Bs (every chip contributes exactly one,
|
|
862
|
+
* and removing a chip removes its ZWSP too) with no live occurrence of
|
|
863
|
+
* ours. Deliberate deletion therefore never looks like a rebuild; and a
|
|
864
|
+
* carry-over is matched to its donor by the exact draft the donor last
|
|
865
|
+
* published — unique match only, no guessing. Blocks come from memory
|
|
866
|
+
* first (same tab), then this session's mirror, then the donor's memory
|
|
867
|
+
* or mirror. A stray U+200B that belongs to NO restorable block is
|
|
868
|
+
* ordinary invisible copy from an external app (WeChat, web pages):
|
|
869
|
+
* never restorable, cosmetic — it is left exactly where the user put
|
|
870
|
+
* it. Sweeping it used to rewrite the whole draft mid-typing and is
|
|
871
|
+
* retired in 0.2.2 (see the typing-window guard below).
|
|
872
|
+
*
|
|
873
|
+
* @param sessionId - session whose composer just (re)mounted.
|
|
874
|
+
* @param snapshot - live input state ({draft, occurrences, phase}).
|
|
875
|
+
* @returns whether the draft was rewritten by a real restore.
|
|
876
|
+
*/
|
|
877
|
+
recover(sessionId, snapshot) {
|
|
878
|
+
const k = String(sessionId)
|
|
879
|
+
if (!snapshot || typeof snapshot.draft !== 'string') return false
|
|
880
|
+
const prior = this.lastInput.get(k)
|
|
881
|
+
this.lastInput.set(k, snapshot.draft)
|
|
882
|
+
if (this.serializing.has(k)) return false
|
|
883
|
+
if (snapshot.phase && snapshot.phase !== 'plain') return false
|
|
884
|
+
if ((snapshot.occurrences || []).some((o) => o.source === SOURCE)) return false
|
|
885
|
+
const draft = snapshot.draft
|
|
886
|
+
const strays = (draft.match(/\u200B/g) || []).length
|
|
887
|
+
if (strays === 0) return false
|
|
888
|
+
// Typing-window guard. Every committed keystroke re-runs this pass, so
|
|
889
|
+
// a draft that MOVED since the pass before it is live typing, not a
|
|
890
|
+
// text-only re-seed — and a real restore rewrites the whole document
|
|
891
|
+
// via setDraft: caret to the end, chips re-appended, an in-flight IME
|
|
892
|
+
// composition destroyed (0.2.2 bug: the first character typed was
|
|
893
|
+
// eaten and the view bounced once, again and again). Only a draft
|
|
894
|
+
// IDENTICAL to the last one observed for this session — or one this
|
|
895
|
+
// page view has never seen, or only ever saw the composer hydrate
|
|
896
|
+
// through while empty (fresh-boot re-seed) — can carry the rebuild
|
|
897
|
+
// signature. The empty-prior escape is sealed from the other side by
|
|
898
|
+
// reconcile pruning synchronously dropping the mirror: with nothing
|
|
899
|
+
// restorable on record, a moved-from-empty pass restores nothing.
|
|
900
|
+
if (prior && prior !== draft) return false
|
|
901
|
+
let shell
|
|
902
|
+
try {
|
|
903
|
+
shell = this.scope(k).shell
|
|
904
|
+
} catch (e) { return false } // session not ready yet — a later pass retries
|
|
905
|
+
const usable = (arr) => Array.isArray(arr)
|
|
906
|
+
? arr.filter((b) => b && typeof b.content === 'string' && b.content !== '')
|
|
907
|
+
: []
|
|
908
|
+
let sources = usable(this.listFor(k))
|
|
909
|
+
let origin = sources.length ? 'memory' : null
|
|
910
|
+
let donor = null
|
|
911
|
+
if (!sources.length) {
|
|
912
|
+
const saved = recoveryLoad(k)
|
|
913
|
+
sources = usable(saved && saved.blocks)
|
|
914
|
+
if (sources.length) origin = 'mirror'
|
|
915
|
+
}
|
|
916
|
+
if (!sources.length) {
|
|
917
|
+
donor = this.findCarryoverSource(k, draft)
|
|
918
|
+
if (donor) {
|
|
919
|
+
sources = usable(this.listFor(donor))
|
|
920
|
+
origin = 'carry-over (live)'
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (!sources.length) {
|
|
924
|
+
const scanned = recoveryScanDonor(k, draft)
|
|
925
|
+
if (scanned && scanned.blocks.length) {
|
|
926
|
+
donor = scanned.sid
|
|
927
|
+
sources = usable(scanned.blocks)
|
|
928
|
+
origin = 'carry-over (mirror)'
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (sources.length === 0) {
|
|
932
|
+
// Nothing to restore — invisible external residue (or the residue of
|
|
933
|
+
// a deliberately deleted chip whose node already carries none of our
|
|
934
|
+
// state). NEVER rewrite the draft for it: rewriting drops the caret,
|
|
935
|
+
// destroys an in-flight IME composition and re-seeds stale text over
|
|
936
|
+
// whatever the user typed in the meantime (0.2.2).
|
|
937
|
+
return false
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
shell.setDraft(draft.replace(/\u200B/g, ''))
|
|
941
|
+
} catch (e) {
|
|
942
|
+
console.warn('[dsh-pcb] recovery: draft rewrite failed', e)
|
|
943
|
+
return false
|
|
944
|
+
}
|
|
945
|
+
// The draft — and with it the blocks — now belongs to this session:
|
|
946
|
+
// forget the (possibly moved) bookkeeping; attach() re-derives ids,
|
|
947
|
+
// labels and numbers, and re-mirrors under this sid.
|
|
948
|
+
this.forgetSession(k)
|
|
949
|
+
if (donor) {
|
|
950
|
+
this.forgetSession(donor)
|
|
951
|
+
recoveryClear(donor)
|
|
952
|
+
this.lastInput.delete(donor)
|
|
953
|
+
}
|
|
954
|
+
let restored = 0
|
|
955
|
+
for (const b of sources) {
|
|
956
|
+
const block = { lang: b.lang || '', content: b.content, lines: b.content.split(/\r?\n/), isCode: !!b.isCode, sourceText: b.sourceText || (typeof b.src === 'string' && b.src ? b.src : undefined) }
|
|
957
|
+
if (this.attach(k, block)) restored += 1
|
|
958
|
+
}
|
|
959
|
+
console.log('[dsh-pcb] recovery: re-attached', restored, 'of', sources.length, 'block(s) via', origin, donor ? `(donor ${donor})` : '')
|
|
960
|
+
return restored > 0
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Unmounted session that still lists blocks and last published EXACTLY this draft. */
|
|
964
|
+
findCarryoverSource(targetSid, draft) {
|
|
965
|
+
let found = null
|
|
966
|
+
for (const [x, entries] of this.list) {
|
|
967
|
+
if (x === targetSid || entries.length === 0) continue
|
|
968
|
+
if (this.serializing.has(x)) continue
|
|
969
|
+
const listeners = this.listeners.get(x)
|
|
970
|
+
if (listeners && listeners.size > 0) continue // still mounted — draft did not move
|
|
971
|
+
if (this.lastInput.get(x) !== draft) continue
|
|
972
|
+
if (found) return null // ambiguous
|
|
973
|
+
found = x
|
|
974
|
+
}
|
|
975
|
+
return found
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Drop a session's block bookkeeping (its chips are gone or moving). */
|
|
979
|
+
forgetSession(sessionId) {
|
|
980
|
+
const k = String(sessionId)
|
|
981
|
+
for (const b of this.listFor(k)) {
|
|
982
|
+
this.freeLabel(k, b.type, b.labelNumber)
|
|
983
|
+
this.refIndex.delete(b.id)
|
|
984
|
+
for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
|
|
985
|
+
}
|
|
986
|
+
this.list.delete(k)
|
|
987
|
+
this.selected.delete(k)
|
|
664
988
|
}
|
|
665
989
|
|
|
666
990
|
dispose() {
|
|
667
991
|
for (const t of this.expiry.values()) clearTimeout(t)
|
|
992
|
+
for (const t of this.recoverTimer.values()) clearTimeout(t)
|
|
993
|
+
this.recoverTimer.clear()
|
|
668
994
|
this.expiry.clear(); this.list.clear(); this.selected.clear()
|
|
669
995
|
this.used.clear(); this.refIndex.clear(); this.labelIndex.clear()
|
|
670
996
|
this.inFlight.clear(); this.serializing.clear(); this.listeners.clear()
|
|
@@ -727,6 +1053,12 @@ window.__ModuleLoader__.load({
|
|
|
727
1053
|
useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
|
|
728
1054
|
|
|
729
1055
|
React.useEffect(() => {
|
|
1056
|
+
// Recognize a text-only draft re-seed (workspace switch / reload)
|
|
1057
|
+
// FIRST: re-attach lost chips before reconciliation prunes them. A
|
|
1058
|
+
// successful recovery mutates the draft, so this pass must NOT
|
|
1059
|
+
// reconcile against its now-stale snapshot — the next draftRev pass
|
|
1060
|
+
// does that with fresh occurrences.
|
|
1061
|
+
if (props.controller.recover(props.sessionId, input)) return
|
|
730
1062
|
props.controller.reconcile(props.sessionId, input.occurrences || [], input.phase)
|
|
731
1063
|
}, [props.controller, props.sessionId, input.draftRev, input.phase])
|
|
732
1064
|
|
|
@@ -834,6 +1166,162 @@ window.__ModuleLoader__.load({
|
|
|
834
1166
|
)
|
|
835
1167
|
}
|
|
836
1168
|
|
|
1169
|
+
// ===== Sent-message fold =================================================
|
|
1170
|
+
// A block that folded in the composer comes back *after send* as fenced
|
|
1171
|
+
// text inside the user bubble (DSH renders user messages as plain
|
|
1172
|
+
// pre-wrap runs), so the wall of text this plugin exists to prevent
|
|
1173
|
+
// reappears in the transcript. scanFolds() (see apply) walks sent
|
|
1174
|
+
// bubbles and folds every complete fenced block into a collapsed card:
|
|
1175
|
+
// [</>] python · 128 行 [⎘] ▾ (header click toggles)
|
|
1176
|
+
// React owns the bubble's own children, so nothing is removed or
|
|
1177
|
+
// reordered: the plainRun span being folded is hidden (display:none) and
|
|
1178
|
+
// a host element we fully own is inserted directly after it. A content
|
|
1179
|
+
// stamp on the run keeps the scan idempotent and self-refreshing — text
|
|
1180
|
+
// or locale revision changes rebuild the host, and a fold that becomes
|
|
1181
|
+
// unnecessary is undone. The copy button copies the RAW fenced text
|
|
1182
|
+
// (exactly what was sent); the bubble's own copy/edit/delete bar is
|
|
1183
|
+
// React-side and never sees this DOM.
|
|
1184
|
+
const FOLD_HOST_CLASS = 'dsh-pcb-fold-host'
|
|
1185
|
+
const FOLD_CARD_CLASS = 'dsh-pcb-fold'
|
|
1186
|
+
const FOLD_RUN_ATTR = 'data-dsh-pcb-run' // '1|<stamp>' folded · '0|<stamp>' checked, plain
|
|
1187
|
+
const FOLD_STAMP_ATTR = 'data-dsh-pcb-stamp' // on the host, matches the run's stamp
|
|
1188
|
+
const FOLD_BUBBLE_SEL = [
|
|
1189
|
+
'[data-chat-flow-kind="user"]',
|
|
1190
|
+
'[data-chat-flow-kind="steering"]',
|
|
1191
|
+
'[data-submission-echo]',
|
|
1192
|
+
'[data-pending-steering]',
|
|
1193
|
+
].map((s) => `${s} [class*="bubble"]`).join(',')
|
|
1194
|
+
const foldOpenKeys = new Set() // user-toggled-open cards, stable across rebuilds
|
|
1195
|
+
let foldRev = 0 // bumped on locale switch so cards re-title
|
|
1196
|
+
|
|
1197
|
+
function hash32(str) {
|
|
1198
|
+
let h = 0x811c9dc5
|
|
1199
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
1200
|
+
h ^= str.charCodeAt(i)
|
|
1201
|
+
h = Math.imul(h, 0x01000193)
|
|
1202
|
+
}
|
|
1203
|
+
return (h >>> 0).toString(36)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function foldStamp(text) {
|
|
1207
|
+
return `${foldRev}:${text.length}:${hash32(text)}`
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Cheap pre-filter so the per-frame scan stays trivial on unfenced runs.
|
|
1211
|
+
const FENCE_HINT = /(^|\n) {0,3}(```|~~~)/
|
|
1212
|
+
|
|
1213
|
+
function isFoldRun(el) {
|
|
1214
|
+
return el.tagName === 'SPAN' && (el.getAttribute('class') || '').includes('plainRun')
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Segments when the text holds at least one non-empty complete fence, else null. */
|
|
1218
|
+
function foldPlanFor(text) {
|
|
1219
|
+
if (!FENCE_HINT.test(text)) return null
|
|
1220
|
+
const segments = splitFencedSegments(text)
|
|
1221
|
+
let folds = 0
|
|
1222
|
+
for (const seg of segments) if (seg.kind === 'fence' && seg.body.trim() !== '') folds += 1
|
|
1223
|
+
return folds > 0 ? segments : null
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function buildFoldCard(seg, openKey, t) {
|
|
1227
|
+
const infoLang = (seg.lang || '').toLowerCase()
|
|
1228
|
+
// Only an EXPLICIT ```text fence is a text block. The composer never
|
|
1229
|
+
// emits a bare fence for prose (plain blocks always serialize as
|
|
1230
|
+
// ```text), so a bare ``` fence is code whose language the composer
|
|
1231
|
+
// could not name — fold it as a code card and re-guess the language
|
|
1232
|
+
// for display. (0.2.0 bug: bare fences folded as 文本块, mismatching
|
|
1233
|
+
// the 代码块 chip the message was drafted from.)
|
|
1234
|
+
const isText = infoLang === 'text'
|
|
1235
|
+
const lang = isText ? '' : (infoLang || detectLang(seg.body) || '')
|
|
1236
|
+
const type = isText ? 'text' : 'code'
|
|
1237
|
+
const lines = seg.body.split('\n').length
|
|
1238
|
+
const open = foldOpenKeys.has(openKey)
|
|
1239
|
+
|
|
1240
|
+
const card = document.createElement('div')
|
|
1241
|
+
card.className = FOLD_CARD_CLASS
|
|
1242
|
+
card.setAttribute('data-pcb-type', type)
|
|
1243
|
+
card.setAttribute('data-open', open ? '1' : '0')
|
|
1244
|
+
|
|
1245
|
+
const head = document.createElement('div')
|
|
1246
|
+
head.className = 'dsh-pcb-fold-head'
|
|
1247
|
+
|
|
1248
|
+
const toggle = document.createElement('button')
|
|
1249
|
+
toggle.type = 'button'
|
|
1250
|
+
toggle.className = 'dsh-pcb-fold-toggle'
|
|
1251
|
+
toggle.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
1252
|
+
toggle.setAttribute('aria-label', t('fold.aria'))
|
|
1253
|
+
toggle.title = t(open ? 'collapse' : 'expand')
|
|
1254
|
+
const glyph = document.createElement('span')
|
|
1255
|
+
glyph.className = 'dsh-pcb-fold-glyph'
|
|
1256
|
+
glyph.setAttribute('aria-hidden', 'true')
|
|
1257
|
+
glyph.textContent = type === 'code' ? '</>' : 'Aa'
|
|
1258
|
+
const title = document.createElement('span')
|
|
1259
|
+
title.className = 'dsh-pcb-fold-title'
|
|
1260
|
+
title.textContent = foldTitle(t, type, lang, lines)
|
|
1261
|
+
const chevron = document.createElement('span')
|
|
1262
|
+
chevron.className = 'dsh-pcb-fold-chevron'
|
|
1263
|
+
chevron.setAttribute('aria-hidden', 'true')
|
|
1264
|
+
chevron.textContent = '▾'
|
|
1265
|
+
toggle.append(glyph, title, chevron)
|
|
1266
|
+
|
|
1267
|
+
const copy = document.createElement('button')
|
|
1268
|
+
copy.type = 'button'
|
|
1269
|
+
copy.className = 'dsh-pcb-btn dsh-pcb-fold-copy'
|
|
1270
|
+
copy.title = t('copy')
|
|
1271
|
+
copy.setAttribute('aria-label', t('copy'))
|
|
1272
|
+
copy.textContent = '⎘'
|
|
1273
|
+
let copyTimer = 0
|
|
1274
|
+
copy.addEventListener('click', (ev) => {
|
|
1275
|
+
ev.preventDefault()
|
|
1276
|
+
ev.stopPropagation()
|
|
1277
|
+
try {
|
|
1278
|
+
const done = navigator.clipboard?.writeText(seg.raw)
|
|
1279
|
+
if (done && typeof done.then === 'function') {
|
|
1280
|
+
done.then(() => {
|
|
1281
|
+
copy.textContent = '✓'
|
|
1282
|
+
clearTimeout(copyTimer)
|
|
1283
|
+
copyTimer = setTimeout(() => { copy.textContent = '⎘' }, 1200)
|
|
1284
|
+
}, () => {})
|
|
1285
|
+
}
|
|
1286
|
+
} catch (e) { /* noop */ }
|
|
1287
|
+
})
|
|
1288
|
+
|
|
1289
|
+
head.append(toggle, copy)
|
|
1290
|
+
const pre = document.createElement('pre')
|
|
1291
|
+
pre.className = 'dsh-pcb-fold-body'
|
|
1292
|
+
pre.textContent = seg.body
|
|
1293
|
+
card.append(head, pre)
|
|
1294
|
+
|
|
1295
|
+
toggle.addEventListener('click', (ev) => {
|
|
1296
|
+
ev.preventDefault()
|
|
1297
|
+
ev.stopPropagation()
|
|
1298
|
+
const nowOpen = card.getAttribute('data-open') !== '1'
|
|
1299
|
+
card.setAttribute('data-open', nowOpen ? '1' : '0')
|
|
1300
|
+
toggle.setAttribute('aria-expanded', nowOpen ? 'true' : 'false')
|
|
1301
|
+
toggle.title = t(nowOpen ? 'collapse' : 'expand')
|
|
1302
|
+
if (nowOpen) foldOpenKeys.add(openKey)
|
|
1303
|
+
else foldOpenKeys.delete(openKey)
|
|
1304
|
+
})
|
|
1305
|
+
return card
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function buildFoldHost(segments, keyBase, t) {
|
|
1309
|
+
const host = document.createElement('div')
|
|
1310
|
+
host.className = FOLD_HOST_CLASS
|
|
1311
|
+
segments.forEach((seg, i) => {
|
|
1312
|
+
if (seg.kind === 'fence' && seg.body.trim() !== '') {
|
|
1313
|
+
host.appendChild(buildFoldCard(seg, `${keyBase}:${i}`, t))
|
|
1314
|
+
} else {
|
|
1315
|
+
const prose = document.createElement('span')
|
|
1316
|
+
prose.className = 'dsh-pcb-fold-prose'
|
|
1317
|
+
// An un-foldable fence (empty body) keeps its raw text, fences and all.
|
|
1318
|
+
prose.textContent = seg.kind === 'fence' ? seg.raw : seg.text
|
|
1319
|
+
host.appendChild(prose)
|
|
1320
|
+
}
|
|
1321
|
+
})
|
|
1322
|
+
return host
|
|
1323
|
+
}
|
|
1324
|
+
|
|
837
1325
|
// ===== Styles ============================================================
|
|
838
1326
|
const CSS = `
|
|
839
1327
|
/* --- restyle DSH's inline reference chip into a boxed badge --- */
|
|
@@ -905,9 +1393,26 @@ window.__ModuleLoader__.load({
|
|
|
905
1393
|
/* --- hide the composer's own placeholder while chips/cards are active --- */
|
|
906
1394
|
[data-dsh-pcb-active="1"] [data-composer-placeholder]{visibility:hidden}
|
|
907
1395
|
|
|
1396
|
+
/* --- sent-message fold cards (conversation bubbles) --- */
|
|
1397
|
+
.dsh-pcb-fold-host{box-sizing:border-box;display:flex;flex-direction:column;gap:8px;width:100%;min-width:0}
|
|
1398
|
+
.dsh-pcb-fold-prose{white-space:pre-wrap;word-break:break-word}
|
|
1399
|
+
.dsh-pcb-fold{box-sizing:border-box;width:100%;min-width:0;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-1));box-shadow:var(--dsw-shadow-lv1);color:var(--dsw-alias-label-primary);overflow:hidden}
|
|
1400
|
+
.dsh-pcb-fold-head{box-sizing:border-box;min-height:34px;flex:none;display:flex;align-items:center;gap:4px;padding:0 6px 0 10px}
|
|
1401
|
+
.dsh-pcb-fold-toggle{flex:1;min-width:0;display:flex;align-items:center;gap:7px;padding:0;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
|
|
1402
|
+
.dsh-pcb-fold-glyph{flex:none;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;font-weight:700;line-height:16px;opacity:.9}
|
|
1403
|
+
.dsh-pcb-fold[data-pcb-type="code"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-state-business-primary,#4c6ef5)}
|
|
1404
|
+
.dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-label-tertiary)}
|
|
1405
|
+
.dsh-pcb-fold-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
1406
|
+
.dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-title{font-family:var(--dsw-font-family)}
|
|
1407
|
+
.dsh-pcb-fold-chevron{flex:none;width:14px;text-align:center;font-size:11px;line-height:1;color:var(--dsw-alias-label-tertiary);transition:transform .14s ease}
|
|
1408
|
+
.dsh-pcb-fold[data-open="1"] .dsh-pcb-fold-chevron{transform:rotate(180deg)}
|
|
1409
|
+
.dsh-pcb-fold[data-open="0"] .dsh-pcb-fold-body{display:none}
|
|
1410
|
+
.dsh-pcb-fold-body{box-sizing:border-box;margin:0;max-height:360px;overflow:auto;padding:8px 12px;border-top:1px solid var(--dsw-alias-border-l2);font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:1.5;white-space:pre;word-break:break-all;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
|
|
1411
|
+
|
|
908
1412
|
@media (max-width:640px){
|
|
909
1413
|
.dsh-pcb-head{height:34px}
|
|
910
1414
|
.dsh-pcb-lang{max-width:55%}
|
|
1415
|
+
.dsh-pcb-fold-body{max-height:240px}
|
|
911
1416
|
}
|
|
912
1417
|
@media (pointer:coarse){
|
|
913
1418
|
.${CHIP_X_CLASS}{width:22px;height:22px;font-size:15px}
|
|
@@ -918,7 +1423,9 @@ window.__ModuleLoader__.load({
|
|
|
918
1423
|
const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
|
|
919
1424
|
|
|
920
1425
|
function apply(ctx) {
|
|
921
|
-
|
|
1426
|
+
// Bump alongside package.json on every release — the load log is the
|
|
1427
|
+
// only proof of WHICH bundle generation the browser actually loaded.
|
|
1428
|
+
try { console.log('[dsh-paste-code-block] client loaded v0.2.5') } catch (e) { /* noop */ }
|
|
922
1429
|
|
|
923
1430
|
// Publish our dictionaries under the plugin namespace, then bind a
|
|
924
1431
|
// translator that always reflects the ACTIVE DSH language (Settings →
|
|
@@ -932,7 +1439,10 @@ window.__ModuleLoader__.load({
|
|
|
932
1439
|
: createT(detectBrowserLocale())
|
|
933
1440
|
|
|
934
1441
|
const controller = new BlockController(ctx, t)
|
|
1442
|
+
// Test seam (tests/fold-smoke.test.js drives recover() directly).
|
|
1443
|
+
exports.__controllerForTests = controller
|
|
935
1444
|
ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
|
|
1445
|
+
try { recoveryPruneExpired() } catch (e) { /* private mode: recovery simply off */ }
|
|
936
1446
|
|
|
937
1447
|
// ----- chip DOM sync: localized labels + per-chip ✕ delete button -----
|
|
938
1448
|
function scanChips() {
|
|
@@ -983,10 +1493,73 @@ window.__ModuleLoader__.load({
|
|
|
983
1493
|
}
|
|
984
1494
|
}
|
|
985
1495
|
|
|
1496
|
+
// ----- sent-message fold: the scan ------------------------------------
|
|
1497
|
+
function foldRun(run, plan, keyBase, stamp) {
|
|
1498
|
+
run.setAttribute(FOLD_RUN_ATTR, `1|${stamp}`)
|
|
1499
|
+
run.style.display = 'none'
|
|
1500
|
+
const next = run.nextElementSibling
|
|
1501
|
+
if (next && next.classList.contains(FOLD_HOST_CLASS)) {
|
|
1502
|
+
if (next.getAttribute(FOLD_STAMP_ATTR) === stamp) return // already current
|
|
1503
|
+
next.remove()
|
|
1504
|
+
}
|
|
1505
|
+
const host = buildFoldHost(plan, keyBase, t)
|
|
1506
|
+
host.setAttribute(FOLD_STAMP_ATTR, stamp)
|
|
1507
|
+
run.insertAdjacentElement('afterend', host)
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Restore a previously folded run to DSH's own rendering. */
|
|
1511
|
+
function unfoldRun(run, stamp) {
|
|
1512
|
+
const next = run.nextElementSibling
|
|
1513
|
+
if (next && next.classList.contains(FOLD_HOST_CLASS)) next.remove()
|
|
1514
|
+
run.style.display = ''
|
|
1515
|
+
run.setAttribute(FOLD_RUN_ATTR, `0|${stamp}`)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function scanFolds() {
|
|
1519
|
+
if (typeof document === 'undefined') return
|
|
1520
|
+
let bubbles
|
|
1521
|
+
try { bubbles = document.querySelectorAll(FOLD_BUBBLE_SEL) } catch (e) { return }
|
|
1522
|
+
for (const bubble of bubbles) {
|
|
1523
|
+
if (!bubble.isConnected) continue
|
|
1524
|
+
const holder = bubble.closest('[data-chat-flow-key]')
|
|
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
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
986
1559
|
let scanScheduled = 0
|
|
987
1560
|
function scheduleScan() {
|
|
988
1561
|
if (scanScheduled) return
|
|
989
|
-
const run = () => { scanScheduled = 0; scanChips() }
|
|
1562
|
+
const run = () => { scanScheduled = 0; scanChips(); scanFolds() }
|
|
990
1563
|
scanScheduled = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
|
|
991
1564
|
}
|
|
992
1565
|
|
|
@@ -998,13 +1571,28 @@ window.__ModuleLoader__.load({
|
|
|
998
1571
|
}, 'paste-code-block: chip sync observer')
|
|
999
1572
|
|
|
1000
1573
|
// A locale switch (or a late dictionary registration bumps the revision
|
|
1001
|
-
// too) re-derives labels: controller state first, chip DOM second.
|
|
1574
|
+
// too) re-derives labels: controller state first, chip DOM second. Sent
|
|
1575
|
+
// fold cards are rebuilt the same way — bumping foldRev invalidates
|
|
1576
|
+
// every run stamp, so the next scan re-titles the cards in the new
|
|
1577
|
+
// language while foldOpenKeys keeps who was expanded.
|
|
1002
1578
|
ctx.effect(() => {
|
|
1003
1579
|
const face = ctx.locale
|
|
1004
1580
|
if (!face || typeof face.subscribe !== 'function') return undefined
|
|
1005
|
-
return face.subscribe(() => { controller.retitleAll(); scheduleScan() })
|
|
1581
|
+
return face.subscribe(() => { controller.retitleAll(); foldRev += 1; scheduleScan() })
|
|
1006
1582
|
}, 'paste-code-block: locale sync')
|
|
1007
|
-
scheduleScan() // late-mount safety for chips rendered before plugin boot
|
|
1583
|
+
scheduleScan() // late-mount safety for chips and sent bubbles rendered before plugin boot
|
|
1584
|
+
|
|
1585
|
+
// On dispose (plugin removed / HMR swap) hand the transcript back to DSH.
|
|
1586
|
+
ctx.effect(() => () => {
|
|
1587
|
+
try {
|
|
1588
|
+
for (const host of document.querySelectorAll(`.${FOLD_HOST_CLASS}`)) host.remove()
|
|
1589
|
+
for (const run of document.querySelectorAll(`[${FOLD_RUN_ATTR}]`)) {
|
|
1590
|
+
run.style.display = ''
|
|
1591
|
+
run.removeAttribute(FOLD_RUN_ATTR)
|
|
1592
|
+
}
|
|
1593
|
+
} catch (e) { /* noop */ }
|
|
1594
|
+
foldOpenKeys.clear()
|
|
1595
|
+
}, 'paste-code-block: fold restore')
|
|
1008
1596
|
|
|
1009
1597
|
ctx.effect(() => ctx.inputTriggers.registerSource({
|
|
1010
1598
|
trigger: '@',
|
|
@@ -1026,6 +1614,34 @@ window.__ModuleLoader__.load({
|
|
|
1026
1614
|
return () => style.remove()
|
|
1027
1615
|
}, 'paste-code-block: styles')
|
|
1028
1616
|
|
|
1617
|
+
// Paste-storm guard. External automation — a Tampermonkey AutoClicker,
|
|
1618
|
+
// a clipboard bridge, IME glue, or a held-down Ctrl+V — can re-dispatch
|
|
1619
|
+
// the SAME clipboard text dozens of times a second. Each accepted paste
|
|
1620
|
+
// mints a new block, so a storm stuffs the composer with duplicate
|
|
1621
|
+
// chips. Collapse runs of an identical clipboard text that arrive faster
|
|
1622
|
+
// than a human can act into a single paste (a 1200 ms sliding window,
|
|
1623
|
+
// refreshed on every matching event, so a held repeat stays suppressed
|
|
1624
|
+
// while it continues). 0.2.4 added a content-liveness dedup that ALSO
|
|
1625
|
+
// suppressed a re-paste of the same text while its block was still
|
|
1626
|
+
// attached — that made "paste the same snippet twice" impossible, which
|
|
1627
|
+
// users hit immediately. 0.2.5 reverts that: identical text pasted after
|
|
1628
|
+
// the burst window is a fresh intent and gets its own block. Only
|
|
1629
|
+
// machine-paced repeats within the window are collapsed.
|
|
1630
|
+
const STORM_WINDOW_MS = 1200
|
|
1631
|
+
const pasteStorm = {
|
|
1632
|
+
lastText: '',
|
|
1633
|
+
lastAt: 0,
|
|
1634
|
+
// True when this text is the tail of a storm; every call refreshes
|
|
1635
|
+
// the window, so a held repeat stays suppressed while it continues.
|
|
1636
|
+
seen(text, now) {
|
|
1637
|
+
const dup = text === this.lastText && now - this.lastAt < STORM_WINDOW_MS
|
|
1638
|
+
this.lastText = text
|
|
1639
|
+
this.lastAt = now
|
|
1640
|
+
return dup
|
|
1641
|
+
},
|
|
1642
|
+
}
|
|
1643
|
+
exports.__pasteStormForTests = pasteStorm
|
|
1644
|
+
|
|
1029
1645
|
// Turn qualifying pastes into boxed inline chips.
|
|
1030
1646
|
const onPaste = (ev) => {
|
|
1031
1647
|
const clip = ev.clipboardData
|
|
@@ -1038,7 +1654,19 @@ window.__ModuleLoader__.load({
|
|
|
1038
1654
|
if (!sid) return
|
|
1039
1655
|
const block = parseBlock(text)
|
|
1040
1656
|
if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
|
|
1041
|
-
|
|
1657
|
+
if (pasteStorm.seen(text, Date.now())) {
|
|
1658
|
+
// Consume the event (never let the raw text fall through to the
|
|
1659
|
+
// composer) but create nothing.
|
|
1660
|
+
ev.preventDefault()
|
|
1661
|
+
ev.stopPropagation()
|
|
1662
|
+
console.log('[dsh-paste-code-block] storm paste ignored (<' + STORM_WINDOW_MS + 'ms repeat)')
|
|
1663
|
+
return
|
|
1664
|
+
}
|
|
1665
|
+
block.sourceText = text
|
|
1666
|
+
// trusted=false means a page script dispatched this paste (userscript,
|
|
1667
|
+
// clipboard bridge, replay glue) rather than a real keystroke — the
|
|
1668
|
+
// load-log line that names the source when a storm report comes in.
|
|
1669
|
+
console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang, 'trusted=', ev.isTrusted)
|
|
1042
1670
|
ev.preventDefault()
|
|
1043
1671
|
ev.stopPropagation()
|
|
1044
1672
|
try {
|
package/src/i18n.js
CHANGED
|
@@ -34,6 +34,9 @@ export const zh = {
|
|
|
34
34
|
'edit.aria': '编辑代码块内容',
|
|
35
35
|
'error.stale': '代码块已失效,请重新粘贴',
|
|
36
36
|
'error.remove': '无法删除该块,请重试',
|
|
37
|
+
'fold.code': '代码块',
|
|
38
|
+
'fold.text': '文本块',
|
|
39
|
+
'fold.aria': '展开或折叠该块',
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/** English dictionary, key-identical to the Chinese source of truth. */
|
|
@@ -53,6 +56,9 @@ export const en = {
|
|
|
53
56
|
'edit.aria': 'Edit block content',
|
|
54
57
|
'error.stale': 'This block is no longer valid — paste it again',
|
|
55
58
|
'error.remove': 'Could not remove the block — please retry',
|
|
59
|
+
'fold.code': 'Code',
|
|
60
|
+
'fold.text': 'Text',
|
|
61
|
+
'fold.aria': 'Expand or collapse this block',
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Shipped dictionaries keyed by locale id (mirrors DSH's built-in zh/en set). */
|
|
@@ -97,6 +103,19 @@ export function blockLabel(t, type, n) {
|
|
|
97
103
|
return t(type === 'code' ? 'block.code' : 'block.text', { n })
|
|
98
104
|
}
|
|
99
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Build the title of a SENT-message fold card: `python · 128 行` /
|
|
108
|
+
* `Code · 128 lines`. `lang` (the fence info string) names code cards when
|
|
109
|
+
* present; text cards and untagged code cards fall back to the localized
|
|
110
|
+
* type name. Line count always renders, so users can gauge size while the
|
|
111
|
+
* card is collapsed.
|
|
112
|
+
*/
|
|
113
|
+
export function foldTitle(t, type, lang, lines) {
|
|
114
|
+
const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
|
|
115
|
+
const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
|
|
116
|
+
return `${name} · ${meta}`
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
/**
|
|
101
120
|
* Recognize a rendered block label in ANY shipped locale (or a pre-0.1.3
|
|
102
121
|
* legacy locale) and recover its `{ type, n }` identity. This lets a chip
|
package/src/parse.js
CHANGED
|
@@ -99,4 +99,68 @@ export function countMarkdownSignals(text) {
|
|
|
99
99
|
export function serializeBlock(block) {
|
|
100
100
|
const lang = block.isCode ? (block.lang || '') : 'text'
|
|
101
101
|
return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Split a message text into ordered `prose` / `fence` segments using GFM-style
|
|
106
|
+
* fenced code blocks (``` or ~~~, opening fence indented at most 3 spaces,
|
|
107
|
+
* closing fence of >= the opening length and alone on its line).
|
|
108
|
+
*
|
|
109
|
+
* Used by the sent-message fold scanner (see design.md §12): a message the
|
|
110
|
+
* plugin serialized on send comes back as fenced text inside the user
|
|
111
|
+
* bubble, and this function locates the fences so each can be rendered as a
|
|
112
|
+
* collapsed card while the surrounding prose stays visible.
|
|
113
|
+
*
|
|
114
|
+
* Returns [] for empty input. Segments:
|
|
115
|
+
* - { kind: 'prose', text } — verbatim lines between fences
|
|
116
|
+
* - { kind: 'fence', lang, info, body, raw } — complete, terminated fence
|
|
117
|
+
* lang = first word of the info string ('' when absent)
|
|
118
|
+
* info = trimmed info string
|
|
119
|
+
* body = content between the fence lines (no fences, verbatim)
|
|
120
|
+
* raw = the whole slice including both fence lines
|
|
121
|
+
* An unterminated opening fence (no matching close) is kept in the prose run
|
|
122
|
+
* — conservative: we never fold text we are not sure ended.
|
|
123
|
+
*/
|
|
124
|
+
export function splitFencedSegments(text) {
|
|
125
|
+
const src = String(text == null ? '' : text)
|
|
126
|
+
if (!src) return []
|
|
127
|
+
const lines = src.split(/\r?\n/)
|
|
128
|
+
const segments = []
|
|
129
|
+
const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
|
|
130
|
+
let proseStart = 0
|
|
131
|
+
let i = 0
|
|
132
|
+
const flushProse = (end) => {
|
|
133
|
+
if (end > proseStart) segments.push({ kind: 'prose', text: lines.slice(proseStart, end).join('\n') })
|
|
134
|
+
}
|
|
135
|
+
while (i < lines.length) {
|
|
136
|
+
const m = OPEN.exec(lines[i])
|
|
137
|
+
if (!m) { i += 1; continue }
|
|
138
|
+
const marker = m[1]
|
|
139
|
+
const ch = marker[0]
|
|
140
|
+
const info = m[2].trim()
|
|
141
|
+
// A ``` fence's info string may not contain backticks (GFM); if it does,
|
|
142
|
+
// this line is ordinary prose, not an opener.
|
|
143
|
+
if (ch === '`' && info.includes('`')) { i += 1; continue }
|
|
144
|
+
const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
|
|
145
|
+
let j = i + 1
|
|
146
|
+
let closed = false
|
|
147
|
+
while (j < lines.length) {
|
|
148
|
+
if (closeRe.test(lines[j])) { closed = true; break }
|
|
149
|
+
j += 1
|
|
150
|
+
}
|
|
151
|
+
if (!closed) { i += 1; continue } // unterminated -> stays prose, keep scanning
|
|
152
|
+
flushProse(i)
|
|
153
|
+
const bodyLines = lines.slice(i + 1, j)
|
|
154
|
+
segments.push({
|
|
155
|
+
kind: 'fence',
|
|
156
|
+
lang: info ? info.split(/\s+/)[0] : '',
|
|
157
|
+
info,
|
|
158
|
+
body: bodyLines.join('\n'),
|
|
159
|
+
raw: lines.slice(i, j + 1).join('\n'),
|
|
160
|
+
})
|
|
161
|
+
i = j + 1
|
|
162
|
+
proseStart = i
|
|
163
|
+
}
|
|
164
|
+
flushProse(lines.length)
|
|
165
|
+
return segments
|
|
102
166
|
}
|