dsh-ide-git 0.17.0 → 0.18.1

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': '平铺',
@@ -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',
@@ -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': '平面清單',
@@ -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': '平面清單',
@@ -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': '平面清單',
@@ -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': 'フラット表示',
@@ -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': '평면 목록',
@@ -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',
@@ -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',
@@ -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': 'Плоский список',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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': 'قائمة مسطّحة',
@@ -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': 'सपाट सूची',
@@ -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': 'รายการแบน',
@@ -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
@@ -9291,14 +9723,16 @@ window.__ModuleLoader__.load({
9291
9723
  const tone = kind === 'tag' ? 'warn' : kind === 'remote' ? 'violet' : kind === 'head' ? 'success' : 'accent'
9292
9724
  const glyph = kind === 'tag' ? 'tag' : kind === 'remote' ? 'fetch' : kind === 'head' ? 'star' : 'branch'
9293
9725
  const where = entry.upstream === null || entry.upstream === undefined ? entry.name : entry.name + ' → ' + entry.upstream
9294
- // A single click only SELECTS: checking a branch out is a real change of
9295
- // working tree, and a stray click in a long list must not do that (reported
9296
- // 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.
9297
9731
  return E('div', {
9298
- 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' : ''),
9299
9733
  style: { paddingLeft: (BRANCH_INDENT_BASE + (props.depth === undefined ? 0 : props.depth) * BRANCH_INDENT_STEP) + 'px' },
9300
9734
  title: where + ' · ' + t('branches.pickHint'),
9301
- onClick: () => props.onPick(entry),
9735
+ onClick: (event) => props.onPick(event, entry, props.selKey),
9302
9736
  onDoubleClick: () => props.onCheckout(entry),
9303
9737
  onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, entry) },
9304
9738
  },
@@ -9311,15 +9745,20 @@ window.__ModuleLoader__.load({
9311
9745
  className: 'dig-badge dig-badge-muted dig-badge-worktree',
9312
9746
  title: t('branches.worktree') + ': ' + entry.worktree,
9313
9747
  }, E(Icon, { name: 'folder', size: 10 })),
9314
- entry.ahead > 0 ? E('span', { className: 'dig-badge' }, '↑' + entry.ahead) : null,
9315
- entry.behind > 0 ? E('span', { className: 'dig-badge' }, '↓' + entry.behind) : null)
9748
+ entry.ahead > 0 ? E('span', { className: 'dig-badge dig-badge-ahead' }, '↑' + entry.ahead) : null,
9749
+ entry.behind > 0 ? E('span', { className: 'dig-badge dig-badge-behind' }, '↓' + entry.behind) : null)
9316
9750
  }
9317
9751
 
