dsh-ide-git 0.13.3 → 0.13.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 CHANGED
@@ -13,6 +13,7 @@ DSH 自带的 Git 面板覆盖「暂存 / 提交 / 还原 / 看历史」;`dsh-id
13
13
  - **分支管理没有树**:内置面板只有一个分支下拉。这里给出 `HEAD(当前分支) / 本地 / 远程 / 标签` 的分组树,每个分支带 ahead/behind 角标、上游、worktree 占用标记(`W`)。
14
14
  - **历史只是列表**:这里画出提交图谱(lane 拓扑 + 分支/标签 ref 徽章),支持加载更多与过滤(文本 / 哈希 / 作者)。
15
15
  - **想看某个提交改了什么**:点提交直接进详情——作者、日期、完整提交信息、变更文件(含 +/− 行数),点文件看逐行 diff(红绿 + 行号 + hunk 高亮)。
16
+ - **想指给 AI 一段代码**:在 diff 窗里选中几行、右键,把「哪个文件哪几行」(附提交或工作区上下文)直接发进当前会话的输入框。
16
17
  - **想比较分支**:分支右键即可与当前分支比较,结果按文件与提交数汇报。
17
18
  - **操作要够全**:签出、从任意分支/提交新建分支、重命名、删除、合并到当前、变基当前到此、新建标签、cherry-pick、还原提交、重置(保留/丢弃)、fetch / pull --ff-only / push(确认后)。
18
19
 
@@ -56,6 +57,17 @@ DSH 自带的 Git 面板覆盖「暂存 / 提交 / 还原 / 看历史」;`dsh-id
56
57
  </tr>
57
58
  </table>
58
59
 
60
+ ## 右键发送到对话
61
+
62
+ 面板里的一切都能右键塞进**当前会话的输入框**——走 DSH 官方的 composer 插入通道(不动你已输入的文字,追加在末尾;发送中或服务不在时自动退化为复制到剪贴板并提示,绝不死点击):
63
+
64
+ - **提交** → `git 提交 47ad017「提交标题」(仓库 xxx)`:短哈希 + 提交信息 + 仓库,一段话指名一个修订;
65
+ - **分支** → `git 分支 main(仓库 xxx)`;
66
+ - **文件**(变更列表与提交详情的文件行)→ 一个 `@路径` 文件引用 chip(DSH 原生 @ 语法,含空格的路径自动加引号),或者只发文件路径;
67
+ - **diff 选中行**:在提交详情或工作区变更打开的 diff 窗里**选中一段文本再右键** → 「发送选中行到对话」:`@文件` chip + 行号说明——提交场景是「第 N–M 行(提交 47ad017「标题」@ 当前分支)」,工作区场景是「第 N–M 行(工作区已暂存 / 未提交)」。行号由选区所在的 diff 行按 hunk 头换算成**新文件行号**;选中的全是删除行时用旧号并标注「旧行号」。选区落不到可定位的 diff 行上时,菜单退化为「发送选中片段到对话」(原文截断 600 字符),不编造行号;菜单里同时有「复制选中引用」。没有选区时右键保持浏览器默认菜单。
68
+
69
+ 配套已经就位的几件事:变更文件行与详情文件行的行首图标按扩展名着色(DSH 自家 FileTypeIcon);diff 预览用 DSH 自家 DiffBlock 组件渲染(红绿底、复制 / 换行工具栏,视觉与宿主一致);diff 窗高度可拖拽调节并按位置记忆;整个面板还可以挂进**主对话区**——「对话 / 轨迹」旁的 Git 标签页,插件设置里可关。
70
+
59
71
  ## 挂在哪里:两个宿主,一个面板
60
72
 
61
73
  本插件只注册一次,落到哪个面上由**宿主**决定,两条通道互斥:
