dsh-ide-git 0.24.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * chrome:
10
10
  *
11
11
  * columns wide and tall enough : repo/branch bar | tree | graph | changes
12
- * stack narrow and tall : changes over graph, collapsible tree
13
- * compact short or very narrow : one header row + a Changes/History switch
12
+ * stack narrow and tall : changes over a tabbed history/diff window
13
+ * compact very narrow : changes on top + the same tabbed window
14
14
  *
15
15
  * Theming: no hard-coded surfaces. Every surface is a --dsw-alias-* token, so
16
16
  * transparent themes and background plugins (dsh-any-background) show through,
@@ -90,6 +90,8 @@ window.__ModuleLoader__.load({
90
90
  'repo.nested': '子仓库',
91
91
  'repo.none': '当前工作区不是 Git 仓库',
92
92
  'repo.pick': '选择要查看的仓库',
93
+ 'repo.filter': '搜索仓库',
94
+ 'picker.empty': '没有匹配项',
93
95
  'repo.empty': '这个工作区下没有找到 Git 仓库',
94
96
  'repo.switch': '切换仓库',
95
97
  'branch.switch': '切换分支',
@@ -462,6 +464,8 @@ window.__ModuleLoader__.load({
462
464
  'repo.nested': 'nested',
463
465
  'repo.none': 'This workspace is not a Git repository',
464
466
  'repo.pick': 'Pick a repository to inspect',
467
+ 'repo.filter': 'Search repositories',
468
+ 'picker.empty': 'No matches',
465
469
  'repo.empty': 'No Git repository found in this workspace',
466
470
  'repo.switch': 'Switch repository',
467
471
  'branch.switch': 'Switch branch',
@@ -842,6 +846,8 @@ window.__ModuleLoader__.load({
842
846
  'repo.nested': '巢狀',
843
847
  'repo.none': '呢個工作區唔係 Git 存放庫',
844
848
  'repo.pick': '揀一個存放庫嚟睇',
849
+ 'repo.filter': '搵存放庫',
850
+ 'picker.empty': '冇相符項目',
845
851
  'repo.empty': '呢個工作區搵唔到 Git 存放庫',
846
852
  'repo.switch': '切換存放庫',
847
853
  'branch.switch': '切換分支',
@@ -1214,6 +1220,8 @@ window.__ModuleLoader__.load({
1214
1220
  'repo.nested': '巢狀',
1215
1221
  'repo.none': '這個工作區不是 Git 版本庫',
1216
1222
  'repo.pick': '選擇要檢視的版本庫',
1223
+ 'repo.filter': '搜尋版本庫',
1224
+ 'picker.empty': '沒有相符項目',
1217
1225
  'repo.empty': '這個工作區找不到 Git 版本庫',
1218
1226
  'repo.switch': '切換版本庫',
1219
1227
  'branch.switch': '切換分支',
@@ -1586,6 +1594,8 @@ window.__ModuleLoader__.load({
1586
1594
  'repo.nested': '巢狀',
1587
1595
  'repo.none': '呢個工作區唔係 Git 存放庫',
1588
1596
  'repo.pick': '揀一個存放庫嚟睇',
1597
+ 'repo.filter': '搵存放庫',
1598
+ 'picker.empty': '冇相符項目',
1589
1599
  'repo.empty': '呢個工作區搵唔到 Git 存放庫',
1590
1600
  'repo.switch': '切換存放庫',
1591
1601
  'branch.switch': '切換分支',
@@ -1958,6 +1968,8 @@ window.__ModuleLoader__.load({
1958
1968
  'repo.nested': 'ネスト',
1959
1969
  'repo.none': 'このワークスペースは Git リポジトリではありません',
1960
1970
  'repo.pick': '確認するリポジトリを選択',
1971
+ 'repo.filter': 'リポジトリを検索',
1972
+ 'picker.empty': '一致する項目がありません',
1961
1973
  'repo.empty': 'このワークスペースに Git リポジトリが見つかりません',
1962
1974
  'repo.switch': 'リポジトリを切り替え',
1963
1975
  'branch.switch': 'ブランチを切り替え',
@@ -2330,6 +2342,8 @@ window.__ModuleLoader__.load({
2330
2342
  'repo.nested': '중첩',
2331
2343
  'repo.none': '이 워크스페이스는 Git 저장소가 아닙니다',
2332
2344
  'repo.pick': '확인할 저장소를 선택하세요',
2345
+ 'repo.filter': '저장소 검색',
2346
+ 'picker.empty': '일치하는 항목이 없습니다',
2333
2347
  'repo.empty': '이 워크스페이스에서 Git 저장소를 찾을 수 없습니다',
2334
2348
  'repo.switch': '저장소 전환',
2335
2349
  'branch.switch': '브랜치 전환',
@@ -2702,6 +2716,8 @@ window.__ModuleLoader__.load({
2702
2716
  'repo.nested': 'verschachtelt',
2703
2717
  'repo.none': 'Dieser Arbeitsbereich ist kein Git-Repository',
2704
2718
  'repo.pick': 'Repository zum Ansehen wählen',
2719
+ 'repo.filter': 'Repositories durchsuchen',
2720
+ 'picker.empty': 'Keine Treffer',
2705
2721
  'repo.empty': 'In diesem Arbeitsbereich wurde kein Git-Repository gefunden',
2706
2722
  'repo.switch': 'Repository wechseln',
2707
2723
  'branch.switch': 'Branch wechseln',
@@ -3074,6 +3090,8 @@ window.__ModuleLoader__.load({
3074
3090
  'repo.nested': 'imbriqué',
3075
3091
  'repo.none': 'Cet espace de travail n\'est pas un dépôt Git',
3076
3092
  'repo.pick': 'Choisir un dépôt à inspecter',
3093
+ 'repo.filter': 'Rechercher des dépôts',
3094
+ 'picker.empty': 'Aucun résultat',
3077
3095
  'repo.empty': 'Aucun dépôt Git trouvé dans cet espace de travail',
3078
3096
  'repo.switch': 'Changer de dépôt',
3079
3097
  'branch.switch': 'Changer de branche',
@@ -3446,6 +3464,8 @@ window.__ModuleLoader__.load({
3446
3464
  'repo.nested': 'вложенный',
3447
3465
  'repo.none': 'Эта рабочая область не является репозиторием Git',
3448
3466
  'repo.pick': 'Выберите репозиторий для просмотра',
3467
+ 'repo.filter': 'Поиск репозиториев',
3468
+ 'picker.empty': 'Нет совпадений',
3449
3469
  'repo.empty': 'В этой рабочей области репозиторий Git не найден',
3450
3470
  'repo.switch': 'Сменить репозиторий',
3451
3471
  'branch.switch': 'Сменить ветку',
@@ -3818,6 +3838,8 @@ window.__ModuleLoader__.load({
3818
3838
  'repo.nested': 'aninhado',
3819
3839
  'repo.none': 'Esta área de trabalho não é um repositório Git',
3820
3840
  'repo.pick': 'Escolha um repositório para inspecionar',
3841
+ 'repo.filter': 'Pesquisar repositórios',
3842
+ 'picker.empty': 'Nenhum resultado',
3821
3843
  'repo.empty': 'Nenhum repositório Git encontrado nesta área de trabalho',
3822
3844
  'repo.switch': 'Trocar de repositório',
3823
3845
  'branch.switch': 'Trocar de branch',
@@ -4190,6 +4212,8 @@ window.__ModuleLoader__.load({
4190
4212
  'repo.nested': 'annidato',
4191
4213
  'repo.none': 'Questa area di lavoro non è un repository Git',
4192
4214
  'repo.pick': 'Scegli un repository da esaminare',
4215
+ 'repo.filter': 'Cerca repository',
4216
+ 'picker.empty': 'Nessuna corrispondenza',
4193
4217
  'repo.empty': 'Nessun repository Git trovato in questa area di lavoro',
4194
4218
  'repo.switch': 'Cambia repository',
4195
4219
  'branch.switch': 'Cambia branch',
@@ -4562,6 +4586,8 @@ window.__ModuleLoader__.load({
4562
4586
  'repo.nested': 'genest',
4563
4587
  'repo.none': 'Deze werkruimte is geen Git-repository',
4564
4588
  'repo.pick': 'Kies een repository om te bekijken',
4589
+ 'repo.filter': 'Repositories zoeken',
4590
+ 'picker.empty': 'Geen resultaten',
4565
4591
  'repo.empty': 'Geen Git-repository gevonden in deze werkruimte',
4566
4592
  'repo.switch': 'Wissel van repository',
4567
4593
  'branch.switch': 'Wissel van branch',
@@ -4934,6 +4960,8 @@ window.__ModuleLoader__.load({
4934
4960
  'repo.nested': 'zagnieżdżone',
4935
4961
  'repo.none': 'Ten obszar roboczy nie jest repozytorium Git',
4936
4962
  'repo.pick': 'Wybierz repozytorium do podglądu',
4963
+ 'repo.filter': 'Szukaj repozytoriów',
4964
+ 'picker.empty': 'Brak dopasowań',
4937
4965
  'repo.empty': 'W tym obszarze roboczym nie znaleziono repozytorium Git',
4938
4966
  'repo.switch': 'Zmień repozytorium',
4939
4967
  'branch.switch': 'Zmień gałąź',
@@ -5306,6 +5334,8 @@ window.__ModuleLoader__.load({
5306
5334
  'repo.nested': 'nästlad',
5307
5335
  'repo.none': 'Den här arbetsytan är inte ett Git-arkiv',
5308
5336
  'repo.pick': 'Välj ett arkiv att granska',
5337
+ 'repo.filter': 'Sök bland arkiv',
5338
+ 'picker.empty': 'Inga träffar',
5309
5339
  'repo.empty': 'Inget Git-arkiv hittades i den här arbetsytan',
5310
5340
  'repo.switch': 'Byt arkiv',
5311
5341
  'branch.switch': 'Byt gren',
@@ -5678,6 +5708,8 @@ window.__ModuleLoader__.load({
5678
5708
  'repo.nested': 'iç içe',
5679
5709
  'repo.none': 'Bu çalışma alanı bir Git deposu değil',
5680
5710
  'repo.pick': 'İncelenecek depoyu seçin',
5711
+ 'repo.filter': 'Depo ara',
5712
+ 'picker.empty': 'Eşleşme yok',
5681
5713
  'repo.empty': 'Bu çalışma alanında Git deposu bulunamadı',
5682
5714
  'repo.switch': 'Depoyu değiştir',
5683
5715
  'branch.switch': 'Dalı değiştir',
@@ -6050,6 +6082,8 @@ window.__ModuleLoader__.load({
6050
6082
  'repo.nested': 'bersarang',
6051
6083
  'repo.none': 'Ruang kerja ini bukan repositori Git',
6052
6084
  'repo.pick': 'Pilih repositori untuk diperiksa',
6085
+ 'repo.filter': 'Cari repositori',
6086
+ 'picker.empty': 'Tidak ada kecocokan',
6053
6087
  'repo.empty': 'Tidak ada repositori Git di ruang kerja ini',
6054
6088
  'repo.switch': 'Ganti repositori',
6055
6089
  'branch.switch': 'Ganti branch',
@@ -6422,6 +6456,8 @@ window.__ModuleLoader__.load({
6422
6456
  'repo.nested': 'lồng nhau',
6423
6457
  'repo.none': 'Không gian làm việc này không phải kho Git',
6424
6458
  'repo.pick': 'Chọn một kho để xem',
6459
+ 'repo.filter': 'Tìm kho',
6460
+ 'picker.empty': 'Không có kết quả phù hợp',
6425
6461
  'repo.empty': 'Không tìm thấy kho Git trong không gian làm việc này',
6426
6462
  'repo.switch': 'Chuyển kho',
6427
6463
  'branch.switch': 'Chuyển nhánh',
@@ -6794,6 +6830,8 @@ window.__ModuleLoader__.load({
6794
6830
  'repo.nested': 'متشعّب',
6795
6831
  'repo.none': 'مساحة العمل هذه ليست مستودع Git',
6796
6832
  'repo.pick': 'اختر مستودعًا لعرضه',
6833
+ 'repo.filter': 'البحث عن المستودعات',
6834
+ 'picker.empty': 'لا توجد نتائج مطابقة',
6797
6835
  'repo.empty': 'لم يُعثر على مستودع Git في مساحة العمل هذه',
6798
6836
  'repo.switch': 'تبديل المستودع',
6799
6837
  'branch.switch': 'تبديل الفرع',
@@ -7166,6 +7204,8 @@ window.__ModuleLoader__.load({
7166
7204
  'repo.nested': 'नेस्टेड',
7167
7205
  'repo.none': 'यह कार्यक्षेत्र Git रिपॉज़िटरी नहीं है',
7168
7206
  'repo.pick': 'देखने के लिए रिपॉज़िटरी चुनें',
7207
+ 'repo.filter': 'रिपॉज़िटरी खोजें',
7208
+ 'picker.empty': 'कोई मेल नहीं',
7169
7209
  'repo.empty': 'इस कार्यक्षेत्र में कोई Git रिपॉज़िटरी नहीं मिली',
7170
7210
  'repo.switch': 'रिपॉज़िटरी बदलें',
7171
7211
  'branch.switch': 'शाखा बदलें',
@@ -7538,6 +7578,8 @@ window.__ModuleLoader__.load({
7538
7578
  'repo.nested': 'ซ้อนกัน',
7539
7579
  'repo.none': 'เวิร์กสเปซนี้ไม่ใช่รีโพซิทอรี Git',
7540
7580
  'repo.pick': 'เลือกรีโพซิทอรีที่จะดู',
7581
+ 'repo.filter': 'ค้นหารีโพซิทอรี',
7582
+ 'picker.empty': 'ไม่มีรายการที่ตรงกัน',
7541
7583
  'repo.empty': 'ไม่พบรีโพซิทอรี Git ในเวิร์กสเปซนี้',
7542
7584
  'repo.switch': 'สลับรีโพซิทอรี',
7543
7585
  'branch.switch': 'สลับแบรนช์',
@@ -8852,8 +8894,12 @@ window.__ModuleLoader__.load({
8852
8894
  panes measure width (columns). */
8853
8895
  const PANE_CHROME_KEYS = {
8854
8896
  columns: ['tree', 'changes', 'diff'],
8855
- stack: ['tree', 'changes', 'diff'],
8856
- compact: ['tree', 'diff'],
8897
+ /* v0.26.1: BOTH stacked chromes put the history list and the diff in one
8898
+ TABBED window (history | diff) under the changes list, so the diff is
8899
+ no longer a pane of its own here — it fills the tab body. Only the
8900
+ three-column chrome still splits the diff out of the middle column. */
8901
+ stack: ['tree', 'changes'],
8902
+ compact: ['tree', 'changes'],
8857
8903
  }
8858
8904
  /* Whether a key measures a height (stacked chromes) or a width (columns).
8859
8905
  This is the chrome's DEFAULT for the tree/changes panes; the diff pane
@@ -8869,9 +8915,8 @@ window.__ModuleLoader__.load({
8869
8915
  'columns:diff': { min: 120, max: 900 },
8870
8916
  'stack:tree': { min: 100, max: 420 },
8871
8917
  'stack:changes': { min: 120, max: 460 },
8872
- 'stack:diff': { min: 120, max: 900 },
8873
8918
  'compact:tree': { min: 100, max: 420 },
8874
- 'compact:diff': { min: 120, max: 900 },
8919
+ 'compact:changes': { min: 120, max: 460 },
8875
8920
  }
8876
8921
  /* Today's fixed sizes stay the default — and stay in the stylesheet: a pane
8877
8922
  the user never dragged keeps exactly the look it had before, while a
@@ -8884,6 +8929,7 @@ window.__ModuleLoader__.load({
8884
8929
  'stack:tree': { ratio: 0.36 },
8885
8930
  'stack:changes': { ratio: 0.46 },
8886
8931
  'compact:tree': { ratio: 0.42 },
8932
+ 'compact:changes': { ratio: 0.46 },
8887
8933
  }
8888
8934
  const PANE_MAIN_MIN_W = 240
8889
8935
  const PANE_MAIN_MIN_H = 150
@@ -8899,7 +8945,7 @@ window.__ModuleLoader__.load({
8899
8945
  history↔diff separator lives inside the middle pane, so it is not counted
8900
8946
  against the body's own budget. */
8901
8947
  const PANE_GUTTER_PX = 8
8902
- const PANE_BODY_GUTTERS = { columns: 2, stack: 2, compact: 1 }
8948
+ const PANE_BODY_GUTTERS = { columns: 2, stack: 2, compact: 2 }
8903
8949
  const PANE_BUCKETS = Object.keys(PANE_CHROME_KEYS).reduce(
8904
8950
  (all, chrome) => all.concat([chrome + ':wide', chrome + ':tall']), [])
8905
8951
 
@@ -10469,12 +10515,187 @@ window.__ModuleLoader__.load({
10469
10515
  return E('div', { className: 'dig-menu-wrap', ref: ref, style: { left: position.left + 'px', top: position.top + 'px' } },
10470
10516
  E('div', { className: 'dig-menu', ref: cardRef, 'data-menu-material': 'translucent' },
10471
10517
  E('div', { className: 'dig-menu-scroll' },
10472
- props.items.map((item, index) => renderItem(item, index, false)))),
10518
+ props.body === undefined
10519
+ ? (Array.isArray(props.items) ? props.items : []).map((item, index) => renderItem(item, index, false))
10520
+ : props.body)),
10473
10521
  sub === null ? null : E('div', { className: 'dig-menu dig-menu-sub', 'data-menu-material': 'translucent', style: sub.flip === true ? { right: '100%', top: sub.top + 'px' } : { left: '100%', top: sub.top + 'px' } },
10474
10522
  E('div', { className: 'dig-menu-scroll' },
10475
10523
  sub.items.map((item, index) => renderItem(item, index, true)))))
10476
10524
  }
10477
10525
 
10526
+ /* Searchable picker body shared by the panel header switchers and the
10527
+ composer pills. It owns no positioning: the panel renders it as a
10528
+ ContextMenu body, the composer inside its own .dig-cs-menu. Repositories
10529
+ are a flat, filtered list; branches keep their slash namespace as a
10530
+ collapsible tree, and a non-empty query opens every folder so a match
10531
+ is never hidden behind a fold. */
10532
+ function SearchPicker(props) {
10533
+ const t = props.t
10534
+ const mode = props.mode === 'branch' ? 'branch' : 'repo'
10535
+ const [needle, setNeedle] = useState('')
10536
+ const [folded, setFolded] = useState({})
10537
+ const [active, setActive] = useState(0)
10538
+ const inputRef = useRef(null)
10539
+ const listRef = useRef(null)
10540
+ useEffect(() => {
10541
+ const input = inputRef.current
10542
+ if (input !== null && typeof input.focus === 'function') input.focus({ preventScroll: true })
10543
+ }, [])
10544
+ const query = needle.trim().toLowerCase()
10545
+ const matches = (value) => query === '' || String(value === undefined || value === null ? '' : value).toLowerCase().indexOf(query) >= 0
10546
+ const rows = []
10547
+ if (mode === 'repo') {
10548
+ const repos = Array.isArray(props.repos) ? props.repos : []
10549
+ for (const repo of repos) {
10550
+ const name = repo.name === undefined || repo.name === null ? baseNameOf(repo.path) : String(repo.name)
10551
+ if (query !== '' && matches(name) === false && matches(repo.path) === false && matches(repo.sub) === false && matches(repo.branch) === false) continue
10552
+ rows.push({ key: 'repo:' + String(repo.path), kind: 'repo', repo: repo, name: name })
10553
+ }
10554
+ } else {
10555
+ const entries = (Array.isArray(props.branches) ? props.branches : []).filter((entry) => matches(entry.name))
10556
+ const root = { folders: new Map(), leaves: [] }
10557
+ for (const entry of entries) {
10558
+ const parts = String(entry.name).split('/')
10559
+ let node = root
10560
+ for (let index = 0; index < parts.length - 1; index += 1) {
10561
+ const key = parts[index]
10562
+ if (node.folders.has(key) === false) node.folders.set(key, { folders: new Map(), leaves: [] })
10563
+ node = node.folders.get(key)
10564
+ }
10565
+ node.leaves.push({ entry: entry, label: parts[parts.length - 1] })
10566
+ }
10567
+ const countLeaves = (node) => node.leaves.length + Array.from(node.folders.values()).reduce((sum, child) => sum + countLeaves(child), 0)
10568
+ const walk = (node, prefix, depth) => {
10569
+ for (const name of Array.from(node.folders.keys()).sort()) {
10570
+ const id = prefix === '' ? name : prefix + '/' + name
10571
+ const open = query !== '' || folded[id] !== true
10572
+ rows.push({ key: 'dir:' + id, kind: 'folder', id: id, name: name, depth: depth, count: countLeaves(node.folders.get(name)), open: open })
10573
+ if (open === true) walk(node.folders.get(name), id, depth + 1)
10574
+ }
10575
+ for (const leaf of node.leaves) {
10576
+ const entry = leaf.entry
10577
+ const worktree = entry.worktree !== null && entry.worktree !== undefined
10578
+ const busy = typeof props.busyName === 'string' && props.busyName !== ''
10579
+ rows.push({
10580
+ key: 'branch:' + String(entry.name), kind: 'branch', entry: entry, label: leaf.label, depth: depth,
10581
+ disabled: worktree === true || busy === true, worktree: worktree,
10582
+ })
10583
+ }
10584
+ }
10585
+ walk(root, '', 0)
10586
+ }
10587
+ const selectable = rows.filter((row) => row.kind !== 'folder' && row.disabled !== true)
10588
+ const activeIndex = selectable.length === 0 ? -1 : Math.min(active, selectable.length - 1)
10589
+ const activeRow = activeIndex < 0 ? null : selectable[activeIndex]
10590
+ const activeKey = activeRow === null ? '' : activeRow.key
10591
+ useEffect(() => {
10592
+ const host = listRef.current
10593
+ if (host === null || activeKey === '') return
10594
+ const found = Array.from(host.querySelectorAll('[data-pick-key]')).find((row) => row.getAttribute('data-pick-key') === activeKey)
10595
+ if (found !== undefined && typeof found.scrollIntoView === 'function') found.scrollIntoView({ block: 'nearest' })
10596
+ }, [activeKey])
10597
+ const pickRow = (row) => {
10598
+ if (row === null || row.disabled === true || typeof props.onPick !== 'function') return
10599
+ if (row.kind === 'repo') props.onPick(row.repo.path, row.repo)
10600
+ else if (row.kind === 'branch') props.onPick(row.entry.name, row.entry)
10601
+ }
10602
+ const onKeyDown = (event) => {
10603
+ if (event.key === 'Escape') {
10604
+ event.preventDefault()
10605
+ event.stopPropagation()
10606
+ if (typeof props.onClose === 'function') props.onClose()
10607
+ return
10608
+ }
10609
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
10610
+ if (selectable.length === 0) return
10611
+ event.preventDefault()
10612
+ event.stopPropagation()
10613
+ setActive((value) => {
10614
+ const current = Math.min(value, selectable.length - 1)
10615
+ return event.key === 'ArrowDown' ? (current + 1) % selectable.length : (current - 1 + selectable.length) % selectable.length
10616
+ })
10617
+ return
10618
+ }
10619
+ if (event.key === 'Enter') {
10620
+ event.preventDefault()
10621
+ event.stopPropagation()
10622
+ pickRow(activeRow)
10623
+ }
10624
+ }
10625
+ const currentRepo = mode === 'repo' && props.current !== undefined && props.current !== null ? String(props.current) : ''
10626
+ const currentBranch = mode === 'branch' && props.current !== undefined && props.current !== null ? String(props.current) : ''
10627
+ const rowClass = (row) => 'dig-pick-row'
10628
+ + (activeKey === row.key ? ' dig-pick-row-active' : '')
10629
+ + ((currentRepo !== '' && row.kind === 'repo' && String(row.repo.path) === currentRepo) ? ' dig-pick-row-current' : '')
10630
+ + ((currentBranch !== '' && row.kind === 'branch' && String(row.entry.name) === currentBranch) ? ' dig-pick-row-current' : '')
10631
+ + (row.disabled === true ? ' dig-pick-row-disabled' : '')
10632
+ const rowsView = rows.map((row) => {
10633
+ if (row.kind === 'folder') {
10634
+ return E('button', {
10635
+ key: row.key, type: 'button',
10636
+ className: 'dig-pick-folder' + (row.open !== true ? ' dig-pick-folder-closed' : ''),
10637
+ style: { paddingLeft: (6 + row.depth * 14) + 'px' },
10638
+ onClick: () => { if (query !== '') return; setFolded((previous) => Object.assign({}, previous, { [row.id]: row.open === true })) },
10639
+ },
10640
+ E('span', { className: 'dig-pick-chevron' + (row.open === true ? ' dig-pick-chevron-open' : '') }, E(Icon, { name: 'chevron', size: 10 })),
10641
+ E(Icon, { name: 'folder', size: 12 }),
10642
+ E('span', { className: 'dig-pick-name' }, row.name),
10643
+ E('span', { className: 'dig-pick-count' }, String(row.count)))
10644
+ }
10645
+ if (row.kind === 'repo') {
10646
+ return E('button', {
10647
+ key: row.key, type: 'button', className: rowClass(row), 'data-pick-key': row.key,
10648
+ title: row.repo.path,
10649
+ onMouseEnter: () => { const index = selectable.indexOf(row); if (index >= 0) setActive(index) },
10650
+ onClick: () => pickRow(row),
10651
+ },
10652
+ E(Icon, { name: 'folder', size: 13 }),
10653
+ E('span', { className: 'dig-pick-main' },
10654
+ E('span', { className: 'dig-pick-name' }, row.name),
10655
+ row.repo.sub !== undefined && row.repo.sub !== null && row.repo.sub !== '' && row.repo.sub !== row.name
10656
+ ? E('span', { className: 'dig-pick-sub' }, row.repo.sub)
10657
+ : null),
10658
+ row.repo.branch === undefined || row.repo.branch === null || row.repo.branch === ''
10659
+ ? null
10660
+ : E('span', { className: 'dig-pick-badge' }, row.repo.branch))
10661
+ }
10662
+ const entry = row.entry
10663
+ const head = entry.head === true
10664
+ const worktreeNote = row.worktree === true
10665
+ ? (props.worktreeNote === undefined ? t('branches.worktree') : props.worktreeNote)
10666
+ : null
10667
+ return E('button', {
10668
+ key: row.key, type: 'button',
10669
+ className: rowClass(row) + (head === true ? ' dig-pick-row-head' : ''),
10670
+ 'data-pick-key': row.key,
10671
+ style: { paddingLeft: (6 + row.depth * 14) + 'px' },
10672
+ disabled: row.disabled === true,
10673
+ title: String(entry.name) + (worktreeNote === null ? '' : ' · ' + worktreeNote),
10674
+ onMouseEnter: () => { const index = selectable.indexOf(row); if (index >= 0) setActive(index) },
10675
+ onClick: () => pickRow(row),
10676
+ },
10677
+ E('span', { className: 'dig-pick-icon dig-tone-' + (head === true ? 'warn' : 'accent') }, E(Icon, { name: head === true ? 'star' : 'branch', size: 12 })),
10678
+ E('span', { className: 'dig-pick-name' }, row.label),
10679
+ worktreeNote === null ? null : E('span', { className: 'dig-pick-note' }, worktreeNote),
10680
+ entry.ahead > 0 ? E('span', { className: 'dig-pick-count' }, '↑' + String(entry.ahead)) : null,
10681
+ entry.behind > 0 ? E('span', { className: 'dig-pick-count' }, '↓' + String(entry.behind)) : null)
10682
+ })
10683
+ return E('div', { className: 'dig-pick dig-pick-' + mode },
10684
+ E('div', { className: 'dig-pick-search' },
10685
+ E('input', {
10686
+ ref: inputRef,
10687
+ className: 'dig-input dig-input-compact dig-pick-input',
10688
+ value: needle,
10689
+ spellCheck: false,
10690
+ placeholder: mode === 'repo' ? t('repo.filter') : t('branches.filter'),
10691
+ 'aria-label': mode === 'repo' ? t('repo.filter') : t('branches.filter'),
10692
+ onChange: (event) => { setNeedle(event.target.value); setActive(0) },
10693
+ onKeyDown: onKeyDown,
10694
+ })),
10695
+ E('div', { className: 'dig-pick-list', ref: listRef, role: 'listbox' },
10696
+ rowsView.length === 0 ? E('div', { className: 'dig-pick-empty' }, t('picker.empty')) : rowsView))
10697
+ }
10698
+
10478
10699
  /* Lets the user choose which actions the rail shows and in which order. The
10479
10700
  list is drag-sortable and arrow-sortable (the arrows also work with a
10480
10701
  keyboard); visibility is a per-action toggle, not a delete, so a hidden
@@ -12027,10 +12248,12 @@ window.__ModuleLoader__.load({
12027
12248
  return
12028
12249
  }
12029
12250
  anchorRef.current = key
12030
- /* Plain click only SELECTS (single-replace, the branch tree's model):
12031
- the diff moved to DOUBLE-click — a stray click mid-multi-select must
12032
- not yank the pane away (v0.18.3). */
12251
+ /* Plain click SELECTS and shows the diff (v0.25.1, user request): in the
12252
+ narrow right column the list replaces the pane, so a click that only
12253
+ highlighted the row looked like "nothing happened". Modifier clicks
12254
+ above stay pure multi-select; ignored rows have no diff to show. */
12033
12255
  setSelection((previous) => previous.size === 1 && previous.has(key) === true ? previous : new Set([key]))
12256
+ if (group !== 'ignored' && props.onDiff !== undefined) props.onDiff(item, group)
12034
12257
  }, [setSelection, anchorRef, props.onDiff])
12035
12258
  /* Rubber-band selection now comes from the shared useRubberBand hook
12036
12259
  (see its comment): the fixed row heights + the prefix-sum offsets
@@ -13165,11 +13388,37 @@ window.__ModuleLoader__.load({
13165
13388
  const [selectedPath, setSelectedPath] = useState(null)
13166
13389
  const [patch, setPatch] = useState('')
13167
13390
  const [patchLoading, setPatchLoading] = useState(false)
13391
+ /* The compact chrome's lower window is tabbed (history | diff) instead of
13392
+ split, so opening a file does not take the history list away: the diff
13393
+ gets its own tab and the user can switch back (v0.26.0). */
13394
+ const [splitTab, setSplitTab] = useState('history')
13395
+ useEffect(() => {
13396
+ if (splitTab === 'diff' && patch === '' && patchLoading === false) setSplitTab('history')
13397
+ }, [splitTab, patch, patchLoading])
13398
+ /* v0.26.3: the row fires onDiff from its single click now, so a double
13399
+ click used to open the same diff three times — and the responses raced
13400
+ (a slow first request could land after the second and paint the wrong
13401
+ file under the new title). `diffSeq` stamps each request and only the
13402
+ newest one is allowed to write; `diffLast` swallows the burst a double
13403
+ click produces. */
13404
+ const diffSeq = useRef(0)
13405
+ const diffLast = useRef({ key: '', at: 0 })
13168
13406
  /* What the open diff pane shows (v0.13.5): the pane is shared by the
13169
13407
  commit detail (hash + subject) and the working tree (staged or not),
13170
13408
  and the selection menu's payload must say which. Cleared everywhere
13171
13409
  the patch is cleared. */
13172
13410
  const [diffContext, setDiffContext] = useState(null)
13411
+ /* v0.27: closing / switching away must also invalidate the request in
13412
+ flight and reset the double-click guard. Without the stamp bump a
13413
+ late response repainted the closed pane; without the diffLast reset
13414
+ reopening the same file within 400ms was swallowed. */
13415
+ const resetDiff = useCallback(() => {
13416
+ diffSeq.current += 1
13417
+ diffLast.current = { key: '', at: 0 }
13418
+ setPatch('')
13419
+ setPatchLoading(false)
13420
+ setDiffContext(null)
13421
+ }, [])
13173
13422
  /* ---- multi-select (issue #13): the change list's selection lives here
13174
13423
  so the right-click menu, the keyboard and the selection bar act on one
13175
13424
  set; the branch tree and the history keep theirs closer to their rows
@@ -13203,6 +13452,10 @@ window.__ModuleLoader__.load({
13203
13452
  const onChangeSelection = useCallback((update) => { setChangeSelection(update); clearPicksExcept('changes') }, [clearPicksExcept])
13204
13453
  const onCommitPicks = useCallback((update) => { setPickedCommits(update); clearPicksExcept('history') }, [clearPicksExcept])
13205
13454
  const [menu, setMenu] = useState(null)
13455
+ /* The header repo/branch switchers need a search box and a branch tree,
13456
+ which a plain ContextMenu item list cannot express; they share the
13457
+ same anchored surface through its `body` prop. */
13458
+ const [picker, setPicker] = useState(null)
13206
13459
  const [dialog, setDialog] = useState(null)
13207
13460
  const [tick, setTick] = useState(0)
13208
13461
  const [favTick, setFavTick] = useState(0)
@@ -13818,11 +14071,10 @@ window.__ModuleLoader__.load({
13818
14071
  headRef.current = null
13819
14072
  setDetail(null)
13820
14073
  setSelectedHash(null)
13821
- setPatch('')
13822
- setDiffContext(null)
14074
+ resetDiff()
13823
14075
  setView('history')
13824
14076
  setPathFilter('')
13825
- }, [sessionId])
14077
+ }, [sessionId, resetDiff])
13826
14078
 
13827
14079
  /* The composer status pill and this panel are separate trees; when the
13828
14080
  pill (or any other seat) switches the repository or the branch, this
@@ -13849,16 +14101,24 @@ window.__ModuleLoader__.load({
13849
14101
  }, [sessionId, repoRoot, pickRepo, refresh])
13850
14102
 
13851
14103
  const openDiff = useCallback(async (seed) => {
14104
+ const key = JSON.stringify([base, seed])
14105
+ const now = Date.now()
14106
+ if (key === diffLast.current.key && now - diffLast.current.at < 400) return
14107
+ diffLast.current = { key: key, at: now }
14108
+ const run = diffSeq.current + 1
14109
+ diffSeq.current = run
13852
14110
  setPatchLoading(true)
13853
14111
  setPatch('')
13854
14112
  try {
13855
14113
  const data = await request('diff', Object.assign({}, base, seed))
14114
+ if (diffSeq.current !== run) return
13856
14115
  setPatch(data.patch)
13857
14116
  } catch (caught) {
14117
+ if (diffSeq.current !== run) return
13858
14118
  setErrorCode(null)
13859
14119
  setError(caught instanceof Error ? caught.message : String(caught))
13860
14120
  } finally {
13861
- setPatchLoading(false)
14121
+ if (diffSeq.current === run) setPatchLoading(false)
13862
14122
  }
13863
14123
  }, [base])
13864
14124
 
@@ -13939,6 +14199,11 @@ window.__ModuleLoader__.load({
13939
14199
  file menu, working-diff shortcut): external first, inline fallback. */
13940
14200
  const showDiff = useCallback((seed) => {
13941
14201
  if (previewDiff(seed) === true) return
14202
+ /* The pane header names the file; its close button clears the selection
14203
+ again, so the inline path owns selectedPath here too. */
14204
+ setSelectedPath(seed.path === undefined ? null : seed.path)
14205
+ /* Compact chrome: the diff lives in a tab, so opening a file shows it. */
14206
+ setSplitTab('diff')
13942
14207
  if (seed.hash !== undefined && seed.hash !== null) {
13943
14208
  setDiffContext({ kind: 'commit', hash: seed.hash, subject: seed.subject || '' })
13944
14209
  void openDiff({ hash: seed.hash, path: seed.path })
@@ -13951,8 +14216,7 @@ window.__ModuleLoader__.load({
13951
14216
  const selectCommit = useCallback(async (commit) => {
13952
14217
  setSelectedHash(commit.hash)
13953
14218
  setSelectedPath(null)
13954
- setPatch('')
13955
- setDiffContext(null)
14219
+ resetDiff()
13956
14220
  setView('detail')
13957
14221
  setDetail(null)
13958
14222
  try {
@@ -13961,7 +14225,7 @@ window.__ModuleLoader__.load({
13961
14225
  setErrorCode(null)
13962
14226
  setError(caught instanceof Error ? caught.message : String(caught))
13963
14227
  }
13964
- }, [base])
14228
+ }, [base, resetDiff])
13965
14229
 
13966
14230
  const checkout = useCallback(async (entry) => {
13967
14231
  if (entry.tag === true) { void run('checkout', { branch: entry.name }); return }
@@ -13988,6 +14252,7 @@ window.__ModuleLoader__.load({
13988
14252
  const openMenuAt = useCallback((event, items) => {
13989
14253
  const element = hostRef.current
13990
14254
  const rect = element === null ? null : element.getBoundingClientRect()
14255
+ setPicker(null)
13991
14256
  setMenu({
13992
14257
  x: event.clientX - (rect === null ? 0 : rect.left),
13993
14258
  y: event.clientY - (rect === null ? 0 : rect.top),
@@ -13997,6 +14262,22 @@ window.__ModuleLoader__.load({
13997
14262
  })
13998
14263
  }, [])
13999
14264
 
14265
+ /* Same anchor math as openMenuAt, but the body is a SearchPicker instead
14266
+ of a fixed item list. Keeping it separate means the generic context
14267
+ menus do not grow search state they never use. */
14268
+ const openPickerAt = useCallback((event, kind) => {
14269
+ const element = hostRef.current
14270
+ const rect = element === null ? null : element.getBoundingClientRect()
14271
+ setMenu(null)
14272
+ setPicker({
14273
+ kind: kind,
14274
+ x: event.clientX - (rect === null ? 0 : rect.left),
14275
+ y: event.clientY - (rect === null ? 0 : rect.top),
14276
+ boundsWidth: rect === null ? 0 : Math.round(rect.width),
14277
+ boundsHeight: rect === null ? 0 : Math.round(rect.height),
14278
+ })
14279
+ }, [])
14280
+
14000
14281
  /* ---------- toast plumbing ---------- */
14001
14282
  /* Declared BEFORE the context menus on purpose: the send-to-chat bindings
14002
14283
  and every menu builder close over pushToast, and a hooks dependency
@@ -14712,7 +14993,13 @@ window.__ModuleLoader__.load({
14712
14993
 
14713
14994
  /* ---------- shared pieces ---------- */
14714
14995
 
14715
- const repoOptions = repoState === null ? [] : repoState.repos
14996
+ /* The scanned row keeps the branch it had when the workspace was
14997
+ listed; after a checkout the branch pill and the tree move on, so the
14998
+ repo label must follow the live HEAD instead of naming the old
14999
+ branch. */
15000
+ const repoOptions = repoState === null ? [] : repoState.repos.map((repo) => (
15001
+ repo.path === repoRoot && branches !== null ? Object.assign({}, repo, { branch: branches.branch }) : repo
15002
+ ))
14716
15003
  /* A linked worktree opened from the worktree view is a repository root
14717
15004
  the workspace scan never listed. Without a header entry the top bar
14718
15005
  kept naming the primary checkout while every branch and change below
@@ -14734,27 +15021,12 @@ window.__ModuleLoader__.load({
14734
15021
  popovers reuse openMenuAt, so they clamp to the panel box and share
14735
15022
  the opaque, frosted menu surface instead of the OS select popup. */
14736
15023
  const openRepoMenu = useCallback((event) => {
14737
- openMenuAt(event, headerRepos.map((repo) => ({
14738
- id: 'repo:' + repo.path,
14739
- icon: 'folder',
14740
- tone: 'accent',
14741
- label: repo.name + (repo.branch === null || repo.branch === undefined ? '' : ' · ' + repo.branch),
14742
- active: repo.path === repoRoot,
14743
- run: () => { if (repo.path !== repoRoot) pickRepo(repo.path) },
14744
- })))
14745
- }, [openMenuAt, headerRepos, repoRoot, pickRepo])
15024
+ openPickerAt(event, 'repo')
15025
+ }, [openPickerAt])
14746
15026
 
14747
15027
  const openBranchSwitchMenu = useCallback((event) => {
14748
- const local = branches === null ? [] : branches.local
14749
- openMenuAt(event, local.map((entry) => ({
14750
- id: 'switch:' + entry.name,
14751
- icon: entry.head === true ? 'star' : 'branch',
14752
- tone: entry.head === true ? 'warn' : 'accent',
14753
- label: entry.name,
14754
- active: entry.head === true,
14755
- run: () => { if (entry.head !== true) void checkout(entry) },
14756
- })))
14757
- }, [openMenuAt, branches, checkout])
15028
+ openPickerAt(event, 'branch')
15029
+ }, [openPickerAt])
14758
15030
 
14759
15031
  const topBar = E('div', { className: 'dig-topbar' },
14760
15032
  E(RepoSelect, {
@@ -14815,7 +15087,7 @@ window.__ModuleLoader__.load({
14815
15087
  const historyPane = view === 'detail'
14816
15088
  ? E(CommitDetail, {
14817
15089
  t: t, detail: detail, selectedPath: selectedPath,
14818
- onBack: () => { setView('history'); setSelectedPath(null); setPatch(''); setDiffContext(null) },
15090
+ onBack: () => { setView('history'); setSelectedPath(null); resetDiff() },
14819
15091
  /* Plain click only highlights the file; the DOUBLE-click opens its
14820
15092
  diff (v0.18.3, one click model across every list). */
14821
15093
  onSelectFile: (file) => { setSelectedPath(file.path) },
@@ -14846,11 +15118,20 @@ window.__ModuleLoader__.load({
14846
15118
  request: request,
14847
15119
  })
14848
15120
 
14849
- const diffPane = patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane', 'data-pane': 'diff', style: paneStyle('diff'), onContextMenu: onDiffPaneContextMenu },
15121
+ /* The compact chrome renders the diff INSTEAD of the changes list (the
15122
+ narrow right column has no neighbour to show it beside), so it needs a
15123
+ filling variant; every other chrome keeps the 55%-tall split pane. */
15124
+ const diffPaneOf = (fill) => patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane' + (fill === true ? ' dig-diff-pane-fill' : ''), 'data-pane': 'diff', style: fill === true ? null : paneStyle('diff'), onContextMenu: onDiffPaneContextMenu },
14850
15125
  E('div', { className: 'dig-diff-head' },
14851
15126
  E('span', { className: 'dig-mono dig-diff-path' }, selectedPath === null ? '' : selectedPath),
14852
- E('button', { type: 'button', className: 'dig-icon-btn', onClick: () => { setPatch(''); setSelectedPath(null); setDiffContext(null) } }, E(Icon, { name: 'close', size: 12 }))),
15127
+ E('button', {
15128
+ type: 'button',
15129
+ className: 'dig-icon-btn',
15130
+ title: t('toast.close'),
15131
+ onClick: () => { resetDiff(); setSelectedPath(null); setSplitTab('history') },
15132
+ }, E(Icon, { name: 'close', size: 12 }))),
14853
15133
  E(DiffBody, { patch: patch, loading: patchLoading, t: t }))
15134
+ const diffPane = diffPaneOf(false)
14854
15135
 
14855
15136
  /* ---------- rail (IDE-style left action strip) ---------- */
14856
15137
 
@@ -15226,7 +15507,7 @@ window.__ModuleLoader__.load({
15226
15507
  else if (spec.id === 'tree') { entry.active = treeOpen; entry.run = () => foldTree(!treeOpen) }
15227
15508
  else if (spec.id === 'worktrees') {
15228
15509
  entry.active = view === 'worktrees'
15229
- entry.run = () => { setPatch(''); setDiffContext(null); setView(view === 'worktrees' ? 'history' : 'worktrees') }
15510
+ entry.run = () => { resetDiff(); setView(view === 'worktrees' ? 'history' : 'worktrees') }
15230
15511
  }
15231
15512
  else if (spec.id === 'float') {
15232
15513
  // Label AND icon follow where the panel actually is: floating panels get
@@ -15372,9 +15653,56 @@ window.__ModuleLoader__.load({
15372
15653
  const worktrees = summary !== null && Array.isArray(summary.worktrees) ? summary.worktrees : []
15373
15654
 
15374
15655
  /* The history↔diff separator only exists while a diff is on screen, and it
15375
- lives inside the middle pane — the diff is the pane below it. */
15656
+ lives inside the middle pane — the diff is the pane below it. Only the
15657
+ three-column chrome keeps that split (v0.26.1). */
15376
15658
  const diffGutter = diffPane === null ? null : gutter('diff', 'next')
15377
15659
 
15660
+ /* v0.26.1 — the stacked chromes (stack and compact) show the history list
15661
+ and the diff in ONE tabbed window below the changes list: clicking a
15662
+ file flips that window to the diff tab instead of splitting the diff out
15663
+ and squeezing the history. `withCount` only matters on compact, whose
15664
+ changes head (and with it the "changes {n}" badge) is hidden; `withTree`
15665
+ adds the tree toggle that chrome has no other way to reach. */
15666
+ const noDiff = patch === '' && patchLoading === false
15667
+ const fillDiff = diffPaneOf(true)
15668
+ const splitTabs = (withCount, withTree) => E('div', { className: 'dig-split-tabs' },
15669
+ withCount === true ? E('span', { className: 'dig-compact-count' }, fill(t('seg.changes'), { n: dirty })) : null,
15670
+ E('button', {
15671
+ type: 'button',
15672
+ className: 'dig-tab',
15673
+ 'data-active': splitTab === 'history' ? 'true' : 'false',
15674
+ 'data-action': 'history-tab',
15675
+ onClick: () => setSplitTab('history'),
15676
+ }, t('seg.history')),
15677
+ E('button', {
15678
+ type: 'button',
15679
+ className: 'dig-tab',
15680
+ 'data-active': splitTab === 'diff' ? 'true' : 'false',
15681
+ 'data-action': 'diff-tab',
15682
+ disabled: noDiff,
15683
+ title: noDiff ? t('diff.empty') : t('diff.codeLabel'),
15684
+ onClick: () => setSplitTab('diff'),
15685
+ }, t('diff.codeLabel')),
15686
+ E('span', { className: 'dig-topbar-spacer' }),
15687
+ withTree === true ? E('button', {
15688
+ type: 'button',
15689
+ className: 'dig-icon-btn' + (treeOpen ? ' dig-icon-btn-active' : ''),
15690
+ 'data-action': 'tree',
15691
+ 'aria-pressed': treeOpen ? 'true' : 'false',
15692
+ title: t('toolbar.tree'),
15693
+ onClick: () => foldTree(!treeOpen),
15694
+ }, E(Icon, { name: treeOpen ? 'eye' : 'eyeOff' })) : null)
15695
+
15696
+ /* v0.26.3: a tabbed window keeps BOTH panes mounted and only hides the
15697
+ inactive one. Swapping the elements out unmounted the history list (and
15698
+ the commit detail) on every diff switch, which threw away its scroll
15699
+ position and the windowed list's state. `display:none` keeps the
15700
+ scrollTop (verified in Chromium). The hidden diff measures 0 tall and
15701
+ is dropped by measurePanes' `live > 0` filter, so pane geometry stays
15702
+ honest. */
15703
+ const diffActive = splitTab === 'diff' && fillDiff !== null
15704
+ const tabPane = (active, node) => node === null ? null : E('div', { className: 'dig-tab-pane' + (active === true ? '' : ' dig-tab-pane-hidden') }, node)
15705
+
15378
15706
  let body
15379
15707
  if (repoState === null) {
15380
15708
  /* The scan is still running: a neutral loading state, never the
@@ -15398,31 +15726,19 @@ window.__ModuleLoader__.load({
15398
15726
  onRemove: (tree) => setDialog({ kind: 'worktreeRemove', tree: tree, force: false }),
15399
15727
  }))
15400
15728
  } else if (compact) {
15729
+ /* v0.26.0: the changes list sits on TOP and the lower window is a tab
15730
+ pair (history | diff) instead of a split — opening a file no longer
15731
+ takes the history list away, and the changes list never disappears.
15732
+ The diff tab only exists while there is a patch to show. */
15401
15733
  body = E('div', { className: 'dig-body dig-body-compact', ref: bodyRef },
15402
- E('div', { className: 'dig-compact-bar' },
15403
- E(Segmented, {
15404
- value: view === 'detail' ? 'history' : view,
15405
- onChange: (next) => { setView(next); setPatch(''); setDiffContext(null) },
15406
- items: [
15407
- { id: 'changes', label: fill(t('seg.changes'), { n: dirty }) },
15408
- { id: 'history', label: t('seg.history') },
15409
- ],
15410
- }),
15411
- E('span', { className: 'dig-topbar-spacer' }),
15412
- // Two-way on purpose: treeOpen is persisted, so a header button that
15413
- // only rendered while the tree was OPEN left a folded tree with no way
15414
- // back on this chrome (the rail now carries one too, for the others).
15415
- E('button', {
15416
- type: 'button',
15417
- className: 'dig-icon-btn' + (treeOpen ? ' dig-icon-btn-active' : ''),
15418
- 'data-action': 'tree',
15419
- 'aria-pressed': treeOpen ? 'true' : 'false',
15420
- title: t('toolbar.tree'),
15421
- onClick: () => foldTree(!treeOpen),
15422
- }, E(Icon, { name: treeOpen ? 'eye' : 'eyeOff' }))),
15423
15734
  treeOpen ? E('div', { className: 'dig-compact-tree', 'data-pane': 'tree', style: paneStyle('tree') }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBrowse: browseBranch, onBranchMenu: branchMenu, onDeleteEntries: deleteBranchEntries, onCopyValues: copyValues, onPicksChange: onBranchPicks, clearSignal: pickEpochs.tree })) : null,
15424
15735
  treeOpen ? gutter('tree', 'prev') : null,
15425
- view === 'changes' ? changesPane : E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane))
15736
+ E('div', { className: 'dig-pane dig-pane-changes-stack', 'data-pane': 'changes', style: paneStyle('changes') }, changesPane),
15737
+ gutter('changes', 'prev'),
15738
+ E('div', { className: 'dig-pane dig-pane-main' },
15739
+ splitTabs(true, true),
15740
+ tabPane(diffActive === false, historyPane),
15741
+ fillDiff === null ? null : tabPane(diffActive, fillDiff)))
15426
15742
  } else if (columns) {
15427
15743
  body = E('div', { className: 'dig-body dig-body-columns', ref: bodyRef },
15428
15744
  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, onBrowse: browseBranch, onBranchMenu: branchMenu, onDeleteEntries: deleteBranchEntries, onCopyValues: copyValues, onPicksChange: onBranchPicks, clearSignal: pickEpochs.tree })) : null,
@@ -15436,7 +15752,13 @@ window.__ModuleLoader__.load({
15436
15752
  treeOpen ? gutter('tree', 'prev') : null,
15437
15753
  E('div', { className: 'dig-pane dig-pane-changes-stack', 'data-pane': 'changes', style: paneStyle('changes') }, changesPane),
15438
15754
  gutter('changes', 'prev'),
15439
- E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffGutter, diffPane))
15755
+ /* v0.26.1: below the commit box the history list and the diff share
15756
+ one tabbed window — a click on a changed file flips to the diff tab
15757
+ and leaves the list (and its scroll position) alone. */
15758
+ E('div', { className: 'dig-pane dig-pane-main' },
15759
+ splitTabs(false, false),
15760
+ tabPane(diffActive === false, historyPane),
15761
+ fillDiff === null ? null : tabPane(diffActive, fillDiff)))
15440
15762
  }
15441
15763
 
15442
15764
  /* ---------- overlays ---------- */
@@ -15452,6 +15774,37 @@ window.__ModuleLoader__.load({
15452
15774
  onClose: () => setMenu(null),
15453
15775
  }))
15454
15776
  }
15777
+ if (picker !== null) {
15778
+ /* The panel header's repo/branch switchers share the ContextMenu
15779
+ surface (same clamping and outside-click), but their body is the
15780
+ searchable picker instead of an item list. */
15781
+ const branchList = branches === null ? [] : branches.local
15782
+ overlays.push(E(ContextMenu, {
15783
+ key: 'picker',
15784
+ anchor: { x: picker.x, y: picker.y },
15785
+ boundsWidth: picker.boundsWidth,
15786
+ boundsHeight: picker.boundsHeight,
15787
+ items: [],
15788
+ body: E(SearchPicker, {
15789
+ key: picker.kind,
15790
+ mode: picker.kind,
15791
+ t: t,
15792
+ repos: picker.kind === 'repo' ? headerRepos : [],
15793
+ branches: picker.kind === 'branch' ? branchList : [],
15794
+ current: picker.kind === 'repo' ? repoRoot : (branches === null ? '' : branches.branch),
15795
+ onPick: (value, row) => {
15796
+ setPicker(null)
15797
+ if (picker.kind === 'repo') {
15798
+ if (value !== repoRoot) pickRepo(value)
15799
+ return
15800
+ }
15801
+ if (row.head !== true) void checkout(row)
15802
+ },
15803
+ onClose: () => setPicker(null),
15804
+ }),
15805
+ onClose: () => setPicker(null),
15806
+ }))
15807
+ }
15455
15808
  if (railSettings === true) {
15456
15809
  overlays.push(E(RailSettings, {
15457
15810
  key: 'rail-settings',
@@ -15952,7 +16305,21 @@ window.__ModuleLoader__.load({
15952
16305
  '.dig-pane-main{flex:1;min-width:0}',
15953
16306
  '.dig-pane-changes{width:290px;flex:none;border-left:1px solid var(--dsw-alias-border-l2)}',
15954
16307
  '.dig-pane-changes-stack{max-height:46%;flex:none;border-bottom:1px solid var(--dsw-alias-border-l2)}',
15955
- '.dig-compact-bar{display:flex;align-items:center;gap:6px;padding:4px 6px;flex:none}',
16308
+ /* v0.26.1: both stacked chromes show history and diff in one tabbed
16309
+ window; the strip is the window's tab bar (the compact chrome also
16310
+ hosts the changes count and the tree toggle in it). */
16311
+ '.dig-split-tabs{display:flex;align-items:center;gap:4px;padding:4px 6px;flex:none;border-bottom:1px solid var(--dsw-alias-border-l2)}',
16312
+ /* v0.26.3: the tabbed window's bodies stay mounted; the inactive one is
16313
+ hidden so the history list keeps its scroll position and the detail
16314
+ keeps its expanded state. */
16315
+ '.dig-tab-pane{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0}',
16316
+ '.dig-tab-pane-hidden{display:none}',
16317
+ '.dig-compact-count{color:var(--dsw-alias-label-secondary);font-weight:500;font-size:11px;padding:0 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:45%}',
16318
+ '.dig-tab{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;font-weight:500;padding:2px 10px;border-radius:var(--dsw-radius-sm,8px);cursor:pointer;white-space:nowrap;flex:none}',
16319
+ '.dig-tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
16320
+ '.dig-tab[data-active="true"]{background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}',
16321
+ '.dig-tab:disabled{opacity:.45;cursor:default}',
16322
+ '.dig-tab:disabled:hover{background:transparent;color:var(--dsw-alias-label-secondary)}',
15956
16323
  '.dig-compact-tree{max-height:42%;flex:none;border-bottom:1px solid var(--dsw-alias-border-l2)}',
15957
16324
  // A divider: an 8px hit area (a fingertip needs it) painted as a 1px hairline
15958
16325
  // that lights up on hover, focus or while dragging. touch-action:none is what
@@ -15986,6 +16353,29 @@ window.__ModuleLoader__.load({
15986
16353
  '.dig-picker-name{flex:none;font-weight:600}',
15987
16354
  '.dig-picker-kind{color:var(--dsw-alias-label-tertiary);flex:none}',
15988
16355
  '.dig-picker-path{color:var(--dsw-alias-label-tertiary);margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:50%}',
16356
+ /* v0.27 searchable repo / branch picker (panel header + composer pill).
16357
+ The list scrolls, the search field stays pinned on top. */
16358
+ '.dig-pick{display:flex;flex-direction:column;gap:2px;width:300px;max-width:100%;min-height:0;color:var(--dsw-alias-label-primary)}',
16359
+ '.dig-pick-search{position:sticky;top:0;z-index:1;padding:2px 2px 6px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1))}',
16360
+ '.dig-pick-list{display:flex;flex-direction:column;gap:1px;overflow:auto;min-height:0;max-height:300px;overscroll-behavior:contain}',
16361
+ '.dig-pick-row{display:flex;align-items:center;gap:6px;width:100%;padding:4px 8px;border:none;border-radius:var(--dsw-radius-sm,8px);background:transparent;color:inherit;font:inherit;font-size:13px;text-align:left;cursor:pointer;min-width:0}',
16362
+ '.dig-pick-row:hover:not(:disabled),.dig-pick-folder:hover{background:var(--dsw-alias-interactive-bg-hover)}',
16363
+ '.dig-pick-row-active{background:var(--dsw-alias-interactive-bg-hover)}',
16364
+ '.dig-pick-row-current{color:var(--dsw-alias-brand-primary)}',
16365
+ '.dig-pick-row-disabled{opacity:.45;cursor:default}',
16366
+ '.dig-pick-row-head{font-weight:600}',
16367
+ '.dig-pick-folder{display:flex;align-items:center;gap:5px;width:100%;padding:3px 8px;border:none;border-radius:var(--dsw-radius-sm,8px);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;text-align:left;cursor:pointer;min-width:0}',
16368
+ '.dig-pick-chevron{display:inline-flex;flex:none;transition:transform .12s ease;opacity:.6}',
16369
+ '.dig-pick-chevron-open{transform:rotate(90deg)}',
16370
+ '.dig-pick-main{display:flex;flex-direction:column;min-width:0;flex:1}',
16371
+ '.dig-pick-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
16372
+ '.dig-pick-sub{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:12px}',
16373
+ '.dig-pick-icon{display:inline-flex;flex:none}',
16374
+ '.dig-pick-badge{margin-left:auto;flex:none;max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 6px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:10px;line-height:16px}',
16375
+ '.dig-pick-note{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px}',
16376
+ '.dig-pick-count{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}',
16377
+ '.dig-pick-empty{padding:10px 8px;color:var(--dsw-alias-label-tertiary);font-size:12px}',
16378
+ '.dig-cs-menu.dig-cs-picker{width:300px;min-width:260px;max-width:340px;max-height:360px;padding:4px;overflow:hidden}',
15989
16379
  /* position:relative: the marquee box paints against this wrapper (the
15990
16380
  scroll container is offset by the search field — same rule as
15991
16381
  .dig-history above). */
@@ -16008,6 +16398,11 @@ window.__ModuleLoader__.load({
16008
16398
  '.dig-folder-head:hover{color:var(--dsw-alias-label-primary)}',
16009
16399
  '.dig-folder-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
16010
16400
  '.dig-count{margin-left:auto;opacity:.6;font-weight:400}',
16401
+ /* v0.26.2: the changes head is the one count that belongs to the WORD, not
16402
+ to the right edge — .dig-count pushes itself to the far right (fine for the
16403
+ branch groups), so the changes header opts back in to sitting right after
16404
+ its title (user report: the number floated in the middle of the row). */
16405
+ '.dig-changes-head .dig-count{margin-left:0}',
16011
16406
  '.dig-chevron{display:inline-flex;transform:rotate(-90deg);transition:transform .12s ease}',
16012
16407
  '.dig-chevron-open{transform:rotate(0deg)}',
16013
16408
  '.dig-row{display:flex;align-items:center;gap:5px;padding:2px 8px;cursor:pointer;white-space:nowrap;overflow:hidden}',
@@ -16060,7 +16455,10 @@ window.__ModuleLoader__.load({
16060
16455
  filter bar, and painting from the wrong rect was the v0.19.2 drift. */
16061
16456
  '.dig-history{display:flex;flex-direction:column;min-height:0;flex:1;position:relative}',
16062
16457
  '.dig-history-scroll{flex:1;overflow:auto}',
16063
- '.dig-commit{display:flex;align-items:center;gap:6px;padding:1px 8px 1px 0;cursor:pointer;height:26px;overflow:hidden}',
16458
+ // The row's left edge follows the bars above it (filter row / compact
16459
+ // switch: 4px 6px padding), so the time column and the graph start on the
16460
+ // same inset as the input box instead of flush against the pane edge.
16461
+ '.dig-commit{display:flex;align-items:center;gap:6px;padding:1px 8px 1px 6px;cursor:pointer;height:26px;overflow:hidden}',
16064
16462
  '.dig-commit:hover{background:var(--dsw-alias-interactive-bg-hover)}',
16065
16463
  /* Same theme-aware gray mix as .dig-row-selected (see it). */
16066
16464
  '.dig-commit-selected{background:color-mix(in srgb,var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover)) 58%,var(--dsw-static-neutral-bluish-75,var(--dsw-alias-bg-layer-3)) 42%);color:var(--dsw-alias-label-primary);box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary)}',
@@ -16171,6 +16569,7 @@ window.__ModuleLoader__.load({
16171
16569
  '.dig-detail-files{display:flex;flex-direction:column}',
16172
16570
  '.dig-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
16173
16571
  '.dig-diff-pane{flex:none;max-height:55%;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--dsw-alias-border-l2)}',
16572
+ '.dig-diff-pane-fill{flex:1;max-height:none;border-top:none}',
16174
16573
  '.dig-diff-head{display:flex;align-items:center;gap:8px;padding:3px 8px;color:var(--dsw-alias-label-tertiary);flex:none;min-width:0}',
16175
16574
  '.dig-diff-path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}',
16176
16575
  '.dig-diff{flex:1;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:calc(12px + var(--dsh-code-font-delta, 0px));line-height:1.5;font-weight:500;min-height:0}',
@@ -16422,6 +16821,13 @@ window.__ModuleLoader__.load({
16422
16821
  '.dig-wt-label{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500}',
16423
16822
  '.dig-wt-error{padding:6px 8px;margin-bottom:8px;border-radius:var(--dsw-radius-sm,8px);background:rgba(220,38,38,.12);color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;word-break:break-word}',
16424
16823
  'body[data-ds-dark-theme] .dig-wt-error{background:rgba(248,113,113,.16)}',
16824
+ /* ---- icon tooltip ---- */
16825
+ /* A body child, not a panel child: the bottom workbench declares
16826
+ `contain: layout style`, so a fixed element inside it would take the
16827
+ panel box as its containing block and land off-screen (invariant 13).
16828
+ Styled from the same menu tokens so it reads as part of the product. */
16829
+ '.dig-tip{position:fixed;z-index:9999;display:none;box-sizing:border-box;max-width:280px;padding:5px 8px;border-radius:var(--dsw-radius-sm,8px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1)));color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent,0 8px 22px rgba(0,0,0,.32));font-family:var(--dsw-font-family,system-ui,sans-serif);font-size:calc(11px + var(--dsh-content-font-delta-secondary,0px));font-weight:500;line-height:1.45;text-align:left;white-space:normal;overflow-wrap:anywhere;pointer-events:none}',
16830
+ '.dig-tip[data-show="true"]{display:block}',
16425
16831
  ].concat(LANE_TRACE_RULES).join('\n')
16426
16832
 
16427
16833
  /* ============================== plugin ============================== */
@@ -17168,50 +17574,48 @@ window.__ModuleLoader__.load({
17168
17574
  behind > 0 ? E('span', { className: 'dig-cs-badge dig-cs-behind', title: t('composer.behindHint') }, '↓' + String(behind)) : null,
17169
17575
  ahead > 0 ? E('span', { className: 'dig-cs-badge dig-cs-ahead', title: t('composer.aheadHint') }, '↑' + String(ahead)) : null,
17170
17576
  error !== '' ? E('span', { className: 'dig-cs-error', title: error }, '×') : null,
17171
- menu === 'repo' ? E('div', { className: 'dig-cs-menu', 'data-cordis-panel': '' },
17577
+ menu === 'repo' ? E('div', { className: 'dig-cs-menu dig-cs-picker', 'data-cordis-panel': '' },
17172
17578
  /* v0.19.2: one row per checkout — the repository's own name first
17173
17579
  (origin URL basename, falling back to the directory name), and the
17174
17580
  single-level directory name as a small sub line only when the two
17175
17581
  differ (two clones of one repository stay tellable apart). The
17176
- full path lives in the tooltip, not in the row. */
17177
- data.repos.map((row) => {
17178
- const dirName = baseNameOf(row.path)
17179
- const displayName = row.origin !== undefined && row.origin !== '' ? row.origin : dirName
17180
- return E('button', {
17181
- key: row.path,
17182
- type: 'button',
17183
- className: 'dig-cs-item',
17184
- 'data-current': row.path === repoRoot ? 'true' : 'false',
17185
- title: row.path,
17186
- onClick: () => pickRepoRoot(row.path),
17187
- },
17188
- E(Icon, { name: 'commit', size: 11 }),
17189
- E('span', { className: 'dig-cs-item-main' },
17190
- E('span', { className: 'dig-cs-item-name' }, displayName),
17191
- dirName !== displayName ? E('span', { className: 'dig-cs-item-sub' }, dirName) : null))
17582
+ full path lives in the tooltip, not in the row. v0.27 adds the
17583
+ search field so seven clones need no scrolling to find one. */
17584
+ E(SearchPicker, {
17585
+ key: 'composer-repo',
17586
+ mode: 'repo',
17587
+ t: t,
17588
+ repos: data.repos.map((row) => {
17589
+ const dirName = baseNameOf(row.path)
17590
+ const displayName = row.origin !== undefined && row.origin !== '' ? row.origin : dirName
17591
+ return {
17592
+ path: row.path,
17593
+ name: displayName,
17594
+ sub: dirName !== displayName ? dirName : undefined,
17595
+ }
17596
+ }),
17597
+ current: repoRoot,
17598
+ onPick: (path) => pickRepoRoot(path),
17599
+ onClose: () => setMenu(null),
17192
17600
  }))
17193
17601
  : null,
17194
17602
  menu === 'branch' ? (branchLoading === true || branchList === null
17195
17603
  ? E('div', { className: 'dig-cs-menu', 'data-cordis-panel': '' }, E('div', { className: 'dig-cs-item dig-cs-static' }, '…'))
17196
- : E('div', { className: 'dig-cs-menu', 'data-cordis-panel': '' },
17197
- branchList.local.map((entry) => E('button', {
17198
- key: entry.name,
17199
- type: 'button',
17200
- className: 'dig-cs-item',
17201
- 'data-current': entry.head === true ? 'true' : 'false',
17202
- disabled: entry.worktree !== null || checkingOut !== '',
17203
- title: entry.worktree !== null ? t('composer.inWorktree') : (checkingOut !== '' ? t('composer.busy') : entry.name),
17204
- onClick: () => { void checkoutBranch(entry.name) },
17205
- },
17206
- E(Icon, { name: 'branch', size: 11 }),
17207
- E('span', { className: 'dig-cs-item-name' }, entry.name),
17208
- entry.worktree !== null ? E('span', { className: 'dig-cs-item-note' }, t('composer.inWorktree')) : null,
17209
- checkingOut === entry.name ? E('span', { className: 'dig-cs-item-note' }, '…') : null,
17210
- entry.ahead > 0 || entry.behind > 0
17211
- ? E('span', { className: 'dig-cs-item-counts' },
17212
- entry.ahead > 0 ? E('span', { className: 'dig-cs-ahead' }, '↑' + String(entry.ahead)) : null,
17213
- entry.behind > 0 ? E('span', { className: 'dig-cs-behind' }, '↓' + String(entry.behind)) : null)
17214
- : null))))
17604
+ : E('div', { className: 'dig-cs-menu dig-cs-picker', 'data-cordis-panel': '' },
17605
+ E(SearchPicker, {
17606
+ key: 'composer-branch',
17607
+ mode: 'branch',
17608
+ t: t,
17609
+ branches: branchList.local,
17610
+ current: branchName === null ? '' : branchName,
17611
+ busyName: checkingOut,
17612
+ worktreeNote: t('composer.inWorktree'),
17613
+ onPick: (name, entry) => {
17614
+ if (entry.head === true) { setMenu(null); return }
17615
+ void checkoutBranch(name)
17616
+ },
17617
+ onClose: () => setMenu(null),
17618
+ })))
17215
17619
  : null,
17216
17620
  )
17217
17621
  }
@@ -17518,6 +17922,193 @@ window.__ModuleLoader__.load({
17518
17922
  }))))
17519
17923
  }
17520
17924
 
17925
+ /* ---- icon tooltips ----
17926
+ Every icon action already carries its name in `title` (the rail adds the
17927
+ disabled reason; the toolbar / mini / pill buttons carry the action
17928
+ verb). Native title bubbles are the wrong surface for this product: the
17929
+ bottom workbench is a dense horizontal strip, the bubble needs a full
17930
+ second of rest, it is drawn in the browser's own styling, and over a
17931
+ DISABLED rail button it never appears at all. Draw one themed card
17932
+ instead, shared by every seat (panel, composer pill, diff preview) and
17933
+ hung off <body> — see the `.dig-tip` note in CSS for why it can't live
17934
+ inside the panel. The card mirrors the anchor's own `title`, which is
17935
+ removed while the card is up so the native bubble never doubles it. */
17936
+ const TIP_ID = 'dsh-ide-git-tip'
17937
+ const TIP_DELAY_MS = 120
17938
+ const TIP_GAP = 6
17939
+ /* Only this plugin's own surfaces explain themselves. `.dig-root` is the
17940
+ panel (native and better-sidebar seats alike), `.dig-cs-root` the
17941
+ composer status pill, `.dig-preview-root` the external diff seat. */
17942
+ const TIP_ROOTS = '.dig-root, .dig-cs-root, .dig-preview-root'
17943
+
17944
+ function installHoverTips() {
17945
+ /* The SSR self-check drives apply() with a minimal document stub: it has
17946
+ no body, no id lookup and no event targets. Those environments get the
17947
+ no-op installer rather than a throw during plugin activation. */
17948
+ if (typeof document === 'undefined' || document.body == null
17949
+ || typeof document.createElement !== 'function'
17950
+ || typeof document.getElementById !== 'function'
17951
+ || typeof document.addEventListener !== 'function'
17952
+ || typeof window === 'undefined'
17953
+ || typeof window.addEventListener !== 'function') return () => {}
17954
+ /* HMR / re-apply: never stack two cards (or two listener sets on one). */
17955
+ const stale = document.getElementById(TIP_ID)
17956
+ if (stale !== null) stale.remove()
17957
+ const card = document.createElement('div')
17958
+ card.id = TIP_ID
17959
+ card.className = 'dig-tip'
17960
+ card.setAttribute('data-dsh-ide-git', '')
17961
+ card.setAttribute('role', 'tooltip')
17962
+ card.setAttribute('aria-hidden', 'true')
17963
+ document.body.appendChild(card)
17964
+
17965
+ /* `anchor` is the button the pointer is on (or about to rest on, while
17966
+ `timer` runs); `shown` is the one whose title the card has taken. */
17967
+ let anchor = null
17968
+ let shown = null
17969
+ let takenTitle = null
17970
+ let timer = null
17971
+
17972
+ const restore = () => {
17973
+ if (shown === null) return
17974
+ /* React may have rewritten the attribute while the card was up (toggle
17975
+ buttons swap their copy): only put the taken text back when nothing
17976
+ else has claimed the slot, so a fresh label is never clobbered. */
17977
+ try {
17978
+ if (shown.getAttribute('title') === null && takenTitle !== null) shown.setAttribute('title', takenTitle)
17979
+ } catch (error) { void error }
17980
+ shown = null
17981
+ takenTitle = null
17982
+ }
17983
+
17984
+ const hide = () => {
17985
+ if (timer !== null) { clearTimeout(timer); timer = null }
17986
+ anchor = null
17987
+ restore()
17988
+ card.removeAttribute('data-show')
17989
+ card.setAttribute('aria-hidden', 'true')
17990
+ card.textContent = ''
17991
+ }
17992
+
17993
+ /* Viewport coordinates on a body child: no panel containment to fight. */
17994
+ const place = () => {
17995
+ const rect = anchor.getBoundingClientRect()
17996
+ const width = card.offsetWidth
17997
+ const height = card.offsetHeight
17998
+ const viewportW = window.innerWidth || document.documentElement.clientWidth || 0
17999
+ const viewportH = window.innerHeight || document.documentElement.clientHeight || 0
18000
+ /* The vertical action rail is a 24px column on the panel's left edge:
18001
+ a card under an icon there would cover the icons below it, so this one
18002
+ sits to its right and follows the row. (The horizontal rail uses
18003
+ `.dig-rail-row`, so the `closest` query only matches the vertical
18004
+ seat — top rows keep the below-the-button placement.) */
18005
+ if (anchor.closest('.dig-rail') !== null) {
18006
+ let left = rect.left + rect.width + TIP_GAP
18007
+ if (left + width > viewportW - 4) left = rect.left - TIP_GAP - width
18008
+ if (left < 4) left = 4
18009
+ let top = rect.top + (rect.height - height) / 2
18010
+ if (top + height > viewportH - 4) top = viewportH - height - 4
18011
+ if (top < 4) top = 4
18012
+ card.style.left = Math.round(left) + 'px'
18013
+ card.style.top = Math.round(top) + 'px'
18014
+ return
18015
+ }
18016
+ /* Below the anchor; flip above when the bottom edge would clip. */
18017
+ let top = rect.bottom + TIP_GAP
18018
+ if (top + height > viewportH - 4) top = rect.top - height - TIP_GAP
18019
+ if (top < 4) top = 4
18020
+ let left = rect.left + (rect.width - width) / 2
18021
+ if (left + width > viewportW - 4) left = viewportW - width - 4
18022
+ if (left < 4) left = 4
18023
+ card.style.left = Math.round(left) + 'px'
18024
+ card.style.top = Math.round(top) + 'px'
18025
+ }
18026
+
18027
+ const reveal = () => {
18028
+ timer = null
18029
+ if (anchor === null) return
18030
+ const label = anchor.getAttribute('title')
18031
+ if (label === null || label === '') { hide(); return }
18032
+ takenTitle = label
18033
+ try { anchor.removeAttribute('title') } catch (error) { void error }
18034
+ shown = anchor
18035
+ card.textContent = label
18036
+ card.setAttribute('data-show', 'true')
18037
+ card.removeAttribute('aria-hidden')
18038
+ place()
18039
+ }
18040
+
18041
+ /* Whatever sits under the pointer, not whatever received the event: a
18042
+ disabled button still hit-tests (rail icons), and the SVG glyph inside
18043
+ the button is usually the element under the cursor. `closest` walks up
18044
+ to the button. The label is read here, but the anchor's own copy is
18045
+ already taken while its card is up — so the shown button answers from
18046
+ the saved text, otherwise it would stop counting as a candidate the
18047
+ moment the card opened. */
18048
+ const buttonAt = (x, y) => {
18049
+ if (typeof document.elementFromPoint !== 'function') return null
18050
+ let hit = null
18051
+ try { hit = document.elementFromPoint(x, y) } catch (error) { void error; return null }
18052
+ if (hit === null || hit === undefined || typeof hit.closest !== 'function') return null
18053
+ if (hit.closest(TIP_ROOTS) === null) return null
18054
+ return hit.closest('button, [role="button"]')
18055
+ }
18056
+
18057
+ const labelOf = (button) => {
18058
+ if (button === shown) return takenTitle
18059
+ const label = button.getAttribute('title')
18060
+ return label === null || label === '' ? null : label
18061
+ }
18062
+
18063
+ const onPointerMove = (event) => {
18064
+ if (event !== null && event !== undefined && event.pointerType === 'touch') return
18065
+ /* A drag is not a hover: reordering rail actions, dragging a divider or
18066
+ selecting rows all sweep across buttons with a pressed pointer, and
18067
+ no card should pop up under the drag. */
18068
+ if (event !== null && event !== undefined && typeof event.buttons === 'number' && event.buttons !== 0) {
18069
+ if (anchor !== null || timer !== null || shown !== null) hide()
18070
+ return
18071
+ }
18072
+ const button = buttonAt(event === null || event === undefined ? 0 : event.clientX, event === null || event === undefined ? 0 : event.clientY)
18073
+ if (button === null || labelOf(button) === null) {
18074
+ if (anchor !== null || timer !== null || shown !== null) hide()
18075
+ return
18076
+ }
18077
+ if (button === anchor) return
18078
+ /* Another button: the old card (and the title it took) goes now, the
18079
+ new one waits out the grace period — sweeping the rail must not
18080
+ flash a card per icon. */
18081
+ if (timer !== null) { clearTimeout(timer); timer = null }
18082
+ restore()
18083
+ card.removeAttribute('data-show')
18084
+ anchor = button
18085
+ timer = setTimeout(reveal, TIP_DELAY_MS)
18086
+ }
18087
+
18088
+ document.addEventListener('pointermove', onPointerMove, { passive: true })
18089
+ document.addEventListener('pointerdown', hide, true)
18090
+ document.addEventListener('pointerleave', hide)
18091
+ document.addEventListener('wheel', hide, { passive: true, capture: true })
18092
+ /* Scroll and resize move the anchor under a card that cannot follow it:
18093
+ the card would lie about where the button is. Drop it and let the next
18094
+ pointer move re-open it. */
18095
+ window.addEventListener('scroll', hide, true)
18096
+ window.addEventListener('resize', hide)
18097
+ window.addEventListener('blur', hide)
18098
+
18099
+ return () => {
18100
+ hide()
18101
+ document.removeEventListener('pointermove', onPointerMove)
18102
+ document.removeEventListener('pointerdown', hide, true)
18103
+ document.removeEventListener('pointerleave', hide)
18104
+ document.removeEventListener('wheel', hide, { capture: true })
18105
+ window.removeEventListener('scroll', hide, true)
18106
+ window.removeEventListener('resize', hide)
18107
+ window.removeEventListener('blur', hide)
18108
+ card.remove()
18109
+ }
18110
+ }
18111
+
17521
18112
  function apply(ctx) {
17522
18113
  const style = document.createElement('style')
17523
18114
  style.setAttribute('data-dsh-ide-git', '')
@@ -17525,6 +18116,11 @@ window.__ModuleLoader__.load({
17525
18116
  document.head.appendChild(style)
17526
18117
  ctx.effect(() => () => { style.remove() }, 'dsh-ide-git: panel styles')
17527
18118
 
18119
+ /* Icon affordances explain themselves on hover wherever this plugin
18120
+ draws one — the panel in any seat, the composer pill, the diff
18121
+ preview. One document-level card covers them all. */
18122
+ ctx.effect(() => installHoverTips(), 'dsh-ide-git: icon tooltips')
18123
+
17528
18124
  const t = translatorOf(ctx)
17529
18125
 
17530
18126
  /* DSH's own locale registry: bind this plugin's namespace so host-side