9318
9752
  function BranchTree(props) {
9319
9753
  const t = props.t
9320
9754
  const [filter, setFilter] = useState('')
9321
9755
  const [collapsed, setCollapsed] = useState(() => treeSectionsOf(readTreeState()))
9322
- 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([])
9323
9762
  const [foldedFolders, setFoldedFolders] = useState(() => {
9324
9763
  const stored = readTreeState()
9325
9764
  return stored !== null && typeof stored === 'object' && stored.folders !== null && typeof stored.folders === 'object'
@@ -9342,6 +9781,21 @@ window.__ModuleLoader__.load({
9342
9781
  const remotes = (branches === null ? [] : branches.remote).filter((entry) => match(entry.name))
9343
9782
  const tags = (branches === null ? [] : branches.tags).filter((entry) => match(entry.name))
9344
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])
9345
9799
  /**
9346
9800
  * Branch names nest on '/', the way an IDE shows a branch namespace: clicking a
9347
9801
  * row only SELECTS it (checking out rewrites the working tree), so the row
@@ -9362,11 +9816,48 @@ window.__ModuleLoader__.load({
9362
9816
  return root
9363
9817
  }
9364
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])
9365
9855
  const branchRow = (entry, label, depth) => E(BranchRow, {
9366
- key: 'row:' + entry.name, entry: entry, label: label, depth: depth, t: t,
9367
- picked: picked === entry.name,
9368
- onPick: (target) => setPicked(target.name),
9369
- 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,
9370
9861
  })
9371
9862
  const rowsOf = (node, prefix, depth) => {
9372
9863
  const out = []
@@ -9401,7 +9892,71 @@ window.__ModuleLoader__.load({
9401
9892
  grouped === true
9402
9893
  ? rowsOf(forestOf(entries.map((entry) => decorate(entry))), '', 0)
9403
9894
  : entries.map((entry) => branchRow(decorate(entry), undefined, 0))))
9404
- 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
+ },
9405
9960
  E('div', { className: 'dig-search' },
9406
9961
  E('input', {
9407
9962
  className: 'dig-input dig-input-compact', value: filter, spellCheck: false,
@@ -9435,9 +9990,11 @@ window.__ModuleLoader__.load({
9435
9990
  const readOnly = props.group === 'ignored'
9436
9991
  const status = readOnly ? 'I' : (raw === '' ? 'M' : raw.charAt(0))
9437
9992
  return E('div', {
9438
- className: 'dig-row dig-row-file',
9993
+ className: 'dig-row dig-row-file' + (props.selected === true ? ' dig-row-selected' : ''),
9439
9994
  title: item.origPath === undefined ? item.path : item.origPath + ' → ' + item.path,
9440
- 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),
9441
9998
  onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, item, props.group) },
9442
9999
  },
9443
10000
  FileTypeGlyph({ path: item.path }),
@@ -9452,7 +10009,7 @@ window.__ModuleLoader__.load({
9452
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 }))
9453
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 }))),
9454
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 })))
9455
- }, (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)
9456
10013
 
9457
10014
  function ChangesPanel(props) {
9458
10015
  const t = props.t
@@ -9489,6 +10046,154 @@ window.__ModuleLoader__.load({
9489
10046
  state directly through props. */
9490
10047
  const foldedDirs = props.foldedDirs === undefined ? {} : props.foldedDirs
9491
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
+ }
9492
10197
  const changes = summary === null ? null : summary.changes
9493
10198
  const conflicted = changes === null ? [] : changes.conflicted
9494
10199
  const staged = changes === null ? [] : changes.staged
@@ -9537,6 +10242,8 @@ window.__ModuleLoader__.load({
9537
10242
  }
9538
10243
  const entryRow = (key, item) => E(ChangeRow, {
9539
10244
  key: key + ':' + item.path, item: item, group: key, t: t,
10245
+ selected: selection.has(key + ':' + item.path) === true,
10246
+ onRowClick: handleRowClick,
9540
10247
  onStage: props.onStage, onUnstage: props.onUnstage, onDiscard: props.onDiscard, onDiff: props.onDiff, onMenu: props.onChangeMenu,
9541
10248
  })
9542
10249
  const folderOf = (item) => {
@@ -9604,12 +10311,17 @@ window.__ModuleLoader__.load({
9604
10311
  emitGroup('untracked', fill(t('counts.untracked'), { n: untracked.length }), untracked)
9605
10312
  emitGroup('ignored', fill(t('counts.ignored'), { n: ignored.length }), ignored)
9606
10313
  return out
9607
- }, [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])
9608
10315
  const changeOffsets = useMemo(() => {
9609
10316
  const sums = [0]
9610
10317
  for (const item of changeItems) sums.push(sums[sums.length - 1] + item.h)
9611
10318
  return sums
9612
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
9613
10325
  /* Virtual window, VSCode-style (ListView.getVisibleRange): both ends are
9614
10326
  pixel→index conversions over the same prefix-sum array, so no "row
9615
10327
  count" ever enters the math (issue #12 — the old winLimit computed a
@@ -9707,11 +10419,34 @@ window.__ModuleLoader__.load({
9707
10419
  'data-action': 'more',
9708
10420
  onClick: (event) => props.onMoreMenu(event),
9709
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')))
9710
10431
  return E('div', { className: 'dig-changes' },
9711
10432
  aiConfirmDialog,
9712
10433
  head,
10434
+ selectionBar,
9713
10435
  E('div', { className: 'dig-changes-body' },
9714
- 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
+ },
9715
10450
  collapsed ? null : [
9716
10451
  total === 0 && ignored.length === 0 ? E('div', { className: 'dig-empty', key: 'empty' }, t('changes.empty')) : null,
9717
10452
  /* Windowed slice: spacers above/below stand in for the unmounted
@@ -9720,7 +10455,11 @@ window.__ModuleLoader__.load({
9720
10455
  visibleItems.map((item) => E(Fragment, { key: item.key }, item.node)),
9721
10456
  winLast < changeItems.length ? E('div', { key: 'pad-bottom', style: { height: String(changeOffsets[changeItems.length] - changeOffsets[winLast]) + 'px' } }) : null,
9722
10457
  ]),
9723
- 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
+ }))
9724
10463
  }
9725
10464
 
9726
10465
  /* ============================== history ============================== */
@@ -9844,7 +10583,9 @@ window.__ModuleLoader__.load({
9844
10583
  graph stroke whose data-lane differs — zero React re-renders. */
9845
10584
  'data-lane': String(props.row.lane),
9846
10585
  className: 'dig-commit' + (props.selected === true ? ' dig-commit-selected' : ''),
9847
- 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),
9848
10589
  onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, commit) },
9849
10590
  },
9850
10591
  E('span', { className: 'dig-commit-date', title: shortDate(commit.date) }, relativeTime(commit.date)),
@@ -9893,6 +10634,7 @@ window.__ModuleLoader__.load({
9893
10634
  const commits = props.commits
9894
10635
  const historyScrollRef = useRef(null)
9895
10636
  const viewport = useViewportBox(historyScrollRef)
10637
+ const pickAnchor = useRef(null)
9896
10638
  const [text, setText] = useState('')
9897
10639
  const [author, setAuthor] = useState('')
9898
10640
  const [refName, setRefName] = useState('')
@@ -9947,6 +10689,59 @@ window.__ModuleLoader__.load({
9947
10689
  }, [commits, text, author, refName, since, order])
9948
10690
 
9949
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
+ }
9950
10745
  const dirtyFilter = text !== '' || author !== '' || refName !== '' || since !== '' || (props.pathFilter !== undefined && props.pathFilter !== '')
9951
10746
  const applyPath = (value) => { if (props.onPathFilter !== undefined) props.onPathFilter(value) }
9952
10747
  const clearAll = () => {
@@ -10026,6 +10821,11 @@ window.__ModuleLoader__.load({
10026
10821
  E('div', {
10027
10822
  className: 'dig-history-scroll',
10028
10823
  ref: historyScrollRef,
10824
+ tabIndex: 0,
10825
+ onKeyDown: onListKeyDown,
10826
+ onPointerDownCapture: (event) => {
10827
+ if (isTypingTarget(event.target) === false) event.currentTarget.focus({ preventScroll: true })
10828
+ },
10029
10829
  /* Lane hover highlight (GitLens-style trace): copy the hovered row's
10030
10830
  lane into data-hover-lane; generated CSS dims every stroke whose
10031
10831
  data-lane differs. Direct DOM dataset writes on purpose — the
@@ -10045,8 +10845,9 @@ window.__ModuleLoader__.load({
10045
10845
  visibleRows.map((row) => E(CommitRow, {
10046
10846
  key: row.commit.hash,
10047
10847
  commit: row.commit, row: row, t: t,
10048
- selected: props.selectedHash === row.commit.hash,
10049
- 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,
10050
10851
  })),
10051
10852
  winEnd < rows.length ? E('div', { key: 'pad-bottom', style: { height: String((rows.length - winEnd) * HISTORY_ROW_H) + 'px' } }) : null,
10052
10853
  props.hasMore === true
@@ -10129,6 +10930,13 @@ window.__ModuleLoader__.load({
10129
10930
  and the selection menu's payload must say which. Cleared everywhere
10130
10931
  the patch is cleared. */
10131
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())
10132
10940
  const [menu, setMenu] = useState(null)
10133
10941
  const [dialog, setDialog] = useState(null)
10134
10942
  const [tick, setTick] = useState(0)
@@ -10876,30 +11684,230 @@ window.__ModuleLoader__.load({
10876
11684
  sendToComposer({ text: text, fallback: text })
10877
11685
  }, [t, sendToComposer])
10878
11686
 
10879
- 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) => {
10880
11842
  const current = branches === null ? '' : branches.branch
10881
11843
  const isLocal = entry.remote !== true && entry.tag !== true
10882
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
+ }
10883
11861
  const items = [
10884
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) } },
10885
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,
10886
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,
10887
11865
  { id: 'compare', icon: 'filter', tone: 'violet', label: t('action.compare'), disabled: entry.head === true || current === '', reason: reason, run: () => { void compareWith(current, entry.name) } },
10888
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]) },
10889
11868
  null,
10890
11869
  { id: 'favorite', icon: 'star', tone: 'warn', label: t('action.favorite'), active: favorites.indexOf(entry.name) >= 0, run: () => toggleFavoriteBranch(entry.name) },
10891
11870
  { id: 'newBranch', icon: 'plus', tone: 'success', label: t('action.newBranchFrom'), run: () => setDialog({ kind: 'newBranch', from: entry.name }) },
10892
11871
  isLocal ? { id: 'rename', icon: 'file', tone: 'primary', label: t('action.rename'), run: () => setDialog({ kind: 'renameBranch', from: entry.name }) } : null,
10893
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
+ }),
10894
11885
  null,
10895
11886
  { id: 'update', icon: 'fetch', tone: 'cyan', label: t('action.update'), run: () => { void run('fetch', { prune: true }) } },
10896
11887
  { id: 'push', icon: 'push', tone: 'success', label: t('action.push'), disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'push' }) },