@@ -84,14 +96,15 @@ DSH 自带的 Git 面板覆盖「暂存 / 提交 / 还原 / 看历史」;`dsh-id
84
96
  | 动作条 | IDEA 式左侧竖排(底部工作台)/ 顶栏下一行横排(右侧栏):刷新、新建分支、签出、删除、比较、显示差异、贮藏、新建标签、收藏、抓取、拉取、推送。**按可用空间自动决定放几个**,放不下的收进 `⋯ 更多`;末尾 `⚙ 设置` 可拖动排序、逐项显隐(存 `dsh-ide-git.rail.v1`)。每个按钮按当前状态点亮/置灰(没有其他分支就不能删除/签出/比较,没有远程就不能抓取/推送,没有上游就不能拉取,没有变更就没有差异可看) |
85
97
  | 状态行 | 当前分支、上游、`↑ahead ↓behind`、stash 数量、忙碌指示 |
86
98
  | 分支树 | `HEAD / 本地 / 远程 / 标签` 分组(可折叠)、过滤框、星标当前分支、ahead/behind 角标、worktree 占用标记;本地蓝 / 远程紫 / 标签黄 / HEAD 绿 |
87
- | 分支右键 | 签出、将当前分支变基到此、合并到当前分支、与当前分支比较、从该分支新建分支、重命名、删除(当前分支禁用)、更新(Fetch)、推送 |
99
+ | 分支右键 | 签出、将当前分支变基到此、合并到当前分支、与当前分支比较、从该分支新建分支、重命名、删除(当前分支禁用)、更新(Fetch)、推送、发送到对话(分支名 + 仓库) |
88
100
  | 历史筛选 | 文本或哈希、分支或标签、提交人、日期(今天 / 近 7 天 / 近 30 天 / 今年),外加排序方向(新→旧 / 旧→新)与一键清除;路径筛选回车后由 git 服务端过滤(`git log -- <path>`) |
89
101
  | 提交列表 | 列序对齐 IDEA:**日期 → 提交人 → 图谱 → 分支标签 → 提交信息**;lane 拓扑图整格连线(相邻行像素对齐)、HEAD 空心点、refs 徽章(HEAD 绿 / 本地蓝 / 远程紫 / 标签黄,最多 3 个 + `+n`)、加载更多(每页 120) |
90
- | 提交右键 | 提交详情、复制修订号、签出该修订(确认)、在此新建分支、新建标签、cherry-pick、还原提交、重置到此(保留 / 丢弃更改) |
102
+ | 提交右键 | 提交详情、复制修订号、签出该修订(确认)、在此新建分支、新建标签、cherry-pick、还原提交、重置到此(保留 / 丢弃更改)、发送到对话(短哈希 + 提交信息 + 仓库) |
91
103
  | 提交详情 | 完整提交信息、作者/日期、变更文件列表(+/−),点文件看逐行 diff |
104
+ | diff 窗 | DSH 自家 DiffBlock 渲染(红绿底 + 复制/换行工具栏)、高度可拖拽调节并按位置记忆;**选中 diff 行右键**把行级引用发进对话——`@文件` + 第 N–M 行 + 提交(短哈希/标题/当前分支)或工作区(已暂存/未提交)上下文,删除行标注旧行号,选区定位不到行时发选中片段(截断 600 字符),另有「复制选中引用」 |
92
105
  | 变更列表 | 冲突 / 已暂存 / 更改 / 未跟踪 四组,行内 暂存 / 取消暂存 / 丢弃,分组级「全部暂存 / 全部取消暂存」,点击行看 diff |
93
106
  | 提交框 | 多行提交信息、`Ctrl+Enter`(macOS `Cmd+Enter`)提交、修补上次提交(`--amend`) |
94
- | 文件右键 | 显示差异、暂存/取消暂存、丢弃更改、复制路径 |
107
+ | 文件右键 | 显示差异、暂存/取消暂存、丢弃更改、复制路径、发送到对话(`@文件` 引用 chip 或文件路径) |
95
108
  | 安全与撤回 | 删除分支 / 删除贮藏后右下角弹提示条,点「撤回」即可恢复(真删除 + 按删除前记下的对象 id 重建,一次性、30 分钟内有效),顶栏的撤回图标里也能找到最近删除;`main`/`master`/`trunk` 的删除要求输入分支名确认,所有破坏性确认框焦点默认在「取消」;同一仓库的写操作在宿主串行执行;仓库里还有未结束的 merge / rebase / cherry-pick / revert / bisect 时,相关动作全部置灰并说明原因 |
96
109
 
97
110
  ## 安装
package/README_EN.md CHANGED
@@ -48,6 +48,17 @@ DSH's built-in Git panel covers stage / commit / revert / history. `dsh-ide-git`
48
48
  </tr>
49
49
  </table>
50
50
 
51
+ ## Send to chat
52
+
53
+ Everything in the panel can be right-clicked into the **current session's composer** — through DSH's official insertion channel (it appends at the tail without touching what you already typed; while sending, or when the service is absent, it falls back to copying the payload with a toast, never a dead click):
54
+
55
+ - **Commit** → `git commit 47ad017 "Subject" (repo xxx)`: short hash + message + repository, one sentence naming a revision;
56
+ - **Branch** → `git branch main (repo xxx)`;
57
+ - **File** (change rows and commit-detail file rows) → a `@path` file reference chip (DSH's native @ grammar, quoted paths included) or just the raw path;
58
+ - **Selected diff lines**: select some text in a diff window (opened from a commit detail or the working tree), right-click → **Send selected lines to chat**: an `@file` chip plus a line note — `lines N–M (commit 47ad017 "Subject" @ branch)` for a commit, `lines N–M (worktree, staged / uncommitted)` for the working tree. Numbers are reconstructed from the diff rows under the selection against the patch's hunk headers, in **new-file numbering**; a purely deleted span reports OLD numbers and says so. If the selection cannot be pinned to diff rows the menu degrades to **Send selected snippet to chat** (text truncated at 600 characters) instead of inventing numbers, and a **Copy the selection reference** item is always there. With no selection the browser's own menu stays.
59
+
60
+ Also in place: change-row and detail-file-row icons are tinted by extension (DSH's own FileTypeIcon); diff previews render through DSH's own DiffBlock (red/green rows, copy / wrap toolbar, host-consistent visuals); the diff window's height is draggable and remembered per placement; and the whole panel can live in the **main conversation area** — the Git tab next to Dialogue / Trace, switchable in the plugin settings.
61
+
51
62
  ## Where it mounts: two hosts, one panel
52
63
 
53
64
  The plugin registers once; which surface it lands on is the **host's** decision, and the two doors are exclusive:
@@ -74,13 +85,15 @@ The panel never guesses: it measures itself with a `ResizeObserver` (width ≥ 6
74
85
  - Action rail: the JetBrains-style vertical strip in the bottom workbench (and one horizontal row under the header in the right sidebar) with refresh, new branch, checkout, delete, compare, show diff, stash, new tag, favorite, fetch, pull and push. **It shows as many buttons as fit**, folds the rest into a `⋯ more` menu, and pins a `⚙ settings` button that lets you reorder actions and toggle each of them (stored in `dsh-ide-git.rail.v1`). Every action lights up or greys out with the current state (no other branch → delete / checkout / compare off, no remote → fetch / push off, no upstream → pull off, no changes → diff off).
75
86
  - Status line: branch, upstream, ahead/behind, stash count, busy indicator.
76
87
  - Branch tree: HEAD / Local / Remote / Tags groups, filter box, ahead/behind badges, worktree marker; local blue / remote violet / tag amber / HEAD green.
77
- - Branch context menu: checkout, rebase current onto this, merge into current, compare with current, new branch from here, rename, delete, fetch, push.
88
+ - Branch context menu: checkout, rebase current onto this, merge into current, compare with current, new branch from here, rename, delete, fetch, push, send to chat (branch + repo).
78
89
  - History filters: text or hash, branch or tag, author, date (today / 7 days / 30 days / this year), an order toggle (newest / oldest first) and one-click clear; the path filter runs server-side (`git log -- <path>`) on Enter.
79
90
  - Commit list: IDEA column order — **date → author → graph → refs → subject**; a lane graph whose segments span whole rows (pixel-aligned across rows), a hollow dot for HEAD, refs badges (HEAD green / local blue / remote violet / tag amber, at most three plus `+n`), load more (120 per page).
80
- - Commit context menu: details, copy revision, checkout revision, new branch here, new tag, cherry-pick, revert, reset here (keep / discard changes).
91
+ - Commit context menu: details, copy revision, checkout revision, new branch here, new tag, cherry-pick, revert, reset here (keep / discard changes), send to chat (short hash + message + repo).
81
92
  - Commit details: full message, author/date, changed files with +/− and per-file line diff.
93
+ - Diff pane: rendered with DSH's own DiffBlock (red/green rows plus a copy / wrap toolbar), draggable height remembered per placement; **select diff rows and right-click** to send a line-level reference into the conversation — an `@file` chip plus the line range with commit (hash / subject / current branch) or worktree (staged / uncommitted) context; deleted rows are labelled as old-numbered, selections that cannot be pinned to rows degrade to the raw snippet (truncated at 600 characters), and a copy-reference item is always available.
82
94
  - Changes: conflicts / staged / changes / untracked groups, inline stage / unstage / discard, group-level actions, click for diff.
83
95
  - Commit box: multi-line message, Ctrl+Enter (Cmd+Enter) to commit, amend.
96
+ - File context menu: show diff, stage / unstage, discard changes, copy path, send to chat (an `@file` reference chip or the raw path).
84
97
  - **Safety and undo**: deleting a branch or a stash pops a toast in the corner with an **Undo** action — the delete is real, and undo recreates the ref from the object id recorded just before it (one-shot, valid for 30 minutes). Deleting `main` / `master` / `trunk` requires typing the branch name, destructive dialogs focus Cancel instead of the red button, writes are serialised per repository on the host, and while a merge / rebase / cherry-pick / revert / bisect is still open the affected actions are greyed out with the reason.
85
98
 
86
99
  ## Install
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-ide-git",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "main": "./src/index.js",
5
5
  "description": "IDE 级 Git 操作面板:分支树 + 提交图谱 + 变更/暂存/提交 + 提交详情。装了 dsh-better-sidebar 时挂成它的原生 tab(右侧栏 + 底部工作台);没装时直接挂 DSH 原生右侧栏,单独安装即可用。JetBrains 风格 Git 工具窗口 for DeepSeek Harness.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ide-git",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "description": "DSH plugin: an IDE-grade Git tool window as a native dsh-better-sidebar tab — branch tree, commit graph, changes, commit details, JetBrains-style actions, in the right sidebar and the bottom panel. | DSH 插件:IDE 级 Git 工具窗口,以 dsh-better-sidebar 原生 Tab 挂载——分支树 / 提交图谱 / 变更与提交详情 / JetBrains 风格操作,右侧栏与底部面板双布局自适应。",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -154,6 +154,17 @@ window.__ModuleLoader__.load({
154
154
  'action.details': '提交详情',
155
155
  'menu.sendToChat': '发送到对话',
156
156
  'menu.sendFilePathToChat': '发送文件路径到对话',
157
+ 'send.commitText': 'git 提交 {hash}「{subject}」(仓库 {repo})',
158
+ 'send.branchText': 'git 分支 {name}(仓库 {repo})',
159
+ 'menu.sendDiffLinesToChat': '发送选中行到对话',
160
+ 'menu.sendDiffSnippetToChat': '发送选中片段到对话',
161
+ 'menu.copyDiffRef': '复制选中引用',
162
+ 'send.diffLinesCommit': '{lines}(提交 {hash}「{subject}」@ {branch})',
163
+ 'send.diffLinesWorktree': '{lines}(工作区{state})',
164
+ 'send.diffStaged': '已暂存',
165
+ 'send.diffUnstaged': '未提交',
166
+ 'send.diffNewLines': '第 {lines} 行',
167
+ 'send.diffOldLines': '第 {lines} 行(旧行号)',
157
168
  'history.empty': '这个仓库还没有提交',
158
169
  'history.loadMore': '加载更多',
159
170
  'history.filter': '文本或哈希',
@@ -354,6 +365,17 @@ window.__ModuleLoader__.load({
354
365
  'action.details': 'Commit details',
355
366
  'menu.sendToChat': 'Send to chat',
356
367
  'menu.sendFilePathToChat': 'Send file path to chat',
368
+ 'send.commitText': 'git commit {hash} "{subject}" (repo {repo})',
369
+ 'send.branchText': 'git branch {name} (repo {repo})',
370
+ 'menu.sendDiffLinesToChat': 'Send selected lines to chat',
371
+ 'menu.sendDiffSnippetToChat': 'Send selected snippet to chat',
372
+ 'menu.copyDiffRef': 'Copy the selection reference',
373
+ 'send.diffLinesCommit': '{lines} (commit {hash} "{subject}" @ {branch})',
374
+ 'send.diffLinesWorktree': '{lines} (worktree, {state})',
375
+ 'send.diffStaged': 'staged',
376
+ 'send.diffUnstaged': 'uncommitted',
377
+ 'send.diffNewLines': 'lines {lines}',
378
+ 'send.diffOldLines': 'lines {lines} (old numbering)',
357
379
  'history.empty': 'This repository has no commits yet',
358
380
  'history.loadMore': 'Load more',
359
381
  'history.filter': 'Text or hash',
@@ -562,6 +584,17 @@ window.__ModuleLoader__.load({
562
584
  'action.details': '提交詳情',
563
585
  'menu.sendToChat': '傳送到對話',
564
586
  'menu.sendFilePathToChat': '傳送檔案路徑至對話',
587
+ 'send.commitText': 'git 提交 {hash}「{subject}」(存放庫 {repo})',
588
+ 'send.branchText': 'git 分支 {name}(存放庫 {repo})',
589
+ 'menu.sendDiffLinesToChat': '傳送選中行到對話',
590
+ 'menu.sendDiffSnippetToChat': '傳送選中片段到對話',
591
+ 'menu.copyDiffRef': '複製選中引用',
592
+ 'send.diffLinesCommit': '{lines}(提交 {hash}「{subject}」@ {branch})',
593
+ 'send.diffLinesWorktree': '{lines}(工作區{state})',
594
+ 'send.diffStaged': '已暫存',
595
+ 'send.diffUnstaged': '未提交',
596
+ 'send.diffNewLines': '第 {lines} 行',
597
+ 'send.diffOldLines': '第 {lines} 行(舊行號)',
565
598
  'history.empty': '呢個存放庫仲未有提交',
566
599
  'history.loadMore': '載入更多',
567
600
  'history.filter': '文字或雜湊',
@@ -762,6 +795,17 @@ window.__ModuleLoader__.load({
762
795
  'action.details': '提交詳情',
763
796
  'menu.sendToChat': '傳送至對話',
764
797
  'menu.sendFilePathToChat': '傳送檔案路徑至對話',
798
+ 'send.commitText': 'git 提交 {hash}「{subject}」(版本庫 {repo})',
799
+ 'send.branchText': 'git 分支 {name}(版本庫 {repo})',
800
+ 'menu.sendDiffLinesToChat': '傳送選取行至對話',
801
+ 'menu.sendDiffSnippetToChat': '傳送選取片段至對話',
802
+ 'menu.copyDiffRef': '複製選取的引用',
803
+ 'send.diffLinesCommit': '{lines}(提交 {hash}「{subject}」@ {branch})',
804
+ 'send.diffLinesWorktree': '{lines}(工作區{state})',
805
+ 'send.diffStaged': '已暫存',
806
+ 'send.diffUnstaged': '未提交',
807
+ 'send.diffNewLines': '第 {lines} 行',
808
+ 'send.diffOldLines': '第 {lines} 行(舊行號)',
765
809
  'history.empty': '這個版本庫還沒有提交',
766
810
  'history.loadMore': '載入更多',
767
811
  'history.filter': '文字或雜湊',
@@ -962,6 +1006,17 @@ window.__ModuleLoader__.load({
962
1006
  'action.details': '提交詳情',
963
1007
  'menu.sendToChat': '傳送至對話',
964
1008
  'menu.sendFilePathToChat': '傳送檔案路徑至對話',
1009
+ 'send.commitText': 'git 提交 {hash}「{subject}」(存放庫 {repo})',
1010
+ 'send.branchText': 'git 分支 {name}(存放庫 {repo})',
1011
+ 'menu.sendDiffLinesToChat': '傳送選中行至對話',
1012
+ 'menu.sendDiffSnippetToChat': '傳送選中片段至對話',
1013
+ 'menu.copyDiffRef': '複製選中引用',
1014
+ 'send.diffLinesCommit': '{lines}(提交 {hash}「{subject}」@ {branch})',
1015
+ 'send.diffLinesWorktree': '{lines}(工作區{state})',
1016
+ 'send.diffStaged': '已暫存',
1017
+ 'send.diffUnstaged': '未提交',
1018
+ 'send.diffNewLines': '第 {lines} 行',
1019
+ 'send.diffOldLines': '第 {lines} 行(舊行號)',
965
1020
  'history.empty': '呢個存放庫仲未有提交',
966
1021
  'history.loadMore': '載入更多',
967
1022
  'history.filter': '文字或雜湊',
@@ -1162,6 +1217,17 @@ window.__ModuleLoader__.load({
1162
1217
  'action.details': 'コミットの詳細',
1163
1218
  'menu.sendToChat': 'チャットへ送る',
1164
1219
  'menu.sendFilePathToChat': 'ファイルパスをチャットへ送る',
1220
+ 'send.commitText': 'git コミット {hash}「{subject}」(リポジトリ {repo})',
1221
+ 'send.branchText': 'git ブランチ {name}(リポジトリ {repo})',
1222
+ 'menu.sendDiffLinesToChat': '選択した行をチャットへ送る',
1223
+ 'menu.sendDiffSnippetToChat': '選択した範囲をチャットへ送る',
1224
+ 'menu.copyDiffRef': '選択範囲の参照をコピー',
1225
+ 'send.diffLinesCommit': '{lines}(コミット {hash}「{subject}」@ {branch})',
1226
+ 'send.diffLinesWorktree': '{lines}(作業ツリー、{state})',
1227
+ 'send.diffStaged': 'ステージ済み',
1228
+ 'send.diffUnstaged': '未コミット',
1229
+ 'send.diffNewLines': '{lines} 行目',
1230
+ 'send.diffOldLines': '{lines} 行目(旧番号)',
1165
1231
  'history.empty': 'このリポジトリにはまだコミットがありません',
1166
1232
  'history.loadMore': 'さらに読み込む',
1167
1233
  'history.filter': 'テキストまたはハッシュ',
@@ -1362,6 +1428,17 @@ window.__ModuleLoader__.load({
1362
1428
  'action.details': '커밋 상세',
1363
1429
  'menu.sendToChat': '대화로 보내기',
1364
1430
  'menu.sendFilePathToChat': '파일 경로를 대화로 보내기',
1431
+ 'send.commitText': 'git 커밋 {hash} "{subject}" (저장소 {repo})',
1432
+ 'send.branchText': 'git 브랜치 {name} (저장소 {repo})',
1433
+ 'menu.sendDiffLinesToChat': '선택한 줄 대화로 보내기',
1434
+ 'menu.sendDiffSnippetToChat': '선택한 조각 대화로 보내기',
1435
+ 'menu.copyDiffRef': '선택 범위 참조 복사',
1436
+ 'send.diffLinesCommit': '{lines}(커밋 {hash} "{subject}" @ {branch})',
1437
+ 'send.diffLinesWorktree': '{lines}(작업 트리, {state})',
1438
+ 'send.diffStaged': '스테이징됨',
1439
+ 'send.diffUnstaged': '커밋되지 않음',
1440
+ 'send.diffNewLines': '{lines}행',
1441
+ 'send.diffOldLines': '{lines}행(이전 번호)',
1365
1442
  'history.empty': '이 저장소에는 아직 커밋이 없습니다',
1366
1443
  'history.loadMore': '더 불러오기',
1367
1444
  'history.filter': '텍스트 또는 해시',
@@ -1562,6 +1639,17 @@ window.__ModuleLoader__.load({
1562
1639
  'action.details': 'Commit-Details',
1563
1640
  'menu.sendToChat': 'Im Chat senden',
1564
1641
  'menu.sendFilePathToChat': 'Dateipfad im Chat senden',
1642
+ 'send.commitText': 'git-Commit {hash} "{subject}" (Repository {repo})',
1643
+ 'send.branchText': 'git-Branch {name} (Repository {repo})',
1644
+ 'menu.sendDiffLinesToChat': 'Ausgewählte Zeilen in den Chat senden',
1645
+ 'menu.sendDiffSnippetToChat': 'Ausgewählten Ausschnitt in den Chat senden',
1646
+ 'menu.copyDiffRef': 'Auswahlbezug kopieren',
1647
+ 'send.diffLinesCommit': '{lines} (Commit {hash} "{subject}" @ {branch})',
1648
+ 'send.diffLinesWorktree': '{lines} (Arbeitsbaum, {state})',
1649
+ 'send.diffStaged': 'staged',
1650
+ 'send.diffUnstaged': 'nicht committet',
1651
+ 'send.diffNewLines': 'Zeilen {lines}',
1652
+ 'send.diffOldLines': 'Zeilen {lines} (alte Nummerierung)',
1565
1653
  'history.empty': 'Dieses Repository hat noch keine Commits',
1566
1654
  'history.loadMore': 'Mehr laden',
1567
1655
  'history.filter': 'Text oder Hash',
@@ -1762,6 +1850,17 @@ window.__ModuleLoader__.load({
1762
1850
  'action.details': 'Détails du commit',
1763
1851
  'menu.sendToChat': 'Envoyer dans la conversation',
1764
1852
  'menu.sendFilePathToChat': 'Envoyer le chemin du fichier dans la conversation',
1853
+ 'send.commitText': 'commit git {hash} « {subject} » (dépôt {repo})',
1854
+ 'send.branchText': 'branche git {name} (dépôt {repo})',
1855
+ 'menu.sendDiffLinesToChat': 'Envoyer les lignes sélectionnées dans la conversation',
1856
+ 'menu.sendDiffSnippetToChat': 'Envoyer l\'extrait sélectionné dans la conversation',
1857
+ 'menu.copyDiffRef': 'Copier la référence de la sélection',
1858
+ 'send.diffLinesCommit': '{lines} (commit {hash} « {subject} » @ {branch})',
1859
+ 'send.diffLinesWorktree': '{lines} (arbre de travail, {state})',
1860
+ 'send.diffStaged': 'indexé',
1861
+ 'send.diffUnstaged': 'non commité',
1862
+ 'send.diffNewLines': 'lignes {lines}',
1863
+ 'send.diffOldLines': 'lignes {lines} (ancienne numérotation)',
1765
1864
  'history.empty': 'Ce dépôt n\'a pas encore de commit',
1766
1865
  'history.loadMore': 'Charger plus',
1767
1866
  'history.filter': 'Texte ou empreinte',
@@ -1962,6 +2061,17 @@ window.__ModuleLoader__.load({
1962
2061
  'action.details': 'Сведения о коммите',
1963
2062
  'menu.sendToChat': 'Отправить в чат',
1964
2063
  'menu.sendFilePathToChat': 'Отправить путь к файлу в чат',
2064
+ 'send.commitText': 'git-коммит {hash} «{subject}» (репозиторий {repo})',
2065
+ 'send.branchText': 'git-ветка {name} (репозиторий {repo})',
2066
+ 'menu.sendDiffLinesToChat': 'Отправить выбранные строки в чат',
2067
+ 'menu.sendDiffSnippetToChat': 'Отправить выбранный фрагмент в чат',
2068
+ 'menu.copyDiffRef': 'Скопировать ссылку на выделение',
2069
+ 'send.diffLinesCommit': '{lines} (коммит {hash} «{subject}» @ {branch})',
2070
+ 'send.diffLinesWorktree': '{lines} (рабочее дерево, {state})',
2071
+ 'send.diffStaged': 'в индексе',
2072
+ 'send.diffUnstaged': 'не закоммичено',
2073
+ 'send.diffNewLines': 'строки {lines}',
2074
+ 'send.diffOldLines': 'строки {lines} (старая нумерация)',
1965
2075
  'history.empty': 'В этом репозитории ещё нет коммитов',
1966
2076
  'history.loadMore': 'Загрузить ещё',
1967
2077
  'history.filter': 'Текст или хеш',
@@ -2162,6 +2272,17 @@ window.__ModuleLoader__.load({
2162
2272
  'action.details': 'Detalhes do commit',
2163
2273
  'menu.sendToChat': 'Enviar para a conversa',
2164
2274
  'menu.sendFilePathToChat': 'Enviar caminho do arquivo para a conversa',
2275
+ 'send.commitText': 'commit git {hash} "{subject}" (repositório {repo})',
2276
+ 'send.branchText': 'branch git {name} (repositório {repo})',
2277
+ 'menu.sendDiffLinesToChat': 'Enviar linhas selecionadas para a conversa',
2278
+ 'menu.sendDiffSnippetToChat': 'Enviar trecho selecionado para a conversa',
2279
+ 'menu.copyDiffRef': 'Copiar a referência da seleção',
2280
+ 'send.diffLinesCommit': '{lines} (commit {hash} "{subject}" @ {branch})',
2281
+ 'send.diffLinesWorktree': '{lines} (árvore de trabalho, {state})',
2282
+ 'send.diffStaged': 'preparado',
2283
+ 'send.diffUnstaged': 'não commitado',
2284
+ 'send.diffNewLines': 'linhas {lines}',
2285
+ 'send.diffOldLines': 'linhas {lines} (numeração antiga)',
2165
2286
  'history.empty': 'Este repositório ainda não tem commits',
2166
2287
  'history.loadMore': 'Carregar mais',
2167
2288
  'history.filter': 'Texto ou hash',
@@ -2362,6 +2483,17 @@ window.__ModuleLoader__.load({
2362
2483
  'action.details': 'Dettagli del commit',
2363
2484
  'menu.sendToChat': 'Invia alla conversazione',
2364
2485
  'menu.sendFilePathToChat': 'Invia il percorso del file alla conversazione',
2486
+ 'send.commitText': 'commit git {hash} "{subject}" (repository {repo})',
2487
+ 'send.branchText': 'branch git {name} (repository {repo})',
2488
+ 'menu.sendDiffLinesToChat': 'Invia le righe selezionate alla conversazione',
2489
+ 'menu.sendDiffSnippetToChat': 'Invia il frammento selezionato alla conversazione',
2490
+ 'menu.copyDiffRef': 'Copia il riferimento alla selezione',
2491
+ 'send.diffLinesCommit': '{lines} (commit {hash} "{subject}" @ {branch})',
2492
+ 'send.diffLinesWorktree': '{lines} (albero di lavoro, {state})',
2493
+ 'send.diffStaged': 'in stage',
2494
+ 'send.diffUnstaged': 'senza commit',
2495
+ 'send.diffNewLines': 'righe {lines}',
2496
+ 'send.diffOldLines': 'righe {lines} (numerazione precedente)',
2365
2497
  'history.empty': 'Questo repository non ha ancora commit',
2366
2498
  'history.loadMore': 'Carica altro',
2367
2499
  'history.filter': 'Testo o hash',
@@ -2562,6 +2694,17 @@ window.__ModuleLoader__.load({
2562
2694
  'action.details': 'Commit-details',
2563
2695
  'menu.sendToChat': 'Naar het gesprek verzenden',
2564
2696
  'menu.sendFilePathToChat': 'Bestandspad naar het gesprek verzenden',
2697
+ 'send.commitText': 'git-commit {hash} "{subject}" (repository {repo})',
2698
+ 'send.branchText': 'git-branch {name} (repository {repo})',
2699
+ 'menu.sendDiffLinesToChat': 'Geselecteerde regels naar het gesprek verzenden',
2700
+ 'menu.sendDiffSnippetToChat': 'Geselecteerd fragment naar het gesprek verzenden',
2701
+ 'menu.copyDiffRef': 'Selectiereferentie kopiëren',
2702
+ 'send.diffLinesCommit': '{lines} (commit {hash} "{subject}" @ {branch})',
2703
+ 'send.diffLinesWorktree': '{lines} (werkboom, {state})',
2704
+ 'send.diffStaged': 'staged',
2705
+ 'send.diffUnstaged': 'niet vastgelegd',
2706
+ 'send.diffNewLines': 'regels {lines}',
2707
+ 'send.diffOldLines': 'regels {lines} (oude nummering)',
2565
2708
  'history.empty': 'Deze repository heeft nog geen commits',
2566
2709
  'history.loadMore': 'Meer laden',
2567
2710
  'history.filter': 'Tekst of hash',
@@ -2762,6 +2905,17 @@ window.__ModuleLoader__.load({
2762
2905
  'action.details': 'Szczegóły commita',
2763
2906
  'menu.sendToChat': 'Wyślij do rozmowy',
2764
2907
  'menu.sendFilePathToChat': 'Wyślij ścieżkę pliku do rozmowy',
2908
+ 'send.commitText': 'commit git {hash} „{subject}" (repozytorium {repo})',
2909
+ 'send.branchText': 'gałąź git {name} (repozytorium {repo})',
2910
+ 'menu.sendDiffLinesToChat': 'Wyślij zaznaczone wiersze do rozmowy',
2911
+ 'menu.sendDiffSnippetToChat': 'Wyślij zaznaczony fragment do rozmowy',
2912
+ 'menu.copyDiffRef': 'Skopiuj odwołanie do zaznaczenia',
2913
+ 'send.diffLinesCommit': '{lines} (commit {hash} „{subject}" @ {branch})',
2914
+ 'send.diffLinesWorktree': '{lines} (drzewo robocze, {state})',
2915
+ 'send.diffStaged': 'przygotowane',
2916
+ 'send.diffUnstaged': 'niezacommitowane',
2917
+ 'send.diffNewLines': 'wiersze {lines}',
2918
+ 'send.diffOldLines': 'wiersze {lines} (stara numeracja)',
2765
2919
  'history.empty': 'To repozytorium nie ma jeszcze commitów',
2766
2920
  'history.loadMore': 'Wczytaj więcej',
2767
2921
  'history.filter': 'Tekst lub hash',
@@ -2962,6 +3116,17 @@ window.__ModuleLoader__.load({
2962
3116
  'action.details': 'Commit-detaljer',
2963
3117
  'menu.sendToChat': 'Skicka till samtalet',
2964
3118
  'menu.sendFilePathToChat': 'Skicka filsökvägen till samtalet',
3119
+ 'send.commitText': 'git-commit {hash} ”{subject}” (arkiv {repo})',
3120
+ 'send.branchText': 'git-gren {name} (arkiv {repo})',
3121
+ 'menu.sendDiffLinesToChat': 'Skicka markerade rader till samtalet',
3122
+ 'menu.sendDiffSnippetToChat': 'Skicka markerat utdrag till samtalet',
3123
+ 'menu.copyDiffRef': 'Kopiera markeringens referens',
3124
+ 'send.diffLinesCommit': '{lines} (incheckning {hash} ”{subject}” @ {branch})',
3125
+ 'send.diffLinesWorktree': '{lines} (arbetskopia, {state})',
3126
+ 'send.diffStaged': 'staged',
3127
+ 'send.diffUnstaged': 'ej incheckad',
3128
+ 'send.diffNewLines': 'rader {lines}',
3129
+ 'send.diffOldLines': 'rader {lines} (gammal numrering)',
2965
3130
  'history.empty': 'Det här arkivet har inga commits ännu',
2966
3131
  'history.loadMore': 'Ladda mer',
2967
3132
  'history.filter': 'Text eller hash',
@@ -3162,6 +3327,17 @@ window.__ModuleLoader__.load({
3162
3327
  'action.details': 'Commit ayrıntıları',
3163
3328
  'menu.sendToChat': 'Sohbete gönder',
3164
3329
  'menu.sendFilePathToChat': 'Dosya yolunu sohbete gönder',
3330
+ 'send.commitText': 'git commit {hash} "{subject}" (depo {repo})',
3331
+ 'send.branchText': 'git dalı {name} (depo {repo})',
3332
+ 'menu.sendDiffLinesToChat': 'Seçili satırları sohbete gönder',
3333
+ 'menu.sendDiffSnippetToChat': 'Seçili parçayı sohbete gönder',
3334
+ 'menu.copyDiffRef': 'Seçim referansını kopyala',
3335
+ 'send.diffLinesCommit': '{lines} (işleme {hash} "{subject}" @ {branch})',
3336
+ 'send.diffLinesWorktree': '{lines} (çalışma ağacı, {state})',
3337
+ 'send.diffStaged': 'hazırlanmış',
3338
+ 'send.diffUnstaged': 'işlenmemiş',
3339
+ 'send.diffNewLines': 'satırlar {lines}',
3340
+ 'send.diffOldLines': 'satırlar {lines} (eski numaralandırma)',
3165
3341
  'history.empty': 'Bu depoda henüz commit yok',
3166
3342
  'history.loadMore': 'Daha fazla yükle',
3167
3343
  'history.filter': 'Metin veya hash',
@@ -3362,6 +3538,17 @@ window.__ModuleLoader__.load({
3362
3538
  'action.details': 'Detail commit',
3363
3539
  'menu.sendToChat': 'Kirim ke obrolan',
3364
3540
  'menu.sendFilePathToChat': 'Kirim jalur berkas ke obrolan',
3541
+ 'send.commitText': 'commit git {hash} "{subject}" (repositori {repo})',
3542
+ 'send.branchText': 'cabang git {name} (repositori {repo})',
3543
+ 'menu.sendDiffLinesToChat': 'Kirim baris terpilih ke obrolan',
3544
+ 'menu.sendDiffSnippetToChat': 'Kirim cuplikan terpilih ke obrolan',
3545
+ 'menu.copyDiffRef': 'Salin referensi pilihan',
3546
+ 'send.diffLinesCommit': '{lines} (commit {hash} "{subject}" @ {branch})',
3547
+ 'send.diffLinesWorktree': '{lines} (pohon kerja, {state})',
3548
+ 'send.diffStaged': 'di-stage',
3549
+ 'send.diffUnstaged': 'belum di-commit',
3550
+ 'send.diffNewLines': 'baris {lines}',
3551
+ 'send.diffOldLines': 'baris {lines} (penomoran lama)',
3365
3552
  'history.empty': 'Repositori ini belum punya commit',
3366
3553
  'history.loadMore': 'Muat lebih banyak',
3367
3554
  'history.filter': 'Teks atau hash',
@@ -3562,6 +3749,17 @@ window.__ModuleLoader__.load({
3562
3749
  'action.details': 'Chi tiết commit',
3563
3750
  'menu.sendToChat': 'Gửi vào hội thoại',
3564
3751
  'menu.sendFilePathToChat': 'Gửi đường dẫn tệp vào hội thoại',
3752
+ 'send.commitText': 'git commit {hash} "{subject}" (kho {repo})',
3753
+ 'send.branchText': 'nhánh git {name} (kho {repo})',
3754
+ 'menu.sendDiffLinesToChat': 'Gửi các dòng đã chọn vào hội thoại',
3755
+ 'menu.sendDiffSnippetToChat': 'Gửi đoạn đã chọn vào hội thoại',
3756
+ 'menu.copyDiffRef': 'Sao chép tham chiếu vùng chọn',
3757
+ 'send.diffLinesCommit': '{lines} (commit {hash} "{subject}" @ {branch})',
3758
+ 'send.diffLinesWorktree': '{lines} (cây làm việc, {state})',
3759
+ 'send.diffStaged': 'đã staged',
3760
+ 'send.diffUnstaged': 'chưa commit',
3761
+ 'send.diffNewLines': 'dòng {lines}',
3762
+ 'send.diffOldLines': 'dòng {lines} (đánh số cũ)',
3565
3763
  'history.empty': 'Kho này chưa có commit nào',
3566
3764
  'history.loadMore': 'Tải thêm',
3567
3765
  'history.filter': 'Văn bản hoặc hash',
@@ -3762,6 +3960,17 @@ window.__ModuleLoader__.load({
3762
3960
  'action.details': 'تفاصيل الالتزام',
3763
3961
  'menu.sendToChat': 'إرسال إلى المحادثة',
3764
3962
  'menu.sendFilePathToChat': 'إرسال مسار الملف إلى المحادثة',
3963
+ 'send.commitText': 'التزام git {hash} "{subject}" (المستودع {repo})',
3964
+ 'send.branchText': 'فرع git {name} (المستودع {repo})',
3965
+ 'menu.sendDiffLinesToChat': 'إرسال الأسطر المحددة إلى المحادثة',
3966
+ 'menu.sendDiffSnippetToChat': 'إرسال المقتطف المحدد إلى المحادثة',
3967
+ 'menu.copyDiffRef': 'نسخ مرجع التحديد',
3968
+ 'send.diffLinesCommit': '{lines} (التزام {hash} "{subject}" @ {branch})',
3969
+ 'send.diffLinesWorktree': '{lines} (شجرة العمل، {state})',
3970
+ 'send.diffStaged': 'مُرحَّل',
3971
+ 'send.diffUnstaged': 'غير مُلتزم',
3972
+ 'send.diffNewLines': 'الأسطر {lines}',
3973
+ 'send.diffOldLines': 'الأسطر {lines} (ترقيم قديم)',
3765
3974
  'history.empty': 'لا توجد التزامات في هذا المستودع بعد',
3766
3975
  'history.loadMore': 'تحميل المزيد',
3767
3976
  'history.filter': 'نص أو تجزئة',
@@ -3962,6 +4171,17 @@ window.__ModuleLoader__.load({
3962
4171
  'action.details': 'कमिट विवरण',
3963
4172
  'menu.sendToChat': 'बातचीत में भेजें',
3964
4173
  'menu.sendFilePathToChat': 'फ़ाइल पथ बातचीत में भेजें',
4174
+ 'send.commitText': 'git कमिट {hash} "{subject}" (रिपॉज़िटरी {repo})',
4175
+ 'send.branchText': 'git ब्रांच {name} (रिपॉज़िटरी {repo})',
4176
+ 'menu.sendDiffLinesToChat': 'चयनित पंक्तियाँ बातचीत में भेजें',
4177
+ 'menu.sendDiffSnippetToChat': 'चयनित अंश बातचीत में भेजें',
4178
+ 'menu.copyDiffRef': 'चयन का संदर्भ कॉपी करें',
4179
+ 'send.diffLinesCommit': '{lines} (कमिट {hash} "{subject}" @ {branch})',
4180
+ 'send.diffLinesWorktree': '{lines} (वर्कट्री, {state})',
4181
+ 'send.diffStaged': 'स्टेज्ड',
4182
+ 'send.diffUnstaged': 'अनकमिटेड',
4183
+ 'send.diffNewLines': 'पंक्तियाँ {lines}',
4184
+ 'send.diffOldLines': 'पंक्तियाँ {lines} (पुरानी क्रमांक)',
3965
4185
  'history.empty': 'इस रिपॉज़िटरी में अभी कोई कमिट नहीं है',
3966
4186
  'history.loadMore': 'और लोड करें',
3967
4187
  'history.filter': 'टेक्स्ट या हैश',
@@ -4162,6 +4382,17 @@ window.__ModuleLoader__.load({
4162
4382
  'action.details': 'รายละเอียดคอมมิต',
4163
4383
  'menu.sendToChat': 'ส่งไปยังบทสนทนา',
4164
4384
  'menu.sendFilePathToChat': 'ส่งพาธไฟล์ไปยังบทสนทนา',
4385
+ 'send.commitText': 'git คอมมิต {hash} "{subject}" (รีโพซิทอรี {repo})',
4386
+ 'send.branchText': 'git บรานช์ {name} (รีโพซิทอรี {repo})',
4387
+ 'menu.sendDiffLinesToChat': 'ส่งบรรทัดที่เลือกไปยังบทสนทนา',
4388
+ 'menu.sendDiffSnippetToChat': 'ส่งข้อความที่เลือกไปยังบทสนทนา',
4389
+ 'menu.copyDiffRef': 'คัดลอกการอ้างอิงที่เลือก',
4390
+ 'send.diffLinesCommit': '{lines} (คอมมิต {hash} "{subject}" @ {branch})',
4391
+ 'send.diffLinesWorktree': '{lines} (แผนผังงาน, {state})',
4392
+ 'send.diffStaged': 'สเตจแล้ว',
4393
+ 'send.diffUnstaged': 'ยังไม่คอมมิต',
4394
+ 'send.diffNewLines': 'บรรทัด {lines}',
4395
+ 'send.diffOldLines': 'บรรทัด {lines} (เลขบรรทัดเดิม)',
4165
4396
  'history.empty': 'รีโพซิทอรีนี้ยังไม่มีคอมมิต',
4166
4397
  'history.loadMore': 'โหลดเพิ่ม',
4167
4398
  'history.filter': 'ข้อความหรือแฮช',
@@ -5453,20 +5684,47 @@ window.__ModuleLoader__.load({
5453
5684
  } catch (error) { void error; return null }
5454
5685
  }
5455
5686
 
5687
+ /* The composer carries TWO text projections (ui-conversation projection.ts):
5688
+ detectText feeds TokenSpan coordinates — every chip counts as ONE U+FFFC
5689
+ there — while the published InputState.draft is the clipboard projection,
5690
+ where a chip expands to its full `@path`. The insert verbs take DETECT
5691
+ spans, and selectSpan rejects `end > detectLength` outright (span-map.ts),
5692
+ so a tail span built from draft.length is correct only while the draft has
5693
+ NO chips. With one in the draft it points past the document end and the
5694
+ edit silently fails — exactly the "chip landed, the line-range sentence
5695
+ never did" real-machine report against v0.13.5 (and any plain-text send
5696
+ into a draft that already holds a chip). Convert instead: subtract every
5697
+ occurrence's (clipboard length - 1). */
5698
+ function detectTailSpanOf(state) {
5699
+ if (state === undefined || state === null || typeof state.draft !== 'string' || typeof state.draftRev !== 'number') return null
5700
+ let at = state.draft.length
5701
+ const occurrences = Array.isArray(state.occurrences) ? state.occurrences : []
5702
+ for (const occ of occurrences) {
5703
+ if (occ === null || typeof occ !== 'object') continue
5704
+ const width = typeof occ.length === 'number' && occ.length >= 0
5705
+ ? occ.length
5706
+ : (typeof occ.clipboardText === 'string' ? occ.clipboardText.length : 1)
5707
+ at -= Math.max(0, width - 1)
5708
+ }
5709
+ if (at < 0) return null
5710
+ return { start: at, end: at, draftRev: state.draftRev }
5711
+ }
5712
+
5456
5713
  /* apply(actx, input, span) → boolean. The span is an EMPTY span at the draft
5457
- tail in detect coordinates (state.draft is exactly the projection the CAS
5458
- compares). A false answer is the user typing between our read and write —
5459
- re-read once and retry (the brief's advice: one retry, no spinning). The
5460
- busy phases (adjudicating/submitting) also answer false; that lands on the
5461
- same clipboard fallback, which is the honest outcome. */
5714
+ tail in DETECT coordinates (the coordinates the CAS and selectSpan
5715
+ compare — see detectTailSpanOf). A false answer is the user typing
5716
+ between our read and write — re-read once and retry (the brief's advice:
5717
+ one retry, no spinning). The busy phases (adjudicating/submitting) also
5718
+ answer false; that lands on the same clipboard fallback, which is the
5719
+ honest outcome. */
5462
5720
  function insertIntoComposer(ctx, sessionId, apply) {
5463
5721
  const target = composerTargetOf(ctx, sessionId)
5464
5722
  if (target === null) return false
5465
5723
  for (let attempt = 0; attempt < 2; attempt += 1) {
5466
5724
  let state
5467
5725
  try { state = target.input.state.getSnapshot() } catch (error) { void error; return false }
5468
- if (state === undefined || state === null || typeof state.draft !== 'string' || typeof state.draftRev !== 'number') return false
5469
- const span = { start: state.draft.length, end: state.draft.length, draftRev: state.draftRev }
5726
+ const span = detectTailSpanOf(state)
5727
+ if (span === null) return false
5470
5728
  let applied = false
5471
5729
  try { applied = apply(target.actx, target.input, span) === true } catch (error) { void error; applied = false }
5472
5730
  if (applied === true) {
@@ -5534,7 +5792,11 @@ window.__ModuleLoader__.load({
5534
5792
  } else if (line.indexOf('@@') === 0) {
5535
5793
  kind = 'hunk'
5536
5794
  const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
5537
- if (match !== null) { oldLine = Number.parseInt(match[1], 10); newLine = Number.parseInt(match[2], 10) }
5795
+ // The @@ header names the FIRST content line (git semantics), but the
5796
+ // counters below are PRE-INCREMENTED before they land in a row — so
5797
+ // seed one less. Off by one otherwise: v0.13.3's hand-drawn gutter
5798
+ // numbered every line one too high (surfaced by the selection core).
5799
+ if (match !== null) { oldLine = Number.parseInt(match[1], 10) - 1; newLine = Number.parseInt(match[2], 10) - 1 }
5538
5800
  } else if (line.indexOf('+') === 0) { kind = 'add'; newLine += 1 }
5539
5801
  else if (line.indexOf('-') === 0) { kind = 'del'; oldLine += 1 }
5540
5802
  else if (line.indexOf('\\') === 0) { kind = 'meta' }
@@ -5548,6 +5810,250 @@ window.__ModuleLoader__.load({
5548
5810
  return out
5549
5811
  }
5550
5812
 
5813
+ /* ---- diff selection core
5814
+ Pure line-number plumbing for the diff-pane context menu (v0.13.5).
5815
+ The host DiffBlock draws rows WITHOUT line-number attributes — a row is
5816
+ `<div class={line + kindClass}>{row.text}</div>` (DiffBlock.tsx:204) and
5817
+ the `+`/`-`/two-space prefixes live in CSS `::before` content — so the
5818
+ menu reconstructs numbers by replaying the ROW KINDS in document order
5819
+ from the patch's first hunk header. The hand-drawn rows already carry
5820
+ parsed numbers (diffLines above). Kept DOM-free between the markers so
5821
+ tests/smoke.mjs can slice the block and drive the real implementation. */
5822
+
5823
+ const DIFF_SNIPPET_MAX = 600
5824
+
5825
+ /* First hunk header of a single-file patch → the absolute numbers the
5826
+ replay starts from (`@@ -a,b +c,d @@`: a/c are the sides' first lines).
5827
+ A patch without any header (binary, mode-only) has no coordinates. */
5828
+ function hunkStartOf(patch) {
5829
+ const headers = hunkHeadersOf(patch)
5830
+ return headers === null ? null : headers[0]
5831
+ }
5832
+
5833
+ /* Every hunk header in patch order: a multi-hunk diff restarts BOTH
5834
+ counters at each `@@` line, so the replay needs one seed per hunk. */
5835
+ function hunkHeadersOf(patch) {
5836
+ const text = typeof patch === 'string' ? patch : ''
5837
+ if (text.indexOf('@@') < 0) return null
5838
+ const headers = []
5839
+ const pattern = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/gm
5840
+ let match = pattern.exec(text)
5841
+ while (match !== null) {
5842
+ headers.push({ oldLine: Number.parseInt(match[1], 10), newLine: Number.parseInt(match[2], 10) })
5843
+ match = pattern.exec(text)
5844
+ }
5845
+ return headers.length === 0 ? null : headers
5846
+ }
5847
+
5848
+ /* getComputedStyle(el, '::before').content → the row kind DiffBlock
5849
+ paints: `- ` del, `+ ` add, two spaces context, nothing for path/gap
5850
+ chrome. Browsers quote the value ("+ "); strip quotes before comparing. */
5851
+ function beforeContentKind(content) {
5852
+ if (typeof content !== 'string') return null
5853
+ const value = content.length >= 2 && content.charAt(0) === '"' && content.charAt(content.length - 1) === '"'
5854
+ ? content.slice(1, -1)
5855
+ : content
5856
+ if (value === '- ') return 'del'
5857
+ if (value === '+ ') return 'add'
5858
+ if (value === ' ') return 'ctx'
5859
+ return null
5860
+ }
5861
+
5862
+ /* Replay kinds[0..to] (document order), collecting the span covered by
5863
+ kinds[from..to]. Each hunk restarts BOTH counters from its own header:
5864
+ a chrome row (path/gap, kind null) marks the boundary, and the next
5865
+ content row consumes the NEXT header in patch order — the path row at
5866
+ the top then the gap rows between hunks. New-side numbers win; a purely
5867
+ deleted span reports the OLD numbers with old: true — deleted rows
5868
+ exist on that side only. */
5869
+ function lineSpanFromKinds(kinds, from, to, headers) {
5870
+ if (Array.isArray(kinds) === false || Array.isArray(headers) === false || headers.length === 0) return null
5871
+ if (!(from >= 0 && to >= from && to < kinds.length)) return null
5872
+ let headerIndex = 0
5873
+ let oldLine = 0
5874
+ let newLine = 0
5875
+ let started = false
5876
+ let newFrom = null
5877
+ let newTo = null
5878
+ let oldFrom = null
5879
+ let oldTo = null
5880
+ for (let index = 0; index <= to; index += 1) {
5881
+ const kind = kinds[index]
5882
+ if (kind !== 'del' && kind !== 'add' && kind !== 'ctx') {
5883
+ started = false // chrome row: the next content row starts a new hunk
5884
+ continue
5885
+ }
5886
+ if (started === false) {
5887
+ if (headerIndex >= headers.length) break // more hunks than headers: stop, don't guess
5888
+ const header = headers[headerIndex]
5889
+ headerIndex += 1
5890
+ oldLine = header.oldLine
5891
+ newLine = header.newLine
5892
+ started = true
5893
+ }
5894
+ let oldHit = null
5895
+ let newHit = null
5896
+ if (kind === 'del') { oldHit = oldLine; oldLine += 1 }
5897
+ else if (kind === 'add') { newHit = newLine; newLine += 1 }
5898
+ else { oldHit = oldLine; newHit = newLine; oldLine += 1; newLine += 1 }
5899
+ if (index >= from) {
5900
+ if (oldHit !== null) { oldFrom = oldFrom === null ? oldHit : oldFrom; oldTo = oldHit }
5901
+ if (newHit !== null) { newFrom = newFrom === null ? newHit : newFrom; newTo = newHit }
5902
+ }
5903
+ }
5904
+ if (newFrom !== null) return { start: newFrom, end: newTo === null ? newFrom : newTo, old: false }
5905
+ if (oldFrom !== null) return { start: oldFrom, end: oldTo === null ? oldFrom : oldTo, old: true }
5906
+ return null
5907
+ }
5908
+
5909
+ /* The hand-drawn rows already carry parsed numbers: reduce the selected
5910
+ row range exactly like lineSpanFromKinds reduces replayed kinds. */
5911
+ function rowSpanOfRows(rows, from, to) {
5912
+ if (Array.isArray(rows) === false) return null
5913
+ if (!(from >= 0 && to >= from && to < rows.length)) return null
5914
+ let newFrom = null
5915
+ let newTo = null
5916
+ let oldFrom = null
5917
+ let oldTo = null
5918
+ for (let index = from; index <= to; index += 1) {
5919
+ const row = rows[index]
5920
+ if (row === undefined || row === null) continue
5921
+ const newHit = typeof row.newLine === 'number' ? row.newLine : null
5922
+ const oldHit = typeof row.oldLine === 'number' ? row.oldLine : null
5923
+ if (newHit !== null) { newFrom = newFrom === null ? newHit : newFrom; newTo = newHit }
5924
+ if (oldHit !== null) { oldFrom = oldFrom === null ? oldHit : oldFrom; oldTo = oldHit }
5925
+ }
5926
+ if (newFrom !== null) return { start: newFrom, end: newTo === null ? newFrom : newTo, old: false }
5927
+ if (oldFrom !== null) return { start: oldFrom, end: oldTo === null ? oldFrom : oldTo, old: true }
5928
+ return null
5929
+ }
5930
+
5931
+ /* '7' or '7–9' — the {lines} value the payload templates receive. */
5932
+ function linesLabelOf(start, end) {
5933
+ return start === end ? String(start) : String(start) + '–' + String(end)
5934
+ }
5935
+
5936
+ /* The localized line-span fragment the payload templates embed: new-side
5937
+ numbers read plain, old-side numbers say so instead of pretending. */
5938
+ function lineSpanLabelOf(t, span) {
5939
+ const lines = linesLabelOf(span.start, span.end)
5940
+ return fill(t(span.old === true ? 'send.diffOldLines' : 'send.diffNewLines'), { lines: lines })
5941
+ }
5942
+
5943
+ /* The self-describing line reference text for one resolved span.
5944
+ Commit context carries hash + subject (truncated like the commit send)
5945
+ and the HEAD branch; the worktree pane says staged or uncommitted. */
5946
+ function diffSpanTextOf(t, context, span, branchName) {
5947
+ const lines = lineSpanLabelOf(t, span)
5948
+ if (context === null || context === undefined || context.kind !== 'commit') {
5949
+ return fill(t('send.diffLinesWorktree'), {
5950
+ lines: lines,
5951
+ state: t(context !== null && context !== undefined && context.staged === true ? 'send.diffStaged' : 'send.diffUnstaged'),
5952
+ })
5953
+ }
5954
+ const subject = typeof context.subject === 'string' ? context.subject.trim().slice(0, 60) : ''
5955
+ return fill(t('send.diffLinesCommit'), {
5956
+ lines: lines,
5957
+ hash: typeof context.hash === 'string' && context.hash !== '' ? context.hash.slice(0, 7) : '—',
5958
+ subject: subject === '' ? '—' : subject,
5959
+ branch: typeof branchName === 'string' && branchName !== '' ? branchName : '—',
5960
+ })
5961
+ }
5962
+
5963
+ /* ---- end diff selection core */
5964
+
5965
+ /* ---------- DOM side of the diff-pane context menu (browser only) ----------
5966
+ SSR never calls these: they run inside the pane's onContextMenu, after
5967
+ the selection has been captured. The row element under a selection
5968
+ endpoint is either a hand-drawn `.dig-diff-line`, or a DiffBlock row —
5969
+ a div child of the body whose OWN parent carries the `data-diff`
5970
+ marker. `data-diff` is a component attribute (DiffBlock.tsx:199), not a
5971
+ hashed CSS-module class, so the lookup does not bet on build-time class
5972
+ renaming; the row KIND comes from the `::before` content the kinds are
5973
+ painted with, for the same reason. */
5974
+
5975
+ function savedSelectionOf() {
5976
+ try {
5977
+ const selection = window.getSelection()
5978
+ if (selection === null || selection.rangeCount === 0) return null
5979
+ const text = String(selection)
5980
+ if (text === '') return null
5981
+ return { text: text, range: selection.getRangeAt(0).cloneRange() }
5982
+ } catch (error) { void error; return null }
5983
+ }
5984
+
5985
+ function selectionRowOf(node) {
5986
+ let element = node === null || node === undefined ? null
5987
+ : node.nodeType === 3 ? (node.parentElement === null ? null : node.parentElement)
5988
+ : node.nodeType === 1 ? node
5989
+ : null
5990
+ for (let hop = 0; element !== null && element !== undefined && hop < 8; hop += 1) {
5991
+ if (element.classList !== undefined && element.classList.contains('dig-diff-line') === true) {
5992
+ return { kind: 'drawn', row: element, body: element.parentElement }
5993
+ }
5994
+ const parent = element.parentElement
5995
+ if (parent !== null && parent.getAttribute !== undefined && parent.hasAttribute('data-diff') === false) {
5996
+ const root = parent.parentElement
5997
+ if (root !== null && root.getAttribute !== undefined && root.hasAttribute('data-diff') === true) {
5998
+ return { kind: 'host', row: element, body: parent }
5999
+ }
6000
+ }
6001
+ element = parent
6002
+ }
6003
+ return null
6004
+ }
6005
+
6006
+ /* The rendered rows of a DiffBlock body, with the kind each one paints.
6007
+ The (never-rendered at this panel's maxLines) fold toggle is a button,
6008
+ not a div — skipped so it cannot shift the row indexes. */
6009
+ function hostRowKinds(body) {
6010
+ const children = body === null || body === undefined ? [] : body.children
6011
+ const rows = []
6012
+ const kinds = []
6013
+ for (let index = 0; index < children.length; index += 1) {
6014
+ const child = children[index]
6015
+ if (child === null || child === undefined || child.tagName === 'BUTTON') continue
6016
+ rows.push(child)
6017
+ let kind = null
6018
+ try { kind = beforeContentKind(window.getComputedStyle(child, '::before').content) } catch (error) { void error; kind = null }
6019
+ kinds.push(kind)
6020
+ }
6021
+ return { rows: rows, kinds: kinds }
6022
+ }
6023
+
6024
+ /* { kind: 'lines', span } for a selection inside ONE diff body, or
6025
+ { kind: 'snippet' } when the rows are there but the numbers cannot be
6026
+ reconstructed. null (rows missing entirely) means "not a diff
6027
+ selection": the menu keeps its old behaviour. */
6028
+ function resolveDiffSelection(patch, saved) {
6029
+ if (saved === null || saved === undefined || saved.text === '' || saved.range === null || saved.range === undefined) return null
6030
+ const from = selectionRowOf(saved.range.startContainer)
6031
+ const to = selectionRowOf(saved.range.endContainer)
6032
+ if (from === null || to === null) return null
6033
+ if (from.kind !== to.kind || from.body !== to.body) return { kind: 'snippet', text: saved.text }
6034
+ let span = null
6035
+ if (from.kind === 'drawn') {
6036
+ const rows = diffLines(patch)
6037
+ const all = from.body === null || from.body === undefined ? [] : Array.prototype.slice.call(from.body.querySelectorAll('.dig-diff-line'))
6038
+ const fromIndex = all.indexOf(from.row)
6039
+ const toIndex = all.indexOf(to.row)
6040
+ if (fromIndex >= 0 && toIndex >= 0) span = rowSpanOfRows(rows, Math.min(fromIndex, toIndex), Math.max(fromIndex, toIndex))
6041
+ } else {
6042
+ const headers = hunkHeadersOf(patch)
6043
+ if (headers !== null) {
6044
+ const collected = hostRowKinds(from.body)
6045
+ const fromIndex = collected.rows.indexOf(from.row)
6046
+ const toIndex = collected.rows.indexOf(to.row)
6047
+ if (fromIndex >= 0 && toIndex >= 0) {
6048
+ span = lineSpanFromKinds(collected.kinds, Math.min(fromIndex, toIndex), Math.max(fromIndex, toIndex), headers)
6049
+ }
6050
+ }
6051
+ }
6052
+ if (span === null) return { kind: 'snippet', text: saved.text }
6053
+ return { kind: 'lines', span: span, text: saved.text }
6054
+ }
6055
+
6056
+
5551
6057
  /* DiffBlock's own 16-line cap would REPLACE scrolling with a fold button;
5552
6058
  this panel keeps the old contract (render everything, scroll inside the
5553
6059
  .dig-diff-pane max-height), so the cap is pushed out of reach and the
@@ -7115,6 +7621,11 @@ window.__ModuleLoader__.load({
7115
7621
  const [selectedPath, setSelectedPath] = useState(null)
7116
7622
  const [patch, setPatch] = useState('')
7117
7623
  const [patchLoading, setPatchLoading] = useState(false)
7624
+ /* What the open diff pane shows (v0.13.5): the pane is shared by the
7625
+ commit detail (hash + subject) and the working tree (staged or not),
7626
+ and the selection menu's payload must say which. Cleared everywhere
7627
+ the patch is cleared. */
7628
+ const [diffContext, setDiffContext] = useState(null)
7118
7629
  const [menu, setMenu] = useState(null)
7119
7630
  const [dialog, setDialog] = useState(null)
7120
7631
  const [tick, setTick] = useState(0)
@@ -7481,6 +7992,7 @@ window.__ModuleLoader__.load({
7481
7992
  setDetail(null)
7482
7993
  setSelectedHash(null)
7483
7994
  setPatch('')
7995
+ setDiffContext(null)
7484
7996
  setView('history')
7485
7997
  setPathFilter('')
7486
7998
  }, [sessionId])
@@ -7502,6 +8014,7 @@ window.__ModuleLoader__.load({
7502
8014
  setSelectedHash(commit.hash)
7503
8015
  setSelectedPath(null)
7504
8016
  setPatch('')
8017
+ setDiffContext(null)
7505
8018
  setView('detail')
7506
8019
  setDetail(null)
7507
8020
  try {
@@ -7578,6 +8091,41 @@ window.__ModuleLoader__.load({
7578
8091
  and a toast says so. */
7579
8092
  const sendToComposer = useCallback((payload) => {
7580
8093
  const applied = insertIntoComposer(props.ctx, sessionId, (actx, input, span) => {
8094
+ if (payload.mention !== undefined && payload.text !== undefined) {
8095
+ // Chip + trailing sentence (v0.13.5 diff line refs): the @file chip
8096
+ // first, then the line-range text at the NEW draft tail (the chip
8097
+ // changed both the draft and its rev). The facade appends a
8098
+ // separating space after a chip when none follows, so the text leg
8099
+ // goes in verbatim. If the text leg loses its CAS race twice the
8100
+ // chip still stands — the reference made it, that reads as sent.
8101
+ let chipApplied = false
8102
+ try {
8103
+ chipApplied = input.insertReference({
8104
+ source: 'reference',
8105
+ ref: payload.mention,
8106
+ label: payload.label,
8107
+ appearance: payload.appearance,
8108
+ clipboardText: payload.mention,
8109
+ }, span) === true
8110
+ } catch (error) { void error; chipApplied = false }
8111
+ if (chipApplied === false) return false
8112
+ try {
8113
+ let state = input.state.getSnapshot()
8114
+ for (let attempt = 0; attempt < 2; attempt += 1) {
8115
+ // The chip changed BOTH projections; the tail must be rebuilt
8116
+ // in detect coordinates (see detectTailSpanOf), or selectSpan
8117
+ // rejects it and the sentence silently vanishes.
8118
+ const tail = detectTailSpanOf(state)
8119
+ if (tail !== null) {
8120
+ let textApplied = false
8121
+ try { textApplied = actx.bail(actx, 'slash/input-insert-text', { text: payload.text, span: tail }) === true } catch (error) { void error; textApplied = false }
8122
+ if (textApplied === true) return true
8123
+ }
8124
+ try { state = input.state.getSnapshot() } catch (error) { void error; return true }
8125
+ }
8126
+ } catch (error) { void error }
8127
+ return true
8128
+ }
7581
8129
  if (payload.appearance !== undefined) {
7582
8130
  return input.insertReference({
7583
8131
  source: 'reference',
@@ -7610,6 +8158,21 @@ window.__ModuleLoader__.load({
7610
8158
  })
7611
8159
  }, [sendToComposer])
7612
8160
 
8161
+ /* Self-describing text payloads for the commit/branch sends (v0.13.3 user
8162
+ feedback): a bare short hash or branch name is noise out of context —
8163
+ the text must say WHAT it is and WHERE from. The wording rides the
8164
+ fill() templates send.commitText / send.branchText; `repo` is the repo
8165
+ root's basename. An unknown root degrades to a neutral dash — fill()
8166
+ leaves unknown keys verbatim, so the callers must never let an
8167
+ undefined repo reach the template. The file @-chip is already
8168
+ self-describing and stays untouched. */
8169
+ const repoLabel = repoRoot === null ? '' : baseName(repoRoot)
8170
+ const sendGitSummaryToChat = useCallback((key, values) => {
8171
+ const repo = values.repo === undefined || values.repo === '' ? '—' : values.repo
8172
+ const text = fill(t(key), Object.assign({}, values, { repo: repo }))
8173
+ sendToComposer({ text: text, fallback: text })
8174
+ }, [t, sendToComposer])
8175
+
7613
8176
  const branchMenu = useCallback((event, entry) => {
7614
8177
  const current = branches === null ? '' : branches.branch
7615
8178
  const isLocal = entry.remote !== true && entry.tag !== true
@@ -7619,7 +8182,7 @@ window.__ModuleLoader__.load({
7619
8182
  isLocal ? { id: 'rebase', icon: 'compare', tone: 'violet', label: t('action.rebaseCurrentOnto'), disabled: entry.head === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void run('rebase', { onto: entry.name }) } } : null,
7620
8183
  isLocal ? { id: 'merge', icon: 'compare', tone: 'accent', label: t('action.mergeIntoCurrent'), disabled: entry.head === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void run('merge', { branch: entry.name }) } } : null,
7621
8184
  { id: 'compare', icon: 'filter', tone: 'violet', label: t('action.compare'), disabled: entry.head === true || current === '', reason: reason, run: () => { void compareWith(current, entry.name) } },
7622
- { id: 'send', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => sendToComposer({ text: entry.name, fallback: entry.name }) },
8185
+ { id: 'send', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => sendGitSummaryToChat('send.branchText', { name: entry.name, repo: repoLabel }) },
7623
8186
  null,
7624
8187
  { id: 'favorite', icon: 'star', tone: 'warn', label: t('action.favorite'), active: favorites.indexOf(entry.name) >= 0, run: () => toggleFavoriteBranch(entry.name) },
7625
8188
  { id: 'newBranch', icon: 'plus', tone: 'success', label: t('action.newBranchFrom'), run: () => setDialog({ kind: 'newBranch', from: entry.name }) },
@@ -7630,14 +8193,20 @@ window.__ModuleLoader__.load({
7630
8193
  { id: 'push', icon: 'push', tone: 'success', label: t('action.push'), disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'push' }) },
7631
8194
  ].filter((item) => item !== null)
7632
8195
  openMenuAt(event, items)
7633
- }, [branches, checkout, run, t, compareWith, toggleFavoriteBranch, openMenuAt, favorites, blocked, sendToComposer])
8196
+ }, [branches, checkout, run, t, compareWith, toggleFavoriteBranch, openMenuAt, favorites, blocked, sendGitSummaryToChat, repoLabel])
7634
8197
 
7635
8198
  const commitMenu = useCallback((event, commit) => {
7636
8199
  const shortHash = commit.hash.slice(0, 7)
7637
8200
  const items = [
7638
8201
  { id: 'details', icon: 'file', tone: 'primary', label: t('action.details'), run: () => { void selectCommit(commit) } },
7639
8202
  { id: 'copy', icon: 'tag', tone: 'secondary', label: t('action.copyHash'), run: () => { copyText(commit.hash) } },
7640
- { id: 'send', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => sendToComposer({ text: shortHash, fallback: shortHash }) },
8203
+ { id: 'send', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => {
8204
+ // A commit whose subject is empty (git tolerates empty messages)
8205
+ // degrades to the old bare-hash payload instead of faking one.
8206
+ const subject = typeof commit.subject === 'string' ? commit.subject.trim() : ''
8207
+ if (subject === '') { sendToComposer({ text: shortHash, fallback: shortHash }); return }
8208
+ sendGitSummaryToChat('send.commitText', { hash: shortHash, subject: subject, repo: repoLabel })
8209
+ } },
7641
8210
  null,
7642
8211
  { id: 'checkout', icon: 'checkout', tone: 'accent', label: t('action.checkout'), run: () => setDialog({ kind: 'checkoutCommit', hash: commit.hash }) },
7643
8212
  { id: 'branch', icon: 'plus', tone: 'success', label: t('action.newBranchHere'), run: () => setDialog({ kind: 'newBranch', from: commit.hash }) },
@@ -7650,11 +8219,11 @@ window.__ModuleLoader__.load({
7650
8219
  { id: 'resetHard', icon: 'trash', tone: 'danger', label: t('action.resetHard'), danger: true, disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'reset', hash: commit.hash, mode: 'hard' }) },
7651
8220
  ]
7652
8221
  openMenuAt(event, items)
7653
- }, [run, selectCommit, t, openMenuAt, blocked, sendToComposer])
8222
+ }, [run, selectCommit, t, openMenuAt, blocked, sendToComposer, sendGitSummaryToChat, repoLabel])
7654
8223
 
7655
8224
  const changeMenu = useCallback((event, item, group) => {
7656
8225
  const items = [
7657
- { id: 'diff', icon: 'file', tone: 'primary', label: t('action.showDiff'), run: () => { void openDiff({ path: item.path, staged: group === 'staged' }) } },
8226
+ { id: 'diff', icon: 'file', tone: 'primary', label: t('action.showDiff'), run: () => { setDiffContext({ kind: 'worktree', staged: group === 'staged' }); void openDiff({ path: item.path, staged: group === 'staged' }) } },
7658
8227
  { id: 'sendRef', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => sendFileReferenceToChat(item.path) },
7659
8228
  { id: 'sendPath', icon: 'file', tone: 'secondary', label: t('menu.sendFilePathToChat'), run: () => sendToComposer({ text: item.path, fallback: item.path }) },
7660
8229
  null,
@@ -7672,7 +8241,11 @@ window.__ModuleLoader__.load({
7672
8241
  staging verbs: a committed file's diff replays against its commit. */
7673
8242
  const detailFileMenu = useCallback((event, file) => {
7674
8243
  openMenuAt(event, [
7675
- { id: 'diff', icon: 'file', tone: 'primary', label: t('action.showDiff'), run: () => { setSelectedPath(file.path); void openDiff({ hash: detail === null ? undefined : detail.hash, path: file.path }) } },
8244
+ { id: 'diff', icon: 'file', tone: 'primary', label: t('action.showDiff'), run: () => {
8245
+ setSelectedPath(file.path)
8246
+ setDiffContext({ kind: 'commit', hash: detail === null || detail === undefined ? undefined : detail.hash, subject: detail === null || detail === undefined ? '' : detail.subject })
8247
+ void openDiff({ hash: detail === null ? undefined : detail.hash, path: file.path })
8248
+ } },
7676
8249
  null,
7677
8250
  { id: 'sendRef', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => sendFileReferenceToChat(file.path) },
7678
8251
  { id: 'sendPath', icon: 'file', tone: 'secondary', label: t('menu.sendFilePathToChat'), run: () => sendToComposer({ text: file.path, fallback: file.path }) },
@@ -7681,6 +8254,66 @@ window.__ModuleLoader__.load({
7681
8254
  ])
7682
8255
  }, [detail, openDiff, openMenuAt, sendFileReferenceToChat, sendToComposer, t])
7683
8256
 
8257
+ /* The diff pane's own context menu (v0.13.5): select text in the diff,
8258
+ right-click, send a self-describing line reference to the composer.
8259
+ The window selection is read BEFORE the menu opens — some platforms
8260
+ clear it the moment a context menu shows — and the whole payload is
8261
+ computed right here, so the menu items run from captured values and
8262
+ never depend on a selection that may no longer exist. A selection
8263
+ outside the diff rows keeps the browser's own menu: preventDefault is
8264
+ skipped and no item appears (the old behaviour).
8265
+
8266
+ LAST-RESORT SEMANTICS (v0.13.5 forward compat): the rows under this
8267
+ pane may be HOST-rendered (ui-primitives DiffBlock), and a future DSH
8268
+ is free to add its own context-menu surface to that component. React
8269
+ dispatches synthetic contextmenu handlers in bubble order — innermost
8270
+ first — so an inner handler that claimed the event (opened its menu
8271
+ or called preventDefault) has already set defaultPrevented by the
8272
+ time this container handler runs. That check is the FIRST statement:
8273
+ if the host handled it we return and the host menu stands alone —
8274
+ never two menus at once, never an override. An unusable selection
8275
+ also returns without preventDefault (browser menu preserved). Only a
8276
+ valid diff selection that NOTHING else handled reaches our menu.
8277
+ UPGRADE MEMO (diff-右键向前兼容): DiffBlock today exposes NO menu
8278
+ surface and NO extension prop — DiffBlockProps is exactly
8279
+ { diffs, labels, maxLines, className } (ui-primitives DiffBlock.tsx);
8280
+ its only stable hook is the `data-diff` marker on the root div, which
8281
+ this pane already keys on. When upstream ships a menu surface (most
8282
+ likely an onRowContextMenu / items prop mounted on the row div or the
8283
+ body container), MIGRATE to that prop — feed it our line-reference
8284
+ items and RETIRE this container-level handler — the absorption
8285
+ pattern dsh-better-workspace uses (host menu surface + host
8286
+ dictionary, own entries appended last), not an event race. Until
8287
+ then this handler stays deliberately last-resort. */
8288
+ const onDiffPaneContextMenu = useCallback((event) => {
8289
+ if (event.defaultPrevented === true) return // an inner (host DiffBlock) handler already owns this event
8290
+ const saved = savedSelectionOf()
8291
+ const resolved = saved === null ? null : resolveDiffSelection(patch, saved)
8292
+ if (saved === null || resolved === null) return
8293
+ event.preventDefault()
8294
+ const path = selectedPath
8295
+ const hasFile = typeof path === 'string' && path !== ''
8296
+ const branchName = branches === null ? '' : branches.branch
8297
+ const mention = hasFile === true ? fileMentionOf(path) : null
8298
+ const snippet = resolved.text.length > DIFF_SNIPPET_MAX ? resolved.text.slice(0, DIFF_SNIPPET_MAX) : resolved.text
8299
+ const text = resolved.kind === 'lines' && hasFile === true
8300
+ ? diffSpanTextOf(t, diffContext, resolved.span, branchName)
8301
+ : snippet
8302
+ const plain = (mention === null ? (hasFile === true ? path : '') : mention) + ' ' + text
8303
+ const sendChip = () => {
8304
+ if (mention === null || hasFile === false) { sendToComposer({ text: text, fallback: text }); return }
8305
+ sendToComposer({ mention: mention, label: baseName(path), appearance: 'file', text: text, fallback: plain })
8306
+ }
8307
+ const items = [
8308
+ hasFile === true && resolved.kind === 'lines'
8309
+ ? { id: 'sendDiffLines', icon: 'send', tone: 'accent', label: t('menu.sendDiffLinesToChat'), run: sendChip }
8310
+ : { id: 'sendDiffSnippet', icon: 'send', tone: 'accent', label: t(resolved.kind === 'lines' ? 'menu.sendDiffLinesToChat' : 'menu.sendDiffSnippetToChat'), run: sendChip },
8311
+ null,
8312
+ { id: 'copyDiffRef', icon: 'tag', tone: 'secondary', label: t('menu.copyDiffRef'), run: () => { copyText(plain) } },
8313
+ ]
8314
+ openMenuAt(event, items)
8315
+ }, [patch, selectedPath, diffContext, branches, openMenuAt, sendToComposer, t])
8316
+
7684
8317
  const submitDialog = useCallback(async (state, value) => {
7685
8318
  setDialog(null)
7686
8319
  if (state.kind === 'newBranch') { await run('checkout', { branch: value, create: true, startPoint: state.from }); return }
@@ -7852,16 +8485,17 @@ window.__ModuleLoader__.load({
7852
8485
  onStageAll: (entries) => { void run('stage', { paths: entries.map((entry) => entry.path) }) },
7853
8486
  onUnstageAll: () => { void run('unstage', { paths: (summary === null ? [] : summary.changes.staged).map((entry) => entry.path) }) },
7854
8487
  onDiscard: (item, group) => setDialog({ kind: 'discard', item: item, group: group }),
7855
- onDiff: (item, group) => { void openDiff({ path: item.path, staged: group === 'staged' }) },
8488
+ onDiff: (item, group) => { setDiffContext({ kind: 'worktree', staged: group === 'staged' }); void openDiff({ path: item.path, staged: group === 'staged' }) },
7856
8489
  onChangeMenu: changeMenu,
7857
8490
  })
7858
8491
 
7859
8492
  const historyPane = view === 'detail'
7860
8493
  ? E(CommitDetail, {
7861
8494
  t: t, detail: detail, selectedPath: selectedPath,
7862
- onBack: () => { setView('history'); setSelectedPath(null); setPatch('') },
8495
+ onBack: () => { setView('history'); setSelectedPath(null); setPatch(''); setDiffContext(null) },
7863
8496
  onSelectFile: (file) => {
7864
8497
  setSelectedPath(file.path)
8498
+ setDiffContext({ kind: 'commit', hash: detail === null || detail === undefined ? undefined : detail.hash, subject: detail === null || detail === undefined ? '' : detail.subject })
7865
8499
  void openDiff({ hash: detail === null ? undefined : detail.hash, path: file.path })
7866
8500
  },
7867
8501
  onFileMenu: detailFileMenu,
@@ -7875,10 +8509,10 @@ window.__ModuleLoader__.load({
7875
8509
  onLoadMore: () => { void guard(() => loadCommits(commits.length)) },
7876
8510
  })
7877
8511
 
7878
- const diffPane = patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane', 'data-pane': 'diff', style: paneStyle('diff') },
8512
+ const diffPane = patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane', 'data-pane': 'diff', style: paneStyle('diff'), onContextMenu: onDiffPaneContextMenu },
7879
8513
  E('div', { className: 'dig-diff-head' },
7880
8514
  E('span', { className: 'dig-mono dig-diff-path' }, selectedPath === null ? '' : selectedPath),
7881
- E('button', { type: 'button', className: 'dig-icon-btn', onClick: () => { setPatch(''); setSelectedPath(null) } }, E(Icon, { name: 'close', size: 12 }))),
8515
+ E('button', { type: 'button', className: 'dig-icon-btn', onClick: () => { setPatch(''); setSelectedPath(null); setDiffContext(null) } }, E(Icon, { name: 'close', size: 12 }))),
7882
8516
  E(DiffBody, { patch: patch, loading: patchLoading, t: t }))
7883
8517
 
7884
8518
  /* ---------- rail (IDE-style left action strip) ---------- */
@@ -8117,7 +8751,7 @@ window.__ModuleLoader__.load({
8117
8751
  E('div', { className: 'dig-compact-bar' },
8118
8752
  E(Segmented, {
8119
8753
  value: view === 'detail' ? 'history' : view,
8120
- onChange: (next) => { setView(next); setPatch('') },
8754
+ onChange: (next) => { setView(next); setPatch(''); setDiffContext(null) },
8121
8755
  items: [
8122
8756
  { id: 'changes', label: fill(t('seg.changes'), { n: dirty }) },
8123
8757
  { id: 'history', label: t('seg.history') },
@@ -8695,30 +9329,48 @@ window.__ModuleLoader__.load({
8695
9329
  with the panel seat pinned to the visible height the scrollHeight
8696
9330
  stays clientHeight + that flow space — the wheel could still scroll
8697
9331
  the whole window by exactly that leftover (user report: the layout
8698
- fits, yet the page keeps scrolling). The overflow only exists AFTER
8699
- the new height has been applied, so measure() sets the visible-height
8700
- target and then, one frame later, CONVERGES: it reads the real
8701
- overflow and shrinks the seat by that amount. Guard rails keep it
8702
- stable: shrink only while overflow > 0 (measure() itself recomputes
8703
- the base on every pass, so the correction never compounds), clamp at
8704
- the same 240 floor, and the correction is remembered so the next
8705
- measure() starts from the already-corrected target. */
9332
+ fits, yet the page keeps scrolling). v0.13.3 report: content mounts
9333
+ or changes AFTER the first pass (the composer, the hero, a diff), and
9334
+ a single feedback convergence is not enough. measure() therefore
9335
+ recomputes the UNCORRECTED base (base.current) and hands off to the
9336
+ CONTROLLED CONVERGER below, which is re-run by every trigger: the
9337
+ initial mount, rAF/timeout follow-ups, the scrollport's own resize
9338
+ (ResizeObserver) and window resizes, and — new — the scrollport's
9339
+ content mutations (MutationObserver, debounced), because content
9340
+ growing later re-opens the overflow without resizing the scrollport
9341
+ itself. */
8706
9342
  const leftover = { current: 0 }
9343
+ const base = { current: 0 }
9344
+ /* The converger: up to FOUR rAF-spaced rounds. Each round reads
9345
+ scroll.scrollHeight - scroll.clientHeight; anything over 1px shrinks
9346
+ the seat by exactly that amount and OVERWRITES the leftover record
9347
+ (never adds — every corrected height derives from the fresh base, so
9348
+ repeated rounds cannot compound), clamped at the same 240 floor.
9349
+ Shrink-only: nothing here grows the seat back. A host without the
9350
+ conversation scrollport never reaches this code — measure() skips it
9351
+ all, silently, and never throws. */
9352
+ const converge = (scroll) => {
9353
+ let rounds = 0
9354
+ const step = () => {
9355
+ rounds += 1
9356
+ if (el.isConnected !== true) return
9357
+ const overflow = scroll.scrollHeight - scroll.clientHeight
9358
+ if (overflow <= 1) return
9359
+ leftover.current = overflow
9360
+ setSeatHeight(Math.max(240, base.current - overflow))
9361
+ if (rounds < 4) requestAnimationFrame(step)
9362
+ }
9363
+ requestAnimationFrame(step)
9364
+ }
8707
9365
  const measure = () => {
8708
9366
  let scroll = null
8709
9367
  try { scroll = el.closest('[data-conversation-scroll]') } catch (error) { void error }
8710
9368
  if (scroll === null || scroll === undefined) return
8711
9369
  const offset = Math.max(0, el.getBoundingClientRect().top - scroll.getBoundingClientRect().top)
8712
- const available = Math.round(scroll.clientHeight - offset - 8) - leftover.current
8713
- if (available >= 240) setSeatHeight(available)
8714
- requestAnimationFrame(() => {
8715
- if (el.isConnected !== true) return
8716
- const overflow = scroll.scrollHeight - scroll.clientHeight
8717
- if (overflow > 0) {
8718
- leftover.current = overflow
8719
- setSeatHeight(Math.max(240, available - overflow))
8720
- }
8721
- })
9370
+ base.current = Math.round(scroll.clientHeight - offset - 8)
9371
+ const corrected = base.current - leftover.current
9372
+ if (corrected >= 240) setSeatHeight(corrected)
9373
+ converge(scroll)
8722
9374
  }
8723
9375
  measure()
8724
9376
  const raf = requestAnimationFrame(() => { measure() })
@@ -8731,10 +9383,28 @@ window.__ModuleLoader__.load({
8731
9383
  observer.observe(scroll)
8732
9384
  }
8733
9385
  } catch (error) { void error }
9386
+ /* Content that mounts or changes later does not resize the scrollport,
9387
+ so the ResizeObserver stays silent while the overflow re-opens; the
9388
+ mutation watcher re-measures for it. Debounced ~120ms so a mount
9389
+ burst converges once instead of once per node. */
9390
+ let mutationObserver
9391
+ let mutationTimer = null
9392
+ try {
9393
+ const scroll = el.closest('[data-conversation-scroll]')
9394
+ if (scroll !== null && scroll !== undefined && typeof MutationObserver === 'function') {
9395
+ mutationObserver = new MutationObserver(() => {
9396
+ if (mutationTimer !== null) clearTimeout(mutationTimer)
9397
+ mutationTimer = setTimeout(() => { mutationTimer = null; measure() }, 120)
9398
+ })
9399
+ mutationObserver.observe(scroll, { childList: true, subtree: true })
9400
+ }
9401
+ } catch (error) { void error }
8734
9402
  window.addEventListener('resize', measure)
8735
9403
  return () => {
8736
9404
  cancelAnimationFrame(raf)
8737
9405
  clearTimeout(late)
9406
+ if (mutationTimer !== null) clearTimeout(mutationTimer)
9407
+ if (mutationObserver !== undefined) mutationObserver.disconnect()
8738
9408
  if (observer !== undefined) observer.disconnect()
8739
9409
  window.removeEventListener('resize', measure)
8740
9410
  }