dsh-ide-git 0.7.3 → 0.9.0

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/src/client.js CHANGED
@@ -38,6 +38,9 @@ window.__ModuleLoader__.load({
38
38
  would escape the mount and 404 behind any strict proxy. */
39
39
  const API_BASE = 'dsh-ide-git/api'
40
40
  const TAB_ID = 'dsh-ide-git:panel'
41
+ /* The bundle's package name: the key the Plugins page uses for a bundle's own
42
+ page and its `plugins.bundle.config` slot. */
43
+ const SETTINGS_BUNDLE = 'dsh-ide-git'
41
44
  /* The namespace this plugin's dictionaries are published under in the DSH
42
45
  locale registry. */
43
46
  const LOCALE_NS = 'dsh-ide-git'
@@ -115,6 +118,11 @@ window.__ModuleLoader__.load({
115
118
  'action.showDiff': '显示差异',
116
119
  'action.copyPath': '复制路径',
117
120
  'action.checkout': '签出',
121
+ 'action.float': '浮动本页',
122
+ 'action.dock': '收回停靠',
123
+ 'action.split': '分栏',
124
+ 'action.fullscreen': '全屏',
125
+ 'action.exitFullscreen': '退出全屏',
118
126
  'action.newBranchFrom': '从该分支新建分支...',
119
127
  'action.newBranchHere': '在此新建分支...',
120
128
  'action.rename': '重命名...',
@@ -138,6 +146,8 @@ window.__ModuleLoader__.load({
138
146
  'detail.back': '返回历史',
139
147
  'detail.noFiles': '没有文件变更',
140
148
  'diff.empty': '没有可显示的文本差异',
149
+ 'pane.diff': '差异视图',
150
+ 'pane.resize': '拖动调整{name}尺寸,双击复位,方向键微调',
141
151
  'diff.binary': '二进制文件',
142
152
  'diff.loading': '正在加载差异...',
143
153
  'toolbar.refresh': '刷新',
@@ -148,6 +158,28 @@ window.__ModuleLoader__.load({
148
158
  'toolbar.tree': '分支栏',
149
159
  'rail.more': '更多操作',
150
160
  'rail.settings': '动作条设置',
161
+ 'rail.openSettings': '打开插件设置',
162
+ 'settings.rail.title': '动作条按钮',
163
+ 'settings.rail.hint': '勾选要显示在动作条上的动作;顺序固定为默认顺序,取消勾选即隐藏。',
164
+ 'settings.rail.unavailable': '当前宿主没有设置服务,请用面板内的「动作条设置」。',
165
+ 'settings.commit.title': 'AI 提交信息',
166
+ 'settings.commit.model': '使用的模型',
167
+ 'settings.commit.modelHint': '留空 = 跟随当前会话(provider/model)',
168
+ 'settings.commit.reasoning': '思考强度',
169
+ 'settings.commit.reasoningHint': '留空 = 模型默认',
170
+ 'settings.commit.prompt': '补充提示词',
171
+ 'settings.commit.promptHint': '例如:用 Conventional Commits,subject 用中文,不超过 72 字符',
172
+ 'settings.commit.tooLong': '超过 {n} 个字符的部分不会保存',
173
+ 'changes.aiWrite': 'AI 写提交信息',
174
+ 'changes.aiBusy': '正在生成…',
175
+ 'changes.aiQuota': '会消耗你的额度',
176
+ 'changes.aiOverwriteTitle': '覆盖当前草稿?',
177
+ 'changes.aiOverwriteText': 'AI 生成的内容会替换你已经输入的提交信息(可以撤销)。',
178
+ 'changes.aiUndo': '撤销,恢复我的草稿',
179
+ 'changes.aiFailed': '生成失败:',
180
+ 'changes.aiNotice': '部分变更没有发给模型(已截断或丢弃),信息可能不完整。',
181
+ 'settings.rail.writeFailed': '设置没写进去,已退回原状态;请重试。',
182
+ 'settings.rail.readonly': '当前宿主把配置设为只读,开关不可修改。',
151
183
  'rail.settingsHint': '拖动或用箭头调整顺序,眼睛图标控制显示',
152
184
  'rail.reset': '恢复默认',
153
185
  'rail.up': '上移',
@@ -269,6 +301,11 @@ window.__ModuleLoader__.load({
269
301
  'action.showDiff': 'Show diff',
270
302
  'action.copyPath': 'Copy path',
271
303
  'action.checkout': 'Checkout',
304
+ 'action.float': 'Float this page',
305
+ 'action.dock': 'Dock back',
306
+ 'action.split': 'Split pane',
307
+ 'action.fullscreen': 'Full screen',
308
+ 'action.exitFullscreen': 'Exit full screen',
272
309
  'action.newBranchFrom': 'New branch from here...',
273
310
  'action.newBranchHere': 'New branch here...',
274
311
  'action.rename': 'Rename...',
@@ -292,6 +329,8 @@ window.__ModuleLoader__.load({
292
329
  'detail.back': 'Back to history',
293
330
  'detail.noFiles': 'No file changes',
294
331
  'diff.empty': 'No textual diff to show',
332
+ 'pane.diff': 'diff view',
333
+ 'pane.resize': 'Drag to resize {name}: double-click resets, arrow keys nudge',
295
334
  'diff.binary': 'Binary file',
296
335
  'diff.loading': 'Loading diff...',
297
336
  'toolbar.refresh': 'Refresh',
@@ -302,6 +341,28 @@ window.__ModuleLoader__.load({
302
341
  'toolbar.tree': 'Branch pane',
303
342
  'rail.more': 'More actions',
304
343
  'rail.settings': 'Action bar settings',
344
+ 'rail.openSettings': 'Open plugin settings',
345
+ 'settings.rail.title': 'Action rail buttons',
346
+ 'settings.rail.hint': 'Tick the actions shown on the rail. The order is fixed; unticking one hides it.',
347
+ 'settings.rail.unavailable': 'This host has no settings service; use the in-panel “Action rail settings”.',
348
+ 'settings.commit.title': 'AI commit message',
349
+ 'settings.commit.model': 'Model',
350
+ 'settings.commit.modelHint': 'Empty = follow this session (provider/model)',
351
+ 'settings.commit.reasoning': 'Reasoning effort',
352
+ 'settings.commit.reasoningHint': 'Empty = the model\'s default',
353
+ 'settings.commit.prompt': 'Extra instructions',
354
+ 'settings.commit.promptHint': 'e.g. Conventional Commits, Chinese subject, at most 72 characters',
355
+ 'settings.commit.tooLong': 'Only the first {n} characters are saved',
356
+ 'changes.aiWrite': 'Write with AI',
357
+ 'changes.aiBusy': 'Writing…',
358
+ 'changes.aiQuota': 'Uses your own quota',
359
+ 'changes.aiOverwriteTitle': 'Replace the current draft?',
360
+ 'changes.aiOverwriteText': 'The AI text will replace the commit message you typed (you can undo it).',
361
+ 'changes.aiUndo': 'Undo, bring my draft back',
362
+ 'changes.aiFailed': 'Could not write it:',
363
+ 'changes.aiNotice': 'Some changes were not sent to the model (truncated or dropped); the message may be incomplete.',
364
+ 'settings.rail.writeFailed': 'The setting did not save; the switch went back. Try again.',
365
+ 'settings.rail.readonly': 'This host serves the configuration read-only.',
305
366
  'rail.settingsHint': 'Drag or use the arrows to reorder; the eye toggles visibility',
306
367
  'rail.reset': 'Restore defaults',
307
368
  'rail.up': 'Move up',
@@ -431,6 +492,11 @@ window.__ModuleLoader__.load({
431
492
  'action.showDiff': '顯示差異',
432
493
  'action.copyPath': '複製路徑',
433
494
  'action.checkout': '簽出',
495
+ 'action.float': '浮動本頁',
496
+ 'action.dock': '收回停靠',
497
+ 'action.split': '分欄',
498
+ 'action.fullscreen': '全螢幕',
499
+ 'action.exitFullscreen': '結束全螢幕',
434
500
  'action.newBranchFrom': '由此新建分支...',
435
501
  'action.newBranchHere': '喺呢度新建分支...',
436
502
  'action.rename': '重新命名...',
@@ -454,6 +520,8 @@ window.__ModuleLoader__.load({
454
520
  'detail.back': '返回歷史',
455
521
  'detail.noFiles': '冇檔案變更',
456
522
  'diff.empty': '冇文字差異可以顯示',
523
+ 'pane.diff': '差異檢視',
524
+ 'pane.resize': '拖動調整{name}尺寸,雙擊復位,方向鍵微調',
457
525
  'diff.binary': '二進位檔案',
458
526
  'diff.loading': '正在載入差異...',
459
527
  'toolbar.refresh': '重新整理',
@@ -464,6 +532,28 @@ window.__ModuleLoader__.load({
464
532
  'toolbar.tree': '分支面板',
465
533
  'rail.more': '更多操作',
466
534
  'rail.settings': '動作列設定',
535
+ 'rail.openSettings': '開啟外掛設定',
536
+ 'settings.rail.title': '動作列按鈕',
537
+ 'settings.rail.hint': '勾選要顯示在動作列上的動作;順序固定,取消勾選即隱藏。',
538
+ 'settings.rail.unavailable': '目前宿主沒有設定服務,請用面板內的「動作條設定」。',
539
+ 'settings.commit.title': 'AI 提交訊息',
540
+ 'settings.commit.model': '使用嘅模型',
541
+ 'settings.commit.modelHint': '留空 = 跟隨當前對話(provider/model)',
542
+ 'settings.commit.reasoning': '思考強度',
543
+ 'settings.commit.reasoningHint': '留空 = 模型預設',
544
+ 'settings.commit.prompt': '補充提示詞',
545
+ 'settings.commit.promptHint': '例如:用 Conventional Commits,subject 用中文,唔超過 72 字',
546
+ 'settings.commit.tooLong': '超過 {n} 個字嘅部分唔會儲存',
547
+ 'changes.aiWrite': 'AI 寫提交訊息',
548
+ 'changes.aiBusy': '生成緊…',
549
+ 'changes.aiQuota': '會消耗你嘅額度',
550
+ 'changes.aiOverwriteTitle': '覆蓋當前草稿?',
551
+ 'changes.aiOverwriteText': 'AI 生成嘅內容會換走你打咗嘅提交訊息(可以撤銷)。',
552
+ 'changes.aiUndo': '撤銷,還原我嘅草稿',
553
+ 'changes.aiFailed': '生成失敗:',
554
+ 'changes.aiNotice': '有部分變更冇發俾模型(已截斷或丟棄),訊息可能唔完整。',
555
+ 'settings.rail.writeFailed': '設定寫唔入,已經退回原狀;請再試。',
556
+ 'settings.rail.readonly': '目前宿主將設定設為唯讀,開關改唔到。',
467
557
  'rail.settingsHint': '拖曳或用箭嘴排序;眼睛圖示切換顯示',
468
558
  'rail.reset': '還原預設',
469
559
  'rail.up': '上移',
@@ -585,6 +675,11 @@ window.__ModuleLoader__.load({
585
675
  'action.showDiff': '顯示差異',
586
676
  'action.copyPath': '複製路徑',
587
677
  'action.checkout': '簽出',
678
+ 'action.float': '浮動本頁',
679
+ 'action.dock': '收回停靠',
680
+ 'action.split': '分割欄',
681
+ 'action.fullscreen': '全螢幕',
682
+ 'action.exitFullscreen': '結束全螢幕',
588
683
  'action.newBranchFrom': '由此新建分支...',
589
684
  'action.newBranchHere': '在此新建分支...',
590
685
  'action.rename': '重新命名...',
@@ -608,6 +703,8 @@ window.__ModuleLoader__.load({
608
703
  'detail.back': '返回歷史',
609
704
  'detail.noFiles': '沒有檔案變更',
610
705
  'diff.empty': '沒有文字差異可顯示',
706
+ 'pane.diff': '差異檢視',
707
+ 'pane.resize': '拖曳調整{name}尺寸,雙擊重設,方向鍵微調',
611
708
  'diff.binary': '二進位檔案',
612
709
  'diff.loading': '正在載入差異...',
613
710
  'toolbar.refresh': '重新整理',
@@ -618,6 +715,28 @@ window.__ModuleLoader__.load({
618
715
  'toolbar.tree': '分支面板',
619
716
  'rail.more': '更多操作',
620
717
  'rail.settings': '動作列設定',
718
+ 'rail.openSettings': '開啟外掛設定',
719
+ 'settings.rail.title': '動作列按鈕',
720
+ 'settings.rail.hint': '勾選要顯示在動作列上的動作;順序固定,取消勾選即隱藏。',
721
+ 'settings.rail.unavailable': '目前宿主沒有設定服務,請用面板內的「動作條設定」。',
722
+ 'settings.commit.title': 'AI 提交訊息',
723
+ 'settings.commit.model': '使用的模型',
724
+ 'settings.commit.modelHint': '留空 = 跟隨目前工作階段(provider/model)',
725
+ 'settings.commit.reasoning': '思考強度',
726
+ 'settings.commit.reasoningHint': '留空 = 模型預設',
727
+ 'settings.commit.prompt': '補充提示詞',
728
+ 'settings.commit.promptHint': '例如:用 Conventional Commits,subject 用中文,不超過 72 字元',
729
+ 'settings.commit.tooLong': '超過 {n} 個字元的部分不會儲存',
730
+ 'changes.aiWrite': 'AI 寫提交訊息',
731
+ 'changes.aiBusy': '正在生成…',
732
+ 'changes.aiQuota': '會消耗你的額度',
733
+ 'changes.aiOverwriteTitle': '覆蓋目前的草稿?',
734
+ 'changes.aiOverwriteText': 'AI 生成的內容會取代你已輸入的提交訊息(可以復原)。',
735
+ 'changes.aiUndo': '復原,還原我的草稿',
736
+ 'changes.aiFailed': '生成失敗:',
737
+ 'changes.aiNotice': '部分變更沒有傳給模型(已截斷或捨棄),訊息可能不完整。',
738
+ 'settings.rail.writeFailed': '設定沒有寫入,已退回原狀態;請重試。',
739
+ 'settings.rail.readonly': '目前宿主將設定設為唯讀,開關無法修改。',
621
740
  'rail.settingsHint': '拖曳或用箭頭排序;眼睛圖示切換顯示',
622
741
  'rail.reset': '還原預設',
623
742
  'rail.up': '上移',
@@ -739,6 +858,11 @@ window.__ModuleLoader__.load({
739
858
  'action.showDiff': '顯示差異',
740
859
  'action.copyPath': '複製路徑',
741
860
  'action.checkout': '簽出',
861
+ 'action.float': '浮動本頁',
862
+ 'action.dock': '收回停靠',
863
+ 'action.split': '分欄',
864
+ 'action.fullscreen': '全螢幕',
865
+ 'action.exitFullscreen': '結束全螢幕',
742
866
  'action.newBranchFrom': '由此新建分支...',
743
867
  'action.newBranchHere': '喺呢度新建分支...',
744
868
  'action.rename': '重新命名...',
@@ -762,6 +886,8 @@ window.__ModuleLoader__.load({
762
886
  'detail.back': '返回歷史',
763
887
  'detail.noFiles': '冇檔案變更',
764
888
  'diff.empty': '冇文字差異可以顯示',
889
+ 'pane.diff': '差異檢視',
890
+ 'pane.resize': '拖動調整{name}尺寸,雙擊復位,方向鍵微調',
765
891
  'diff.binary': '二進位檔案',
766
892
  'diff.loading': '正在載入差異...',
767
893
  'toolbar.refresh': '重新整理',
@@ -772,6 +898,28 @@ window.__ModuleLoader__.load({
772
898
  'toolbar.tree': '分支面板',
773
899
  'rail.more': '更多操作',
774
900
  'rail.settings': '動作列設定',
901
+ 'rail.openSettings': '開啟外掛設定',
902
+ 'settings.rail.title': '動作列按鈕',
903
+ 'settings.rail.hint': '勾選要顯示在動作列上的動作;順序固定,取消勾選即隱藏。',
904
+ 'settings.rail.unavailable': '目前宿主沒有設定服務,請用面板內的「動作條設定」。',
905
+ 'settings.commit.title': 'AI 提交訊息',
906
+ 'settings.commit.model': '使用的模型',
907
+ 'settings.commit.modelHint': '留空 = 跟隨目前工作階段(provider/model)',
908
+ 'settings.commit.reasoning': '思考強度',
909
+ 'settings.commit.reasoningHint': '留空 = 模型預設',
910
+ 'settings.commit.prompt': '補充提示詞',
911
+ 'settings.commit.promptHint': '例如:用 Conventional Commits,subject 用中文,不超過 72 字元',
912
+ 'settings.commit.tooLong': '超過 {n} 個字元的部分不會儲存',
913
+ 'changes.aiWrite': 'AI 寫提交訊息',
914
+ 'changes.aiBusy': '正在生成…',
915
+ 'changes.aiQuota': '會消耗你的額度',
916
+ 'changes.aiOverwriteTitle': '覆蓋目前的草稿?',
917
+ 'changes.aiOverwriteText': 'AI 生成的內容會取代你已輸入的提交訊息(可以復原)。',
918
+ 'changes.aiUndo': '復原,還原我的草稿',
919
+ 'changes.aiFailed': '生成失敗:',
920
+ 'changes.aiNotice': '部分變更沒有傳給模型(已截斷或捨棄),訊息可能不完整。',
921
+ 'settings.rail.writeFailed': '設定沒有寫入,已退回原狀態;請重試。',
922
+ 'settings.rail.readonly': '目前宿主將設定設為唯讀,開關無法修改。',
775
923
  'rail.settingsHint': '拖曳或用箭嘴排序;眼睛圖示切換顯示',
776
924
  'rail.reset': '還原預設',
777
925
  'rail.up': '上移',
@@ -893,6 +1041,11 @@ window.__ModuleLoader__.load({
893
1041
  'action.showDiff': '差分を表示',
894
1042
  'action.copyPath': 'パスをコピー',
895
1043
  'action.checkout': 'チェックアウト',
1044
+ 'action.float': 'このページを浮動表示',
1045
+ 'action.dock': 'ドックに戻す',
1046
+ 'action.split': '分割',
1047
+ 'action.fullscreen': '全画面',
1048
+ 'action.exitFullscreen': '全画面を終了',
896
1049
  'action.newBranchFrom': 'ここから新しいブランチ...',
897
1050
  'action.newBranchHere': 'ここに新しいブランチ...',
898
1051
  'action.rename': '名前を変更...',
@@ -916,6 +1069,8 @@ window.__ModuleLoader__.load({
916
1069
  'detail.back': '履歴に戻る',
917
1070
  'detail.noFiles': 'ファイルの変更はありません',
918
1071
  'diff.empty': '表示できるテキスト差分がありません',
1072
+ 'pane.diff': '差分ビュー',
1073
+ 'pane.resize': '{name}のサイズをドラッグで調整(ダブルクリックでリセット、矢印キーで微調整)',
919
1074
  'diff.binary': 'バイナリファイル',
920
1075
  'diff.loading': '差分を読み込み中...',
921
1076
  'toolbar.refresh': '更新',
@@ -926,6 +1081,28 @@ window.__ModuleLoader__.load({
926
1081
  'toolbar.tree': 'ブランチペイン',
927
1082
  'rail.more': 'その他の操作',
928
1083
  'rail.settings': 'アクションバーの設定',
1084
+ 'rail.openSettings': 'プラグイン設定を開く',
1085
+ 'settings.rail.title': 'アクションレールのボタン',
1086
+ 'settings.rail.hint': 'レールに表示する操作にチェックを入れてください。順序は固定で、チェックを外すと非表示になります。',
1087
+ 'settings.rail.unavailable': 'このホストには設定サービスがないため、パネル内の「アクションレール設定」を使ってください。',
1088
+ 'settings.commit.title': 'AI コミットメッセージ',
1089
+ 'settings.commit.model': '使用するモデル',
1090
+ 'settings.commit.modelHint': '空欄 = 現在のセッションに従う(provider/model)',
1091
+ 'settings.commit.reasoning': '思考の強度',
1092
+ 'settings.commit.reasoningHint': '空欄 = モデルの既定値',
1093
+ 'settings.commit.prompt': '追加の指示',
1094
+ 'settings.commit.promptHint': '例:Conventional Commits、subject は日本語、72 文字以内',
1095
+ 'settings.commit.tooLong': '{n} 文字を超える部分は保存されません',
1096
+ 'changes.aiWrite': 'AI で書く',
1097
+ 'changes.aiBusy': '生成中…',
1098
+ 'changes.aiQuota': 'あなたの利用枠を消費します',
1099
+ 'changes.aiOverwriteTitle': '現在の下書きを置き換えますか?',
1100
+ 'changes.aiOverwriteText': 'AI の文章が入力済みのコミットメッセージを置き換えます(元に戻せます)。',
1101
+ 'changes.aiUndo': '元に戻す',
1102
+ 'changes.aiFailed': '生成できませんでした:',
1103
+ 'changes.aiNotice': '一部の変更はモデルに送られていません(切り詰めまたは除外)。内容が不完全な可能性があります。',
1104
+ 'settings.rail.writeFailed': '設定を保存できませんでした。元の状態に戻しました。もう一度お試しください。',
1105
+ 'settings.rail.readonly': 'このホストは設定を読み取り専用で提供しています。',
929
1106
  'rail.settingsHint': 'ドラッグまたは矢印で並べ替え、目のアイコンで表示を切り替え',
930
1107
  'rail.reset': '既定に戻す',
931
1108
  'rail.up': '上へ',
@@ -1047,6 +1224,11 @@ window.__ModuleLoader__.load({
1047
1224
  'action.showDiff': '차이 보기',
1048
1225
  'action.copyPath': '경로 복사',
1049
1226
  'action.checkout': '체크아웃',
1227
+ 'action.float': '이 페이지 띄우기',
1228
+ 'action.dock': '도킹으로 복귀',
1229
+ 'action.split': '분할',
1230
+ 'action.fullscreen': '전체 화면',
1231
+ 'action.exitFullscreen': '전체 화면 종료',
1050
1232
  'action.newBranchFrom': '여기서 새 브랜치...',
1051
1233
  'action.newBranchHere': '여기에 새 브랜치...',
1052
1234
  'action.rename': '이름 바꾸기...',
@@ -1070,6 +1252,8 @@ window.__ModuleLoader__.load({
1070
1252
  'detail.back': '기록으로 돌아가기',
1071
1253
  'detail.noFiles': '파일 변경 없음',
1072
1254
  'diff.empty': '표시할 텍스트 차이가 없습니다',
1255
+ 'pane.diff': '차이 보기',
1256
+ 'pane.resize': '{name} 크기를 드래그해 조절(더블클릭 초기화, 화살표 키 미세 조정)',
1073
1257
  'diff.binary': '바이너리 파일',
1074
1258
  'diff.loading': '차이 불러오는 중...',
1075
1259
  'toolbar.refresh': '새로 고침',
@@ -1080,6 +1264,28 @@ window.__ModuleLoader__.load({
1080
1264
  'toolbar.tree': '브랜치 창',
1081
1265
  'rail.more': '추가 작업',
1082
1266
  'rail.settings': '작업 표시줄 설정',
1267
+ 'rail.openSettings': '플러그인 설정 열기',
1268
+ 'settings.rail.title': '액션 레일 버튼',
1269
+ 'settings.rail.hint': '레일에 표시할 동작을 선택하세요. 순서는 고정이며 선택을 해제하면 숨겨집니다.',
1270
+ 'settings.rail.unavailable': '이 호스트에는 설정 서비스가 없으므로 패널의 "액션 레일 설정"을 사용하세요.',
1271
+ 'settings.commit.title': 'AI 커밋 메시지',
1272
+ 'settings.commit.model': '사용할 모델',
1273
+ 'settings.commit.modelHint': '비우면 현재 세션을 따릅니다(provider/model)',
1274
+ 'settings.commit.reasoning': '추론 강도',
1275
+ 'settings.commit.reasoningHint': '비우면 모델 기본값',
1276
+ 'settings.commit.prompt': '추가 지시',
1277
+ 'settings.commit.promptHint': '예: Conventional Commits, 제목은 한국어, 72자 이내',
1278
+ 'settings.commit.tooLong': '{n}자를 넘는 부분은 저장되지 않습니다',
1279
+ 'changes.aiWrite': 'AI로 작성',
1280
+ 'changes.aiBusy': '생성 중…',
1281
+ 'changes.aiQuota': '사용자의 할당량을 소모합니다',
1282
+ 'changes.aiOverwriteTitle': '현재 초안을 바꿀까요?',
1283
+ 'changes.aiOverwriteText': 'AI 텍스트가 입력한 커밋 메시지를 대체합니다(되돌릴 수 있습니다).',
1284
+ 'changes.aiUndo': '되돌리기',
1285
+ 'changes.aiFailed': '생성하지 못했습니다:',
1286
+ 'changes.aiNotice': '일부 변경이 모델에 전달되지 않았습니다(잘림 또는 제외). 내용이 불완전할 수 있습니다.',
1287
+ 'settings.rail.writeFailed': '설정이 저장되지 않아 원래 상태로 되돌렸습니다. 다시 시도하세요.',
1288
+ 'settings.rail.readonly': '이 호스트는 설정을 읽기 전용으로 제공합니다.',
1083
1289
  'rail.settingsHint': '끌어서 또는 화살표로 순서를 바꾸고, 눈 아이콘으로 표시를 전환합니다',
1084
1290
  'rail.reset': '기본값 복원',
1085
1291
  'rail.up': '위로',
@@ -1201,6 +1407,11 @@ window.__ModuleLoader__.load({
1201
1407
  'action.showDiff': 'Diff anzeigen',
1202
1408
  'action.copyPath': 'Pfad kopieren',
1203
1409
  'action.checkout': 'Auschecken',
1410
+ 'action.float': 'Diese Seite schweben lassen',
1411
+ 'action.dock': 'Zurück andocken',
1412
+ 'action.split': 'Teilen',
1413
+ 'action.fullscreen': 'Vollbild',
1414
+ 'action.exitFullscreen': 'Vollbild beenden',
1204
1415
  'action.newBranchFrom': 'Neuer Branch von hier...',
1205
1416
  'action.newBranchHere': 'Neuer Branch hier...',
1206
1417
  'action.rename': 'Umbenennen...',
@@ -1224,6 +1435,8 @@ window.__ModuleLoader__.load({
1224
1435
  'detail.back': 'Zurück zum Verlauf',
1225
1436
  'detail.noFiles': 'Keine Dateiänderungen',
1226
1437
  'diff.empty': 'Kein textueller Diff vorhanden',
1438
+ 'pane.diff': 'Diff-Ansicht',
1439
+ 'pane.resize': 'Größe von {name} per Ziehen ändern (Doppelklick setzt zurück, Pfeiltasten justieren)',
1227
1440
  'diff.binary': 'Binärdatei',
1228
1441
  'diff.loading': 'Diff wird geladen...',
1229
1442
  'toolbar.refresh': 'Aktualisieren',
@@ -1234,6 +1447,28 @@ window.__ModuleLoader__.load({
1234
1447
  'toolbar.tree': 'Branch-Bereich',
1235
1448
  'rail.more': 'Weitere Aktionen',
1236
1449
  'rail.settings': 'Einstellungen der Aktionsleiste',
1450
+ 'rail.openSettings': 'Plugin-Einstellungen öffnen',
1451
+ 'settings.rail.title': 'Schaltflächen der Aktionsleiste',
1452
+ 'settings.rail.hint': 'Die auf der Leiste sichtbaren Aktionen ankreuzen. Die Reihenfolge ist fest; abwählen blendet eine aus.',
1453
+ 'settings.rail.unavailable': 'Dieser Host hat keinen Einstellungsdienst; nutze die „Aktionsleisten-Einstellungen“ im Panel.',
1454
+ 'settings.commit.title': 'KI-Commit-Nachricht',
1455
+ 'settings.commit.model': 'Modell',
1456
+ 'settings.commit.modelHint': 'Leer = dieser Sitzung folgen (provider/model)',
1457
+ 'settings.commit.reasoning': 'Denkaufwand',
1458
+ 'settings.commit.reasoningHint': 'Leer = Standard des Modells',
1459
+ 'settings.commit.prompt': 'Zusätzliche Anweisungen',
1460
+ 'settings.commit.promptHint': 'z. B. Conventional Commits, Betreff auf Deutsch, höchstens 72 Zeichen',
1461
+ 'settings.commit.tooLong': 'Nur die ersten {n} Zeichen werden gespeichert',
1462
+ 'changes.aiWrite': 'Mit KI schreiben',
1463
+ 'changes.aiBusy': 'Wird geschrieben…',
1464
+ 'changes.aiQuota': 'Verbraucht dein eigenes Kontingent',
1465
+ 'changes.aiOverwriteTitle': 'Aktuellen Entwurf ersetzen?',
1466
+ 'changes.aiOverwriteText': 'Der KI-Text ersetzt deine eingegebene Commit-Nachricht (rückgängig möglich).',
1467
+ 'changes.aiUndo': 'Rückgängig',
1468
+ 'changes.aiFailed': 'Konnte nicht schreiben:',
1469
+ 'changes.aiNotice': 'Einige Änderungen wurden nicht ans Modell gesendet (gekürzt oder verworfen); die Nachricht kann unvollständig sein.',
1470
+ 'settings.rail.writeFailed': 'Die Einstellung wurde nicht gespeichert; der Schalter ist zurückgesprungen. Bitte erneut versuchen.',
1471
+ 'settings.rail.readonly': 'Dieser Host liefert die Konfiguration schreibgeschützt.',
1237
1472
  'rail.settingsHint': 'Ziehen oder Pfeile zum Sortieren, das Auge schaltet die Sichtbarkeit um',
1238
1473
  'rail.reset': 'Standard wiederherstellen',
1239
1474
  'rail.up': 'Nach oben',
@@ -1355,6 +1590,11 @@ window.__ModuleLoader__.load({
1355
1590
  'action.showDiff': 'Afficher le diff',
1356
1591
  'action.copyPath': 'Copier le chemin',
1357
1592
  'action.checkout': 'Extraire',
1593
+ 'action.float': 'Détacher cette page',
1594
+ 'action.dock': 'Réancrer',
1595
+ 'action.split': 'Fractionner',
1596
+ 'action.fullscreen': 'Plein écran',
1597
+ 'action.exitFullscreen': 'Quitter le plein écran',
1358
1598
  'action.newBranchFrom': 'Nouvelle branche depuis ici...',
1359
1599
  'action.newBranchHere': 'Nouvelle branche ici...',
1360
1600
  'action.rename': 'Renommer...',
@@ -1378,6 +1618,8 @@ window.__ModuleLoader__.load({
1378
1618
  'detail.back': 'Retour à l\'historique',
1379
1619
  'detail.noFiles': 'Aucune modification de fichier',
1380
1620
  'diff.empty': 'Aucun diff textuel à afficher',
1621
+ 'pane.diff': 'vue du diff',
1622
+ 'pane.resize': 'Glisser pour redimensionner {name} (double-clic pour réinitialiser, flèches pour ajuster)',
1381
1623
  'diff.binary': 'Fichier binaire',
1382
1624
  'diff.loading': 'Chargement du diff...',
1383
1625
  'toolbar.refresh': 'Rafraîchir',
@@ -1388,6 +1630,28 @@ window.__ModuleLoader__.load({
1388
1630
  'toolbar.tree': 'Volet des branches',
1389
1631
  'rail.more': 'Plus d\'actions',
1390
1632
  'rail.settings': 'Réglages de la barre d\'actions',
1633
+ 'rail.openSettings': 'Ouvrir les réglages du plugin',
1634
+ 'settings.rail.title': 'Boutons de la barre d’actions',
1635
+ 'settings.rail.hint': 'Cochez les actions affichées sur la barre. L’ordre est fixe ; décocher masque une action.',
1636
+ 'settings.rail.unavailable': 'Cet hôte n’a pas de service de réglages ; utilisez « Réglages de la barre d’actions » dans le panneau.',
1637
+ 'settings.commit.title': 'Message de commit par IA',
1638
+ 'settings.commit.model': 'Modèle',
1639
+ 'settings.commit.modelHint': 'Vide = suivre cette session (provider/model)',
1640
+ 'settings.commit.reasoning': 'Effort de raisonnement',
1641
+ 'settings.commit.reasoningHint': 'Vide = valeur par défaut du modèle',
1642
+ 'settings.commit.prompt': 'Instructions supplémentaires',
1643
+ 'settings.commit.promptHint': 'ex. Conventional Commits, sujet en français, 72 caractères maximum',
1644
+ 'settings.commit.tooLong': 'Seuls les {n} premiers caractères sont enregistrés',
1645
+ 'changes.aiWrite': 'Écrire avec l’IA',
1646
+ 'changes.aiBusy': 'Rédaction…',
1647
+ 'changes.aiQuota': 'Consomme votre propre quota',
1648
+ 'changes.aiOverwriteTitle': 'Remplacer le brouillon actuel ?',
1649
+ 'changes.aiOverwriteText': 'Le texte de l’IA remplacera le message saisi (annulable).',
1650
+ 'changes.aiUndo': 'Annuler, reprendre mon brouillon',
1651
+ 'changes.aiFailed': 'Échec de la rédaction :',
1652
+ 'changes.aiNotice': 'Certaines modifications n’ont pas été envoyées au modèle (tronquées ou écartées) ; le message peut être incomplet.',
1653
+ 'settings.rail.writeFailed': 'Le réglage n’a pas été enregistré ; l’interrupteur est revenu en place. Réessayez.',
1654
+ 'settings.rail.readonly': 'Cet hôte fournit la configuration en lecture seule.',
1391
1655
  'rail.settingsHint': 'Glisser ou utiliser les flèches pour réordonner ; l\'œil bascule la visibilité',
1392
1656
  'rail.reset': 'Rétablir les valeurs par défaut',
1393
1657
  'rail.up': 'Monter',
@@ -1509,6 +1773,11 @@ window.__ModuleLoader__.load({
1509
1773
  'action.showDiff': 'Показать различия',
1510
1774
  'action.copyPath': 'Скопировать путь',
1511
1775
  'action.checkout': 'Переключиться',
1776
+ 'action.float': 'Открепить страницу',
1777
+ 'action.dock': 'Вернуть в док',
1778
+ 'action.split': 'Разделить',
1779
+ 'action.fullscreen': 'Полный экран',
1780
+ 'action.exitFullscreen': 'Выйти из полного экрана',
1512
1781
  'action.newBranchFrom': 'Новая ветка отсюда...',
1513
1782
  'action.newBranchHere': 'Новая ветка здесь...',
1514
1783
  'action.rename': 'Переименовать...',
@@ -1532,6 +1801,8 @@ window.__ModuleLoader__.load({
1532
1801
  'detail.back': 'Назад к истории',
1533
1802
  'detail.noFiles': 'Файлы не изменялись',
1534
1803
  'diff.empty': 'Нет текстовых различий для показа',
1804
+ 'pane.diff': 'просмотр diff',
1805
+ 'pane.resize': 'Перетащите, чтобы изменить размер {name} (двойной клик — сброс, стрелки — точная настройка)',
1535
1806
  'diff.binary': 'Двоичный файл',
1536
1807
  'diff.loading': 'Загрузка различий...',
1537
1808
  'toolbar.refresh': 'Обновить',
@@ -1542,6 +1813,28 @@ window.__ModuleLoader__.load({
1542
1813
  'toolbar.tree': 'Панель веток',
1543
1814
  'rail.more': 'Другие действия',
1544
1815
  'rail.settings': 'Настройки панели действий',
1816
+ 'rail.openSettings': 'Открыть настройки плагина',
1817
+ 'settings.rail.title': 'Кнопки панели действий',
1818
+ 'settings.rail.hint': 'Отметьте действия, показываемые на панели. Порядок фиксирован; снятие флажка скрывает действие.',
1819
+ 'settings.rail.unavailable': 'На этом хосте нет службы настроек; используйте «Настройки панели действий» в панели.',
1820
+ 'settings.commit.title': 'Сообщение коммита от ИИ',
1821
+ 'settings.commit.model': 'Модель',
1822
+ 'settings.commit.modelHint': 'Пусто = как в этой сессии (provider/model)',
1823
+ 'settings.commit.reasoning': 'Усилие рассуждения',
1824
+ 'settings.commit.reasoningHint': 'Пусто = по умолчанию у модели',
1825
+ 'settings.commit.prompt': 'Дополнительные указания',
1826
+ 'settings.commit.promptHint': 'напр. Conventional Commits, тема по-русски, до 72 символов',
1827
+ 'settings.commit.tooLong': 'Сохраняются только первые {n} символов',
1828
+ 'changes.aiWrite': 'Написать с ИИ',
1829
+ 'changes.aiBusy': 'Генерация…',
1830
+ 'changes.aiQuota': 'Расходует вашу квоту',
1831
+ 'changes.aiOverwriteTitle': 'Заменить текущий черновик?',
1832
+ 'changes.aiOverwriteText': 'Текст ИИ заменит введённое сообщение коммита (можно отменить).',
1833
+ 'changes.aiUndo': 'Отменить',
1834
+ 'changes.aiFailed': 'Не удалось сгенерировать:',
1835
+ 'changes.aiNotice': 'Часть изменений не отправлена модели (обрезана или отброшена); сообщение может быть неполным.',
1836
+ 'settings.rail.writeFailed': 'Настройка не сохранилась; переключатель вернулся назад. Попробуйте снова.',
1837
+ 'settings.rail.readonly': 'Этот хост отдаёт конфигурацию только для чтения.',
1545
1838
  'rail.settingsHint': 'Перетаскивайте или используйте стрелки для порядка; значок глаза переключает видимость',
1546
1839
  'rail.reset': 'Вернуть значения по умолчанию',
1547
1840
  'rail.up': 'Вверх',
@@ -1663,6 +1956,11 @@ window.__ModuleLoader__.load({
1663
1956
  'action.showDiff': 'Mostrar diff',
1664
1957
  'action.copyPath': 'Copiar caminho',
1665
1958
  'action.checkout': 'Checkout',
1959
+ 'action.float': 'Flutuar esta página',
1960
+ 'action.dock': 'Reacoplar',
1961
+ 'action.split': 'Dividir',
1962
+ 'action.fullscreen': 'Tela cheia',
1963
+ 'action.exitFullscreen': 'Sair da tela cheia',
1666
1964
  'action.newBranchFrom': 'Nova branch a partir daqui...',
1667
1965
  'action.newBranchHere': 'Nova branch aqui...',
1668
1966
  'action.rename': 'Renomear...',
@@ -1686,6 +1984,8 @@ window.__ModuleLoader__.load({
1686
1984
  'detail.back': 'Voltar ao histórico',
1687
1985
  'detail.noFiles': 'Nenhuma alteração de arquivo',
1688
1986
  'diff.empty': 'Nenhum diff textual para mostrar',
1987
+ 'pane.diff': 'visualização do diff',
1988
+ 'pane.resize': 'Arraste para redimensionar {name} (duplo clique restaura, setas ajustam)',
1689
1989
  'diff.binary': 'Arquivo binário',
1690
1990
  'diff.loading': 'Carregando diff...',
1691
1991
  'toolbar.refresh': 'Atualizar',
@@ -1696,6 +1996,28 @@ window.__ModuleLoader__.load({
1696
1996
  'toolbar.tree': 'Painel de branches',
1697
1997
  'rail.more': 'Mais ações',
1698
1998
  'rail.settings': 'Configurações da barra de ações',
1999
+ 'rail.openSettings': 'Abrir configurações do plugin',
2000
+ 'settings.rail.title': 'Botões da barra de ações',
2001
+ 'settings.rail.hint': 'Marque as ações exibidas na barra. A ordem é fixa; desmarcar oculta uma ação.',
2002
+ 'settings.rail.unavailable': 'Este host não tem serviço de configurações; use «Configurações da barra de ações» no painel.',
2003
+ 'settings.commit.title': 'Mensagem de commit por IA',
2004
+ 'settings.commit.model': 'Modelo',
2005
+ 'settings.commit.modelHint': 'Vazio = seguir esta sessão (provider/model)',
2006
+ 'settings.commit.reasoning': 'Esforço de raciocínio',
2007
+ 'settings.commit.reasoningHint': 'Vazio = padrão do modelo',
2008
+ 'settings.commit.prompt': 'Instruções extras',
2009
+ 'settings.commit.promptHint': 'ex.: Conventional Commits, assunto em português, até 72 caracteres',
2010
+ 'settings.commit.tooLong': 'Só os primeiros {n} caracteres são salvos',
2011
+ 'changes.aiWrite': 'Escrever com IA',
2012
+ 'changes.aiBusy': 'Gerando…',
2013
+ 'changes.aiQuota': 'Consome a sua cota',
2014
+ 'changes.aiOverwriteTitle': 'Substituir o rascunho atual?',
2015
+ 'changes.aiOverwriteText': 'O texto da IA substituirá a mensagem que você digitou (dá para desfazer).',
2016
+ 'changes.aiUndo': 'Desfazer',
2017
+ 'changes.aiFailed': 'Não foi possível gerar:',
2018
+ 'changes.aiNotice': 'Algumas alterações não foram enviadas ao modelo (truncadas ou descartadas); a mensagem pode ficar incompleta.',
2019
+ 'settings.rail.writeFailed': 'A configuração não foi salva; o interruptor voltou ao lugar. Tente de novo.',
2020
+ 'settings.rail.readonly': 'Este host fornece a configuração somente para leitura.',
1699
2021
  'rail.settingsHint': 'Arraste ou use as setas para reordenar; o olho alterna a visibilidade',
1700
2022
  'rail.reset': 'Restaurar padrões',
1701
2023
  'rail.up': 'Mover para cima',
@@ -1817,6 +2139,11 @@ window.__ModuleLoader__.load({
1817
2139
  'action.showDiff': 'Mostra il diff',
1818
2140
  'action.copyPath': 'Copia percorso',
1819
2141
  'action.checkout': 'Checkout',
2142
+ 'action.float': 'Fluttua questa pagina',
2143
+ 'action.dock': 'Riaggancia',
2144
+ 'action.split': 'Dividi',
2145
+ 'action.fullscreen': 'Schermo intero',
2146
+ 'action.exitFullscreen': 'Esci da schermo intero',
1820
2147
  'action.newBranchFrom': 'Nuovo branch da qui...',
1821
2148
  'action.newBranchHere': 'Nuovo branch qui...',
1822
2149
  'action.rename': 'Rinomina...',
@@ -1840,6 +2167,8 @@ window.__ModuleLoader__.load({
1840
2167
  'detail.back': 'Torna alla cronologia',
1841
2168
  'detail.noFiles': 'Nessuna modifica ai file',
1842
2169
  'diff.empty': 'Nessun diff testuale da mostrare',
2170
+ 'pane.diff': 'vista diff',
2171
+ 'pane.resize': 'Trascina per ridimensionare {name} (doppio clic ripristina, frecce per regolare)',
1843
2172
  'diff.binary': 'File binario',
1844
2173
  'diff.loading': 'Caricamento del diff...',
1845
2174
  'toolbar.refresh': 'Aggiorna',
@@ -1850,6 +2179,28 @@ window.__ModuleLoader__.load({
1850
2179
  'toolbar.tree': 'Riquadro dei branch',
1851
2180
  'rail.more': 'Altre azioni',
1852
2181
  'rail.settings': 'Impostazioni della barra delle azioni',
2182
+ 'rail.openSettings': 'Apri le impostazioni del plugin',
2183
+ 'settings.rail.title': 'Pulsanti della barra azioni',
2184
+ 'settings.rail.hint': 'Seleziona le azioni mostrate sulla barra. L’ordine è fisso; deselezionando se ne nasconde una.',
2185
+ 'settings.rail.unavailable': 'Questo host non ha un servizio di impostazioni; usa «Impostazioni barra azioni» nel pannello.',
2186
+ 'settings.commit.title': 'Messaggio di commit con IA',
2187
+ 'settings.commit.model': 'Modello',
2188
+ 'settings.commit.modelHint': 'Vuoto = segui questa sessione (provider/model)',
2189
+ 'settings.commit.reasoning': 'Sforzo di ragionamento',
2190
+ 'settings.commit.reasoningHint': 'Vuoto = predefinito del modello',
2191
+ 'settings.commit.prompt': 'Istruzioni aggiuntive',
2192
+ 'settings.commit.promptHint': 'es. Conventional Commits, oggetto in italiano, max 72 caratteri',
2193
+ 'settings.commit.tooLong': 'Solo i primi {n} caratteri vengono salvati',
2194
+ 'changes.aiWrite': 'Scrivi con l’IA',
2195
+ 'changes.aiBusy': 'Generazione…',
2196
+ 'changes.aiQuota': 'Consuma la tua quota',
2197
+ 'changes.aiOverwriteTitle': 'Sostituire la bozza attuale?',
2198
+ 'changes.aiOverwriteText': 'Il testo dell’IA sostituirà il messaggio digitato (puoi annullare).',
2199
+ 'changes.aiUndo': 'Annulla',
2200
+ 'changes.aiFailed': 'Scrittura non riuscita:',
2201
+ 'changes.aiNotice': 'Alcune modifiche non sono state inviate al modello (troncate o scartate); il messaggio può essere incompleto.',
2202
+ 'settings.rail.writeFailed': 'L’impostazione non è stata salvata; l’interruttore è tornato indietro. Riprova.',
2203
+ 'settings.rail.readonly': 'Questo host fornisce la configurazione in sola lettura.',
1853
2204
  'rail.settingsHint': 'Trascina o usa le frecce per riordinare; l’occhio attiva o disattiva la visibilità',
1854
2205
  'rail.reset': 'Ripristina i valori predefiniti',
1855
2206
  'rail.up': 'Sposta su',
@@ -1971,6 +2322,11 @@ window.__ModuleLoader__.load({
1971
2322
  'action.showDiff': 'Diff tonen',
1972
2323
  'action.copyPath': 'Pad kopiëren',
1973
2324
  'action.checkout': 'Uitchecken',
2325
+ 'action.float': 'Deze pagina losmaken',
2326
+ 'action.dock': 'Terugdokken',
2327
+ 'action.split': 'Splitsen',
2328
+ 'action.fullscreen': 'Volledig scherm',
2329
+ 'action.exitFullscreen': 'Volledig scherm sluiten',
1974
2330
  'action.newBranchFrom': 'Nieuwe branch vanaf hier...',
1975
2331
  'action.newBranchHere': 'Nieuwe branch hier...',
1976
2332
  'action.rename': 'Hernoemen...',
@@ -1994,6 +2350,8 @@ window.__ModuleLoader__.load({
1994
2350
  'detail.back': 'Terug naar geschiedenis',
1995
2351
  'detail.noFiles': 'Geen bestandswijzigingen',
1996
2352
  'diff.empty': 'Geen tekstuele diff om te tonen',
2353
+ 'pane.diff': 'diff-weergave',
2354
+ 'pane.resize': 'Sleep om {name} te vergroten (dubbelklik herstelt, pijltjes stellen fijn af)',
1997
2355
  'diff.binary': 'Binair bestand',
1998
2356
  'diff.loading': 'Diff laden...',
1999
2357
  'toolbar.refresh': 'Vernieuwen',
@@ -2004,6 +2362,28 @@ window.__ModuleLoader__.load({
2004
2362
  'toolbar.tree': 'Branchpaneel',
2005
2363
  'rail.more': 'Meer acties',
2006
2364
  'rail.settings': 'Instellingen van de actiebalk',
2365
+ 'rail.openSettings': 'Plugin-instellingen openen',
2366
+ 'settings.rail.title': 'Knoppen van de actiebalk',
2367
+ 'settings.rail.hint': 'Vink de acties aan die op de balk verschijnen. De volgorde ligt vast; uitvinken verbergt er een.',
2368
+ 'settings.rail.unavailable': 'Deze host heeft geen instellingenservice; gebruik "Actiebalk-instellingen" in het paneel.',
2369
+ 'settings.commit.title': 'AI-commitbericht',
2370
+ 'settings.commit.model': 'Model',
2371
+ 'settings.commit.modelHint': 'Leeg = deze sessie volgen (provider/model)',
2372
+ 'settings.commit.reasoning': 'Redeneerinspanning',
2373
+ 'settings.commit.reasoningHint': 'Leeg = standaard van het model',
2374
+ 'settings.commit.prompt': 'Extra instructies',
2375
+ 'settings.commit.promptHint': 'bv. Conventional Commits, onderwerp in het Nederlands, max. 72 tekens',
2376
+ 'settings.commit.tooLong': 'Alleen de eerste {n} tekens worden opgeslagen',
2377
+ 'changes.aiWrite': 'Met AI schrijven',
2378
+ 'changes.aiBusy': 'Bezig…',
2379
+ 'changes.aiQuota': 'Verbruikt je eigen quota',
2380
+ 'changes.aiOverwriteTitle': 'Huidig concept vervangen?',
2381
+ 'changes.aiOverwriteText': 'De AI-tekst vervangt je getypte commitbericht (ongedaan te maken).',
2382
+ 'changes.aiUndo': 'Ongedaan maken',
2383
+ 'changes.aiFailed': 'Kon niet schrijven:',
2384
+ 'changes.aiNotice': 'Sommige wijzigingen zijn niet naar het model gestuurd (afgekapt of overgeslagen); het bericht kan onvolledig zijn.',
2385
+ 'settings.rail.writeFailed': 'De instelling is niet opgeslagen; de schakelaar sprong terug. Probeer opnieuw.',
2386
+ 'settings.rail.readonly': 'Deze host levert de configuratie alleen-lezen.',
2007
2387
  'rail.settingsHint': 'Sleep of gebruik de pijlen om te sorteren; het oog schakelt de zichtbaarheid',
2008
2388
  'rail.reset': 'Standaardwaarden herstellen',
2009
2389
  'rail.up': 'Omhoog',
@@ -2125,6 +2505,11 @@ window.__ModuleLoader__.load({
2125
2505
  'action.showDiff': 'Pokaż różnice',
2126
2506
  'action.copyPath': 'Kopiuj ścieżkę',
2127
2507
  'action.checkout': 'Przełącz',
2508
+ 'action.float': 'Odczep tę stronę',
2509
+ 'action.dock': 'Zadokuj z powrotem',
2510
+ 'action.split': 'Podziel',
2511
+ 'action.fullscreen': 'Pełny ekran',
2512
+ 'action.exitFullscreen': 'Wyjdź z pełnego ekranu',
2128
2513
  'action.newBranchFrom': 'Nowa gałąź stąd...',
2129
2514
  'action.newBranchHere': 'Nowa gałąź tutaj...',
2130
2515
  'action.rename': 'Zmień nazwę...',
@@ -2148,6 +2533,8 @@ window.__ModuleLoader__.load({
2148
2533
  'detail.back': 'Powrót do historii',
2149
2534
  'detail.noFiles': 'Brak zmian w plikach',
2150
2535
  'diff.empty': 'Brak różnic tekstowych do pokazania',
2536
+ 'pane.diff': 'widok diff',
2537
+ 'pane.resize': 'Przeciągnij, aby zmienić rozmiar {name} (podwójne kliknięcie przywraca, strzałki korygują)',
2151
2538
  'diff.binary': 'Plik binarny',
2152
2539
  'diff.loading': 'Wczytywanie różnic...',
2153
2540
  'toolbar.refresh': 'Odśwież',
@@ -2158,6 +2545,28 @@ window.__ModuleLoader__.load({
2158
2545
  'toolbar.tree': 'Panel gałęzi',
2159
2546
  'rail.more': 'Więcej działań',
2160
2547
  'rail.settings': 'Ustawienia paska działań',
2548
+ 'rail.openSettings': 'Otwórz ustawienia wtyczki',
2549
+ 'settings.rail.title': 'Przyciski paska akcji',
2550
+ 'settings.rail.hint': 'Zaznacz akcje widoczne na pasku. Kolejność jest stała; odznaczenie ukrywa akcję.',
2551
+ 'settings.rail.unavailable': 'Ten host nie ma usługi ustawień; użyj „Ustawienia paska akcji” w panelu.',
2552
+ 'settings.commit.title': 'Wiadomość commita od AI',
2553
+ 'settings.commit.model': 'Model',
2554
+ 'settings.commit.modelHint': 'Puste = zgodnie z tą sesją (provider/model)',
2555
+ 'settings.commit.reasoning': 'Poziom rozumowania',
2556
+ 'settings.commit.reasoningHint': 'Puste = domyślne modelu',
2557
+ 'settings.commit.prompt': 'Dodatkowe instrukcje',
2558
+ 'settings.commit.promptHint': 'np. Conventional Commits, temat po polsku, maks. 72 znaki',
2559
+ 'settings.commit.tooLong': 'Zapisywane jest tylko pierwszych {n} znaków',
2560
+ 'changes.aiWrite': 'Napisz z AI',
2561
+ 'changes.aiBusy': 'Generowanie…',
2562
+ 'changes.aiQuota': 'Zużywa twój limit',
2563
+ 'changes.aiOverwriteTitle': 'Zastąpić bieżący szkic?',
2564
+ 'changes.aiOverwriteText': 'Tekst AI zastąpi wpisaną wiadomość commita (można cofnąć).',
2565
+ 'changes.aiUndo': 'Cofnij',
2566
+ 'changes.aiFailed': 'Nie udało się wygenerować:',
2567
+ 'changes.aiNotice': 'Część zmian nie trafiła do modelu (przycięta lub pominięta); wiadomość może być niepełna.',
2568
+ 'settings.rail.writeFailed': 'Ustawienie nie zostało zapisane; przełącznik wrócił na miejsce. Spróbuj ponownie.',
2569
+ 'settings.rail.readonly': 'Ten host udostępnia konfigurację tylko do odczytu.',
2161
2570
  'rail.settingsHint': 'Przeciągnij lub użyj strzałek, aby zmienić kolejność; oko przełącza widoczność',
2162
2571
  'rail.reset': 'Przywróć domyślne',
2163
2572
  'rail.up': 'W górę',
@@ -2279,6 +2688,11 @@ window.__ModuleLoader__.load({
2279
2688
  'action.showDiff': 'Visa diff',
2280
2689
  'action.copyPath': 'Kopiera sökväg',
2281
2690
  'action.checkout': 'Checka ut',
2691
+ 'action.float': 'Friställ sidan',
2692
+ 'action.dock': 'Docka tillbaka',
2693
+ 'action.split': 'Dela',
2694
+ 'action.fullscreen': 'Helskärm',
2695
+ 'action.exitFullscreen': 'Avsluta helskärm',
2282
2696
  'action.newBranchFrom': 'Ny gren härifrån...',
2283
2697
  'action.newBranchHere': 'Ny gren här...',
2284
2698
  'action.rename': 'Byt namn...',
@@ -2302,6 +2716,8 @@ window.__ModuleLoader__.load({
2302
2716
  'detail.back': 'Tillbaka till historiken',
2303
2717
  'detail.noFiles': 'Inga filändringar',
2304
2718
  'diff.empty': 'Ingen textdiff att visa',
2719
+ 'pane.diff': 'diff-vy',
2720
+ 'pane.resize': 'Dra för att ändra storleken på {name} (dubbelklick återställer, piltangenter finjusterar)',
2305
2721
  'diff.binary': 'Binär fil',
2306
2722
  'diff.loading': 'Laddar diff...',
2307
2723
  'toolbar.refresh': 'Uppdatera',
@@ -2312,6 +2728,28 @@ window.__ModuleLoader__.load({
2312
2728
  'toolbar.tree': 'Grenpanel',
2313
2729
  'rail.more': 'Fler åtgärder',
2314
2730
  'rail.settings': 'Inställningar för åtgärdsfältet',
2731
+ 'rail.openSettings': 'Öppna plugininställningar',
2732
+ 'settings.rail.title': 'Knappar i åtgärdsfältet',
2733
+ 'settings.rail.hint': 'Markera de åtgärder som visas i fältet. Ordningen är fast; avmarkering döljer en.',
2734
+ 'settings.rail.unavailable': 'Denna värd saknar inställningstjänst; använd ”Åtgärdsfältets inställningar” i panelen.',
2735
+ 'settings.commit.title': 'AI-commitmeddelande',
2736
+ 'settings.commit.model': 'Modell',
2737
+ 'settings.commit.modelHint': 'Tomt = följ denna session (provider/model)',
2738
+ 'settings.commit.reasoning': 'Resonemangsinsats',
2739
+ 'settings.commit.reasoningHint': 'Tomt = modellens standard',
2740
+ 'settings.commit.prompt': 'Extra instruktioner',
2741
+ 'settings.commit.promptHint': 't.ex. Conventional Commits, ämne på svenska, högst 72 tecken',
2742
+ 'settings.commit.tooLong': 'Endast de första {n} tecknen sparas',
2743
+ 'changes.aiWrite': 'Skriv med AI',
2744
+ 'changes.aiBusy': 'Skriver…',
2745
+ 'changes.aiQuota': 'Använder din egen kvot',
2746
+ 'changes.aiOverwriteTitle': 'Ersätt nuvarande utkast?',
2747
+ 'changes.aiOverwriteText': 'AI-texten ersätter commitmeddelandet du skrev (går att ångra).',
2748
+ 'changes.aiUndo': 'Ångra',
2749
+ 'changes.aiFailed': 'Kunde inte skriva:',
2750
+ 'changes.aiNotice': 'Vissa ändringar skickades inte till modellen (kapade eller utelämnade); meddelandet kan vara ofullständigt.',
2751
+ 'settings.rail.writeFailed': 'Inställningen sparades inte; reglaget gick tillbaka. Försök igen.',
2752
+ 'settings.rail.readonly': 'Denna värd tillhandahåller konfigurationen skrivskyddad.',
2315
2753
  'rail.settingsHint': 'Dra eller använd pilarna för att sortera; ögat växlar synlighet',
2316
2754
  'rail.reset': 'Återställ standard',
2317
2755
  'rail.up': 'Flytta upp',
@@ -2433,6 +2871,11 @@ window.__ModuleLoader__.load({
2433
2871
  'action.showDiff': 'Farkı göster',
2434
2872
  'action.copyPath': 'Yolu kopyala',
2435
2873
  'action.checkout': 'Çıkar',
2874
+ 'action.float': 'Bu sayfayı yüzdür',
2875
+ 'action.dock': 'Yuvaya geri al',
2876
+ 'action.split': 'Böl',
2877
+ 'action.fullscreen': 'Tam ekran',
2878
+ 'action.exitFullscreen': 'Tam ekrandan çık',
2436
2879
  'action.newBranchFrom': 'Buradan yeni dal...',
2437
2880
  'action.newBranchHere': 'Burada yeni dal...',
2438
2881
  'action.rename': 'Yeniden adlandır...',
@@ -2456,6 +2899,8 @@ window.__ModuleLoader__.load({
2456
2899
  'detail.back': 'Geçmişe dön',
2457
2900
  'detail.noFiles': 'Dosya değişikliği yok',
2458
2901
  'diff.empty': 'Gösterilecek metin farkı yok',
2902
+ 'pane.diff': 'diff görünümü',
2903
+ 'pane.resize': '{name} boyutunu sürükleyerek ayarlayın (çift tıklama sıfırlar, ok tuşları ince ayar yapar)',
2459
2904
  'diff.binary': 'İkili dosya',
2460
2905
  'diff.loading': 'Fark yükleniyor...',
2461
2906
  'toolbar.refresh': 'Yenile',
@@ -2466,6 +2911,28 @@ window.__ModuleLoader__.load({
2466
2911
  'toolbar.tree': 'Dal paneli',
2467
2912
  'rail.more': 'Diğer eylemler',
2468
2913
  'rail.settings': 'Eylem çubuğu ayarları',
2914
+ 'rail.openSettings': 'Eklenti ayarlarını aç',
2915
+ 'settings.rail.title': 'Eylem çubuğu düğmeleri',
2916
+ 'settings.rail.hint': 'Çubukta gösterilecek eylemleri işaretleyin. Sıra sabittir; işareti kaldırmak eylemi gizler.',
2917
+ 'settings.rail.unavailable': 'Bu ana bilgisayarda ayar hizmeti yok; paneldeki "Eylem çubuğu ayarları"nı kullanın.',
2918
+ 'settings.commit.title': 'AI commit mesajı',
2919
+ 'settings.commit.model': 'Model',
2920
+ 'settings.commit.modelHint': 'Boş = bu oturumu izle (provider/model)',
2921
+ 'settings.commit.reasoning': 'Düşünme yoğunluğu',
2922
+ 'settings.commit.reasoningHint': 'Boş = modelin varsayılanı',
2923
+ 'settings.commit.prompt': 'Ek yönergeler',
2924
+ 'settings.commit.promptHint': 'örn. Conventional Commits, konu Türkçe, en fazla 72 karakter',
2925
+ 'settings.commit.tooLong': 'Yalnızca ilk {n} karakter kaydedilir',
2926
+ 'changes.aiWrite': 'AI ile yaz',
2927
+ 'changes.aiBusy': 'Yazılıyor…',
2928
+ 'changes.aiQuota': 'Kendi kotanızı harcar',
2929
+ 'changes.aiOverwriteTitle': 'Geçerli taslağın yerine geçilsin mi?',
2930
+ 'changes.aiOverwriteText': 'AI metni yazdığınız commit mesajının yerine geçer (geri alınabilir).',
2931
+ 'changes.aiUndo': 'Geri al',
2932
+ 'changes.aiFailed': 'Yazılamadı:',
2933
+ 'changes.aiNotice': 'Bazı değişiklikler modele gönderilmedi (kısaltıldı veya atlandı); mesaj eksik olabilir.',
2934
+ 'settings.rail.writeFailed': 'Ayar kaydedilemedi; anahtar eski konumuna döndü. Yeniden deneyin.',
2935
+ 'settings.rail.readonly': 'Bu ana bilgisayar yapılandırmayı salt okunur sunuyor.',
2469
2936
  'rail.settingsHint': 'Sıralamak için sürükleyin veya okları kullanın; göz simgesi görünürlüğü değiştirir',
2470
2937
  'rail.reset': 'Varsayılanlara dön',
2471
2938
  'rail.up': 'Yukarı taşı',
@@ -2587,6 +3054,11 @@ window.__ModuleLoader__.load({
2587
3054
  'action.showDiff': 'Tampilkan diff',
2588
3055
  'action.copyPath': 'Salin jalur',
2589
3056
  'action.checkout': 'Checkout',
3057
+ 'action.float': 'Apungkan halaman ini',
3058
+ 'action.dock': 'Kembalikan ke dok',
3059
+ 'action.split': 'Bagi',
3060
+ 'action.fullscreen': 'Layar penuh',
3061
+ 'action.exitFullscreen': 'Keluar dari layar penuh',
2590
3062
  'action.newBranchFrom': 'Branch baru dari sini...',
2591
3063
  'action.newBranchHere': 'Branch baru di sini...',
2592
3064
  'action.rename': 'Ganti nama...',
@@ -2610,6 +3082,8 @@ window.__ModuleLoader__.load({
2610
3082
  'detail.back': 'Kembali ke riwayat',
2611
3083
  'detail.noFiles': 'Tidak ada perubahan berkas',
2612
3084
  'diff.empty': 'Tidak ada diff teks untuk ditampilkan',
3085
+ 'pane.diff': 'tampilan diff',
3086
+ 'pane.resize': 'Seret untuk mengubah ukuran {name} (klik ganda mengatur ulang, tombol panah menyesuaikan)',
2613
3087
  'diff.binary': 'Berkas biner',
2614
3088
  'diff.loading': 'Memuat diff...',
2615
3089
  'toolbar.refresh': 'Segarkan',
@@ -2620,6 +3094,28 @@ window.__ModuleLoader__.load({
2620
3094
  'toolbar.tree': 'Panel branch',
2621
3095
  'rail.more': 'Tindakan lain',
2622
3096
  'rail.settings': 'Pengaturan bilah tindakan',
3097
+ 'rail.openSettings': 'Buka pengaturan plugin',
3098
+ 'settings.rail.title': 'Tombol bilah aksi',
3099
+ 'settings.rail.hint': 'Centang aksi yang ditampilkan di bilah. Urutannya tetap; menghapus centang menyembunyikannya.',
3100
+ 'settings.rail.unavailable': 'Host ini tidak punya layanan pengaturan; gunakan "Pengaturan bilah aksi" di panel.',
3101
+ 'settings.commit.title': 'Pesan commit AI',
3102
+ 'settings.commit.model': 'Model',
3103
+ 'settings.commit.modelHint': 'Kosong = ikuti sesi ini (provider/model)',
3104
+ 'settings.commit.reasoning': 'Intensitas penalaran',
3105
+ 'settings.commit.reasoningHint': 'Kosong = bawaan model',
3106
+ 'settings.commit.prompt': 'Instruksi tambahan',
3107
+ 'settings.commit.promptHint': 'mis. Conventional Commits, subjek bahasa Indonesia, maks 72 karakter',
3108
+ 'settings.commit.tooLong': 'Hanya {n} karakter pertama yang disimpan',
3109
+ 'changes.aiWrite': 'Tulis dengan AI',
3110
+ 'changes.aiBusy': 'Menulis…',
3111
+ 'changes.aiQuota': 'Memakai kuota Anda',
3112
+ 'changes.aiOverwriteTitle': 'Ganti draf saat ini?',
3113
+ 'changes.aiOverwriteText': 'Teks AI akan menggantikan pesan commit yang Anda tulis (bisa dibatalkan).',
3114
+ 'changes.aiUndo': 'Batalkan',
3115
+ 'changes.aiFailed': 'Gagal menulis:',
3116
+ 'changes.aiNotice': 'Sebagian perubahan tidak dikirim ke model (dipotong atau dibuang); pesan bisa tidak lengkap.',
3117
+ 'settings.rail.writeFailed': 'Pengaturan tidak tersimpan; tombol kembali ke posisi semula. Coba lagi.',
3118
+ 'settings.rail.readonly': 'Host ini menyajikan konfigurasi hanya-baca.',
2623
3119
  'rail.settingsHint': 'Seret atau pakai panah untuk mengurutkan; ikon mata mengalihkan visibilitas',
2624
3120
  'rail.reset': 'Kembalikan default',
2625
3121
  'rail.up': 'Naikkan',
@@ -2741,6 +3237,11 @@ window.__ModuleLoader__.load({
2741
3237
  'action.showDiff': 'Xem diff',
2742
3238
  'action.copyPath': 'Sao chép đường dẫn',
2743
3239
  'action.checkout': 'Checkout',
3240
+ 'action.float': 'Tách trang này',
3241
+ 'action.dock': 'Ghép lại vào dock',
3242
+ 'action.split': 'Chia',
3243
+ 'action.fullscreen': 'Toàn màn hình',
3244
+ 'action.exitFullscreen': 'Thoát toàn màn hình',
2744
3245
  'action.newBranchFrom': 'Nhánh mới từ đây...',
2745
3246
  'action.newBranchHere': 'Nhánh mới tại đây...',
2746
3247
  'action.rename': 'Đổi tên...',
@@ -2764,6 +3265,8 @@ window.__ModuleLoader__.load({
2764
3265
  'detail.back': 'Quay lại lịch sử',
2765
3266
  'detail.noFiles': 'Không có thay đổi tệp',
2766
3267
  'diff.empty': 'Không có diff văn bản để hiển thị',
3268
+ 'pane.diff': 'chế độ xem diff',
3269
+ 'pane.resize': 'Kéo để đổi kích thước {name} (nhấp đúp để đặt lại, phím mũi tên để tinh chỉnh)',
2767
3270
  'diff.binary': 'Tệp nhị phân',
2768
3271
  'diff.loading': 'Đang tải diff...',
2769
3272
  'toolbar.refresh': 'Làm mới',
@@ -2774,6 +3277,28 @@ window.__ModuleLoader__.load({
2774
3277
  'toolbar.tree': 'Bảng nhánh',
2775
3278
  'rail.more': 'Thao tác khác',
2776
3279
  'rail.settings': 'Cài đặt thanh thao tác',
3280
+ 'rail.openSettings': 'Mở cài đặt plugin',
3281
+ 'settings.rail.title': 'Nút thanh tác vụ',
3282
+ 'settings.rail.hint': 'Chọn các tác vụ hiển thị trên thanh. Thứ tự cố định; bỏ chọn sẽ ẩn tác vụ đó.',
3283
+ 'settings.rail.unavailable': 'Máy chủ này không có dịch vụ cài đặt; hãy dùng “Cài đặt thanh tác vụ” trong bảng điều khiển.',
3284
+ 'settings.commit.title': 'Thông điệp commit do AI viết',
3285
+ 'settings.commit.model': 'Mô hình',
3286
+ 'settings.commit.modelHint': 'Trống = theo phiên này (provider/model)',
3287
+ 'settings.commit.reasoning': 'Mức suy luận',
3288
+ 'settings.commit.reasoningHint': 'Trống = mặc định của mô hình',
3289
+ 'settings.commit.prompt': 'Chỉ dẫn bổ sung',
3290
+ 'settings.commit.promptHint': 'ví dụ Conventional Commits, tiêu đề tiếng Việt, tối đa 72 ký tự',
3291
+ 'settings.commit.tooLong': 'Chỉ {n} ký tự đầu được lưu',
3292
+ 'changes.aiWrite': 'Viết bằng AI',
3293
+ 'changes.aiBusy': 'Đang viết…',
3294
+ 'changes.aiQuota': 'Dùng hạn mức của bạn',
3295
+ 'changes.aiOverwriteTitle': 'Thay bản nháp hiện tại?',
3296
+ 'changes.aiOverwriteText': 'Nội dung AI sẽ thay thế thông điệp commit bạn đã nhập (có thể hoàn tác).',
3297
+ 'changes.aiUndo': 'Hoàn tác',
3298
+ 'changes.aiFailed': 'Không viết được:',
3299
+ 'changes.aiNotice': 'Một số thay đổi chưa gửi tới mô hình (bị cắt hoặc bỏ); thông điệp có thể chưa đầy đủ.',
3300
+ 'settings.rail.writeFailed': 'Cài đặt không lưu được; công tắc đã trở về trạng thái cũ. Hãy thử lại.',
3301
+ 'settings.rail.readonly': 'Máy chủ này chỉ cung cấp cấu hình ở chế độ chỉ đọc.',
2777
3302
  'rail.settingsHint': 'Kéo hoặc dùng mũi tên để sắp xếp; biểu tượng con mắt bật tắt hiển thị',
2778
3303
  'rail.reset': 'Khôi phục mặc định',
2779
3304
  'rail.up': 'Di chuyển lên',
@@ -2895,6 +3420,11 @@ window.__ModuleLoader__.load({
2895
3420
  'action.showDiff': 'عرض الفروق',
2896
3421
  'action.copyPath': 'نسخ المسار',
2897
3422
  'action.checkout': 'تبديل',
3423
+ 'action.float': 'تعويم هذه الصفحة',
3424
+ 'action.dock': 'الإرجاع إلى الرصيف',
3425
+ 'action.split': 'تقسيم',
3426
+ 'action.fullscreen': 'ملء الشاشة',
3427
+ 'action.exitFullscreen': 'إنهاء ملء الشاشة',
2898
3428
  'action.newBranchFrom': 'فرع جديد من هنا...',
2899
3429
  'action.newBranchHere': 'فرع جديد هنا...',
2900
3430
  'action.rename': 'إعادة التسمية...',
@@ -2918,6 +3448,8 @@ window.__ModuleLoader__.load({
2918
3448
  'detail.back': 'رجوع إلى السجل',
2919
3449
  'detail.noFiles': 'لا توجد تغييرات في الملفات',
2920
3450
  'diff.empty': 'لا توجد فروق نصية لعرضها',
3451
+ 'pane.diff': 'عرض الفروق',
3452
+ 'pane.resize': 'اسحب لتغيير حجم {name} (النقر المزدوج يعيد الضبط، ومفاتيح الأسهم للضبط الدقيق)',
2921
3453
  'diff.binary': 'ملف ثنائي',
2922
3454
  'diff.loading': 'جارٍ تحميل الفروق...',
2923
3455
  'toolbar.refresh': 'تحديث',
@@ -2928,6 +3460,28 @@ window.__ModuleLoader__.load({
2928
3460
  'toolbar.tree': 'لوحة الفروع',
2929
3461
  'rail.more': 'مزيد من الإجراءات',
2930
3462
  'rail.settings': 'إعدادات شريط الإجراءات',
3463
+ 'rail.openSettings': 'فتح إعدادات الإضافة',
3464
+ 'settings.rail.title': 'أزرار شريط الإجراءات',
3465
+ 'settings.rail.hint': 'حدّد الإجراءات المعروضة على الشريط. الترتيب ثابت، وإلغاء التحديد يخفي الإجراء.',
3466
+ 'settings.rail.unavailable': 'لا تتوفر خدمة إعدادات على هذا المضيف؛ استخدم «إعدادات شريط الإجراءات» في اللوحة.',
3467
+ 'settings.commit.title': 'رسالة إيداع بالذكاء الاصطناعي',
3468
+ 'settings.commit.model': 'النموذج',
3469
+ 'settings.commit.modelHint': 'فارغ = اتبع هذه الجلسة (provider/model)',
3470
+ 'settings.commit.reasoning': 'مستوى التفكير',
3471
+ 'settings.commit.reasoningHint': 'فارغ = الافتراضي للنموذج',
3472
+ 'settings.commit.prompt': 'تعليمات إضافية',
3473
+ 'settings.commit.promptHint': 'مثال: Conventional Commits، والعنوان بالعربية، وبحد أقصى 72 حرفًا',
3474
+ 'settings.commit.tooLong': 'يُحفظ أول {n} حرفًا فقط',
3475
+ 'changes.aiWrite': 'اكتب بالذكاء الاصطناعي',
3476
+ 'changes.aiBusy': 'جارٍ الكتابة…',
3477
+ 'changes.aiQuota': 'يستهلك حصتك',
3478
+ 'changes.aiOverwriteTitle': 'استبدال المسودة الحالية؟',
3479
+ 'changes.aiOverwriteText': 'سيستبدل نص الذكاء الاصطناعي رسالة الإيداع التي كتبتها (يمكن التراجع).',
3480
+ 'changes.aiUndo': 'تراجع',
3481
+ 'changes.aiFailed': 'تعذّرت الكتابة:',
3482
+ 'changes.aiNotice': 'لم تُرسل بعض التغييرات إلى النموذج (مقتطعة أو متروكة)؛ قد تكون الرسالة ناقصة.',
3483
+ 'settings.rail.writeFailed': 'لم يتم حفظ الإعداد؛ عاد المفتاح إلى حاله. حاول مرة أخرى.',
3484
+ 'settings.rail.readonly': 'يوفّر هذا المضيف الإعدادات للقراءة فقط.',
2931
3485
  'rail.settingsHint': 'اسحب أو استخدم الأسهم لإعادة الترتيب؛ وأيقونة العين تبدّل الظهور',
2932
3486
  'rail.reset': 'استعادة الإعدادات الافتراضية',
2933
3487
  'rail.up': 'تحريك لأعلى',
@@ -3049,6 +3603,11 @@ window.__ModuleLoader__.load({
3049
3603
  'action.showDiff': 'अंतर दिखाएँ',
3050
3604
  'action.copyPath': 'पथ कॉपी करें',
3051
3605
  'action.checkout': 'चेकआउट',
3606
+ 'action.float': 'इस पृष्ठ को तैराएँ',
3607
+ 'action.dock': 'डॉक पर वापस लाएँ',
3608
+ 'action.split': 'विभाजित करें',
3609
+ 'action.fullscreen': 'पूर्ण स्क्रीन',
3610
+ 'action.exitFullscreen': 'पूर्ण स्क्रीन से बाहर',
3052
3611
  'action.newBranchFrom': 'यहाँ से नई शाखा...',
3053
3612
  'action.newBranchHere': 'यहाँ नई शाखा...',
3054
3613
  'action.rename': 'नाम बदलें...',
@@ -3072,6 +3631,8 @@ window.__ModuleLoader__.load({
3072
3631
  'detail.back': 'इतिहास पर वापस',
3073
3632
  'detail.noFiles': 'कोई फ़ाइल परिवर्तन नहीं',
3074
3633
  'diff.empty': 'दिखाने के लिए कोई टेक्स्ट अंतर नहीं',
3634
+ 'pane.diff': 'डिफ़ व्यू',
3635
+ 'pane.resize': '{name} का आकार बदलने के लिए खींचें (डबल-क्लिक रीसेट, तीर कुंजियाँ सूक्ष्म समायोजन)',
3075
3636
  'diff.binary': 'बाइनरी फ़ाइल',
3076
3637
  'diff.loading': 'अंतर लोड हो रहा है...',
3077
3638
  'toolbar.refresh': 'ताज़ा करें',
@@ -3082,6 +3643,28 @@ window.__ModuleLoader__.load({
3082
3643
  'toolbar.tree': 'शाखा पैनल',
3083
3644
  'rail.more': 'अधिक क्रियाएँ',
3084
3645
  'rail.settings': 'क्रिया पट्टी सेटिंग्स',
3646
+ 'rail.openSettings': 'प्लगइन सेटिंग खोलें',
3647
+ 'settings.rail.title': 'एक्शन रेल बटन',
3648
+ 'settings.rail.hint': 'रेल पर दिखने वाली क्रियाएँ चुनें। क्रम निश्चित है; चुनाव हटाने पर वह छिप जाती है।',
3649
+ 'settings.rail.unavailable': 'इस होस्ट में सेटिंग सेवा नहीं है; पैनल में «एक्शन रेल सेटिंग» का उपयोग करें।',
3650
+ 'settings.commit.title': 'AI कमिट संदेश',
3651
+ 'settings.commit.model': 'मॉडल',
3652
+ 'settings.commit.modelHint': 'खाली = इस सत्र का अनुसरण करें (provider/model)',
3653
+ 'settings.commit.reasoning': 'तर्क की तीव्रता',
3654
+ 'settings.commit.reasoningHint': 'खाली = मॉडल का डिफ़ॉल्ट',
3655
+ 'settings.commit.prompt': 'अतिरिक्त निर्देश',
3656
+ 'settings.commit.promptHint': 'जैसे Conventional Commits, विषय हिंदी में, अधिकतम 72 अक्षर',
3657
+ 'settings.commit.tooLong': 'केवल पहले {n} अक्षर सहेजे जाते हैं',
3658
+ 'changes.aiWrite': 'AI से लिखें',
3659
+ 'changes.aiBusy': 'लिखा जा रहा है…',
3660
+ 'changes.aiQuota': 'आपका कोटा खर्च होता है',
3661
+ 'changes.aiOverwriteTitle': 'मौजूदा ड्राफ़्ट बदलें?',
3662
+ 'changes.aiOverwriteText': 'AI पाठ आपके लिखे कमिट संदेश की जगह लेगा (पूर्ववत कर सकते हैं)।',
3663
+ 'changes.aiUndo': 'पूर्ववत करें',
3664
+ 'changes.aiFailed': 'लिख नहीं सका:',
3665
+ 'changes.aiNotice': 'कुछ बदलाव मॉडल को नहीं भेजे गए (काटे या छोड़े गए); संदेश अधूरा हो सकता है।',
3666
+ 'settings.rail.writeFailed': 'सेटिंग सहेजी नहीं गई; स्विच वापस पुरानी स्थिति में आ गया। फिर से कोशिश करें।',
3667
+ 'settings.rail.readonly': 'यह होस्ट कॉन्फ़िगरेशन को केवल-पढ़ने के लिए देता है।',
3085
3668
  'rail.settingsHint': 'क्रम बदलने के लिए खींचें या तीरों का उपयोग करें; आँख आइकन दृश्यता बदलता है',
3086
3669
  'rail.reset': 'डिफ़ॉल्ट बहाल करें',
3087
3670
  'rail.up': 'ऊपर ले जाएँ',
@@ -3203,6 +3786,11 @@ window.__ModuleLoader__.load({
3203
3786
  'action.showDiff': 'แสดงความต่าง',
3204
3787
  'action.copyPath': 'คัดลอกพาธ',
3205
3788
  'action.checkout': 'เช็กเอาต์',
3789
+ 'action.float': 'ลอยหน้านี้',
3790
+ 'action.dock': 'กลับเข้าด็อก',
3791
+ 'action.split': 'แบ่งหน้าต่าง',
3792
+ 'action.fullscreen': 'เต็มหน้าจอ',
3793
+ 'action.exitFullscreen': 'ออกจากเต็มหน้าจอ',
3206
3794
  'action.newBranchFrom': 'แบรนช์ใหม่จากที่นี่...',
3207
3795
  'action.newBranchHere': 'แบรนช์ใหม่ที่นี่...',
3208
3796
  'action.rename': 'เปลี่ยนชื่อ...',
@@ -3226,6 +3814,8 @@ window.__ModuleLoader__.load({
3226
3814
  'detail.back': 'กลับไปที่ประวัติ',
3227
3815
  'detail.noFiles': 'ไม่มีการเปลี่ยนแปลงไฟล์',
3228
3816
  'diff.empty': 'ไม่มีความต่างเชิงข้อความให้แสดง',
3817
+ 'pane.diff': 'มุมมอง diff',
3818
+ 'pane.resize': 'ลากเพื่อปรับขนาด {name} (ดับเบิลคลิกเพื่อรีเซ็ต ปุ่มลูกศรปรับละเอียด)',
3229
3819
  'diff.binary': 'ไฟล์ไบนารี',
3230
3820
  'diff.loading': 'กำลังโหลดความต่าง...',
3231
3821
  'toolbar.refresh': 'รีเฟรช',
@@ -3236,6 +3826,28 @@ window.__ModuleLoader__.load({
3236
3826
  'toolbar.tree': 'แผงแบรนช์',
3237
3827
  'rail.more': 'การทำงานเพิ่มเติม',
3238
3828
  'rail.settings': 'ตั้งค่าแถบการทำงาน',
3829
+ 'rail.openSettings': 'เปิดการตั้งค่าปลั๊กอิน',
3830
+ 'settings.rail.title': 'ปุ่มแถบการทำงาน',
3831
+ 'settings.rail.hint': 'ทำเครื่องหมายการทำงานที่จะแสดงบนแถบ ลำดับคงที่; ยกเลิกเครื่องหมายเพื่อซ่อน',
3832
+ 'settings.rail.unavailable': 'โฮสต์นี้ไม่มีบริการตั้งค่า ให้ใช้ «ตั้งค่าแถบการทำงาน» ในแผง',
3833
+ 'settings.commit.title': 'ข้อความคอมมิตโดย AI',
3834
+ 'settings.commit.model': 'โมเดล',
3835
+ 'settings.commit.modelHint': 'ว่าง = ตามเซสชันนี้ (provider/model)',
3836
+ 'settings.commit.reasoning': 'ระดับการใช้เหตุผล',
3837
+ 'settings.commit.reasoningHint': 'ว่าง = ค่าเริ่มต้นของโมเดล',
3838
+ 'settings.commit.prompt': 'คำสั่งเพิ่มเติม',
3839
+ 'settings.commit.promptHint': 'เช่น Conventional Commits หัวข้อเป็นภาษาไทย ไม่เกิน 72 ตัวอักษร',
3840
+ 'settings.commit.tooLong': 'บันทึกเพียง {n} ตัวอักษรแรก',
3841
+ 'changes.aiWrite': 'เขียนด้วย AI',
3842
+ 'changes.aiBusy': 'กำลังเขียน…',
3843
+ 'changes.aiQuota': 'ใช้โควตาของคุณ',
3844
+ 'changes.aiOverwriteTitle': 'แทนที่ฉบับร่างปัจจุบันหรือไม่',
3845
+ 'changes.aiOverwriteText': 'ข้อความจาก AI จะแทนที่ข้อความคอมมิตที่คุณพิมพ์ (เลิกทำได้)',
3846
+ 'changes.aiUndo': 'เลิกทำ',
3847
+ 'changes.aiFailed': 'เขียนไม่สำเร็จ:',
3848
+ 'changes.aiNotice': 'มีการเปลี่ยนแปลงบางส่วนที่ไม่ได้ส่งให้โมเดล (ถูกตัดหรือทิ้ง) ข้อความอาจไม่ครบถ้วน',
3849
+ 'settings.rail.writeFailed': 'บันทึกการตั้งค่าไม่สำเร็จ สวิตช์กลับไปสถานะเดิม ลองอีกครั้ง',
3850
+ 'settings.rail.readonly': 'โฮสต์นี้ให้บริการการตั้งค่าแบบอ่านอย่างเดียว',
3239
3851
  'rail.settingsHint': 'ลากหรือใช้ลูกศรเพื่อจัดลำดับ; ไอคอนตาใช้สลับการแสดง',
3240
3852
  'rail.reset': 'คืนค่าเริ่มต้น',
3241
3853
  'rail.up': 'เลื่อนขึ้น',
@@ -3448,6 +4060,16 @@ window.__ModuleLoader__.load({
3448
4060
  so a version that adds an action never breaks a stored config. */
3449
4061
  const RAIL_SPECS = [
3450
4062
  { id: 'refresh', icon: 'refresh', key: 'toolbar.refresh', tone: 'accent' },
4063
+ /* View actions. The tree toggle lives here as well as in the compact header
4064
+ because a persisted treeOpen=false would otherwise have no way back:
4065
+ compact is the only chrome with a header button, and it used to render
4066
+ that button only while the tree was OPEN. */
4067
+ { id: 'tree', icon: 'branch', key: 'toolbar.tree', tone: 'secondary' },
4068
+ /* The official right-sidebar dock (float / re-dock / split / full screen).
4069
+ They only render where that service exists — see dockFaceOf(). */
4070
+ { id: 'float', icon: 'float', key: 'action.float', tone: 'accent' },
4071
+ { id: 'split', icon: 'split', key: 'action.split', tone: 'secondary' },
4072
+ { id: 'fullscreen', icon: 'fullscreen', key: 'action.fullscreen', tone: 'secondary' },
3451
4073
  { id: 'newBranch', icon: 'plus', key: 'toolbar.newBranch', tone: 'success' },
3452
4074
  { id: 'checkout', icon: 'checkout', key: 'action.checkout', tone: 'accent' },
3453
4075
  { id: 'delete', icon: 'trash', key: 'action.delete', tone: 'danger' },
@@ -3461,6 +4083,10 @@ window.__ModuleLoader__.load({
3461
4083
  { id: 'push', icon: 'push', key: 'toolbar.push', tone: 'success' },
3462
4084
  ]
3463
4085
  const RAIL_IDS = RAIL_SPECS.map((spec) => spec.id)
4086
+ /* Actions that arrange the view rather than touch the repository, so a running
4087
+ git command never greys them out. */
4088
+ const RAIL_VIEW_IDS = ['tree', 'float', 'split', 'fullscreen']
4089
+ const RAIL_DOCK_IDS = ['float', 'split', 'fullscreen']
3464
4090
 
3465
4091
  function normalizeRail(raw) {
3466
4092
  const source = raw !== null && typeof raw === 'object' ? raw : {}
@@ -3478,7 +4104,78 @@ window.__ModuleLoader__.load({
3478
4104
  return { order, hidden }
3479
4105
  }
3480
4106
 
4107
+ /* ---------------------------------------------------------------------
4108
+ Where the rail config LIVES. On dsh >= 0.1.7 a plugin's configuration is
4109
+ the row Config (the official settings surface, persisted by the host under
4110
+ the loader row id) and the browser reads it through ctx.configForms — so
4111
+ the settings card and the rail read ONE source and nothing is written
4112
+ twice. An older host has no such service: there the rail keeps its
4113
+ localStorage home and its in-panel editor, which is the only editor those
4114
+ hosts have. `railForm` is that switch; null means legacy.
4115
+ --------------------------------------------------------------------- */
4116
+ const RAIL_NS = 'ide-git'
4117
+ /* One write window for the settings card: a burst of switches becomes one
4118
+ atomic mutation instead of a stream the Host can lose. */
4119
+ const FLUSH_MS = 250
4120
+ const RAIL_MIGRATED_KEY = 'dsh-ide-git.rail.v1.migrated'
4121
+ let railForm = null
4122
+ let railMigrating = false
4123
+ const railWatchers = new Set()
4124
+
4125
+ /* ---- rail config core (pure: tests/smoke.mjs slices this block) ---- */
4126
+ function railFieldOf(id) {
4127
+ return 'rail' + id.charAt(0).toUpperCase() + id.slice(1)
4128
+ }
4129
+
4130
+ /* Stored booleans -> the { order, hidden } shape the rail has always used.
4131
+ The order is the code order now: the settings card edits one switch per
4132
+ action, so a stored permutation would have no editor left, and dropping it
4133
+ keeps this surface honest rather than half-editable. */
4134
+ function railConfigOfValues(values) {
4135
+ const source = values !== null && typeof values === 'object' ? values : {}
4136
+ const hidden = RAIL_IDS.filter((id) => source[railFieldOf(id)] === false)
4137
+ return { order: RAIL_IDS.slice(), hidden: hidden }
4138
+ }
4139
+
4140
+ /* The inverse of railFieldOf, for the one-field write path. Returns null for a
4141
+ field no rail action owns, so a caller cannot invent a row-Config key. */
4142
+ function railIdOfField(field) {
4143
+ if (typeof field !== 'string' || field.indexOf('rail') !== 0 || field.length <= 4) return null
4144
+ const name = field.slice(4)
4145
+ const id = name.charAt(0).toLowerCase() + name.slice(1)
4146
+ return RAIL_IDS.indexOf(id) < 0 ? null : id
4147
+ }
4148
+
4149
+ function railValuesOfConfig(config) {
4150
+ const values = {}
4151
+ for (const id of RAIL_IDS) values[railFieldOf(id)] = config.hidden.indexOf(id) < 0
4152
+ return values
4153
+ }
4154
+
4155
+ /* ---- end rail config core ---- */
4156
+
4157
+ /* The three AI-commit settings live in the same row Config document the
4158
+ settings card writes; the panel reads them per render, so a change in the
4159
+ card applies to the next click without a reload. */
4160
+ function readCommitSettings() {
4161
+ let values = {}
4162
+ const ready = railFormReady()
4163
+ if (ready !== null && ready.value !== null && typeof ready.value === 'object') values = ready.value
4164
+ const text = (field, max) => (typeof values[field] === 'string' ? values[field].trim().slice(0, max) : '')
4165
+ return { model: text('commitModel', 120), reasoning: text('commitReasoning', 60), prompt: text('commitPrompt', 2000) }
4166
+ }
4167
+
4168
+ function railFormReady() {
4169
+ if (railForm === null) return null
4170
+ const snapshot = railForm.getSnapshot()
4171
+ if (snapshot === null || snapshot === undefined || snapshot.status !== 'ready' || snapshot.value === undefined) return null
4172
+ return snapshot
4173
+ }
4174
+
3481
4175
  function readRailConfig() {
4176
+ const ready = railFormReady()
4177
+ if (ready !== null) return railConfigOfValues(ready.value)
4178
+ // Legacy home (and the state before the host answers): localStorage.
3482
4179
  try {
3483
4180
  const raw = window.localStorage.getItem(RAIL_KEY)
3484
4181
  if (raw === null || raw === '') return normalizeRail(null)
@@ -3487,10 +4184,413 @@ window.__ModuleLoader__.load({
3487
4184
  return normalizeRail(null)
3488
4185
  }
3489
4186
 
3490
- function writeRailConfig(config) {
4187
+ /* `form` lets a caller write through the exact handle it read from (the
4188
+ settings card gets the framework's own form); without it the module handle
4189
+ is used. When a config surface EXISTS but is not ready yet, the write is
4190
+ refused instead of silently landing in the legacy localStorage home — two
4191
+ sources would be worse than a refused edit. */
4192
+ function writeRailConfig(config, form) {
4193
+ const active = form !== undefined && form !== null && typeof form.getSnapshot === 'function' ? form : railForm
4194
+ let ready = null
4195
+ try {
4196
+ const snapshot = active === null || active === undefined ? null : active.getSnapshot()
4197
+ if (snapshot !== null && snapshot !== undefined && snapshot.status === 'ready' && snapshot.value !== undefined) ready = snapshot
4198
+ } catch (error) { void error }
4199
+ if (ready !== null) {
4200
+ if (ready.writable !== true || typeof active.set !== 'function') return
4201
+ railForm = active
4202
+ const current = ready.value !== null && typeof ready.value === 'object' ? ready.value : {}
4203
+ // Only the fields that actually changed: one write, not sixteen.
4204
+ for (const id of RAIL_IDS) {
4205
+ const field = railFieldOf(id)
4206
+ const visible = config.hidden.indexOf(id) < 0
4207
+ if (current[field] === visible) continue
4208
+ const written = active.set(field, visible)
4209
+ if (written !== undefined && written !== null && typeof written.then === 'function') {
4210
+ void written.then((accepted) => {
4211
+ if (accepted === false) console.warn('[dsh-ide-git] rail setting "' + field + '" was refused by the host')
4212
+ }, (error) => { console.warn('[dsh-ide-git] rail setting write failed: ' + String(error && error.message ? error.message : error)) })
4213
+ }
4214
+ }
4215
+ return
4216
+ }
4217
+ if (railForm !== null) return
3491
4218
  try { window.localStorage.setItem(RAIL_KEY, JSON.stringify(config)) } catch (error) { void error }
3492
4219
  }
3493
4220
 
4221
+ /* One switch, one field, one write — computed from the value the user just
4222
+ chose rather than from a whole-config snapshot that may predate the click.
4223
+ The card edits one field at a time, and a full-config write derived from a
4224
+ stale render clobbers whichever sibling was toggled a moment earlier
4225
+ (measured on 16 rapid toggles: 4 fields never reached the row Config and
4226
+ reverted on reload).
4227
+ Returns the write's disposition so the caller can roll its optimistic value
4228
+ back and say so instead of leaving a switch that lies:
4229
+ sent:true + settled:Promise<boolean> when the Host answers
4230
+ sent:false, reason 'read-only' | 'not-ready' | 'unknown'. */
4231
+ function writeRailFields(ops, form) {
4232
+ const active = form !== undefined && form !== null && typeof form.getSnapshot === 'function' ? form : railForm
4233
+ let ready = null
4234
+ try {
4235
+ const snapshot = active === null || active === undefined ? null : active.getSnapshot()
4236
+ if (snapshot !== null && snapshot !== undefined && snapshot.status === 'ready' && snapshot.value !== undefined) ready = snapshot
4237
+ } catch (error) { void error }
4238
+ if (ready !== null) {
4239
+ if (ready.writable !== true || typeof active.mutate !== 'function') return { sent: false, reason: 'read-only' }
4240
+ railForm = active
4241
+ const settled = Promise.resolve(active.mutate(ops)).then(
4242
+ (accepted) => accepted !== false,
4243
+ (error) => {
4244
+ console.warn('[dsh-ide-git] rail setting write failed: ' + String(error && error.message ? error.message : error))
4245
+ return false
4246
+ },
4247
+ )
4248
+ return { sent: true, settled: settled }
4249
+ }
4250
+ /* A config surface that exists but has not answered yet must refuse rather
4251
+ than fall through to the legacy home: two sources would be worse. */
4252
+ if (railForm !== null) return { sent: false, reason: 'not-ready' }
4253
+ const current = readRailConfig()
4254
+ let hidden = current.hidden.slice()
4255
+ for (const op of ops) {
4256
+ const id = railIdOfField(String(op.path[0]))
4257
+ if (id === null) return { sent: false, reason: 'unknown' }
4258
+ const wanted = op.value === true
4259
+ if (wanted === true) hidden = hidden.filter((entry) => entry !== id)
4260
+ else if (hidden.indexOf(id) < 0) hidden = hidden.concat([id])
4261
+ }
4262
+ try { window.localStorage.setItem(RAIL_KEY, JSON.stringify({ order: current.order, hidden: hidden })) } catch (error) { void error }
4263
+ notifyRailConfig()
4264
+ return { sent: true }
4265
+ }
4266
+
4267
+ /* One switch's write. The card batches a burst through writeRailFields so the
4268
+ Host sees one atomic mutation instead of a stream of overlapping ones. */
4269
+ function writeRailField(field, value, form) {
4270
+ return writeRailFields([{ op: 'set', path: [field], value: value }], form)
4271
+ }
4272
+
4273
+ /* One-shot move of the pre-settings rail config into the row Config: read the
4274
+ legacy value ONCE, write it through, mark it done. After this the row Config
4275
+ is the only source (the legacy key is left untouched as a paper trail, but
4276
+ nothing writes it any more on this host). */
4277
+ function migrateRailConfig(form) {
4278
+ try {
4279
+ if (window.localStorage.getItem(RAIL_MIGRATED_KEY) !== null) return
4280
+ if (railMigrating === true) return
4281
+ const done = () => {
4282
+ railMigrating = false
4283
+ try { window.localStorage.setItem(RAIL_MIGRATED_KEY, '1') } catch (error) { void error }
4284
+ }
4285
+ const raw = window.localStorage.getItem(RAIL_KEY)
4286
+ if (raw === null || raw === '') { done(); return }
4287
+ const values = railValuesOfConfig(normalizeRail(JSON.parse(raw)))
4288
+ const ready = railFormReady()
4289
+ const accepted = ready !== null && ready.value !== null && typeof ready.value === 'object' ? ready.value : {}
4290
+ const ops = []
4291
+ for (const id of RAIL_IDS) {
4292
+ const field = railFieldOf(id)
4293
+ if (accepted[field] === values[field]) continue
4294
+ ops.push({ op: 'set', path: [field], value: values[field] === true })
4295
+ }
4296
+ if (ops.length === 0) { done(); return }
4297
+ /* ONE atomic mutation, and the marker ONLY once the Host accepted it.
4298
+ The per-field set() loop this replaces fired sixteen unawaited writes
4299
+ and stamped the marker right away, so on a real profile fifteen of the
4300
+ sixteen fields (and in one case all of them) never reached the row
4301
+ Config and were never retried — an upgrade silently lost the user's
4302
+ rail settings while the marker claimed success. A refusal, a failed
4303
+ write or a superseded one now leaves the marker unwritten so the next
4304
+ load migrates again. */
4305
+ railMigrating = true
4306
+ const outcome = writeRailFields(ops, form)
4307
+ if (outcome.sent !== true) {
4308
+ railMigrating = false
4309
+ console.warn('[dsh-ide-git] legacy rail migration was refused (' + String(outcome.reason) + '); it stays unmarked and is retried')
4310
+ return
4311
+ }
4312
+ if (outcome.settled === undefined) { done(); return }
4313
+ void outcome.settled.then((acceptedWrite) => {
4314
+ if (acceptedWrite === true) { done(); return }
4315
+ railMigrating = false
4316
+ console.warn('[dsh-ide-git] legacy rail migration was not accepted; it stays unmarked and is retried')
4317
+ })
4318
+ } catch (error) { void error }
4319
+ }
4320
+
4321
+ function subscribeRailConfig(listener) {
4322
+ railWatchers.add(listener)
4323
+ return () => { railWatchers.delete(listener) }
4324
+ }
4325
+
4326
+ function notifyRailConfig() {
4327
+ for (const listener of Array.from(railWatchers)) {
4328
+ try { listener() } catch (error) { void error }
4329
+ }
4330
+ }
4331
+
4332
+ /* ============================== pane sizes ============================== */
4333
+
4334
+ /* ---- pane sizing core (pure: tests/smoke.mjs slices this block and drives
4335
+ it through new Function, so it must stay free of window/DOM/React) ---- */
4336
+
4337
+ /* Every fixed pane size in the layout became draggable (issue #5), and the
4338
+ result is remembered PER SURFACE. The same registration renders in the
4339
+ bottom workbench (wide and flat) and in the native right sidebar (narrow
4340
+ and tall), and a 290px changes column that is right in one is half the
4341
+ panel in the other. The bucket is therefore `chrome:wide|tall`, and what
4342
+ is stored is a RATIO of the measured container: resizing the dock or the
4343
+ window rescales the panes instead of breaking the layout. */
4344
+ const PANES_KEY = 'dsh-ide-git.panes.v1'
4345
+ const PANE_CHROME_KEYS = {
4346
+ columns: ['tree', 'changes'],
4347
+ stack: ['tree', 'changes', 'diff'],
4348
+ compact: ['tree', 'diff'],
4349
+ }
4350
+ /* Whether a key measures a height (stacked chromes) or a width (columns). */
4351
+ const PANE_HEIGHT_CHROME = { columns: false, stack: true, compact: true }
4352
+ /* The pane a separator resizes, and the key its label is translated from. */
4353
+ const PANE_NAME_KEYS = { tree: 'toolbar.tree', changes: 'changes.title', diff: 'pane.diff' }
4354
+ /* Hard limits in px: a gutter moves a pane between these and nothing else,
4355
+ so no pane can ever be dragged down to zero. */
4356
+ const PANE_LIMITS = {
4357
+ 'columns:tree': { min: 140, max: 560 },
4358
+ 'columns:changes': { min: 200, max: 640 },
4359
+ 'stack:tree': { min: 100, max: 420 },
4360
+ 'stack:changes': { min: 120, max: 460 },
4361
+ 'stack:diff': { min: 120, max: 900 },
4362
+ 'compact:tree': { min: 100, max: 420 },
4363
+ 'compact:diff': { min: 120, max: 900 },
4364
+ }
4365
+ /* Today's fixed sizes stay the default — and stay in the stylesheet: a pane
4366
+ the user never dragged keeps exactly the look it had before, while a
4367
+ dragged one is remembered as a ratio. */
4368
+ const PANE_DEFAULTS = {
4369
+ 'columns:tree': { px: 200 },
4370
+ 'columns:changes': { px: 290 },
4371
+ 'stack:tree': { ratio: 0.36 },
4372
+ 'stack:changes': { ratio: 0.46 },
4373
+ 'compact:tree': { ratio: 0.42 },
4374
+ }
4375
+ const PANE_MAIN_MIN_W = 240
4376
+ const PANE_MAIN_MIN_H = 150
4377
+ /* The reserve above is the IDEAL the clamp aims for. When the panel is simply
4378
+ too small for every default at once, aiming for it would put a pane's
4379
+ ceiling BELOW the box that pane already occupies — and the first drag would
4380
+ then shrink it instead of growing it. Below that point the middle pane
4381
+ keeps this hard floor instead, and the pane's own current size always wins
4382
+ (see paneGeometry). */
4383
+ const PANE_MAIN_FLOOR_PX = 120
4384
+ const PANE_HISTORY_MIN_H = 120
4385
+ /* Hit area of one gutter, and how many of them sit in the body. The
4386
+ history↔diff separator lives inside the middle pane, so it is not counted
4387
+ against the body's own budget. */
4388
+ const PANE_GUTTER_PX = 8
4389
+ const PANE_BODY_GUTTERS = { columns: 2, stack: 2, compact: 1 }
4390
+ const PANE_BUCKETS = Object.keys(PANE_CHROME_KEYS).reduce(
4391
+ (all, chrome) => all.concat([chrome + ':wide', chrome + ':tall']), [])
4392
+
4393
+ /* Two measurements are the same when every pane they know about is. */
4394
+ function samePaneSizes(current, next) {
4395
+ const keys = Object.keys(current)
4396
+ if (keys.length !== Object.keys(next).length) return false
4397
+ for (const key of keys) { if (current[key] !== next[key]) return false }
4398
+ return true
4399
+ }
4400
+
4401
+ function panBucket(chrome, box) {
4402
+ return chrome + (box.height > box.width ? ':tall' : ':wide')
4403
+ }
4404
+
4405
+ /* Storage is untrusted, so this keeps normalizeRail()'s discipline: unknown
4406
+ buckets, unknown keys, non-finite numbers and out-of-range ratios are all
4407
+ dropped. A ratio is a fraction of the container, so it lives in (0, 1]. */
4408
+ function normalizePanes(raw) {
4409
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
4410
+ const stored = source.panes !== null && typeof source.panes === 'object' ? source.panes : {}
4411
+ const panes = {}
4412
+ for (const bucket of PANE_BUCKETS) {
4413
+ const entry = stored[bucket]
4414
+ if (entry === null || typeof entry !== 'object') continue
4415
+ const keys = PANE_CHROME_KEYS[bucket.slice(0, bucket.indexOf(':'))]
4416
+ const clean = {}
4417
+ for (const key of keys) {
4418
+ const value = entry[key]
4419
+ if (typeof value !== 'number' || Number.isFinite(value) === false) continue
4420
+ if (value <= 0 || value > 1) continue
4421
+ clean[key] = value
4422
+ }
4423
+ if (Object.keys(clean).length > 0) panes[bucket] = clean
4424
+ }
4425
+ return { treeOpen: source.treeOpen !== false, panes: panes }
4426
+ }
4427
+
4428
+ /* Immutable single-key update. ratio === null removes the override, which is
4429
+ exactly what the double-click reset does. */
4430
+ function withPaneRatio(config, bucket, key, ratio) {
4431
+ const panes = {}
4432
+ for (const name of PANE_BUCKETS) {
4433
+ if (config.panes[name] !== undefined) panes[name] = config.panes[name]
4434
+ }
4435
+ const entry = {}
4436
+ const current = panes[bucket] === undefined ? {} : panes[bucket]
4437
+ for (const name of PANE_CHROME_KEYS[bucket.slice(0, bucket.indexOf(':'))]) {
4438
+ if (current[name] !== undefined) entry[name] = current[name]
4439
+ }
4440
+ if (ratio === null) delete entry[key]
4441
+ else entry[key] = ratio
4442
+ if (Object.keys(entry).length === 0) delete panes[bucket]
4443
+ else panes[bucket] = entry
4444
+ return { treeOpen: config.treeOpen, panes: panes }
4445
+ }
4446
+
4447
+ /* Stored ratios (or today's defaults) → concrete px for the current chrome,
4448
+ clamped against the measured container. `overrides` holds only the panes
4449
+ the user actually dragged (absent = leave the stylesheet default alone);
4450
+ `effective` is what the clamp math used; `limits` is the range each gutter
4451
+ is allowed to hand back. `laidOut` is what the panes actually measure right
4452
+ now, which beats the stylesheet default when a pane is content-sized: the
4453
+ stacked chrome caps the changes pane at 46%, but a three-file summary only
4454
+ takes 30%, and reserving the cap would freeze the tree above it. */
4455
+ function paneGeometry(chrome, box, config, laidOut) {
4456
+ const height = PANE_HEIGHT_CHROME[chrome] === true
4457
+ const reference = height ? box.height : box.width
4458
+ const bucket = panBucket(chrome, box)
4459
+ const ready = config !== null && typeof config === 'object'
4460
+ const source = ready && config.panes !== null && typeof config.panes === 'object' ? config.panes : {}
4461
+ const ratios = source[bucket] !== null && typeof source[bucket] === 'object' ? source[bucket] : {}
4462
+ const measured = laidOut !== null && typeof laidOut === 'object' ? laidOut : {}
4463
+ const keys = PANE_CHROME_KEYS[chrome].slice()
4464
+ /* A folded tree has no pane and no gutter, so it must not reserve room. */
4465
+ if (ready && config.treeOpen === false) {
4466
+ const at = keys.indexOf('tree')
4467
+ if (at >= 0) keys.splice(at, 1)
4468
+ }
4469
+ const overrides = {}
4470
+ const effective = {}
4471
+ const limits = {}
4472
+ const sizeOf = (key) => (effective[key] === undefined ? 0 : effective[key])
4473
+ for (const key of keys) {
4474
+ const spec = PANE_DEFAULTS[chrome + ':' + key]
4475
+ const fallback = spec === undefined ? 0 : (spec.px === undefined ? spec.ratio * reference : spec.px)
4476
+ const live = measured[key]
4477
+ effective[key] = typeof live === 'number' && Number.isFinite(live) && live > 0 ? live : fallback
4478
+ }
4479
+ const space = Math.max(0, reference - PANE_BODY_GUTTERS[chrome] * PANE_GUTTER_PX)
4480
+ const reserve = height ? PANE_MAIN_MIN_H : PANE_MAIN_MIN_W
4481
+ /* Deterministic order: changes, then tree, then diff. The diff is carved
4482
+ out of the middle pane, so it is clamped last — against whatever the
4483
+ first two left behind, minus the history list's own minimum. */
4484
+ for (const key of ['changes', 'tree', 'diff']) {
4485
+ if (keys.indexOf(key) < 0) continue
4486
+ const spec = PANE_LIMITS[chrome + ':' + key]
4487
+ const current = sizeOf(key)
4488
+ let ceiling = spec.max
4489
+ if (key === 'diff') {
4490
+ const main = space - sizeOf('changes') - sizeOf('tree') - PANE_GUTTER_PX
4491
+ ceiling = Math.min(ceiling, main - PANE_HISTORY_MIN_H)
4492
+ } else {
4493
+ let taken = 0
4494
+ for (const other of keys) { if (other !== key && other !== 'diff') taken += sizeOf(other) }
4495
+ /* Two tiers, because one number cannot serve both jobs. The IDEAL keeps
4496
+ the middle pane at its reserve — that is what a roomy panel gets. The
4497
+ relaxed tier only kicks in when the ideal would land BELOW the box
4498
+ this pane already occupies (a 674px column cannot hold 200 + 290 + 240
4499
+ at once), and it exists so the clamp degrades to "you may not grow
4500
+ much" instead of "the first drag to the right shrinks you". */
4501
+ const ideal = space - reserve - taken
4502
+ ceiling = Math.min(ceiling, current > ideal ? space - PANE_MAIN_FLOOR_PX - taken : ideal)
4503
+ }
4504
+ /* The clamp may refuse to GROW a pane; it may never shrink one the user
4505
+ did not touch — so the ceiling is never below the pane's current box,
4506
+ and the aria window says exactly that. */
4507
+ const cap = Math.max(spec.max, Math.round(current))
4508
+ const high = Math.min(cap, Math.max(spec.min, Math.round(ceiling), Math.round(current)))
4509
+ limits[key] = { min: spec.min, max: high }
4510
+ const ratio = ratios[key]
4511
+ if (typeof ratio === 'number' && Number.isFinite(ratio)) {
4512
+ const px = Math.round(Math.min(Math.max(ratio * reference, spec.min), high))
4513
+ overrides[key] = px
4514
+ effective[key] = px
4515
+ }
4516
+ }
4517
+ return { bucket: bucket, overrides: overrides, effective: effective, limits: limits }
4518
+ }
4519
+ /* ---- end pane sizing core ---- */
4520
+
4521
+ function readPanes() {
4522
+ try {
4523
+ const raw = window.localStorage.getItem(PANES_KEY)
4524
+ if (raw === null || raw === '') return normalizePanes(null)
4525
+ return normalizePanes(JSON.parse(raw))
4526
+ } catch (error) { void error }
4527
+ return normalizePanes(null)
4528
+ }
4529
+
4530
+ function writePanes(config) {
4531
+ try { window.localStorage.setItem(PANES_KEY, JSON.stringify(normalizePanes(config))) } catch (error) { void error }
4532
+ }
4533
+
4534
+ /* ============================== dock actions ============================== */
4535
+
4536
+ /* ---- dock action core (pure: tests/smoke.mjs slices this block) ---- */
4537
+
4538
+ /* The official right sidebar (ui-sidebar-right) is the only surface that can
4539
+ float a tab out, dock it back, split its pane or take it full screen, and it
4540
+ is NOT there on every host: with dsh-better-sidebar loaded this panel lives
4541
+ in THAT dock, an old host has no sidebarRight at all, and even on a host that
4542
+ ships it the service is only provided once ui-sidebar-right has mounted —
4543
+ reading it during apply() returns undefined (measured on 0.1.7-rc.2). So the
4544
+ service is read at render time, and a missing one means these actions do not
4545
+ exist for this mount: no button, no error, no console noise. */
4546
+ function dockFaceOf(ctx) {
4547
+ if (ctx === null || ctx === undefined || typeof ctx.get !== 'function') return null
4548
+ let service
4549
+ try { service = ctx.get('sidebarRight') } catch (error) { return null }
4550
+ if (service === null || service === undefined || typeof service !== 'object') return null
4551
+ if (typeof service.float !== 'function' || typeof service.dock !== 'function') return null
4552
+ return service
4553
+ }
4554
+
4555
+ /* What this mount can offer. `dock` is the wiring handed down to the panel
4556
+ (null when nothing is wired, e.g. the better-sidebar door), `floatPaneId` is
4557
+ the floating pane the panel is currently drawn in, read from the float
4558
+ layer's own marker. The face publishes operations only — no layout snapshot —
4559
+ so the float state has to come from there. */
4560
+ function dockActionState(dock, floatPaneId) {
4561
+ const wired = dock !== null && typeof dock === 'object' && typeof dock.tabId === 'string'
4562
+ && dock.tabId !== '' && dock.service !== null && typeof dock.service === 'object'
4563
+ const floating = typeof floatPaneId === 'string' && floatPaneId !== ''
4564
+ return {
4565
+ wired: wired,
4566
+ floating: floating,
4567
+ float: wired,
4568
+ /* A float panel hosts exactly one tab: splitting it is not a thing. */
4569
+ split: wired && floating === false,
4570
+ fullscreen: wired && typeof dock.service.toggleFullscreen === 'function',
4571
+ }
4572
+ }
4573
+ /* ---- end dock action core ---- */
4574
+
4575
+ /* The slot hands every tab body its own reader; it THROWS while the tab is not
4576
+ committed in the session, so an unreadable tab means no dock actions rather
4577
+ than a broken panel. Called from the component that owns the hook. */
4578
+ function readDockInfo(useTabInfo, ctx) {
4579
+ if (typeof useTabInfo !== 'function') return null
4580
+ const service = dockFaceOf(ctx)
4581
+ if (service === null) return null
4582
+ let info = null
4583
+ try { info = useTabInfo() } catch (error) { void error }
4584
+ if (info === null || typeof info !== 'object' || info.tab === undefined || info.panel === undefined) return null
4585
+ if (typeof info.tab.id !== 'string') return null
4586
+ return {
4587
+ service: service,
4588
+ tabId: info.tab.id,
4589
+ paneId: typeof info.panel.id === 'string' ? info.panel.id : '',
4590
+ fullscreen: info.sidebar !== undefined && info.sidebar !== null && info.sidebar.fullscreen === true,
4591
+ }
4592
+ }
4593
+
3494
4594
  /* ============================== formatting ============================== */
3495
4595
 
3496
4596
  function relativeTime(iso) {
@@ -3734,7 +4834,11 @@ window.__ModuleLoader__.load({
3734
4834
  compare: ['M4 4h7', 'M4 12h7', 'M6.5 1.5 4 4l2.5 2.5', 'M9.5 9.5 12 12l-2.5 2.5'],
3735
4835
  stash: ['M2.5 3.5h11V6h-11z', 'M3.5 6v6.5h9V6', 'M6.5 8.5h3'],
3736
4836
  tag: ['M2.5 7.5 7.5 2.5h6v6l-5 5z', 'M10.5 5.5h.01'],
3737
- settings: ['M8 6.4a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2Z', 'M8 1.8v1.5', 'M8 12.7v1.5', 'M1.8 8h1.5', 'M12.7 8h1.5', 'M3.6 3.6l1.1 1.1', 'M11.3 11.3l1.1 1.1', 'M12.4 3.6l-1.1 1.1', 'M4.7 11.3l-1.1 1.1'],
4837
+ // A real gear: the toothed contour plus the centre hole, borrowed from the
4838
+ // official product icon set (ui-primitives IconSettingsOutlineMedium, same
4839
+ // 16x16 grid and currentColor stroke) because the previous entry was a
4840
+ // centre dot with eight rays — a sunburst, not a settings control.
4841
+ settings: ['M13.0107 7.79377C12.9505 7.89401 12.9205 7.94413 12.9205 7.99951C12.9205 8.0549 12.9505 8.10502 13.0106 8.20528L13.9849 9.83006C14.045 9.93029 14.0751 9.9804 14.0751 10.0358C14.0751 10.0911 14.045 10.1413 13.9849 10.2415L13.0037 11.8777C12.9468 11.9726 12.9184 12.0201 12.8725 12.0461C12.8267 12.072 12.7713 12.072 12.6607 12.072H10.6704C10.5598 12.072 10.5045 12.072 10.4586 12.098C10.4128 12.1239 10.3843 12.1714 10.3274 12.2662L9.33825 13.9142C9.28133 14.009 9.25287 14.0564 9.20703 14.0823C9.16118 14.1083 9.10588 14.1083 8.99529 14.1083H7.00486C6.89426 14.1083 6.83896 14.1083 6.79312 14.0823C6.74727 14.0564 6.71881 14.009 6.6619 13.9142L5.67273 12.2662C5.61581 12.1714 5.58735 12.1239 5.54151 12.098C5.49566 12.072 5.44036 12.072 5.32977 12.072H3.33945C3.2288 12.072 3.17347 12.072 3.12761 12.0461C3.08176 12.0201 3.0533 11.9726 2.9964 11.8777L2.0152 10.2415C1.9551 10.1413 1.92505 10.0911 1.92505 10.0358C1.92505 9.9804 1.9551 9.93029 2.0152 9.83006L2.98951 8.20528C3.04963 8.10502 3.07969 8.0549 3.07969 7.99951C3.07968 7.94413 3.04961 7.89401 2.98946 7.79377L2.01529 6.17011C1.95514 6.06987 1.92507 6.01975 1.92507 5.96437C1.92506 5.90899 1.95512 5.85886 2.01524 5.7586L2.9964 4.1224C3.0533 4.0275 3.08176 3.98005 3.12761 3.95408C3.17347 3.92811 3.2288 3.92811 3.33945 3.92811H5.32977C5.44036 3.92811 5.49566 3.92811 5.54151 3.90216C5.58735 3.87621 5.61581 3.82879 5.67273 3.73397L6.6619 2.08599C6.71881 1.99116 6.74727 1.94375 6.79312 1.9178C6.83896 1.89185 6.89426 1.89185 7.00486 1.89185H8.99529C9.10588 1.89185 9.16118 1.89185 9.20703 1.9178C9.25287 1.94375 9.28133 1.99116 9.33825 2.08599L10.3274 3.73397C10.3843 3.82879 10.4128 3.87621 10.4586 3.90216C10.5045 3.92811 10.5598 3.92811 10.6704 3.92811H12.6607C12.7713 3.92811 12.8267 3.92811 12.8725 3.95408C12.9184 3.98005 12.9468 4.0275 13.0037 4.1224L13.9849 5.7586C14.045 5.85886 14.0751 5.90899 14.0751 5.96437C14.0751 6.01975 14.045 6.06987 13.9849 6.17011L13.0107 7.79377Z', 'M8 9.75012C8.9665 9.75012 9.75 8.96662 9.75 8.00012C9.75 7.03362 8.9665 6.25012 8 6.25012C7.0335 6.25012 6.25 7.03362 6.25 8.00012C6.25 8.96662 7.0335 9.75012 8 9.75012Z'],
3738
4842
  more: ['M3.6 8h0.01', 'M8 8h0.01', 'M12.4 8h0.01'],
3739
4843
  eye: ['M1.5 8S4.1 3.8 8 3.8 14.5 8 14.5 8 11.9 12.2 8 12.2 1.5 8 1.5 8Z', 'M8 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z'],
3740
4844
  eyeOff: ['M1.6 8S4.2 4 8 4c1 0 1.9.2 2.7.6', 'M13 5.6c.9 1.1 1.5 2.4 1.5 2.4S11.9 12 8 12c-.9 0-1.7-.2-2.4-.5', 'M2.6 2.6l10.8 10.8'],
@@ -3743,6 +4847,11 @@ window.__ModuleLoader__.load({
3743
4847
  grip: ['M5.6 4.6h4.8', 'M5.6 8h4.8', 'M5.6 11.4h4.8'],
3744
4848
  filter: ['M2 3.6h12l-4.6 5.2v4.2l-2.8-1.4V8.8z'],
3745
4849
  check: ['M3.2 8.4 6.4 11.6 12.8 4.6'],
4850
+ // dock actions (official right sidebar): lift a panel out, drop it back into
4851
+ // the dock, split the pane, and the full-screen pair reuses expand/collapse.
4852
+ float: ['M2.5 6.5v7h7v-3', 'M6 10 13.2 2.8', 'M9.4 2.8h3.8v3.8'],
4853
+ dock: ['M2.5 3h11v10h-11z', 'M8 5.2v5.6', 'M5.4 8.2 8 10.8l2.6-2.6'],
4854
+ split: ['M2.5 3h11v10h-11z', 'M8 3v10'],
3746
4855
  expand: ['M4.5 6.2 8 9.7l3.5-3.5', 'M3 12.4h10'],
3747
4856
  collapse: ['M4.5 9.8 8 6.3l3.5 3.5', 'M3 3.6h10'],
3748
4857
  }
@@ -3821,6 +4930,9 @@ window.__ModuleLoader__.load({
3821
4930
  + (item.danger === true ? ' dig-menu-danger' : '')
3822
4931
  + (item.disabled === true ? ' dig-menu-disabled' : '')
3823
4932
  + (item.active === true ? ' dig-menu-item-active' : ''),
4933
+ // The row keeps its id on the DOM so a `⋯ 更多` entry is addressable
4934
+ // (the rail's own buttons do the same; probes click through either).
4935
+ 'data-action': item.id === undefined ? undefined : item.id,
3824
4936
  disabled: item.disabled === true,
3825
4937
  onClick: (event) => { closeRef.current(); item.run(event) },
3826
4938
  },
@@ -3832,6 +4944,264 @@ window.__ModuleLoader__.load({
3832
4944
  list is drag-sortable and arrow-sortable (the arrows also work with a
3833
4945
  keyboard); visibility is a per-action toggle, not a delete, so a hidden
3834
4946
  action keeps its position. */
4947
+ /* The plugin's settings card. It is the SAME surface on the plugin's detail
4948
+ page (slot `plugins.bundle.config`, keyed by the PACKAGE name) and in
4949
+ Settings → Plugins (slot `settings.plugin.item`, keyed by the settings
4950
+ namespace = the loader row id), and it is the only editor when the host
4951
+ serves the row Config — the in-panel editor is not reachable there.
4952
+
4953
+ A host without that service still renders the card (the seat exists), so it
4954
+ says so instead of pretending to have settings. */
4955
+ function RailSettingsCard(props) {
4956
+ const t = props.t
4957
+ const [tick, setTick] = useState(0)
4958
+ const [pending, setPending] = useState({})
4959
+ const [note, setNote] = useState('')
4960
+ /* The three AI-commit settings are free text, so they are drafted locally
4961
+ and written on blur / Enter — one write per edit, never one per keystroke. */
4962
+ const [textDraft, setTextDraft] = useState({})
4963
+ /* The user's latest intent per field, written SYNCHRONOUSLY. React state is
4964
+ not enough: two clicks inside one frame would both read the pre-click
4965
+ snapshot, so the second click recomputed the same target and the toggle
4966
+ was lost (measured: a fast double click on one switch netted one step
4967
+ instead of returning to where it started). */
4968
+ const intent = useRef({})
4969
+ const timers = useRef(new Set())
4970
+ const dirty = useRef(new Map())
4971
+ const pendingFlush = useRef(null)
4972
+ const bump = () => setTick((value) => value + 1)
4973
+ /* Writes the Host answers ok for can still be lost when several overlap its
4974
+ persist cycle: measured on this surface, 16 switches toggled 55ms apart
4975
+ left 3 fields at their old value in the row Config while every request
4976
+ answered 200, and the same 16 at 500ms apart all landed. So a switch is
4977
+ only "done" when the accepted document agrees — checked once after the
4978
+ burst settles, re-issued once, and otherwise rolled back and reported. */
4979
+ const reconcile = (field, want, attempt) => {
4980
+ const timer = setTimeout(() => {
4981
+ timers.current.delete(timer)
4982
+ let hostValue
4983
+ try {
4984
+ const snapshot = scope === null || scope === undefined ? null : scope.getSnapshot()
4985
+ hostValue = snapshot !== null && snapshot !== undefined && snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value[field] : undefined
4986
+ } catch (error) { hostValue = undefined }
4987
+ if (hostValue === want) return
4988
+ if (attempt === 0) {
4989
+ const again = writeRailField(field, want, scope)
4990
+ if (again.sent === true) {
4991
+ if (again.settled !== undefined) void again.settled.then(() => { reconcile(field, want, 1) })
4992
+ else reconcile(field, want, 1)
4993
+ return
4994
+ }
4995
+ }
4996
+ delete intent.current[field]
4997
+ setPending((now) => { const copy = Object.assign({}, now); delete copy[field]; return copy })
4998
+ setNote(t('settings.rail.writeFailed'))
4999
+ }, 600)
5000
+ timers.current.add(timer)
5001
+ }
5002
+ const rollbackFields = (fields) => {
5003
+ for (const field of fields) {
5004
+ delete intent.current[field]
5005
+ setPending((now) => { const copy = Object.assign({}, now); delete copy[field]; return copy })
5006
+ }
5007
+ }
5008
+ /* One atomic mutation for the whole burst. Sixteen separate writes 55ms
5009
+ apart lost 3 fields inside the Host's persist cycle while every request
5010
+ answered 200 (the same sixteen 500ms apart all landed), so the fix is to
5011
+ stop producing a burst: collect the fields the user touched and submit
5012
+ them together, at most one mutation per window. */
5013
+ const commitTextField = (field, max) => {
5014
+ const accepted = values[field] === undefined || values[field] === null ? '' : String(values[field])
5015
+ const shown = Object.prototype.hasOwnProperty.call(textDraft, field) ? textDraft[field] : accepted
5016
+ const wanted = shown.slice(0, max)
5017
+ if (wanted === accepted) return
5018
+ const outcome = writeRailFields([{ op: 'set', path: [field], value: wanted }], scope)
5019
+ if (outcome.sent !== true) {
5020
+ setNote(t(outcome.reason === 'read-only' ? 'settings.rail.readonly' : 'settings.rail.writeFailed'))
5021
+ return
5022
+ }
5023
+ if (outcome.settled === undefined) return
5024
+ void outcome.settled.then((ok) => {
5025
+ if (ok !== true) { setNote(t('settings.rail.writeFailed')); return }
5026
+ /* The accepted document now carries it: drop the draft so the control
5027
+ shows the Host's own value again. */
5028
+ setTextDraft((now) => { const copy = Object.assign({}, now); delete copy[field]; return copy })
5029
+ })
5030
+ }
5031
+ const textRow = (field, labelKey, hintKey, max, multiline) => {
5032
+ const accepted = values[field] === undefined || values[field] === null ? '' : String(values[field])
5033
+ const shown = Object.prototype.hasOwnProperty.call(textDraft, field) ? textDraft[field] : accepted
5034
+ const id = 'dig-' + field
5035
+ const common = {
5036
+ id: id, className: 'dig-input dig-settings-input', value: shown, spellCheck: false,
5037
+ disabled: readOnly === true, placeholder: t(hintKey), 'aria-labelledby': id + '-label',
5038
+ onChange: (event) => setTextDraft((now) => Object.assign({}, now, { [field]: event.target.value })),
5039
+ onBlur: () => commitTextField(field, max),
5040
+ }
5041
+ if (multiline === true) common.onKeyDown = (event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) commitTextField(field, max) }
5042
+ else common.onKeyDown = (event) => { if (event.key === 'Enter') commitTextField(field, max) }
5043
+ return E('div', { className: 'dig-settings-text', key: field },
5044
+ E('label', { id: id + '-label', className: 'dig-settings-text-label', htmlFor: id }, t(labelKey)),
5045
+ multiline === true ? E('textarea', Object.assign({ rows: 3 }, common)) : E('input', Object.assign({ type: 'text' }, common)),
5046
+ shown.length >= max ? E('div', { className: 'dig-settings-error' }, fill(t('settings.commit.tooLong'), { n: String(max) })) : null)
5047
+ }
5048
+ const flush = () => {
5049
+ pendingFlush.current = null
5050
+ /* The pending TARGET per field, not "whatever the render-time intent
5051
+ happens to hold now": the accepted document can already agree with the
5052
+ user's new value, which clears the optimistic intent before this flush
5053
+ runs — reading it here would then submit the opposite of the click and
5054
+ write the switch back to the wrong state. */
5055
+ const targets = new Map(dirty.current)
5056
+ dirty.current.clear()
5057
+ const fields = Array.from(targets.keys())
5058
+ if (fields.length === 0) return
5059
+ const ops = fields.map((field) => ({ op: 'set', path: [field], value: targets.get(field) === true }))
5060
+ const outcome = writeRailFields(ops, scope)
5061
+ if (outcome.sent !== true) {
5062
+ rollbackFields(fields)
5063
+ setNote(t(outcome.reason === 'read-only' ? 'settings.rail.readonly' : 'settings.rail.writeFailed'))
5064
+ return
5065
+ }
5066
+ if (outcome.settled === undefined) return
5067
+ void outcome.settled.then((accepted) => {
5068
+ if (accepted !== true) {
5069
+ rollbackFields(fields)
5070
+ setNote(t('settings.rail.writeFailed'))
5071
+ return
5072
+ }
5073
+ for (const field of fields) reconcile(field, targets.get(field) === true, 0)
5074
+ })
5075
+ }
5076
+ const scheduleFlush = () => {
5077
+ if (pendingFlush.current !== null) return
5078
+ pendingFlush.current = setTimeout(() => { pendingFlush.current = null; flush() }, FLUSH_MS)
5079
+ timers.current.add(pendingFlush.current)
5080
+ }
5081
+ /* Two handles, one document: the slot hands the card the framework's own
5082
+ form for this entry (`props.scope`), and our ctx.configForms handle is the
5083
+ fallback — both address the same namespace, so a write through either is
5084
+ the same row Config write. */
5085
+ const scope = props.scope !== undefined && props.scope !== null && typeof props.scope.getSnapshot === 'function'
5086
+ ? props.scope
5087
+ : railForm
5088
+ useEffect(() => {
5089
+ const offRail = subscribeRailConfig(bump)
5090
+ let offScope
5091
+ if (scope !== null && scope !== undefined && typeof scope.subscribe === 'function') {
5092
+ const unsubscribe = scope.subscribe(bump)
5093
+ if (typeof unsubscribe === 'function') offScope = unsubscribe
5094
+ }
5095
+ return () => {
5096
+ offRail()
5097
+ if (offScope !== undefined) offScope()
5098
+ for (const timer of Array.from(timers.current)) clearTimeout(timer)
5099
+ timers.current.clear()
5100
+ }
5101
+ }, [])
5102
+ void tick
5103
+ const head = E('div', { className: 'dig-settings-head' },
5104
+ E('div', { className: 'dig-settings-title' }, t('settings.rail.title')),
5105
+ E('div', { className: 'dig-settings-hint' }, t('settings.rail.hint')))
5106
+ let snapshot = null
5107
+ try { snapshot = scope === null || scope === undefined ? null : scope.getSnapshot() } catch (error) { void error }
5108
+ const status = snapshot === null || snapshot === undefined || typeof snapshot.status !== 'string' ? 'absent' : snapshot.status
5109
+ const values = snapshot !== null && snapshot !== undefined && snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : null
5110
+ // Drop optimistic values the host has since confirmed (post-commit effect).
5111
+ useEffect(() => {
5112
+ setPending((current) => {
5113
+ const keys = Object.keys(current)
5114
+ if (keys.length === 0 || values === null) return current
5115
+ const next = {}
5116
+ let changed = false
5117
+ for (const key of keys) {
5118
+ if (values[key] === current[key]) {
5119
+ changed = true
5120
+ /* The accepted document agrees: this intent is no longer pending.
5121
+ Deleting twice is harmless, so a re-invoked updater stays safe. */
5122
+ delete intent.current[key]
5123
+ continue
5124
+ }
5125
+ next[key] = current[key]
5126
+ }
5127
+ return changed ? next : current
5128
+ })
5129
+ })
5130
+ // Switches exist only where they can actually write: a host that does not
5131
+ // serve this entry's settings gets a sentence instead of dead controls.
5132
+ if (status !== 'ready' || values === null) {
5133
+ return E('div', { className: 'dig-settings', 'data-settings-status': status }, head,
5134
+ E('div', { className: 'dig-settings-note' }, t('settings.rail.unavailable')))
5135
+ }
5136
+ const config = railConfigOfValues(values)
5137
+ /* A toggle is a host round trip, so the switch would snap back to the old
5138
+ value for a moment and read as "stuck". The optimistic value wins until
5139
+ the accepted document agrees with it (then it is dropped). */
5140
+ const shown = (spec) => {
5141
+ const field = railFieldOf(spec.id)
5142
+ if (Object.prototype.hasOwnProperty.call(intent.current, field)) return intent.current[field]
5143
+ return config.hidden.indexOf(spec.id) < 0
5144
+ }
5145
+ /* A host that serves the document but refuses writes gets disabled switches
5146
+ and a sentence, not controls that silently do nothing. */
5147
+ const readOnly = snapshot.writable !== true
5148
+ return E('div', { className: 'dig-settings', 'data-settings-status': status, 'data-settings-readonly': readOnly === true ? 'true' : 'false' }, head,
5149
+ E('div', { className: readOnly === true ? 'dig-settings-note' : 'dig-settings-note dig-settings-note-hidden', 'data-settings-readonly-note': '' }, t('settings.rail.readonly')),
5150
+ note === '' ? null : E('div', { className: 'dig-settings-error', 'data-settings-error': '' }, note),
5151
+ E('div', { className: 'dig-settings-list' }, RAIL_SPECS.map((spec) => {
5152
+ const inputId = 'dig-rail-' + spec.id
5153
+ const visible = shown(spec)
5154
+ /* A row, not a <label>: a label that both carries htmlFor and wraps its
5155
+ own input toggles twice per click (once for the input, once for the
5156
+ label activation), which reads as a stuck switch and writes twice. */
5157
+ const toggle = (event) => {
5158
+ if (readOnly === true) { setNote(t('settings.rail.readonly')); return }
5159
+ const field = railFieldOf(spec.id)
5160
+ /* The control's own answer when the browser gives it to us: a click that
5161
+ lands while the previous one is still rendering would otherwise
5162
+ compose from the pre-click snapshot and be lost (measured: a fast
5163
+ double click netted one step instead of returning to its start). The
5164
+ row's label span is not a control, so it falls back to the
5165
+ synchronous intent — never to the render snapshot alone. */
5166
+ const fromControl = event !== undefined && event !== null && event.target !== null
5167
+ && event.target !== undefined && typeof event.target.checked === 'boolean'
5168
+ ? event.target.checked
5169
+ : null
5170
+ /* The control already holds the state the user just asked for, so it is
5171
+ the TARGET — inverting it here would write the opposite of the click.
5172
+ Without a control (the row's label span) the target is the inverse of
5173
+ the current intent, falling back to the accepted document. */
5174
+ const wanted = fromControl !== null
5175
+ ? fromControl
5176
+ : (Object.prototype.hasOwnProperty.call(intent.current, field)
5177
+ ? intent.current[field] !== true
5178
+ : config.hidden.indexOf(spec.id) < 0 !== true)
5179
+ intent.current[field] = wanted
5180
+ dirty.current.set(field, wanted)
5181
+ setPending((now) => Object.assign({}, now, { [field]: wanted }))
5182
+ setNote('')
5183
+ bump()
5184
+ scheduleFlush()
5185
+ }
5186
+ return E('div', { key: spec.id, className: 'dig-settings-row', 'data-rail-action': spec.id },
5187
+ E('input', {
5188
+ id: inputId,
5189
+ type: 'checkbox',
5190
+ className: 'dig-settings-check',
5191
+ 'aria-labelledby': inputId + '-label',
5192
+ checked: visible,
5193
+ disabled: readOnly === true,
5194
+ onChange: toggle,
5195
+ }),
5196
+ E('span', { className: 'dig-settings-glyph' }, E(Icon, { name: spec.icon, size: 13 })),
5197
+ E('span', { id: inputId + '-label', className: 'dig-settings-label', onClick: toggle }, t(spec.key)))
5198
+ })),
5199
+ E('div', { className: 'dig-settings-sub' }, t('settings.commit.title')),
5200
+ textRow('commitModel', 'settings.commit.model', 'settings.commit.modelHint', 120, false),
5201
+ textRow('commitReasoning', 'settings.commit.reasoning', 'settings.commit.reasoningHint', 60, false),
5202
+ textRow('commitPrompt', 'settings.commit.prompt', 'settings.commit.promptHint', 2000, true))
5203
+ }
5204
+
3835
5205
  function RailSettings(props) {
3836
5206
  const t = props.t
3837
5207
  const [dragId, setDragId] = useState(null)
@@ -3960,6 +5330,77 @@ window.__ModuleLoader__.load({
3960
5330
  }, item.label)))
3961
5331
  }
3962
5332
 
5333
+ /* A draggable divider between two panes. The pane it resizes is the sibling
5334
+ it is rendered against, and its size is MEASURED at pointerdown — so a pane
5335
+ still living on its stylesheet default (a percentage cap, or a diff pane
5336
+ sized by its own content) drags from exactly where it looks. Pointer capture
5337
+ keeps the drag alive once the pointer leaves the 8px hit area, and
5338
+ touch-action:none is what stops a phone from scrolling the panel instead of
5339
+ resizing it. Arrow keys nudge (Shift = 4x), double-click resets, and the
5340
+ whole thing is focusable so the divider is reachable without a mouse. */
5341
+ function Gutter(props) {
5342
+ const dragRef = useRef(null)
5343
+ const targetOf = (node) => (props.side === 'next' ? node.nextElementSibling : node.previousElementSibling)
5344
+ const extentOf = (node) => {
5345
+ if (node === null || typeof node.getBoundingClientRect !== 'function') return 0
5346
+ const rect = node.getBoundingClientRect()
5347
+ return props.vertical === true ? rect.width : rect.height
5348
+ }
5349
+ const sign = props.side === 'next' ? -1 : 1
5350
+ const onPointerDown = (event) => {
5351
+ if (event.button !== undefined && event.button !== 0) return
5352
+ dragRef.current = {
5353
+ id: event.pointerId,
5354
+ at: props.vertical === true ? event.clientX : event.clientY,
5355
+ size: extentOf(targetOf(event.currentTarget)),
5356
+ }
5357
+ if (typeof event.currentTarget.setPointerCapture === 'function') event.currentTarget.setPointerCapture(event.pointerId)
5358
+ if (typeof event.currentTarget.focus === 'function') event.currentTarget.focus()
5359
+ props.onStart()
5360
+ }
5361
+ const onPointerMove = (event) => {
5362
+ const drag = dragRef.current
5363
+ if (drag === null || drag.id !== event.pointerId) return
5364
+ const at = props.vertical === true ? event.clientX : event.clientY
5365
+ props.onDrag(drag.size + sign * (at - drag.at))
5366
+ }
5367
+ const onPointerUp = () => {
5368
+ if (dragRef.current === null) return
5369
+ dragRef.current = null
5370
+ props.onEnd()
5371
+ }
5372
+ const onKeyDown = (event) => {
5373
+ const step = event.shiftKey === true ? 32 : 8
5374
+ const axis = props.vertical === true
5375
+ ? (event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0)
5376
+ : (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)
5377
+ if (axis === 0) return
5378
+ event.preventDefault()
5379
+ props.onDrag(extentOf(targetOf(event.currentTarget)) + sign * axis * step)
5380
+ props.onEnd()
5381
+ }
5382
+ const label = fill(props.t('pane.resize'), { name: props.t(props.nameKey) })
5383
+ return E('div', {
5384
+ className: 'dig-gutter ' + (props.vertical === true ? 'dig-gutter-v' : 'dig-gutter-h')
5385
+ + (props.active === true ? ' dig-gutter-active' : ''),
5386
+ role: 'separator',
5387
+ tabIndex: 0,
5388
+ 'aria-orientation': props.vertical === true ? 'vertical' : 'horizontal',
5389
+ 'aria-label': label,
5390
+ 'aria-valuenow': props.value,
5391
+ 'aria-valuemin': props.min,
5392
+ 'aria-valuemax': props.max,
5393
+ title: label,
5394
+ onPointerDown: onPointerDown,
5395
+ onPointerMove: onPointerMove,
5396
+ onPointerUp: onPointerUp,
5397
+ onPointerCancel: onPointerUp,
5398
+ onLostPointerCapture: onPointerUp,
5399
+ onDoubleClick: () => props.onReset(),
5400
+ onKeyDown: onKeyDown,
5401
+ })
5402
+ }
5403
+
3963
5404
  function RepoSelect(props) {
3964
5405
  const t = props.t
3965
5406
  /* While the workspace is still being scanned the panel used to print "no
@@ -4189,6 +5630,12 @@ window.__ModuleLoader__.load({
4189
5630
  const [message, setMessage] = useState('')
4190
5631
  const [amend, setAmend] = useState(false)
4191
5632
  const [collapsed, setCollapsed] = useState(false)
5633
+ /* AI-written message (issue #6). The draft is kept so the write can be
5634
+ undone, and an existing draft is never overwritten without a word. */
5635
+ const [aiBusy, setAiBusy] = useState(false)
5636
+ const [aiNote, setAiNote] = useState(null)
5637
+ const [aiAsk, setAiAsk] = useState(false)
5638
+ const [aiUndo, setAiUndo] = useState(null)
4192
5639
  // 'flat' keeps git's own grouping (staged / changes / untracked); 'dir'
4193
5640
  // clusters each group's files under their folder, the way "Group by:
4194
5641
  // Directory" does in an IDE. Folders are addressed by <group>|<dir>, so a
@@ -4202,6 +5649,32 @@ window.__ModuleLoader__.load({
4202
5649
  const untracked = changes === null ? [] : changes.untracked
4203
5650
  const ignored = changes === null || changes.ignored === undefined ? [] : changes.ignored
4204
5651
  const total = conflicted.length + staged.length + unstaged.length + untracked.length
5652
+ const aiRun = async () => {
5653
+ setAiAsk(false)
5654
+ setAiBusy(true)
5655
+ setAiNote(null)
5656
+ try {
5657
+ const settings = props.commitSettings === undefined ? { model: '', reasoning: '', prompt: '' } : props.commitSettings
5658
+ const data = await props.request('commit-message', {
5659
+ cwd: props.cwd,
5660
+ sessionId: props.sessionId,
5661
+ model: settings.model,
5662
+ reasoningEffort: settings.reasoning,
5663
+ prompt: settings.prompt,
5664
+ })
5665
+ setAiUndo(message === '' ? null : message)
5666
+ setMessage(typeof data.message === 'string' ? data.message : '')
5667
+ /* Say what the model did NOT see, instead of quietly sending less. */
5668
+ const input = data.input === undefined ? {} : data.input
5669
+ const incomplete = input.truncated === true || (typeof input.dropped === 'number' && input.dropped > 0) || data.promptTruncated === true
5670
+ setAiNote(incomplete === true ? t('changes.aiNotice') : null)
5671
+ } catch (error) {
5672
+ const code = error !== null && error !== undefined && typeof error.code === 'string' ? error.code : 'unknown'
5673
+ setAiNote(t('changes.aiFailed') + ' [' + code + '] ' + String(error && error.message ? error.message : error))
5674
+ } finally {
5675
+ setAiBusy(false)
5676
+ }
5677
+ }
4205
5678
  const submit = (push) => {
4206
5679
  if (message.trim() === '' || props.busy === true) return false
4207
5680
  props.onCommit(message, amend, push === true)
@@ -4254,6 +5727,15 @@ window.__ModuleLoader__.load({
4254
5727
  key === 'staged' ? E('button', { type: 'button', className: 'dig-link', onClick: () => props.onUnstageAll() }, t('changes.unstageAll')) : null,
4255
5728
  key === 'unstaged' || key === 'untracked' ? E('button', { type: 'button', className: 'dig-link', onClick: () => props.onStageAll(entries) }, t('changes.stageAll')) : null),
4256
5729
  rowsOf(key, entries))
5730
+ const aiConfirmDialog = aiAsk !== true ? null : E(ConfirmDialog, {
5731
+ t: t,
5732
+ title: t('changes.aiOverwriteTitle'),
5733
+ text: t('changes.aiOverwriteText'),
5734
+ okLabel: t('confirm.ok'),
5735
+ cancelLabel: t('confirm.cancel'),
5736
+ onConfirm: () => { void aiRun() },
5737
+ onCancel: () => setAiAsk(false),
5738
+ })
4257
5739
  const composer = props.compact === true
4258
5740
  ? E('div', { className: 'dig-commit-box dig-commit-box-compact' },
4259
5741
  E('input', {
@@ -4264,11 +5746,18 @@ window.__ModuleLoader__.load({
4264
5746
  if (event.key === 'Enter') { event.preventDefault(); submit() }
4265
5747
  },
4266
5748
  }),
5749
+ E('button', {
5750
+ type: 'button', className: 'dig-btn dig-btn-small dig-btn-ai',
5751
+ disabled: aiBusy === true || props.busy === true,
5752
+ title: t('changes.aiQuota'),
5753
+ onClick: () => { if (message.trim() === '') void aiRun(); else setAiAsk(true) },
5754
+ }, aiBusy === true ? t('changes.aiBusy') : t('changes.aiWrite')),
4267
5755
  E('button', {
4268
5756
  type: 'button', className: 'dig-btn dig-btn-primary dig-btn-small',
4269
5757
  disabled: message.trim() === '' || props.busy === true,
4270
5758
  onClick: () => { submit(false) },
4271
- }, t('changes.commit')))
5759
+ }, t('changes.commit')),
5760
+ E('span', { className: 'dig-commit-ai-hint dig-commit-ai-hint-block' }, t('changes.aiQuota')))
4272
5761
  : E('div', { className: 'dig-commit-box' },
4273
5762
  E('textarea', {
4274
5763
  className: 'dig-textarea', value: message, spellCheck: false,
@@ -4278,6 +5767,18 @@ window.__ModuleLoader__.load({
4278
5767
  if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); submit() }
4279
5768
  },
4280
5769
  }),
5770
+ E('div', { className: 'dig-commit-ai' },
5771
+ E('button', {
5772
+ type: 'button', className: 'dig-btn dig-btn-small dig-btn-ai',
5773
+ disabled: aiBusy === true || props.busy === true,
5774
+ onClick: () => { if (message.trim() === '') void aiRun(); else setAiAsk(true) },
5775
+ }, aiBusy === true ? t('changes.aiBusy') : t('changes.aiWrite')),
5776
+ E('span', { className: 'dig-commit-ai-hint' }, t('changes.aiQuota')),
5777
+ aiUndo === null ? null : E('button', {
5778
+ type: 'button', className: 'dig-link',
5779
+ onClick: () => { setMessage(aiUndo); setAiUndo(null); setAiNote(null) },
5780
+ }, t('changes.aiUndo'))),
5781
+ aiNote === null ? null : E('div', { className: 'dig-commit-ai-note', 'data-ai-note': '' }, aiNote),
4281
5782
  E('div', { className: 'dig-commit-actions' },
4282
5783
  E('label', { className: 'dig-check' },
4283
5784
  E('input', { type: 'checkbox', checked: amend, onChange: (event) => setAmend(event.target.checked) }),
@@ -4327,6 +5828,7 @@ window.__ModuleLoader__.load({
4327
5828
  onClick: () => props.onToggleIgnored(),
4328
5829
  }, E(Icon, { name: props.showIgnored === true ? 'eye' : 'eyeOff', size: 13 })))
4329
5830
  return E('div', { className: 'dig-changes' },
5831
+ aiConfirmDialog,
4330
5832
  head,
4331
5833
  E('div', { className: 'dig-changes-body' },
4332
5834
  E('div', { className: 'dig-changes-list' },
@@ -4572,7 +6074,21 @@ window.__ModuleLoader__.load({
4572
6074
  const [note, setNote] = useState(null)
4573
6075
  const [busy, setBusy] = useState(false)
4574
6076
  const [view, setView] = useState('history')
4575
- const [treeOpen, setTreeOpen] = useState(true)
6077
+ /* Pane sizes are remembered per surface (see the pane sizing core). The ref
6078
+ is the value the pointer handlers write through: a drag updates state for
6079
+ the preview and the ref for the single write that pointerup performs. */
6080
+ const [panes, setPanes] = useState(readPanes)
6081
+ const panesRef = useRef(panes)
6082
+ const [treeOpen, setTreeOpen] = useState(panes.treeOpen)
6083
+ const [paneDragging, setPaneDragging] = useState(null)
6084
+ const [paneSizes, setPaneSizes] = useState({})
6085
+ /* The float pane this panel is drawn in, or null while it is docked. The
6086
+ dock face exposes operations only, so the state comes from the float
6087
+ layer's own marker around this panel — the same marker carries the pane
6088
+ id dock() needs to put it back. */
6089
+ const [floatPaneId, setFloatPaneId] = useState(null)
6090
+ const bodyRef = useRef(null)
6091
+ const [bodyBox, setBodyBox] = useState({ width: 0, height: 0 })
4576
6092
  const [detail, setDetail] = useState(null)
4577
6093
  const [selectedHash, setSelectedHash] = useState(null)
4578
6094
  const [selectedPath, setSelectedPath] = useState(null)
@@ -4584,6 +6100,7 @@ window.__ModuleLoader__.load({
4584
6100
  const [favTick, setFavTick] = useState(0)
4585
6101
  const [pathFilter, setPathFilter] = useState('')
4586
6102
  const [railConfig, setRailConfig] = useState(readRailConfig)
6103
+ const [railTick, setRailTick] = useState(0)
4587
6104
  const [railSettings, setRailSettings] = useState(false)
4588
6105
  const [railBox, setRailBox] = useState({ width: 0, height: 0 })
4589
6106
  const [toasts, setToasts] = useState([])
@@ -4619,9 +6136,157 @@ window.__ModuleLoader__.load({
4619
6136
  // its own — v0.1.3 already made that mistake with a height < 330 test (a
4620
6137
  // 1500x300 workbench came out single-column) and v0.3.5 still let
4621
6138
  // height < 200 flip a 1320x180 workbench into the right-sidebar chrome.
6139
+ /* Where the gear goes: the plugin's own settings page when this host has
6140
+ both the row Config and the navigation to it, the in-panel editor
6141
+ otherwise (see RAIL_NS / railForm). */
6142
+ const railSettingsTarget = () => (railForm === null || props.openSettings === null || props.openSettings === undefined ? 'panel' : 'page')
6143
+ const openSettingsPage = () => {
6144
+ if (props.openSettings === null || props.openSettings === undefined) return
6145
+ props.openSettings()
6146
+ }
6147
+
4622
6148
  const compact = size.width > 0 && size.width < COMPACT_MAX_WIDTH
4623
6149
  const columns = !compact && size.width >= 600 && size.width >= size.height * 1.15
4624
6150
 
6151
+ /* ---------- draggable panes (issue #5) ---------- */
6152
+
6153
+ // Like the rail, the panes measure the box that actually holds them instead
6154
+ // of trusting the root probe: the column chrome keeps a vertical rail
6155
+ // beside the body, and the header sits above it.
6156
+ useEffect(() => {
6157
+ const element = bodyRef.current
6158
+ if (element === null) { setBodyBox({ width: 0, height: 0 }); return undefined }
6159
+ const measure = () => {
6160
+ const rect = element.getBoundingClientRect()
6161
+ setBodyBox({ width: Math.round(rect.width), height: Math.round(rect.height) })
6162
+ }
6163
+ measure()
6164
+ if (typeof ResizeObserver === 'function') {
6165
+ const observer = new ResizeObserver(measure)
6166
+ observer.observe(element)
6167
+ return () => observer.disconnect()
6168
+ }
6169
+ window.addEventListener('resize', measure)
6170
+ return () => window.removeEventListener('resize', measure)
6171
+ }, [])
6172
+
6173
+ const chrome = compact ? 'compact' : columns ? 'columns' : 'stack'
6174
+ const measured = bodyBox.width > 0 || bodyBox.height > 0
6175
+ const paneBox = measured ? bodyBox : size
6176
+ /* The column chrome's dividers are vertical LINES, and the panes they move
6177
+ are sized by width; the stacked chromes move heights. One name for that
6178
+ switch: it decides the divider's orientation, the inline size, the ratio
6179
+ reference and the drag cursor, and getting it half-right is how a height
6180
+ drag ends up writing a width. */
6181
+ const paneByWidth = PANE_HEIGHT_CHROME[chrome] !== true
6182
+ /* What the panes actually measure right now. The clamp needs the real box of
6183
+ the pane it is NOT moving: a content-sized pane is usually shorter than
6184
+ its own percentage cap, and reserving the cap would block the drag (or
6185
+ snap a stored size back on the next render). The markers are data
6186
+ attributes rather than class sniffing so the diff pane, nested inside the
6187
+ middle pane, is found the same way. */
6188
+ const measurePanes = () => {
6189
+ const found = {}
6190
+ const element = bodyRef.current
6191
+ if (element === null) return found
6192
+ for (const node of element.querySelectorAll('[data-pane]')) {
6193
+ const rect = node.getBoundingClientRect()
6194
+ found[node.getAttribute('data-pane')] = Math.round(paneByWidth ? rect.width : rect.height)
6195
+ }
6196
+ return found
6197
+ }
6198
+ /* Kept in state, not in a ref: a pane's box changes without the panel
6199
+ re-rendering (content arrives, a dock resizes), and the render that
6200
+ re-clamps a stored size has to see the same numbers the drag did — a drag
6201
+ allowed further than the render allows snaps back under the cursor. The
6202
+ bail-out comparison is what keeps this from looping. */
6203
+ useEffect(() => {
6204
+ const found = measurePanes()
6205
+ setPaneSizes((current) => (samePaneSizes(current, found) ? current : found))
6206
+ })
6207
+ useEffect(() => {
6208
+ const node = hostRef.current
6209
+ const holder = node === null || typeof node.closest !== 'function' ? null : node.closest('[data-dockkit-float]')
6210
+ const next = holder === null ? null : holder.getAttribute('data-dockkit-float')
6211
+ setFloatPaneId((current) => (current === next ? current : next))
6212
+ })
6213
+ const dock = props.dock === undefined ? null : props.dock
6214
+ const dockState = dockActionState(dock, floatPaneId)
6215
+ const paneLayout = paneGeometry(chrome, paneBox, panes, paneSizes)
6216
+
6217
+ const commitPanes = (next) => {
6218
+ panesRef.current = next
6219
+ setPanes(next)
6220
+ }
6221
+
6222
+ /* The gutter hands back the px it wants; the clamp lives here, and what
6223
+ gets stored is the ratio that reproduces it on this container. The live
6224
+ measurement is pushed into state alongside the new ratio, so the render
6225
+ that follows this gesture clamps with the very same box. */
6226
+ const dragPane = (key, rawPx) => {
6227
+ const live = measurePanes()
6228
+ setPaneSizes((current) => (samePaneSizes(current, live) ? current : live))
6229
+ const layout = paneGeometry(chrome, paneBox, panesRef.current, live)
6230
+ const limit = layout.limits[key]
6231
+ const reference = paneByWidth ? paneBox.width : paneBox.height
6232
+ if (limit === undefined || !(reference > 0)) return
6233
+ const px = Math.round(Math.min(Math.max(rawPx, limit.min), limit.max))
6234
+ commitPanes(withPaneRatio(panesRef.current, layout.bucket, key, px / reference))
6235
+ }
6236
+
6237
+ /* One write per gesture (pointerup / keyboard nudge / double-click), not one
6238
+ per pointermove. */
6239
+ const endPaneDrag = () => {
6240
+ setPaneDragging(null)
6241
+ writePanes(panesRef.current)
6242
+ }
6243
+
6244
+ const resetPane = (key) => {
6245
+ commitPanes(withPaneRatio(panesRef.current, paneLayout.bucket, key, null))
6246
+ writePanes(panesRef.current)
6247
+ }
6248
+
6249
+ const foldTree = (open) => {
6250
+ setTreeOpen(open)
6251
+ commitPanes({ treeOpen: open, panes: panesRef.current.panes })
6252
+ writePanes(panesRef.current)
6253
+ }
6254
+
6255
+ /* Only a pane the user actually dragged gets an inline size; everything
6256
+ else keeps the stylesheet default (200px tree, 290px changes, the
6257
+ percentage caps) — which is also what a double-click returns to. */
6258
+ const paneStyle = (key) => {
6259
+ const px = paneLayout.overrides[key]
6260
+ if (px === undefined) return null
6261
+ return paneByWidth ? { width: px + 'px', maxWidth: px + 'px' } : { height: px + 'px', maxHeight: px + 'px' }
6262
+ }
6263
+
6264
+ /* A divider is a control, so it only exists when it can actually move
6265
+ something: no pane of that key in this chrome, or a window with no travel
6266
+ at all, means no element — and therefore no tab stop either (v0.8.0 shipped
6267
+ a 0x0 focusable separator in the columns chrome that way). */
6268
+ const gutter = (key, side) => {
6269
+ const limit = paneLayout.limits[key]
6270
+ if (limit === undefined || limit.max <= limit.min) return null
6271
+ return E(Gutter, {
6272
+ key: 'gutter-' + key,
6273
+ t: t,
6274
+ nameKey: PANE_NAME_KEYS[key],
6275
+ vertical: paneByWidth,
6276
+ side: side,
6277
+ active: paneDragging === key,
6278
+ // Now is clamped into [min, max]: an aria window the panel contradicts is
6279
+ // worse than no window at all.
6280
+ value: Math.min(Math.max(Math.round(paneLayout.effective[key] === undefined ? 0 : paneLayout.effective[key]), limit.min), limit.max),
6281
+ min: limit.min,
6282
+ max: limit.max,
6283
+ onStart: () => setPaneDragging(key),
6284
+ onDrag: (px) => dragPane(key, px),
6285
+ onEnd: endPaneDrag,
6286
+ onReset: () => resetPane(key),
6287
+ })
6288
+ }
6289
+
4625
6290
  // The action rail measures ITSELF, not the panel: how many buttons fit is a
4626
6291
  // function of the strip that holds them (a 1200x300 workbench and a 300x900
4627
6292
  // sidebar disagree about that). railBox keeps the last measured span; until
@@ -5037,8 +6702,10 @@ window.__ModuleLoader__.load({
5037
6702
  E('span', { className: 'dig-banner-text' }, note),
5038
6703
  E('button', { type: 'button', className: 'dig-link', onClick: () => setNote(null) }, t('error.dismiss')))
5039
6704
 
6705
+ const commitSettings = readCommitSettings()
5040
6706
  const changesPane = E(ChangesPanel, {
5041
6707
  t: t, summary: summary, busy: busy, compact: compact, hideHeader: compact,
6708
+ request: request, cwd: cwd, sessionId: sessionId, commitSettings: commitSettings,
5042
6709
  showIgnored: showIgnored,
5043
6710
  onToggleIgnored: () => setShowIgnored((value) => !value),
5044
6711
  // Commit-and-push carries the confirm flag with it: the button already
@@ -5074,7 +6741,7 @@ window.__ModuleLoader__.load({
5074
6741
  onLoadMore: () => { void guard(() => loadCommits(commits.length)) },
5075
6742
  })
5076
6743
 
5077
- const diffPane = patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane' },
6744
+ const diffPane = patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane', 'data-pane': 'diff', style: paneStyle('diff') },
5078
6745
  E('div', { className: 'dig-diff-head' },
5079
6746
  E('span', { className: 'dig-mono dig-diff-path' }, selectedPath === null ? '' : selectedPath),
5080
6747
  E('button', { type: 'button', className: 'dig-icon-btn', onClick: () => { setPatch(''); setSelectedPath(null) } }, E(Icon, { name: 'close', size: 12 }))),
@@ -5153,15 +6820,54 @@ window.__ModuleLoader__.load({
5153
6820
  else if (spec.id === 'fetch') { entry.disabled = remoteReady !== true; entry.run = () => { void run('fetch', { prune: true }) } }
5154
6821
  else if (spec.id === 'pull') { entry.disabled = remoteReady !== true || tracked !== true || blocked; entry.run = () => { void run('pull', { mode: 'ff-only' }) } }
5155
6822
  else if (spec.id === 'push') { entry.disabled = remoteReady !== true || blocked; entry.run = () => setDialog({ kind: 'push' }) }
5156
- if (busy === true && spec.id !== 'refresh') entry.disabled = true
6823
+ else if (spec.id === 'tree') { entry.active = treeOpen; entry.run = () => foldTree(!treeOpen) }
6824
+ else if (spec.id === 'float') {
6825
+ // Label AND icon follow where the panel actually is: floating panels get
6826
+ // the way back, docked ones get the way out.
6827
+ entry.available = dockState.float
6828
+ entry.active = dockState.floating
6829
+ entry.icon = dockState.floating === true ? 'dock' : 'float'
6830
+ entry.label = dockState.floating === true ? t('action.dock') : t('action.float')
6831
+ entry.run = () => {
6832
+ if (dockState.floating === true) { dock.service.dock(floatPaneId); return }
6833
+ dock.service.float(dock.tabId)
6834
+ }
6835
+ }
6836
+ else if (spec.id === 'split') {
6837
+ entry.available = dockState.split
6838
+ entry.run = () => { dock.service.split(dock.paneId) }
6839
+ }
6840
+ else if (spec.id === 'fullscreen') {
6841
+ entry.available = dockState.fullscreen
6842
+ entry.active = dock !== null && dock.fullscreen === true
6843
+ entry.icon = entry.active === true ? 'collapse' : 'expand'
6844
+ entry.label = entry.active === true ? t('action.exitFullscreen') : t('action.fullscreen')
6845
+ entry.run = () => {
6846
+ const service = dock.service
6847
+ const target = typeof service.commandTarget === 'function' ? service.commandTarget(document.activeElement) : undefined
6848
+ if (target !== undefined && target !== null) service.toggleFullscreen(target)
6849
+ }
6850
+ }
6851
+ // A running git command greys out the repository actions, never the view.
6852
+ if (busy === true && spec.id !== 'refresh' && RAIL_VIEW_IDS.indexOf(spec.id) < 0) entry.disabled = true
5157
6853
  return entry
5158
6854
  })
5159
6855
 
5160
6856
  const railOrdered = railConfig.order
5161
6857
  .map((id) => railActions.find((action) => action.id === id))
5162
- .filter((action) => action !== undefined)
6858
+ // An action this mount cannot perform is not rendered at all — hidden, not
6859
+ // disabled: a greyed button for a service the host does not have is noise.
6860
+ .filter((action) => action !== undefined && action.available !== false)
5163
6861
  const railVisible = railOrdered.filter((action) => railConfig.hidden.indexOf(action.id) < 0)
5164
6862
 
6863
+ /* The rail is drawn from the row Config when the host serves it: a change
6864
+ made in the settings card must show up here without a reload. */
6865
+ useEffect(() => subscribeRailConfig(() => {
6866
+ setRailConfig(readRailConfig())
6867
+ setRailTick((value) => value + 1)
6868
+ }), [])
6869
+ void railTick
6870
+
5165
6871
  const applyRail = (next) => { setRailConfig(next); writeRailConfig(next) }
5166
6872
  const moveRailAction = (id, delta) => {
5167
6873
  const order = railConfig.order.slice()
@@ -5211,6 +6917,8 @@ window.__ModuleLoader__.load({
5211
6917
  key: action.id,
5212
6918
  type: 'button',
5213
6919
  className: 'dig-rail-btn dig-tone-' + action.tone + (action.active === true ? ' dig-rail-btn-active' : ''),
6920
+ 'data-action': action.id,
6921
+ 'aria-pressed': action.active === true ? 'true' : 'false',
5214
6922
  title: action.disabled === true ? action.label + ' · ' + reason : action.label,
5215
6923
  disabled: action.disabled === true,
5216
6924
  onClick: action.run,
@@ -5238,26 +6946,40 @@ window.__ModuleLoader__.load({
5238
6946
  key: 'settings',
5239
6947
  type: 'button',
5240
6948
  className: 'dig-rail-btn dig-rail-settings',
5241
- title: t('rail.settings'),
5242
- onClick: () => setRailSettings(true),
6949
+ 'data-action': 'settings',
6950
+ // On a host that serves the row Config this button opens the plugin's own
6951
+ // settings page (where the card below the description edits the rail);
6952
+ // on an older host it keeps the in-panel editor. Never both.
6953
+ title: railSettingsTarget() === 'page' ? t('rail.openSettings') : t('rail.settings'),
6954
+ onClick: () => {
6955
+ const target = railSettingsTarget()
6956
+ if (target === 'page') { openSettingsPage(); return }
6957
+ setRailSettings(true)
6958
+ },
5243
6959
  }, E(Icon, { name: 'settings', size: 15 })))
5244
6960
  return E('div', { className: vertical ? 'dig-rail' : 'dig-rail-row', ref: railHostRef }, children)
5245
6961
  }
5246
6962
 
5247
6963
  /* ---------- body per chrome ---------- */
5248
6964
 
6965
+ /* ---------- body per chrome ---------- */
6966
+
6967
+ /* The history↔diff separator only exists while a diff is on screen, and it
6968
+ lives inside the middle pane — the diff is the pane below it. */
6969
+ const diffGutter = diffPane === null ? null : gutter('diff', 'next')
6970
+
5249
6971
  let body
5250
6972
  if (repoState === null) {
5251
6973
  /* The scan is still running: a neutral loading state, never the
5252
6974
  "no repository found" verdict (that text is reserved for a scan
5253
6975
  that actually came back empty). */
5254
- body = E('div', { className: 'dig-body' }, E('div', { className: 'dig-empty' }, t('status.loading')))
6976
+ body = E('div', { className: 'dig-body', ref: bodyRef }, E('div', { className: 'dig-empty' }, t('status.loading')))
5255
6977
  } else if (repoRoot === null) {
5256
- body = E('div', { className: 'dig-body' }, E(RepoPicker, {
6978
+ body = E('div', { className: 'dig-body', ref: bodyRef }, E(RepoPicker, {
5257
6979
  t: t, cwd: cwd === undefined ? '' : cwd, repos: repoOptions, onPick: pickRepo,
5258
6980
  }))
5259
6981
  } else if (compact) {
5260
- body = E('div', { className: 'dig-body dig-body-compact' },
6982
+ body = E('div', { className: 'dig-body dig-body-compact', ref: bodyRef },
5261
6983
  E('div', { className: 'dig-compact-bar' },
5262
6984
  E(Segmented, {
5263
6985
  value: view === 'detail' ? 'history' : view,
@@ -5268,19 +6990,34 @@ window.__ModuleLoader__.load({
5268
6990
  ],
5269
6991
  }),
5270
6992
  E('span', { className: 'dig-topbar-spacer' }),
5271
- treeOpen ? E('button', { type: 'button', className: 'dig-icon-btn dig-icon-btn-active', title: t('toolbar.tree'), onClick: () => setTreeOpen(false) }, E(Icon, { name: 'branch' })) : null),
5272
- treeOpen ? E('div', { className: 'dig-compact-tree' }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
5273
- view === 'changes' ? changesPane : E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffPane))
6993
+ // Two-way on purpose: treeOpen is persisted, so a header button that
6994
+ // only rendered while the tree was OPEN left a folded tree with no way
6995
+ // back on this chrome (the rail now carries one too, for the others).
6996
+ E('button', {
6997
+ type: 'button',
6998
+ className: 'dig-icon-btn' + (treeOpen ? ' dig-icon-btn-active' : ''),
6999
+ 'data-action': 'tree',
7000
+ 'aria-pressed': treeOpen ? 'true' : 'false',
7001
+ title: t('toolbar.tree'),
7002
+ onClick: () => foldTree(!treeOpen),
7003
+ }, E(Icon, { name: treeOpen ? 'eye' : 'eyeOff' }))),
7004
+ treeOpen ? E('div', { className: 'dig-compact-tree', 'data-pane': 'tree', style: paneStyle('tree') }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
7005
+ treeOpen ? gutter('tree', 'prev') : null,
7006
+ view === 'changes' ? changesPane : E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane))
5274
7007
  } else if (columns) {
5275
- body = E('div', { className: 'dig-body dig-body-columns' },
5276
- treeOpen ? E('div', { className: 'dig-pane dig-pane-tree' }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
5277
- E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffPane),
5278
- E('div', { className: 'dig-pane dig-pane-changes' }, changesPane))
7008
+ body = E('div', { className: 'dig-body dig-body-columns', ref: bodyRef },
7009
+ treeOpen ? E('div', { className: 'dig-pane dig-pane-tree', 'data-pane': 'tree', style: paneStyle('tree') }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
7010
+ treeOpen ? gutter('tree', 'prev') : null,
7011
+ E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane),
7012
+ gutter('changes', 'next'),
7013
+ E('div', { className: 'dig-pane dig-pane-changes', 'data-pane': 'changes', style: paneStyle('changes') }, changesPane))
5279
7014
  } else {
5280
- body = E('div', { className: 'dig-body dig-body-stack' },
5281
- treeOpen ? E('div', { className: 'dig-pane dig-pane-tree-stack' }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
5282
- E('div', { className: 'dig-pane dig-pane-changes-stack' }, changesPane),
5283
- E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffPane))
7015
+ body = E('div', { className: 'dig-body dig-body-stack', ref: bodyRef },
7016
+ treeOpen ? E('div', { className: 'dig-pane dig-pane-tree-stack', 'data-pane': 'tree', style: paneStyle('tree') }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
7017
+ treeOpen ? gutter('tree', 'prev') : null,
7018
+ E('div', { className: 'dig-pane dig-pane-changes-stack', 'data-pane': 'changes', style: paneStyle('changes') }, changesPane),
7019
+ gutter('changes', 'prev'),
7020
+ E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane))
5284
7021
  }
5285
7022
 
5286
7023
  /* ---------- overlays ---------- */
@@ -5406,7 +7143,14 @@ window.__ModuleLoader__.load({
5406
7143
  // narrow ones (the native right sidebar) get the same actions as one
5407
7144
  // horizontal row under the repo/branch bar, and every action lives in
5408
7145
  // exactly one place.
5409
- return E('div', { className: 'dig-root', ref: hostRef }, topBar,
7146
+ // While a divider is being dragged the whole panel stops selecting text and
7147
+ // keeps the resize cursor, even though the pointer has left the 8px gutter.
7148
+ return E('div', {
7149
+ className: 'dig-root'
7150
+ + (compact ? ' dig-root-narrow' : '')
7151
+ + (paneDragging === null ? '' : ' dig-pane-dragging' + (paneByWidth ? '-v' : '-h')),
7152
+ ref: hostRef,
7153
+ }, topBar,
5410
7154
  columns === true || repoRoot === null ? null : renderRail(false),
5411
7155
  banner, noteBanner,
5412
7156
  E('div', { className: 'dig-shell' },
@@ -5470,6 +7214,35 @@ window.__ModuleLoader__.load({
5470
7214
  '.dig-tone-violet{color:#b083f0}',
5471
7215
  '.dig-tone-cyan{color:#59b0d6}',
5472
7216
  '.dig-icon-btn-small{width:20px;height:20px;border-radius:var(--dsw-radius-xs,4px)}',
7217
+ // A panel dragged narrower than COMPACT_MAX_WIDTH (the compact chrome) is the
7218
+ // case where 'nowrap + ellipsis' stops being readable: the overlays clamp
7219
+ // their own width, and their text wraps inside that box instead of clipping.
7220
+ // The settings card (the host's plugin page renders it inside its own
7221
+ // section): one row per rail action, themed entirely by tokens.
7222
+ '.dig-settings{display:flex;flex-direction:column;gap:8px;min-width:0}',
7223
+ '.dig-settings-head{display:flex;flex-direction:column;gap:2px}',
7224
+ '.dig-settings-title{font-weight:600;color:var(--dsw-alias-label-primary)}',
7225
+ '.dig-settings-hint{color:var(--dsw-alias-label-tertiary);font-weight:400}',
7226
+ '.dig-settings-note{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary));font-weight:400}',
7227
+ '.dig-settings-note-hidden{display:none}',
7228
+ '.dig-settings-error{color:var(--dsw-alias-state-error-primary);font-weight:400}',
7229
+ '.dig-settings-sub{margin-top:6px;font-weight:600;color:var(--dsw-alias-label-primary)}',
7230
+ '.dig-settings-text{display:flex;flex-direction:column;gap:3px;min-width:0}',
7231
+ '.dig-settings-text-label{color:var(--dsw-alias-label-secondary);font-weight:400}',
7232
+ '.dig-settings-input{width:100%;box-sizing:border-box;font:inherit}',
7233
+ '.dig-commit-ai{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}',
7234
+ '.dig-commit-ai-hint{color:var(--dsw-alias-label-tertiary);font-size:11px}',
7235
+ '.dig-commit-ai-hint-block{flex:1 0 100%;font-size:11px}',
7236
+ '.dig-commit-ai-note{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary));font-size:11px;margin-bottom:4px;word-break:break-word}',
7237
+ '.dig-settings-list{display:flex;flex-direction:column;gap:2px;max-height:min(46vh,360px);overflow:auto;padding:2px;border:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-sm,8px)}',
7238
+ '.dig-settings-row{display:flex;align-items:center;gap:8px;padding:3px 6px;border-radius:calc(var(--dsw-radius-sm,8px) - 2px);cursor:pointer;min-width:0}',
7239
+ '.dig-settings-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
7240
+ '.dig-settings-check{flex:none;accent-color:var(--dsw-alias-brand-primary)}',
7241
+ '.dig-settings-glyph{display:inline-flex;flex:none;color:var(--dsw-alias-label-secondary)}',
7242
+ '.dig-settings-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}',
7243
+ '.dig-root-narrow .dig-settings-label{white-space:normal}',
7244
+ '.dig-root-narrow .dig-menu-item{white-space:normal}',
7245
+ '.dig-root-narrow .dig-toast-text{white-space:normal}',
5473
7246
  '.dig-toasts{position:absolute;right:8px;bottom:8px;display:flex;flex-direction:column;gap:6px;z-index:80;max-width:min(340px,92%)}',
5474
7247
  '.dig-toast{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:var(--dsw-radius-sm,8px);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));border:0;box-shadow:var(--dsw-elevation-prominent,0 8px 22px rgba(0,0,0,.32))}',
5475
7248
  '.dig-toast-icon{display:inline-flex;flex:none;color:var(--dsw-alias-label-secondary)}',
@@ -5499,6 +7272,20 @@ window.__ModuleLoader__.load({
5499
7272
  '.dig-pane-changes-stack{max-height:46%;flex:none;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
5500
7273
  '.dig-compact-bar{display:flex;align-items:center;gap:6px;padding:4px 6px;flex:none}',
5501
7274
  '.dig-compact-tree{max-height:42%;flex:none;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
7275
+ // A divider: an 8px hit area (a fingertip needs it) painted as a 1px hairline
7276
+ // that lights up on hover, focus or while dragging. touch-action:none is what
7277
+ // makes a touch drag resize the pane instead of scrolling the panel.
7278
+ '.dig-gutter{flex:none;align-self:stretch;position:relative;z-index:1;touch-action:none;background:transparent;border:0;padding:0;cursor:col-resize}',
7279
+ '.dig-gutter-v{width:8px}',
7280
+ '.dig-gutter-h{height:8px;cursor:row-resize}',
7281
+ '.dig-gutter::after{content:"";position:absolute;background:var(--dsw-alias-hairline,var(--dsw-alias-border-l1));transition:background .12s ease}',
7282
+ '.dig-gutter-v::after{top:0;bottom:0;left:50%;width:1px;margin-left:-.5px}',
7283
+ '.dig-gutter-h::after{left:0;right:0;top:50%;height:1px;margin-top:-.5px}',
7284
+ '.dig-gutter:hover::after,.dig-gutter:focus-visible::after,.dig-gutter-active::after{background:var(--dsw-alias-brand-primary)}',
7285
+ '.dig-gutter:focus-visible{outline:1px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-1px}',
7286
+ '.dig-pane-dragging,.dig-pane-dragging *{user-select:none}',
7287
+ '.dig-pane-dragging-v{cursor:col-resize}',
7288
+ '.dig-pane-dragging-h{cursor:row-resize}',
5502
7289
  '.dig-seg{display:inline-flex;gap:2px;padding:2px;border-radius:var(--dsw-radius-sm,8px);background:var(--dsw-alias-interactive-bg-hover);flex:none}',
5503
7290
  '.dig-seg-item{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-weight:500;padding:2px 10px;border-radius:calc(var(--dsw-radius-sm,8px) - 2px);cursor:pointer;white-space:nowrap}',
5504
7291
  '.dig-seg-item:hover{color:var(--dsw-alias-label-primary)}',
@@ -5599,7 +7386,7 @@ window.__ModuleLoader__.load({
5599
7386
  '.dig-row:hover .dig-mini{opacity:1}',
5600
7387
  '.dig-mini:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
5601
7388
  '.dig-commit-box{flex:none;padding:6px;display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
5602
- '.dig-commit-box-compact{flex-direction:row;align-items:center;gap:6px;padding:4px 6px}',
7389
+ '.dig-commit-box-compact{flex-direction:row;align-items:center;gap:6px;padding:4px 6px;flex-wrap:wrap}',
5603
7390
  '.dig-textarea{width:100%;box-sizing:border-box;min-height:50px;resize:vertical;padding:5px 6px;border-radius:var(--dsw-radius-sm,8px);border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;outline:none}',
5604
7391
  '.dig-textarea::placeholder{color:var(--dsw-alias-label-tertiary)}',
5605
7392
  '.dig-textarea:focus{border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary))}',
@@ -5632,13 +7419,13 @@ window.__ModuleLoader__.load({
5632
7419
  '.dig-diff-hunk{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent)}',
5633
7420
  '.dig-diff-meta{color:var(--dsw-alias-label-tertiary)}',
5634
7421
  '.dig-overlay{position:absolute;inset:0;padding:8px;box-sizing:border-box;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.42));-webkit-backdrop-filter:var(--dsw-mask-blur,none);backdrop-filter:var(--dsw-mask-blur,none);display:flex;align-items:center;justify-content:center;z-index:60}',
5635
- '.dig-dialog{min-width:240px;max-width:min(420px,94%);max-height:100%;overflow:auto;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));border:0;border-radius:var(--dsw-radius-panel,28px);padding:14px;display:flex;flex-direction:column;gap:10px;box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.35))}',
7422
+ '.dig-dialog{box-sizing:border-box;min-width:min(240px,100%);max-width:min(420px,94%);max-height:100%;overflow:auto;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));border:0;border-radius:var(--dsw-radius-panel,28px);padding:14px;display:flex;flex-direction:column;gap:10px;box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.35))}',
5636
7423
  '.dig-dialog-wide{min-width:min(360px,92%)}',
5637
7424
  '.dig-dialog-title{font-weight:600}',
5638
7425
  '.dig-dialog-text{color:var(--dsw-alias-label-secondary);white-space:pre-wrap}',
5639
7426
  '.dig-dialog-actions{display:flex;justify-content:flex-end;gap:8px}',
5640
7427
  '.dig-dialog-actions .dig-btn-primary{margin-left:0}',
5641
- '.dig-menu{position:absolute;z-index:70;box-sizing:border-box;min-width:200px;max-width:calc(100% - 8px);max-height:calc(100% - 8px);padding:4px;border:0;border-radius:var(--dsw-radius-md,12px);background:transparent;box-shadow:var(--dsw-elevation-prominent,0 10px 28px rgba(0,0,0,.35));--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);isolation:isolate;display:flex;flex-direction:column}',
7428
+ '.dig-menu{position:absolute;z-index:70;box-sizing:border-box;min-width:min(200px,calc(100% - 8px));max-width:calc(100% - 8px);max-height:calc(100% - 8px);padding:4px;border:0;border-radius:var(--dsw-radius-md,12px);background:transparent;box-shadow:var(--dsw-elevation-prominent,0 10px 28px rgba(0,0,0,.35));--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);isolation:isolate;display:flex;flex-direction:column}',
5642
7429
  '.dig-menu::before{content:"";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1)));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(14px) saturate(1.2));backdrop-filter:var(--dsw-menu-backdrop-filter,blur(14px) saturate(1.2));pointer-events:none}',
5643
7430
  '.dig-menu-scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;display:flex;flex-direction:column}',
5644
7431
  '.dig-menu-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-weight:500;text-align:left;border-radius:var(--dsw-radius-sm,8px);cursor:pointer;white-space:nowrap;overflow:hidden}',
@@ -5680,7 +7467,16 @@ window.__ModuleLoader__.load({
5680
7467
  return typeof unsubscribe === 'function' ? unsubscribe : undefined
5681
7468
  }, [])
5682
7469
  void tick
5683
- return E(Panel, { scope: props.scope, t: props.t, visible: props.visible })
7470
+ /* Read at RENDER time (the service may arrive after this plugin's apply) and
7471
+ soft: a host without the plugin page simply keeps the in-panel editor. */
7472
+ let navigation
7473
+ try {
7474
+ navigation = props.ctx === undefined || props.ctx === null ? undefined : props.ctx.get('pluginNavigation')
7475
+ } catch (error) { navigation = undefined }
7476
+ const openSettings = navigation !== undefined && navigation !== null && typeof navigation.openBundle === 'function'
7477
+ ? () => { navigation.openBundle(SETTINGS_BUNDLE) }
7478
+ : null
7479
+ return E(Panel, { scope: props.scope, t: props.t, visible: props.visible, dock: props.dock, openSettings: openSettings })
5684
7480
  }
5685
7481
 
5686
7482
  function NativePanel(props) {
@@ -5698,7 +7494,11 @@ window.__ModuleLoader__.load({
5698
7494
  }
5699
7495
  }
5700
7496
  const scope = useMemo(() => ({ cwd: cwd, sessionId: sessionId }), [cwd, sessionId])
5701
- return E(LocaleLive, { ctx: props.ctx, scope: scope, t: props.t, visible: true })
7497
+ /* The dock actions need this tab's own identity, which only the native seat
7498
+ binds; the better-sidebar door has no sidebarRight tab id and therefore
7499
+ wires nothing (a panel hosted elsewhere must not float a foreign tab). */
7500
+ const dock = readDockInfo(props.useTabInfo, props.ctx)
7501
+ return E(LocaleLive, { ctx: props.ctx, scope: scope, t: props.t, visible: true, dock: dock })
5702
7502
  }
5703
7503
 
5704
7504
  function apply(ctx) {
@@ -5816,6 +7616,59 @@ window.__ModuleLoader__.load({
5816
7616
  /* ctx.inject runs its callback straight away when the service is already
5817
7617
  there, so only a host without better-sidebar reaches the native seats. */
5818
7618
  if (!hostedByBetterSidebar) hostNatively()
7619
+
7620
+ /* ---- settings: the row Config is the rail's source of truth ---- */
7621
+ /* dsh >= 0.1.7 serves a plugin's row Config to the browser through
7622
+ ctx.configForms; from then on the settings card and the rail read that one
7623
+ document (and the legacy localStorage value is migrated once). A host
7624
+ without the service never fires this callback: railForm stays null and the
7625
+ localStorage home + in-panel editor keep working there. */
7626
+ ctx.inject(['configForms'], (formCtx) => {
7627
+ const forms = formCtx.get('configForms')
7628
+ if (forms === undefined || forms === null || typeof forms.get !== 'function') return
7629
+ let form
7630
+ try { form = forms.get(RAIL_NS) } catch (error) { void error }
7631
+ if (form === undefined || form === null || typeof form.getSnapshot !== 'function') return
7632
+ formCtx.effect(() => {
7633
+ railForm = form
7634
+ migrateRailConfig(form)
7635
+ /* The row Config is still 'loading' at bind time, so the migration is
7636
+ attempted here AND on every notification until it lands — it is
7637
+ idempotent (marker-gated, re-entrancy-guarded) and only stamps the
7638
+ marker once the Host accepted the write. */
7639
+ const unsubscribe = typeof form.subscribe === 'function' ? form.subscribe(() => { notifyRailConfig(); migrateRailConfig(form) }) : undefined
7640
+ notifyRailConfig()
7641
+ return () => {
7642
+ railForm = null
7643
+ if (typeof unsubscribe === 'function') unsubscribe()
7644
+ notifyRailConfig()
7645
+ }
7646
+ }, 'dsh-ide-git: row config')
7647
+ })
7648
+
7649
+ /* The settings card, on both official seats: the plugin's detail page
7650
+ (keyed by the PACKAGE name) and Settings → Plugins (keyed by the settings
7651
+ namespace = the loader row id). Both seats are optional — a host without
7652
+ the slots keeps whatever surface it has. */
7653
+ ctx.inject(['slots'], (slotCtx) => {
7654
+ const slots = slotCtx.get('slots')
7655
+ if (slots === undefined || slots === null || typeof slots.inject !== 'function') return
7656
+ slotCtx.effect(() => {
7657
+ const card = (cardProps) => E(RailSettingsCard, Object.assign({}, cardProps, { t: t }))
7658
+ const offBundle = slots.inject('plugins.bundle.config', () => slots.register(
7659
+ { name: 'plugins.bundle.config', key: SETTINGS_BUNDLE, locale: LOCALE_NS },
7660
+ card,
7661
+ ))
7662
+ const offItem = slots.inject('settings.plugin.item', () => slots.register(
7663
+ { name: 'settings.plugin.item', key: RAIL_NS, locale: LOCALE_NS },
7664
+ card,
7665
+ ))
7666
+ return () => {
7667
+ try { offBundle() } catch (error) { void error }
7668
+ try { offItem() } catch (error) { void error }
7669
+ }
7670
+ }, 'dsh-ide-git: settings card')
7671
+ })
5819
7672
  }
5820
7673
 
5821
7674
  return { name: 'dsh-ide-git', inject: [], apply }