10897
11888
  ].filter((item) => item !== null)
10898
11889
  openMenuAt(event, items)
10899
- }, [branches, checkout, run, t, compareWith, toggleFavoriteBranch, openMenuAt, favorites, blocked, sendGitSummaryToChat, repoLabel])
11890
+ }, [branches, checkout, run, t, compareWith, toggleFavoriteBranch, openMenuAt, favorites, blocked, sendGitSummaryToChat, repoLabel, copyValues])
10900
11891
 
10901
11892
  const commitMenu = useCallback((event, commit) => {
10902
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
+ }
10903
11911
  const items = [
10904
11912
  { id: 'details', icon: 'file', tone: 'primary', label: t('action.details'), run: () => { void selectCommit(commit) } },
10905
11913
  { id: 'copy', icon: 'tag', tone: 'secondary', label: t('action.copyHash'), run: () => { copyText(commit.hash) } },
@@ -10922,9 +11930,23 @@ window.__ModuleLoader__.load({
10922
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' }) },
10923
11931
  ]
10924
11932
  openMenuAt(event, items)
10925
- }, [run, selectCommit, t, openMenuAt, blocked, sendToComposer, sendGitSummaryToChat, repoLabel])
11933
+ }, [run, selectCommit, t, openMenuAt, blocked, sendToComposer, sendGitSummaryToChat, repoLabel, pickedCommits, commits, copyValues])
10926
11934
 
