@xia-sc/dsh-git 0.5.1 → 0.5.2

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/lib/client.js CHANGED
@@ -1,2286 +1,2286 @@
1
- /**
2
- * @xia-sc/dsh-git — browser half (dsh.client bundle).
3
- *
4
- * Two seats, one shared store:
5
- *
6
- * - `conversation.input.dock` → GitDockLine: a compact status PILL rendered
7
- * LEFT-ALIGNED in the row band above the composer card (the textarea's
8
- * top-left, no full-width banner): branch summary, or "not a git
9
- * repository". Clicking it toggles the workbench panel. It follows the
10
- * CURRENT session's workspace directory — clicking different sessions /
11
- * workspaces in the sidebar re-binds it in real time.
12
- * - `shell.overlay` → GitFloatingPanel: renders NOTHING while collapsed (no
13
- * floating bubble that could cover the input box) and the full Git
14
- * workbench when expanded, positioned above the composer card.
15
- *
16
- * All Git work happens on the host through `/dsh-git-rpc` (mounted by
17
- * lib/index.js); this bundle only renders and calls `ctx.connection.rpc`.
18
- * The bundle is hand-written against the client module table (seed words
19
- * only: `react`), so it ships without a build step.
20
- */
21
- window.__ModuleLoader__.load({
22
- id: "@xia-sc/dsh-git",
23
- factory: (require) => {
24
- var module = { exports: {} };
25
- var exports = module.exports;
26
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
27
-
28
- var React = require("react");
29
-
30
- var name = "dsh-git";
31
- /** Required client services (activation gating for the loader entry). */
32
- var inject = ["slots", "connection", "locale"];
33
-
34
- /** Locale namespace owned by this plugin. */
35
- var NS = "dshGit";
36
-
37
- var zh = {
38
- "panel.title": "Git",
39
- "panel.collapse": "收起",
40
- "refresh.aria": "刷新 Git 状态",
41
- "branch.aria": "当前 Git 分支",
42
- "switch.aria": "切换分支",
43
- "switch.dirtyWarn": "有 {count} 个未提交文件(如 {paths}),切换分支可能被 git 拒绝,请先提交或暂存",
44
- "switch.force": "仍要切换",
45
- "branch.new": "新建分支",
46
- "branch.newTitle": "从某个分支创建新分支",
47
- "newBranch.placeholder": "新分支名…",
48
- "newBranch.base": "基于分支",
49
- "newBranch.create": "创建并切换",
50
- "newBranch.cancel": "取消",
51
- "newBranch.empty": "请输入新分支名",
52
- "group.local": "本地分支",
53
- "group.remote": "远程分支",
54
- "loading": "读取中…",
55
- "notRepo": "当前工作区不是 Git 仓库",
56
- "detached": "游离 HEAD",
57
- "dirty": "{count} 个未提交更改",
58
- "aheadBehind": "领先 {ahead} / 落后 {behind}",
59
- "action.fetch": "拉取更新",
60
- "action.pull": "拉取合并",
61
- "action.stage": "暂存全部",
62
- "action.generate": "AI 生成",
63
- "action.commit": "提交",
64
- "action.push": "推送",
65
- "generate.mode.staged": "已暂存",
66
- "generate.mode.unstaged": "未暂存",
67
- "generate.mode.all": "全部",
68
- "generate.mode.title": "AI 生成的依据",
69
- "generate.noChanges": "没有可用的改动",
70
- "generate.done": "已生成提交信息",
71
- "generate.noStaged": "没有已暂存的改动,请先点「暂存全部」,或把生成依据改为「未暂存」「全部」",
72
- "generate.noProvider": "尚未配置模型,请先在 设置 > 模型 中添加",
73
- "generate.noModel": "该提供方没有可用模型",
74
- "generate.empty": "模型没有返回提交信息",
75
- "generate.cancelled": "生成已取消",
76
- "generate.failed": "生成失败",
77
- "commit.placeholder": "提交信息(可多行,Ctrl+Enter 提交)…",
78
- "commit.empty": "请输入提交信息",
79
- "changes.title": "变更({count})",
80
- "changes.none": "工作区干净",
81
- "diff.title": "差异",
82
- "diff.open": "查看差异",
83
- "diff.close": "关闭差异",
84
- "diff.resize": "拖动面板右边缘调整差异宽度(双击复位)",
85
- "diff.scope.worktree": "未暂存",
86
- "diff.scope.index": "已暂存",
87
- "diff.scope.title": "对比哪一侧",
88
- "diff.empty": "这一侧没有改动",
89
- "diff.emptyHint": "点「{other}」看另一侧",
90
- "diff.untracked": "未跟踪文件:与空文件对比",
91
- "diff.binary": "二进制文件,没有可显示的文本差异",
92
- "diff.truncated": "差异过大,只显示了开头一段",
93
- "diff.skipped": "另有 {count} 个未跟踪文件未展开",
94
- "diff.showAll": "显示全部({count} 行)",
95
- "diff.wrap": "自动换行",
96
- "diff.copy": "复制差异",
97
- "diff.copied": "已复制",
98
- "diff.refresh": "重新读取差异",
99
- "diff.loading": "读取差异中…",
100
- "diff.failed": "读取差异失败",
101
- "log.title": "最近提交",
102
- "log.none": "暂无提交",
103
- "busy.fetch": "拉取中…",
104
- "busy.pull": "拉取合并中…",
105
- "busy.stage": "暂存中…",
106
- "busy.generate": "生成中…",
107
- "busy.commit": "提交中…",
108
- "busy.push": "推送中…",
109
- "busy.checkout": "切换中…",
110
- "busy.createBranch": "创建分支中…",
111
- "output.ok": "操作成功",
112
- "dock.aria": "打开 Git 面板",
113
- "status.modified": "修改",
114
- "status.added": "新增",
115
- "status.deleted": "删除",
116
- "status.renamed": "重命名",
117
- "status.copied": "复制",
118
- "status.untracked": "未跟踪",
119
- "status.conflict": "冲突",
120
- "status.type-changed": "类型变更",
121
- "status.changed": "变更"
122
- };
123
-
124
- var en = {
125
- "panel.title": "Git",
126
- "panel.collapse": "Collapse",
127
- "refresh.aria": "Refresh Git status",
128
- "branch.aria": "Current git branch",
129
- "switch.aria": "Switch branch",
130
- "switch.dirtyWarn": "{count} uncommitted file(s) (e.g. {paths}) may block the switch — commit or stash first",
131
- "switch.force": "Switch anyway",
132
- "branch.new": "New branch",
133
- "branch.newTitle": "Create a new branch from a branch",
134
- "newBranch.placeholder": "New branch name…",
135
- "newBranch.base": "Base branch",
136
- "newBranch.create": "Create & switch",
137
- "newBranch.cancel": "Cancel",
138
- "newBranch.empty": "Enter a new branch name",
139
- "group.local": "Local",
140
- "group.remote": "Remote",
141
- "loading": "Loading…",
142
- "notRepo": "This workspace is not a git repository",
143
- "detached": "detached HEAD",
144
- "dirty": "{count} uncommitted change(s)",
145
- "aheadBehind": "{ahead} ahead / {behind} behind",
146
- "action.fetch": "Fetch",
147
- "action.pull": "Pull",
148
- "action.stage": "Stage all",
149
- "action.generate": "AI draft",
150
- "action.commit": "Commit",
151
- "action.push": "Push",
152
- "generate.mode.staged": "Staged",
153
- "generate.mode.unstaged": "Unstaged",
154
- "generate.mode.all": "Everything",
155
- "generate.mode.title": "What the draft is based on",
156
- "generate.noChanges": "No changes to draft from",
157
- "generate.done": "Draft ready",
158
- "generate.noStaged": "Nothing is staged yet — use Stage all first, or switch the basis to Unstaged / Everything",
159
- "generate.noProvider": "No model is configured — add one in Settings > Models",
160
- "generate.noModel": "That provider advertises no model",
161
- "generate.empty": "The model returned no commit message",
162
- "generate.cancelled": "Draft cancelled",
163
- "generate.failed": "Draft failed",
164
- "commit.placeholder": "Commit message (multi-line allowed, Ctrl+Enter commits)…",
165
- "commit.empty": "Enter a commit message",
166
- "changes.title": "Changes ({count})",
167
- "changes.none": "Working tree clean",
168
- "diff.title": "Diff",
169
- "diff.open": "View diff",
170
- "diff.close": "Close diff",
171
- "diff.resize": "Drag the panel's right edge to resize the diff (double-click resets)",
172
- "diff.scope.worktree": "Unstaged",
173
- "diff.scope.index": "Staged",
174
- "diff.scope.title": "Which side to compare",
175
- "diff.empty": "Nothing changed on this side",
176
- "diff.emptyHint": "Pick “{other}” to see the other side",
177
- "diff.untracked": "Untracked file: compared against the empty blob",
178
- "diff.binary": "Binary file — there is no text diff to show",
179
- "diff.truncated": "Diff too large — only the beginning is shown",
180
- "diff.skipped": "{count} more untracked file(s) were not expanded",
181
- "diff.showAll": "Show all ({count} lines)",
182
- "diff.wrap": "Wrap lines",
183
- "diff.copy": "Copy diff",
184
- "diff.copied": "Copied",
185
- "diff.refresh": "Reload the diff",
186
- "diff.loading": "Loading the diff…",
187
- "diff.failed": "Could not read the diff",
188
- "log.title": "Recent commits",
189
- "log.none": "No commits yet",
190
- "busy.fetch": "Fetching…",
191
- "busy.pull": "Pulling…",
192
- "busy.stage": "Staging…",
193
- "busy.generate": "Drafting…",
194
- "busy.commit": "Committing…",
195
- "busy.push": "Pushing…",
196
- "busy.checkout": "Switching…",
197
- "busy.createBranch": "Creating branch…",
198
- "output.ok": "Operation succeeded",
199
- "dock.aria": "Open Git panel",
200
- "status.modified": "modified",
201
- "status.added": "added",
202
- "status.deleted": "deleted",
203
- "status.renamed": "renamed",
204
- "status.copied": "copied",
205
- "status.untracked": "untracked",
206
- "status.conflict": "conflict",
207
- "status.type-changed": "type changed",
208
- "status.changed": "changed"
209
- };
210
-
211
- /** React.createElement shorthand. */
212
- function h(type, props) {
213
- var children = Array.prototype.slice.call(arguments, 2);
214
- return React.createElement.apply(React, [type, props].concat(children));
215
- }
216
-
217
- /** Compact inline styles tuned to the shell overlay / composer dock. */
218
- var S = {
219
- panel: {
220
- position: "fixed",
221
- // Sit above the composer card so the expanded panel never covers
222
- // the input box (the input.left pill sits in the tool row).
223
- bottom: 168,
224
- left: "50%",
225
- transform: "translateX(-50%)",
226
- zIndex: 30,
227
- // border-box so `width` below is the OUTER width: the two-pane math
228
- // (workbench + splitter + diff) and the viewport clamp both count
229
- // real pixels, and a content-box width silently added the border on
230
- // top of them.
231
- boxSizing: "border-box",
232
- width: 400,
233
- maxWidth: "calc(100vw - 24px)",
234
- maxHeight: "60vh",
235
- display: "flex",
236
- flexDirection: "column",
237
- border: "1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.3))",
238
- borderRadius: 12,
239
- background: "var(--dsw-alias-bg-base, #1e1e1e)",
240
- boxShadow: "var(--dsw-shadow-lv2, 0 8px 32px rgba(0,0,0,0.45))",
241
- overflow: "hidden",
242
- pointerEvents: "auto"
243
- },
244
- header: {
245
- display: "flex",
246
- alignItems: "center",
247
- gap: 8,
248
- padding: "8px 12px",
249
- borderBottom: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))",
250
- flex: "none",
251
- cursor: "move",
252
- userSelect: "none",
253
- touchAction: "none",
254
- // Painted above the right-edge resize handle, which spans the panel's
255
- // full height: the header's drag gesture and its buttons own that band.
256
- position: "relative",
257
- zIndex: 1
258
- },
259
- title: {
260
- fontSize: 13,
261
- fontWeight: 600,
262
- color: "var(--dsw-alias-label-primary, #e5e7eb)",
263
- flex: "1",
264
- minWidth: 0,
265
- overflow: "hidden",
266
- textOverflow: "ellipsis",
267
- whiteSpace: "nowrap"
268
- },
269
- body: {
270
- flex: "1",
271
- minHeight: 0,
272
- padding: "10px 12px 12px",
273
- overflowY: "auto"
274
- },
275
- row: {
276
- display: "flex",
277
- alignItems: "center",
278
- gap: 8,
279
- margin: "6px 0"
280
- },
281
- warn: {
282
- display: "flex",
283
- alignItems: "center",
284
- gap: 8,
285
- margin: "6px 0",
286
- padding: "6px 8px",
287
- borderRadius: 8,
288
- background: "var(--dsw-alias-state-warning-tertiary, rgba(245,158,11,0.14))",
289
- border: "1px solid var(--dsw-alias-state-warning-secondary, rgba(245,158,11,0.4))",
290
- color: "var(--dsw-alias-state-warning-primary, #f59e0b)",
291
- fontSize: 12,
292
- lineHeight: "16px"
293
- },
294
- chip: {
295
- background: "var(--dsw-alias-surface-tertiary, rgba(127,127,127,0.12))",
296
- color: "var(--dsw-alias-label-secondary, #c8ccd2)",
297
- borderRadius: 999,
298
- padding: "0 8px",
299
- fontWeight: 500,
300
- fontSize: 12,
301
- lineHeight: "20px",
302
- overflow: "hidden",
303
- textOverflow: "ellipsis",
304
- whiteSpace: "nowrap"
305
- },
306
- dirty: {
307
- color: "var(--dsw-alias-state-warn-primary, #f0a020)",
308
- fontWeight: 600,
309
- fontSize: 12
310
- },
311
- meta: {
312
- color: "var(--dsw-alias-label-quaternary, #6b7280)",
313
- fontSize: 12
314
- },
315
- button: {
316
- background: "var(--dsw-alias-interactive-bg-hover-solid, rgba(127,127,127,0.14))",
317
- border: "1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.3))",
318
- borderRadius: 8,
319
- color: "var(--dsw-alias-label-secondary, #c8ccd2)",
320
- font: "inherit",
321
- fontSize: 12,
322
- lineHeight: "22px",
323
- padding: "0 10px",
324
- cursor: "pointer",
325
- flex: "none"
326
- },
327
- buttonPrimary: {
328
- background: "var(--dsw-alias-state-business-primary, #4f8cff)",
329
- border: "1px solid transparent",
330
- borderRadius: 8,
331
- color: "#fff",
332
- font: "inherit",
333
- fontSize: 12,
334
- lineHeight: "22px",
335
- padding: "0 10px",
336
- cursor: "pointer",
337
- flex: "none"
338
- },
339
- buttonDisabled: {
340
- opacity: 0.5,
341
- cursor: "default"
342
- },
343
- select: {
344
- // colorScheme makes the NATIVE dropdown listbox follow the page
345
- // theme (dark list on dark pages) instead of the OS default
346
- // white; option/optgroup styles above reinforce it.
347
- colorScheme: "light dark",
348
- background: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.1))",
349
- border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
350
- borderRadius: 8,
351
- color: "var(--dsw-alias-label-secondary, #c8ccd2)",
352
- font: "inherit",
353
- fontSize: 12,
354
- lineHeight: "20px",
355
- padding: "2px 8px",
356
- maxWidth: 220,
357
- cursor: "pointer",
358
- flex: "1",
359
- minWidth: 0,
360
- outline: "none"
361
- },
362
- input: {
363
- background: "transparent",
364
- border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
365
- borderRadius: 8,
366
- color: "var(--dsw-alias-label-primary, #e5e7eb)",
367
- font: "inherit",
368
- fontSize: 12,
369
- lineHeight: "20px",
370
- padding: "2px 8px",
371
- flex: "1",
372
- minWidth: 0,
373
- outline: "none"
374
- },
375
- // The commit message is a real multi-line field: an <input type=text>
376
- // silently strips every line feed, so a drafted subject+body would lose
377
- // its body before the request was ever sent.
378
- textarea: {
379
- background: "transparent",
380
- border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
381
- borderRadius: 8,
382
- color: "var(--dsw-alias-label-primary, #e5e7eb)",
383
- font: "inherit",
384
- fontSize: 12,
385
- lineHeight: "18px",
386
- padding: "4px 8px",
387
- flex: "1",
388
- minWidth: 0,
389
- outline: "none",
390
- resize: "vertical",
391
- overflowY: "auto"
392
- },
393
- sectionTitle: {
394
- fontSize: 12,
395
- fontWeight: 600,
396
- color: "var(--dsw-alias-label-tertiary, #9aa0a6)",
397
- margin: "10px 0 4px",
398
- cursor: "pointer",
399
- userSelect: "none"
400
- },
401
- changeRow: {
402
- display: "flex",
403
- alignItems: "center",
404
- gap: 8,
405
- fontSize: 12,
406
- lineHeight: "20px",
407
- color: "var(--dsw-alias-label-secondary, #c8ccd2)",
408
- padding: "1px 6px",
409
- margin: "0 -6px",
410
- borderRadius: 6,
411
- minWidth: 0,
412
- cursor: "pointer"
413
- },
414
- changePath: {
415
- overflow: "hidden",
416
- textOverflow: "ellipsis",
417
- whiteSpace: "nowrap",
418
- flex: "1",
419
- minWidth: 0
420
- },
421
- // A change row is the diff viewer's navigation: it has to read as a
422
- // control, and to show which file the right-hand pane is showing.
423
- changeRowActive: {
424
- display: "flex",
425
- alignItems: "center",
426
- gap: 8,
427
- fontSize: 12,
428
- lineHeight: "20px",
429
- color: "var(--dsw-alias-label-primary, #e5e7eb)",
430
- padding: "1px 6px",
431
- margin: "0 -6px",
432
- borderRadius: 6,
433
- minWidth: 0,
434
- cursor: "pointer",
435
- background: "var(--dsw-alias-interactive-bg-hover-solid, rgba(127,127,127,0.18))"
436
- },
437
- // ── two-pane (changes + diff) layout ────────────────────────────
438
- // `bodySplit` replaces `body` while a file is selected: the left pane
439
- // keeps the whole workbench, the splitter is the width handle, and the
440
- // diff pane owns its own header + scrollport.
441
- bodySplit: {
442
- flex: "1",
443
- minHeight: 0,
444
- display: "flex",
445
- flexDirection: "row",
446
- overflow: "hidden"
447
- },
448
- leftPane: {
449
- flex: "none",
450
- // border-box: DIFF_LEFT_PANE_WIDTH is the room the workbench really
451
- // takes, so the pane width math and the panel edge agree.
452
- boxSizing: "border-box",
453
- width: 300,
454
- minWidth: 0,
455
- padding: "10px 12px 12px",
456
- // The two panes are separated by this edge instead of a draggable
457
- // divider: the width handle is the panel's own right edge.
458
- borderRight: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))",
459
- overflowY: "auto"
460
- },
461
- // The panel's right edge, as the diff's width handle (the whole panel
462
- // grows with it; the workbench keeps its width).
463
- resizeHandle: {
464
- position: "absolute",
465
- top: 0,
466
- right: 0,
467
- bottom: 0,
468
- width: 6,
469
- cursor: "col-resize",
470
- touchAction: "none",
471
- background: "transparent"
472
- },
473
- resizeHandleActive: {
474
- background: "var(--dsw-alias-interactive-bg-hover-solid, rgba(127,127,127,0.3))"
475
- },
476
- diffPane: {
477
- flex: "1",
478
- minWidth: 0,
479
- display: "flex",
480
- flexDirection: "column",
481
- overflow: "hidden"
482
- },
483
- diffHeader: {
484
- flex: "none",
485
- display: "flex",
486
- alignItems: "center",
487
- gap: 6,
488
- flexWrap: "wrap",
489
- padding: "8px 10px",
490
- borderBottom: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))"
491
- },
492
- diffPath: {
493
- flex: "1 1 auto",
494
- minWidth: 0,
495
- overflow: "hidden",
496
- textOverflow: "ellipsis",
497
- whiteSpace: "nowrap",
498
- fontSize: 12,
499
- fontWeight: 600,
500
- color: "var(--dsw-alias-label-primary, #e5e7eb)"
501
- },
502
- scopeChip: {
503
- flex: "none",
504
- lineHeight: "18px",
505
- padding: "0 8px",
506
- borderRadius: 999,
507
- border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
508
- background: "transparent",
509
- color: "var(--dsw-alias-label-tertiary, #9aa0a6)",
510
- font: "inherit",
511
- fontSize: 11,
512
- cursor: "pointer"
513
- },
514
- scopeChipActive: {
515
- flex: "none",
516
- lineHeight: "18px",
517
- padding: "0 8px",
518
- borderRadius: 999,
519
- border: "1px solid transparent",
520
- background: "var(--dsw-alias-surface-tertiary, rgba(127,127,127,0.18))",
521
- color: "var(--dsw-alias-label-primary, #e5e7eb)",
522
- font: "inherit",
523
- fontSize: 11,
524
- cursor: "pointer"
525
- },
526
- diffScroll: {
527
- flex: "1",
528
- minHeight: 0,
529
- overflow: "auto",
530
- padding: "6px 0"
531
- },
532
- diffRow: {
533
- display: "flex",
534
- alignItems: "flex-start",
535
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
536
- fontSize: 12,
537
- lineHeight: "18px",
538
- // `max-content` keeps a long line's row as wide as its text, so the
539
- // added/removed background runs the whole length of the horizontal
540
- // scroll. `diffRowView` drops it to 0 when wrapping is on, where it
541
- // would otherwise prevent the wrap from ever firing.
542
- minWidth: "max-content",
543
- width: "100%"
544
- },
545
- diffGutter: {
546
- flex: "none",
547
- width: 34,
548
- textAlign: "right",
549
- paddingRight: 6,
550
- color: "var(--dsw-alias-label-quaternary, #6b7280)",
551
- userSelect: "none",
552
- WebkitUserSelect: "none"
553
- },
554
- diffText: {
555
- flex: "1",
556
- minWidth: 0,
557
- paddingRight: 12,
558
- tabSize: 4,
559
- whiteSpace: "pre"
560
- },
561
- diffAddRow: {
562
- background: "rgba(52,199,89,0.14)",
563
- color: "var(--dsw-alias-state-success-primary, #34c759)"
564
- },
565
- diffDelRow: {
566
- background: "rgba(239,68,68,0.14)",
567
- color: "var(--dsw-alias-state-error-primary, #ef4444)"
568
- },
569
- diffHunkRow: {
570
- background: "rgba(79,140,255,0.12)",
571
- color: "var(--dsw-alias-state-business-primary, #4f8cff)"
572
- },
573
- diffMetaRow: {
574
- color: "var(--dsw-alias-label-quaternary, #6b7280)"
575
- },
576
- diffNotice: {
577
- margin: "6px 10px",
578
- padding: "6px 8px",
579
- borderRadius: 8,
580
- fontSize: 12,
581
- lineHeight: "18px",
582
- background: "var(--dsw-alias-surface-tertiary, rgba(127,127,127,0.12))",
583
- color: "var(--dsw-alias-label-secondary, #c8ccd2)"
584
- },
585
- statusTag: {
586
- flex: "none",
587
- fontSize: 11,
588
- lineHeight: "16px",
589
- padding: "0 6px",
590
- borderRadius: 4,
591
- background: "var(--dsw-alias-surface-tertiary, rgba(127,127,127,0.12))"
592
- },
593
- logRow: {
594
- display: "flex",
595
- alignItems: "center",
596
- gap: 8,
597
- fontSize: 12,
598
- lineHeight: "20px",
599
- color: "var(--dsw-alias-label-secondary, #c8ccd2)",
600
- padding: "1px 0",
601
- minWidth: 0
602
- },
603
- logSha: {
604
- flex: "none",
605
- color: "var(--dsw-alias-state-business-primary, #4f8cff)",
606
- fontFamily: "monospace"
607
- },
608
- output: {
609
- marginTop: 8,
610
- padding: "6px 10px",
611
- borderRadius: 8,
612
- fontSize: 12,
613
- lineHeight: "18px",
614
- wordBreak: "break-word",
615
- maxHeight: 160,
616
- overflowY: "auto",
617
- whiteSpace: "pre-wrap"
618
- },
619
- outputOk: {
620
- background: "var(--dsw-alias-state-success-tertiary, rgba(52,199,89,0.12))",
621
- color: "var(--dsw-alias-state-success-primary, #34c759)"
622
- },
623
- outputError: {
624
- background: "var(--dsw-alias-state-error-tertiary, rgba(239,68,68,0.12))",
625
- color: "var(--dsw-alias-state-error-primary, #ef4444)"
626
- },
627
- dockWrap: {
628
- display: "inline-flex",
629
- alignItems: "center",
630
- gap: 6,
631
- maxWidth: "100%",
632
- fontSize: 12,
633
- lineHeight: "20px",
634
- color: "var(--dsw-alias-label-tertiary, #9aa0a6)",
635
- whiteSpace: "nowrap",
636
- overflow: "hidden",
637
- textOverflow: "ellipsis",
638
- cursor: "pointer"
639
- }
640
- };
641
-
642
- /** Truncate a display string. */
643
- function shortText(value, max) {
644
- var text = typeof value === "string" ? value : String(value);
645
- return text.length > max ? text.slice(0, max - 1) + "…" : text;
646
- }
647
-
648
- /** Call one /dsh-git-rpc endpoint. */
649
- function rpc(ctx, endpoint, args) {
650
- return ctx.connection.rpc.call("/dsh-git-rpc", endpoint, { args: args || {} });
651
- }
652
-
653
- /**
654
- * Shared Git store: one load per cwd (deduplicated), real-time rebind on
655
- * session/workspace change, and post-action refresh so every seat (panel
656
- * + dock) sees the same truth. Module-level singleton per apply().
657
- */
658
- function createGitStore(ctx) {
659
- var verbs = {
660
- status: function (cwd) { return rpc(ctx, "status", { cwd: cwd }); },
661
- branches: function (cwd) { return rpc(ctx, "branches", { cwd: cwd }); },
662
- checkout: function (cwd, branch) { return rpc(ctx, "checkout", { cwd: cwd, branch: branch }); },
663
- createBranch: function (cwd, branch, base) { return rpc(ctx, "createBranch", { cwd: cwd, branch: branch, base: base }); },
664
- fetch: function (cwd) { return rpc(ctx, "fetch", { cwd: cwd }); },
665
- pull: function (cwd) { return rpc(ctx, "pull", { cwd: cwd }); },
666
- stage: function (cwd) { return rpc(ctx, "stage", { cwd: cwd }); },
667
- diff: function (cwd, file, origFile) {
668
- return rpc(ctx, "diff", { cwd: cwd, path: file, origPath: origFile });
669
- },
670
- commit: function (cwd, message) { return rpc(ctx, "commit", { cwd: cwd, message: message }); },
671
- push: function (cwd) { return rpc(ctx, "push", { cwd: cwd }); },
672
- log: function (cwd) { return rpc(ctx, "log", { cwd: cwd, count: 10 }); },
673
- generateMessage: function (cwd, mode, provider, model) {
674
- return rpc(ctx, "generateMessage", { cwd: cwd, mode: mode, provider: provider, model: model });
675
- }
676
- };
677
-
678
- /**
679
- * The Session this store follows. The session-scoped seat (the input
680
- * dock) binds it; dsh >= 0.1.6 removed `current` from the sessions
681
- * list snapshot, so the root-scoped panel can no longer find the
682
- * current Session by itself and follows this field instead.
683
- */
684
- var sessionId = null;
685
-
686
- function idleState() {
687
- return {
688
- sessionId: sessionId,
689
- cwd: null,
690
- phase: "idle",
691
- repo: false,
692
- branch: null,
693
- detached: false,
694
- oid: null,
695
- upstream: null,
696
- ahead: 0,
697
- behind: 0,
698
- dirty: 0,
699
- changes: [],
700
- local: [],
701
- remote: [],
702
- commits: [],
703
- error: null,
704
- busy: null,
705
- lastResult: null,
706
- panelOpen: false
707
- };
708
- }
709
-
710
- var state = idleState();
711
- var listeners = new Set();
712
- var seq = 0;
713
- var inflight = null;
714
-
715
- function emit(next) {
716
- state = next;
717
- for (var fn of Array.from(listeners)) {
718
- try { fn(); } catch (error) { console.error("[dsh-git] store listener threw:", error); }
719
- }
720
- }
721
-
722
- function getSnapshot() { return state; }
723
-
724
- function subscribe(fn) {
725
- listeners.add(fn);
726
- return function () { listeners.delete(fn); };
727
- }
728
-
729
- function applyData(nextCwd, status, branches, log) {
730
- var sv = status && status.ok === true ? status.value : null;
731
- var bv = branches && branches.ok === true ? branches.value : null;
732
- var lv = log && log.ok === true ? log.value : null;
733
- // A reload is passive: it must not erase the last operation's
734
- // output line, which `act()` sets *before* refreshing.
735
- if (sv === null) {
736
- return Object.assign(idleState(), {
737
- cwd: nextCwd,
738
- phase: "error",
739
- error: status && status.error ? String(status.error.message || "git status failed") : "git status failed",
740
- panelOpen: state.panelOpen,
741
- lastResult: state.lastResult
742
- });
743
- }
744
- return Object.assign(idleState(), {
745
- cwd: nextCwd,
746
- phase: "ready",
747
- repo: sv.repo === true,
748
- branch: sv.branch || null,
749
- detached: sv.detached === true,
750
- oid: sv.oid || null,
751
- upstream: sv.upstream || null,
752
- ahead: typeof sv.ahead === "number" ? sv.ahead : 0,
753
- behind: typeof sv.behind === "number" ? sv.behind : 0,
754
- dirty: typeof sv.dirty === "number" ? sv.dirty : 0,
755
- changes: Array.isArray(sv.changes) ? sv.changes : [],
756
- local: bv !== null && Array.isArray(bv.local) ? bv.local : [],
757
- remote: bv !== null && Array.isArray(bv.remote) ? bv.remote : [],
758
- commits: lv !== null && Array.isArray(lv.commits) ? lv.commits : [],
759
- panelOpen: state.panelOpen,
760
- lastResult: state.lastResult
761
- });
762
- }
763
-
764
- function doRefresh(cwd) {
765
- var mySeq = ++seq;
766
- emit(Object.assign({}, state, { sessionId: sessionId, cwd: cwd, phase: cwd ? "loading" : "idle", error: null }));
767
- if (!cwd) return Promise.resolve();
768
- return Promise.all([
769
- verbs.status(cwd),
770
- verbs.branches(cwd),
771
- verbs.log(cwd)
772
- ]).then(function (results) {
773
- if (mySeq !== seq) return;
774
- emit(applyData(cwd, results[0], results[1], results[2]));
775
- }).catch(function (error) {
776
- if (mySeq !== seq) return;
777
- emit(Object.assign(idleState(), {
778
- cwd: cwd,
779
- phase: "error",
780
- error: error && error.message ? String(error.message) : String(error),
781
- panelOpen: state.panelOpen,
782
- lastResult: state.lastResult
783
- }));
784
- });
785
- }
786
-
787
- function refresh(cwd) {
788
- if (inflight !== null && inflight.cwd === cwd) return inflight.promise;
789
- var promise = doRefresh(cwd);
790
- inflight = { cwd: cwd, promise: promise };
791
- promise.finally(function () {
792
- if (inflight !== null && inflight.cwd === cwd) inflight = null;
793
- }).catch(function () {});
794
- return promise;
795
- }
796
-
797
- /**
798
- * Bind the store to one Session and its workspace directory. The
799
- * session-scoped seat calls this with the identity and cwd the
800
- * framework gave it (`sessionId` slot prop + the sessions list it
801
- * reads through `useSessions`); everything else — the root-scoped
802
- * panel included — then reads the shared store.
803
- * @param id - the Session identity, or undefined/null for "none".
804
- * @param cwd - that Session's workspace directory, when it has one.
805
- * @returns the refresh promise, so tests can await the first load.
806
- */
807
- function bindSession(id, cwd) {
808
- var nextId = typeof id === "string" && id !== "" ? id : null;
809
- var nextCwd = typeof cwd === "string" && cwd !== "" ? cwd : null;
810
- if (nextId === sessionId && nextCwd === state.cwd) return undefined;
811
- sessionId = nextId;
812
- if (nextCwd === state.cwd) {
813
- // Two Sessions can share one workspace: the cwd is unchanged,
814
- // but the identity the panel resolves its model route from is
815
- // not, so publish it without another load.
816
- emit(Object.assign({}, state, { sessionId: sessionId }));
817
- return undefined;
818
- }
819
- return refresh(nextCwd);
820
- }
821
-
822
- /**
823
- * Run one action; on success force a fresh load; surfaces lastResult.
824
- * `describe` lets a caller own the wording of both outcomes (used by
825
- * generation, where the success value is the message itself and a
826
- * failure code maps to a localized string). Resolves with the raw
827
- * RPC result so the caller can also act on it.
828
- */
829
- function act(name, run, describe) {
830
- emit(Object.assign({}, state, { busy: name, lastResult: null }));
831
- return run().then(function (res) {
832
- var result = describe
833
- ? describe(res)
834
- : res && res.ok === true
835
- ? { kind: "ok", text: res.value && res.value.message ? String(res.value.message) : null }
836
- : { kind: "error", text: res && res.error && res.error.message ? String(res.error.message) : "operation failed" };
837
- emit(Object.assign({}, state, { busy: null, lastResult: result }));
838
- if (res && res.ok === true) {
839
- var cwd = state.cwd;
840
- return refresh(cwd).then(function () { return res; });
841
- }
842
- return res;
843
- }).catch(function (error) {
844
- emit(Object.assign({}, state, {
845
- busy: null,
846
- lastResult: { kind: "error", text: error && error.message ? String(error.message) : String(error) }
847
- }));
848
- });
849
- }
850
-
851
- function setPanelOpen(open) {
852
- emit(Object.assign({}, state, { panelOpen: open === true }));
853
- }
854
-
855
- return {
856
- getSnapshot: getSnapshot,
857
- subscribe: subscribe,
858
- refresh: refresh,
859
- bindSession: bindSession,
860
- act: act,
861
- setPanelOpen: setPanelOpen,
862
- verbs: verbs
863
- };
864
- }
865
-
866
- /** Theme-matched colors for the native branch <select> dropdown list. */
867
- var OPTION_STYLE = {
868
- background: "var(--dsw-alias-bg-base, #1e1e1e)",
869
- color: "var(--dsw-alias-label-secondary, #c8ccd2)"
870
- };
871
- var OPTGROUP_STYLE = {
872
- background: "var(--dsw-alias-bg-base, #1e1e1e)",
873
- color: "var(--dsw-alias-label-tertiary, #9aa0a6)",
874
- fontWeight: 600
875
- };
876
-
877
- /** One branch option list; remote picks carry the DWIM-able short name. */
878
- function branchOptions(state, t) {
879
- var local = state.local.map(function (entry) {
880
- return h("option", { key: "l:" + entry.name, value: entry.name, style: OPTION_STYLE }, entry.name + (entry.current ? " ✓" : ""));
881
- });
882
- var remote = state.remote.map(function (entry) {
883
- return h("option", { key: "r:" + entry.name, value: entry.short, style: OPTION_STYLE }, entry.short);
884
- });
885
- var children = [];
886
- if (local.length > 0) children.push(h("optgroup", { key: "lg", label: t("group.local"), style: OPTGROUP_STYLE }, local));
887
- if (remote.length > 0) children.push(h("optgroup", { key: "rg", label: t("group.remote"), style: OPTGROUP_STYLE }, remote));
888
- return children;
889
- }
890
-
891
- /**
892
- * Base-branch options for "new branch from…": local branch names plus
893
- * the FULL remote-tracking refs (e.g. `origin/feature/x`) so the host
894
- * can resolve them directly as the start point.
895
- */
896
- function baseOptions(state, t) {
897
- var local = state.local.map(function (entry) {
898
- return h("option", { key: "l:" + entry.name, value: entry.name, style: OPTION_STYLE }, entry.name + (entry.current ? " ✓" : ""));
899
- });
900
- var remote = state.remote.map(function (entry) {
901
- return h("option", { key: "r:" + entry.name, value: entry.name, style: OPTION_STYLE }, entry.name);
902
- });
903
- var children = [];
904
- if (local.length > 0) children.push(h("optgroup", { key: "lg", label: t("group.local"), style: OPTGROUP_STYLE }, local));
905
- if (remote.length > 0) children.push(h("optgroup", { key: "rg", label: t("group.remote"), style: OPTGROUP_STYLE }, remote));
906
- return children;
907
- }
908
-
909
- /** The row kinds one parsed unified diff can produce. */
910
- var DIFF_ROW = {
911
- context: "context",
912
- add: "add",
913
- del: "del",
914
- hunk: "hunk",
915
- fileHeader: "fileHeader",
916
- meta: "meta",
917
- noNewline: "noNewline"
918
- };
919
-
920
- /** git's own bookkeeping lines, recognized only OUTSIDE a hunk. */
921
- var DIFF_FILE_HEADER = /^(diff --git |index |--- |\+\+\+ |new file mode|deleted file mode|old mode |new mode |similarity index|dissimilarity index|rename from |rename to |copy from |copy to |Binary files |GIT binary patch)/;
922
-
923
- /**
924
- * Parse one unified diff into renderable rows.
925
- *
926
- * Line numbers are tracked from each hunk header, so every content row
927
- * carries the old and/or the new number it belongs to. `--- `/`+++ ` count
928
- * as file headers only outside a hunk: a deleted line whose own text starts
929
- * with `--` looks exactly like one, and misreading it would silently drop a
930
- * real change.
931
- * @param text - unified diff text (several files may be concatenated).
932
- * @returns `{ rows, additions, deletions, hunks }`.
933
- */
934
- function parseUnifiedDiff(text) {
935
- var rows = [];
936
- var additions = 0;
937
- var deletions = 0;
938
- var hunks = 0;
939
- var oldLine = 0;
940
- var newLine = 0;
941
- var inHunk = false;
942
- var lines = String(text === undefined || text === null ? "" : text).split("\n");
943
- for (var i = 0; i < lines.length; i += 1) {
944
- var line = lines[i];
945
- // git ends a patch with a newline, so the split leaves one empty tail.
946
- if (i === lines.length - 1 && line === "") continue;
947
- if (line.indexOf("diff --git ") === 0) {
948
- inHunk = false;
949
- rows.push({ kind: DIFF_ROW.fileHeader, text: line, oldLine: null, newLine: null });
950
- continue;
951
- }
952
- if (line.indexOf("@@") === 0) {
953
- var match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
954
- if (match !== null) {
955
- oldLine = Number(match[1]);
956
- newLine = Number(match[2]);
957
- }
958
- hunks += 1;
959
- inHunk = true;
960
- rows.push({ kind: DIFF_ROW.hunk, text: line, oldLine: null, newLine: null });
961
- continue;
962
- }
963
- if (!inHunk && DIFF_FILE_HEADER.test(line)) {
964
- rows.push({ kind: DIFF_ROW.fileHeader, text: line, oldLine: null, newLine: null });
965
- continue;
966
- }
967
- if (line.charAt(0) === "\\") {
968
- rows.push({ kind: DIFF_ROW.noNewline, text: line, oldLine: null, newLine: null });
969
- continue;
970
- }
971
- var marker = line.charAt(0);
972
- if (marker === "+") {
973
- rows.push({ kind: DIFF_ROW.add, text: line.slice(1), oldLine: null, newLine: newLine });
974
- newLine += 1;
975
- additions += 1;
976
- } else if (marker === "-") {
977
- rows.push({ kind: DIFF_ROW.del, text: line.slice(1), oldLine: oldLine, newLine: null });
978
- oldLine += 1;
979
- deletions += 1;
980
- } else if (marker === " ") {
981
- rows.push({ kind: DIFF_ROW.context, text: line.slice(1), oldLine: oldLine, newLine: newLine });
982
- oldLine += 1;
983
- newLine += 1;
984
- } else {
985
- rows.push({ kind: DIFF_ROW.meta, text: line, oldLine: null, newLine: null });
986
- }
987
- }
988
- return { rows: rows, additions: additions, deletions: deletions, hunks: hunks };
989
- }
990
-
991
- /**
992
- * Turn one side of the host's `diff` value into render state: the parsed
993
- * rows plus the flags that decide which notices the pane shows. Parsing
994
- * happens once per read, not per render, because a diff may be large.
995
- * @param side - `{ diff, binary, truncated }` as the host sent it.
996
- */
997
- function parseDiffSide(side) {
998
- var data = side !== null && typeof side === "object" ? side : {};
999
- var text = typeof data.diff === "string" ? data.diff : "";
1000
- var parsed = parseUnifiedDiff(text);
1001
- return {
1002
- empty: text.trim() === "",
1003
- binary: data.binary === true,
1004
- truncated: data.truncated === true,
1005
- text: text,
1006
- rows: parsed.rows,
1007
- additions: parsed.additions,
1008
- deletions: parsed.deletions,
1009
- hunks: parsed.hunks
1010
- };
1011
- }
1012
-
1013
- /** Rows rendered at once before the pane offers "show all". */
1014
- var DIFF_RENDER_MAX = 1500;
1015
- /** Width of the diff pane when a file is opened, and its readable floor. */
1016
- var DIFF_DEFAULT_WIDTH = 560;
1017
- var DIFF_MIN_WIDTH = 240;
1018
- /** Width of the workbench pane, the panel's own border, and its viewport margin. */
1019
- var DIFF_LEFT_PANE_WIDTH = 300;
1020
- var PANEL_BORDER_WIDTH = 2;
1021
- var PANEL_MARGIN = 24;
1022
- /** Grab width of the panel's right-edge resize handle. */
1023
- var PANEL_RESIZE_GRIP = 6;
1024
-
1025
- /** The last path segment, for chip-sized text. */
1026
- function baseName(file) {
1027
- var text = String(file === undefined || file === null ? "" : file);
1028
- var slash = Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\"));
1029
- return slash === -1 ? text : text.slice(slash + 1);
1030
- }
1031
-
1032
- /**
1033
- * One row of the diff body: the two line-number gutters, then the text.
1034
- * Colours come from the theme's state tokens (with rgba fallbacks), so the
1035
- * added/removed rows stay readable in both light and dark themes.
1036
- * @param row - a row from `parseUnifiedDiff`.
1037
- * @param key - React key.
1038
- * @param wrap - whether long lines wrap instead of scrolling.
1039
- */
1040
- function diffRowView(row, key, wrap) {
1041
- var style = Object.assign({}, S.diffRow);
1042
- // Unwrapped rows take their natural width so the horizontal scroll runs
1043
- // the patch's full length; a wrapped row must NOT keep `max-content`,
1044
- // or the row stays as wide as its longest line.
1045
- if (wrap === true) style = Object.assign(style, { minWidth: 0 });
1046
- if (row.kind === DIFF_ROW.add) style = Object.assign(style, S.diffAddRow);
1047
- else if (row.kind === DIFF_ROW.del) style = Object.assign(style, S.diffDelRow);
1048
- else if (row.kind === DIFF_ROW.hunk) style = Object.assign(style, S.diffHunkRow);
1049
- else if (row.kind !== DIFF_ROW.context) style = Object.assign(style, S.diffMetaRow);
1050
- var code = row.kind === DIFF_ROW.add ? "+" : row.kind === DIFF_ROW.del ? "-" : row.kind === DIFF_ROW.context ? " " : "";
1051
- var text = code === "" ? row.text : code + row.text;
1052
- // The wrap belongs on the TEXT span: `S.diffText` carries its own
1053
- // `white-space: pre`, which would win over the row's value and leave a
1054
- // wrapped row overflowing anyway.
1055
- var textStyle = wrap === true
1056
- ? Object.assign({}, S.diffText, { whiteSpace: "pre-wrap", wordBreak: "break-word" })
1057
- : S.diffText;
1058
- return h("div", {
1059
- key: key,
1060
- style: style,
1061
- "data-dsh-git": "diff-row",
1062
- "data-kind": row.kind
1063
- }, [
1064
- h("span", { key: "old", style: S.diffGutter }, row.oldLine === null || row.oldLine === undefined ? "" : String(row.oldLine)),
1065
- h("span", { key: "new", style: S.diffGutter }, row.newLine === null || row.newLine === undefined ? "" : String(row.newLine)),
1066
- h("span", { key: "text", style: textStyle }, text)
1067
- ]);
1068
- }
1069
-
1070
- /**
1071
- * The right-hand pane of the workbench: one changed file's unified diff.
1072
- *
1073
- * The pane owns its own reads, because its inputs are the selected path
1074
- * plus a data version the panel computes from the store (staging,
1075
- * committing, switching a branch, or refreshing all change it, so the open
1076
- * diff follows the repository without the user clicking anything). The side
1077
- * of the index shown follows the data by default — unstaged when it has
1078
- * anything, staged otherwise — and an explicit pick sticks until the file
1079
- * changes (the panel remounts this pane per file with a React key).
1080
- *
1081
- * @param props - `{ store, t, cwd, change, dataVersion, onClose }`.
1082
- */
1083
- function GitDiffPane(props) {
1084
- var store = props.store;
1085
- var t = props.t;
1086
- var cwd = props.cwd;
1087
- var change = props.change;
1088
- var dataVersion = props.dataVersion;
1089
- var onClose = props.onClose;
1090
-
1091
- var readRef = React.useState({ phase: "loading", error: null, value: null, worktree: null, index: null });
1092
- var read = readRef[0];
1093
- var setRead = readRef[1];
1094
- // null = follow the data (unstaged if it has anything, else staged).
1095
- var scopeRef = React.useState(null);
1096
- var scope = scopeRef[0];
1097
- var setScope = scopeRef[1];
1098
- var wrapRef = React.useState(false);
1099
- var wrap = wrapRef[0];
1100
- var setWrap = wrapRef[1];
1101
- var copiedRef = React.useState(false);
1102
- var copied = copiedRef[0];
1103
- var setCopied = copiedRef[1];
1104
- var showAllRef = React.useState(false);
1105
- var showAll = showAllRef[0];
1106
- var setShowAll = showAllRef[1];
1107
- var nonceRef = React.useState(0);
1108
- var nonce = nonceRef[0];
1109
- var setNonce = nonceRef[1];
1110
- var seqRef = React.useRef(0);
1111
- var copiedTimer = React.useRef(null);
1112
-
1113
- var file = change ? change.file : null;
1114
- var origFile = change ? change.origFile : null;
1115
-
1116
- // One effect owns the read: it bumps the sequence, shows the loading
1117
- // state, and only the newest answer is allowed to land (a stale reply
1118
- // from a previously selected file must never be rendered).
1119
- React.useEffect(function () {
1120
- if (!cwd || !file) return;
1121
- var mySeq = seqRef.current + 1;
1122
- seqRef.current = mySeq;
1123
- setRead({ phase: "loading", error: null, value: null, worktree: null, index: null });
1124
- store.verbs.diff(cwd, file, origFile).then(function (res) {
1125
- if (seqRef.current !== mySeq) return;
1126
- if (!res || res.ok !== true) {
1127
- var message = res && res.error && res.error.message ? String(res.error.message) : t("diff.failed");
1128
- setRead({ phase: "error", error: message, value: null, worktree: null, index: null });
1129
- return;
1130
- }
1131
- var value = res.value !== null && typeof res.value === "object" ? res.value : {};
1132
- if (value.repo === false) {
1133
- setRead({ phase: "error", error: t("notRepo"), value: null, worktree: null, index: null });
1134
- return;
1135
- }
1136
- setRead({
1137
- phase: "ready",
1138
- error: null,
1139
- value: value,
1140
- worktree: parseDiffSide(value.worktree),
1141
- index: parseDiffSide(value.index)
1142
- });
1143
- }).catch(function (error) {
1144
- if (seqRef.current !== mySeq) return;
1145
- setRead({
1146
- phase: "error",
1147
- error: error && error.message ? String(error.message) : t("diff.failed"),
1148
- value: null,
1149
- worktree: null,
1150
- index: null
1151
- });
1152
- });
1153
- }, [cwd, file, origFile, dataVersion, nonce, store]);
1154
-
1155
- React.useEffect(function () {
1156
- return function () {
1157
- if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
1158
- };
1159
- }, []);
1160
-
1161
- if (!change || !cwd) return null;
1162
-
1163
- var worktree = read.worktree;
1164
- var index = read.index;
1165
- var autoScope = worktree !== null && worktree.empty === false ? "worktree" : "index";
1166
- var side = scope === null ? autoScope : scope;
1167
- var active = side === "index" ? index : worktree;
1168
- var otherLabel = side === "index" ? t("diff.scope.worktree") : t("diff.scope.index");
1169
- var pathText = change.path ? String(change.path) : String(change.file);
1170
-
1171
- function pickScope(kind) {
1172
- setScope(kind);
1173
- setShowAll(false);
1174
- }
1175
-
1176
- /**
1177
- * One side chip. A side with nothing in it stays visible — the pair has
1178
- * to read as a switch — but cannot be picked while it is the other chip.
1179
- */
1180
- function scopeChip(kind) {
1181
- var target = kind === "index" ? index : worktree;
1182
- var isEmpty = target === null || target.empty === true;
1183
- var isActive = side === kind;
1184
- if (isEmpty && !isActive) {
1185
- return h("button", {
1186
- key: "scope-" + kind,
1187
- type: "button",
1188
- "data-dsh-git": "diff-scope-" + kind,
1189
- "aria-pressed": false,
1190
- style: Object.assign({}, S.scopeChip, { opacity: 0.45, cursor: "default" }),
1191
- disabled: true,
1192
- title: t("diff.empty")
1193
- }, t("diff.scope." + kind));
1194
- }
1195
- return h("button", {
1196
- key: "scope-" + kind,
1197
- type: "button",
1198
- "data-dsh-git": "diff-scope-" + kind,
1199
- style: isActive ? S.scopeChipActive : S.scopeChip,
1200
- title: t("diff.scope.title"),
1201
- "aria-pressed": isActive,
1202
- onClick: function () { pickScope(kind); }
1203
- }, t("diff.scope." + kind));
1204
- }
1205
-
1206
- function doCopy() {
1207
- var text = active !== null && typeof active.text === "string" ? active.text : "";
1208
- var clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
1209
- if (text === "" || !clipboard || typeof clipboard.writeText !== "function") return;
1210
- clipboard.writeText(text).then(function () {
1211
- setCopied(true);
1212
- if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
1213
- copiedTimer.current = setTimeout(function () { setCopied(false); }, 1500);
1214
- }).catch(function () {});
1215
- }
1216
-
1217
- var headerChildren = [
1218
- h("span", { key: "path", style: S.diffPath, title: String(change.file) }, shortText(pathText, 90)),
1219
- h("span", { key: "status", style: S.statusTag }, t("status." + change.status))
1220
- ];
1221
- if (read.phase === "ready" && active !== null && active.empty === false) {
1222
- headerChildren.push(h("span", { key: "stat", style: S.meta }, "+" + active.additions + " −" + active.deletions));
1223
- }
1224
- headerChildren.push(h("button", {
1225
- key: "close",
1226
- type: "button",
1227
- style: S.button,
1228
- title: t("diff.close"),
1229
- "aria-label": t("diff.close"),
1230
- "data-dsh-git": "diff-close",
1231
- onClick: onClose
1232
- }, "×"));
1233
- headerChildren.push(h("div", {
1234
- key: "tools",
1235
- style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", flexBasis: "100%", marginTop: 2 }
1236
- }, [
1237
- scopeChip("worktree"),
1238
- scopeChip("index"),
1239
- h("button", {
1240
- key: "wrap",
1241
- type: "button",
1242
- "data-dsh-git": "diff-wrap",
1243
- style: wrap === true ? S.scopeChipActive : S.scopeChip,
1244
- title: t("diff.wrap"),
1245
- "aria-pressed": wrap === true,
1246
- onClick: function () { setWrap(!wrap); }
1247
- }, t("diff.wrap")),
1248
- h("button", {
1249
- key: "copy",
1250
- type: "button",
1251
- "data-dsh-git": "diff-copy",
1252
- style: S.scopeChip,
1253
- title: t("diff.copy"),
1254
- onClick: doCopy
1255
- }, copied === true ? t("diff.copied") : t("diff.copy")),
1256
- h("button", {
1257
- key: "reload",
1258
- type: "button",
1259
- "data-dsh-git": "diff-reload",
1260
- style: S.scopeChip,
1261
- title: t("diff.refresh"),
1262
- "aria-label": t("diff.refresh"),
1263
- onClick: function () { setNonce(nonce + 1); }
1264
- }, "↻")
1265
- ]));
1266
-
1267
- var bodyChildren = [];
1268
- if (read.phase === "loading") {
1269
- bodyChildren.push(h("div", { key: "loading", style: S.diffNotice }, t("diff.loading")));
1270
- } else if (read.phase === "error") {
1271
- bodyChildren.push(h("div", { key: "error", style: Object.assign({}, S.diffNotice, S.outputError) }, shortText(read.error, 240)));
1272
- } else if (active !== null) {
1273
- var value = read.value !== null && typeof read.value === "object" ? read.value : {};
1274
- if (value.untracked === true) {
1275
- bodyChildren.push(h("div", { key: "untracked", style: S.diffNotice, "data-dsh-git": "diff-untracked" }, t("diff.untracked")));
1276
- }
1277
- if (typeof value.skipped === "number" && value.skipped > 0) {
1278
- bodyChildren.push(h("div", { key: "skipped", style: S.diffNotice }, t("diff.skipped", { count: value.skipped })));
1279
- }
1280
- if (active.truncated === true) {
1281
- bodyChildren.push(h("div", { key: "truncated", style: S.diffNotice }, t("diff.truncated")));
1282
- }
1283
- if (active.binary === true) {
1284
- bodyChildren.push(h("div", { key: "binary", style: S.diffNotice, "data-dsh-git": "diff-binary" }, t("diff.binary")));
1285
- } else if (active.empty === true) {
1286
- bodyChildren.push(h("div", {
1287
- key: "empty",
1288
- style: S.diffNotice,
1289
- "data-dsh-git": "diff-empty"
1290
- }, t("diff.empty") + " · " + t("diff.emptyHint", { other: otherLabel })));
1291
- } else {
1292
- var clipped = active.rows.length > DIFF_RENDER_MAX && showAll !== true;
1293
- var visible = clipped ? active.rows.slice(0, DIFF_RENDER_MAX) : active.rows;
1294
- for (var i = 0; i < visible.length; i += 1) {
1295
- bodyChildren.push(diffRowView(visible[i], "r" + i, wrap === true));
1296
- }
1297
- if (clipped) {
1298
- bodyChildren.push(h("button", {
1299
- key: "all",
1300
- type: "button",
1301
- style: Object.assign({}, S.button, { margin: "8px 10px" }),
1302
- onClick: function () { setShowAll(true); }
1303
- }, t("diff.showAll", { count: active.rows.length })));
1304
- }
1305
- }
1306
- }
1307
-
1308
- return h("div", { style: S.diffPane, "data-dsh-git": "diff" }, [
1309
- h("div", { key: "head", style: S.diffHeader, "data-dsh-git": "diff-header" }, headerChildren),
1310
- h("div", { key: "scroll", style: S.diffScroll, "data-dsh-git": "diff-body" }, bodyChildren)
1311
- ]);
1312
- }
1313
-
1314
- /**
1315
- * Floating workbench entry (shell.overlay). Renders nothing while
1316
- * collapsed — the persistent status lives in the input.left pill, and
1317
- * this panel appears only when the user expands it from that pill.
1318
- * Follows the current session cwd.
1319
- */
1320
- function GitFloatingPanel(props) {
1321
- var store = props.store;
1322
- var t = props.t || function (key, params) {
1323
- return key + (params ? " " + JSON.stringify(params) : "");
1324
- };
1325
- var useSessions = props.useSessions;
1326
- // useSessions is a selector hook (useSyncExternalStoreWithSelector):
1327
- // the selector argument is REQUIRED — a bare useSessions() crashes
1328
- // with "selector is not a function".
1329
- var sessions = useSessions(function (s) { return s; });
1330
-
1331
- // NOTE: the shell's seed react-dom is an experimental 18.3.1-next
1332
- // build where React.useSyncExternalStore misbehaves (returns
1333
- // undefined / throws), so subscribe through the classic
1334
- // useState + useEffect pattern instead — safe on any React.
1335
- var stateRef = React.useState(function () { return store.getSnapshot(); });
1336
- var state = stateRef[0];
1337
- var setState = stateRef[1];
1338
- React.useEffect(function () {
1339
- var unsubscribe = store.subscribe(function () {
1340
- setState(store.getSnapshot());
1341
- });
1342
- return unsubscribe;
1343
- }, [store]);
1344
-
1345
- // The session-scoped pill owns the session → cwd binding; this
1346
- // root-scoped seat follows the shared store. dsh >= 0.1.6 no longer
1347
- // carries `current` in the sessions list snapshot — it now selects
1348
- // the current Session through the renderer's scope adapter, which a
1349
- // root-scoped entry cannot read — so `state.sessionId` is the only
1350
- // identity reachable from here.
1351
- var cwd = state.cwd;
1352
- var sessionId = state.sessionId;
1353
- var currentSummary = sessionId !== null && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
1354
-
1355
- // The session's own model route, when one is recorded: the durable
1356
- // `modelSelection` projection, pending pick first, last used second.
1357
- // Generation falls back to the host's first registered route.
1358
- var sessionRoute = (function () {
1359
- var selection = currentSummary && currentSummary.projectionValues ? currentSummary.projectionValues.modelSelection : undefined;
1360
- if (!selection) return null;
1361
- var effective = selection.next || selection.lastUsed;
1362
- if (!effective || !effective.provider || !effective.model) return null;
1363
- return { provider: effective.provider, model: effective.model };
1364
- })();
1365
-
1366
- var messageRef = React.useState("");
1367
- var message = messageRef[0];
1368
- var setMessage = messageRef[1];
1369
- // Draft basis. `staged` is the default because it is the only basis
1370
- // whose content is what the commit below will actually record.
1371
- var generateModeRef = React.useState("staged");
1372
- var generateMode = generateModeRef[0];
1373
- var setGenerateMode = generateModeRef[1];
1374
- var changesOpenRef = React.useState(false);
1375
- var changesOpen = changesOpenRef[0];
1376
- var setChangesOpen = changesOpenRef[1];
1377
- var logOpenRef = React.useState(false);
1378
- var logOpen = logOpenRef[0];
1379
- var setLogOpen = logOpenRef[1];
1380
-
1381
- // Branch the user picked while the tree is dirty: the pre-check
1382
- // shows a warning instead of switching immediately; the switch only
1383
- // runs via the "switch anyway" button (or once the tree is clean).
1384
- var pendingRef = React.useState(null);
1385
- var pendingBranch = pendingRef[0];
1386
- var setPendingBranch = pendingRef[1];
1387
-
1388
- // "New branch from…" form state: open flag, the new branch name
1389
- // being typed, and the base branch explicitly picked by the user
1390
- // (null = default to the current branch on first open).
1391
- var newBranchOpenRef = React.useState(false);
1392
- var newBranchOpen = newBranchOpenRef[0];
1393
- var setNewBranchOpen = newBranchOpenRef[1];
1394
- var newBranchNameRef = React.useState("");
1395
- var newBranchName = newBranchNameRef[0];
1396
- var setNewBranchName = newBranchNameRef[1];
1397
- var newBranchBaseRef = React.useState(null);
1398
- var newBranchBase = newBranchBaseRef[0];
1399
- var setNewBranchBase = newBranchBaseRef[1];
1400
-
1401
- // ── draggable panel (header is the drag handle) ─────────────
1402
- // Default: centered above the composer (S.panel). After the first
1403
- // drag, switch to explicit left/top so the panel stays where the
1404
- // user dropped it. Double-click the header to snap back.
1405
- var panelRef = React.useRef(null);
1406
- var dragPosRef = React.useState(null);
1407
- var dragPos = dragPosRef[0];
1408
- var setDragPos = dragPosRef[1];
1409
-
1410
- function clampDragPos(left, top, width, height) {
1411
- var vw = (typeof window !== "undefined" && window.innerWidth) || 1920;
1412
- var vh = (typeof window !== "undefined" && window.innerHeight) || 1080;
1413
- var w = width || 400;
1414
- var hh = height || 200;
1415
- var maxLeft = Math.max(0, vw - Math.min(w, 120));
1416
- var maxTop = Math.max(0, vh - 40);
1417
- return {
1418
- left: Math.min(Math.max(0, left), maxLeft),
1419
- top: Math.min(Math.max(0, top), maxTop)
1420
- };
1421
- }
1422
-
1423
- function onPanelHeaderMouseDown(event) {
1424
- if (event.button !== undefined && event.button !== 0) return;
1425
- var target = event.target;
1426
- if (target && target.closest) {
1427
- try { if (target.closest("button,select,input,textarea,a")) return; } catch (ignore) {}
1428
- } else if (target && /^(BUTTON|SELECT|INPUT|TEXTAREA|A)$/.test(target.tagName || "")) {
1429
- return;
1430
- }
1431
- var panelEl = panelRef.current;
1432
- if (!panelEl || !panelEl.getBoundingClientRect) return;
1433
- var doc = (typeof document !== "undefined") ? document : null;
1434
- if (!doc || !doc.addEventListener) return;
1435
- var rect = panelEl.getBoundingClientRect();
1436
- var startX = event.clientX;
1437
- var startY = event.clientY;
1438
- var baseLeft = rect.left;
1439
- var baseTop = rect.top;
1440
- if (event.preventDefault) event.preventDefault();
1441
- function onMove(e) {
1442
- var c = clampDragPos(
1443
- baseLeft + (e.clientX - startX),
1444
- baseTop + (e.clientY - startY),
1445
- rect.width, rect.height
1446
- );
1447
- setDragPos(c);
1448
- }
1449
- function onUp() {
1450
- doc.removeEventListener("mousemove", onMove);
1451
- doc.removeEventListener("mouseup", onUp);
1452
- try { doc.body.style.userSelect = ""; doc.body.style.cursor = ""; } catch (ignore2) {}
1453
- }
1454
- doc.addEventListener("mousemove", onMove);
1455
- doc.addEventListener("mouseup", onUp);
1456
- try { doc.body.style.userSelect = "none"; doc.body.style.cursor = "grabbing"; } catch (ignore3) {}
1457
- }
1458
-
1459
- function onPanelHeaderTouchStart(event) {
1460
- var touches = event.touches;
1461
- if (!touches || touches.length !== 1) return;
1462
- var target = event.target;
1463
- if (target && target.closest) {
1464
- try { if (target.closest("button,select,input,textarea,a")) return; } catch (ignore) {}
1465
- }
1466
- var panelEl = panelRef.current;
1467
- if (!panelEl || !panelEl.getBoundingClientRect) return;
1468
- var doc = (typeof document !== "undefined") ? document : null;
1469
- if (!doc || !doc.addEventListener) return;
1470
- var rect = panelEl.getBoundingClientRect();
1471
- var touch = touches[0];
1472
- var startX = touch.clientX;
1473
- var startY = touch.clientY;
1474
- var baseLeft = rect.left;
1475
- var baseTop = rect.top;
1476
- function onMove(e) {
1477
- var t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]);
1478
- if (!t) return;
1479
- if (e.preventDefault) e.preventDefault();
1480
- var c = clampDragPos(
1481
- baseLeft + (t.clientX - startX),
1482
- baseTop + (t.clientY - startY),
1483
- rect.width, rect.height
1484
- );
1485
- setDragPos(c);
1486
- }
1487
- function onEnd() {
1488
- doc.removeEventListener("touchmove", onMove);
1489
- doc.removeEventListener("touchend", onEnd);
1490
- doc.removeEventListener("touchcancel", onEnd);
1491
- }
1492
- doc.addEventListener("touchmove", onMove, { passive: false });
1493
- doc.addEventListener("touchend", onEnd);
1494
- doc.addEventListener("touchcancel", onEnd);
1495
- }
1496
-
1497
- // ── diff viewer state ───────────────────────────────────────────
1498
- // `selected` is the change the right-hand pane is showing (null = the
1499
- // panel is the plain single-column workbench), `diffWidth` is that
1500
- // pane's width, and the PANEL'S RIGHT EDGE is its handle.
1501
- var selectedRef = React.useState(null);
1502
- var selected = selectedRef[0];
1503
- var setSelected = selectedRef[1];
1504
- var diffWidthRef = React.useState(DIFF_DEFAULT_WIDTH);
1505
- var diffWidth = diffWidthRef[0];
1506
- var setDiffWidth = diffWidthRef[1];
1507
- var resizeHoverRef = React.useState(false);
1508
- var resizeHover = resizeHoverRef[0];
1509
- var setResizeHover = resizeHoverRef[1];
1510
-
1511
- /**
1512
- * Widen the pane only as far as the viewport allows.
1513
- * @param value - the wanted diff width.
1514
- * @param anchor - the left edge to grow from; defaults to the panel's
1515
- * current position, or to a symmetric margin while it is centred.
1516
- */
1517
- function clampDiffWidth(value, anchor) {
1518
- var vw = (typeof window !== "undefined" && window.innerWidth) || 1920;
1519
- var pos = anchor !== undefined ? anchor : dragPos;
1520
- var room = pos === null || pos === undefined ? vw - PANEL_MARGIN : vw - 12 - pos.left;
1521
- var available = room - DIFF_LEFT_PANE_WIDTH - PANEL_BORDER_WIDTH;
1522
- return Math.min(Math.max(DIFF_MIN_WIDTH, Math.round(value)), Math.max(DIFF_MIN_WIDTH, available));
1523
- }
1524
-
1525
- /**
1526
- * Start a width drag on the panel's right edge.
1527
- *
1528
- * The panel is anchored to its CURRENT left edge first: while it is
1529
- * centred, widening it moves the right edge by half the growth, so a
1530
- * centred panel would lag behind the pointer by 2×. Anchoring makes the
1531
- * edge follow the mouse exactly, and leaves the panel where the user
1532
- * found it.
1533
- */
1534
- function onPanelResizeMouseDown(event) {
1535
- if (event.button !== undefined && event.button !== 0) return;
1536
- var doc = (typeof document !== "undefined") ? document : null;
1537
- if (!doc || !doc.addEventListener) return;
1538
- var el = panelRef.current;
1539
- if (!el || !el.getBoundingClientRect) return;
1540
- var rect = el.getBoundingClientRect();
1541
- var anchor = { left: rect.left, top: rect.top };
1542
- var startX = event.clientX;
1543
- var base = diffWidth;
1544
- if (event.preventDefault) event.preventDefault();
1545
- setDragPos(anchor);
1546
- function onMove(e) {
1547
- setDiffWidth(clampDiffWidth(base + (e.clientX - startX), anchor));
1548
- }
1549
- function onUp() {
1550
- doc.removeEventListener("mousemove", onMove);
1551
- doc.removeEventListener("mouseup", onUp);
1552
- try { doc.body.style.userSelect = ""; doc.body.style.cursor = ""; } catch (ignore) {}
1553
- }
1554
- doc.addEventListener("mousemove", onMove);
1555
- doc.addEventListener("mouseup", onUp);
1556
- try { doc.body.style.userSelect = "none"; doc.body.style.cursor = "col-resize"; } catch (ignore2) {}
1557
- }
1558
-
1559
- function onPanelResizeTouchStart(event) {
1560
- var touches = event.touches;
1561
- if (!touches || touches.length !== 1) return;
1562
- var doc = (typeof document !== "undefined") ? document : null;
1563
- if (!doc || !doc.addEventListener) return;
1564
- var el = panelRef.current;
1565
- if (!el || !el.getBoundingClientRect) return;
1566
- var rect = el.getBoundingClientRect();
1567
- var anchor = { left: rect.left, top: rect.top };
1568
- var startX = touches[0].clientX;
1569
- var base = diffWidth;
1570
- setDragPos(anchor);
1571
- function onMove(e) {
1572
- var touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]);
1573
- if (!touch) return;
1574
- if (e.preventDefault) e.preventDefault();
1575
- setDiffWidth(clampDiffWidth(base + (touch.clientX - startX), anchor));
1576
- }
1577
- function onEnd() {
1578
- doc.removeEventListener("touchmove", onMove);
1579
- doc.removeEventListener("touchend", onEnd);
1580
- doc.removeEventListener("touchcancel", onEnd);
1581
- }
1582
- doc.addEventListener("touchmove", onMove, { passive: false });
1583
- doc.addEventListener("touchend", onEnd);
1584
- doc.addEventListener("touchcancel", onEnd);
1585
- }
1586
-
1587
- // A window that shrank can leave the pane wider than the room it has.
1588
- React.useEffect(function () {
1589
- if (typeof window === "undefined" || !window.addEventListener) return undefined;
1590
- function onResize() {
1591
- setDiffWidth(function (current) { return clampDiffWidth(current); });
1592
- }
1593
- window.addEventListener("resize", onResize);
1594
- return function () { window.removeEventListener("resize", onResize); };
1595
- }, [dragPos]);
1596
-
1597
- // The viewer follows the session's workspace; keeping a selection across
1598
- // a rebind would show another repository's file.
1599
- React.useEffect(function () {
1600
- setSelected(null);
1601
- }, [cwd]);
1602
-
1603
- /**
1604
- * Select one change for the viewer. Clicking the file already showing
1605
- * closes the pane (the panel returns to its compact single column), and
1606
- * the first selection opens the full change list — the list is the
1607
- * viewer's navigation, so leaving it behind "show more" hides half the
1608
- * feature.
1609
- */
1610
- function selectChange(change) {
1611
- var file = change.file !== undefined ? change.file : change.path;
1612
- var origFile = change.origFile !== undefined ? change.origFile : null;
1613
- if (selected !== null && selected.file === file && selected.origFile === origFile) {
1614
- setSelected(null);
1615
- return;
1616
- }
1617
- setSelected({
1618
- file: file,
1619
- origFile: origFile,
1620
- status: change.status,
1621
- path: change.path !== undefined ? change.path : file,
1622
- index: change.index,
1623
- worktree: change.worktree
1624
- });
1625
- if (!changesOpen) setChangesOpen(true);
1626
- }
1627
-
1628
- // Width follows what the panel is showing: the compact single column
1629
- // while nothing is selected, and the two-pane width (workbench + diff)
1630
- // once a file is open. Dragging the panel's right edge is what resizes
1631
- // the diff, so the panel grows and shrinks with it.
1632
- var panelWidth = selected === null ? S.panel.width : DIFF_LEFT_PANE_WIDTH + PANEL_BORDER_WIDTH + diffWidth;
1633
- var panelStyle = Object.assign({}, S.panel, {
1634
- width: panelWidth,
1635
- maxHeight: selected === null ? S.panel.maxHeight : "72vh"
1636
- });
1637
- if (dragPos) {
1638
- panelStyle = Object.assign(panelStyle, {
1639
- left: dragPos.left,
1640
- top: dragPos.top,
1641
- bottom: "auto",
1642
- transform: "none"
1643
- });
1644
- }
1645
-
1646
- if (cwd === null) {
1647
- // No Session bound (nothing to float over the input).
1648
- return null;
1649
- }
1650
-
1651
- var busy = state.busy;
1652
- var busyLabel = busy ? t("busy." + busy) : null;
1653
-
1654
- // ── collapsed state: render nothing ─────────────────────────────
1655
- // No floating bubble: the persistent status lives in the input.left
1656
- // pill; this panel only appears when the user clicks that pill.
1657
- if (!state.panelOpen) return null;
1658
-
1659
- // ── expanded panel ───────────────────────────────────────────────
1660
- var header = h("div", {
1661
- key: "header",
1662
- style: S.header,
1663
- onMouseDown: onPanelHeaderMouseDown,
1664
- onTouchStart: onPanelHeaderTouchStart,
1665
- onDoubleClick: function () { setDragPos(null); },
1666
- title: "拖动移动面板(双击复位)",
1667
- "data-dsh-git": "panel-drag"
1668
- }, [
1669
- h("span", { key: "title", style: S.title }, "⎇ " + t("panel.title")),
1670
- h("span", { key: "cwd", style: S.meta, title: cwd }, shortText(cwd, 60)),
1671
- h("button", {
1672
- key: "refresh",
1673
- type: "button",
1674
- style: S.button,
1675
- disabled: busy !== null,
1676
- onClick: function () { store.refresh(state.cwd); },
1677
- "aria-label": t("refresh.aria"),
1678
- title: t("refresh.aria")
1679
- }, "↻"),
1680
- h("button", {
1681
- key: "collapse",
1682
- type: "button",
1683
- style: S.button,
1684
- onClick: function () { store.setPanelOpen(false); },
1685
- "aria-label": t("panel.collapse"),
1686
- title: t("panel.collapse")
1687
- }, "–")
1688
- ]);
1689
-
1690
- var bodyChildren = [];
1691
- if (state.phase === "loading" || state.phase === "idle") {
1692
- bodyChildren.push(h("div", { key: "loading", style: S.meta }, t("loading")));
1693
- } else if (state.phase === "error") {
1694
- bodyChildren.push(h("div", { key: "error", style: { color: "var(--dsw-alias-state-error-primary, #ef4444)", fontSize: 12, lineHeight: "18px" } }, shortText(state.error, 240)));
1695
- } else if (!state.repo) {
1696
- bodyChildren.push(h("div", { key: "norepo", style: S.meta }, t("notRepo")));
1697
- } else {
1698
- // status line
1699
- var statusLine = [];
1700
- var branchText = state.branch;
1701
- if (!branchText && state.detached) {
1702
- branchText = t("detached") + (state.oid ? " " + state.oid.slice(0, 7) : "");
1703
- }
1704
- statusLine.push(h("span", { key: "chip", style: S.chip, title: branchText || cwd }, branchText || "—"));
1705
- if (state.dirty > 0) {
1706
- statusLine.push(h("span", { key: "dirty", style: S.dirty, title: t("dirty", { count: state.dirty }) }, "● " + state.dirty));
1707
- }
1708
- if (state.ahead > 0 || state.behind > 0) {
1709
- statusLine.push(h("span", { key: "ab", style: S.meta }, "↑" + state.ahead + " ↓" + state.behind));
1710
- }
1711
- if (state.upstream) {
1712
- statusLine.push(h("span", { key: "up", style: S.meta, title: state.upstream }, shortText(state.upstream, 40)));
1713
- }
1714
- bodyChildren.push(h("div", { key: "status", style: S.row }, statusLine));
1715
-
1716
- // branch switcher — dirty-tree pre-check: selecting a branch while
1717
- // the tree has uncommitted changes shows a warning instead of an
1718
- // immediate switch; the user decides (commit/stash, or force).
1719
- var selectChildren = [h("option", { key: "ph", value: "", disabled: true, style: OPTION_STYLE }, branchText || "—")];
1720
- Array.prototype.push.apply(selectChildren, branchOptions(state, t));
1721
- bodyChildren.push(h("div", { key: "branch", style: S.row }, [
1722
- h("select", {
1723
- key: "sel",
1724
- style: S.select,
1725
- value: state.branch || "",
1726
- disabled: busy !== null,
1727
- onChange: function (event) {
1728
- var value = event.target.value;
1729
- if (!value || value === state.branch) { setPendingBranch(null); return; }
1730
- if (state.dirty > 0) { setPendingBranch(value); return; }
1731
- store.act("checkout", function () { return store.verbs.checkout(state.cwd, value); });
1732
- },
1733
- "aria-label": t("switch.aria")
1734
- }, selectChildren),
1735
- h("button", {
1736
- key: "new",
1737
- type: "button",
1738
- style: Object.assign({}, S.button, busy !== null ? S.buttonDisabled : null),
1739
- disabled: busy !== null,
1740
- title: t("branch.newTitle"),
1741
- "aria-label": t("branch.new"),
1742
- onClick: function () {
1743
- if (!newBranchOpen && (newBranchBase === null || newBranchBase === "")) {
1744
- setNewBranchBase(state.branch || (state.local.length > 0 ? state.local[0].name : ""));
1745
- }
1746
- setNewBranchOpen(!newBranchOpen);
1747
- }
1748
- }, busy === "createBranch" ? t("busy.createBranch") : "+ " + t("branch.new"))
1749
- ]));
1750
- if (newBranchOpen) {
1751
- var effectiveBase = newBranchBase || state.branch || "";
1752
- var baseKids = baseOptions(state, t);
1753
- bodyChildren.push(h("div", { key: "newbranch", style: S.row }, [
1754
- h("input", {
1755
- key: "name",
1756
- type: "text",
1757
- style: S.input,
1758
- value: newBranchName,
1759
- placeholder: t("newBranch.placeholder"),
1760
- disabled: busy !== null,
1761
- onChange: function (event) { setNewBranchName(event.target.value); },
1762
- onKeyDown: function (event) {
1763
- if (event.key === "Enter" && newBranchName.trim() !== "" && effectiveBase !== "" && busy === null) {
1764
- doCreateBranch();
1765
- }
1766
- }
1767
- }),
1768
- h("select", {
1769
- key: "base",
1770
- style: S.select,
1771
- value: effectiveBase,
1772
- disabled: busy !== null,
1773
- title: t("newBranch.base"),
1774
- "aria-label": t("newBranch.base"),
1775
- onChange: function (event) { setNewBranchBase(event.target.value); }
1776
- }, baseKids)
1777
- ]));
1778
- bodyChildren.push(h("div", { key: "newbranchact", style: S.row }, [
1779
- h("button", {
1780
- key: "create",
1781
- type: "button",
1782
- style: Object.assign({}, S.buttonPrimary, busy !== null || newBranchName.trim() === "" || effectiveBase === "" ? S.buttonDisabled : null),
1783
- disabled: busy !== null || newBranchName.trim() === "" || effectiveBase === "",
1784
- title: newBranchName.trim() === "" ? t("newBranch.empty") : undefined,
1785
- onClick: doCreateBranch
1786
- }, busy === "createBranch" ? t("busy.createBranch") : t("newBranch.create")),
1787
- h("button", {
1788
- key: "cancel",
1789
- type: "button",
1790
- style: S.button,
1791
- disabled: busy !== null,
1792
- onClick: function () { setNewBranchOpen(false); }
1793
- }, t("newBranch.cancel"))
1794
- ]));
1795
-
1796
- function doCreateBranch() {
1797
- var nameText = newBranchName.trim();
1798
- var baseText = effectiveBase;
1799
- if (nameText === "" || baseText === "" || busy !== null) return;
1800
- setNewBranchName("");
1801
- setNewBranchOpen(false);
1802
- store.act("createBranch", function () { return store.verbs.createBranch(state.cwd, nameText, baseText); });
1803
- }
1804
- }
1805
- if (pendingBranch !== null && state.dirty > 0) {
1806
- var samplePaths = state.changes.slice(0, 3).map(function (change) { return change.path; }).join("、");
1807
- if (samplePaths === "") samplePaths = "…";
1808
- if (samplePaths.length > 60) samplePaths = samplePaths.slice(0, 57) + "…";
1809
- bodyChildren.push(h("div", { key: "dirtywarn", style: S.warn }, [
1810
- h("span", { key: "txt", style: { flex: "1", minWidth: 0 } },
1811
- t("switch.dirtyWarn", { count: state.dirty, paths: samplePaths })),
1812
- h("button", {
1813
- key: "force",
1814
- type: "button",
1815
- style: Object.assign({}, S.button, busy !== null ? S.buttonDisabled : null),
1816
- disabled: busy !== null,
1817
- onClick: function () {
1818
- var target = pendingBranch;
1819
- setPendingBranch(null);
1820
- store.act("checkout", function () { return store.verbs.checkout(state.cwd, target); });
1821
- }
1822
- }, busy === "checkout" ? t("busy.checkout") : t("switch.force"))
1823
- ]));
1824
- }
1825
-
1826
- // network + commit row
1827
- bodyChildren.push(h("div", { key: "net", style: S.row }, [
1828
- h("button", {
1829
- key: "fetch",
1830
- type: "button",
1831
- style: Object.assign({}, S.button, busy !== null ? S.buttonDisabled : null),
1832
- disabled: busy !== null,
1833
- onClick: function () { store.act("fetch", function () { return store.verbs.fetch(state.cwd); }); }
1834
- }, busy === "fetch" ? t("busy.fetch") : t("action.fetch")),
1835
- h("button", {
1836
- key: "pull",
1837
- type: "button",
1838
- style: Object.assign({}, S.button, busy !== null ? S.buttonDisabled : null),
1839
- disabled: busy !== null,
1840
- onClick: function () { store.act("pull", function () { return store.verbs.pull(state.cwd); }); }
1841
- }, busy === "pull" ? t("busy.pull") : t("action.pull")),
1842
- h("button", {
1843
- key: "push",
1844
- type: "button",
1845
- style: Object.assign({}, S.buttonPrimary, busy !== null ? S.buttonDisabled : null),
1846
- disabled: busy !== null,
1847
- onClick: function () { store.act("push", function () { return store.verbs.push(state.cwd); }); }
1848
- }, busy === "push" ? t("busy.push") : t("action.push"))
1849
- ]));
1850
-
1851
- // commit tooling: stage everything, pick the draft basis, draft.
1852
- // Ordering mirrors the real workflow (stage → draft → commit) and
1853
- // keeps the 400px panel readable by splitting the tool row from the
1854
- // message row.
1855
- var noChanges = state.dirty === 0;
1856
- bodyChildren.push(h("div", { key: "commitTools", style: S.row }, [
1857
- h("button", {
1858
- key: "stage",
1859
- type: "button",
1860
- style: Object.assign({}, S.button, busy !== null || noChanges ? S.buttonDisabled : null),
1861
- disabled: busy !== null || noChanges,
1862
- title: noChanges ? t("changes.none") : t("action.stage"),
1863
- onClick: function () { store.act("stage", function () { return store.verbs.stage(state.cwd); }); }
1864
- }, busy === "stage" ? t("busy.stage") : t("action.stage")),
1865
- h("select", {
1866
- key: "mode",
1867
- style: Object.assign({}, S.select, { flex: "none", width: "auto", maxWidth: 96 }),
1868
- value: generateMode,
1869
- disabled: busy !== null,
1870
- title: t("generate.mode.title"),
1871
- "aria-label": t("generate.mode.title"),
1872
- onChange: function (event) { setGenerateMode(event.target.value); }
1873
- }, [
1874
- h("option", { key: "staged", value: "staged", style: OPTION_STYLE }, t("generate.mode.staged")),
1875
- h("option", { key: "unstaged", value: "unstaged", style: OPTION_STYLE }, t("generate.mode.unstaged")),
1876
- h("option", { key: "all", value: "all", style: OPTION_STYLE }, t("generate.mode.all"))
1877
- ]),
1878
- h("button", {
1879
- key: "generate",
1880
- type: "button",
1881
- style: Object.assign({}, S.button, busy !== null || noChanges ? S.buttonDisabled : null),
1882
- disabled: busy !== null || noChanges,
1883
- title: noChanges ? t("changes.none") : t("action.generate"),
1884
- onClick: doGenerate
1885
- }, busy === "generate" ? t("busy.generate") : "✨ " + t("action.generate"))
1886
- ]));
1887
-
1888
- bodyChildren.push(h("div", { key: "commit", style: S.row }, [
1889
- h("textarea", {
1890
- key: "msg",
1891
- rows: 2,
1892
- style: S.textarea,
1893
- value: message,
1894
- placeholder: t("commit.placeholder"),
1895
- disabled: busy !== null,
1896
- onChange: function (event) { setMessage(event.target.value); },
1897
- // Enter inserts a newline (a message may have a body); the
1898
- // explicit commit chord is Ctrl/Cmd+Enter.
1899
- onKeyDown: function (event) {
1900
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && message.trim() !== "" && busy === null) {
1901
- event.preventDefault();
1902
- doCommit();
1903
- }
1904
- }
1905
- }),
1906
- h("button", {
1907
- key: "commit",
1908
- type: "button",
1909
- style: Object.assign({}, S.buttonPrimary, busy !== null || message.trim() === "" ? S.buttonDisabled : null),
1910
- disabled: busy !== null || message.trim() === "",
1911
- title: message.trim() === "" ? t("commit.empty") : undefined,
1912
- onClick: doCommit
1913
- }, busy === "commit" ? t("busy.commit") : t("action.commit"))
1914
- ]));
1915
-
1916
- function doCommit() {
1917
- var text = message.trim();
1918
- if (text === "" || busy !== null) return;
1919
- setMessage("");
1920
- store.act("commit", function () { return store.verbs.commit(state.cwd, text); });
1921
- }
1922
-
1923
- /**
1924
- * Word one generation outcome. The host reports a stable code in
1925
- * `error.details.code`; known codes get a localized sentence and
1926
- * anything unrecognized falls through to the host's own message.
1927
- */
1928
- function describeGenerate(res) {
1929
- if (res && res.ok === true) {
1930
- return { kind: "ok", text: t("generate.done") };
1931
- }
1932
- var details = res && res.error && res.error.details ? res.error.details : {};
1933
- var known = {
1934
- "no-changes": details.mode === "staged" ? t("generate.noStaged") : t("generate.noChanges"),
1935
- "no-provider": t("generate.noProvider"),
1936
- "no-model": t("generate.noModel"),
1937
- "llm-empty": t("generate.empty"),
1938
- "cancelled": t("generate.cancelled")
1939
- };
1940
- var text = known[details.code];
1941
- if (text === undefined) {
1942
- text = res && res.error && res.error.message ? String(res.error.message) : t("generate.failed");
1943
- }
1944
- return { kind: "error", text: text };
1945
- }
1946
-
1947
- function doGenerate() {
1948
- if (busy !== null) return;
1949
- var route = sessionRoute;
1950
- store.act("generate", function () {
1951
- return store.verbs.generateMessage(
1952
- state.cwd,
1953
- generateMode,
1954
- route ? route.provider : undefined,
1955
- route ? route.model : undefined
1956
- );
1957
- }, describeGenerate).then(function (res) {
1958
- if (res && res.ok === true && res.value && res.value.message) {
1959
- setMessage(String(res.value.message));
1960
- }
1961
- });
1962
- }
1963
-
1964
- // changes (one row per file) — collapsible, and the diff viewer's
1965
- // navigation: clicking a row opens that file's diff on the right.
1966
- var changesRow;
1967
- if (state.changes.length === 0) {
1968
- changesRow = h("div", { key: "none", style: S.meta }, t("changes.none"));
1969
- } else {
1970
- var shown = changesOpen ? state.changes : state.changes.slice(0, 8);
1971
- changesRow = h("div", { key: "list" }, shown.map(function (change, index) {
1972
- var file = change.file !== undefined ? change.file : change.path;
1973
- var origFile = change.origFile !== undefined ? change.origFile : null;
1974
- var isActive = selected !== null && selected.file === file && selected.origFile === origFile;
1975
- return h("div", {
1976
- key: index,
1977
- style: isActive ? S.changeRowActive : S.changeRow,
1978
- role: "button",
1979
- tabIndex: 0,
1980
- title: t("diff.open"),
1981
- "aria-label": t("diff.open") + " " + change.path,
1982
- "data-dsh-git": "change-row",
1983
- "data-active": isActive === true ? "true" : "false",
1984
- onClick: function () { selectChange(change); },
1985
- onKeyDown: function (event) {
1986
- if (event.key === "Enter" || event.key === " ") {
1987
- if (event.preventDefault) event.preventDefault();
1988
- selectChange(change);
1989
- }
1990
- }
1991
- }, [
1992
- h("span", { key: "tag", style: S.statusTag }, t("status." + change.status)),
1993
- h("span", { key: "path", style: S.changePath, title: change.path }, change.path)
1994
- ]);
1995
- }));
1996
- if (state.changes.length > shown.length) {
1997
- changesRow = h("div", { key: "list" }, [
1998
- changesRow,
1999
- h("button", {
2000
- key: "more",
2001
- type: "button",
2002
- style: S.button,
2003
- onClick: function () { setChangesOpen(!changesOpen); }
2004
- }, changesOpen ? "▴" : "▾ " + (state.changes.length - shown.length))
2005
- ]);
2006
- }
2007
- }
2008
- bodyChildren.push(h("div", { key: "changes", style: S.sectionTitle, onClick: function () { setChangesOpen(!changesOpen); } }, (changesOpen ? "▾ " : "▸ ") + t("changes.title", { count: state.changes.length })));
2009
- bodyChildren.push(h("div", { key: "changesBody" }, changesRow));
2010
-
2011
- // recent commits — collapsible
2012
- var logRows;
2013
- if (state.commits.length === 0) {
2014
- logRows = h("div", { key: "none", style: S.meta }, t("log.none"));
2015
- } else {
2016
- var logShown = logOpen ? state.commits : state.commits.slice(0, 5);
2017
- logRows = h("div", { key: "list" }, logShown.map(function (commit, index) {
2018
- return h("div", { key: index, style: S.logRow, title: commit.subject }, [
2019
- h("span", { key: "sha", style: S.logSha }, commit.sha),
2020
- h("span", { key: "subj", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "1", minWidth: 0 } }, commit.subject)
2021
- ]);
2022
- }));
2023
- if (state.commits.length > logShown.length) {
2024
- logRows = h("div", { key: "list" }, [
2025
- logRows,
2026
- h("button", {
2027
- key: "more",
2028
- type: "button",
2029
- style: S.button,
2030
- onClick: function () { setLogOpen(!logOpen); }
2031
- }, logOpen ? "▴" : "▾ " + (state.commits.length - logShown.length))
2032
- ]);
2033
- }
2034
- }
2035
- bodyChildren.push(h("div", { key: "logTitle", style: S.sectionTitle, onClick: function () { setLogOpen(!logOpen); } }, (logOpen ? "▾ " : "▸ ") + t("log.title")));
2036
- bodyChildren.push(h("div", { key: "logBody" }, logRows));
2037
- }
2038
-
2039
- // last operation output
2040
- if (state.lastResult) {
2041
- bodyChildren.push(h("div", {
2042
- key: "output",
2043
- style: Object.assign({}, S.output, state.lastResult.kind === "ok" ? S.outputOk : S.outputError)
2044
- }, state.lastResult.text || t("output.ok")));
2045
- }
2046
- if (busy) {
2047
- bodyChildren.push(h("div", { key: "busy", style: S.meta }, busyLabel));
2048
- }
2049
-
2050
- // Everything the open diff depends on, as one comparable string: a
2051
- // commit moves the oid, a branch switch moves several of these, a
2052
- // session switch changes the cwd, and STAGING moves the per-file index
2053
- // letters. The letters are in here, not just the file count, because
2054
- // `git add` leaves the count and the dirty tally untouched while moving
2055
- // the change from the worktree side to the index side — the exact move
2056
- // the open diff has to follow.
2057
- var dataVersion = [
2058
- state.cwd,
2059
- state.oid,
2060
- state.branch,
2061
- state.dirty,
2062
- state.changes.map(function (change) {
2063
- return (change.index !== undefined ? change.index : " ") + (change.worktree !== undefined ? change.worktree : " ") + change.file;
2064
- }).join(",")
2065
- ].join("|");
2066
-
2067
- // The pane's state chip follows the CURRENT status of the file, not the
2068
- // one captured when it was clicked: staging or committing moves a file
2069
- // between the list's labels while the pane stays open on it.
2070
- var liveSelection = selected;
2071
- if (selected !== null) {
2072
- for (var ci = 0; ci < state.changes.length; ci += 1) {
2073
- var candidate = state.changes[ci];
2074
- var candidateFile = candidate.file !== undefined ? candidate.file : candidate.path;
2075
- var candidateOrig = candidate.origFile !== undefined ? candidate.origFile : null;
2076
- if (candidateFile === selected.file && candidateOrig === selected.origFile) {
2077
- liveSelection = Object.assign({}, selected, { status: candidate.status, path: candidate.path });
2078
- break;
2079
- }
2080
- }
2081
- }
2082
-
2083
- var body = selected === null
2084
- ? h("div", { key: "body", style: S.body }, bodyChildren)
2085
- : h("div", { key: "body", style: S.bodySplit }, [
2086
- h("div", { key: "workbench", style: S.leftPane, "data-dsh-git": "panel-left" }, bodyChildren),
2087
- h(GitDiffPane, {
2088
- // Keyed by file so a new selection starts with fresh view state
2089
- // (side choice, wrapping, "show all") instead of inheriting the
2090
- // previous file's.
2091
- key: "diff:" + selected.file + "\u0000" + (selected.origFile === null || selected.origFile === undefined ? "" : selected.origFile),
2092
- store: store,
2093
- t: t,
2094
- cwd: state.cwd,
2095
- change: liveSelection,
2096
- dataVersion: dataVersion,
2097
- onClose: function () { setSelected(null); }
2098
- })
2099
- ]);
2100
-
2101
- // The diff's width handle IS the panel's right edge: dragging it widens
2102
- // the panel (the workbench keeps its width) and the diff takes the rest.
2103
- // It covers the full height but sits under the header, so the header's
2104
- // own drag and its buttons keep the pointer in that band.
2105
- var resizeHandle = selected === null ? null : h("div", {
2106
- key: "resize",
2107
- "data-dsh-git": "panel-resize",
2108
- style: resizeHover === true ? Object.assign({}, S.resizeHandle, S.resizeHandleActive) : S.resizeHandle,
2109
- role: "separator",
2110
- "aria-orientation": "vertical",
2111
- "aria-label": t("diff.resize"),
2112
- title: t("diff.resize"),
2113
- onMouseDown: onPanelResizeMouseDown,
2114
- onTouchStart: onPanelResizeTouchStart,
2115
- onDoubleClick: function () { setDiffWidth(clampDiffWidth(DIFF_DEFAULT_WIDTH)); },
2116
- onMouseEnter: function () { setResizeHover(true); },
2117
- onMouseLeave: function () { setResizeHover(false); }
2118
- });
2119
-
2120
- return h("div", { "data-dsh-git": "panel", style: panelStyle, ref: panelRef }, [header, body, resizeHandle]);
2121
- }
2122
-
2123
- /**
2124
- * Input-dock pill (conversation.input.dock): a compact status capsule,
2125
- * left-aligned above the composer card (textarea top-left); clicking
2126
- * toggles the workbench panel. Reads the same store, so it stays in
2127
- * sync with the panel and with session switches.
2128
- */
2129
- function GitDockLine(props) {
2130
- var store = props.store;
2131
- var t = props.t || function (key, params) {
2132
- return key + (params ? " " + JSON.stringify(params) : "");
2133
- };
2134
- // This seat is session-scoped, so the framework hands it the current
2135
- // Session identity; that `sessionId` is the one thing a root-scoped
2136
- // seat (the panel) cannot read for itself under dsh >= 0.1.6.
2137
- var sessionId = props.sessionId;
2138
- var useSessions = props.useSessions;
2139
- // useSessions is a selector hook (useSyncExternalStoreWithSelector):
2140
- // the selector argument is REQUIRED — a bare useSessions() crashes
2141
- // with "selector is not a function".
2142
- var sessions = useSessions(function (s) { return s; });
2143
- var summary = sessionId !== undefined && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
2144
- var cwd = summary ? summary.cwd : undefined;
2145
-
2146
- // Embedded Conversations (a right-sidebar chat tab) carry their own
2147
- // Session binding; only the main-view seat may drive the shared
2148
- // workbench, or a subagent chat would pull the panel's repo out from
2149
- // under the Session the user is looking at. `retainedBy.mainView` is
2150
- // the same signal ui-session derives the current Session from; when
2151
- // the snapshot does not carry it (an older host) the seat binds
2152
- // anyway, so the pill never disappears for want of a count.
2153
- var mainView = summary === undefined || summary.retainedBy === undefined || summary.retainedBy === null
2154
- ? true
2155
- : (summary.retainedBy.mainView || 0) > 0;
2156
-
2157
- // Classic subscription pattern (see GitFloatingPanel for why).
2158
- var stateRef = React.useState(function () { return store.getSnapshot(); });
2159
- var state = stateRef[0];
2160
- var setState = stateRef[1];
2161
- React.useEffect(function () {
2162
- var unsubscribe = store.subscribe(function () {
2163
- setState(store.getSnapshot());
2164
- });
2165
- return unsubscribe;
2166
- }, [store]);
2167
-
2168
- // The pill owns the store's session binding: it is the only seat that
2169
- // knows the current Session's identity and workspace directory.
2170
- React.useEffect(function () {
2171
- if (!mainView) return;
2172
- store.bindSession(sessionId, cwd);
2173
- }, [sessionId, cwd, mainView, store]);
2174
-
2175
- if (!cwd || !mainView || state.sessionId !== sessionId) return null;
2176
-
2177
- var text;
2178
- if (state.phase === "loading" || state.phase === "idle") {
2179
- text = "⎇ " + t("loading");
2180
- } else if (state.phase === "error" || !state.repo) {
2181
- text = "⎇ " + t("notRepo");
2182
- } else {
2183
- var branchText = state.branch;
2184
- if (!branchText && state.detached) {
2185
- branchText = t("detached") + (state.oid ? " " + state.oid.slice(0, 7) : "");
2186
- }
2187
- text = "⎇ " + (branchText || "—");
2188
- if (state.dirty > 0) text += " ●" + state.dirty;
2189
- if (state.ahead > 0 || state.behind > 0) text += " ↑" + state.ahead + " ↓" + state.behind;
2190
- }
2191
-
2192
- // Left-aligned compact pill in the row band ABOVE the composer card
2193
- // (conversation.input.dock). The padding-left mirrors the centered
2194
- // card's left margin, so the pill hugs the textarea's top-left
2195
- // corner instead of floating at the conversation column's edge.
2196
- return h("div", {
2197
- style: {
2198
- boxSizing: "border-box",
2199
- display: "flex",
2200
- justifyContent: "flex-start",
2201
- width: "100%",
2202
- paddingLeft: "calc((100% - var(--dsh-composer-card-max-width, 778px)) / 2)",
2203
- margin: "2px 0"
2204
- },
2205
- "data-dsh-git": "dock-row"
2206
- }, h("button", {
2207
- type: "button",
2208
- style: {
2209
- display: "inline-flex",
2210
- alignItems: "center",
2211
- gap: 6,
2212
- maxWidth: 220,
2213
- padding: "1px 10px",
2214
- borderRadius: 999,
2215
- border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
2216
- background: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.12))",
2217
- color: "var(--dsw-alias-label-secondary, #c8ccd2)",
2218
- font: "inherit",
2219
- fontSize: 12,
2220
- lineHeight: "20px",
2221
- cursor: "pointer",
2222
- whiteSpace: "nowrap",
2223
- overflow: "hidden",
2224
- textOverflow: "ellipsis",
2225
- userSelect: "none"
2226
- },
2227
- "data-dsh-git": "dock",
2228
- title: cwd,
2229
- "aria-label": t("dock.aria"),
2230
- onClick: function () { store.setPanelOpen(!state.panelOpen); }
2231
- }, text));
2232
- }
2233
-
2234
- /**
2235
- * Client plugin body.
2236
- * @param ctx - client root context (slots, connection, locale).
2237
- */
2238
- function apply(ctx) {
2239
- ctx.effect(function () {
2240
- return ctx.locale.register(NS, { zh: zh, en: en });
2241
- }, "dsh-git: dictionaries");
2242
-
2243
- var store = createGitStore(ctx);
2244
-
2245
- ctx.slots.inject("shell.overlay", function () {
2246
- return ctx.slots.register({
2247
- name: "shell.overlay",
2248
- id: "dsh-git-panel",
2249
- order: 10,
2250
- locale: NS,
2251
- inject: function () {
2252
- return { store: store };
2253
- }
2254
- }, GitFloatingPanel);
2255
- });
2256
-
2257
- ctx.slots.inject("conversation.input.dock", function () {
2258
- return ctx.slots.register({
2259
- name: "conversation.input.dock",
2260
- id: "dsh-git-pill",
2261
- order: 20,
2262
- locale: NS,
2263
- inject: function (sessionId) {
2264
- return { store: store };
2265
- }
2266
- }, GitDockLine);
2267
- });
2268
- }
2269
-
2270
- exports.apply = apply;
2271
- exports.inject = inject;
2272
- exports.name = name;
2273
- // Test-only surface: the module-loader contract reads `apply`, `inject`,
2274
- // and `name` and ignores everything else, while the render test needs the
2275
- // diff parser and one row renderer without a browser (SSR runs no effects,
2276
- // so the pane's own reads cannot be driven from a static render).
2277
- exports.__internals = {
2278
- parseUnifiedDiff: parseUnifiedDiff,
2279
- parseDiffSide: parseDiffSide,
2280
- diffRowView: diffRowView,
2281
- GitDiffPane: GitDiffPane,
2282
- DIFF_ROW: DIFF_ROW
2283
- };
2284
- return module.exports;
2285
- }
2286
- });
1
+ /**
2
+ * @xia-sc/dsh-git — browser half (dsh.client bundle).
3
+ *
4
+ * Two seats, one shared store:
5
+ *
6
+ * - `conversation.input.dock` → GitDockLine: a compact status PILL rendered
7
+ * LEFT-ALIGNED in the row band above the composer card (the textarea's
8
+ * top-left, no full-width banner): branch summary, or "not a git
9
+ * repository". Clicking it toggles the workbench panel. It follows the
10
+ * CURRENT session's workspace directory — clicking different sessions /
11
+ * workspaces in the sidebar re-binds it in real time.
12
+ * - `shell.overlay` → GitFloatingPanel: renders NOTHING while collapsed (no
13
+ * floating bubble that could cover the input box) and the full Git
14
+ * workbench when expanded, positioned above the composer card.
15
+ *
16
+ * All Git work happens on the host through `/dsh-git-rpc` (mounted by
17
+ * lib/index.js); this bundle only renders and calls `ctx.connection.rpc`.
18
+ * The bundle is hand-written against the client module table (seed words
19
+ * only: `react`), so it ships without a build step.
20
+ */
21
+ window.__ModuleLoader__.load({
22
+ id: "@xia-sc/dsh-git",
23
+ factory: (require) => {
24
+ var module = { exports: {} };
25
+ var exports = module.exports;
26
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
27
+
28
+ var React = require("react");
29
+
30
+ var name = "dsh-git";
31
+ /** Required client services (activation gating for the loader entry). */
32
+ var inject = ["slots", "connection", "locale"];
33
+
34
+ /** Locale namespace owned by this plugin. */
35
+ var NS = "dshGit";
36
+
37
+ var zh = {
38
+ "panel.title": "Git",
39
+ "panel.collapse": "收起",
40
+ "refresh.aria": "刷新 Git 状态",
41
+ "branch.aria": "当前 Git 分支",
42
+ "switch.aria": "切换分支",
43
+ "switch.dirtyWarn": "有 {count} 个未提交文件(如 {paths}),切换分支可能被 git 拒绝,请先提交或暂存",
44
+ "switch.force": "仍要切换",
45
+ "branch.new": "新建分支",
46
+ "branch.newTitle": "从某个分支创建新分支",
47
+ "newBranch.placeholder": "新分支名…",
48
+ "newBranch.base": "基于分支",
49
+ "newBranch.create": "创建并切换",
50
+ "newBranch.cancel": "取消",
51
+ "newBranch.empty": "请输入新分支名",
52
+ "group.local": "本地分支",
53
+ "group.remote": "远程分支",
54
+ "loading": "读取中…",
55
+ "notRepo": "当前工作区不是 Git 仓库",
56
+ "detached": "游离 HEAD",
57
+ "dirty": "{count} 个未提交更改",
58
+ "aheadBehind": "领先 {ahead} / 落后 {behind}",
59
+ "action.fetch": "拉取更新",
60
+ "action.pull": "拉取合并",
61
+ "action.stage": "暂存全部",
62
+ "action.generate": "AI 生成",
63
+ "action.commit": "提交",
64
+ "action.push": "推送",
65
+ "generate.mode.staged": "已暂存",
66
+ "generate.mode.unstaged": "未暂存",
67
+ "generate.mode.all": "全部",
68
+ "generate.mode.title": "AI 生成的依据",
69
+ "generate.noChanges": "没有可用的改动",
70
+ "generate.done": "已生成提交信息",
71
+ "generate.noStaged": "没有已暂存的改动,请先点「暂存全部」,或把生成依据改为「未暂存」「全部」",
72
+ "generate.noProvider": "尚未配置模型,请先在 设置 > 模型 中添加",
73
+ "generate.noModel": "该提供方没有可用模型",
74
+ "generate.empty": "模型没有返回提交信息",
75
+ "generate.cancelled": "生成已取消",
76
+ "generate.failed": "生成失败",
77
+ "commit.placeholder": "提交信息(可多行,Ctrl+Enter 提交)…",
78
+ "commit.empty": "请输入提交信息",
79
+ "changes.title": "变更({count})",
80
+ "changes.none": "工作区干净",
81
+ "diff.title": "差异",
82
+ "diff.open": "查看差异",
83
+ "diff.close": "关闭差异",
84
+ "diff.resize": "拖动面板右边缘调整差异宽度(双击复位)",
85
+ "diff.scope.worktree": "未暂存",
86
+ "diff.scope.index": "已暂存",
87
+ "diff.scope.title": "对比哪一侧",
88
+ "diff.empty": "这一侧没有改动",
89
+ "diff.emptyHint": "点「{other}」看另一侧",
90
+ "diff.untracked": "未跟踪文件:与空文件对比",
91
+ "diff.binary": "二进制文件,没有可显示的文本差异",
92
+ "diff.truncated": "差异过大,只显示了开头一段",
93
+ "diff.skipped": "另有 {count} 个未跟踪文件未展开",
94
+ "diff.showAll": "显示全部({count} 行)",
95
+ "diff.wrap": "自动换行",
96
+ "diff.copy": "复制差异",
97
+ "diff.copied": "已复制",
98
+ "diff.refresh": "重新读取差异",
99
+ "diff.loading": "读取差异中…",
100
+ "diff.failed": "读取差异失败",
101
+ "log.title": "最近提交",
102
+ "log.none": "暂无提交",
103
+ "busy.fetch": "拉取中…",
104
+ "busy.pull": "拉取合并中…",
105
+ "busy.stage": "暂存中…",
106
+ "busy.generate": "生成中…",
107
+ "busy.commit": "提交中…",
108
+ "busy.push": "推送中…",
109
+ "busy.checkout": "切换中…",
110
+ "busy.createBranch": "创建分支中…",
111
+ "output.ok": "操作成功",
112
+ "dock.aria": "打开 Git 面板",
113
+ "status.modified": "修改",
114
+ "status.added": "新增",
115
+ "status.deleted": "删除",
116
+ "status.renamed": "重命名",
117
+ "status.copied": "复制",
118
+ "status.untracked": "未跟踪",
119
+ "status.conflict": "冲突",
120
+ "status.type-changed": "类型变更",
121
+ "status.changed": "变更"
122
+ };
123
+
124
+ var en = {
125
+ "panel.title": "Git",
126
+ "panel.collapse": "Collapse",
127
+ "refresh.aria": "Refresh Git status",
128
+ "branch.aria": "Current git branch",
129
+ "switch.aria": "Switch branch",
130
+ "switch.dirtyWarn": "{count} uncommitted file(s) (e.g. {paths}) may block the switch — commit or stash first",
131
+ "switch.force": "Switch anyway",
132
+ "branch.new": "New branch",
133
+ "branch.newTitle": "Create a new branch from a branch",
134
+ "newBranch.placeholder": "New branch name…",
135
+ "newBranch.base": "Base branch",
136
+ "newBranch.create": "Create & switch",
137
+ "newBranch.cancel": "Cancel",
138
+ "newBranch.empty": "Enter a new branch name",
139
+ "group.local": "Local",
140
+ "group.remote": "Remote",
141
+ "loading": "Loading…",
142
+ "notRepo": "This workspace is not a git repository",
143
+ "detached": "detached HEAD",
144
+ "dirty": "{count} uncommitted change(s)",
145
+ "aheadBehind": "{ahead} ahead / {behind} behind",
146
+ "action.fetch": "Fetch",
147
+ "action.pull": "Pull",
148
+ "action.stage": "Stage all",
149
+ "action.generate": "AI draft",
150
+ "action.commit": "Commit",
151
+ "action.push": "Push",
152
+ "generate.mode.staged": "Staged",
153
+ "generate.mode.unstaged": "Unstaged",
154
+ "generate.mode.all": "Everything",
155
+ "generate.mode.title": "What the draft is based on",
156
+ "generate.noChanges": "No changes to draft from",
157
+ "generate.done": "Draft ready",
158
+ "generate.noStaged": "Nothing is staged yet — use Stage all first, or switch the basis to Unstaged / Everything",
159
+ "generate.noProvider": "No model is configured — add one in Settings > Models",
160
+ "generate.noModel": "That provider advertises no model",
161
+ "generate.empty": "The model returned no commit message",
162
+ "generate.cancelled": "Draft cancelled",
163
+ "generate.failed": "Draft failed",
164
+ "commit.placeholder": "Commit message (multi-line allowed, Ctrl+Enter commits)…",
165
+ "commit.empty": "Enter a commit message",
166
+ "changes.title": "Changes ({count})",
167
+ "changes.none": "Working tree clean",
168
+ "diff.title": "Diff",
169
+ "diff.open": "View diff",
170
+ "diff.close": "Close diff",
171
+ "diff.resize": "Drag the panel's right edge to resize the diff (double-click resets)",
172
+ "diff.scope.worktree": "Unstaged",
173
+ "diff.scope.index": "Staged",
174
+ "diff.scope.title": "Which side to compare",
175
+ "diff.empty": "Nothing changed on this side",
176
+ "diff.emptyHint": "Pick “{other}” to see the other side",
177
+ "diff.untracked": "Untracked file: compared against the empty blob",
178
+ "diff.binary": "Binary file — there is no text diff to show",
179
+ "diff.truncated": "Diff too large — only the beginning is shown",
180
+ "diff.skipped": "{count} more untracked file(s) were not expanded",
181
+ "diff.showAll": "Show all ({count} lines)",
182
+ "diff.wrap": "Wrap lines",
183
+ "diff.copy": "Copy diff",
184
+ "diff.copied": "Copied",
185
+ "diff.refresh": "Reload the diff",
186
+ "diff.loading": "Loading the diff…",
187
+ "diff.failed": "Could not read the diff",
188
+ "log.title": "Recent commits",
189
+ "log.none": "No commits yet",
190
+ "busy.fetch": "Fetching…",
191
+ "busy.pull": "Pulling…",
192
+ "busy.stage": "Staging…",
193
+ "busy.generate": "Drafting…",
194
+ "busy.commit": "Committing…",
195
+ "busy.push": "Pushing…",
196
+ "busy.checkout": "Switching…",
197
+ "busy.createBranch": "Creating branch…",
198
+ "output.ok": "Operation succeeded",
199
+ "dock.aria": "Open Git panel",
200
+ "status.modified": "modified",
201
+ "status.added": "added",
202
+ "status.deleted": "deleted",
203
+ "status.renamed": "renamed",
204
+ "status.copied": "copied",
205
+ "status.untracked": "untracked",
206
+ "status.conflict": "conflict",
207
+ "status.type-changed": "type changed",
208
+ "status.changed": "changed"
209
+ };
210
+
211
+ /** React.createElement shorthand. */
212
+ function h(type, props) {
213
+ var children = Array.prototype.slice.call(arguments, 2);
214
+ return React.createElement.apply(React, [type, props].concat(children));
215
+ }
216
+
217
+ /** Compact inline styles tuned to the shell overlay / composer dock. */
218
+ var S = {
219
+ panel: {
220
+ position: "fixed",
221
+ // Sit above the composer card so the expanded panel never covers
222
+ // the input box (the input.left pill sits in the tool row).
223
+ bottom: 168,
224
+ left: "50%",
225
+ transform: "translateX(-50%)",
226
+ zIndex: 30,
227
+ // border-box so `width` below is the OUTER width: the two-pane math
228
+ // (workbench + splitter + diff) and the viewport clamp both count
229
+ // real pixels, and a content-box width silently added the border on
230
+ // top of them.
231
+ boxSizing: "border-box",
232
+ width: 400,
233
+ maxWidth: "calc(100vw - 24px)",
234
+ maxHeight: "60vh",
235
+ display: "flex",
236
+ flexDirection: "column",
237
+ border: "1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.3))",
238
+ borderRadius: 12,
239
+ background: "var(--dsw-alias-bg-base, #1e1e1e)",
240
+ boxShadow: "var(--dsw-shadow-lv2, 0 8px 32px rgba(0,0,0,0.45))",
241
+ overflow: "hidden",
242
+ pointerEvents: "auto"
243
+ },
244
+ header: {
245
+ display: "flex",
246
+ alignItems: "center",
247
+ gap: 8,
248
+ padding: "8px 12px",
249
+ borderBottom: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))",
250
+ flex: "none",
251
+ cursor: "move",
252
+ userSelect: "none",
253
+ touchAction: "none",
254
+ // Painted above the right-edge resize handle, which spans the panel's
255
+ // full height: the header's drag gesture and its buttons own that band.
256
+ position: "relative",
257
+ zIndex: 1
258
+ },
259
+ title: {
260
+ fontSize: 13,
261
+ fontWeight: 600,
262
+ color: "var(--dsw-alias-label-primary, #e5e7eb)",
263
+ flex: "1",
264
+ minWidth: 0,
265
+ overflow: "hidden",
266
+ textOverflow: "ellipsis",
267
+ whiteSpace: "nowrap"
268
+ },
269
+ body: {
270
+ flex: "1",
271
+ minHeight: 0,
272
+ padding: "10px 12px 12px",
273
+ overflowY: "auto"
274
+ },
275
+ row: {
276
+ display: "flex",
277
+ alignItems: "center",
278
+ gap: 8,
279
+ margin: "6px 0"
280
+ },
281
+ warn: {
282
+ display: "flex",
283
+ alignItems: "center",
284
+ gap: 8,
285
+ margin: "6px 0",
286
+ padding: "6px 8px",
287
+ borderRadius: 8,
288
+ background: "var(--dsw-alias-state-warning-tertiary, rgba(245,158,11,0.14))",
289
+ border: "1px solid var(--dsw-alias-state-warning-secondary, rgba(245,158,11,0.4))",
290
+ color: "var(--dsw-alias-state-warning-primary, #f59e0b)",
291
+ fontSize: 12,
292
+ lineHeight: "16px"
293
+ },
294
+ chip: {
295
+ background: "var(--dsw-alias-surface-tertiary, rgba(127,127,127,0.12))",
296
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)",
297
+ borderRadius: 999,
298
+ padding: "0 8px",
299
+ fontWeight: 500,
300
+ fontSize: 12,
301
+ lineHeight: "20px",
302
+ overflow: "hidden",
303
+ textOverflow: "ellipsis",
304
+ whiteSpace: "nowrap"
305
+ },
306
+ dirty: {
307
+ color: "var(--dsw-alias-state-warn-primary, #f0a020)",
308
+ fontWeight: 600,
309
+ fontSize: 12
310
+ },
311
+ meta: {
312
+ color: "var(--dsw-alias-label-quaternary, #6b7280)",
313
+ fontSize: 12
314
+ },
315
+ button: {
316
+ background: "var(--dsw-alias-interactive-bg-hover-solid, rgba(127,127,127,0.14))",
317
+ border: "1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.3))",
318
+ borderRadius: 8,
319
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)",
320
+ font: "inherit",
321
+ fontSize: 12,
322
+ lineHeight: "22px",
323
+ padding: "0 10px",
324
+ cursor: "pointer",
325
+ flex: "none"
326
+ },
327
+ buttonPrimary: {
328
+ background: "var(--dsw-alias-state-business-primary, #4f8cff)",
329
+ border: "1px solid transparent",
330
+ borderRadius: 8,
331
+ color: "#fff",
332
+ font: "inherit",
333
+ fontSize: 12,
334
+ lineHeight: "22px",
335
+ padding: "0 10px",
336
+ cursor: "pointer",
337
+ flex: "none"
338
+ },
339
+ buttonDisabled: {
340
+ opacity: 0.5,
341
+ cursor: "default"
342
+ },
343
+ select: {
344
+ // colorScheme makes the NATIVE dropdown listbox follow the page
345
+ // theme (dark list on dark pages) instead of the OS default
346
+ // white; option/optgroup styles above reinforce it.
347
+ colorScheme: "light dark",
348
+ background: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.1))",
349
+ border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
350
+ borderRadius: 8,
351
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)",
352
+ font: "inherit",
353
+ fontSize: 12,
354
+ lineHeight: "20px",
355
+ padding: "2px 8px",
356
+ maxWidth: 220,
357
+ cursor: "pointer",
358
+ flex: "1",
359
+ minWidth: 0,
360
+ outline: "none"
361
+ },
362
+ input: {
363
+ background: "transparent",
364
+ border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
365
+ borderRadius: 8,
366
+ color: "var(--dsw-alias-label-primary, #e5e7eb)",
367
+ font: "inherit",
368
+ fontSize: 12,
369
+ lineHeight: "20px",
370
+ padding: "2px 8px",
371
+ flex: "1",
372
+ minWidth: 0,
373
+ outline: "none"
374
+ },
375
+ // The commit message is a real multi-line field: an <input type=text>
376
+ // silently strips every line feed, so a drafted subject+body would lose
377
+ // its body before the request was ever sent.
378
+ textarea: {
379
+ background: "transparent",
380
+ border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
381
+ borderRadius: 8,
382
+ color: "var(--dsw-alias-label-primary, #e5e7eb)",
383
+ font: "inherit",
384
+ fontSize: 12,
385
+ lineHeight: "18px",
386
+ padding: "4px 8px",
387
+ flex: "1",
388
+ minWidth: 0,
389
+ outline: "none",
390
+ resize: "vertical",
391
+ overflowY: "auto"
392
+ },
393
+ sectionTitle: {
394
+ fontSize: 12,
395
+ fontWeight: 600,
396
+ color: "var(--dsw-alias-label-tertiary, #9aa0a6)",
397
+ margin: "10px 0 4px",
398
+ cursor: "pointer",
399
+ userSelect: "none"
400
+ },
401
+ changeRow: {
402
+ display: "flex",
403
+ alignItems: "center",
404
+ gap: 8,
405
+ fontSize: 12,
406
+ lineHeight: "20px",
407
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)",
408
+ padding: "1px 6px",
409
+ margin: "0 -6px",
410
+ borderRadius: 6,
411
+ minWidth: 0,
412
+ cursor: "pointer"
413
+ },
414
+ changePath: {
415
+ overflow: "hidden",
416
+ textOverflow: "ellipsis",
417
+ whiteSpace: "nowrap",
418
+ flex: "1",
419
+ minWidth: 0
420
+ },
421
+ // A change row is the diff viewer's navigation: it has to read as a
422
+ // control, and to show which file the right-hand pane is showing.
423
+ changeRowActive: {
424
+ display: "flex",
425
+ alignItems: "center",
426
+ gap: 8,
427
+ fontSize: 12,
428
+ lineHeight: "20px",
429
+ color: "var(--dsw-alias-label-primary, #e5e7eb)",
430
+ padding: "1px 6px",
431
+ margin: "0 -6px",
432
+ borderRadius: 6,
433
+ minWidth: 0,
434
+ cursor: "pointer",
435
+ background: "var(--dsw-alias-interactive-bg-hover-solid, rgba(127,127,127,0.18))"
436
+ },
437
+ // ── two-pane (changes + diff) layout ────────────────────────────
438
+ // `bodySplit` replaces `body` while a file is selected: the left pane
439
+ // keeps the whole workbench, the splitter is the width handle, and the
440
+ // diff pane owns its own header + scrollport.
441
+ bodySplit: {
442
+ flex: "1",
443
+ minHeight: 0,
444
+ display: "flex",
445
+ flexDirection: "row",
446
+ overflow: "hidden"
447
+ },
448
+ leftPane: {
449
+ flex: "none",
450
+ // border-box: DIFF_LEFT_PANE_WIDTH is the room the workbench really
451
+ // takes, so the pane width math and the panel edge agree.
452
+ boxSizing: "border-box",
453
+ width: 300,
454
+ minWidth: 0,
455
+ padding: "10px 12px 12px",
456
+ // The two panes are separated by this edge instead of a draggable
457
+ // divider: the width handle is the panel's own right edge.
458
+ borderRight: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))",
459
+ overflowY: "auto"
460
+ },
461
+ // The panel's right edge, as the diff's width handle (the whole panel
462
+ // grows with it; the workbench keeps its width).
463
+ resizeHandle: {
464
+ position: "absolute",
465
+ top: 0,
466
+ right: 0,
467
+ bottom: 0,
468
+ width: 6,
469
+ cursor: "col-resize",
470
+ touchAction: "none",
471
+ background: "transparent"
472
+ },
473
+ resizeHandleActive: {
474
+ background: "var(--dsw-alias-interactive-bg-hover-solid, rgba(127,127,127,0.3))"
475
+ },
476
+ diffPane: {
477
+ flex: "1",
478
+ minWidth: 0,
479
+ display: "flex",
480
+ flexDirection: "column",
481
+ overflow: "hidden"
482
+ },
483
+ diffHeader: {
484
+ flex: "none",
485
+ display: "flex",
486
+ alignItems: "center",
487
+ gap: 6,
488
+ flexWrap: "wrap",
489
+ padding: "8px 10px",
490
+ borderBottom: "1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))"
491
+ },
492
+ diffPath: {
493
+ flex: "1 1 auto",
494
+ minWidth: 0,
495
+ overflow: "hidden",
496
+ textOverflow: "ellipsis",
497
+ whiteSpace: "nowrap",
498
+ fontSize: 12,
499
+ fontWeight: 600,
500
+ color: "var(--dsw-alias-label-primary, #e5e7eb)"
501
+ },
502
+ scopeChip: {
503
+ flex: "none",
504
+ lineHeight: "18px",
505
+ padding: "0 8px",
506
+ borderRadius: 999,
507
+ border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
508
+ background: "transparent",
509
+ color: "var(--dsw-alias-label-tertiary, #9aa0a6)",
510
+ font: "inherit",
511
+ fontSize: 11,
512
+ cursor: "pointer"
513
+ },
514
+ scopeChipActive: {
515
+ flex: "none",
516
+ lineHeight: "18px",
517
+ padding: "0 8px",
518
+ borderRadius: 999,
519
+ border: "1px solid transparent",
520
+ background: "var(--dsw-alias-surface-tertiary, rgba(127,127,127,0.18))",
521
+ color: "var(--dsw-alias-label-primary, #e5e7eb)",
522
+ font: "inherit",
523
+ fontSize: 11,
524
+ cursor: "pointer"
525
+ },
526
+ diffScroll: {
527
+ flex: "1",
528
+ minHeight: 0,
529
+ overflow: "auto",
530
+ padding: "6px 0"
531
+ },
532
+ diffRow: {
533
+ display: "flex",
534
+ alignItems: "flex-start",
535
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
536
+ fontSize: 12,
537
+ lineHeight: "18px",
538
+ // `max-content` keeps a long line's row as wide as its text, so the
539
+ // added/removed background runs the whole length of the horizontal
540
+ // scroll. `diffRowView` drops it to 0 when wrapping is on, where it
541
+ // would otherwise prevent the wrap from ever firing.
542
+ minWidth: "max-content",
543
+ width: "100%"
544
+ },
545
+ diffGutter: {
546
+ flex: "none",
547
+ width: 34,
548
+ textAlign: "right",
549
+ paddingRight: 6,
550
+ color: "var(--dsw-alias-label-quaternary, #6b7280)",
551
+ userSelect: "none",
552
+ WebkitUserSelect: "none"
553
+ },
554
+ diffText: {
555
+ flex: "1",
556
+ minWidth: 0,
557
+ paddingRight: 12,
558
+ tabSize: 4,
559
+ whiteSpace: "pre"
560
+ },
561
+ diffAddRow: {
562
+ background: "rgba(52,199,89,0.14)",
563
+ color: "var(--dsw-alias-state-success-primary, #34c759)"
564
+ },
565
+ diffDelRow: {
566
+ background: "rgba(239,68,68,0.14)",
567
+ color: "var(--dsw-alias-state-error-primary, #ef4444)"
568
+ },
569
+ diffHunkRow: {
570
+ background: "rgba(79,140,255,0.12)",
571
+ color: "var(--dsw-alias-state-business-primary, #4f8cff)"
572
+ },
573
+ diffMetaRow: {
574
+ color: "var(--dsw-alias-label-quaternary, #6b7280)"
575
+ },
576
+ diffNotice: {
577
+ margin: "6px 10px",
578
+ padding: "6px 8px",
579
+ borderRadius: 8,
580
+ fontSize: 12,
581
+ lineHeight: "18px",
582
+ background: "var(--dsw-alias-surface-tertiary, rgba(127,127,127,0.12))",
583
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)"
584
+ },
585
+ statusTag: {
586
+ flex: "none",
587
+ fontSize: 11,
588
+ lineHeight: "16px",
589
+ padding: "0 6px",
590
+ borderRadius: 4,
591
+ background: "var(--dsw-alias-surface-tertiary, rgba(127,127,127,0.12))"
592
+ },
593
+ logRow: {
594
+ display: "flex",
595
+ alignItems: "center",
596
+ gap: 8,
597
+ fontSize: 12,
598
+ lineHeight: "20px",
599
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)",
600
+ padding: "1px 0",
601
+ minWidth: 0
602
+ },
603
+ logSha: {
604
+ flex: "none",
605
+ color: "var(--dsw-alias-state-business-primary, #4f8cff)",
606
+ fontFamily: "monospace"
607
+ },
608
+ output: {
609
+ marginTop: 8,
610
+ padding: "6px 10px",
611
+ borderRadius: 8,
612
+ fontSize: 12,
613
+ lineHeight: "18px",
614
+ wordBreak: "break-word",
615
+ maxHeight: 160,
616
+ overflowY: "auto",
617
+ whiteSpace: "pre-wrap"
618
+ },
619
+ outputOk: {
620
+ background: "var(--dsw-alias-state-success-tertiary, rgba(52,199,89,0.12))",
621
+ color: "var(--dsw-alias-state-success-primary, #34c759)"
622
+ },
623
+ outputError: {
624
+ background: "var(--dsw-alias-state-error-tertiary, rgba(239,68,68,0.12))",
625
+ color: "var(--dsw-alias-state-error-primary, #ef4444)"
626
+ },
627
+ dockWrap: {
628
+ display: "inline-flex",
629
+ alignItems: "center",
630
+ gap: 6,
631
+ maxWidth: "100%",
632
+ fontSize: 12,
633
+ lineHeight: "20px",
634
+ color: "var(--dsw-alias-label-tertiary, #9aa0a6)",
635
+ whiteSpace: "nowrap",
636
+ overflow: "hidden",
637
+ textOverflow: "ellipsis",
638
+ cursor: "pointer"
639
+ }
640
+ };
641
+
642
+ /** Truncate a display string. */
643
+ function shortText(value, max) {
644
+ var text = typeof value === "string" ? value : String(value);
645
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
646
+ }
647
+
648
+ /** Call one /dsh-git-rpc endpoint. */
649
+ function rpc(ctx, endpoint, args) {
650
+ return ctx.connection.rpc.call("/dsh-git-rpc", endpoint, { args: args || {} });
651
+ }
652
+
653
+ /**
654
+ * Shared Git store: one load per cwd (deduplicated), real-time rebind on
655
+ * session/workspace change, and post-action refresh so every seat (panel
656
+ * + dock) sees the same truth. Module-level singleton per apply().
657
+ */
658
+ function createGitStore(ctx) {
659
+ var verbs = {
660
+ status: function (cwd) { return rpc(ctx, "status", { cwd: cwd }); },
661
+ branches: function (cwd) { return rpc(ctx, "branches", { cwd: cwd }); },
662
+ checkout: function (cwd, branch) { return rpc(ctx, "checkout", { cwd: cwd, branch: branch }); },
663
+ createBranch: function (cwd, branch, base) { return rpc(ctx, "createBranch", { cwd: cwd, branch: branch, base: base }); },
664
+ fetch: function (cwd) { return rpc(ctx, "fetch", { cwd: cwd }); },
665
+ pull: function (cwd) { return rpc(ctx, "pull", { cwd: cwd }); },
666
+ stage: function (cwd) { return rpc(ctx, "stage", { cwd: cwd }); },
667
+ diff: function (cwd, file, origFile) {
668
+ return rpc(ctx, "diff", { cwd: cwd, path: file, origPath: origFile });
669
+ },
670
+ commit: function (cwd, message) { return rpc(ctx, "commit", { cwd: cwd, message: message }); },
671
+ push: function (cwd) { return rpc(ctx, "push", { cwd: cwd }); },
672
+ log: function (cwd) { return rpc(ctx, "log", { cwd: cwd, count: 10 }); },
673
+ generateMessage: function (cwd, mode, provider, model) {
674
+ return rpc(ctx, "generateMessage", { cwd: cwd, mode: mode, provider: provider, model: model });
675
+ }
676
+ };
677
+
678
+ /**
679
+ * The Session this store follows. The session-scoped seat (the input
680
+ * dock) binds it; dsh >= 0.1.6 removed `current` from the sessions
681
+ * list snapshot, so the root-scoped panel can no longer find the
682
+ * current Session by itself and follows this field instead.
683
+ */
684
+ var sessionId = null;
685
+
686
+ function idleState() {
687
+ return {
688
+ sessionId: sessionId,
689
+ cwd: null,
690
+ phase: "idle",
691
+ repo: false,
692
+ branch: null,
693
+ detached: false,
694
+ oid: null,
695
+ upstream: null,
696
+ ahead: 0,
697
+ behind: 0,
698
+ dirty: 0,
699
+ changes: [],
700
+ local: [],
701
+ remote: [],
702
+ commits: [],
703
+ error: null,
704
+ busy: null,
705
+ lastResult: null,
706
+ panelOpen: false
707
+ };
708
+ }
709
+
710
+ var state = idleState();
711
+ var listeners = new Set();
712
+ var seq = 0;
713
+ var inflight = null;
714
+
715
+ function emit(next) {
716
+ state = next;
717
+ for (var fn of Array.from(listeners)) {
718
+ try { fn(); } catch (error) { console.error("[dsh-git] store listener threw:", error); }
719
+ }
720
+ }
721
+
722
+ function getSnapshot() { return state; }
723
+
724
+ function subscribe(fn) {
725
+ listeners.add(fn);
726
+ return function () { listeners.delete(fn); };
727
+ }
728
+
729
+ function applyData(nextCwd, status, branches, log) {
730
+ var sv = status && status.ok === true ? status.value : null;
731
+ var bv = branches && branches.ok === true ? branches.value : null;
732
+ var lv = log && log.ok === true ? log.value : null;
733
+ // A reload is passive: it must not erase the last operation's
734
+ // output line, which `act()` sets *before* refreshing.
735
+ if (sv === null) {
736
+ return Object.assign(idleState(), {
737
+ cwd: nextCwd,
738
+ phase: "error",
739
+ error: status && status.error ? String(status.error.message || "git status failed") : "git status failed",
740
+ panelOpen: state.panelOpen,
741
+ lastResult: state.lastResult
742
+ });
743
+ }
744
+ return Object.assign(idleState(), {
745
+ cwd: nextCwd,
746
+ phase: "ready",
747
+ repo: sv.repo === true,
748
+ branch: sv.branch || null,
749
+ detached: sv.detached === true,
750
+ oid: sv.oid || null,
751
+ upstream: sv.upstream || null,
752
+ ahead: typeof sv.ahead === "number" ? sv.ahead : 0,
753
+ behind: typeof sv.behind === "number" ? sv.behind : 0,
754
+ dirty: typeof sv.dirty === "number" ? sv.dirty : 0,
755
+ changes: Array.isArray(sv.changes) ? sv.changes : [],
756
+ local: bv !== null && Array.isArray(bv.local) ? bv.local : [],
757
+ remote: bv !== null && Array.isArray(bv.remote) ? bv.remote : [],
758
+ commits: lv !== null && Array.isArray(lv.commits) ? lv.commits : [],
759
+ panelOpen: state.panelOpen,
760
+ lastResult: state.lastResult
761
+ });
762
+ }
763
+
764
+ function doRefresh(cwd) {
765
+ var mySeq = ++seq;
766
+ emit(Object.assign({}, state, { sessionId: sessionId, cwd: cwd, phase: cwd ? "loading" : "idle", error: null }));
767
+ if (!cwd) return Promise.resolve();
768
+ return Promise.all([
769
+ verbs.status(cwd),
770
+ verbs.branches(cwd),
771
+ verbs.log(cwd)
772
+ ]).then(function (results) {
773
+ if (mySeq !== seq) return;
774
+ emit(applyData(cwd, results[0], results[1], results[2]));
775
+ }).catch(function (error) {
776
+ if (mySeq !== seq) return;
777
+ emit(Object.assign(idleState(), {
778
+ cwd: cwd,
779
+ phase: "error",
780
+ error: error && error.message ? String(error.message) : String(error),
781
+ panelOpen: state.panelOpen,
782
+ lastResult: state.lastResult
783
+ }));
784
+ });
785
+ }
786
+
787
+ function refresh(cwd) {
788
+ if (inflight !== null && inflight.cwd === cwd) return inflight.promise;
789
+ var promise = doRefresh(cwd);
790
+ inflight = { cwd: cwd, promise: promise };
791
+ promise.finally(function () {
792
+ if (inflight !== null && inflight.cwd === cwd) inflight = null;
793
+ }).catch(function () {});
794
+ return promise;
795
+ }
796
+
797
+ /**
798
+ * Bind the store to one Session and its workspace directory. The
799
+ * session-scoped seat calls this with the identity and cwd the
800
+ * framework gave it (`sessionId` slot prop + the sessions list it
801
+ * reads through `useSessions`); everything else — the root-scoped
802
+ * panel included — then reads the shared store.
803
+ * @param id - the Session identity, or undefined/null for "none".
804
+ * @param cwd - that Session's workspace directory, when it has one.
805
+ * @returns the refresh promise, so tests can await the first load.
806
+ */
807
+ function bindSession(id, cwd) {
808
+ var nextId = typeof id === "string" && id !== "" ? id : null;
809
+ var nextCwd = typeof cwd === "string" && cwd !== "" ? cwd : null;
810
+ if (nextId === sessionId && nextCwd === state.cwd) return undefined;
811
+ sessionId = nextId;
812
+ if (nextCwd === state.cwd) {
813
+ // Two Sessions can share one workspace: the cwd is unchanged,
814
+ // but the identity the panel resolves its model route from is
815
+ // not, so publish it without another load.
816
+ emit(Object.assign({}, state, { sessionId: sessionId }));
817
+ return undefined;
818
+ }
819
+ return refresh(nextCwd);
820
+ }
821
+
822
+ /**
823
+ * Run one action; on success force a fresh load; surfaces lastResult.
824
+ * `describe` lets a caller own the wording of both outcomes (used by
825
+ * generation, where the success value is the message itself and a
826
+ * failure code maps to a localized string). Resolves with the raw
827
+ * RPC result so the caller can also act on it.
828
+ */
829
+ function act(name, run, describe) {
830
+ emit(Object.assign({}, state, { busy: name, lastResult: null }));
831
+ return run().then(function (res) {
832
+ var result = describe
833
+ ? describe(res)
834
+ : res && res.ok === true
835
+ ? { kind: "ok", text: res.value && res.value.message ? String(res.value.message) : null }
836
+ : { kind: "error", text: res && res.error && res.error.message ? String(res.error.message) : "operation failed" };
837
+ emit(Object.assign({}, state, { busy: null, lastResult: result }));
838
+ if (res && res.ok === true) {
839
+ var cwd = state.cwd;
840
+ return refresh(cwd).then(function () { return res; });
841
+ }
842
+ return res;
843
+ }).catch(function (error) {
844
+ emit(Object.assign({}, state, {
845
+ busy: null,
846
+ lastResult: { kind: "error", text: error && error.message ? String(error.message) : String(error) }
847
+ }));
848
+ });
849
+ }
850
+
851
+ function setPanelOpen(open) {
852
+ emit(Object.assign({}, state, { panelOpen: open === true }));
853
+ }
854
+
855
+ return {
856
+ getSnapshot: getSnapshot,
857
+ subscribe: subscribe,
858
+ refresh: refresh,
859
+ bindSession: bindSession,
860
+ act: act,
861
+ setPanelOpen: setPanelOpen,
862
+ verbs: verbs
863
+ };
864
+ }
865
+
866
+ /** Theme-matched colors for the native branch <select> dropdown list. */
867
+ var OPTION_STYLE = {
868
+ background: "var(--dsw-alias-bg-base, #1e1e1e)",
869
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)"
870
+ };
871
+ var OPTGROUP_STYLE = {
872
+ background: "var(--dsw-alias-bg-base, #1e1e1e)",
873
+ color: "var(--dsw-alias-label-tertiary, #9aa0a6)",
874
+ fontWeight: 600
875
+ };
876
+
877
+ /** One branch option list; remote picks carry the DWIM-able short name. */
878
+ function branchOptions(state, t) {
879
+ var local = state.local.map(function (entry) {
880
+ return h("option", { key: "l:" + entry.name, value: entry.name, style: OPTION_STYLE }, entry.name + (entry.current ? " ✓" : ""));
881
+ });
882
+ var remote = state.remote.map(function (entry) {
883
+ return h("option", { key: "r:" + entry.name, value: entry.short, style: OPTION_STYLE }, entry.short);
884
+ });
885
+ var children = [];
886
+ if (local.length > 0) children.push(h("optgroup", { key: "lg", label: t("group.local"), style: OPTGROUP_STYLE }, local));
887
+ if (remote.length > 0) children.push(h("optgroup", { key: "rg", label: t("group.remote"), style: OPTGROUP_STYLE }, remote));
888
+ return children;
889
+ }
890
+
891
+ /**
892
+ * Base-branch options for "new branch from…": local branch names plus
893
+ * the FULL remote-tracking refs (e.g. `origin/feature/x`) so the host
894
+ * can resolve them directly as the start point.
895
+ */
896
+ function baseOptions(state, t) {
897
+ var local = state.local.map(function (entry) {
898
+ return h("option", { key: "l:" + entry.name, value: entry.name, style: OPTION_STYLE }, entry.name + (entry.current ? " ✓" : ""));
899
+ });
900
+ var remote = state.remote.map(function (entry) {
901
+ return h("option", { key: "r:" + entry.name, value: entry.name, style: OPTION_STYLE }, entry.name);
902
+ });
903
+ var children = [];
904
+ if (local.length > 0) children.push(h("optgroup", { key: "lg", label: t("group.local"), style: OPTGROUP_STYLE }, local));
905
+ if (remote.length > 0) children.push(h("optgroup", { key: "rg", label: t("group.remote"), style: OPTGROUP_STYLE }, remote));
906
+ return children;
907
+ }
908
+
909
+ /** The row kinds one parsed unified diff can produce. */
910
+ var DIFF_ROW = {
911
+ context: "context",
912
+ add: "add",
913
+ del: "del",
914
+ hunk: "hunk",
915
+ fileHeader: "fileHeader",
916
+ meta: "meta",
917
+ noNewline: "noNewline"
918
+ };
919
+
920
+ /** git's own bookkeeping lines, recognized only OUTSIDE a hunk. */
921
+ var DIFF_FILE_HEADER = /^(diff --git |index |--- |\+\+\+ |new file mode|deleted file mode|old mode |new mode |similarity index|dissimilarity index|rename from |rename to |copy from |copy to |Binary files |GIT binary patch)/;
922
+
923
+ /**
924
+ * Parse one unified diff into renderable rows.
925
+ *
926
+ * Line numbers are tracked from each hunk header, so every content row
927
+ * carries the old and/or the new number it belongs to. `--- `/`+++ ` count
928
+ * as file headers only outside a hunk: a deleted line whose own text starts
929
+ * with `--` looks exactly like one, and misreading it would silently drop a
930
+ * real change.
931
+ * @param text - unified diff text (several files may be concatenated).
932
+ * @returns `{ rows, additions, deletions, hunks }`.
933
+ */
934
+ function parseUnifiedDiff(text) {
935
+ var rows = [];
936
+ var additions = 0;
937
+ var deletions = 0;
938
+ var hunks = 0;
939
+ var oldLine = 0;
940
+ var newLine = 0;
941
+ var inHunk = false;
942
+ var lines = String(text === undefined || text === null ? "" : text).split("\n");
943
+ for (var i = 0; i < lines.length; i += 1) {
944
+ var line = lines[i];
945
+ // git ends a patch with a newline, so the split leaves one empty tail.
946
+ if (i === lines.length - 1 && line === "") continue;
947
+ if (line.indexOf("diff --git ") === 0) {
948
+ inHunk = false;
949
+ rows.push({ kind: DIFF_ROW.fileHeader, text: line, oldLine: null, newLine: null });
950
+ continue;
951
+ }
952
+ if (line.indexOf("@@") === 0) {
953
+ var match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
954
+ if (match !== null) {
955
+ oldLine = Number(match[1]);
956
+ newLine = Number(match[2]);
957
+ }
958
+ hunks += 1;
959
+ inHunk = true;
960
+ rows.push({ kind: DIFF_ROW.hunk, text: line, oldLine: null, newLine: null });
961
+ continue;
962
+ }
963
+ if (!inHunk && DIFF_FILE_HEADER.test(line)) {
964
+ rows.push({ kind: DIFF_ROW.fileHeader, text: line, oldLine: null, newLine: null });
965
+ continue;
966
+ }
967
+ if (line.charAt(0) === "\\") {
968
+ rows.push({ kind: DIFF_ROW.noNewline, text: line, oldLine: null, newLine: null });
969
+ continue;
970
+ }
971
+ var marker = line.charAt(0);
972
+ if (marker === "+") {
973
+ rows.push({ kind: DIFF_ROW.add, text: line.slice(1), oldLine: null, newLine: newLine });
974
+ newLine += 1;
975
+ additions += 1;
976
+ } else if (marker === "-") {
977
+ rows.push({ kind: DIFF_ROW.del, text: line.slice(1), oldLine: oldLine, newLine: null });
978
+ oldLine += 1;
979
+ deletions += 1;
980
+ } else if (marker === " ") {
981
+ rows.push({ kind: DIFF_ROW.context, text: line.slice(1), oldLine: oldLine, newLine: newLine });
982
+ oldLine += 1;
983
+ newLine += 1;
984
+ } else {
985
+ rows.push({ kind: DIFF_ROW.meta, text: line, oldLine: null, newLine: null });
986
+ }
987
+ }
988
+ return { rows: rows, additions: additions, deletions: deletions, hunks: hunks };
989
+ }
990
+
991
+ /**
992
+ * Turn one side of the host's `diff` value into render state: the parsed
993
+ * rows plus the flags that decide which notices the pane shows. Parsing
994
+ * happens once per read, not per render, because a diff may be large.
995
+ * @param side - `{ diff, binary, truncated }` as the host sent it.
996
+ */
997
+ function parseDiffSide(side) {
998
+ var data = side !== null && typeof side === "object" ? side : {};
999
+ var text = typeof data.diff === "string" ? data.diff : "";
1000
+ var parsed = parseUnifiedDiff(text);
1001
+ return {
1002
+ empty: text.trim() === "",
1003
+ binary: data.binary === true,
1004
+ truncated: data.truncated === true,
1005
+ text: text,
1006
+ rows: parsed.rows,
1007
+ additions: parsed.additions,
1008
+ deletions: parsed.deletions,
1009
+ hunks: parsed.hunks
1010
+ };
1011
+ }
1012
+
1013
+ /** Rows rendered at once before the pane offers "show all". */
1014
+ var DIFF_RENDER_MAX = 1500;
1015
+ /** Width of the diff pane when a file is opened, and its readable floor. */
1016
+ var DIFF_DEFAULT_WIDTH = 560;
1017
+ var DIFF_MIN_WIDTH = 240;
1018
+ /** Width of the workbench pane, the panel's own border, and its viewport margin. */
1019
+ var DIFF_LEFT_PANE_WIDTH = 300;
1020
+ var PANEL_BORDER_WIDTH = 2;
1021
+ var PANEL_MARGIN = 24;
1022
+ /** Grab width of the panel's right-edge resize handle. */
1023
+ var PANEL_RESIZE_GRIP = 6;
1024
+
1025
+ /** The last path segment, for chip-sized text. */
1026
+ function baseName(file) {
1027
+ var text = String(file === undefined || file === null ? "" : file);
1028
+ var slash = Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\"));
1029
+ return slash === -1 ? text : text.slice(slash + 1);
1030
+ }
1031
+
1032
+ /**
1033
+ * One row of the diff body: the two line-number gutters, then the text.
1034
+ * Colours come from the theme's state tokens (with rgba fallbacks), so the
1035
+ * added/removed rows stay readable in both light and dark themes.
1036
+ * @param row - a row from `parseUnifiedDiff`.
1037
+ * @param key - React key.
1038
+ * @param wrap - whether long lines wrap instead of scrolling.
1039
+ */
1040
+ function diffRowView(row, key, wrap) {
1041
+ var style = Object.assign({}, S.diffRow);
1042
+ // Unwrapped rows take their natural width so the horizontal scroll runs
1043
+ // the patch's full length; a wrapped row must NOT keep `max-content`,
1044
+ // or the row stays as wide as its longest line.
1045
+ if (wrap === true) style = Object.assign(style, { minWidth: 0 });
1046
+ if (row.kind === DIFF_ROW.add) style = Object.assign(style, S.diffAddRow);
1047
+ else if (row.kind === DIFF_ROW.del) style = Object.assign(style, S.diffDelRow);
1048
+ else if (row.kind === DIFF_ROW.hunk) style = Object.assign(style, S.diffHunkRow);
1049
+ else if (row.kind !== DIFF_ROW.context) style = Object.assign(style, S.diffMetaRow);
1050
+ var code = row.kind === DIFF_ROW.add ? "+" : row.kind === DIFF_ROW.del ? "-" : row.kind === DIFF_ROW.context ? " " : "";
1051
+ var text = code === "" ? row.text : code + row.text;
1052
+ // The wrap belongs on the TEXT span: `S.diffText` carries its own
1053
+ // `white-space: pre`, which would win over the row's value and leave a
1054
+ // wrapped row overflowing anyway.
1055
+ var textStyle = wrap === true
1056
+ ? Object.assign({}, S.diffText, { whiteSpace: "pre-wrap", wordBreak: "break-word" })
1057
+ : S.diffText;
1058
+ return h("div", {
1059
+ key: key,
1060
+ style: style,
1061
+ "data-dsh-git": "diff-row",
1062
+ "data-kind": row.kind
1063
+ }, [
1064
+ h("span", { key: "old", style: S.diffGutter }, row.oldLine === null || row.oldLine === undefined ? "" : String(row.oldLine)),
1065
+ h("span", { key: "new", style: S.diffGutter }, row.newLine === null || row.newLine === undefined ? "" : String(row.newLine)),
1066
+ h("span", { key: "text", style: textStyle }, text)
1067
+ ]);
1068
+ }
1069
+
1070
+ /**
1071
+ * The right-hand pane of the workbench: one changed file's unified diff.
1072
+ *
1073
+ * The pane owns its own reads, because its inputs are the selected path
1074
+ * plus a data version the panel computes from the store (staging,
1075
+ * committing, switching a branch, or refreshing all change it, so the open
1076
+ * diff follows the repository without the user clicking anything). The side
1077
+ * of the index shown follows the data by default — unstaged when it has
1078
+ * anything, staged otherwise — and an explicit pick sticks until the file
1079
+ * changes (the panel remounts this pane per file with a React key).
1080
+ *
1081
+ * @param props - `{ store, t, cwd, change, dataVersion, onClose }`.
1082
+ */
1083
+ function GitDiffPane(props) {
1084
+ var store = props.store;
1085
+ var t = props.t;
1086
+ var cwd = props.cwd;
1087
+ var change = props.change;
1088
+ var dataVersion = props.dataVersion;
1089
+ var onClose = props.onClose;
1090
+
1091
+ var readRef = React.useState({ phase: "loading", error: null, value: null, worktree: null, index: null });
1092
+ var read = readRef[0];
1093
+ var setRead = readRef[1];
1094
+ // null = follow the data (unstaged if it has anything, else staged).
1095
+ var scopeRef = React.useState(null);
1096
+ var scope = scopeRef[0];
1097
+ var setScope = scopeRef[1];
1098
+ var wrapRef = React.useState(false);
1099
+ var wrap = wrapRef[0];
1100
+ var setWrap = wrapRef[1];
1101
+ var copiedRef = React.useState(false);
1102
+ var copied = copiedRef[0];
1103
+ var setCopied = copiedRef[1];
1104
+ var showAllRef = React.useState(false);
1105
+ var showAll = showAllRef[0];
1106
+ var setShowAll = showAllRef[1];
1107
+ var nonceRef = React.useState(0);
1108
+ var nonce = nonceRef[0];
1109
+ var setNonce = nonceRef[1];
1110
+ var seqRef = React.useRef(0);
1111
+ var copiedTimer = React.useRef(null);
1112
+
1113
+ var file = change ? change.file : null;
1114
+ var origFile = change ? change.origFile : null;
1115
+
1116
+ // One effect owns the read: it bumps the sequence, shows the loading
1117
+ // state, and only the newest answer is allowed to land (a stale reply
1118
+ // from a previously selected file must never be rendered).
1119
+ React.useEffect(function () {
1120
+ if (!cwd || !file) return;
1121
+ var mySeq = seqRef.current + 1;
1122
+ seqRef.current = mySeq;
1123
+ setRead({ phase: "loading", error: null, value: null, worktree: null, index: null });
1124
+ store.verbs.diff(cwd, file, origFile).then(function (res) {
1125
+ if (seqRef.current !== mySeq) return;
1126
+ if (!res || res.ok !== true) {
1127
+ var message = res && res.error && res.error.message ? String(res.error.message) : t("diff.failed");
1128
+ setRead({ phase: "error", error: message, value: null, worktree: null, index: null });
1129
+ return;
1130
+ }
1131
+ var value = res.value !== null && typeof res.value === "object" ? res.value : {};
1132
+ if (value.repo === false) {
1133
+ setRead({ phase: "error", error: t("notRepo"), value: null, worktree: null, index: null });
1134
+ return;
1135
+ }
1136
+ setRead({
1137
+ phase: "ready",
1138
+ error: null,
1139
+ value: value,
1140
+ worktree: parseDiffSide(value.worktree),
1141
+ index: parseDiffSide(value.index)
1142
+ });
1143
+ }).catch(function (error) {
1144
+ if (seqRef.current !== mySeq) return;
1145
+ setRead({
1146
+ phase: "error",
1147
+ error: error && error.message ? String(error.message) : t("diff.failed"),
1148
+ value: null,
1149
+ worktree: null,
1150
+ index: null
1151
+ });
1152
+ });
1153
+ }, [cwd, file, origFile, dataVersion, nonce, store]);
1154
+
1155
+ React.useEffect(function () {
1156
+ return function () {
1157
+ if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
1158
+ };
1159
+ }, []);
1160
+
1161
+ if (!change || !cwd) return null;
1162
+
1163
+ var worktree = read.worktree;
1164
+ var index = read.index;
1165
+ var autoScope = worktree !== null && worktree.empty === false ? "worktree" : "index";
1166
+ var side = scope === null ? autoScope : scope;
1167
+ var active = side === "index" ? index : worktree;
1168
+ var otherLabel = side === "index" ? t("diff.scope.worktree") : t("diff.scope.index");
1169
+ var pathText = change.path ? String(change.path) : String(change.file);
1170
+
1171
+ function pickScope(kind) {
1172
+ setScope(kind);
1173
+ setShowAll(false);
1174
+ }
1175
+
1176
+ /**
1177
+ * One side chip. A side with nothing in it stays visible — the pair has
1178
+ * to read as a switch — but cannot be picked while it is the other chip.
1179
+ */
1180
+ function scopeChip(kind) {
1181
+ var target = kind === "index" ? index : worktree;
1182
+ var isEmpty = target === null || target.empty === true;
1183
+ var isActive = side === kind;
1184
+ if (isEmpty && !isActive) {
1185
+ return h("button", {
1186
+ key: "scope-" + kind,
1187
+ type: "button",
1188
+ "data-dsh-git": "diff-scope-" + kind,
1189
+ "aria-pressed": false,
1190
+ style: Object.assign({}, S.scopeChip, { opacity: 0.45, cursor: "default" }),
1191
+ disabled: true,
1192
+ title: t("diff.empty")
1193
+ }, t("diff.scope." + kind));
1194
+ }
1195
+ return h("button", {
1196
+ key: "scope-" + kind,
1197
+ type: "button",
1198
+ "data-dsh-git": "diff-scope-" + kind,
1199
+ style: isActive ? S.scopeChipActive : S.scopeChip,
1200
+ title: t("diff.scope.title"),
1201
+ "aria-pressed": isActive,
1202
+ onClick: function () { pickScope(kind); }
1203
+ }, t("diff.scope." + kind));
1204
+ }
1205
+
1206
+ function doCopy() {
1207
+ var text = active !== null && typeof active.text === "string" ? active.text : "";
1208
+ var clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
1209
+ if (text === "" || !clipboard || typeof clipboard.writeText !== "function") return;
1210
+ clipboard.writeText(text).then(function () {
1211
+ setCopied(true);
1212
+ if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
1213
+ copiedTimer.current = setTimeout(function () { setCopied(false); }, 1500);
1214
+ }).catch(function () {});
1215
+ }
1216
+
1217
+ var headerChildren = [
1218
+ h("span", { key: "path", style: S.diffPath, title: String(change.file) }, shortText(pathText, 90)),
1219
+ h("span", { key: "status", style: S.statusTag }, t("status." + change.status))
1220
+ ];
1221
+ if (read.phase === "ready" && active !== null && active.empty === false) {
1222
+ headerChildren.push(h("span", { key: "stat", style: S.meta }, "+" + active.additions + " −" + active.deletions));
1223
+ }
1224
+ headerChildren.push(h("button", {
1225
+ key: "close",
1226
+ type: "button",
1227
+ style: S.button,
1228
+ title: t("diff.close"),
1229
+ "aria-label": t("diff.close"),
1230
+ "data-dsh-git": "diff-close",
1231
+ onClick: onClose
1232
+ }, "×"));
1233
+ headerChildren.push(h("div", {
1234
+ key: "tools",
1235
+ style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", flexBasis: "100%", marginTop: 2 }
1236
+ }, [
1237
+ scopeChip("worktree"),
1238
+ scopeChip("index"),
1239
+ h("button", {
1240
+ key: "wrap",
1241
+ type: "button",
1242
+ "data-dsh-git": "diff-wrap",
1243
+ style: wrap === true ? S.scopeChipActive : S.scopeChip,
1244
+ title: t("diff.wrap"),
1245
+ "aria-pressed": wrap === true,
1246
+ onClick: function () { setWrap(!wrap); }
1247
+ }, t("diff.wrap")),
1248
+ h("button", {
1249
+ key: "copy",
1250
+ type: "button",
1251
+ "data-dsh-git": "diff-copy",
1252
+ style: S.scopeChip,
1253
+ title: t("diff.copy"),
1254
+ onClick: doCopy
1255
+ }, copied === true ? t("diff.copied") : t("diff.copy")),
1256
+ h("button", {
1257
+ key: "reload",
1258
+ type: "button",
1259
+ "data-dsh-git": "diff-reload",
1260
+ style: S.scopeChip,
1261
+ title: t("diff.refresh"),
1262
+ "aria-label": t("diff.refresh"),
1263
+ onClick: function () { setNonce(nonce + 1); }
1264
+ }, "↻")
1265
+ ]));
1266
+
1267
+ var bodyChildren = [];
1268
+ if (read.phase === "loading") {
1269
+ bodyChildren.push(h("div", { key: "loading", style: S.diffNotice }, t("diff.loading")));
1270
+ } else if (read.phase === "error") {
1271
+ bodyChildren.push(h("div", { key: "error", style: Object.assign({}, S.diffNotice, S.outputError) }, shortText(read.error, 240)));
1272
+ } else if (active !== null) {
1273
+ var value = read.value !== null && typeof read.value === "object" ? read.value : {};
1274
+ if (value.untracked === true) {
1275
+ bodyChildren.push(h("div", { key: "untracked", style: S.diffNotice, "data-dsh-git": "diff-untracked" }, t("diff.untracked")));
1276
+ }
1277
+ if (typeof value.skipped === "number" && value.skipped > 0) {
1278
+ bodyChildren.push(h("div", { key: "skipped", style: S.diffNotice }, t("diff.skipped", { count: value.skipped })));
1279
+ }
1280
+ if (active.truncated === true) {
1281
+ bodyChildren.push(h("div", { key: "truncated", style: S.diffNotice }, t("diff.truncated")));
1282
+ }
1283
+ if (active.binary === true) {
1284
+ bodyChildren.push(h("div", { key: "binary", style: S.diffNotice, "data-dsh-git": "diff-binary" }, t("diff.binary")));
1285
+ } else if (active.empty === true) {
1286
+ bodyChildren.push(h("div", {
1287
+ key: "empty",
1288
+ style: S.diffNotice,
1289
+ "data-dsh-git": "diff-empty"
1290
+ }, t("diff.empty") + " · " + t("diff.emptyHint", { other: otherLabel })));
1291
+ } else {
1292
+ var clipped = active.rows.length > DIFF_RENDER_MAX && showAll !== true;
1293
+ var visible = clipped ? active.rows.slice(0, DIFF_RENDER_MAX) : active.rows;
1294
+ for (var i = 0; i < visible.length; i += 1) {
1295
+ bodyChildren.push(diffRowView(visible[i], "r" + i, wrap === true));
1296
+ }
1297
+ if (clipped) {
1298
+ bodyChildren.push(h("button", {
1299
+ key: "all",
1300
+ type: "button",
1301
+ style: Object.assign({}, S.button, { margin: "8px 10px" }),
1302
+ onClick: function () { setShowAll(true); }
1303
+ }, t("diff.showAll", { count: active.rows.length })));
1304
+ }
1305
+ }
1306
+ }
1307
+
1308
+ return h("div", { style: S.diffPane, "data-dsh-git": "diff" }, [
1309
+ h("div", { key: "head", style: S.diffHeader, "data-dsh-git": "diff-header" }, headerChildren),
1310
+ h("div", { key: "scroll", style: S.diffScroll, "data-dsh-git": "diff-body" }, bodyChildren)
1311
+ ]);
1312
+ }
1313
+
1314
+ /**
1315
+ * Floating workbench entry (shell.overlay). Renders nothing while
1316
+ * collapsed — the persistent status lives in the input.left pill, and
1317
+ * this panel appears only when the user expands it from that pill.
1318
+ * Follows the current session cwd.
1319
+ */
1320
+ function GitFloatingPanel(props) {
1321
+ var store = props.store;
1322
+ var t = props.t || function (key, params) {
1323
+ return key + (params ? " " + JSON.stringify(params) : "");
1324
+ };
1325
+ var useSessions = props.useSessions;
1326
+ // useSessions is a selector hook (useSyncExternalStoreWithSelector):
1327
+ // the selector argument is REQUIRED — a bare useSessions() crashes
1328
+ // with "selector is not a function".
1329
+ var sessions = useSessions(function (s) { return s; });
1330
+
1331
+ // NOTE: the shell's seed react-dom is an experimental 18.3.1-next
1332
+ // build where React.useSyncExternalStore misbehaves (returns
1333
+ // undefined / throws), so subscribe through the classic
1334
+ // useState + useEffect pattern instead — safe on any React.
1335
+ var stateRef = React.useState(function () { return store.getSnapshot(); });
1336
+ var state = stateRef[0];
1337
+ var setState = stateRef[1];
1338
+ React.useEffect(function () {
1339
+ var unsubscribe = store.subscribe(function () {
1340
+ setState(store.getSnapshot());
1341
+ });
1342
+ return unsubscribe;
1343
+ }, [store]);
1344
+
1345
+ // The session-scoped pill owns the session → cwd binding; this
1346
+ // root-scoped seat follows the shared store. dsh >= 0.1.6 no longer
1347
+ // carries `current` in the sessions list snapshot — it now selects
1348
+ // the current Session through the renderer's scope adapter, which a
1349
+ // root-scoped entry cannot read — so `state.sessionId` is the only
1350
+ // identity reachable from here.
1351
+ var cwd = state.cwd;
1352
+ var sessionId = state.sessionId;
1353
+ var currentSummary = sessionId !== null && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
1354
+
1355
+ // The session's own model route, when one is recorded: the durable
1356
+ // `modelSelection` projection, pending pick first, last used second.
1357
+ // Generation falls back to the host's first registered route.
1358
+ var sessionRoute = (function () {
1359
+ var selection = currentSummary && currentSummary.projectionValues ? currentSummary.projectionValues.modelSelection : undefined;
1360
+ if (!selection) return null;
1361
+ var effective = selection.next || selection.lastUsed;
1362
+ if (!effective || !effective.provider || !effective.model) return null;
1363
+ return { provider: effective.provider, model: effective.model };
1364
+ })();
1365
+
1366
+ var messageRef = React.useState("");
1367
+ var message = messageRef[0];
1368
+ var setMessage = messageRef[1];
1369
+ // Draft basis. `staged` is the default because it is the only basis
1370
+ // whose content is what the commit below will actually record.
1371
+ var generateModeRef = React.useState("staged");
1372
+ var generateMode = generateModeRef[0];
1373
+ var setGenerateMode = generateModeRef[1];
1374
+ var changesOpenRef = React.useState(false);
1375
+ var changesOpen = changesOpenRef[0];
1376
+ var setChangesOpen = changesOpenRef[1];
1377
+ var logOpenRef = React.useState(false);
1378
+ var logOpen = logOpenRef[0];
1379
+ var setLogOpen = logOpenRef[1];
1380
+
1381
+ // Branch the user picked while the tree is dirty: the pre-check
1382
+ // shows a warning instead of switching immediately; the switch only
1383
+ // runs via the "switch anyway" button (or once the tree is clean).
1384
+ var pendingRef = React.useState(null);
1385
+ var pendingBranch = pendingRef[0];
1386
+ var setPendingBranch = pendingRef[1];
1387
+
1388
+ // "New branch from…" form state: open flag, the new branch name
1389
+ // being typed, and the base branch explicitly picked by the user
1390
+ // (null = default to the current branch on first open).
1391
+ var newBranchOpenRef = React.useState(false);
1392
+ var newBranchOpen = newBranchOpenRef[0];
1393
+ var setNewBranchOpen = newBranchOpenRef[1];
1394
+ var newBranchNameRef = React.useState("");
1395
+ var newBranchName = newBranchNameRef[0];
1396
+ var setNewBranchName = newBranchNameRef[1];
1397
+ var newBranchBaseRef = React.useState(null);
1398
+ var newBranchBase = newBranchBaseRef[0];
1399
+ var setNewBranchBase = newBranchBaseRef[1];
1400
+
1401
+ // ── draggable panel (header is the drag handle) ─────────────
1402
+ // Default: centered above the composer (S.panel). After the first
1403
+ // drag, switch to explicit left/top so the panel stays where the
1404
+ // user dropped it. Double-click the header to snap back.
1405
+ var panelRef = React.useRef(null);
1406
+ var dragPosRef = React.useState(null);
1407
+ var dragPos = dragPosRef[0];
1408
+ var setDragPos = dragPosRef[1];
1409
+
1410
+ function clampDragPos(left, top, width, height) {
1411
+ var vw = (typeof window !== "undefined" && window.innerWidth) || 1920;
1412
+ var vh = (typeof window !== "undefined" && window.innerHeight) || 1080;
1413
+ var w = width || 400;
1414
+ var hh = height || 200;
1415
+ var maxLeft = Math.max(0, vw - Math.min(w, 120));
1416
+ var maxTop = Math.max(0, vh - 40);
1417
+ return {
1418
+ left: Math.min(Math.max(0, left), maxLeft),
1419
+ top: Math.min(Math.max(0, top), maxTop)
1420
+ };
1421
+ }
1422
+
1423
+ function onPanelHeaderMouseDown(event) {
1424
+ if (event.button !== undefined && event.button !== 0) return;
1425
+ var target = event.target;
1426
+ if (target && target.closest) {
1427
+ try { if (target.closest("button,select,input,textarea,a")) return; } catch (ignore) {}
1428
+ } else if (target && /^(BUTTON|SELECT|INPUT|TEXTAREA|A)$/.test(target.tagName || "")) {
1429
+ return;
1430
+ }
1431
+ var panelEl = panelRef.current;
1432
+ if (!panelEl || !panelEl.getBoundingClientRect) return;
1433
+ var doc = (typeof document !== "undefined") ? document : null;
1434
+ if (!doc || !doc.addEventListener) return;
1435
+ var rect = panelEl.getBoundingClientRect();
1436
+ var startX = event.clientX;
1437
+ var startY = event.clientY;
1438
+ var baseLeft = rect.left;
1439
+ var baseTop = rect.top;
1440
+ if (event.preventDefault) event.preventDefault();
1441
+ function onMove(e) {
1442
+ var c = clampDragPos(
1443
+ baseLeft + (e.clientX - startX),
1444
+ baseTop + (e.clientY - startY),
1445
+ rect.width, rect.height
1446
+ );
1447
+ setDragPos(c);
1448
+ }
1449
+ function onUp() {
1450
+ doc.removeEventListener("mousemove", onMove);
1451
+ doc.removeEventListener("mouseup", onUp);
1452
+ try { doc.body.style.userSelect = ""; doc.body.style.cursor = ""; } catch (ignore2) {}
1453
+ }
1454
+ doc.addEventListener("mousemove", onMove);
1455
+ doc.addEventListener("mouseup", onUp);
1456
+ try { doc.body.style.userSelect = "none"; doc.body.style.cursor = "grabbing"; } catch (ignore3) {}
1457
+ }
1458
+
1459
+ function onPanelHeaderTouchStart(event) {
1460
+ var touches = event.touches;
1461
+ if (!touches || touches.length !== 1) return;
1462
+ var target = event.target;
1463
+ if (target && target.closest) {
1464
+ try { if (target.closest("button,select,input,textarea,a")) return; } catch (ignore) {}
1465
+ }
1466
+ var panelEl = panelRef.current;
1467
+ if (!panelEl || !panelEl.getBoundingClientRect) return;
1468
+ var doc = (typeof document !== "undefined") ? document : null;
1469
+ if (!doc || !doc.addEventListener) return;
1470
+ var rect = panelEl.getBoundingClientRect();
1471
+ var touch = touches[0];
1472
+ var startX = touch.clientX;
1473
+ var startY = touch.clientY;
1474
+ var baseLeft = rect.left;
1475
+ var baseTop = rect.top;
1476
+ function onMove(e) {
1477
+ var t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]);
1478
+ if (!t) return;
1479
+ if (e.preventDefault) e.preventDefault();
1480
+ var c = clampDragPos(
1481
+ baseLeft + (t.clientX - startX),
1482
+ baseTop + (t.clientY - startY),
1483
+ rect.width, rect.height
1484
+ );
1485
+ setDragPos(c);
1486
+ }
1487
+ function onEnd() {
1488
+ doc.removeEventListener("touchmove", onMove);
1489
+ doc.removeEventListener("touchend", onEnd);
1490
+ doc.removeEventListener("touchcancel", onEnd);
1491
+ }
1492
+ doc.addEventListener("touchmove", onMove, { passive: false });
1493
+ doc.addEventListener("touchend", onEnd);
1494
+ doc.addEventListener("touchcancel", onEnd);
1495
+ }
1496
+
1497
+ // ── diff viewer state ───────────────────────────────────────────
1498
+ // `selected` is the change the right-hand pane is showing (null = the
1499
+ // panel is the plain single-column workbench), `diffWidth` is that
1500
+ // pane's width, and the PANEL'S RIGHT EDGE is its handle.
1501
+ var selectedRef = React.useState(null);
1502
+ var selected = selectedRef[0];
1503
+ var setSelected = selectedRef[1];
1504
+ var diffWidthRef = React.useState(DIFF_DEFAULT_WIDTH);
1505
+ var diffWidth = diffWidthRef[0];
1506
+ var setDiffWidth = diffWidthRef[1];
1507
+ var resizeHoverRef = React.useState(false);
1508
+ var resizeHover = resizeHoverRef[0];
1509
+ var setResizeHover = resizeHoverRef[1];
1510
+
1511
+ /**
1512
+ * Widen the pane only as far as the viewport allows.
1513
+ * @param value - the wanted diff width.
1514
+ * @param anchor - the left edge to grow from; defaults to the panel's
1515
+ * current position, or to a symmetric margin while it is centred.
1516
+ */
1517
+ function clampDiffWidth(value, anchor) {
1518
+ var vw = (typeof window !== "undefined" && window.innerWidth) || 1920;
1519
+ var pos = anchor !== undefined ? anchor : dragPos;
1520
+ var room = pos === null || pos === undefined ? vw - PANEL_MARGIN : vw - 12 - pos.left;
1521
+ var available = room - DIFF_LEFT_PANE_WIDTH - PANEL_BORDER_WIDTH;
1522
+ return Math.min(Math.max(DIFF_MIN_WIDTH, Math.round(value)), Math.max(DIFF_MIN_WIDTH, available));
1523
+ }
1524
+
1525
+ /**
1526
+ * Start a width drag on the panel's right edge.
1527
+ *
1528
+ * The panel is anchored to its CURRENT left edge first: while it is
1529
+ * centred, widening it moves the right edge by half the growth, so a
1530
+ * centred panel would lag behind the pointer by 2×. Anchoring makes the
1531
+ * edge follow the mouse exactly, and leaves the panel where the user
1532
+ * found it.
1533
+ */
1534
+ function onPanelResizeMouseDown(event) {
1535
+ if (event.button !== undefined && event.button !== 0) return;
1536
+ var doc = (typeof document !== "undefined") ? document : null;
1537
+ if (!doc || !doc.addEventListener) return;
1538
+ var el = panelRef.current;
1539
+ if (!el || !el.getBoundingClientRect) return;
1540
+ var rect = el.getBoundingClientRect();
1541
+ var anchor = { left: rect.left, top: rect.top };
1542
+ var startX = event.clientX;
1543
+ var base = diffWidth;
1544
+ if (event.preventDefault) event.preventDefault();
1545
+ setDragPos(anchor);
1546
+ function onMove(e) {
1547
+ setDiffWidth(clampDiffWidth(base + (e.clientX - startX), anchor));
1548
+ }
1549
+ function onUp() {
1550
+ doc.removeEventListener("mousemove", onMove);
1551
+ doc.removeEventListener("mouseup", onUp);
1552
+ try { doc.body.style.userSelect = ""; doc.body.style.cursor = ""; } catch (ignore) {}
1553
+ }
1554
+ doc.addEventListener("mousemove", onMove);
1555
+ doc.addEventListener("mouseup", onUp);
1556
+ try { doc.body.style.userSelect = "none"; doc.body.style.cursor = "col-resize"; } catch (ignore2) {}
1557
+ }
1558
+
1559
+ function onPanelResizeTouchStart(event) {
1560
+ var touches = event.touches;
1561
+ if (!touches || touches.length !== 1) return;
1562
+ var doc = (typeof document !== "undefined") ? document : null;
1563
+ if (!doc || !doc.addEventListener) return;
1564
+ var el = panelRef.current;
1565
+ if (!el || !el.getBoundingClientRect) return;
1566
+ var rect = el.getBoundingClientRect();
1567
+ var anchor = { left: rect.left, top: rect.top };
1568
+ var startX = touches[0].clientX;
1569
+ var base = diffWidth;
1570
+ setDragPos(anchor);
1571
+ function onMove(e) {
1572
+ var touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]);
1573
+ if (!touch) return;
1574
+ if (e.preventDefault) e.preventDefault();
1575
+ setDiffWidth(clampDiffWidth(base + (touch.clientX - startX), anchor));
1576
+ }
1577
+ function onEnd() {
1578
+ doc.removeEventListener("touchmove", onMove);
1579
+ doc.removeEventListener("touchend", onEnd);
1580
+ doc.removeEventListener("touchcancel", onEnd);
1581
+ }
1582
+ doc.addEventListener("touchmove", onMove, { passive: false });
1583
+ doc.addEventListener("touchend", onEnd);
1584
+ doc.addEventListener("touchcancel", onEnd);
1585
+ }
1586
+
1587
+ // A window that shrank can leave the pane wider than the room it has.
1588
+ React.useEffect(function () {
1589
+ if (typeof window === "undefined" || !window.addEventListener) return undefined;
1590
+ function onResize() {
1591
+ setDiffWidth(function (current) { return clampDiffWidth(current); });
1592
+ }
1593
+ window.addEventListener("resize", onResize);
1594
+ return function () { window.removeEventListener("resize", onResize); };
1595
+ }, [dragPos]);
1596
+
1597
+ // The viewer follows the session's workspace; keeping a selection across
1598
+ // a rebind would show another repository's file.
1599
+ React.useEffect(function () {
1600
+ setSelected(null);
1601
+ }, [cwd]);
1602
+
1603
+ /**
1604
+ * Select one change for the viewer. Clicking the file already showing
1605
+ * closes the pane (the panel returns to its compact single column), and
1606
+ * the first selection opens the full change list — the list is the
1607
+ * viewer's navigation, so leaving it behind "show more" hides half the
1608
+ * feature.
1609
+ */
1610
+ function selectChange(change) {
1611
+ var file = change.file !== undefined ? change.file : change.path;
1612
+ var origFile = change.origFile !== undefined ? change.origFile : null;
1613
+ if (selected !== null && selected.file === file && selected.origFile === origFile) {
1614
+ setSelected(null);
1615
+ return;
1616
+ }
1617
+ setSelected({
1618
+ file: file,
1619
+ origFile: origFile,
1620
+ status: change.status,
1621
+ path: change.path !== undefined ? change.path : file,
1622
+ index: change.index,
1623
+ worktree: change.worktree
1624
+ });
1625
+ if (!changesOpen) setChangesOpen(true);
1626
+ }
1627
+
1628
+ // Width follows what the panel is showing: the compact single column
1629
+ // while nothing is selected, and the two-pane width (workbench + diff)
1630
+ // once a file is open. Dragging the panel's right edge is what resizes
1631
+ // the diff, so the panel grows and shrinks with it.
1632
+ var panelWidth = selected === null ? S.panel.width : DIFF_LEFT_PANE_WIDTH + PANEL_BORDER_WIDTH + diffWidth;
1633
+ var panelStyle = Object.assign({}, S.panel, {
1634
+ width: panelWidth,
1635
+ maxHeight: selected === null ? S.panel.maxHeight : "72vh"
1636
+ });
1637
+ if (dragPos) {
1638
+ panelStyle = Object.assign(panelStyle, {
1639
+ left: dragPos.left,
1640
+ top: dragPos.top,
1641
+ bottom: "auto",
1642
+ transform: "none"
1643
+ });
1644
+ }
1645
+
1646
+ if (cwd === null) {
1647
+ // No Session bound (nothing to float over the input).
1648
+ return null;
1649
+ }
1650
+
1651
+ var busy = state.busy;
1652
+ var busyLabel = busy ? t("busy." + busy) : null;
1653
+
1654
+ // ── collapsed state: render nothing ─────────────────────────────
1655
+ // No floating bubble: the persistent status lives in the input.left
1656
+ // pill; this panel only appears when the user clicks that pill.
1657
+ if (!state.panelOpen) return null;
1658
+
1659
+ // ── expanded panel ───────────────────────────────────────────────
1660
+ var header = h("div", {
1661
+ key: "header",
1662
+ style: S.header,
1663
+ onMouseDown: onPanelHeaderMouseDown,
1664
+ onTouchStart: onPanelHeaderTouchStart,
1665
+ onDoubleClick: function () { setDragPos(null); },
1666
+ title: "拖动移动面板(双击复位)",
1667
+ "data-dsh-git": "panel-drag"
1668
+ }, [
1669
+ h("span", { key: "title", style: S.title }, "⎇ " + t("panel.title")),
1670
+ h("span", { key: "cwd", style: S.meta, title: cwd }, shortText(cwd, 60)),
1671
+ h("button", {
1672
+ key: "refresh",
1673
+ type: "button",
1674
+ style: S.button,
1675
+ disabled: busy !== null,
1676
+ onClick: function () { store.refresh(state.cwd); },
1677
+ "aria-label": t("refresh.aria"),
1678
+ title: t("refresh.aria")
1679
+ }, "↻"),
1680
+ h("button", {
1681
+ key: "collapse",
1682
+ type: "button",
1683
+ style: S.button,
1684
+ onClick: function () { store.setPanelOpen(false); },
1685
+ "aria-label": t("panel.collapse"),
1686
+ title: t("panel.collapse")
1687
+ }, "–")
1688
+ ]);
1689
+
1690
+ var bodyChildren = [];
1691
+ if (state.phase === "loading" || state.phase === "idle") {
1692
+ bodyChildren.push(h("div", { key: "loading", style: S.meta }, t("loading")));
1693
+ } else if (state.phase === "error") {
1694
+ bodyChildren.push(h("div", { key: "error", style: { color: "var(--dsw-alias-state-error-primary, #ef4444)", fontSize: 12, lineHeight: "18px" } }, shortText(state.error, 240)));
1695
+ } else if (!state.repo) {
1696
+ bodyChildren.push(h("div", { key: "norepo", style: S.meta }, t("notRepo")));
1697
+ } else {
1698
+ // status line
1699
+ var statusLine = [];
1700
+ var branchText = state.branch;
1701
+ if (!branchText && state.detached) {
1702
+ branchText = t("detached") + (state.oid ? " " + state.oid.slice(0, 7) : "");
1703
+ }
1704
+ statusLine.push(h("span", { key: "chip", style: S.chip, title: branchText || cwd }, branchText || "—"));
1705
+ if (state.dirty > 0) {
1706
+ statusLine.push(h("span", { key: "dirty", style: S.dirty, title: t("dirty", { count: state.dirty }) }, "● " + state.dirty));
1707
+ }
1708
+ if (state.ahead > 0 || state.behind > 0) {
1709
+ statusLine.push(h("span", { key: "ab", style: S.meta }, "↑" + state.ahead + " ↓" + state.behind));
1710
+ }
1711
+ if (state.upstream) {
1712
+ statusLine.push(h("span", { key: "up", style: S.meta, title: state.upstream }, shortText(state.upstream, 40)));
1713
+ }
1714
+ bodyChildren.push(h("div", { key: "status", style: S.row }, statusLine));
1715
+
1716
+ // branch switcher — dirty-tree pre-check: selecting a branch while
1717
+ // the tree has uncommitted changes shows a warning instead of an
1718
+ // immediate switch; the user decides (commit/stash, or force).
1719
+ var selectChildren = [h("option", { key: "ph", value: "", disabled: true, style: OPTION_STYLE }, branchText || "—")];
1720
+ Array.prototype.push.apply(selectChildren, branchOptions(state, t));
1721
+ bodyChildren.push(h("div", { key: "branch", style: S.row }, [
1722
+ h("select", {
1723
+ key: "sel",
1724
+ style: S.select,
1725
+ value: state.branch || "",
1726
+ disabled: busy !== null,
1727
+ onChange: function (event) {
1728
+ var value = event.target.value;
1729
+ if (!value || value === state.branch) { setPendingBranch(null); return; }
1730
+ if (state.dirty > 0) { setPendingBranch(value); return; }
1731
+ store.act("checkout", function () { return store.verbs.checkout(state.cwd, value); });
1732
+ },
1733
+ "aria-label": t("switch.aria")
1734
+ }, selectChildren),
1735
+ h("button", {
1736
+ key: "new",
1737
+ type: "button",
1738
+ style: Object.assign({}, S.button, busy !== null ? S.buttonDisabled : null),
1739
+ disabled: busy !== null,
1740
+ title: t("branch.newTitle"),
1741
+ "aria-label": t("branch.new"),
1742
+ onClick: function () {
1743
+ if (!newBranchOpen && (newBranchBase === null || newBranchBase === "")) {
1744
+ setNewBranchBase(state.branch || (state.local.length > 0 ? state.local[0].name : ""));
1745
+ }
1746
+ setNewBranchOpen(!newBranchOpen);
1747
+ }
1748
+ }, busy === "createBranch" ? t("busy.createBranch") : "+ " + t("branch.new"))
1749
+ ]));
1750
+ if (newBranchOpen) {
1751
+ var effectiveBase = newBranchBase || state.branch || "";
1752
+ var baseKids = baseOptions(state, t);
1753
+ bodyChildren.push(h("div", { key: "newbranch", style: S.row }, [
1754
+ h("input", {
1755
+ key: "name",
1756
+ type: "text",
1757
+ style: S.input,
1758
+ value: newBranchName,
1759
+ placeholder: t("newBranch.placeholder"),
1760
+ disabled: busy !== null,
1761
+ onChange: function (event) { setNewBranchName(event.target.value); },
1762
+ onKeyDown: function (event) {
1763
+ if (event.key === "Enter" && newBranchName.trim() !== "" && effectiveBase !== "" && busy === null) {
1764
+ doCreateBranch();
1765
+ }
1766
+ }
1767
+ }),
1768
+ h("select", {
1769
+ key: "base",
1770
+ style: S.select,
1771
+ value: effectiveBase,
1772
+ disabled: busy !== null,
1773
+ title: t("newBranch.base"),
1774
+ "aria-label": t("newBranch.base"),
1775
+ onChange: function (event) { setNewBranchBase(event.target.value); }
1776
+ }, baseKids)
1777
+ ]));
1778
+ bodyChildren.push(h("div", { key: "newbranchact", style: S.row }, [
1779
+ h("button", {
1780
+ key: "create",
1781
+ type: "button",
1782
+ style: Object.assign({}, S.buttonPrimary, busy !== null || newBranchName.trim() === "" || effectiveBase === "" ? S.buttonDisabled : null),
1783
+ disabled: busy !== null || newBranchName.trim() === "" || effectiveBase === "",
1784
+ title: newBranchName.trim() === "" ? t("newBranch.empty") : undefined,
1785
+ onClick: doCreateBranch
1786
+ }, busy === "createBranch" ? t("busy.createBranch") : t("newBranch.create")),
1787
+ h("button", {
1788
+ key: "cancel",
1789
+ type: "button",
1790
+ style: S.button,
1791
+ disabled: busy !== null,
1792
+ onClick: function () { setNewBranchOpen(false); }
1793
+ }, t("newBranch.cancel"))
1794
+ ]));
1795
+
1796
+ function doCreateBranch() {
1797
+ var nameText = newBranchName.trim();
1798
+ var baseText = effectiveBase;
1799
+ if (nameText === "" || baseText === "" || busy !== null) return;
1800
+ setNewBranchName("");
1801
+ setNewBranchOpen(false);
1802
+ store.act("createBranch", function () { return store.verbs.createBranch(state.cwd, nameText, baseText); });
1803
+ }
1804
+ }
1805
+ if (pendingBranch !== null && state.dirty > 0) {
1806
+ var samplePaths = state.changes.slice(0, 3).map(function (change) { return change.path; }).join("、");
1807
+ if (samplePaths === "") samplePaths = "…";
1808
+ if (samplePaths.length > 60) samplePaths = samplePaths.slice(0, 57) + "…";
1809
+ bodyChildren.push(h("div", { key: "dirtywarn", style: S.warn }, [
1810
+ h("span", { key: "txt", style: { flex: "1", minWidth: 0 } },
1811
+ t("switch.dirtyWarn", { count: state.dirty, paths: samplePaths })),
1812
+ h("button", {
1813
+ key: "force",
1814
+ type: "button",
1815
+ style: Object.assign({}, S.button, busy !== null ? S.buttonDisabled : null),
1816
+ disabled: busy !== null,
1817
+ onClick: function () {
1818
+ var target = pendingBranch;
1819
+ setPendingBranch(null);
1820
+ store.act("checkout", function () { return store.verbs.checkout(state.cwd, target); });
1821
+ }
1822
+ }, busy === "checkout" ? t("busy.checkout") : t("switch.force"))
1823
+ ]));
1824
+ }
1825
+
1826
+ // network + commit row
1827
+ bodyChildren.push(h("div", { key: "net", style: S.row }, [
1828
+ h("button", {
1829
+ key: "fetch",
1830
+ type: "button",
1831
+ style: Object.assign({}, S.button, busy !== null ? S.buttonDisabled : null),
1832
+ disabled: busy !== null,
1833
+ onClick: function () { store.act("fetch", function () { return store.verbs.fetch(state.cwd); }); }
1834
+ }, busy === "fetch" ? t("busy.fetch") : t("action.fetch")),
1835
+ h("button", {
1836
+ key: "pull",
1837
+ type: "button",
1838
+ style: Object.assign({}, S.button, busy !== null ? S.buttonDisabled : null),
1839
+ disabled: busy !== null,
1840
+ onClick: function () { store.act("pull", function () { return store.verbs.pull(state.cwd); }); }
1841
+ }, busy === "pull" ? t("busy.pull") : t("action.pull")),
1842
+ h("button", {
1843
+ key: "push",
1844
+ type: "button",
1845
+ style: Object.assign({}, S.buttonPrimary, busy !== null ? S.buttonDisabled : null),
1846
+ disabled: busy !== null,
1847
+ onClick: function () { store.act("push", function () { return store.verbs.push(state.cwd); }); }
1848
+ }, busy === "push" ? t("busy.push") : t("action.push"))
1849
+ ]));
1850
+
1851
+ // commit tooling: stage everything, pick the draft basis, draft.
1852
+ // Ordering mirrors the real workflow (stage → draft → commit) and
1853
+ // keeps the 400px panel readable by splitting the tool row from the
1854
+ // message row.
1855
+ var noChanges = state.dirty === 0;
1856
+ bodyChildren.push(h("div", { key: "commitTools", style: S.row }, [
1857
+ h("button", {
1858
+ key: "stage",
1859
+ type: "button",
1860
+ style: Object.assign({}, S.button, busy !== null || noChanges ? S.buttonDisabled : null),
1861
+ disabled: busy !== null || noChanges,
1862
+ title: noChanges ? t("changes.none") : t("action.stage"),
1863
+ onClick: function () { store.act("stage", function () { return store.verbs.stage(state.cwd); }); }
1864
+ }, busy === "stage" ? t("busy.stage") : t("action.stage")),
1865
+ h("select", {
1866
+ key: "mode",
1867
+ style: Object.assign({}, S.select, { flex: "none", width: "auto", maxWidth: 96 }),
1868
+ value: generateMode,
1869
+ disabled: busy !== null,
1870
+ title: t("generate.mode.title"),
1871
+ "aria-label": t("generate.mode.title"),
1872
+ onChange: function (event) { setGenerateMode(event.target.value); }
1873
+ }, [
1874
+ h("option", { key: "staged", value: "staged", style: OPTION_STYLE }, t("generate.mode.staged")),
1875
+ h("option", { key: "unstaged", value: "unstaged", style: OPTION_STYLE }, t("generate.mode.unstaged")),
1876
+ h("option", { key: "all", value: "all", style: OPTION_STYLE }, t("generate.mode.all"))
1877
+ ]),
1878
+ h("button", {
1879
+ key: "generate",
1880
+ type: "button",
1881
+ style: Object.assign({}, S.button, busy !== null || noChanges ? S.buttonDisabled : null),
1882
+ disabled: busy !== null || noChanges,
1883
+ title: noChanges ? t("changes.none") : t("action.generate"),
1884
+ onClick: doGenerate
1885
+ }, busy === "generate" ? t("busy.generate") : "✨ " + t("action.generate"))
1886
+ ]));
1887
+
1888
+ bodyChildren.push(h("div", { key: "commit", style: S.row }, [
1889
+ h("textarea", {
1890
+ key: "msg",
1891
+ rows: 2,
1892
+ style: S.textarea,
1893
+ value: message,
1894
+ placeholder: t("commit.placeholder"),
1895
+ disabled: busy !== null,
1896
+ onChange: function (event) { setMessage(event.target.value); },
1897
+ // Enter inserts a newline (a message may have a body); the
1898
+ // explicit commit chord is Ctrl/Cmd+Enter.
1899
+ onKeyDown: function (event) {
1900
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && message.trim() !== "" && busy === null) {
1901
+ event.preventDefault();
1902
+ doCommit();
1903
+ }
1904
+ }
1905
+ }),
1906
+ h("button", {
1907
+ key: "commit",
1908
+ type: "button",
1909
+ style: Object.assign({}, S.buttonPrimary, busy !== null || message.trim() === "" ? S.buttonDisabled : null),
1910
+ disabled: busy !== null || message.trim() === "",
1911
+ title: message.trim() === "" ? t("commit.empty") : undefined,
1912
+ onClick: doCommit
1913
+ }, busy === "commit" ? t("busy.commit") : t("action.commit"))
1914
+ ]));
1915
+
1916
+ function doCommit() {
1917
+ var text = message.trim();
1918
+ if (text === "" || busy !== null) return;
1919
+ setMessage("");
1920
+ store.act("commit", function () { return store.verbs.commit(state.cwd, text); });
1921
+ }
1922
+
1923
+ /**
1924
+ * Word one generation outcome. The host reports a stable code in
1925
+ * `error.details.code`; known codes get a localized sentence and
1926
+ * anything unrecognized falls through to the host's own message.
1927
+ */
1928
+ function describeGenerate(res) {
1929
+ if (res && res.ok === true) {
1930
+ return { kind: "ok", text: t("generate.done") };
1931
+ }
1932
+ var details = res && res.error && res.error.details ? res.error.details : {};
1933
+ var known = {
1934
+ "no-changes": details.mode === "staged" ? t("generate.noStaged") : t("generate.noChanges"),
1935
+ "no-provider": t("generate.noProvider"),
1936
+ "no-model": t("generate.noModel"),
1937
+ "llm-empty": t("generate.empty"),
1938
+ "cancelled": t("generate.cancelled")
1939
+ };
1940
+ var text = known[details.code];
1941
+ if (text === undefined) {
1942
+ text = res && res.error && res.error.message ? String(res.error.message) : t("generate.failed");
1943
+ }
1944
+ return { kind: "error", text: text };
1945
+ }
1946
+
1947
+ function doGenerate() {
1948
+ if (busy !== null) return;
1949
+ var route = sessionRoute;
1950
+ store.act("generate", function () {
1951
+ return store.verbs.generateMessage(
1952
+ state.cwd,
1953
+ generateMode,
1954
+ route ? route.provider : undefined,
1955
+ route ? route.model : undefined
1956
+ );
1957
+ }, describeGenerate).then(function (res) {
1958
+ if (res && res.ok === true && res.value && res.value.message) {
1959
+ setMessage(String(res.value.message));
1960
+ }
1961
+ });
1962
+ }
1963
+
1964
+ // changes (one row per file) — collapsible, and the diff viewer's
1965
+ // navigation: clicking a row opens that file's diff on the right.
1966
+ var changesRow;
1967
+ if (state.changes.length === 0) {
1968
+ changesRow = h("div", { key: "none", style: S.meta }, t("changes.none"));
1969
+ } else {
1970
+ var shown = changesOpen ? state.changes : state.changes.slice(0, 8);
1971
+ changesRow = h("div", { key: "list" }, shown.map(function (change, index) {
1972
+ var file = change.file !== undefined ? change.file : change.path;
1973
+ var origFile = change.origFile !== undefined ? change.origFile : null;
1974
+ var isActive = selected !== null && selected.file === file && selected.origFile === origFile;
1975
+ return h("div", {
1976
+ key: index,
1977
+ style: isActive ? S.changeRowActive : S.changeRow,
1978
+ role: "button",
1979
+ tabIndex: 0,
1980
+ title: t("diff.open"),
1981
+ "aria-label": t("diff.open") + " " + change.path,
1982
+ "data-dsh-git": "change-row",
1983
+ "data-active": isActive === true ? "true" : "false",
1984
+ onClick: function () { selectChange(change); },
1985
+ onKeyDown: function (event) {
1986
+ if (event.key === "Enter" || event.key === " ") {
1987
+ if (event.preventDefault) event.preventDefault();
1988
+ selectChange(change);
1989
+ }
1990
+ }
1991
+ }, [
1992
+ h("span", { key: "tag", style: S.statusTag }, t("status." + change.status)),
1993
+ h("span", { key: "path", style: S.changePath, title: change.path }, change.path)
1994
+ ]);
1995
+ }));
1996
+ if (state.changes.length > shown.length) {
1997
+ changesRow = h("div", { key: "list" }, [
1998
+ changesRow,
1999
+ h("button", {
2000
+ key: "more",
2001
+ type: "button",
2002
+ style: S.button,
2003
+ onClick: function () { setChangesOpen(!changesOpen); }
2004
+ }, changesOpen ? "▴" : "▾ " + (state.changes.length - shown.length))
2005
+ ]);
2006
+ }
2007
+ }
2008
+ bodyChildren.push(h("div", { key: "changes", style: S.sectionTitle, onClick: function () { setChangesOpen(!changesOpen); } }, (changesOpen ? "▾ " : "▸ ") + t("changes.title", { count: state.changes.length })));
2009
+ bodyChildren.push(h("div", { key: "changesBody" }, changesRow));
2010
+
2011
+ // recent commits — collapsible
2012
+ var logRows;
2013
+ if (state.commits.length === 0) {
2014
+ logRows = h("div", { key: "none", style: S.meta }, t("log.none"));
2015
+ } else {
2016
+ var logShown = logOpen ? state.commits : state.commits.slice(0, 5);
2017
+ logRows = h("div", { key: "list" }, logShown.map(function (commit, index) {
2018
+ return h("div", { key: index, style: S.logRow, title: commit.subject }, [
2019
+ h("span", { key: "sha", style: S.logSha }, commit.sha),
2020
+ h("span", { key: "subj", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "1", minWidth: 0 } }, commit.subject)
2021
+ ]);
2022
+ }));
2023
+ if (state.commits.length > logShown.length) {
2024
+ logRows = h("div", { key: "list" }, [
2025
+ logRows,
2026
+ h("button", {
2027
+ key: "more",
2028
+ type: "button",
2029
+ style: S.button,
2030
+ onClick: function () { setLogOpen(!logOpen); }
2031
+ }, logOpen ? "▴" : "▾ " + (state.commits.length - logShown.length))
2032
+ ]);
2033
+ }
2034
+ }
2035
+ bodyChildren.push(h("div", { key: "logTitle", style: S.sectionTitle, onClick: function () { setLogOpen(!logOpen); } }, (logOpen ? "▾ " : "▸ ") + t("log.title")));
2036
+ bodyChildren.push(h("div", { key: "logBody" }, logRows));
2037
+ }
2038
+
2039
+ // last operation output
2040
+ if (state.lastResult) {
2041
+ bodyChildren.push(h("div", {
2042
+ key: "output",
2043
+ style: Object.assign({}, S.output, state.lastResult.kind === "ok" ? S.outputOk : S.outputError)
2044
+ }, state.lastResult.text || t("output.ok")));
2045
+ }
2046
+ if (busy) {
2047
+ bodyChildren.push(h("div", { key: "busy", style: S.meta }, busyLabel));
2048
+ }
2049
+
2050
+ // Everything the open diff depends on, as one comparable string: a
2051
+ // commit moves the oid, a branch switch moves several of these, a
2052
+ // session switch changes the cwd, and STAGING moves the per-file index
2053
+ // letters. The letters are in here, not just the file count, because
2054
+ // `git add` leaves the count and the dirty tally untouched while moving
2055
+ // the change from the worktree side to the index side — the exact move
2056
+ // the open diff has to follow.
2057
+ var dataVersion = [
2058
+ state.cwd,
2059
+ state.oid,
2060
+ state.branch,
2061
+ state.dirty,
2062
+ state.changes.map(function (change) {
2063
+ return (change.index !== undefined ? change.index : " ") + (change.worktree !== undefined ? change.worktree : " ") + change.file;
2064
+ }).join(",")
2065
+ ].join("|");
2066
+
2067
+ // The pane's state chip follows the CURRENT status of the file, not the
2068
+ // one captured when it was clicked: staging or committing moves a file
2069
+ // between the list's labels while the pane stays open on it.
2070
+ var liveSelection = selected;
2071
+ if (selected !== null) {
2072
+ for (var ci = 0; ci < state.changes.length; ci += 1) {
2073
+ var candidate = state.changes[ci];
2074
+ var candidateFile = candidate.file !== undefined ? candidate.file : candidate.path;
2075
+ var candidateOrig = candidate.origFile !== undefined ? candidate.origFile : null;
2076
+ if (candidateFile === selected.file && candidateOrig === selected.origFile) {
2077
+ liveSelection = Object.assign({}, selected, { status: candidate.status, path: candidate.path });
2078
+ break;
2079
+ }
2080
+ }
2081
+ }
2082
+
2083
+ var body = selected === null
2084
+ ? h("div", { key: "body", style: S.body }, bodyChildren)
2085
+ : h("div", { key: "body", style: S.bodySplit }, [
2086
+ h("div", { key: "workbench", style: S.leftPane, "data-dsh-git": "panel-left" }, bodyChildren),
2087
+ h(GitDiffPane, {
2088
+ // Keyed by file so a new selection starts with fresh view state
2089
+ // (side choice, wrapping, "show all") instead of inheriting the
2090
+ // previous file's.
2091
+ key: "diff:" + selected.file + "\u0000" + (selected.origFile === null || selected.origFile === undefined ? "" : selected.origFile),
2092
+ store: store,
2093
+ t: t,
2094
+ cwd: state.cwd,
2095
+ change: liveSelection,
2096
+ dataVersion: dataVersion,
2097
+ onClose: function () { setSelected(null); }
2098
+ })
2099
+ ]);
2100
+
2101
+ // The diff's width handle IS the panel's right edge: dragging it widens
2102
+ // the panel (the workbench keeps its width) and the diff takes the rest.
2103
+ // It covers the full height but sits under the header, so the header's
2104
+ // own drag and its buttons keep the pointer in that band.
2105
+ var resizeHandle = selected === null ? null : h("div", {
2106
+ key: "resize",
2107
+ "data-dsh-git": "panel-resize",
2108
+ style: resizeHover === true ? Object.assign({}, S.resizeHandle, S.resizeHandleActive) : S.resizeHandle,
2109
+ role: "separator",
2110
+ "aria-orientation": "vertical",
2111
+ "aria-label": t("diff.resize"),
2112
+ title: t("diff.resize"),
2113
+ onMouseDown: onPanelResizeMouseDown,
2114
+ onTouchStart: onPanelResizeTouchStart,
2115
+ onDoubleClick: function () { setDiffWidth(clampDiffWidth(DIFF_DEFAULT_WIDTH)); },
2116
+ onMouseEnter: function () { setResizeHover(true); },
2117
+ onMouseLeave: function () { setResizeHover(false); }
2118
+ });
2119
+
2120
+ return h("div", { "data-dsh-git": "panel", style: panelStyle, ref: panelRef }, [header, body, resizeHandle]);
2121
+ }
2122
+
2123
+ /**
2124
+ * Input-dock pill (conversation.input.dock): a compact status capsule,
2125
+ * left-aligned above the composer card (textarea top-left); clicking
2126
+ * toggles the workbench panel. Reads the same store, so it stays in
2127
+ * sync with the panel and with session switches.
2128
+ */
2129
+ function GitDockLine(props) {
2130
+ var store = props.store;
2131
+ var t = props.t || function (key, params) {
2132
+ return key + (params ? " " + JSON.stringify(params) : "");
2133
+ };
2134
+ // This seat is session-scoped, so the framework hands it the current
2135
+ // Session identity; that `sessionId` is the one thing a root-scoped
2136
+ // seat (the panel) cannot read for itself under dsh >= 0.1.6.
2137
+ var sessionId = props.sessionId;
2138
+ var useSessions = props.useSessions;
2139
+ // useSessions is a selector hook (useSyncExternalStoreWithSelector):
2140
+ // the selector argument is REQUIRED — a bare useSessions() crashes
2141
+ // with "selector is not a function".
2142
+ var sessions = useSessions(function (s) { return s; });
2143
+ var summary = sessionId !== undefined && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
2144
+ var cwd = summary ? summary.cwd : undefined;
2145
+
2146
+ // Embedded Conversations (a right-sidebar chat tab) carry their own
2147
+ // Session binding; only the main-view seat may drive the shared
2148
+ // workbench, or a subagent chat would pull the panel's repo out from
2149
+ // under the Session the user is looking at. `retainedBy.mainView` is
2150
+ // the same signal ui-session derives the current Session from; when
2151
+ // the snapshot does not carry it (an older host) the seat binds
2152
+ // anyway, so the pill never disappears for want of a count.
2153
+ var mainView = summary === undefined || summary.retainedBy === undefined || summary.retainedBy === null
2154
+ ? true
2155
+ : (summary.retainedBy.mainView || 0) > 0;
2156
+
2157
+ // Classic subscription pattern (see GitFloatingPanel for why).
2158
+ var stateRef = React.useState(function () { return store.getSnapshot(); });
2159
+ var state = stateRef[0];
2160
+ var setState = stateRef[1];
2161
+ React.useEffect(function () {
2162
+ var unsubscribe = store.subscribe(function () {
2163
+ setState(store.getSnapshot());
2164
+ });
2165
+ return unsubscribe;
2166
+ }, [store]);
2167
+
2168
+ // The pill owns the store's session binding: it is the only seat that
2169
+ // knows the current Session's identity and workspace directory.
2170
+ React.useEffect(function () {
2171
+ if (!mainView) return;
2172
+ store.bindSession(sessionId, cwd);
2173
+ }, [sessionId, cwd, mainView, store]);
2174
+
2175
+ if (!cwd || !mainView || state.sessionId !== sessionId) return null;
2176
+
2177
+ var text;
2178
+ if (state.phase === "loading" || state.phase === "idle") {
2179
+ text = "⎇ " + t("loading");
2180
+ } else if (state.phase === "error" || !state.repo) {
2181
+ text = "⎇ " + t("notRepo");
2182
+ } else {
2183
+ var branchText = state.branch;
2184
+ if (!branchText && state.detached) {
2185
+ branchText = t("detached") + (state.oid ? " " + state.oid.slice(0, 7) : "");
2186
+ }
2187
+ text = "⎇ " + (branchText || "—");
2188
+ if (state.dirty > 0) text += " ●" + state.dirty;
2189
+ if (state.ahead > 0 || state.behind > 0) text += " ↑" + state.ahead + " ↓" + state.behind;
2190
+ }
2191
+
2192
+ // Left-aligned compact pill in the row band ABOVE the composer card
2193
+ // (conversation.input.dock). The padding-left mirrors the centered
2194
+ // card's left margin, so the pill hugs the textarea's top-left
2195
+ // corner instead of floating at the conversation column's edge.
2196
+ return h("div", {
2197
+ style: {
2198
+ boxSizing: "border-box",
2199
+ display: "flex",
2200
+ justifyContent: "flex-start",
2201
+ width: "100%",
2202
+ paddingLeft: "calc((100% - var(--dsh-composer-card-max-width, 778px)) / 2)",
2203
+ margin: "2px 0"
2204
+ },
2205
+ "data-dsh-git": "dock-row"
2206
+ }, h("button", {
2207
+ type: "button",
2208
+ style: {
2209
+ display: "inline-flex",
2210
+ alignItems: "center",
2211
+ gap: 6,
2212
+ maxWidth: 220,
2213
+ padding: "1px 10px",
2214
+ borderRadius: 999,
2215
+ border: "1px solid var(--dsw-alias-separator-primary, rgba(127,127,127,0.25))",
2216
+ background: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.12))",
2217
+ color: "var(--dsw-alias-label-secondary, #c8ccd2)",
2218
+ font: "inherit",
2219
+ fontSize: 12,
2220
+ lineHeight: "20px",
2221
+ cursor: "pointer",
2222
+ whiteSpace: "nowrap",
2223
+ overflow: "hidden",
2224
+ textOverflow: "ellipsis",
2225
+ userSelect: "none"
2226
+ },
2227
+ "data-dsh-git": "dock",
2228
+ title: cwd,
2229
+ "aria-label": t("dock.aria"),
2230
+ onClick: function () { store.setPanelOpen(!state.panelOpen); }
2231
+ }, text));
2232
+ }
2233
+
2234
+ /**
2235
+ * Client plugin body.
2236
+ * @param ctx - client root context (slots, connection, locale).
2237
+ */
2238
+ function apply(ctx) {
2239
+ ctx.effect(function () {
2240
+ return ctx.locale.register(NS, { zh: zh, en: en });
2241
+ }, "dsh-git: dictionaries");
2242
+
2243
+ var store = createGitStore(ctx);
2244
+
2245
+ ctx.slots.inject("shell.overlay", function () {
2246
+ return ctx.slots.register({
2247
+ name: "shell.overlay",
2248
+ id: "dsh-git-panel",
2249
+ order: 10,
2250
+ locale: NS,
2251
+ inject: function () {
2252
+ return { store: store };
2253
+ }
2254
+ }, GitFloatingPanel);
2255
+ });
2256
+
2257
+ ctx.slots.inject("conversation.input.dock", function () {
2258
+ return ctx.slots.register({
2259
+ name: "conversation.input.dock",
2260
+ id: "dsh-git-pill",
2261
+ order: 20,
2262
+ locale: NS,
2263
+ inject: function (sessionId) {
2264
+ return { store: store };
2265
+ }
2266
+ }, GitDockLine);
2267
+ });
2268
+ }
2269
+
2270
+ exports.apply = apply;
2271
+ exports.inject = inject;
2272
+ exports.name = name;
2273
+ // Test-only surface: the module-loader contract reads `apply`, `inject`,
2274
+ // and `name` and ignores everything else, while the render test needs the
2275
+ // diff parser and one row renderer without a browser (SSR runs no effects,
2276
+ // so the pane's own reads cannot be driven from a static render).
2277
+ exports.__internals = {
2278
+ parseUnifiedDiff: parseUnifiedDiff,
2279
+ parseDiffSide: parseDiffSide,
2280
+ diffRowView: diffRowView,
2281
+ GitDiffPane: GitDiffPane,
2282
+ DIFF_ROW: DIFF_ROW
2283
+ };
2284
+ return module.exports;
2285
+ }
2286
+ });