dsh-ide-git 0.16.4 → 0.18.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
@@ -122,6 +122,25 @@ window.__ModuleLoader__.load({
122
122
  'changes.commit': '提交',
123
123
  'changes.amend': '修补',
124
124
  'changes.stageAll': '全部暂存',
125
+ 'selection.count': '已选 {n} 项',
126
+ 'selection.copied': '已复制 {n} 项',
127
+ 'selection.copyPaths': '复制路径',
128
+ 'selection.clear': '清除选择',
129
+ 'action.copyName': '复制名称',
130
+ 'menu.copyNamesN': '复制 {n} 个名称',
131
+ 'menu.copyHashesN': '复制 {n} 个哈希',
132
+ 'menu.copyPathsN': '复制 {n} 个路径',
133
+ 'menu.stageN': '暂存 {n} 项',
134
+ 'menu.unstageN': '取消暂存 {n} 项',
135
+ 'menu.discardN': '还原 {n} 项…',
136
+ 'menu.cherryN': 'Cherry-pick {n} 个提交',
137
+ 'menu.deleteLocalN': '删除 {n} 个本地分支…',
138
+ 'menu.deleteRemoteN': '删除 {n} 个远程分支…',
139
+ 'menu.deleteTagsN': '删除 {n} 个标签…',
140
+ 'confirm.deleteRefs': '删除所选引用?本地分支 {b} 个、远程分支 {r} 个、标签 {g} 个。',
141
+ 'confirm.discardMany': '还原 {n} 个文件的未提交改动?',
142
+ 'confirm.discardManyUntracked': '其中 {m} 个未跟踪文件将被彻底删除。',
143
+ 'toast.branchesDeleted': '已删除 {n} 个分支',
125
144
  'changes.unstageAll': '全部取消暂存',
126
145
  'changes.groupBy': '分组依据',
127
146
  'changes.groupFlat': '平铺',
@@ -279,7 +298,7 @@ window.__ModuleLoader__.load({
279
298
  'lf.truncated': '只显示前 {count} 行',
280
299
  'preview.title': 'Git 预览',
281
300
  'preview.hint': '点击文件或提交的「显示差异」,差异会在侧边栏这里打开。',
282
- 'settings.rail.title': '动作条按钮',
301
+ 'settings.rail.title': '动作条',
283
302
  'settings.rail.hint': '勾选要显示在动作条上的动作;顺序固定为默认顺序,取消勾选即隐藏。',
284
303
  'settings.rail.unavailable': '当前宿主没有设置服务,请用面板内的「动作条设置」。',
285
304
  'settings.commit.title': 'AI 提交信息',
@@ -411,6 +430,25 @@ window.__ModuleLoader__.load({
411
430
  'changes.commit': 'Commit',
412
431
  'changes.amend': 'Amend',
413
432
  'changes.stageAll': 'Stage all',
433
+ 'selection.count': '{n} selected',
434
+ 'selection.copied': 'Copied {n} item(s)',
435
+ 'selection.copyPaths': 'Copy paths',
436
+ 'selection.clear': 'Clear selection',
437
+ 'action.copyName': 'Copy name',
438
+ 'menu.copyNamesN': 'Copy {n} names',
439
+ 'menu.copyHashesN': 'Copy {n} hashes',
440
+ 'menu.copyPathsN': 'Copy {n} paths',
441
+ 'menu.stageN': 'Stage {n} items',
442
+ 'menu.unstageN': 'Unstage {n} items',
443
+ 'menu.discardN': 'Discard {n} items…',
444
+ 'menu.cherryN': 'Cherry-pick {n} commits',
445
+ 'menu.deleteLocalN': 'Delete {n} local branches…',
446
+ 'menu.deleteRemoteN': 'Delete {n} remote branches…',
447
+ 'menu.deleteTagsN': 'Delete {n} tags…',
448
+ 'confirm.deleteRefs': 'Delete the selected refs? {b} local, {r} remote, {g} tag(s).',
449
+ 'confirm.discardMany': 'Discard uncommitted changes of {n} files?',
450
+ 'confirm.discardManyUntracked': '{m} untracked file(s) will be deleted for good.',
451
+ 'toast.branchesDeleted': '{n} branch(es) deleted',
414
452
  'changes.unstageAll': 'Unstage all',
415
453
  'changes.groupBy': 'Group by',
416
454
  'changes.groupFlat': 'Flat list',
@@ -568,7 +606,7 @@ window.__ModuleLoader__.load({
568
606
  'lf.truncated': 'Showing the first {count} lines',
569
607
  'preview.title': 'Git Preview',
570
608
  'preview.hint': 'Click “Show diff” on a file or commit — the diff opens here in the sidebar.',
571
- 'settings.rail.title': 'Action rail buttons',
609
+ 'settings.rail.title': 'Action rail',
572
610
  'settings.rail.hint': 'Tick the actions shown on the rail. The order is fixed; unticking one hides it.',
573
611
  'settings.rail.unavailable': 'This host has no settings service; use the in-panel “Action rail settings”.',
574
612
  'settings.commit.title': 'AI commit message',
@@ -708,6 +746,25 @@ window.__ModuleLoader__.load({
708
746
  'changes.commit': '提交',
709
747
  'changes.amend': '修訂上次提交',
710
748
  'changes.stageAll': '全部暫存',
749
+ 'selection.count': '已選 {n} 項',
750
+ 'selection.copied': '已複製 {n} 項',
751
+ 'selection.copyPaths': '複製路徑',
752
+ 'selection.clear': '清除選擇',
753
+ 'action.copyName': '複製名稱',
754
+ 'menu.copyNamesN': '複製 {n} 個名稱',
755
+ 'menu.copyHashesN': '複製 {n} 個雜湊',
756
+ 'menu.copyPathsN': '複製 {n} 個路徑',
757
+ 'menu.stageN': '暫存 {n} 項',
758
+ 'menu.unstageN': '取消暫存 {n} 項',
759
+ 'menu.discardN': '還原 {n} 項…',
760
+ 'menu.cherryN': 'Cherry-pick {n} 個提交',
761
+ 'menu.deleteLocalN': '刪除 {n} 個本地分支…',
762
+ 'menu.deleteRemoteN': '刪除 {n} 個遠端分支…',
763
+ 'menu.deleteTagsN': '刪除 {n} 個標籤…',
764
+ 'confirm.deleteRefs': '刪除所選引用?本地分支 {b} 個、遠端分支 {r} 個、標籤 {g} 個。',
765
+ 'confirm.discardMany': '還原 {n} 個檔案的未提交變更?',
766
+ 'confirm.discardManyUntracked': '其中 {m} 個未追蹤檔案將被徹底刪除。',
767
+ 'toast.branchesDeleted': '已刪除 {n} 個分支',
711
768
  'changes.unstageAll': '全部取消暫存',
712
769
  'changes.groupBy': '分組方式',
713
770
  'changes.groupFlat': '平面清單',
@@ -865,7 +922,7 @@ window.__ModuleLoader__.load({
865
922
  'lf.truncated': '只顯示前 {count} 行',
866
923
  'preview.title': 'Git 預覽',
867
924
  'preview.hint': '喺檔案或提交撳「顯示差異」,差異就會喺側邊欄度開啟。',
868
- 'settings.rail.title': '動作列按鈕',
925
+ 'settings.rail.title': '動作列',
869
926
  'settings.rail.hint': '勾選要顯示在動作列上的動作;順序固定,取消勾選即隱藏。',
870
927
  'settings.rail.unavailable': '目前宿主沒有設定服務,請用面板內的「動作條設定」。',
871
928
  'settings.commit.title': 'AI 提交訊息',
@@ -997,6 +1054,25 @@ window.__ModuleLoader__.load({
997
1054
  'changes.commit': '提交',
998
1055
  'changes.amend': '修訂上次提交',
999
1056
  'changes.stageAll': '全部暫存',
1057
+ 'selection.count': '已選 {n} 項',
1058
+ 'selection.copied': '已複製 {n} 項',
1059
+ 'selection.copyPaths': '複製路徑',
1060
+ 'selection.clear': '清除選擇',
1061
+ 'action.copyName': '複製名稱',
1062
+ 'menu.copyNamesN': '複製 {n} 個名稱',
1063
+ 'menu.copyHashesN': '複製 {n} 個雜湊',
1064
+ 'menu.copyPathsN': '複製 {n} 個路徑',
1065
+ 'menu.stageN': '暫存 {n} 項',
1066
+ 'menu.unstageN': '取消暫存 {n} 項',
1067
+ 'menu.discardN': '還原 {n} 項…',
1068
+ 'menu.cherryN': 'Cherry-pick {n} 個提交',
1069
+ 'menu.deleteLocalN': '刪除 {n} 個本地分支…',
1070
+ 'menu.deleteRemoteN': '刪除 {n} 個遠端分支…',
1071
+ 'menu.deleteTagsN': '刪除 {n} 個標籤…',
1072
+ 'confirm.deleteRefs': '刪除所選引用?本地分支 {b} 個、遠端分支 {r} 個、標籤 {g} 個。',
1073
+ 'confirm.discardMany': '還原 {n} 個檔案的未提交變更?',
1074
+ 'confirm.discardManyUntracked': '其中 {m} 個未追蹤檔案將被徹底刪除。',
1075
+ 'toast.branchesDeleted': '已刪除 {n} 個分支',
1000
1076
  'changes.unstageAll': '全部取消暫存',
1001
1077
  'changes.groupBy': '分組方式',
1002
1078
  'changes.groupFlat': '平面清單',
@@ -1154,7 +1230,7 @@ window.__ModuleLoader__.load({
1154
1230
  'lf.truncated': '只顯示前 {count} 行',
1155
1231
  'preview.title': 'Git 預覽',
1156
1232
  'preview.hint': '點選檔案或提交的「顯示差異」,差異就會在側邊欄開啟。',
1157
- 'settings.rail.title': '動作列按鈕',
1233
+ 'settings.rail.title': '動作列',
1158
1234
  'settings.rail.hint': '勾選要顯示在動作列上的動作;順序固定,取消勾選即隱藏。',
1159
1235
  'settings.rail.unavailable': '目前宿主沒有設定服務,請用面板內的「動作條設定」。',
1160
1236
  'settings.commit.title': 'AI 提交訊息',
@@ -1286,6 +1362,25 @@ window.__ModuleLoader__.load({
1286
1362
  'changes.commit': '提交',
1287
1363
  'changes.amend': '修訂上次提交',
1288
1364
  'changes.stageAll': '全部暫存',
1365
+ 'selection.count': '已選 {n} 項',
1366
+ 'selection.copied': '已複製 {n} 項',
1367
+ 'selection.copyPaths': '複製路徑',
1368
+ 'selection.clear': '清除選擇',
1369
+ 'action.copyName': '複製名稱',
1370
+ 'menu.copyNamesN': '複製 {n} 個名稱',
1371
+ 'menu.copyHashesN': '複製 {n} 個雜湊',
1372
+ 'menu.copyPathsN': '複製 {n} 個路徑',
1373
+ 'menu.stageN': '暫存 {n} 項',
1374
+ 'menu.unstageN': '取消暫存 {n} 項',
1375
+ 'menu.discardN': '還原 {n} 項…',
1376
+ 'menu.cherryN': 'Cherry-pick {n} 個提交',
1377
+ 'menu.deleteLocalN': '刪除 {n} 個本地分支…',
1378
+ 'menu.deleteRemoteN': '刪除 {n} 個遠端分支…',
1379
+ 'menu.deleteTagsN': '刪除 {n} 個標籤…',
1380
+ 'confirm.deleteRefs': '刪除所選引用?本地分支 {b} 個、遠端分支 {r} 個、標籤 {g} 個。',
1381
+ 'confirm.discardMany': '還原 {n} 個檔案的未提交變更?',
1382
+ 'confirm.discardManyUntracked': '其中 {m} 個未追蹤檔案將被徹底刪除。',
1383
+ 'toast.branchesDeleted': '已刪除 {n} 個分支',
1289
1384
  'changes.unstageAll': '全部取消暫存',
1290
1385
  'changes.groupBy': '分組方式',
1291
1386
  'changes.groupFlat': '平面清單',
@@ -1443,7 +1538,7 @@ window.__ModuleLoader__.load({
1443
1538
  'lf.truncated': '只顯示前 {count} 行',
1444
1539
  'preview.title': 'Git 預覽',
1445
1540
  'preview.hint': '喺檔案或提交撳「顯示差異」,差異就會喺側邊欄度開啟。',
1446
- 'settings.rail.title': '動作列按鈕',
1541
+ 'settings.rail.title': '動作列',
1447
1542
  'settings.rail.hint': '勾選要顯示在動作列上的動作;順序固定,取消勾選即隱藏。',
1448
1543
  'settings.rail.unavailable': '目前宿主沒有設定服務,請用面板內的「動作條設定」。',
1449
1544
  'settings.commit.title': 'AI 提交訊息',
@@ -1575,6 +1670,25 @@ window.__ModuleLoader__.load({
1575
1670
  'changes.commit': 'コミット',
1576
1671
  'changes.amend': '前回のコミットを修正',
1577
1672
  'changes.stageAll': 'すべてステージ',
1673
+ 'selection.count': '{n} 件を選択中',
1674
+ 'selection.copied': '{n} 件をコピーしました',
1675
+ 'selection.copyPaths': 'パスをコピー',
1676
+ 'selection.clear': '選択を解除',
1677
+ 'action.copyName': '名前をコピー',
1678
+ 'menu.copyNamesN': '{n} 件の名前をコピー',
1679
+ 'menu.copyHashesN': '{n} 件のハッシュをコピー',
1680
+ 'menu.copyPathsN': '{n} 件のパスをコピー',
1681
+ 'menu.stageN': '{n} 件をステージ',
1682
+ 'menu.unstageN': '{n} 件のステージ解除',
1683
+ 'menu.discardN': '{n} 件を破棄…',
1684
+ 'menu.cherryN': 'コミット {n} 件をチェリーピック',
1685
+ 'menu.deleteLocalN': 'ローカルブランチ {n} 件を削除…',
1686
+ 'menu.deleteRemoteN': 'リモートブランチ {n} 件を削除…',
1687
+ 'menu.deleteTagsN': 'タグ {n} 件を削除…',
1688
+ 'confirm.deleteRefs': '選択した参照を削除しますか?ローカル {b} 件、リモート {r} 件、タグ {g} 件。',
1689
+ 'confirm.discardMany': '{n} 件の未コミット変更を破棄しますか?',
1690
+ 'confirm.discardManyUntracked': 'うち {m} 件の未追跡ファイルは完全に削除されます。',
1691
+ 'toast.branchesDeleted': '{n} 件のブランチを削除しました',
1578
1692
  'changes.unstageAll': 'すべてステージ解除',
1579
1693
  'changes.groupBy': 'グループ化',
1580
1694
  'changes.groupFlat': 'フラット表示',
@@ -1732,7 +1846,7 @@ window.__ModuleLoader__.load({
1732
1846
  'lf.truncated': '先頭 {count} 行のみ表示',
1733
1847
  'preview.title': 'Git プレビュー',
1734
1848
  'preview.hint': 'ファイルやコミットの「差分を表示」をクリックすると、差分がここに開きます。',
1735
- 'settings.rail.title': 'アクションレールのボタン',
1849
+ 'settings.rail.title': 'レール',
1736
1850
  'settings.rail.hint': 'レールに表示する操作にチェックを入れてください。順序は固定で、チェックを外すと非表示になります。',
1737
1851
  'settings.rail.unavailable': 'このホストには設定サービスがないため、パネル内の「アクションレール設定」を使ってください。',
1738
1852
  'settings.commit.title': 'AI コミットメッセージ',
@@ -1864,6 +1978,25 @@ window.__ModuleLoader__.load({
1864
1978
  'changes.commit': '커밋',
1865
1979
  'changes.amend': '이전 커밋 수정',
1866
1980
  'changes.stageAll': '모두 스테이지',
1981
+ 'selection.count': '{n}개 선택됨',
1982
+ 'selection.copied': '{n}개 복사됨',
1983
+ 'selection.copyPaths': '경로 복사',
1984
+ 'selection.clear': '선택 해제',
1985
+ 'action.copyName': '이름 복사',
1986
+ 'menu.copyNamesN': '이름 {n}개 복사',
1987
+ 'menu.copyHashesN': '해시 {n}개 복사',
1988
+ 'menu.copyPathsN': '경로 {n}개 복사',
1989
+ 'menu.stageN': '{n}개 스테이지',
1990
+ 'menu.unstageN': '{n}개 스테이지 해제',
1991
+ 'menu.discardN': '{n}개 되돌리기…',
1992
+ 'menu.cherryN': '커밋 {n}개 체리픽',
1993
+ 'menu.deleteLocalN': '로컬 브랜치 {n}개 삭제…',
1994
+ 'menu.deleteRemoteN': '원격 브랜치 {n}개 삭제…',
1995
+ 'menu.deleteTagsN': '태그 {n}개 삭제…',
1996
+ 'confirm.deleteRefs': '선택한 레퍼런스를 삭제할까요? 로컬 {b}개, 원격 {r}개, 태그 {g}개.',
1997
+ 'confirm.discardMany': '{n}개 파일의 커밋되지 않은 변경을 되돌릴까요?',
1998
+ 'confirm.discardManyUntracked': '이 중 추적되지 않는 {m}개 파일은 완전히 삭제됩니다.',
1999
+ 'toast.branchesDeleted': '브랜치 {n}개 삭제됨',
1867
2000
  'changes.unstageAll': '모두 스테이지 해제',
1868
2001
  'changes.groupBy': '그룹 기준',
1869
2002
  'changes.groupFlat': '평면 목록',
@@ -2021,7 +2154,7 @@ window.__ModuleLoader__.load({
2021
2154
  'lf.truncated': '처음 {count}줄만 표시',
2022
2155
  'preview.title': 'Git 미리보기',
2023
2156
  'preview.hint': '파일이나 커밋의 「차이 보기」를 클릭하면 차이가 사이드바 여기에 열립니다.',
2024
- 'settings.rail.title': '액션 레일 버튼',
2157
+ 'settings.rail.title': '레일',
2025
2158
  'settings.rail.hint': '레일에 표시할 동작을 선택하세요. 순서는 고정이며 선택을 해제하면 숨겨집니다.',
2026
2159
  'settings.rail.unavailable': '이 호스트에는 설정 서비스가 없으므로 패널의 "액션 레일 설정"을 사용하세요.',
2027
2160
  'settings.commit.title': 'AI 커밋 메시지',
@@ -2153,6 +2286,25 @@ window.__ModuleLoader__.load({
2153
2286
  'changes.commit': 'Committen',
2154
2287
  'changes.amend': 'Letzten Commit ergänzen',
2155
2288
  'changes.stageAll': 'Alle vormerken',
2289
+ 'selection.count': '{n} ausgewählt',
2290
+ 'selection.copied': '{n} Element(e) kopiert',
2291
+ 'selection.copyPaths': 'Pfade kopieren',
2292
+ 'selection.clear': 'Auswahl aufheben',
2293
+ 'action.copyName': 'Name kopieren',
2294
+ 'menu.copyNamesN': '{n} Namen kopieren',
2295
+ 'menu.copyHashesN': '{n} Hashes kopieren',
2296
+ 'menu.copyPathsN': '{n} Pfade kopieren',
2297
+ 'menu.stageN': '{n} Elemente vormerken',
2298
+ 'menu.unstageN': '{n} Elemente zurücknehmen',
2299
+ 'menu.discardN': '{n} Elemente verwerfen…',
2300
+ 'menu.cherryN': '{n} Commits cherry-picken',
2301
+ 'menu.deleteLocalN': '{n} lokale Branches löschen…',
2302
+ 'menu.deleteRemoteN': '{n} Remote-Branches löschen…',
2303
+ 'menu.deleteTagsN': '{n} Tags löschen…',
2304
+ 'confirm.deleteRefs': 'Ausgewählte Referenzen löschen? {b} lokal, {r} remote, {g} Tag(s).',
2305
+ 'confirm.discardMany': 'Uncommittete Änderungen von {n} Dateien verwerfen?',
2306
+ 'confirm.discardManyUntracked': '{m} unbeobachtete Datei(en) werden endgültig gelöscht.',
2307
+ 'toast.branchesDeleted': '{n} Branch(es) gelöscht',
2156
2308
  'changes.unstageAll': 'Alle zurücknehmen',
2157
2309
  'changes.groupBy': 'Gruppieren nach',
2158
2310
  'changes.groupFlat': 'Flache Liste',
@@ -2310,7 +2462,7 @@ window.__ModuleLoader__.load({
2310
2462
  'lf.truncated': 'Nur die ersten {count} Zeilen',
2311
2463
  'preview.title': 'Git-Vorschau',
2312
2464
  'preview.hint': '„Diff anzeigen“ an einer Datei oder einem Commit öffnet den Diff hier in der Seitenleiste.',
2313
- 'settings.rail.title': 'Schaltflächen der Aktionsleiste',
2465
+ 'settings.rail.title': 'Aktionsleiste',
2314
2466
  'settings.rail.hint': 'Die auf der Leiste sichtbaren Aktionen ankreuzen. Die Reihenfolge ist fest; abwählen blendet eine aus.',
2315
2467
  'settings.rail.unavailable': 'Dieser Host hat keinen Einstellungsdienst; nutze die „Aktionsleisten-Einstellungen“ im Panel.',
2316
2468
  'settings.commit.title': 'KI-Commit-Nachricht',
@@ -2442,6 +2594,25 @@ window.__ModuleLoader__.load({
2442
2594
  'changes.commit': 'Valider',
2443
2595
  'changes.amend': 'Corriger le dernier commit',
2444
2596
  'changes.stageAll': 'Tout indexer',
2597
+ 'selection.count': '{n} sélectionné(s)',
2598
+ 'selection.copied': '{n} élément(s) copié(s)',
2599
+ 'selection.copyPaths': 'Copier les chemins',
2600
+ 'selection.clear': 'Effacer la sélection',
2601
+ 'action.copyName': 'Copier le nom',
2602
+ 'menu.copyNamesN': 'Copier {n} noms',
2603
+ 'menu.copyHashesN': 'Copier {n} hachages',
2604
+ 'menu.copyPathsN': 'Copier {n} chemins',
2605
+ 'menu.stageN': 'Indexer {n} éléments',
2606
+ 'menu.unstageN': 'Désindexer {n} éléments',
2607
+ 'menu.discardN': 'Abandonner {n} éléments…',
2608
+ 'menu.cherryN': 'Cherry-pick de {n} commits',
2609
+ 'menu.deleteLocalN': 'Supprimer {n} branches locales…',
2610
+ 'menu.deleteRemoteN': 'Supprimer {n} branches distantes…',
2611
+ 'menu.deleteTagsN': 'Supprimer {n} étiquettes…',
2612
+ 'confirm.deleteRefs': 'Supprimer les références sélectionnées ? {b} locale(s), {r} distante(s), {g} étiquette(s).',
2613
+ 'confirm.discardMany': 'Abandonner les modifications non commitées de {n} fichiers ?',
2614
+ 'confirm.discardManyUntracked': '{m} fichier(s) non suivi(s) seront définitivement supprimés.',
2615
+ 'toast.branchesDeleted': '{n} branche(s) supprimée(s)',
2445
2616
  'changes.unstageAll': 'Tout désindexer',
2446
2617
  'changes.groupBy': 'Grouper par',
2447
2618
  'changes.groupFlat': 'Liste plate',
@@ -2599,7 +2770,7 @@ window.__ModuleLoader__.load({
2599
2770
  'lf.truncated': 'Affichage des {count} premières lignes',
2600
2771
  'preview.title': 'Aperçu Git',
2601
2772
  'preview.hint': 'Cliquez sur « Afficher le diff » sur un fichier ou un commit : le diff s’ouvre ici, dans la barre latérale.',
2602
- 'settings.rail.title': 'Boutons de la barre d’actions',
2773
+ 'settings.rail.title': 'Barre d’actions',
2603
2774
  'settings.rail.hint': 'Cochez les actions affichées sur la barre. L’ordre est fixe ; décocher masque une action.',
2604
2775
  '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.',
2605
2776
  'settings.commit.title': 'Message de commit par IA',
@@ -2731,6 +2902,25 @@ window.__ModuleLoader__.load({
2731
2902
  'changes.commit': 'Закоммитить',
2732
2903
  'changes.amend': 'Дополнить последний коммит',
2733
2904
  'changes.stageAll': 'Добавить всё в индекс',
2905
+ 'selection.count': 'Выбрано: {n}',
2906
+ 'selection.copied': 'Скопировано: {n}',
2907
+ 'selection.copyPaths': 'Копировать пути',
2908
+ 'selection.clear': 'Снять выделение',
2909
+ 'action.copyName': 'Копировать имя',
2910
+ 'menu.copyNamesN': 'Копировать имён: {n}',
2911
+ 'menu.copyHashesN': 'Копировать хешей: {n}',
2912
+ 'menu.copyPathsN': 'Копировать путей: {n}',
2913
+ 'menu.stageN': 'В индекс: {n}',
2914
+ 'menu.unstageN': 'Из индекса: {n}',
2915
+ 'menu.discardN': 'Отменить изменения: {n}…',
2916
+ 'menu.cherryN': 'Cherry-pick {n} коммит(ов)',
2917
+ 'menu.deleteLocalN': 'Удалить локальных ветвей: {n}…',
2918
+ 'menu.deleteRemoteN': 'Удалить внешних ветвей: {n}…',
2919
+ 'menu.deleteTagsN': 'Удалить меток: {n}…',
2920
+ 'confirm.deleteRefs': 'Удалить выбранные ссылки? Локальных: {b}, внешних: {r}, меток: {g}.',
2921
+ 'confirm.discardMany': 'Отменить незафиксированные изменения в {n} файлах?',
2922
+ 'confirm.discardManyUntracked': 'Будет безвозвратно удалено непрослеживаемых файлов: {m}.',
2923
+ 'toast.branchesDeleted': 'Ветвей удалено: {n}',
2734
2924
  'changes.unstageAll': 'Убрать всё из индекса',
2735
2925
  'changes.groupBy': 'Группировать по',
2736
2926
  'changes.groupFlat': 'Плоский список',
@@ -2888,7 +3078,7 @@ window.__ModuleLoader__.load({
2888
3078
  'lf.truncated': 'Показаны первые {count} строк',
2889
3079
  'preview.title': 'Git-предпросмотр',
2890
3080
  'preview.hint': 'Нажмите «Показать различия» у файла или коммита — различия откроются здесь, в боковой панели.',
2891
- 'settings.rail.title': 'Кнопки панели действий',
3081
+ 'settings.rail.title': 'Панель действий',
2892
3082
  'settings.rail.hint': 'Отметьте действия, показываемые на панели. Порядок фиксирован; снятие флажка скрывает действие.',
2893
3083
  'settings.rail.unavailable': 'На этом хосте нет службы настроек; используйте «Настройки панели действий» в панели.',
2894
3084
  'settings.commit.title': 'Сообщение коммита от ИИ',
@@ -3020,6 +3210,25 @@ window.__ModuleLoader__.load({
3020
3210
  'changes.commit': 'Commitar',
3021
3211
  'changes.amend': 'Corrigir o último commit',
3022
3212
  'changes.stageAll': 'Preparar tudo',
3213
+ 'selection.count': '{n} selecionado(s)',
3214
+ 'selection.copied': '{n} item(ns) copiado(s)',
3215
+ 'selection.copyPaths': 'Copiar caminhos',
3216
+ 'selection.clear': 'Limpar seleção',
3217
+ 'action.copyName': 'Copiar nome',
3218
+ 'menu.copyNamesN': 'Copiar {n} nomes',
3219
+ 'menu.copyHashesN': 'Copiar {n} hashes',
3220
+ 'menu.copyPathsN': 'Copiar {n} caminhos',
3221
+ 'menu.stageN': 'Preparar {n} itens',
3222
+ 'menu.unstageN': 'Despreparar {n} itens',
3223
+ 'menu.discardN': 'Descartar {n} itens…',
3224
+ 'menu.cherryN': 'Cherry-pick de {n} commits',
3225
+ 'menu.deleteLocalN': 'Excluir {n} ramificações locais…',
3226
+ 'menu.deleteRemoteN': 'Excluir {n} ramificações remotas…',
3227
+ 'menu.deleteTagsN': 'Excluir {n} etiquetas…',
3228
+ 'confirm.deleteRefs': 'Excluir as referências selecionadas? {b} locais, {r} remotas, {g} etiqueta(s).',
3229
+ 'confirm.discardMany': 'Descartar as alterações não commitadas de {n} arquivos?',
3230
+ 'confirm.discardManyUntracked': '{m} arquivo(s) não rastreado(s) serão excluídos em definitivo.',
3231
+ 'toast.branchesDeleted': '{n} ramificação(ões) excluída(s)',
3023
3232
  'changes.unstageAll': 'Despreparar tudo',
3024
3233
  'changes.groupBy': 'Agrupar por',
3025
3234
  'changes.groupFlat': 'Lista simples',
@@ -3177,7 +3386,7 @@ window.__ModuleLoader__.load({
3177
3386
  'lf.truncated': 'Mostrando as primeiras {count} linhas',
3178
3387
  'preview.title': 'Pré-visualização do Git',
3179
3388
  'preview.hint': 'Clique em “Mostrar diff” num arquivo ou commit — o diff abre aqui, na barra lateral.',
3180
- 'settings.rail.title': 'Botões da barra de ações',
3389
+ 'settings.rail.title': 'Barra de ações',
3181
3390
  'settings.rail.hint': 'Marque as ações exibidas na barra. A ordem é fixa; desmarcar oculta uma ação.',
3182
3391
  'settings.rail.unavailable': 'Este host não tem serviço de configurações; use «Configurações da barra de ações» no painel.',
3183
3392
  'settings.commit.title': 'Mensagem de commit por IA',
@@ -3309,6 +3518,25 @@ window.__ModuleLoader__.load({
3309
3518
  'changes.commit': 'Esegui commit',
3310
3519
  'changes.amend': 'Correggi l’ultimo commit',
3311
3520
  'changes.stageAll': 'Metti tutto in stage',
3521
+ 'selection.count': '{n} selezionato/i',
3522
+ 'selection.copied': '{n} elemento/i copiato/i',
3523
+ 'selection.copyPaths': 'Copia percorsi',
3524
+ 'selection.clear': 'Azzera selezione',
3525
+ 'action.copyName': 'Copia nome',
3526
+ 'menu.copyNamesN': 'Copia {n} nomi',
3527
+ 'menu.copyHashesN': 'Copia {n} hash',
3528
+ 'menu.copyPathsN': 'Copia {n} percorsi',
3529
+ 'menu.stageN': 'Metti in stage {n} elementi',
3530
+ 'menu.unstageN': 'Rimuovi {n} elementi dallo stage',
3531
+ 'menu.discardN': 'Scarta {n} elementi…',
3532
+ 'menu.cherryN': 'Cherry-pick di {n} commit',
3533
+ 'menu.deleteLocalN': 'Elimina {n} branch locali…',
3534
+ 'menu.deleteRemoteN': 'Elimina {n} branch remoti…',
3535
+ 'menu.deleteTagsN': 'Elimina {n} tag…',
3536
+ 'confirm.deleteRefs': 'Eliminare i riferimenti selezionati? {b} locali, {r} remoti, {g} tag.',
3537
+ 'confirm.discardMany': 'Scartare le modifiche non commitate di {n} file?',
3538
+ 'confirm.discardManyUntracked': '{m} file non tracciati saranno eliminati definitivamente.',
3539
+ 'toast.branchesDeleted': '{n} branch eliminati',
3312
3540
  'changes.unstageAll': 'Rimuovi tutto dallo stage',
3313
3541
  'changes.groupBy': 'Raggruppa per',
3314
3542
  'changes.groupFlat': 'Elenco piatto',
@@ -3466,7 +3694,7 @@ window.__ModuleLoader__.load({
3466
3694
  'lf.truncated': 'Mostrate le prime {count} righe',
3467
3695
  'preview.title': 'Anteprima Git',
3468
3696
  'preview.hint': 'Fai clic su «Mostra diff» su un file o un commit: il diff si apre qui, nella barra laterale.',
3469
- 'settings.rail.title': 'Pulsanti della barra azioni',
3697
+ 'settings.rail.title': 'Barra delle azioni',
3470
3698
  'settings.rail.hint': 'Seleziona le azioni mostrate sulla barra. L’ordine è fisso; deselezionando se ne nasconde una.',
3471
3699
  'settings.rail.unavailable': 'Questo host non ha un servizio di impostazioni; usa «Impostazioni barra azioni» nel pannello.',
3472
3700
  'settings.commit.title': 'Messaggio di commit con IA',
@@ -3598,6 +3826,25 @@ window.__ModuleLoader__.load({
3598
3826
  'changes.commit': 'Committen',
3599
3827
  'changes.amend': 'Laatste commit aanvullen',
3600
3828
  'changes.stageAll': 'Alles stagen',
3829
+ 'selection.count': '{n} geselecteerd',
3830
+ 'selection.copied': '{n} item(s) gekopieerd',
3831
+ 'selection.copyPaths': 'Paden kopiëren',
3832
+ 'selection.clear': 'Selectie wissen',
3833
+ 'action.copyName': 'Naam kopiëren',
3834
+ 'menu.copyNamesN': '{n} namen kopiëren',
3835
+ 'menu.copyHashesN': '{n} hashes kopiëren',
3836
+ 'menu.copyPathsN': '{n} paden kopiëren',
3837
+ 'menu.stageN': '{n} items stagen',
3838
+ 'menu.unstageN': '{n} items unstagen',
3839
+ 'menu.discardN': '{n} items weggooien…',
3840
+ 'menu.cherryN': '{n} commits cherry-picken',
3841
+ 'menu.deleteLocalN': '{n} lokale branches verwijderen…',
3842
+ 'menu.deleteRemoteN': '{n} remote branches verwijderen…',
3843
+ 'menu.deleteTagsN': '{n} tags verwijderen…',
3844
+ 'confirm.deleteRefs': 'De geselecteerde referenties verwijderen? {b} lokaal, {r} remote, {g} tag(s).',
3845
+ 'confirm.discardMany': 'Niet-gecommitte wijzigingen van {n} bestanden weggooien?',
3846
+ 'confirm.discardManyUntracked': '{m} niet-gevolgde bestand(en) worden definitief verwijderd.',
3847
+ 'toast.branchesDeleted': '{n} branch(es) verwijderd',
3601
3848
  'changes.unstageAll': 'Alles unstagen',
3602
3849
  'changes.groupBy': 'Groeperen op',
3603
3850
  'changes.groupFlat': 'Platte lijst',
@@ -3755,7 +4002,7 @@ window.__ModuleLoader__.load({
3755
4002
  'lf.truncated': 'Eerste {count} regels worden weergegeven',
3756
4003
  'preview.title': 'Git-voorbeeld',
3757
4004
  'preview.hint': 'Klik op „Diff tonen“ bij een bestand of commit — de diff opent hier in de zijbalk.',
3758
- 'settings.rail.title': 'Knoppen van de actiebalk',
4005
+ 'settings.rail.title': 'Actiebalk',
3759
4006
  'settings.rail.hint': 'Vink de acties aan die op de balk verschijnen. De volgorde ligt vast; uitvinken verbergt er een.',
3760
4007
  'settings.rail.unavailable': 'Deze host heeft geen instellingenservice; gebruik "Actiebalk-instellingen" in het paneel.',
3761
4008
  'settings.commit.title': 'AI-commitbericht',
@@ -3887,6 +4134,25 @@ window.__ModuleLoader__.load({
3887
4134
  'changes.commit': 'Zatwierdź',
3888
4135
  'changes.amend': 'Popraw ostatni commit',
3889
4136
  'changes.stageAll': 'Dodaj wszystko do indeksu',
4137
+ 'selection.count': 'Wybrano: {n}',
4138
+ 'selection.copied': 'Skopiowano: {n}',
4139
+ 'selection.copyPaths': 'Kopiuj ścieżki',
4140
+ 'selection.clear': 'Wyczyść zaznaczenie',
4141
+ 'action.copyName': 'Kopiuj nazwę',
4142
+ 'menu.copyNamesN': 'Kopiuj {n} nazw',
4143
+ 'menu.copyHashesN': 'Kopiuj {n} skrótów',
4144
+ 'menu.copyPathsN': 'Kopiuj {n} ścieżek',
4145
+ 'menu.stageN': 'Dodaj {n} do indeksu',
4146
+ 'menu.unstageN': 'Usuń {n} z indeksu',
4147
+ 'menu.discardN': 'Odrzuć {n}…',
4148
+ 'menu.cherryN': 'Cherry-pick {n} commit(ów)',
4149
+ 'menu.deleteLocalN': 'Usuń {n} gałęzi lokalnych…',
4150
+ 'menu.deleteRemoteN': 'Usuń {n} gałęzi zdalnych…',
4151
+ 'menu.deleteTagsN': 'Usuń {n} tagów…',
4152
+ 'confirm.deleteRefs': 'Usunąć wybrane referencje? Lokalne: {b}, zdalne: {r}, tagi: {g}.',
4153
+ 'confirm.discardMany': 'Odrzucić niezacommitowane zmiany w {n} plikach?',
4154
+ 'confirm.discardManyUntracked': '{m} nieśledzonych plików zostanie trwale usuniętych.',
4155
+ 'toast.branchesDeleted': 'Usunięto gałęzi: {n}',
3890
4156
  'changes.unstageAll': 'Usuń wszystko z indeksu',
3891
4157
  'changes.groupBy': 'Grupuj według',
3892
4158
  'changes.groupFlat': 'Płaska lista',
@@ -4044,7 +4310,7 @@ window.__ModuleLoader__.load({
4044
4310
  'lf.truncated': 'Pokazano pierwsze {count} wierszy',
4045
4311
  'preview.title': 'Podgląd Git',
4046
4312
  'preview.hint': 'Kliknij „Pokaż różnice” przy pliku lub commicie — różnice otworzą się tutaj, na pasku bocznym.',
4047
- 'settings.rail.title': 'Przyciski paska akcji',
4313
+ 'settings.rail.title': 'Pasek akcji',
4048
4314
  'settings.rail.hint': 'Zaznacz akcje widoczne na pasku. Kolejność jest stała; odznaczenie ukrywa akcję.',
4049
4315
  'settings.rail.unavailable': 'Ten host nie ma usługi ustawień; użyj „Ustawienia paska akcji” w panelu.',
4050
4316
  'settings.commit.title': 'Wiadomość commita od AI',
@@ -4176,6 +4442,25 @@ window.__ModuleLoader__.load({
4176
4442
  'changes.commit': 'Committa',
4177
4443
  'changes.amend': 'Komplettera senaste commit',
4178
4444
  'changes.stageAll': 'Klädda alla',
4445
+ 'selection.count': '{n} markerade',
4446
+ 'selection.copied': 'Kopierade {n} objekt',
4447
+ 'selection.copyPaths': 'Kopiera sökvägar',
4448
+ 'selection.clear': 'Rensa markering',
4449
+ 'action.copyName': 'Kopiera namn',
4450
+ 'menu.copyNamesN': 'Kopiera {n} namn',
4451
+ 'menu.copyHashesN': 'Kopiera {n} hashar',
4452
+ 'menu.copyPathsN': 'Kopiera {n} sökvägar',
4453
+ 'menu.stageN': 'Klädda {n} objekt',
4454
+ 'menu.unstageN': 'Avklädda {n} objekt',
4455
+ 'menu.discardN': 'Förkasta {n} objekt…',
4456
+ 'menu.cherryN': 'Cherry-picka {n} commit(s)',
4457
+ 'menu.deleteLocalN': 'Ta bort {n} lokala grenar…',
4458
+ 'menu.deleteRemoteN': 'Ta bort {n} fjärrgrenar…',
4459
+ 'menu.deleteTagsN': 'Ta bort {n} taggar…',
4460
+ 'confirm.deleteRefs': 'Ta bort markerade referenser? {b} lokala, {r} fjärr, {g} taggar.',
4461
+ 'confirm.discardMany': 'Förkasta icke-committade ändringar i {n} filer?',
4462
+ 'confirm.discardManyUntracked': '{m} ospårade filer tas bort permanent.',
4463
+ 'toast.branchesDeleted': 'Tog bort {n} gren(ar)',
4179
4464
  'changes.unstageAll': 'Avklädda alla',
4180
4465
  'changes.groupBy': 'Gruppera efter',
4181
4466
  'changes.groupFlat': 'Platt lista',
@@ -4333,7 +4618,7 @@ window.__ModuleLoader__.load({
4333
4618
  'lf.truncated': 'Visar de första {count} raderna',
4334
4619
  'preview.title': 'Git-förhandsvisning',
4335
4620
  'preview.hint': 'Klicka på ”Visa diff” på en fil eller en commit — diffen öppnas här i sidopanelen.',
4336
- 'settings.rail.title': 'Knappar i åtgärdsfältet',
4621
+ 'settings.rail.title': 'Åtgärdsfältet',
4337
4622
  'settings.rail.hint': 'Markera de åtgärder som visas i fältet. Ordningen är fast; avmarkering döljer en.',
4338
4623
  'settings.rail.unavailable': 'Denna värd saknar inställningstjänst; använd ”Åtgärdsfältets inställningar” i panelen.',
4339
4624
  'settings.commit.title': 'AI-commitmeddelande',
@@ -4465,6 +4750,25 @@ window.__ModuleLoader__.load({
4465
4750
  'changes.commit': 'Commit’le',
4466
4751
  'changes.amend': 'Son commit’i düzelt',
4467
4752
  'changes.stageAll': 'Tümünü hazırla',
4753
+ 'selection.count': '{n} seçili',
4754
+ 'selection.copied': '{n} öğe kopyalandı',
4755
+ 'selection.copyPaths': 'Yolları kopyala',
4756
+ 'selection.clear': 'Seçimi temizle',
4757
+ 'action.copyName': 'Adı kopyala',
4758
+ 'menu.copyNamesN': '{n} adı kopyala',
4759
+ 'menu.copyHashesN': '{n} karmayı kopyala',
4760
+ 'menu.copyPathsN': '{n} yolu kopyala',
4761
+ 'menu.stageN': '{n} öğeyi hazırla',
4762
+ 'menu.unstageN': '{n} öğeyi hazırlıktan çıkar',
4763
+ 'menu.discardN': '{n} öğeyi at…',
4764
+ 'menu.cherryN': '{n} commit seçip al',
4765
+ 'menu.deleteLocalN': '{n} yerel dalı sil…',
4766
+ 'menu.deleteRemoteN': '{n} uzak dalı sil…',
4767
+ 'menu.deleteTagsN': '{n} etiketi sil…',
4768
+ 'confirm.deleteRefs': 'Seçili referanslar silinsin mi? {b} yerel, {r} uzak, {g} etiket.',
4769
+ 'confirm.discardMany': '{n} dosyanın commitlenmemiş değişiklikleri atılsın mı?',
4770
+ 'confirm.discardManyUntracked': '{m} izlenmeyen dosya kalıcı olarak silinecek.',
4771
+ 'toast.branchesDeleted': '{n} dal silindi',
4468
4772
  'changes.unstageAll': 'Tümünü hazırlıktan çıkar',
4469
4773
  'changes.groupBy': 'Gruplama ölçütü',
4470
4774
  'changes.groupFlat': 'Düz liste',
@@ -4622,7 +4926,7 @@ window.__ModuleLoader__.load({
4622
4926
  'lf.truncated': 'Yalnızca ilk {count} satır gösteriliyor',
4623
4927
  'preview.title': 'Git Önizlemesi',
4624
4928
  'preview.hint': 'Bir dosya veya commit üzerinde „Farkı göster”e tıklayın — fark burada, kenar çubuğunda açılır.',
4625
- 'settings.rail.title': 'Eylem çubuğu düğmeleri',
4929
+ 'settings.rail.title': 'Eylem çubuğu',
4626
4930
  'settings.rail.hint': 'Çubukta gösterilecek eylemleri işaretleyin. Sıra sabittir; işareti kaldırmak eylemi gizler.',
4627
4931
  'settings.rail.unavailable': 'Bu ana bilgisayarda ayar hizmeti yok; paneldeki "Eylem çubuğu ayarları"nı kullanın.',
4628
4932
  'settings.commit.title': 'AI commit mesajı',
@@ -4754,6 +5058,25 @@ window.__ModuleLoader__.load({
4754
5058
  'changes.commit': 'Commit',
4755
5059
  'changes.amend': 'Perbaiki commit terakhir',
4756
5060
  'changes.stageAll': 'Stage semua',
5061
+ 'selection.count': '{n} dipilih',
5062
+ 'selection.copied': '{n} item disalin',
5063
+ 'selection.copyPaths': 'Salin jalur',
5064
+ 'selection.clear': 'Hapus pilihan',
5065
+ 'action.copyName': 'Salin nama',
5066
+ 'menu.copyNamesN': 'Salin {n} nama',
5067
+ 'menu.copyHashesN': 'Salin {n} hash',
5068
+ 'menu.copyPathsN': 'Salin {n} jalur',
5069
+ 'menu.stageN': 'Stage {n} item',
5070
+ 'menu.unstageN': 'Batalkan stage {n} item',
5071
+ 'menu.discardN': 'Buang {n} item…',
5072
+ 'menu.cherryN': 'Cherry-pick {n} commit',
5073
+ 'menu.deleteLocalN': 'Hapus {n} cabang lokal…',
5074
+ 'menu.deleteRemoteN': 'Hapus {n} cabang remote…',
5075
+ 'menu.deleteTagsN': 'Hapus {n} tag…',
5076
+ 'confirm.deleteRefs': 'Hapus referensi terpilih? {b} lokal, {r} remote, {g} tag.',
5077
+ 'confirm.discardMany': 'Buang perubahan yang belum di-commit dari {n} berkas?',
5078
+ 'confirm.discardManyUntracked': '{m} berkas yang tidak dilacak akan dihapus permanen.',
5079
+ 'toast.branchesDeleted': '{n} cabang dihapus',
4757
5080
  'changes.unstageAll': 'Batalkan stage semua',
4758
5081
  'changes.groupBy': 'Kelompokkan menurut',
4759
5082
  'changes.groupFlat': 'Daftar datar',
@@ -4911,7 +5234,7 @@ window.__ModuleLoader__.load({
4911
5234
  'lf.truncated': 'Menampilkan {count} baris pertama',
4912
5235
  'preview.title': 'Pratinjau Git',
4913
5236
  'preview.hint': 'Klik “Tampilkan diff” pada file atau commit — diff terbuka di sini, di bilah sisi.',
4914
- 'settings.rail.title': 'Tombol bilah aksi',
5237
+ 'settings.rail.title': 'Bilah aksi',
4915
5238
  'settings.rail.hint': 'Centang aksi yang ditampilkan di bilah. Urutannya tetap; menghapus centang menyembunyikannya.',
4916
5239
  'settings.rail.unavailable': 'Host ini tidak punya layanan pengaturan; gunakan "Pengaturan bilah aksi" di panel.',
4917
5240
  'settings.commit.title': 'Pesan commit AI',
@@ -5043,6 +5366,25 @@ window.__ModuleLoader__.load({
5043
5366
  'changes.commit': 'Commit',
5044
5367
  'changes.amend': 'Sửa commit gần nhất',
5045
5368
  'changes.stageAll': 'Stage tất cả',
5369
+ 'selection.count': 'Đã chọn {n}',
5370
+ 'selection.copied': 'Đã sao chép {n} mục',
5371
+ 'selection.copyPaths': 'Sao chép đường dẫn',
5372
+ 'selection.clear': 'Bỏ chọn',
5373
+ 'action.copyName': 'Sao chép tên',
5374
+ 'menu.copyNamesN': 'Sao chép {n} tên',
5375
+ 'menu.copyHashesN': 'Sao chép {n} hash',
5376
+ 'menu.copyPathsN': 'Sao chép {n} đường dẫn',
5377
+ 'menu.stageN': 'Stage {n} mục',
5378
+ 'menu.unstageN': 'Bỏ stage {n} mục',
5379
+ 'menu.discardN': 'Bỏ {n} mục…',
5380
+ 'menu.cherryN': 'Cherry-pick {n} commit',
5381
+ 'menu.deleteLocalN': 'Xoá {n} nhánh cục bộ…',
5382
+ 'menu.deleteRemoteN': 'Xoá {n} nhánh từ xa…',
5383
+ 'menu.deleteTagsN': 'Xoá {n} thẻ…',
5384
+ 'confirm.deleteRefs': 'Xoá các tham chiếu đã chọn? {b} cục bộ, {r} từ xa, {g} thẻ.',
5385
+ 'confirm.discardMany': 'Bỏ thay đổi chưa commit của {n} tệp?',
5386
+ 'confirm.discardManyUntracked': '{m} tệp chưa theo dõi sẽ bị xoá vĩnh viễn.',
5387
+ 'toast.branchesDeleted': 'Đã xoá {n} nhánh',
5046
5388
  'changes.unstageAll': 'Bỏ stage tất cả',
5047
5389
  'changes.groupBy': 'Nhóm theo',
5048
5390
  'changes.groupFlat': 'Danh sách phẳng',
@@ -5200,7 +5542,7 @@ window.__ModuleLoader__.load({
5200
5542
  'lf.truncated': 'Chỉ hiện {count} dòng đầu',
5201
5543
  'preview.title': 'Xem trước Git',
5202
5544
  'preview.hint': 'Bấm “Xem diff” trên tệp hoặc commit — diff sẽ mở ở đây, trên thanh bên.',
5203
- 'settings.rail.title': 'Nút thanh tác vụ',
5545
+ 'settings.rail.title': 'Thanh tác vụ',
5204
5546
  '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ụ đó.',
5205
5547
  '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.',
5206
5548
  'settings.commit.title': 'Thông điệp commit do AI viết',
@@ -5332,6 +5674,25 @@ window.__ModuleLoader__.load({
5332
5674
  'changes.commit': 'التزام',
5333
5675
  'changes.amend': 'تعديل الالتزام الأخير',
5334
5676
  'changes.stageAll': 'جهّز الكل',
5677
+ 'selection.count': 'تم تحديد {n}',
5678
+ 'selection.copied': 'تم نسخ {n} عنصرًا',
5679
+ 'selection.copyPaths': 'انسخ المسارات',
5680
+ 'selection.clear': 'امسح التحديد',
5681
+ 'action.copyName': 'انسخ الاسم',
5682
+ 'menu.copyNamesN': 'انسخ {n} اسمًا',
5683
+ 'menu.copyHashesN': 'انسخ {n} تجزئة',
5684
+ 'menu.copyPathsN': 'انسخ {n} مسارًا',
5685
+ 'menu.stageN': 'جهّز {n} عنصرًا',
5686
+ 'menu.unstageN': 'ألغِ تجهيز {n} عنصرًا',
5687
+ 'menu.discardN': 'تجاهل {n} عنصرًا…',
5688
+ 'menu.cherryN': 'Cherry-pick لـ {n} إيداع',
5689
+ 'menu.deleteLocalN': 'احذف {n} فرعًا محليًا…',
5690
+ 'menu.deleteRemoteN': 'احذف {n} فرعًا بعيدًا…',
5691
+ 'menu.deleteTagsN': 'احذف {n} وسمًا…',
5692
+ 'confirm.deleteRefs': 'حذف المراجع المحددة؟ {b} محلية و{r} بعيدة و{g} وسوم.',
5693
+ 'confirm.discardMany': 'تجاهل التغييرات غير المودعة في {n} ملفًا؟',
5694
+ 'confirm.discardManyUntracked': '{m} ملفًا غير متتبَّع سيُحذف نهائيًا.',
5695
+ 'toast.branchesDeleted': 'تم حذف {n} فرعًا',
5335
5696
  'changes.unstageAll': 'ألغِ تجهيز الكل',
5336
5697
  'changes.groupBy': 'التجميع حسب',
5337
5698
  'changes.groupFlat': 'قائمة مسطّحة',
@@ -5489,7 +5850,7 @@ window.__ModuleLoader__.load({
5489
5850
  'lf.truncated': 'عرض أول {count} سطرًا فقط',
5490
5851
  'preview.title': 'معاينة Git',
5491
5852
  'preview.hint': 'انقر «عرض الفروق» على ملف أو commit — تُفتح الفروق هنا في الشريط الجانبي.',
5492
- 'settings.rail.title': 'أزرار شريط الإجراءات',
5853
+ 'settings.rail.title': 'شريط الإجراءات',
5493
5854
  'settings.rail.hint': 'حدّد الإجراءات المعروضة على الشريط. الترتيب ثابت، وإلغاء التحديد يخفي الإجراء.',
5494
5855
  'settings.rail.unavailable': 'لا تتوفر خدمة إعدادات على هذا المضيف؛ استخدم «إعدادات شريط الإجراءات» في اللوحة.',
5495
5856
  'settings.commit.title': 'رسالة إيداع بالذكاء الاصطناعي',
@@ -5621,6 +5982,25 @@ window.__ModuleLoader__.load({
5621
5982
  'changes.commit': 'कमिट करें',
5622
5983
  'changes.amend': 'पिछला कमिट सुधारें',
5623
5984
  'changes.stageAll': 'सभी स्टेज करें',
5985
+ 'selection.count': '{n} चयनित',
5986
+ 'selection.copied': '{n} आइटम कॉपी किए',
5987
+ 'selection.copyPaths': 'पाथ कॉपी करें',
5988
+ 'selection.clear': 'चयन हटाएँ',
5989
+ 'action.copyName': 'नाम कॉपी करें',
5990
+ 'menu.copyNamesN': '{n} नाम कॉपी करें',
5991
+ 'menu.copyHashesN': '{n} हैश कॉपी करें',
5992
+ 'menu.copyPathsN': '{n} पाथ कॉपी करें',
5993
+ 'menu.stageN': '{n} आइटम स्टेज करें',
5994
+ 'menu.unstageN': '{n} आइटम अनस्टेज करें',
5995
+ 'menu.discardN': '{n} आइटम छोड़ें…',
5996
+ 'menu.cherryN': '{n} कमिट चेरी-पिक करें',
5997
+ 'menu.deleteLocalN': '{n} लोकल ब्रांच हटाएँ…',
5998
+ 'menu.deleteRemoteN': '{n} रिमोट ब्रांच हटाएँ…',
5999
+ 'menu.deleteTagsN': '{n} टैग हटाएँ…',
6000
+ 'confirm.deleteRefs': 'चयनित रेफ़रेंस हटाएँ? लोकल {b}, रिमोट {r}, टैग {g}।',
6001
+ 'confirm.discardMany': '{n} फ़ाइलों के अनकमिटेड बदलाव छोड़ें?',
6002
+ 'confirm.discardManyUntracked': 'इनमें {m} अनट्रैक्ड फ़ाइलें स्थायी रूप से हट जाएँगी।',
6003
+ 'toast.branchesDeleted': '{n} ब्रांच हटाई गईं',
5624
6004
  'changes.unstageAll': 'सभी अनस्टेज करें',
5625
6005
  'changes.groupBy': 'इसके अनुसार समूह',
5626
6006
  'changes.groupFlat': 'सपाट सूची',
@@ -5778,7 +6158,7 @@ window.__ModuleLoader__.load({
5778
6158
  'lf.truncated': 'केवल पहली {count} पंक्तियाँ दिखाई जा रही हैं',
5779
6159
  'preview.title': 'Git पूर्वावलोकन',
5780
6160
  'preview.hint': 'फ़ाइल या कमिट पर “अंतर दिखाएँ” पर क्लिक करें — अंतर यहीं साइडबार में खुलेगा।',
5781
- 'settings.rail.title': 'एक्शन रेल बटन',
6161
+ 'settings.rail.title': 'क्रिया रेल',
5782
6162
  'settings.rail.hint': 'रेल पर दिखने वाली क्रियाएँ चुनें। क्रम निश्चित है; चुनाव हटाने पर वह छिप जाती है।',
5783
6163
  'settings.rail.unavailable': 'इस होस्ट में सेटिंग सेवा नहीं है; पैनल में «एक्शन रेल सेटिंग» का उपयोग करें।',
5784
6164
  'settings.commit.title': 'AI कमिट संदेश',
@@ -5910,6 +6290,25 @@ window.__ModuleLoader__.load({
5910
6290
  'changes.commit': 'คอมมิต',
5911
6291
  'changes.amend': 'แก้ไขคอมมิตล่าสุด',
5912
6292
  'changes.stageAll': 'สเตจทั้งหมด',
6293
+ 'selection.count': 'เลือก {n} รายการ',
6294
+ 'selection.copied': 'คัดลอก {n} รายการแล้ว',
6295
+ 'selection.copyPaths': 'คัดลอกพาธ',
6296
+ 'selection.clear': 'ล้างการเลือก',
6297
+ 'action.copyName': 'คัดลอกชื่อ',
6298
+ 'menu.copyNamesN': 'คัดลอกชื่อ {n} รายการ',
6299
+ 'menu.copyHashesN': 'คัดลอกแฮช {n} รายการ',
6300
+ 'menu.copyPathsN': 'คัดลอกพาธ {n} รายการ',
6301
+ 'menu.stageN': 'สเตจ {n} รายการ',
6302
+ 'menu.unstageN': 'ยกเลิกสเตจ {n} รายการ',
6303
+ 'menu.discardN': 'ทิ้ง {n} รายการ…',
6304
+ 'menu.cherryN': 'Cherry-pick {n} คอมมิต',
6305
+ 'menu.deleteLocalN': 'ลบแบรนช์ในเครื่อง {n} รายการ…',
6306
+ 'menu.deleteRemoteN': 'ลบแบรนช์รีโมต {n} รายการ…',
6307
+ 'menu.deleteTagsN': 'ลบแท็ก {n} รายการ…',
6308
+ 'confirm.deleteRefs': 'ลบรีเฟอเรนซ์ที่เลือก? ในเครื่อง {b} รีโมต {r} แท็ก {g}',
6309
+ 'confirm.discardMany': 'ทิ้งการเปลี่ยนแปลงที่ยังไม่คอมมิตของ {n} ไฟล์?',
6310
+ 'confirm.discardManyUntracked': 'ไฟล์ที่ไม่ได้ติดตาม {m} ไฟล์จะถูกลบถาวร',
6311
+ 'toast.branchesDeleted': 'ลบแบรนช์ {n} รายการแล้ว',
5913
6312
  'changes.unstageAll': 'ยกเลิกสเตจทั้งหมด',
5914
6313
  'changes.groupBy': 'จัดกลุ่มตาม',
5915
6314
  'changes.groupFlat': 'รายการแบน',
@@ -6067,7 +6466,7 @@ window.__ModuleLoader__.load({
6067
6466
  'lf.truncated': 'แสดงเฉพาะ {count} บรรทัดแรก',
6068
6467
  'preview.title': 'ดูตัวอย่าง Git',
6069
6468
  'preview.hint': 'คลิก “แสดงความต่าง” ที่ไฟล์หรือคอมมิต — ความต่างจะเปิดที่นี่ในแถบข้าง',
6070
- 'settings.rail.title': 'ปุ่มแถบการทำงาน',
6469
+ 'settings.rail.title': 'แถบการทำงาน',
6071
6470
  'settings.rail.hint': 'ทำเครื่องหมายการทำงานที่จะแสดงบนแถบ ลำดับคงที่; ยกเลิกเครื่องหมายเพื่อซ่อน',
6072
6471
  'settings.rail.unavailable': 'โฮสต์นี้ไม่มีบริการตั้งค่า ให้ใช้ «ตั้งค่าแถบการทำงาน» ในแผง',
6073
6472
  'settings.commit.title': 'ข้อความคอมมิตโดย AI',
@@ -6226,6 +6625,39 @@ window.__ModuleLoader__.load({
6226
6625
  })
6227
6626
  }
6228
6627
 
6628
+ /* ---- multi-select shared helpers (selection episode, issue #13) ---- */
6629
+
6630
+ /* Keyboard shortcuts (Ctrl+C / Del / Ctrl+A / Esc) must never hijack the
6631
+ user's own typing or the browser's native copy inside a field. */
6632
+ function isTypingTarget(target) {
6633
+ if (target === null || target === undefined || (target instanceof Element) === false) return false
6634
+ const tag = target.tagName
6635
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true
6636
+ }
6637
+
6638
+ /* The modifier that toggles selection: Cmd on macOS, Ctrl everywhere else
6639
+ (VS Code's list convention). Plain Shift is always the range modifier. */
6640
+ function isToggleModifier(event) {
6641
+ return event.metaKey === true || event.ctrlKey === true
6642
+ }
6643
+
6644
+ /* A branch entry's selection key: the kind prefix keeps the (rare) case of
6645
+ a branch and a tag sharing one name from colliding inside one Set. */
6646
+ function branchSelKeyOf(entry) {
6647
+ const kind = entry.tag === true ? 'tag' : entry.remote === true ? 'remote' : 'local'
6648
+ return kind + ':' + entry.name
6649
+ }
6650
+
6651
+ /* Join selected values for the clipboard — one per line, the shape a
6652
+ terminal or an editor expects when pasting a list. */
6653
+ function clipboardLines(values) {
6654
+ return values.join('\n')
6655
+ }
6656
+
6657
+ /* Shared frozen-by-convention empty selection: selection Sets are never
6658
+ mutated in place, so one instance is safe to hand around. */
6659
+ const EMPTY_SELECTION = new Set()
6660
+
6229
6661
  /* ============================== api ============================== */
6230
6662
 
6231
6663
  /* ---- remote channel fallback core (pure: tests/smoke.mjs slices this
@@ -8574,6 +9006,31 @@ window.__ModuleLoader__.load({
8574
9006
  }, children),
8575
9007
  hint === null ? null : E('div', { className: 'dig-settings-subnote' }, hint))
8576
9008
  }
9009
+ /* Collapsible sections, HAND-DRAWN. The official disclosure row was ruled
9010
+ out the same way the sibling dsh-gitbash-shell ruled it out (v0.38.0 -
9011
+ 0.38.3): the ui-primitives module shape this plugin's restricted require
9012
+ returns does not expose it, so a borrowed component silently degrades to
9013
+ a flat title+stack pile. One button + one conditional draws the whole
9014
+ thing: the header row always renders, the body only while open, and all
9015
+ sections ship collapsed. State lives with the other hooks, above every
9016
+ early return, so a status flip cannot reorder them. */
9017
+ const [folds, setFolds] = useState({})
9018
+ const toggleFold = (key) => {
9019
+ setFolds((previous) => Object.assign({}, previous, { [key]: !(previous[key] === true) }))
9020
+ }
9021
+ const fold = (titleKey, ...body) => {
9022
+ const open = folds[titleKey] === true
9023
+ return E('div', { className: 'dig-fold' },
9024
+ E('button', {
9025
+ type: 'button',
9026
+ className: 'dig-foldHead',
9027
+ 'aria-expanded': open ? 'true' : 'false',
9028
+ onClick: () => toggleFold(titleKey),
9029
+ },
9030
+ E('span', { className: 'dig-foldChevron' + (open ? ' dig-foldChevronOpen' : ''), 'aria-hidden': 'true' }, '▸'),
9031
+ E('span', { className: 'dig-foldTitle' }, t(titleKey))),
9032
+ open ? E('div', { className: 'dig-stack' }, body) : null)
9033
+ }
8577
9034
  const flush = () => {
8578
9035
  pendingFlush.current = null
8579
9036
  /* The pending TARGET per field, not "whatever the render-time intent
@@ -8728,9 +9185,14 @@ window.__ModuleLoader__.load({
8728
9185
  and a sentence, not controls that silently do nothing. */
8729
9186
  const readOnly = snapshot.writable !== true
8730
9187
  return wrapCard(E('div', { className: 'dig-settings dig-card-body', 'data-settings-status': status, 'data-settings-readonly': readOnly === true ? 'true' : 'false' },
8731
- E('div', { className: 'dig-settings-hint' }, t('settings.rail.hint')),
8732
9188
  E('div', { className: readOnly === true ? 'dig-settings-note' : 'dig-settings-note dig-settings-note-hidden', 'data-settings-readonly-note': '' }, t('settings.rail.readonly')),
8733
9189
  note === '' ? null : E('div', { className: 'dig-settings-error', 'data-settings-error': '' }, note),
9190
+ /* Three collapsible sections, one per functional domain — the header
9191
+ rows are what a collapsed card shows. The readonly/error notes stay
9192
+ OUTSIDE them: they are status, not settings, and must stay visible.
9193
+ Section 1 carries its own hint (it describes exactly this list). */
9194
+ fold('settings.rail.title',
9195
+ E('div', { className: 'dig-settings-hint' }, t('settings.rail.hint')),
8734
9196
  E('div', { className: 'dig-settings-list' }, RAIL_SPECS.map((spec) => {
8735
9197
  const inputId = 'dig-rail-' + spec.id
8736
9198
  const visible = shown(spec)
@@ -8778,11 +9240,11 @@ window.__ModuleLoader__.load({
8778
9240
  }),
8779
9241
  E('span', { className: 'dig-settings-glyph' }, E(Icon, { name: spec.icon, size: 13 })),
8780
9242
  E('span', { id: inputId + '-label', className: 'dig-settings-label', onClick: toggle }, t(spec.key)))
8781
- })),
8782
- /* Placement (issue #8): one switch, the same optimistic-write contract
8783
- the rail rows keep. The conversation tab registers/unregisters live
8784
- (see door 3 in apply), so the flip is visible without a reload. */
8785
- E('div', { className: 'dig-settings-sub' }, t('settings.placement.title')),
9243
+ }))),
9244
+ /* Section 2 — placement (issue #8): one switch, the same optimistic-write
9245
+ contract the rail rows keep. The conversation tab registers/unregisters
9246
+ live (see door 3 in apply), so the flip is visible without a reload. */
9247
+ fold('settings.placement.title',
8786
9248
  (() => {
8787
9249
  const field = 'conversationTab'
8788
9250
  const inputId = 'dig-placement-' + field
@@ -8865,8 +9327,10 @@ window.__ModuleLoader__.load({
8865
9327
  E('span', { className: 'dig-settings-glyph' }, E(Icon, { name: 'commit', size: 13 })),
8866
9328
  E('span', { id: inputId + '-label', className: 'dig-settings-label', onClick: toggle }, t('settings.placement.immersive'))),
8867
9329
  E('div', { className: 'dig-settings-subnote' }, t('settings.placement.immersiveHint')))
8868
- })(),
8869
- E('div', { className: 'dig-settings-sub' }, t('settings.commit.title')),
9330
+ })()),
9331
+ /* Section 3 — the AI commit message (issue #6): model, reasoning and the
9332
+ prompt template. */
9333
+ fold('settings.commit.title',
8870
9334
  (() => {
8871
9335
  /* The model row is a picker over the host's provider catalog; while
8872
9336
  the catalog has not answered (or cannot), the row stays the plain
@@ -8912,7 +9376,7 @@ window.__ModuleLoader__.load({
8912
9376
  }
8913
9377
  return textRow('commitReasoning', 'settings.commit.reasoning', 'settings.commit.reasoningHint', 60, false)
8914
9378
  })(),
8915
- textRow('commitPrompt', 'settings.commit.prompt', 'settings.commit.promptHint', 2000, true)))
9379
+ textRow('commitPrompt', 'settings.commit.prompt', 'settings.commit.promptHint', 2000, true))))
8916
9380
  }
8917
9381
 
8918
9382
  function RailSettings(props) {
@@ -9259,14 +9723,16 @@ window.__ModuleLoader__.load({
9259
9723
  const tone = kind === 'tag' ? 'warn' : kind === 'remote' ? 'violet' : kind === 'head' ? 'success' : 'accent'
9260
9724
  const glyph = kind === 'tag' ? 'tag' : kind === 'remote' ? 'fetch' : kind === 'head' ? 'star' : 'branch'
9261
9725
  const where = entry.upstream === null || entry.upstream === undefined ? entry.name : entry.name + ' → ' + entry.upstream
9262
- // A single click only SELECTS: checking a branch out is a real change of
9263
- // working tree, and a stray click in a long list must not do that (reported
9264
- // on v0.3.7). Double-click checks out, right-click has the whole menu.
9726
+ // A single click only SELECTS (now with Shift/Ctrl+click multi-select —
9727
+ // the modifiers are resolved one level up in BranchTree): checking a
9728
+ // branch out is a real change of working tree, and a stray click in a
9729
+ // long list must not do that (reported on v0.3.7). Double-click checks
9730
+ // out, right-click has the whole menu.
9265
9731
  return E('div', {
9266
- className: 'dig-row dig-row-' + kind + (props.picked === true ? ' dig-row-picked' : ''),
9732
+ className: 'dig-row dig-row-' + kind + (props.selected === true ? ' dig-row-selected' : ''),
9267
9733
  style: { paddingLeft: (BRANCH_INDENT_BASE + (props.depth === undefined ? 0 : props.depth) * BRANCH_INDENT_STEP) + 'px' },
9268
9734
  title: where + ' · ' + t('branches.pickHint'),
9269
- onClick: () => props.onPick(entry),
9735
+ onClick: (event) => props.onPick(event, entry, props.selKey),
9270
9736
  onDoubleClick: () => props.onCheckout(entry),
9271
9737
  onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, entry) },
9272
9738
  },
@@ -9287,7 +9753,12 @@ window.__ModuleLoader__.load({
9287
9753
  const t = props.t
9288
9754
  const [filter, setFilter] = useState('')
9289
9755
  const [collapsed, setCollapsed] = useState(() => treeSectionsOf(readTreeState()))
9290
- const [picked, setPicked] = useState('')
9756
+ /* Multi-select (issue #13): the old single `picked` name becomes a Set of
9757
+ selKeys (`local:name` / `remote:name` / `tag:name` — the prefix keeps a
9758
+ branch and a tag that share a name from colliding). */
9759
+ const [picks, setPicks] = useState(() => new Set())
9760
+ const pickAnchor = useRef(null)
9761
+ const visibleRef = useRef([])
9291
9762
  const [foldedFolders, setFoldedFolders] = useState(() => {
9292
9763
  const stored = readTreeState()
9293
9764
  return stored !== null && typeof stored === 'object' && stored.folders !== null && typeof stored.folders === 'object'
@@ -9310,6 +9781,21 @@ window.__ModuleLoader__.load({
9310
9781
  const remotes = (branches === null ? [] : branches.remote).filter((entry) => match(entry.name))
9311
9782
  const tags = (branches === null ? [] : branches.tags).filter((entry) => match(entry.name))
9312
9783
  const favorites = Array.isArray(props.favorites) ? props.favorites : []
9784
+ /* A refresh that makes a picked ref disappear must not keep it selected. */
9785
+ useEffect(() => {
9786
+ setPicks((previous) => {
9787
+ if (previous.size === 0) return previous
9788
+ const alive = new Set()
9789
+ for (const entry of locals) alive.add(branchSelKeyOf(entry))
9790
+ for (const entry of remotes) alive.add(branchSelKeyOf(entry))
9791
+ for (const entry of tags) alive.add(branchSelKeyOf(entry))
9792
+ const next = new Set()
9793
+ for (const key of previous) {
9794
+ if (alive.has(key) === true) next.add(key)
9795
+ }
9796
+ return next.size === previous.size ? previous : next
9797
+ })
9798
+ }, [branches])
9313
9799
  /**
9314
9800
  * Branch names nest on '/', the way an IDE shows a branch namespace: clicking a
9315
9801
  * row only SELECTS it (checking out rewrites the working tree), so the row
@@ -9330,11 +9816,48 @@ window.__ModuleLoader__.load({
9330
9816
  return root
9331
9817
  }
9332
9818
  const countLeaves = (node) => node.leaves.length + Array.from(node.folders.values()).reduce((sum, child) => sum + countLeaves(child), 0)
9819
+ /* Depth-first flatten in the EXACT order the tree renders them: expanded
9820
+ folders before their leaves. Shift-ranges and Ctrl+A walk this one
9821
+ order; folded folders contribute nothing (their rows are not shown). */
9822
+ const flatLeaves = (node, prefix, depth, out) => {
9823
+ for (const name of Array.from(node.folders.keys()).sort()) {
9824
+ const id = prefix === '' ? name : prefix + '/' + name
9825
+ if (foldedFolders[id] !== true) flatLeaves(node.folders.get(name), id, depth + 1, out)
9826
+ }
9827
+ for (const leaf of node.leaves) out.push({ entry: leaf.entry, label: leaf.label, depth: depth })
9828
+ return out
9829
+ }
9830
+ const handlePick = useCallback((event, entry, selKey) => {
9831
+ const ordered = visibleRef.current.map((leaf) => branchSelKeyOf(leaf.entry))
9832
+ if (event.shiftKey === true && pickAnchor.current !== null) {
9833
+ const from = ordered.indexOf(pickAnchor.current)
9834
+ const to = ordered.indexOf(selKey)
9835
+ if (from >= 0 && to >= 0) {
9836
+ const low = Math.min(from, to)
9837
+ const high = Math.max(from, to)
9838
+ const next = new Set(picks)
9839
+ for (let index = low; index <= high; index += 1) next.add(ordered[index])
9840
+ setPicks(next)
9841
+ return
9842
+ }
9843
+ }
9844
+ if (isToggleModifier(event) === true) {
9845
+ const next = new Set(picks)
9846
+ if (next.has(selKey) === true) next.delete(selKey)
9847
+ else next.add(selKey)
9848
+ pickAnchor.current = selKey
9849
+ setPicks(next)
9850
+ return
9851
+ }
9852
+ pickAnchor.current = selKey
9853
+ setPicks(new Set([selKey]))
9854
+ }, [picks])
9333
9855
  const branchRow = (entry, label, depth) => E(BranchRow, {
9334
- key: 'row:' + entry.name, entry: entry, label: label, depth: depth, t: t,
9335
- picked: picked === entry.name,
9336
- onPick: (target) => setPicked(target.name),
9337
- onCheckout: props.onCheckout, onMenu: props.onBranchMenu,
9856
+ key: 'row:' + branchSelKeyOf(entry), entry: entry, label: label, depth: depth, t: t,
9857
+ selKey: branchSelKeyOf(entry),
9858
+ selected: picks.has(branchSelKeyOf(entry)) === true,
9859
+ onPick: handlePick,
9860
+ onCheckout: props.onCheckout, onMenu: onRowMenu,
9338
9861
  })
9339
9862
  const rowsOf = (node, prefix, depth) => {
9340
9863
  const out = []
@@ -9369,7 +9892,71 @@ window.__ModuleLoader__.load({
9369
9892
  grouped === true
9370
9893
  ? rowsOf(forestOf(entries.map((entry) => decorate(entry))), '', 0)
9371
9894
  : entries.map((entry) => branchRow(decorate(entry), undefined, 0))))
9372
- return E('div', { className: 'dig-tree' },
9895
+ /* Render-order mirror for Shift-ranges and Ctrl+A, plus the selKey→entry
9896
+ lookup the right-click menu and the Delete key resolve through. */
9897
+ const visibleLeaves = []
9898
+ if (favorites.length > 0) flatLeaves(forestOf(locals.filter((entry) => favorites.indexOf(entry.name) >= 0)), '', 0, visibleLeaves)
9899
+ flatLeaves(forestOf(locals), '', 0, visibleLeaves)
9900
+ for (const entry of remotes) visibleLeaves.push({ entry: entry, label: undefined, depth: 0 })
9901
+ for (const entry of tags) visibleLeaves.push({ entry: entry, label: undefined, depth: 0 })
9902
+ visibleRef.current = visibleLeaves
9903
+ const entryBySelKey = new Map()
9904
+ for (const entry of locals) entryBySelKey.set(branchSelKeyOf(entry), { selKey: branchSelKeyOf(entry), kind: 'local', name: entry.name, entry: entry })
9905
+ for (const entry of remotes) entryBySelKey.set(branchSelKeyOf(entry), { selKey: branchSelKeyOf(entry), kind: 'remote', name: entry.name, entry: entry })
9906
+ for (const entry of tags) entryBySelKey.set(branchSelKeyOf(entry), { selKey: branchSelKeyOf(entry), kind: 'tag', name: entry.name, entry: entry })
9907
+ const pickedEntries = () => {
9908
+ const entries = []
9909
+ for (const key of picks) {
9910
+ const found = entryBySelKey.get(key)
9911
+ if (found !== undefined) entries.push(found)
9912
+ }
9913
+ return entries
9914
+ }
9915
+ const onRowMenu = (event, entry) => {
9916
+ const selKey = branchSelKeyOf(entry)
9917
+ // Right-clicking a row that is part of a multi-select acts on the WHOLE
9918
+ // selection; anything else collapses to the ordinary single-row menu.
9919
+ if (picks.has(selKey) === true && picks.size > 1 && props.onBranchMenu !== undefined) {
9920
+ props.onBranchMenu(event, entry, pickedEntries())
9921
+ return
9922
+ }
9923
+ props.onBranchMenu(event, entry, null)
9924
+ }
9925
+ /* List-scope keyboard: Ctrl/Cmd+C copies the picked names, Delete asks
9926
+ Panel for a grouped delete (head branches are skipped — git refuses
9927
+ them anyway), Ctrl+A selects every visible row, Esc clears. */
9928
+ const onTreeKeyDown = (event) => {
9929
+ if (isTypingTarget(event.target) === true) return
9930
+ const mod = isToggleModifier(event)
9931
+ if (mod === true && (event.key === 'c' || event.key === 'C')) {
9932
+ if (picks.size === 0 || props.onCopyValues === undefined) return
9933
+ event.preventDefault()
9934
+ props.onCopyValues(pickedEntries().map((item) => item.name))
9935
+ return
9936
+ }
9937
+ if (event.key === 'Delete' || event.key === 'Backspace') {
9938
+ if (picks.size === 0 || props.onDeleteEntries === undefined) return
9939
+ const entries = pickedEntries().filter((item) => item.kind !== 'local' || item.entry.head !== true)
9940
+ if (entries.length === 0) return
9941
+ event.preventDefault()
9942
+ props.onDeleteEntries(entries)
9943
+ return
9944
+ }
9945
+ if (mod === true && (event.key === 'a' || event.key === 'A')) {
9946
+ event.preventDefault()
9947
+ setPicks(new Set(visibleLeaves.map((leaf) => branchSelKeyOf(leaf.entry))))
9948
+ return
9949
+ }
9950
+ if (event.key === 'Escape') setPicks(new Set())
9951
+ }
9952
+ return E('div', {
9953
+ className: 'dig-tree',
9954
+ tabIndex: 0,
9955
+ onKeyDown: onTreeKeyDown,
9956
+ onPointerDownCapture: (event) => {
9957
+ if (isTypingTarget(event.target) === false) event.currentTarget.focus({ preventScroll: true })
9958
+ },
9959
+ },
9373
9960
  E('div', { className: 'dig-search' },
9374
9961
  E('input', {
9375
9962
  className: 'dig-input dig-input-compact', value: filter, spellCheck: false,
@@ -9403,9 +9990,11 @@ window.__ModuleLoader__.load({
9403
9990
  const readOnly = props.group === 'ignored'
9404
9991
  const status = readOnly ? 'I' : (raw === '' ? 'M' : raw.charAt(0))
9405
9992
  return E('div', {
9406
- className: 'dig-row dig-row-file',
9993
+ className: 'dig-row dig-row-file' + (props.selected === true ? ' dig-row-selected' : ''),
9407
9994
  title: item.origPath === undefined ? item.path : item.origPath + ' → ' + item.path,
9408
- onClick: () => props.onDiff(item, props.group),
9995
+ /* The click is handled ONE level up (ChangesPanel): plain click still
9996
+ opens the diff, Shift/Ctrl+click join or toggle the multi-select. */
9997
+ onClick: (event) => props.onRowClick(event, item, props.group),
9409
9998
  onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, item, props.group) },
9410
9999
  },
9411
10000
  FileTypeGlyph({ path: item.path }),
@@ -9420,7 +10009,7 @@ window.__ModuleLoader__.load({
9420
10009
  ? E('button', { type: 'button', className: 'dig-mini', title: t('action.unstage'), onClick: (event) => { event.stopPropagation(); props.onUnstage(item) } }, E(Icon, { name: 'minus', size: 12 }))
9421
10010
  : E('button', { type: 'button', className: 'dig-mini', title: t('action.stage'), onClick: (event) => { event.stopPropagation(); props.onStage(item) } }, E(Icon, { name: 'plus', size: 12 }))),
9422
10011
  readOnly ? null : E('button', { type: 'button', className: 'dig-mini', title: t('action.discard'), onClick: (event) => { event.stopPropagation(); props.onDiscard(item, props.group) } }, E(Icon, { name: 'undo', size: 12 })))
9423
- }, (prev, next) => prev.item === next.item && prev.group === next.group && prev.t === next.t)
10012
+ }, (prev, next) => prev.item === next.item && prev.group === next.group && prev.t === next.t && prev.selected === next.selected)
9424
10013
 
9425
10014
  function ChangesPanel(props) {
9426
10015
  const t = props.t
@@ -9457,6 +10046,154 @@ window.__ModuleLoader__.load({
9457
10046
  state directly through props. */
9458
10047
  const foldedDirs = props.foldedDirs === undefined ? {} : props.foldedDirs
9459
10048
  const setFoldedDirs = props.onFoldedDirs
10049
+ /* ---- multi-select (issue #13): the state lives in Panel so the right-click
10050
+ menu and the keyboard shortcuts can act on it; this panel renders it,
10051
+ feeds it from clicks/drag, and mirrors it onto row classes. ---- */
10052
+ const selection = props.selection === undefined ? EMPTY_SELECTION : props.selection
10053
+ const setSelection = props.onSelectionChange
10054
+ const anchorRef = props.anchorRef
10055
+ const changeItemsRef = useRef(null)
10056
+ const changeOffsetsRef = useRef(null)
10057
+ const [marquee, setMarquee] = useState(null)
10058
+ const marqueeFrame = useRef(0)
10059
+ const marqueeState = useRef(null)
10060
+ const marqueeSwallowClick = useRef(false)
10061
+ /* Rows in their RENDER order (the order windowing flattens them into) —
10062
+ Shift-click spans follow what the eye sees, not the git order. */
10063
+ const orderedRowKeys = () => {
10064
+ const items = changeItemsRef.current
10065
+ const keys = []
10066
+ if (items === null) return keys
10067
+ for (const entry of items) {
10068
+ if (entry.kind === 'row' && entry.key.indexOf('ignored:') !== 0) keys.push(entry.key)
10069
+ }
10070
+ return keys
10071
+ }
10072
+ const handleRowClick = useCallback((event, item, group) => {
10073
+ const key = group + ':' + item.path
10074
+ /* Functional updates ONLY: the memoised rows may hold a stale closure
10075
+ of this handler (their memo compares item/group/t/selected, not the
10076
+ callback — same stale-callback trade-off as the stage buttons), so
10077
+ the selection must never be read from it. */
10078
+ if (event.shiftKey === true && anchorRef !== undefined && anchorRef.current !== null) {
10079
+ const ordered = orderedRowKeys()
10080
+ const from = ordered.indexOf(anchorRef.current)
10081
+ const to = ordered.indexOf(key)
10082
+ if (from >= 0 && to >= 0) {
10083
+ const low = Math.min(from, to)
10084
+ const high = Math.max(from, to)
10085
+ setSelection((previous) => {
10086
+ const next = new Set(previous)
10087
+ for (let index = low; index <= high; index += 1) next.add(ordered[index])
10088
+ return next
10089
+ })
10090
+ return
10091
+ }
10092
+ }
10093
+ if (isToggleModifier(event) === true) {
10094
+ anchorRef.current = key
10095
+ setSelection((previous) => {
10096
+ const next = new Set(previous)
10097
+ if (next.has(key) === true) next.delete(key)
10098
+ else next.add(key)
10099
+ return next
10100
+ })
10101
+ return
10102
+ }
10103
+ anchorRef.current = key
10104
+ setSelection((previous) => previous.size === 0 ? previous : new Set())
10105
+ props.onDiff(item, group)
10106
+ }, [setSelection, anchorRef, props.onDiff])
10107
+ /* ---- rubber-band selection over the windowed list: the fixed row
10108
+ heights + the prefix-sum offsets turn a pixel span back into the
10109
+ row-index span (the same windowStart binary search the window uses),
10110
+ so the drag never needs the DOM rows to exist. The marquee box itself
10111
+ is a viewport-anchored absolute div inside .dig-changes (invariant 13:
10112
+ never position:fixed inside the workbench's contain:layout). ---- */
10113
+ const marqueeKeysAt = (contentTop, contentBottom) => {
10114
+ const items = changeItemsRef.current
10115
+ const offsets = changeOffsetsRef.current
10116
+ if (items === null || offsets === null || items.length === 0) return []
10117
+ const first = Math.max(0, windowStart(offsets, contentTop))
10118
+ const last = Math.min(items.length - 1, windowStart(offsets, contentBottom))
10119
+ const keys = []
10120
+ for (let index = first; index <= last; index += 1) {
10121
+ const entry = items[index]
10122
+ if (entry.kind === 'row' && entry.key.indexOf('ignored:') !== 0) keys.push(entry.key)
10123
+ }
10124
+ return keys
10125
+ }
10126
+ const syncMarquee = () => {
10127
+ const state = marqueeState.current
10128
+ const host = changesScrollRef.current
10129
+ if (state === null || host === null) return
10130
+ const rect = host.getBoundingClientRect()
10131
+ const contentY0 = state.startY - rect.top + state.scrollY0
10132
+ const contentY1 = state.clientY - rect.top + host.scrollTop
10133
+ setSelection(new Set(marqueeKeysAt(Math.min(contentY0, contentY1), Math.max(contentY0, contentY1))))
10134
+ setMarquee({
10135
+ x: Math.min(state.startX, state.clientX) - rect.left,
10136
+ y: Math.min(state.startY, state.clientY) - rect.top,
10137
+ w: Math.abs(state.clientX - state.startX),
10138
+ h: Math.abs(state.clientY - state.startY),
10139
+ })
10140
+ /* Auto-scroll when the pointer rides an edge; keep the loop alive while
10141
+ it does so the selection follows the content streaming past. */
10142
+ const EDGE = 28
10143
+ let speed = 0
10144
+ if (state.clientY - rect.top < EDGE) speed = -Math.ceil((EDGE - (state.clientY - rect.top)) / 3)
10145
+ else if (rect.bottom - state.clientY < EDGE) speed = Math.ceil((EDGE - (rect.bottom - state.clientY)) / 3)
10146
+ if (speed !== 0) {
10147
+ host.scrollTop += speed * 2
10148
+ cancelAnimationFrame(marqueeFrame.current)
10149
+ marqueeFrame.current = requestAnimationFrame(syncMarquee)
10150
+ }
10151
+ }
10152
+ const scheduleMarqueeSync = () => {
10153
+ cancelAnimationFrame(marqueeFrame.current)
10154
+ marqueeFrame.current = requestAnimationFrame(syncMarquee)
10155
+ }
10156
+ const onListPointerDown = (event) => {
10157
+ if (event.button !== 0) return
10158
+ const host = changesScrollRef.current
10159
+ if (host === null) return
10160
+ if (event.target instanceof Element && event.target.closest('button, input, textarea, select, a') !== null) return
10161
+ // Keep the browser from starting a text selection under the drag; the
10162
+ // real clicks still fire (only the selection machinery is suppressed).
10163
+ event.preventDefault()
10164
+ const rect = host.getBoundingClientRect()
10165
+ marqueeState.current = {
10166
+ startX: event.clientX,
10167
+ startY: event.clientY,
10168
+ scrollY0: host.scrollTop,
10169
+ clientX: event.clientX,
10170
+ clientY: event.clientY,
10171
+ active: false,
10172
+ }
10173
+ const onMove = (moveEvent) => {
10174
+ const state = marqueeState.current
10175
+ if (state === null) return
10176
+ state.clientX = moveEvent.clientX
10177
+ state.clientY = moveEvent.clientY
10178
+ if (state.active === false && Math.abs(moveEvent.clientX - state.startX) < 4 && Math.abs(moveEvent.clientY - state.startY) < 4) return
10179
+ state.active = true
10180
+ marqueeSwallowClick.current = true
10181
+ scheduleMarqueeSync()
10182
+ }
10183
+ const onUp = () => {
10184
+ window.removeEventListener('pointermove', onMove)
10185
+ window.removeEventListener('pointerup', onUp)
10186
+ cancelAnimationFrame(marqueeFrame.current)
10187
+ const wasActive = marqueeState.current !== null && marqueeState.current.active === true
10188
+ marqueeState.current = null
10189
+ setMarquee(null)
10190
+ // The drag's pointerup is followed by a synthetic click on whatever
10191
+ // row is under it — swallow exactly that one click.
10192
+ if (wasActive === true) setTimeout(() => { marqueeSwallowClick.current = false }, 0)
10193
+ }
10194
+ window.addEventListener('pointermove', onMove)
10195
+ window.addEventListener('pointerup', onUp)
10196
+ }
9460
10197
  const changes = summary === null ? null : summary.changes
9461
10198
  const conflicted = changes === null ? [] : changes.conflicted
9462
10199
  const staged = changes === null ? [] : changes.staged
@@ -9505,6 +10242,8 @@ window.__ModuleLoader__.load({
9505
10242
  }
9506
10243
  const entryRow = (key, item) => E(ChangeRow, {
9507
10244
  key: key + ':' + item.path, item: item, group: key, t: t,
10245
+ selected: selection.has(key + ':' + item.path) === true,
10246
+ onRowClick: handleRowClick,
9508
10247
  onStage: props.onStage, onUnstage: props.onUnstage, onDiscard: props.onDiscard, onDiff: props.onDiff, onMenu: props.onChangeMenu,
9509
10248
  })
9510
10249
  const folderOf = (item) => {
@@ -9572,12 +10311,17 @@ window.__ModuleLoader__.load({
9572
10311
  emitGroup('untracked', fill(t('counts.untracked'), { n: untracked.length }), untracked)
9573
10312
  emitGroup('ignored', fill(t('counts.ignored'), { n: ignored.length }), ignored)
9574
10313
  return out
9575
- }, [conflicted, staged, unstaged, untracked, ignored, groupBy, foldedDirs, sortKey, t, props.onUnstageAll, props.onStageAll])
10314
+ }, [conflicted, staged, unstaged, untracked, ignored, groupBy, foldedDirs, sortKey, t, selection, handleRowClick, props.onUnstageAll, props.onStageAll])
9576
10315
  const changeOffsets = useMemo(() => {
9577
10316
  const sums = [0]
9578
10317
  for (const item of changeItems) sums.push(sums[sums.length - 1] + item.h)
9579
10318
  return sums
9580
10319
  }, [changeItems])
10320
+ /* Render-phase mirrors: the drag-select and Shift-range logic read these
10321
+ through refs, so their callbacks keep stable identities (invariant 12:
10322
+ a dep array is evaluated during render — no forward references). */
10323
+ changeItemsRef.current = changeItems
10324
+ changeOffsetsRef.current = changeOffsets
9581
10325
  /* Virtual window, VSCode-style (ListView.getVisibleRange): both ends are
9582
10326
  pixel→index conversions over the same prefix-sum array, so no "row
9583
10327
  count" ever enters the math (issue #12 — the old winLimit computed a
@@ -9675,11 +10419,34 @@ window.__ModuleLoader__.load({
9675
10419
  'data-action': 'more',
9676
10420
  onClick: (event) => props.onMoreMenu(event),
9677
10421
  }, E(Icon, { name: 'more', size: 18 })))
10422
+ /* The selection action bar: visible only while a multi-select exists, so
10423
+ the mouse-only path (no right-click, no keyboard) keeps full power. */
10424
+ const selectionBar = selection.size === 0 ? null : E('div', { className: 'dig-selection-bar' },
10425
+ E('span', { className: 'dig-selection-count' }, fill(t('selection.count'), { n: selection.size })),
10426
+ props.onBulkStage === undefined ? null : E('button', { type: 'button', className: 'dig-link', onClick: () => props.onBulkStage() }, t('action.stage')),
10427
+ props.onBulkUnstage === undefined ? null : E('button', { type: 'button', className: 'dig-link', onClick: () => props.onBulkUnstage() }, t('action.unstage')),
10428
+ props.onBulkDiscard === undefined ? null : E('button', { type: 'button', className: 'dig-link dig-link-danger', onClick: () => props.onBulkDiscard() }, t('action.discard')),
10429
+ props.onCopySelection === undefined ? null : E('button', { type: 'button', className: 'dig-link', onClick: () => props.onCopySelection() }, t('selection.copyPaths')),
10430
+ E('button', { type: 'button', className: 'dig-link', onClick: () => setSelection(new Set()) }, t('selection.clear')))
9678
10431
  return E('div', { className: 'dig-changes' },
9679
10432
  aiConfirmDialog,
9680
10433
  head,
10434
+ selectionBar,
9681
10435
  E('div', { className: 'dig-changes-body' },
9682
- E('div', { className: 'dig-changes-list', ref: changesScrollRef },
10436
+ E('div', {
10437
+ className: 'dig-changes-list', ref: changesScrollRef,
10438
+ /* Focusable list: the keyboard shortcuts (Ctrl+C / Del / Ctrl+A /
10439
+ Esc) land on whichever list currently holds focus. */
10440
+ tabIndex: 0,
10441
+ onKeyDown: props.onKeyDown,
10442
+ onPointerDownCapture: (event) => {
10443
+ if (isTypingTarget(event.target) === false) event.currentTarget.focus({ preventScroll: true })
10444
+ },
10445
+ onPointerDown: onListPointerDown,
10446
+ onClickCapture: (event) => {
10447
+ if (marqueeSwallowClick.current === true) { event.preventDefault(); event.stopPropagation() }
10448
+ },
10449
+ },
9683
10450
  collapsed ? null : [
9684
10451
  total === 0 && ignored.length === 0 ? E('div', { className: 'dig-empty', key: 'empty' }, t('changes.empty')) : null,
9685
10452
  /* Windowed slice: spacers above/below stand in for the unmounted
@@ -9688,7 +10455,11 @@ window.__ModuleLoader__.load({
9688
10455
  visibleItems.map((item) => E(Fragment, { key: item.key }, item.node)),
9689
10456
  winLast < changeItems.length ? E('div', { key: 'pad-bottom', style: { height: String(changeOffsets[changeItems.length] - changeOffsets[winLast]) + 'px' } }) : null,
9690
10457
  ]),
9691
- composer))
10458
+ composer),
10459
+ marquee === null ? null : E('div', {
10460
+ className: 'dig-marquee',
10461
+ style: { left: marquee.x + 'px', top: marquee.y + 'px', width: marquee.w + 'px', height: marquee.h + 'px' },
10462
+ }))
9692
10463
  }
9693
10464
 
9694
10465
  /* ============================== history ============================== */
@@ -9812,7 +10583,9 @@ window.__ModuleLoader__.load({
9812
10583
  graph stroke whose data-lane differs — zero React re-renders. */
9813
10584
  'data-lane': String(props.row.lane),
9814
10585
  className: 'dig-commit' + (props.selected === true ? ' dig-commit-selected' : ''),
9815
- onClick: () => props.onSelect(commit),
10586
+ /* Modifier handling lives in HistoryList (it owns the rendered row
10587
+ order the Shift-range walks); a plain click still opens the detail. */
10588
+ onClick: (event) => props.onRowClick(event, commit),
9816
10589
  onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, commit) },
9817
10590
  },
9818
10591
  E('span', { className: 'dig-commit-date', title: shortDate(commit.date) }, relativeTime(commit.date)),
@@ -9861,6 +10634,7 @@ window.__ModuleLoader__.load({
9861
10634
  const commits = props.commits
9862
10635
  const historyScrollRef = useRef(null)
9863
10636
  const viewport = useViewportBox(historyScrollRef)
10637
+ const pickAnchor = useRef(null)
9864
10638
  const [text, setText] = useState('')
9865
10639
  const [author, setAuthor] = useState('')
9866
10640
  const [refName, setRefName] = useState('')
@@ -9915,6 +10689,59 @@ window.__ModuleLoader__.load({
9915
10689
  }, [commits, text, author, refName, since, order])
9916
10690
 
9917
10691
  const rows = useMemo(() => buildRows(filtered), [filtered])
10692
+ /* Multi-select gestures (issue #13): the rendered row order is what the
10693
+ Shift-range walks, and the range is computed over the FILTERED rows —
10694
+ the same list the eye sees. Plain click keeps opening the detail and
10695
+ collapses the multi-select, exactly like the other two lists. The
10696
+ stale-callback risk is nil: rows change identity with the filters, and
10697
+ every row re-renders whenever its commit reference changes. */
10698
+ const handleRowClick = (event, commit) => {
10699
+ const pickedHashes = props.pickedHashes
10700
+ const setPicks = props.onPicksChange
10701
+ if (setPicks === undefined) { props.onSelect(commit); return }
10702
+ const ordered = rows.map((row) => row.commit.hash)
10703
+ const index = ordered.indexOf(commit.hash)
10704
+ /* Functional updates only — memoised CommitRows may hold a stale
10705
+ closure of this handler (their memo compares commit/row/selected),
10706
+ so the picked set is never read from it. */
10707
+ if (event.shiftKey === true && pickAnchor.current !== null) {
10708
+ const from = ordered.indexOf(pickAnchor.current)
10709
+ if (from >= 0 && index >= 0) {
10710
+ const low = Math.min(from, index)
10711
+ const high = Math.max(from, index)
10712
+ setPicks((previous) => {
10713
+ const next = new Set(previous)
10714
+ for (let at = low; at <= high; at += 1) next.add(ordered[at])
10715
+ return next
10716
+ })
10717
+ return
10718
+ }
10719
+ }
10720
+ if (isToggleModifier(event) === true) {
10721
+ pickAnchor.current = commit.hash
10722
+ setPicks((previous) => {
10723
+ const next = new Set(previous)
10724
+ if (next.has(commit.hash) === true) next.delete(commit.hash)
10725
+ else next.add(commit.hash)
10726
+ return next
10727
+ })
10728
+ return
10729
+ }
10730
+ pickAnchor.current = commit.hash
10731
+ setPicks((previous) => previous.size === 0 ? previous : new Set())
10732
+ props.onSelect(commit)
10733
+ }
10734
+ /* Ctrl+A selects the whole filtered list; everything else (Ctrl+C, Esc)
10735
+ belongs to the Panel-level handler that owns the picked set. */
10736
+ const onListKeyDown = (event) => {
10737
+ if (isTypingTarget(event.target) === true) return
10738
+ if (isToggleModifier(event) === true && (event.key === 'a' || event.key === 'A')) {
10739
+ event.preventDefault()
10740
+ if (props.onSelectAll !== undefined) props.onSelectAll(rows.map((row) => row.commit.hash))
10741
+ return
10742
+ }
10743
+ if (props.onKeyDown !== undefined) props.onKeyDown(event)
10744
+ }
9918
10745
  const dirtyFilter = text !== '' || author !== '' || refName !== '' || since !== '' || (props.pathFilter !== undefined && props.pathFilter !== '')
9919
10746
  const applyPath = (value) => { if (props.onPathFilter !== undefined) props.onPathFilter(value) }
9920
10747
  const clearAll = () => {
@@ -9994,6 +10821,11 @@ window.__ModuleLoader__.load({
9994
10821
  E('div', {
9995
10822
  className: 'dig-history-scroll',
9996
10823
  ref: historyScrollRef,
10824
+ tabIndex: 0,
10825
+ onKeyDown: onListKeyDown,
10826
+ onPointerDownCapture: (event) => {
10827
+ if (isTypingTarget(event.target) === false) event.currentTarget.focus({ preventScroll: true })
10828
+ },
9997
10829
  /* Lane hover highlight (GitLens-style trace): copy the hovered row's
9998
10830
  lane into data-hover-lane; generated CSS dims every stroke whose
9999
10831
  data-lane differs. Direct DOM dataset writes on purpose — the
@@ -10013,8 +10845,9 @@ window.__ModuleLoader__.load({
10013
10845
  visibleRows.map((row) => E(CommitRow, {
10014
10846
  key: row.commit.hash,
10015
10847
  commit: row.commit, row: row, t: t,
10016
- selected: props.selectedHash === row.commit.hash,
10017
- onSelect: props.onSelect, onMenu: props.onMenu, openMenuAt: props.openMenuAt,
10848
+ selected: props.selectedHash === row.commit.hash
10849
+ || (props.pickedHashes !== undefined && props.pickedHashes.has(row.commit.hash) === true),
10850
+ onRowClick: handleRowClick, onMenu: props.onMenu, openMenuAt: props.openMenuAt,
10018
10851
  })),
10019
10852
  winEnd < rows.length ? E('div', { key: 'pad-bottom', style: { height: String((rows.length - winEnd) * HISTORY_ROW_H) + 'px' } }) : null,
10020
10853
  props.hasMore === true
@@ -10097,6 +10930,13 @@ window.__ModuleLoader__.load({
10097
10930
  and the selection menu's payload must say which. Cleared everywhere
10098
10931
  the patch is cleared. */
10099
10932
  const [diffContext, setDiffContext] = useState(null)
10933
+ /* ---- multi-select (issue #13): the change list's selection lives here
10934
+ so the right-click menu, the keyboard and the selection bar act on one
10935
+ set; the branch tree and the history keep theirs closer to their rows
10936
+ (BranchTree-internal / pickedCommits below). ---- */
10937
+ const [changeSelection, setChangeSelection] = useState(() => new Set())
10938
+ const changeAnchor = useRef(null)
10939
+ const [pickedCommits, setPickedCommits] = useState(() => new Set())
10100
10940
  const [menu, setMenu] = useState(null)
10101
10941
  const [dialog, setDialog] = useState(null)
10102
10942
  const [tick, setTick] = useState(0)
@@ -10844,30 +11684,230 @@ window.__ModuleLoader__.load({
10844
11684
  sendToComposer({ text: text, fallback: text })
10845
11685
  }, [t, sendToComposer])
10846
11686
 
10847
- const branchMenu = useCallback((event, entry) => {
11687
+ /* ---------- multi-select actions (issue #13) ---------- */
11688
+
11689
+ /* Copy with feedback: plain copyText stays silent, which reads as "did
11690
+ nothing" the moment the paste target is in another app. */
11691
+ const copyValues = useCallback((values) => {
11692
+ if (values === null || values === undefined || values.length === 0) return
11693
+ copyText(clipboardLines(values))
11694
+ pushToast({ text: fill(t('selection.copied'), { n: values.length }), icon: 'tag', tone: 'ok', ttl: 2500 })
11695
+ }, [pushToast, t])
11696
+
11697
+ /* Resolve the change selection against the CURRENT summary: paths move
11698
+ between groups on every stage/unstage, so the key ('group:path') is
11699
+ the only stable handle and the entry lookup must be fresh. */
11700
+ const changeSelectionGroups = () => {
11701
+ const changes = summary === null ? null : summary.changes
11702
+ const groups = { staged: [], other: [], tracked: [], untracked: [] }
11703
+ if (changes === null) return groups
11704
+ const byKey = new Map()
11705
+ for (const entry of changes.staged) byKey.set('staged:' + entry.path, entry)
11706
+ for (const entry of changes.unstaged) byKey.set('unstaged:' + entry.path, entry)
11707
+ for (const entry of changes.untracked) byKey.set('untracked:' + entry.path, entry)
11708
+ for (const entry of changes.conflicted) byKey.set('conflicted:' + entry.path, entry)
11709
+ for (const key of changeSelection) {
11710
+ const entry = byKey.get(key)
11711
+ if (entry === undefined) continue
11712
+ if (key.indexOf('staged:') === 0) groups.staged.push(entry.path)
11713
+ else groups.other.push(entry.path)
11714
+ // Conflicted rides the tracked lane, like the single-file discard does.
11715
+ if (key.indexOf('untracked:') === 0) groups.untracked.push(entry.path)
11716
+ else groups.tracked.push(entry.path)
11717
+ }
11718
+ return groups
11719
+ }
11720
+ const bulkStageChanges = () => {
11721
+ const groups = changeSelectionGroups()
11722
+ if (groups.other.length === 0) return
11723
+ void run('stage', { paths: groups.other })
11724
+ }
11725
+ const bulkUnstageChanges = () => {
11726
+ const groups = changeSelectionGroups()
11727
+ if (groups.staged.length === 0) return
11728
+ void run('unstage', { paths: groups.staged })
11729
+ }
11730
+ const openDiscardManyDialog = () => {
11731
+ const groups = changeSelectionGroups()
11732
+ if (groups.tracked.length === 0 && groups.untracked.length === 0) return
11733
+ setDialog({ kind: 'discardMany', tracked: groups.tracked, untracked: groups.untracked })
11734
+ }
11735
+ const copyChangeSelection = () => {
11736
+ const groups = changeSelectionGroups()
11737
+ copyValues(groups.staged.concat(groups.other))
11738
+ }
11739
+ /* A refresh that resolves a selected key away (file staged, discarded,
11740
+ renamed) must not keep it selected. */
11741
+ useEffect(() => {
11742
+ setChangeSelection((previous) => {
11743
+ if (previous.size === 0) return previous
11744
+ const changes = summary === null ? null : summary.changes
11745
+ if (changes === null) return previous
11746
+ const alive = new Set()
11747
+ for (const entry of changes.conflicted) alive.add('conflicted:' + entry.path)
11748
+ for (const entry of changes.staged) alive.add('staged:' + entry.path)
11749
+ for (const entry of changes.unstaged) alive.add('unstaged:' + entry.path)
11750
+ for (const entry of changes.untracked) alive.add('untracked:' + entry.path)
11751
+ const next = new Set()
11752
+ for (const key of previous) {
11753
+ if (alive.has(key) === true) next.add(key)
11754
+ }
11755
+ return next.size === previous.size ? previous : next
11756
+ })
11757
+ }, [summary])
11758
+ /* Changes-list keyboard scope: Ctrl/Cmd+C copies paths, Delete opens the
11759
+ grouped discard confirm, Ctrl+A selects everything but ignored, Esc
11760
+ clears. Typing targets keep their native behaviour. */
11761
+ const onChangesKeyDown = (event) => {
11762
+ if (isTypingTarget(event.target) === true) return
11763
+ const mod = isToggleModifier(event)
11764
+ if (mod === true && (event.key === 'c' || event.key === 'C')) {
11765
+ const groups = changeSelectionGroups()
11766
+ const paths = groups.staged.concat(groups.other)
11767
+ if (paths.length === 0) return
11768
+ event.preventDefault()
11769
+ copyValues(paths)
11770
+ return
11771
+ }
11772
+ if (event.key === 'Delete' || event.key === 'Backspace') {
11773
+ if (changeSelection.size === 0) return
11774
+ event.preventDefault()
11775
+ openDiscardManyDialog()
11776
+ return
11777
+ }
11778
+ if (mod === true && (event.key === 'a' || event.key === 'A')) {
11779
+ const changes = summary === null ? null : summary.changes
11780
+ if (changes === null) return
11781
+ event.preventDefault()
11782
+ const next = new Set()
11783
+ for (const entry of changes.conflicted) next.add('conflicted:' + entry.path)
11784
+ for (const entry of changes.staged) next.add('staged:' + entry.path)
11785
+ for (const entry of changes.unstaged) next.add('unstaged:' + entry.path)
11786
+ for (const entry of changes.untracked) next.add('untracked:' + entry.path)
11787
+ setChangeSelection(next)
11788
+ return
11789
+ }
11790
+ if (event.key === 'Escape') setChangeSelection(new Set())
11791
+ }
11792
+ /* History keyboard scope: Ctrl/Cmd+C copies full hashes, Esc clears.
11793
+ Delete has no commit analogue — deliberately unmapped. */
11794
+ const onHistoryKeyDown = (event) => {
11795
+ if (isTypingTarget(event.target) === true) return
11796
+ if (isToggleModifier(event) === true && (event.key === 'c' || event.key === 'C')) {
11797
+ if (pickedCommits.size === 0) return
11798
+ event.preventDefault()
11799
+ copyValues(Array.from(pickedCommits))
11800
+ return
11801
+ }
11802
+ if (event.key === 'Escape') setPickedCommits(new Set())
11803
+ }
11804
+ /* Branch-tree Delete: group the picked refs by kind and hand them to ONE
11805
+ confirm dialog (head branches never get here — BranchTree filters). */
11806
+ const deleteBranchEntries = (entries) => {
11807
+ const locals = []
11808
+ const remotes = []
11809
+ const tags = []
11810
+ for (const item of entries) {
11811
+ if (item.kind === 'local') locals.push(item.name)
11812
+ else if (item.kind === 'tag') tags.push(item.name)
11813
+ else {
11814
+ const slash = item.name.indexOf('/')
11815
+ remotes.push({
11816
+ remote: slash >= 0 ? item.name.slice(0, slash) : item.name,
11817
+ branch: slash >= 0 ? item.name.slice(slash + 1) : item.name,
11818
+ })
11819
+ }
11820
+ }
11821
+ if (locals.length === 0 && remotes.length === 0 && tags.length === 0) return
11822
+ setDialog({ kind: 'deleteRefs', locals: locals, remotes: remotes, tags: tags })
11823
+ }
11824
+ /* Batch local delete with the same undo toast the single delete has.
11825
+ Plain function on purpose (invariant 12): a useCallback here would
11826
+ evaluate `undoAction` in its dep array — declared further below. */
11827
+ const deleteBranches = async (names) => {
11828
+ const data = await run('branchDelete', { names: names, confirm: true })
11829
+ if (data === undefined) return
11830
+ if (data.undo === undefined) return
11831
+ pushToast({
11832
+ text: fill(t('toast.branchesDeleted'), { n: names.length }),
11833
+ icon: 'trash',
11834
+ tone: 'warn',
11835
+ undoId: data.undo.id,
11836
+ actionLabel: t('toast.undo'),
11837
+ onAction: () => { void undoAction(data.undo.id) },
11838
+ })
11839
+ }
11840
+
11841
+ const branchMenu = useCallback((event, entry, picked) => {
10848
11842
  const current = branches === null ? '' : branches.branch
10849
11843
  const isLocal = entry.remote !== true && entry.tag !== true
10850
11844
  const reason = t('action.unavailable')
11845
+ /* Multi-select menu: the verbs that only make sense for one ref
11846
+ (checkout, merge, rebase, rename…) are dropped, not greyed — the
11847
+ selection itself already says what the user is going for. */
11848
+ if (picked !== null && picked !== undefined && picked.length > 1) {
11849
+ const locals = picked.filter((item) => item.kind === 'local' && item.entry.head !== true)
11850
+ const remotes = picked.filter((item) => item.kind === 'remote')
11851
+ const tags = picked.filter((item) => item.kind === 'tag')
11852
+ openMenuAt(event, [
11853
+ { id: 'copyNames', icon: 'tag', tone: 'secondary', label: fill(t('menu.copyNamesN'), { n: picked.length }), run: () => copyValues(picked.map((item) => item.name)) },
11854
+ null,
11855
+ locals.length > 0 ? { id: 'deleteLocal', icon: 'trash', tone: 'danger', danger: true, label: fill(t('menu.deleteLocalN'), { n: locals.length }), run: () => setDialog({ kind: 'deleteRefs', locals: locals.map((item) => item.name), remotes: [], tags: [] }) } : null,
11856
+ remotes.length > 0 ? { id: 'deleteRemote', icon: 'trash', tone: 'danger', danger: true, label: fill(t('menu.deleteRemoteN'), { n: remotes.length }), run: () => setDialog({ kind: 'deleteRefs', locals: [], remotes: remotes.map((item) => { const slash = item.name.indexOf('/'); return { remote: slash >= 0 ? item.name.slice(0, slash) : item.name, branch: slash >= 0 ? item.name.slice(slash + 1) : item.name } }), tags: [] }) } : null,
11857
+ tags.length > 0 ? { id: 'deleteTags', icon: 'trash', tone: 'danger', danger: true, label: fill(t('menu.deleteTagsN'), { n: tags.length }), run: () => setDialog({ kind: 'deleteRefs', locals: [], remotes: [], tags: tags.map((item) => item.name) }) } : null,
11858
+ ].filter((item) => item !== null))
11859
+ return
11860
+ }
10851
11861
  const items = [
10852
11862
  { id: 'checkout', icon: 'checkout', tone: 'accent', label: t('action.checkout'), disabled: entry.head === true || entry.tag === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void checkout(entry) } },
10853
11863
  isLocal ? { id: 'rebase', icon: 'compare', tone: 'violet', label: t('action.rebaseCurrentOnto'), disabled: entry.head === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void run('rebase', { onto: entry.name }) } } : null,
10854
11864
  isLocal ? { id: 'merge', icon: 'compare', tone: 'accent', label: t('action.mergeIntoCurrent'), disabled: entry.head === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void run('merge', { branch: entry.name }) } } : null,
10855
11865
  { id: 'compare', icon: 'filter', tone: 'violet', label: t('action.compare'), disabled: entry.head === true || current === '', reason: reason, run: () => { void compareWith(current, entry.name) } },
10856
11866
  { id: 'send', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => sendGitSummaryToChat('send.branchText', { name: entry.name, repo: repoLabel }) },
11867
+ { id: 'copy', icon: 'tag', tone: 'secondary', label: t('action.copyName'), run: () => copyValues([entry.name]) },
10857
11868
  null,
10858
11869
  { id: 'favorite', icon: 'star', tone: 'warn', label: t('action.favorite'), active: favorites.indexOf(entry.name) >= 0, run: () => toggleFavoriteBranch(entry.name) },
10859
11870
  { id: 'newBranch', icon: 'plus', tone: 'success', label: t('action.newBranchFrom'), run: () => setDialog({ kind: 'newBranch', from: entry.name }) },
10860
11871
  isLocal ? { id: 'rename', icon: 'file', tone: 'primary', label: t('action.rename'), run: () => setDialog({ kind: 'renameBranch', from: entry.name }) } : null,
10861
11872
  isLocal ? { id: 'delete', icon: 'trash', tone: 'danger', label: t('action.delete'), danger: true, disabled: entry.head === true, reason: reason, run: () => setDialog({ kind: 'deleteBranch', name: entry.name }) } : null,
11873
+ entry.tag === true
11874
+ ? { id: 'delete', icon: 'trash', tone: 'danger', label: t('action.delete'), danger: true, run: () => setDialog({ kind: 'deleteRefs', locals: [], remotes: [], tags: [entry.name] }) }
11875
+ : (isLocal ? null : {
11876
+ id: 'delete', icon: 'trash', tone: 'danger', label: t('action.delete'), danger: true,
11877
+ run: () => {
11878
+ const slash = entry.name.indexOf('/')
11879
+ setDialog({
11880
+ kind: 'deleteRefs', locals: [], tags: [],
11881
+ remotes: [{ remote: slash >= 0 ? entry.name.slice(0, slash) : entry.name, branch: entry.name.slice(slash + 1) }],
11882
+ })
11883
+ },
11884
+ }),
10862
11885
  null,
10863
11886
  { id: 'update', icon: 'fetch', tone: 'cyan', label: t('action.update'), run: () => { void run('fetch', { prune: true }) } },
10864
11887
  { id: 'push', icon: 'push', tone: 'success', label: t('action.push'), disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'push' }) },
10865
11888
  ].filter((item) => item !== null)
10866
11889
  openMenuAt(event, items)
10867
- }, [branches, checkout, run, t, compareWith, toggleFavoriteBranch, openMenuAt, favorites, blocked, sendGitSummaryToChat, repoLabel])
11890
+ }, [branches, checkout, run, t, compareWith, toggleFavoriteBranch, openMenuAt, favorites, blocked, sendGitSummaryToChat, repoLabel, copyValues])
10868
11891
 
10869
11892
  const commitMenu = useCallback((event, commit) => {
10870
11893
  const shortHash = commit.hash.slice(0, 7)
11894
+ /* Multi-select: copy every picked hash and cherry-pick them as ONE
11895
+ sequence, oldest first (the graph lists newest first, so the walk
11896
+ order reverses). Verbs that only make sense against one commit
11897
+ (checkout, branch, tag, reset, revert) drop out entirely. */
11898
+ if (pickedCommits.has(commit.hash) === true && pickedCommits.size > 1) {
11899
+ const ordered = []
11900
+ for (const entry of commits) {
11901
+ if (pickedCommits.has(entry.hash) === true) ordered.push(entry.hash)
11902
+ }
11903
+ const oldestFirst = ordered.slice().reverse()
11904
+ openMenuAt(event, [
11905
+ { id: 'copyHashes', icon: 'tag', tone: 'secondary', label: fill(t('menu.copyHashesN'), { n: pickedCommits.size }), run: () => copyValues(Array.from(pickedCommits)) },
11906
+ null,
11907
+ { id: 'cherry', icon: 'commit', tone: 'accent', label: fill(t('menu.cherryN'), { n: pickedCommits.size }), disabled: blocked, reason: t('operation.hint'), run: () => { void run('cherryPick', { hashes: oldestFirst }) } },
11908
+ ])
11909
+ return
11910
+ }
10871
11911
  const items = [
10872
11912
  { id: 'details', icon: 'file', tone: 'primary', label: t('action.details'), run: () => { void selectCommit(commit) } },
10873
11913
  { id: 'copy', icon: 'tag', tone: 'secondary', label: t('action.copyHash'), run: () => { copyText(commit.hash) } },
@@ -10890,9 +11930,23 @@ window.__ModuleLoader__.load({
10890
11930
  { id: 'resetHard', icon: 'trash', tone: 'danger', label: t('action.resetHard'), danger: true, disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'reset', hash: commit.hash, mode: 'hard' }) },
10891
11931
  ]
10892
11932
  openMenuAt(event, items)
10893
- }, [run, selectCommit, t, openMenuAt, blocked, sendToComposer, sendGitSummaryToChat, repoLabel])
11933
+ }, [run, selectCommit, t, openMenuAt, blocked, sendToComposer, sendGitSummaryToChat, repoLabel, pickedCommits, commits, copyValues])
10894
11934
 
10895
11935
  const changeMenu = useCallback((event, item, group) => {
11936
+ /* Multi-select: right-clicking a row that belongs to the selection
11937
+ acts on the WHOLE selection (stage N / unstage N / discard N). */
11938
+ if (changeSelection.has(group + ':' + item.path) === true && changeSelection.size > 1) {
11939
+ const groups = changeSelectionGroups()
11940
+ const allPaths = groups.staged.concat(groups.other)
11941
+ openMenuAt(event, [
11942
+ groups.other.length > 0 ? { id: 'stage', icon: 'plus', tone: 'success', label: fill(t('menu.stageN'), { n: groups.other.length }), run: () => { void run('stage', { paths: groups.other }) } } : null,
11943
+ groups.staged.length > 0 ? { id: 'unstage', icon: 'minus', tone: 'warn', label: fill(t('menu.unstageN'), { n: groups.staged.length }), run: () => { void run('unstage', { paths: groups.staged }) } } : null,
11944
+ { id: 'discard', icon: 'undo', tone: 'danger', danger: true, label: fill(t('menu.discardN'), { n: groups.tracked.length + groups.untracked.length }), run: () => openDiscardManyDialog() },
11945
+ null,
11946
+ { id: 'copy', icon: 'tag', tone: 'secondary', label: fill(t('menu.copyPathsN'), { n: allPaths.length }), run: () => copyValues(allPaths) },
11947
+ ].filter((entry) => entry !== null))
11948
+ return
11949
+ }
10896
11950
  const items = [
10897
11951
  { id: 'diff', icon: 'file', tone: 'primary', label: t('action.showDiff'), run: () => { showDiff({ path: item.path, staged: group === 'staged', untracked: group === 'untracked' }) } },
10898
11952
  { id: 'sendRef', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => sendFileReferenceToChat(item.path) },
@@ -10905,7 +11959,7 @@ window.__ModuleLoader__.load({
10905
11959
  { id: 'copy', icon: 'tag', tone: 'secondary', label: t('action.copyPath'), run: () => { copyText(item.path) } },
10906
11960
  ]
10907
11961
  openMenuAt(event, items)
10908
- }, [showDiff, run, t, openMenuAt, sendFileReferenceToChat, sendToComposer])
11962
+ }, [showDiff, run, t, openMenuAt, sendFileReferenceToChat, sendToComposer, changeSelection, changeSelectionGroups, openDiscardManyDialog, copyValues])
10909
11963
 
10910
11964
  /* The commit-detail file list shares the working-tree row menu, minus the
10911
11965
  staging verbs: a committed file's diff replays against its commit. */
@@ -11193,6 +12247,11 @@ window.__ModuleLoader__.load({
11193
12247
  onSelect: (commit) => { void selectCommit(commit) },
11194
12248
  onMenu: commitMenu,
11195
12249
  onLoadMore: () => { void guard(() => loadCommits(commits.length)) },
12250
+ /* ---- multi-select (issue #13) ---- */
12251
+ pickedHashes: pickedCommits,
12252
+ onPicksChange: setPickedCommits,
12253
+ onSelectAll: (hashes) => setPickedCommits(new Set(hashes)),
12254
+ onKeyDown: onHistoryKeyDown,
11196
12255
  })
11197
12256
 
11198
12257
  const diffPane = patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane', 'data-pane': 'diff', style: paneStyle('diff'), onContextMenu: onDiffPaneContextMenu },
@@ -11457,6 +12516,15 @@ window.__ModuleLoader__.load({
11457
12516
  onDiscard: (item, group) => setDialog({ kind: 'discard', item: item, group: group }),
11458
12517
  onDiff: (item, group) => { showDiff({ path: item.path, staged: group === 'staged', untracked: group === 'untracked' }) },
11459
12518
  onChangeMenu: changeMenu,
12519
+ /* ---- multi-select (issue #13) ---- */
12520
+ selection: changeSelection,
12521
+ onSelectionChange: setChangeSelection,
12522
+ anchorRef: changeAnchor,
12523
+ onBulkStage: bulkStageChanges,
12524
+ onBulkUnstage: bulkUnstageChanges,
12525
+ onBulkDiscard: openDiscardManyDialog,
12526
+ onCopySelection: copyChangeSelection,
12527
+ onKeyDown: onChangesKeyDown,
11460
12528
  })
11461
12529
 
11462
12530
 
@@ -11659,19 +12727,19 @@ window.__ModuleLoader__.load({
11659
12727
  title: t('toolbar.tree'),
11660
12728
  onClick: () => foldTree(!treeOpen),
11661
12729
  }, E(Icon, { name: treeOpen ? 'eye' : 'eyeOff' }))),
11662
- 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,
12730
+ 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, onDeleteEntries: deleteBranchEntries, onCopyValues: copyValues })) : null,
11663
12731
  treeOpen ? gutter('tree', 'prev') : null,
11664
12732
  view === 'changes' ? changesPane : E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane))
11665
12733
  } else if (columns) {
11666
12734
  body = E('div', { className: 'dig-body dig-body-columns', ref: bodyRef },
11667
- 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,
12735
+ 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, onDeleteEntries: deleteBranchEntries, onCopyValues: copyValues })) : null,
11668
12736
  treeOpen ? gutter('tree', 'prev') : null,
11669
12737
  E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane),
11670
12738
  gutter('changes', 'next'),
11671
12739
  E('div', { className: 'dig-pane dig-pane-changes', 'data-pane': 'changes', style: paneStyle('changes') }, changesPane))
11672
12740
  } else {
11673
12741
  body = E('div', { className: 'dig-body dig-body-stack', ref: bodyRef },
11674
- 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,
12742
+ 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, onDeleteEntries: deleteBranchEntries, onCopyValues: copyValues })) : null,
11675
12743
  treeOpen ? gutter('tree', 'prev') : null,
11676
12744
  E('div', { className: 'dig-pane dig-pane-changes-stack', 'data-pane': 'changes', style: paneStyle('changes') }, changesPane),
11677
12745
  gutter('changes', 'prev'),
@@ -11851,6 +12919,60 @@ window.__ModuleLoader__.load({
11851
12919
  onConfirm: () => { const ref = dialog.ref; setDialog(null); void dropStash(ref) },
11852
12920
  }))
11853
12921
  }
12922
+ /* Multi-select delete (issue #13): local branches / remote branches / tags
12923
+ in ONE confirm — each rides its existing destructive host method, and a
12924
+ protected local branch keeps the type-the-name gate. */
12925
+ if (dialog !== null && dialog.kind === 'deleteRefs') {
12926
+ const guardedName = dialog.locals.find((name) => isProtectedBranch(name) === true)
12927
+ const listed = dialog.locals
12928
+ .concat(dialog.tags)
12929
+ .concat(dialog.remotes.map((item) => item.remote + '/' + item.branch))
12930
+ const listText = listed.length <= 8 ? listed.join('、') : listed.slice(0, 8).join('、') + ' …'
12931
+ overlays.push(E(ConfirmDialog, {
12932
+ key: 'deleteRefs',
12933
+ t: t,
12934
+ title: t('confirm.title'),
12935
+ text: fill(t('confirm.deleteRefs'), { b: dialog.locals.length, r: dialog.remotes.length, g: dialog.tags.length })
12936
+ + (listText === '' ? '' : ' ' + listText),
12937
+ requireText: guardedName === undefined ? undefined : guardedName,
12938
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
12939
+ onCancel: () => setDialog(null),
12940
+ onConfirm: () => {
12941
+ const locals = dialog.locals
12942
+ const tags = dialog.tags
12943
+ const remotes = dialog.remotes
12944
+ setDialog(null)
12945
+ if (locals.length > 0) void deleteBranches(locals)
12946
+ if (tags.length > 0) void run('tagDelete', { names: tags })
12947
+ const byRemote = new Map()
12948
+ for (const item of remotes) {
12949
+ if (byRemote.has(item.remote) === false) byRemote.set(item.remote, [])
12950
+ byRemote.get(item.remote).push(item.branch)
12951
+ }
12952
+ for (const pair of byRemote) void run('branchDeleteRemote', { remote: pair[0], branches: pair[1], confirm: true })
12953
+ },
12954
+ }))
12955
+ }
12956
+ /* Multi-select discard: tracked paths go through `checkout --`, untracked
12957
+ through `clean -fd` — the same two-lane split as Discard All. */
12958
+ if (dialog !== null && dialog.kind === 'discardMany') {
12959
+ overlays.push(E(ConfirmDialog, {
12960
+ key: 'discardMany',
12961
+ t: t,
12962
+ title: t('confirm.title'),
12963
+ text: fill(t('confirm.discardMany'), { n: dialog.tracked.length + dialog.untracked.length })
12964
+ + (dialog.untracked.length > 0 ? ' ' + fill(t('confirm.discardManyUntracked'), { m: dialog.untracked.length }) : ''),
12965
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
12966
+ onCancel: () => setDialog(null),
12967
+ onConfirm: () => {
12968
+ const tracked = dialog.tracked
12969
+ const untracked = dialog.untracked
12970
+ setDialog(null)
12971
+ if (tracked.length > 0) void run('discard', { paths: tracked, confirm: true })
12972
+ if (untracked.length > 0) void run('discard', { paths: untracked, untracked: true, confirm: true })
12973
+ },
12974
+ }))
12975
+ }
11854
12976
  if (dialog !== null && dialog.kind === 'discard') {
11855
12977
  overlays.push(E(ConfirmDialog, {
11856
12978
  key: 'discard',
@@ -12052,6 +13174,14 @@ window.__ModuleLoader__.load({
12052
13174
  '.dig-settings-glyph{display:inline-flex;flex:none;color:var(--dsw-alias-label-secondary)}',
12053
13175
  '.dig-settings-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}',
12054
13176
  '.dig-root-narrow .dig-settings-label{white-space:normal}',
13177
+ /* Collapsible setting sections (hand-drawn, same pattern as dsh-gitbash-shell v0.38.4). */
13178
+ '.dig-fold{border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm,8px);padding:4px 12px 12px;background:var(--dsw-alias-bg-layer-1,transparent)}',
13179
+ '.dig-fold .dig-stack{padding-top:10px}',
13180
+ '.dig-foldHead{display:flex;align-items:center;gap:8px;width:100%;appearance:none;background:none;border:0;padding:6px 0;font:inherit;font-size:13px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-secondary);cursor:pointer;text-align:left}',
13181
+ '.dig-foldHead:hover{color:var(--dsw-alias-label-primary)}',
13182
+ '.dig-foldChevron{flex:none;display:inline-block;color:var(--dsw-alias-label-tertiary);transition:transform .16s}',
13183
+ '.dig-foldChevron.dig-foldChevronOpen{transform:rotate(90deg)}',
13184
+ '.dig-foldTitle{min-width:0;overflow-wrap:anywhere}',
12055
13185
  '.dig-root-narrow .dig-menu-item{white-space:normal}',
12056
13186
  '.dig-root-narrow .dig-toast-text{white-space:normal}',
12057
13187
  '.dig-toasts{position:absolute;right:8px;bottom:8px;display:flex;flex-direction:column;gap:6px;z-index:80;max-width:min(340px,92%)}',
@@ -12139,7 +13269,7 @@ window.__ModuleLoader__.load({
12139
13269
  '.dig-row{display:flex;align-items:center;gap:5px;padding:2px 8px;cursor:pointer;white-space:nowrap;overflow:hidden}',
12140
13270
  '.dig-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
12141
13271
  '.dig-row-head{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
12142
- '.dig-row-selected{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))}',
13272
+ '.dig-row-selected{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary)}',
12143
13273
  '.dig-row-remote .dig-row-label{color:var(--dsw-alias-label-secondary);font-weight:500}',
12144
13274
  '.dig-row-tag .dig-row-label{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary));font-weight:500}',
12145
13275
  '.dig-row-head .dig-row-label{font-weight:600}',
@@ -12177,9 +13307,20 @@ window.__ModuleLoader__.load({
12177
13307
  '.dig-load-more{margin:6px auto;display:block;padding:3px 12px;border-radius:var(--dsw-radius-sm,8px);border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer}',
12178
13308
  '.dig-load-more:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
12179
13309
  '.dig-empty{padding:14px;text-align:center;color:var(--dsw-alias-label-tertiary);font-weight:500}',
12180
- '.dig-changes{display:flex;flex-direction:column;min-height:0;flex:1}',
13310
+ '.dig-changes{display:flex;flex-direction:column;min-height:0;flex:1;position:relative}',
12181
13311
  '.dig-changes-body{display:flex;flex-direction:column;min-height:0;flex:1}',
12182
13312
  '.dig-changes-list{flex:1;overflow:auto;min-height:0}',
13313
+ /* Multi-select chrome (issue #13). The marquee box is anchored to the
13314
+ VIEWPORT-space .dig-changes (invariant 13: position:fixed would be
13315
+ captured by the workbench's contain:layout and flung off-panel). */
13316
+ '.dig-marquee{position:absolute;border:1px solid var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent);pointer-events:none;z-index:6}',
13317
+ '.dig-selection-bar{display:flex;align-items:center;gap:12px;padding:3px 10px;font-size:11px;background:var(--dsw-alias-interactive-bg-hover);border-bottom:1px solid var(--dsw-alias-border-subtle,transparent)}',
13318
+ '.dig-selection-bar .dig-link{margin-left:0}',
13319
+ '.dig-selection-bar .dig-link-danger{color:var(--dsw-alias-state-error-primary)}',
13320
+ '.dig-selection-count{color:var(--dsw-alias-label-secondary);white-space:nowrap}',
13321
+ /* Focusable lists keep their own selection highlight as the focus
13322
+ indicator, so the container ring is suppressed. */
13323
+ '.dig-changes-list:focus-visible,.dig-tree:focus-visible,.dig-history-scroll:focus-visible{outline:none}',
12183
13324
  '.dig-group{display:flex;flex-direction:column}',
12184
13325
  '.dig-group-head{display:flex;align-items:center;gap:8px;padding:2px 8px;height:17px;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:600}',
12185
13326
  '.dig-link{margin-left:auto;border:none;background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;cursor:pointer;padding:0;flex:none}',