10927
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
+ }
10928
11950
  const items = [
10929
11951
  { id: 'diff', icon: 'file', tone: 'primary', label: t('action.showDiff'), run: () => { showDiff({ path: item.path, staged: group === 'staged', untracked: group === 'untracked' }) } },
10930
11952
  { id: 'sendRef', icon: 'send', tone: 'accent', label: t('menu.sendToChat'), run: () => sendFileReferenceToChat(item.path) },
@@ -10937,7 +11959,7 @@ window.__ModuleLoader__.load({
10937
11959
  { id: 'copy', icon: 'tag', tone: 'secondary', label: t('action.copyPath'), run: () => { copyText(item.path) } },
10938
11960
  ]
10939
11961
  openMenuAt(event, items)
10940
- }, [showDiff, run, t, openMenuAt, sendFileReferenceToChat, sendToComposer])
11962
+ }, [showDiff, run, t, openMenuAt, sendFileReferenceToChat, sendToComposer, changeSelection, changeSelectionGroups, openDiscardManyDialog, copyValues])
10941
11963
 
10942
11964
  /* The commit-detail file list shares the working-tree row menu, minus the
10943
11965
  staging verbs: a committed file's diff replays against its commit. */
@@ -11185,7 +12207,8 @@ window.__ModuleLoader__.load({
11185
12207
  E('span', { className: 'dig-repo-name' }, branches.branch),
11186
12208
  E(Icon, { name: 'chevron', size: 12 }))),
11187
12209
  summary === null || summary.upstream === null ? null : E('span', { className: 'dig-track' },
11188
- (summary.ahead > 0 ? '↑' + summary.ahead : '') + (summary.behind > 0 ? ' ↓' + summary.behind : '')),
12210
+ summary.ahead > 0 ? E('span', { className: 'dig-track-ahead' }, '↑' + summary.ahead) : null,
12211
+ summary.behind > 0 ? E('span', { className: 'dig-track-behind' }, (summary.ahead > 0 ? ' ' : '') + '↓' + summary.behind) : null),
11189
12212
  operation === null ? null : E('span', { className: 'dig-opchip', title: t('operation.hint') }, t('operation.' + operation)),
11190
12213
  E('button', {
11191
12214
  type: 'button', className: 'dig-icon-btn dig-icon-btn-small',
@@ -11225,6 +12248,11 @@ window.__ModuleLoader__.load({
11225
12248
  onSelect: (commit) => { void selectCommit(commit) },
11226
12249
  onMenu: commitMenu,
11227
12250
  onLoadMore: () => { void guard(() => loadCommits(commits.length)) },
12251
+ /* ---- multi-select (issue #13) ---- */
12252
+ pickedHashes: pickedCommits,
12253
+ onPicksChange: setPickedCommits,
12254
+ onSelectAll: (hashes) => setPickedCommits(new Set(hashes)),
12255
+ onKeyDown: onHistoryKeyDown,
11228
12256
  })
11229
12257
 
11230
12258
  const diffPane = patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane', 'data-pane': 'diff', style: paneStyle('diff'), onContextMenu: onDiffPaneContextMenu },
@@ -11489,6 +12517,15 @@ window.__ModuleLoader__.load({
11489
12517
  onDiscard: (item, group) => setDialog({ kind: 'discard', item: item, group: group }),
11490
12518
  onDiff: (item, group) => { showDiff({ path: item.path, staged: group === 'staged', untracked: group === 'untracked' }) },
11491
12519
  onChangeMenu: changeMenu,
12520
+ /* ---- multi-select (issue #13) ---- */
12521
+ selection: changeSelection,
12522
+ onSelectionChange: setChangeSelection,
12523
+ anchorRef: changeAnchor,
12524
+ onBulkStage: bulkStageChanges,
12525
+ onBulkUnstage: bulkUnstageChanges,
12526
+ onBulkDiscard: openDiscardManyDialog,
12527
+ onCopySelection: copyChangeSelection,
12528
+ onKeyDown: onChangesKeyDown,
11492
12529
  })
11493
12530
 
11494
12531
 
@@ -11691,19 +12728,19 @@ window.__ModuleLoader__.load({
11691
12728
  title: t('toolbar.tree'),
11692
12729
  onClick: () => foldTree(!treeOpen),
11693
12730
  }, E(Icon, { name: treeOpen ? 'eye' : 'eyeOff' }))),
11694
- 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,
12731
+ 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,
11695
12732
  treeOpen ? gutter('tree', 'prev') : null,
11696
12733
  view === 'changes' ? changesPane : E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane))
11697
12734
  } else if (columns) {
11698
12735
  body = E('div', { className: 'dig-body dig-body-columns', ref: bodyRef },
11699
- 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,
12736
+ 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,
11700
12737
  treeOpen ? gutter('tree', 'prev') : null,
11701
12738
  E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane),
11702
12739
  gutter('changes', 'next'),
11703
12740
  E('div', { className: 'dig-pane dig-pane-changes', 'data-pane': 'changes', style: paneStyle('changes') }, changesPane))
11704
12741
  } else {
11705
12742
  body = E('div', { className: 'dig-body dig-body-stack', ref: bodyRef },
11706
- 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,
12743
+ 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,
11707
12744
  treeOpen ? gutter('tree', 'prev') : null,
11708
12745
  E('div', { className: 'dig-pane dig-pane-changes-stack', 'data-pane': 'changes', style: paneStyle('changes') }, changesPane),
11709
12746
  gutter('changes', 'prev'),
@@ -11883,6 +12920,60 @@ window.__ModuleLoader__.load({
11883
12920
  onConfirm: () => { const ref = dialog.ref; setDialog(null); void dropStash(ref) },
11884
12921
  }))
11885
12922
  }
12923
+ /* Multi-select delete (issue #13): local branches / remote branches / tags
12924
+ in ONE confirm — each rides its existing destructive host method, and a
12925
+ protected local branch keeps the type-the-name gate. */
12926
+ if (dialog !== null && dialog.kind === 'deleteRefs') {
12927
+ const guardedName = dialog.locals.find((name) => isProtectedBranch(name) === true)
12928
+ const listed = dialog.locals
12929
+ .concat(dialog.tags)
12930
+ .concat(dialog.remotes.map((item) => item.remote + '/' + item.branch))
12931
+ const listText = listed.length <= 8 ? listed.join('、') : listed.slice(0, 8).join('、') + ' …'
12932
+ overlays.push(E(ConfirmDialog, {
12933
+ key: 'deleteRefs',
12934
+ t: t,
12935
+ title: t('confirm.title'),
12936
+ text: fill(t('confirm.deleteRefs'), { b: dialog.locals.length, r: dialog.remotes.length, g: dialog.tags.length })
12937
+ + (listText === '' ? '' : ' ' + listText),
12938
+ requireText: guardedName === undefined ? undefined : guardedName,
12939
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
12940
+ onCancel: () => setDialog(null),
12941
+ onConfirm: () => {
12942
+ const locals = dialog.locals
12943
+ const tags = dialog.tags
12944
+ const remotes = dialog.remotes
12945
+ setDialog(null)
12946
+ if (locals.length > 0) void deleteBranches(locals)
12947
+ if (tags.length > 0) void run('tagDelete', { names: tags })
12948
+ const byRemote = new Map()
12949
+ for (const item of remotes) {
12950
+ if (byRemote.has(item.remote) === false) byRemote.set(item.remote, [])
12951
+ byRemote.get(item.remote).push(item.branch)
12952
+ }
12953
+ for (const pair of byRemote) void run('branchDeleteRemote', { remote: pair[0], branches: pair[1], confirm: true })
12954
+ },
12955
+ }))
12956
+ }
12957
+ /* Multi-select discard: tracked paths go through `checkout --`, untracked
12958
+ through `clean -fd` — the same two-lane split as Discard All. */
12959
+ if (dialog !== null && dialog.kind === 'discardMany') {
12960
+ overlays.push(E(ConfirmDialog, {
12961
+ key: 'discardMany',
12962
+ t: t,
12963
+ title: t('confirm.title'),
12964
+ text: fill(t('confirm.discardMany'), { n: dialog.tracked.length + dialog.untracked.length })
12965
+ + (dialog.untracked.length > 0 ? ' ' + fill(t('confirm.discardManyUntracked'), { m: dialog.untracked.length }) : ''),
12966
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
12967
+ onCancel: () => setDialog(null),
12968
+ onConfirm: () => {
12969
+ const tracked = dialog.tracked
12970
+ const untracked = dialog.untracked
12971
+ setDialog(null)
12972
+ if (tracked.length > 0) void run('discard', { paths: tracked, confirm: true })
12973
+ if (untracked.length > 0) void run('discard', { paths: untracked, untracked: true, confirm: true })
12974
+ },
12975
+ }))
12976
+ }
11886
12977
  if (dialog !== null && dialog.kind === 'discard') {
11887
12978
  overlays.push(E(ConfirmDialog, {
11888
12979
  key: 'discard',
@@ -12179,7 +13270,7 @@ window.__ModuleLoader__.load({
12179
13270
  '.dig-row{display:flex;align-items:center;gap:5px;padding:2px 8px;cursor:pointer;white-space:nowrap;overflow:hidden}',
12180
13271
  '.dig-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
12181
13272
  '.dig-row-head{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
12182
- '.dig-row-selected{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))}',
13273
+ '.dig-row-selected{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary)}',
12183
13274
  '.dig-row-remote .dig-row-label{color:var(--dsw-alias-label-secondary);font-weight:500}',
12184
13275
  '.dig-row-tag .dig-row-label{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary));font-weight:500}',
12185
13276
  '.dig-row-head .dig-row-label{font-weight:600}',
@@ -12188,6 +13279,10 @@ window.__ModuleLoader__.load({
12188
13279
  '.dig-row-sub{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis}',
12189
13280
  '.dig-row-dir{margin-left:auto;opacity:.75}',
12190
13281
  '.dig-badge{font-size:10px;padding:0 5px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);flex:none}',
13282
+ /* Ahead/behind colours (user request): pending pushes read green, pending
13283
+ pulls read blue — the same semantics in the tree rows and the top bar. */
13284
+ '.dig-badge-ahead,.dig-track-ahead{color:var(--dsw-alias-state-success-primary)}',
13285
+ '.dig-badge-behind,.dig-track-behind{color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary))}',
12191
13286
  '.dig-badge-muted{opacity:.8}',
12192
13287
  '.dig-badge-worktree{padding:0 3px;display:inline-flex;align-items:center;height:14px}',
12193
13288
  '.dig-history{display:flex;flex-direction:column;min-height:0;flex:1}',
@@ -12217,9 +13312,20 @@ window.__ModuleLoader__.load({
12217
13312
  '.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}',
12218
13313
  '.dig-load-more:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
12219
13314
  '.dig-empty{padding:14px;text-align:center;color:var(--dsw-alias-label-tertiary);font-weight:500}',
12220
- '.dig-changes{display:flex;flex-direction:column;min-height:0;flex:1}',
13315
+ '.dig-changes{display:flex;flex-direction:column;min-height:0;flex:1;position:relative}',
12221
13316
  '.dig-changes-body{display:flex;flex-direction:column;min-height:0;flex:1}',
12222
13317
  '.dig-changes-list{flex:1;overflow:auto;min-height:0}',
13318
+ /* Multi-select chrome (issue #13). The marquee box is anchored to the
13319
+ VIEWPORT-space .dig-changes (invariant 13: position:fixed would be
13320
+ captured by the workbench's contain:layout and flung off-panel). */
13321
+ '.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}',
13322
+ '.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)}',
13323
+ '.dig-selection-bar .dig-link{margin-left:0}',
13324
+ '.dig-selection-bar .dig-link-danger{color:var(--dsw-alias-state-error-primary)}',
13325
+ '.dig-selection-count{color:var(--dsw-alias-label-secondary);white-space:nowrap}',
13326
+ /* Focusable lists keep their own selection highlight as the focus
13327
+ indicator, so the container ring is suppressed. */
13328
+ '.dig-changes-list:focus-visible,.dig-tree:focus-visible,.dig-history-scroll:focus-visible{outline:none}',
12223
13329
  '.dig-group{display:flex;flex-direction:column}',
12224
13330
  '.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}',
12225
13331
  '.dig-link{margin-left:auto;border:none;background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;cursor:pointer;padding:0;flex:none}',