dsh-git-idea 0.1.0 → 0.2.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.
Files changed (4) hide show
  1. package/README.md +164 -62
  2. package/client/client.js +727 -120
  3. package/lib/index.js +634 -981
  4. package/package.json +3 -4
package/client/client.js CHANGED
@@ -48,6 +48,24 @@ window.__ModuleLoader__.load({
48
48
  },
49
49
  }
50
50
 
51
+ /* The dynamic bridge's browser realm also handed the fragments a `styles`
52
+ symbol, and 46-css.js is written against it: one `insert(text)` that
53
+ appends a <style> element and returns the remover that `ctx.effect`
54
+ disposes with. The real client realm has no such symbol —
55
+ `dsh-client-modules` instead claims whatever <style> a factory injected
56
+ and tags it for HMR — so the prelude supplies the same one over the same
57
+ DOM. */
58
+ const styles = {
59
+ insert: function (text) {
60
+ const element = document.createElement('style')
61
+ element.textContent = text
62
+ document.head.appendChild(element)
63
+ return function () {
64
+ if (element.parentNode !== null) element.parentNode.removeChild(element)
65
+ }
66
+ },
67
+ }
68
+
51
69
  const plugin = (function () {
52
70
  return {
53
71
  apply(ctx) {
@@ -292,6 +310,124 @@ return {
292
310
  repoEpochs[repoEpochKey(repo, sessionId)] = repoEpoch(repo, sessionId) + 1
293
311
  }
294
312
 
313
+ /* ── 工作区读数:整个插件只有一份 ──
314
+
315
+ `git status` 全树在这台机器上就是几秒(读者那个 /mnt/d 工作区、5093 个文件:
316
+ 8.1s 冷,`-uno` 也要 5.3s),而这条 RPC 通道一次只跑一个处理函数 —— 一次全树读
317
+ 在飞的时候,屏幕上每一次点击、另一块屏幕的每一次读,全都排在它后面。真机上量到
318
+ 的是:终端里提交一次(引用变了),队列 18–21s 才排空,其中 13s 是两次全树读;
319
+ 面板关着时那一次也要 10.6s,而屏幕上看到的就是「点了没反应」。
320
+
321
+ 所以「工作区现在什么样」全局只留一份:谁读到的都写在这里,面板和 chip 都从这一
322
+ 份渲染 —— 两块屏幕于是不可能各说各话,也不会各量一遍(同一个问题在 Host 那边本来
323
+ 也只会起一个进程)。一条记录里四样东西各有各的用处:
324
+
325
+ status 最后一次合并好的工作区快照(和面板「变更」页上那份是同一个东西)
326
+ at 最后一次**任何**读数的时刻(全树,或只问屏上那几条路径)
327
+ fullAt 最后一次**全树**读数的时刻:只有它证明这份快照是完整的
328
+ costMs 那次全树读占住通道多久;下一次该隔多久由它决定 */
329
+ const treeReads = {}
330
+ let treeVersion = 0
331
+ const treeSignal = createSignal(function () { return treeVersion })
332
+ const useTreeVersion = treeSignal.use
333
+
334
+ function treeRecord(repo) {
335
+ const found = treeReads[repo]
336
+ return found === undefined ? null : found
337
+ }
338
+
339
+ function treeCount(repo) {
340
+ const record = treeRecord(repo)
341
+ return record === null ? 0 : record.count
342
+ }
343
+
344
+ function publishTreeRead(repo, status, full, costMs) {
345
+ if (repo == null || repo.length === 0 || status == null || status.ok !== true) return
346
+ const previous = treeRecord(repo)
347
+ const now = Date.now()
348
+ treeReads[repo] = {
349
+ status: status,
350
+ count: mergeChanges(status).length,
351
+ at: now,
352
+ fullAt: full === true ? now : (previous === null ? 0 : previous.fullAt),
353
+ costMs: typeof costMs === 'number' && isFinite(costMs) && costMs >= 0
354
+ ? costMs
355
+ : (previous === null ? 0 : previous.costMs),
356
+ }
357
+ treeVersion += 1
358
+ treeSignal.notify()
359
+ }
360
+
361
+ /* 一次全树读之后,隔多久才值得再来一次:它占住整条通道 costMs 毫秒,那就让空档至少
362
+ 是它的 8 倍。量得快的仓库照旧 30 秒一次;慢挂载上不会每 30 秒冻 8 秒(上限 5 分钟)。 */
363
+ const FULL_READ_FLOOR_MS = 30000
364
+ const FULL_READ_CEIL_MS = 300000
365
+ const FULL_READ_FACTOR = 8
366
+ function fullReadGapMs(costMs) {
367
+ const cost = typeof costMs === 'number' && isFinite(costMs) && costMs > 0 ? costMs : 0
368
+ const wanted = Math.round(cost * FULL_READ_FACTOR)
369
+ if (wanted <= FULL_READ_FLOOR_MS) return FULL_READ_FLOOR_MS
370
+ return wanted > FULL_READ_CEIL_MS ? FULL_READ_CEIL_MS : wanted
371
+ }
372
+
373
+ /* 这份快照还完整吗:true 表示该有人再整棵树量一次。 */
374
+ function treeReadDue(repo) {
375
+ const record = treeRecord(repo)
376
+ if (record === null || record.fullAt === 0) return true
377
+ return Date.now() - record.fullAt >= fullReadGapMs(record.costMs)
378
+ }
379
+
380
+ /* 有一次**会改变那个数字**的读正在飞(这个仓库)—— 全树读,或者只问几条路径的那种
381
+ 都算。它落地之前,屏幕上那个数字不能当成「刚刚核对过」:chip 这时说的是
382
+ 「正在核对」,而不是继续报一个它还没验证过的数字。 */
383
+ const treeReadings = {}
384
+ function treeCountReadStart(repo) {
385
+ if (repo == null || repo.length === 0) return function () {}
386
+ treeReadings[repo] = (treeReadings[repo] === undefined ? 0 : treeReadings[repo]) + 1
387
+ treeVersion += 1
388
+ treeSignal.notify()
389
+ let done = false
390
+ return function () {
391
+ if (done) return
392
+ done = true
393
+ if (treeReadings[repo] > 0) treeReadings[repo] -= 1
394
+ treeVersion += 1
395
+ treeSignal.notify()
396
+ }
397
+ }
398
+
399
+ function treeCountReading(repo) {
400
+ return repo != null && repo.length > 0 && treeReadings[repo] !== undefined && treeReadings[repo] > 0
401
+ }
402
+
403
+ /* 上一次全树读里那些脏路径。一次 bump 之后拿它问一次(0.2s)就能把屏幕上那份快照
404
+ 更新掉 —— 提交之后那几个文件就是这样立刻消失的,而不是等一次新的全树读。 */
405
+ function treeReadPaths(repo) {
406
+ const record = treeRecord(repo)
407
+ if (record === null || record.status == null) return []
408
+ return pathsOfInterest(record.status, repo)
409
+ }
410
+
411
+ /* 一条路径读比这个还贵,就说明父目录那一层把大树扫进去了:这个仓库从此只问那几条
412
+ 路径本身。读者那个仓库(Windows 挂载)上,12 条含父目录 6138ms、9 条不含 295ms。 */
413
+ const PATHS_READ_MAX_MS = 1500
414
+
415
+ /* 一次「只问屏上那几条路径」的读花了多久。父目录那一层(为了「改动文件旁边新出现的
416
+ 文件」)在某些仓库上会把旁边整棵大树扫一遍 —— 量到一次超时就收窄,只问那几条路径
417
+ 本身(见 52-detail.js 里的数)。 */
418
+ const treeNarrowed = {}
419
+ function treeWide(repo) {
420
+ return treeNarrowed[repo] !== true
421
+ }
422
+
423
+ function pathsReadSpent(repo, ms) {
424
+ if (repo == null || repo.length === 0 || treeNarrowed[repo] === true) return
425
+ if (!(typeof ms === 'number' && isFinite(ms) && ms >= PATHS_READ_MAX_MS)) return
426
+ treeNarrowed[repo] = true
427
+ treeVersion += 1
428
+ treeSignal.notify()
429
+ }
430
+
295
431
  /* Which switcher is showing, if either: the dropdown hanging off the panel
296
432
  header's branch chip ('panel'), or the card the composer chip opens on
297
433
  hover ('hover'). One at a time, never both with the panel. */
@@ -371,6 +507,24 @@ return {
371
507
  const repoAppliedSignal = createSignal(function () { return repoApplied })
372
508
  const useRepoApplied = repoAppliedSignal.use
373
509
 
510
+ /* The same answer for the one surface that has no session of its own: the
511
+ settings page is global, so it cannot name a session — and it must not name
512
+ a *path* either, because the Host is the one that resolves a session's
513
+ workspace (see `repoFrom` in the Host half). So what is remembered here is
514
+ only the id, and the settings page hands that back to the Host: the same
515
+ resolution, the same sandbox policy, one source of truth. */
516
+ let lastSessionId = ''
517
+ const lastSessionSignal = createSignal(function () { return lastSessionId })
518
+ const useLastSession = lastSessionSignal.use
519
+
520
+ function rememberSession(sessionId) {
521
+ if (sessionId === undefined || sessionId === null) return
522
+ const one = String(sessionId)
523
+ if (one.length === 0 || one === lastSessionId) return
524
+ lastSessionId = one
525
+ lastSessionSignal.notify()
526
+ }
527
+
374
528
  function sessionRepo(sessionId) {
375
529
  if (sessionId === undefined || sessionId === null) return ''
376
530
  const value = sharedRepos[sessionId]
@@ -492,7 +646,9 @@ return {
492
646
  watchEnabled: true,
493
647
  watchChip: true,
494
648
  watchFastSec: 3,
495
- watchSlowSec: 15,
649
+ /* 面板关着时 chip 那条慢 lane。它现在只发一次便宜签名(真机上 0.12s),所以可以
650
+ 问得比过去勤:15s 的话,终端里提交完要过十几秒 chip 才改口。 */
651
+ watchSlowSec: 5,
496
652
  hoverSwitch: true,
497
653
  /* Which of the two changes views the panel opens in: the directory tree
498
654
  (IDEA's default) or the flat list of paths. A preference of this browser
@@ -551,7 +707,13 @@ return {
551
707
  half, so they travel across browsers and machines. As an ordinary plugin
552
708
  this is exactly what its config section would hold. */
553
709
 
554
- const PLUGIN_CONFIG_DEFAULTS = { initBranch: 'main', cherryPickRecord: false }
710
+ const PLUGIN_CONFIG_DEFAULTS = {
711
+ initBranch: 'main', cherryPickRecord: false,
712
+ /* 空 = 用部署 PATH 里的 git。 */
713
+ gitPath: '',
714
+ /* 这三条是插件自己给 git 的实参,不是 git 设置的副本。 */
715
+ fetchPrune: true, pullRebase: false, pushSetUpstream: false,
716
+ }
555
717
  let pluginConfig = Object.assign({}, PLUGIN_CONFIG_DEFAULTS)
556
718
  let pluginConfigPath = ''
557
719
  let pluginConfigLoaded = false
@@ -559,11 +721,23 @@ return {
559
721
  const pluginConfigSignal = createSignal(function () { return pluginConfig })
560
722
  const usePluginConfig = pluginConfigSignal.use
561
723
 
724
+ /* 每次**Host 确认过**的配置计数。屏幕上那份草稿是即时的(`savePluginConfig` 当场
725
+ 改内存,400ms 后才落盘),所以「问 Host 一件事」不能挂在草稿上:挂上去的话每敲
726
+ 一个键都是一次询问,而且问到的是 Host 手里那份还没更新的配置 —— 真机上量到的
727
+ 是界面永远慢一步。这个计数只在答复带着配置回来时才动(初次读取、保存成功)。 */
728
+ let pluginConfigCommitted = 0
729
+ const pluginConfigCommittedSignal = createSignal(function () { return pluginConfigCommitted })
730
+ const usePluginConfigCommitted = pluginConfigCommittedSignal.use
731
+
562
732
  function normalizePluginConfig(raw) {
563
733
  const out = Object.assign({}, PLUGIN_CONFIG_DEFAULTS)
564
734
  if (raw == null || typeof raw !== 'object') return out
565
735
  if (typeof raw.initBranch === 'string') out.initBranch = raw.initBranch.trim().slice(0, 120)
566
736
  out.cherryPickRecord = raw.cherryPickRecord === true
737
+ if (typeof raw.gitPath === 'string') out.gitPath = raw.gitPath.trim().slice(0, 400)
738
+ out.fetchPrune = raw.fetchPrune !== false
739
+ out.pullRebase = raw.pullRebase === true
740
+ out.pushSetUpstream = raw.pushSetUpstream === true
567
741
  return out
568
742
  }
569
743
 
@@ -573,7 +747,9 @@ return {
573
747
  if (data.config !== undefined) pluginConfig = normalizePluginConfig(data.config)
574
748
  pluginConfigError = ''
575
749
  }
750
+ pluginConfigCommitted += 1
576
751
  pluginConfigSignal.notify()
752
+ pluginConfigCommittedSignal.notify()
577
753
  }
578
754
 
579
755
  function loadPluginConfig() {
@@ -593,9 +769,15 @@ return {
593
769
  let configSaveTimer = null
594
770
 
595
771
  function writePluginConfig() {
596
- callHost('git/config-save', { config: pluginConfig }).then(function (result) {
772
+ const request = { config: pluginConfig }
773
+ /* 写在部署的配置目录里,也就是任何工作区之外:带上这个页面在哪个会话里,Host
774
+ 才能用这个会话的沙箱策略去写(没有会话时写不出去,而那种失败必须说得出来)。 */
775
+ if (lastSessionId.length > 0) request.sessionId = lastSessionId
776
+ callHost('git/config-save', request).then(function (result) {
597
777
  if (result == null || result.ok !== true) {
598
- pluginConfigError = text(result != null ? result.error : '') || '保存失败'
778
+ /* git 那套「这台机器 / 这个沙箱不让我做这件事」的说法是同一份(见
779
+ commandDetail):这里也走它,免得写不进去时屏幕上什么都没有。 */
780
+ pluginConfigError = commandDetail(result) || text(result != null ? result.error : '') || '保存失败'
599
781
  pluginConfigSignal.notify()
600
782
  return
601
783
  }
@@ -1430,12 +1612,19 @@ textarea.dsh-git-input{resize:vertical}
1430
1612
  const win = useVirtualWindow('log', count, ROW_H)
1431
1613
 
1432
1614
  if (commits === null) {
1433
- const reason = graph != null && graph.error === 'not-a-repository'
1434
- ? ('不是 git 仓库:' + text(graph.repo))
1435
- : '无法读取提交历史'
1615
+ const reason = graph != null && graph.noGit === true
1616
+ ? ('找不到 git' + text(graph.repo))
1617
+ : graph != null && graph.error === 'not-a-repository'
1618
+ ? ('不是 git 仓库:' + text(graph.repo))
1619
+ : '无法读取提交历史'
1436
1620
  return h('div', { className: 'dsh-git-pane dsh-git-error' }, reason)
1437
1621
  }
1438
- if (count === 0) return h('div', { className: 'dsh-git-pane dsh-git-dim' }, '没有匹配的提交')
1622
+ if (count === 0) {
1623
+ /* 空历史有两种:这个仓库还没有第一个提交(刚 init),和筛选没匹配到。
1624
+ 前者不是「没有匹配」,说成那样会让人去清筛选。 */
1625
+ return h('div', { className: 'dsh-git-pane dsh-git-dim' },
1626
+ graph != null && graph.unborn === true ? '这个仓库还没有提交' : '没有匹配的提交')
1627
+ }
1439
1628
 
1440
1629
  const laneNum = Math.max(1, graph.lanes)
1441
1630
  const graphWidth = laneNum * LANE_W + 6
@@ -1830,6 +2019,10 @@ textarea.dsh-git-input{resize:vertical}
1830
2019
  ok: true, repo: partial.repo, branch: partial.branch, detached: partial.detached,
1831
2020
  upstream: partial.upstream, ahead: partial.ahead, behind: partial.behind,
1832
2021
  sequencer: partial.sequencer,
2022
+ /* 和 branch、upstream 一样来自这一次读:身份缺不缺是机器上的事实,不随路径
2023
+ 部分读而改变,但也不能因为一次合并就把它丢掉(丢掉的后果是提交区那个提示
2024
+ 闪一下又没了)。 */
2025
+ needsIdentity: partial.needsIdentity === true,
1833
2026
  staged: keep(current.staged).concat(list(partial.staged)),
1834
2027
  unstaged: keep(current.unstaged).concat(list(partial.unstaged)),
1835
2028
  untracked: keep(current.untracked).concat(list(partial.untracked)),
@@ -1845,8 +2038,22 @@ textarea.dsh-git-input{resize:vertical}
1845
2038
  the whole tree again, which is the cost this is here to avoid. */
1846
2039
  const PATHS_MAX = 200
1847
2040
 
1848
- function pathsOfInterest(status) {
2041
+ /* ── 父目录那一层有多贵 ──
2042
+
2043
+ 「改动文件旁边新出现的文件」确实要问它所在的目录才看得见,可是**目录条目
2044
+ (`.../`,git 把没跟踪的目录折叠成一条)本身就是自己的子树**:再带上它的上一层
2045
+ 就是把旁边整棵大树扫一遍。读者那个仓库上量到的是:
2046
+
2047
+ 7 条原始路径(其中 3 条是折叠目录) 280ms
2048
+ 12 条(每条再带上父目录) 6138ms ← `holox-modules` 一条吃掉了全部
2049
+ 9 条(目录条目不带父目录) 295ms
2050
+
2051
+ 所以目录条目不带上父目录;文件的父目录留着(文件旁边新出现的文件还是由它看见)。
2052
+ 另外量到一次路径读本身就很贵(`PATHS_READ_MAX_MS`,见 10-state.js)时,这个仓库
2053
+ 整个收窄成只问那几条路径本身 —— 那种仓库上新文件就交给整棵树的时钟。 */
2054
+ function pathsOfInterest(status, repo) {
1849
2055
  if (status == null || status.ok !== true) return []
2056
+ const wide = repo === undefined || repo === null || repo.length === 0 ? true : treeWide(repo) === true
1850
2057
  const seen = {}
1851
2058
  const out = []
1852
2059
  const add = function (path) {
@@ -1860,8 +2067,10 @@ textarea.dsh-git-input{resize:vertical}
1860
2067
  for (let k = 0; k < entries.length; k += 1) {
1861
2068
  const path = entryPath(entries[k])
1862
2069
  if (path.length === 0) continue
1863
- const bare = path.slice(-1) === '/' ? path.slice(0, -1) : path
2070
+ const collapsed = path.slice(-1) === '/'
2071
+ const bare = collapsed ? path.slice(0, -1) : path
1864
2072
  add(bare)
2073
+ if (collapsed === true || wide !== true) continue
1865
2074
  const cut = bare.lastIndexOf('/')
1866
2075
  if (cut > 0) add(bare.slice(0, cut))
1867
2076
  }
@@ -1883,14 +2092,17 @@ textarea.dsh-git-input{resize:vertical}
1883
2092
  return byPath[path]
1884
2093
  }
1885
2094
  const list = function (value) { return Array.isArray(value) ? value : [] }
2095
+ /* 四条列表都走 `entryPath`:git 那边这三种形状都可能出现(对象最常,裸字符串也
2096
+ 合法),读 `entry.path` 会把裸字符串那一条**整条丢掉** —— 列表里少一行,而
2097
+ 「有几个改动」那个数字(mergeChanges 的长度)也跟着少一个。 */
1886
2098
  const staged = list(work.staged)
1887
- for (let i = 0; i < staged.length; i += 1) put(text(staged[i].path), { staged: true, indexCode: text(staged[i].code) })
2099
+ for (let i = 0; i < staged.length; i += 1) put(entryPath(staged[i]), { staged: true, indexCode: text(staged[i].code) })
1888
2100
  const unstaged = list(work.unstaged)
1889
- for (let i = 0; i < unstaged.length; i += 1) put(text(unstaged[i].path), { workCode: text(unstaged[i].code) })
2101
+ for (let i = 0; i < unstaged.length; i += 1) put(entryPath(unstaged[i]), { workCode: text(unstaged[i].code) })
1890
2102
  const untracked = list(work.untracked)
1891
2103
  for (let i = 0; i < untracked.length; i += 1) put(entryPath(untracked[i]), { workCode: '??', untracked: true })
1892
2104
  const unmerged = list(work.unmerged)
1893
- for (let i = 0; i < unmerged.length; i += 1) put(text(unmerged[i].path), { workCode: text(unmerged[i].code), conflict: true })
2105
+ for (let i = 0; i < unmerged.length; i += 1) put(entryPath(unmerged[i]), { workCode: text(unmerged[i].code), conflict: true })
1894
2106
  const out = []
1895
2107
  for (let i = 0; i < order.length; i += 1) {
1896
2108
  const entry = byPath[order[i]]
@@ -1957,9 +2169,11 @@ textarea.dsh-git-input{resize:vertical}
1957
2169
  const work = props.work
1958
2170
  if (work == null) return h('div', { className: 'dsh-git-pane dsh-git-dim' }, '正在读取工作区…')
1959
2171
  if (work.ok !== true) {
1960
- const reason = work.error === 'not-a-repository'
1961
- ? ('不是 git 仓库:' + text(work.repo))
1962
- : '无法读取工作区状态'
2172
+ const reason = work.noGit === true
2173
+ ? ('找不到 git' + text(work.repo))
2174
+ : work.error === 'not-a-repository'
2175
+ ? ('不是 git 仓库:' + text(work.repo))
2176
+ : '无法读取工作区状态'
1963
2177
  return h('div', { className: 'dsh-git-pane dsh-git-error' }, reason)
1964
2178
  }
1965
2179
 
@@ -2262,6 +2476,13 @@ textarea.dsh-git-input{resize:vertical}
2262
2476
 
2263
2477
  const side = h('div', { className: 'dsh-git-commitpane' },
2264
2478
  h('div', { className: 'dsh-git-group-title' }, '提交信息'),
2479
+ /* 先说出来,而不是等读者写完提交信息再被 git 拒一次。两条路都留着:设置页里
2480
+ 能填的那个地方(面板里点得到),和在终端里跑的两条命令(面板不一定开着)。 */
2481
+ props.work.needsIdentity === true
2482
+ ? h('div', { key: 'ident', className: 'dsh-git-hint dsh-git-warn' },
2483
+ '这台机器还没配 git 提交身份,提交会被 git 拒绝。设置页「dsh-git-idea配置 → 提交身份」里能填,'
2484
+ + '或在终端里跑:git config --global user.name "你的名字"、git config --global user.email "你的邮箱"。')
2485
+ : null,
2265
2486
  clearable('msg', h('textarea', {
2266
2487
  className: 'dsh-git-input',
2267
2488
  rows: 6,
@@ -2569,6 +2790,16 @@ textarea.dsh-git-input{resize:vertical}
2569
2790
  /* 路径已经确定,只是这里没有仓库:没有要解释的规则,也没有要填的东西。
2570
2791
  不劝人换目录,也不让人把已经显示在上面的路径再抄一遍。 */
2571
2792
  'not-a-repo': { title: '这个目录不是 Git 仓库', hint: '', editable: false },
2793
+ /* 目录是对的,机器上少了东西:这个页面不能改路径,也不能初始化 —— 两件事
2794
+ 都救不了这个状态,而 `git init` 只会再失败一次。留一个「打开这个目录」
2795
+ 当作装好 git 之后的重试。 */
2796
+ 'no-git': {
2797
+ title: '这台机器上找不到 git',
2798
+ hint: '上面这个目录本身是仓库,但面板读它、改它都要调用 git。'
2799
+ + '装上 git,或让它出现在 dsh 进程的 PATH 里,再点一次「打开这个目录」。',
2800
+ editable: false,
2801
+ init: false,
2802
+ },
2572
2803
  'git-error': { title: 'git 命令执行失败', hint: '目录存在,但 git 没能读取它。下方是 git 的原话。' },
2573
2804
  }
2574
2805
 
@@ -2588,6 +2819,9 @@ textarea.dsh-git-input{resize:vertical}
2588
2819
  /* 只有「路径还没定」或「这个路径有问题」时才需要人改路径。
2589
2820
  路径本身没错、只是这里没有仓库时,上面那行已经说清是哪个目录了。 */
2590
2821
  const editable = info.editable !== false
2822
+ /* 初始化是「这里还没有仓库」的出路。没有 git 的时候它不是出路,是同一个
2823
+ 失败再演一次。 */
2824
+ const canInit = info.init !== false
2591
2825
  const target = draft.trim()
2592
2826
 
2593
2827
  const open = function () {
@@ -2638,11 +2872,11 @@ textarea.dsh-git-input{resize:vertical}
2638
2872
  disabled: busy || target.length === 0,
2639
2873
  onClick: doInit,
2640
2874
  }, busy ? '正在初始化…' : '确认初始化(会写入 .git)')
2641
- : h('button', {
2875
+ : (canInit ? h('button', {
2642
2876
  type: 'button', className: 'dsh-git-btn',
2643
2877
  disabled: busy || target.length === 0,
2644
2878
  onClick: function () { setArmed(true); setProblem(null) },
2645
- }, '在此初始化仓库'),
2879
+ }, '在此初始化仓库') : null),
2646
2880
  armed ? h('button', {
2647
2881
  type: 'button', className: 'dsh-git-btn',
2648
2882
  disabled: busy,
@@ -2659,8 +2893,35 @@ textarea.dsh-git-input{resize:vertical}
2659
2893
  something in it rather than picking one and showing nothing. */
2660
2894
  function commandDetail(result) {
2661
2895
  if (result == null) return ''
2896
+ /* 和下面沙箱那条同一类:失败的原因不在仓库里,而在机器上。这次 git 一个字
2897
+ 都没说 —— 它根本没被启动 —— 所以这里给整句话,不留 bash 的原话:原话是
2898
+ `bash: git: command not found`,而读者已经从上面那行知道这件事了。 */
2899
+ if (result.noGit === true) {
2900
+ return '这台机器上找不到 git:面板读它、改它都要调用 git。'
2901
+ + '装上 git,或让它出现在 dsh 进程的 PATH 里,再试一次。'
2902
+ }
2662
2903
  const err = text(result.stderr).replace(/\s+$/, '')
2663
2904
  const detail = err.length > 0 ? err.slice(0, 400) : text(result.stdout).replace(/\s+$/, '').slice(0, 400)
2905
+ /* git 在这件事上说八行,其中七行是建议("Run git config --global ..."),最后
2906
+ 一行才是拒绝本身。这里说的是同一件事,但先说面板里能点的那个地方(设置页的
2907
+ 提交身份),再给能照抄的命令 —— 两条路都留着,因为面板并不总是开着的。 */
2908
+ if (result.needsIdentity === true) {
2909
+ const lines = err.length > 0 ? err.split('\n') : []
2910
+ let last = ''
2911
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
2912
+ if (lines[i].trim().length > 0) { last = lines[i].trim(); break }
2913
+ }
2914
+ const why = 'git 不知道这次提交该署谁的名字,所以把它拒了 —— 作者身份写在 git 的配置里,'
2915
+ + '不在这个仓库里。设置页「dsh-git-idea配置 → 提交身份」里可以填,'
2916
+ + '或者在终端里跑一遍:\n'
2917
+ + ' git config --global user.name "你的名字"\n'
2918
+ + ' git config --global user.email "你的邮箱"\n'
2919
+ + '不加 --global 只对这个仓库生效。'
2920
+ /* git 的原话照旧留在下面一行:身份缺失是这次提交过不去的一道坎,但不一定是
2921
+ 唯一一道 —— 一个失败的钩子、一次没解决的冲突各自另有话说,把那句话丢掉就是
2922
+ 同一类误诊("这台机器上没有 git" 曾经也这样盖掉过真正的答案)。 */
2923
+ return last.length > 0 ? why + '\n' + last : why
2924
+ }
2664
2925
  /* git says "Unable to create ... .git/index.lock: Permission denied", which
2665
2926
  reads as a broken repository. It is the file sandbox refusing the write,
2666
2927
  and the reader can act on that (widen the session's file policy, or move
@@ -3414,8 +3675,14 @@ textarea.dsh-git-input{resize:vertical}
3414
3675
  h('span', { key: 'n', className: 'dsh-git-bs-count' }, String(group.rows.length))))
3415
3676
  if (shut) continue
3416
3677
  if (group.rows.length === 0) {
3417
- items.push(h('div', { key: 'g:' + group.id + ':none', className: 'dsh-git-bs-empty' },
3418
- needle.length > 0 ? '没有匹配的分支' : '这个仓库还没有本地分支'))
3678
+ /* 一个本地分支都没有有两种:真的没有,和「当前这个分支还没有第一个提交」
3679
+ —— 后者嘴里得说出它叫什么,不然 chip 上写着 main,卡片却说没有分支。 */
3680
+ const empty = needle.length > 0
3681
+ ? '没有匹配的分支'
3682
+ : (data != null && data.unborn === true && text(data.current).length > 0
3683
+ ? '当前在 ' + text(data.current) + ',还没有第一个提交'
3684
+ : '这个仓库还没有本地分支')
3685
+ items.push(h('div', { key: 'g:' + group.id + ':none', className: 'dsh-git-bs-empty' }, empty))
3419
3686
  continue
3420
3687
  }
3421
3688
  for (let r = 0; r < group.rows.length; r += 1) {
@@ -3450,7 +3717,10 @@ textarea.dsh-git-input{resize:vertical}
3450
3717
  onClick: function () { setStash(true); choose(pending, true) },
3451
3718
  }, '先暂存本地改动,再切到 ' + pending))
3452
3719
  }
3453
- if (props.dirty > 0) {
3720
+ /* 还没有第一个提交的仓库里 `git stash` 必然失败("You do not have the initial
3721
+ commit yet"),所以这一个勾选框不能出现 —— 「切完自动恢复」在这里是一句
3722
+ 兑现不了的承诺。改动本身不会丢:切分支时 git 会自己拒绝或带过去。 */
3723
+ if (props.dirty > 0 && (data == null || data.unborn !== true)) {
3454
3724
  foot.push(h('label', {
3455
3725
  key: 'stash', className: 'dsh-git-bs-check',
3456
3726
  title: '把本地改动 stash 起来,切过去之后再自动 pop 回来',
@@ -3633,20 +3903,25 @@ textarea.dsh-git-input{resize:vertical}
3633
3903
  the 36 paths the changes tree was showing. They are the same answer for
3634
3904
  everything that is on screen.
3635
3905
 
3636
- So the whole tree is read on its own clock (opening the panel, the
3637
- refresh button, a repository-level operation, and once every
3638
- FULL_STATUS_MS), and everything the reader does between those — a tick,
3639
- an edit to a file that is already listed, a stage — is confirmed by a
3640
- read of the paths involved. A file that was clean and is now modified is
3641
- the one thing a pathspec read cannot see; the whole-tree read is what
3642
- catches it, which is why it still happens on a clock.
3643
-
3644
- The latest snapshot and the last whole-tree read live in one object
3645
- rather than in the state alone: an effect keeps the render it was created
3646
- in, so a callback registered once would otherwise read a stale
3647
- `status` for as long as its dependencies do not move. */
3648
- const [panelBox] = React.useState(function () { return { status: null, fullAt: 0, scope: '', mutations: Promise.resolve() } })
3906
+ So the whole tree is read on its own clock (opening a repository the
3907
+ plugin has not read yet, the refresh button, and once every fullReadGapMs
3908
+ while the changes tab is on screen), and everything the reader does
3909
+ between those — a tick, an edit to a file that is already listed, a stage,
3910
+ a commit — is confirmed by a read of the paths involved. A file that was
3911
+ clean and is now modified is the one thing a pathspec read cannot see; the
3912
+ whole-tree read is what catches it, which is why it still happens on a
3913
+ clock.
3914
+
3915
+ The snapshot, the cost of the last whole-tree read and whether the next
3916
+ read has to be a whole one live in one object rather than in the state
3917
+ alone: an effect keeps the render it was created in, so a callback
3918
+ registered once would otherwise read a stale `status` for as long as its
3919
+ dependencies do not move. */
3920
+ const [panelBox] = React.useState(function () { return { status: null, needFull: false, repo: '', costMs: 0, lastFull: false, mutations: Promise.resolve() } })
3649
3921
  panelBox.status = status
3922
+ /* 一次全树读占住整条通道多久 —— 下一次该隔多久再量一遍由它决定(fullReadGapMs)。
3923
+ 进 state 的原因只有一个:间隔变了要把那个时钟重新起一遍。 */
3924
+ const [treeCost, setTreeCost] = React.useState(0)
3650
3925
 
3651
3926
  /* work is the only truth about whether this path is a usable repository.
3652
3927
  Everything that reads refs, history or the index is gated on it, so a
@@ -3655,6 +3930,13 @@ textarea.dsh-git-input{resize:vertical}
3655
3930
  const repoOk = work != null && work.ok === true
3656
3931
  const needsSetup = work != null && work.ok !== true
3657
3932
 
3933
+ /* 读数按答复里的仓库记,不是按这次请求写的那个:请求里常常只有会话 id(Host 才知道
3934
+ 这个会话的工作区在哪)。收窄与否也是按这个路径记的。 */
3935
+ const treeKey = function (status, fallback) {
3936
+ const resolved = status != null ? text(status.repo) : ''
3937
+ return resolved.length > 0 ? resolved : fallback
3938
+ }
3939
+
3658
3940
  const base = function (repo) {
3659
3941
  const request = { sessionId: sessionId }
3660
3942
  if (repo.length > 0) request.repo = repo
@@ -3679,47 +3961,70 @@ textarea.dsh-git-input{resize:vertical}
3679
3961
  const full = paths == null || paths.length === 0
3680
3962
  const work = Object.assign({}, request)
3681
3963
  if (!full) work.paths = paths
3964
+ const started = Date.now()
3965
+ /* 全树那一次在飞的时候,全局那份读数就是「还没核对过」:chip 这时说的是
3966
+ 「正在核对」,而不是继续报一个它没验证过的数字。 */
3967
+ /* 读数按**答复里的仓库**记,不是按这次请求写的那个:请求里常常只有会话 id
3968
+ (Host 才知道这个会话的工作区在哪),而读数要能被 chip 按路径查到。 */
3969
+ const resolved = text(data.repo).length > 0 ? text(data.repo) : asked
3970
+ const finished = treeCountReadStart(resolved)
3682
3971
  callHost('git/panel', work).then(function (reply) {
3972
+ finished()
3683
3973
  /* A read that came back after the path changed is not this path's
3684
3974
  answer; the effect below will load the new one anyway. */
3685
3975
  if (asked !== appliedRepo && asked.length > 0) return
3686
3976
  if (epoch !== repoEpoch(asked, sessionId)) return
3977
+ panelBox.lastFull = full
3978
+ /* 只问几条路径的那种读有多贵:贵到一定程度就说明父目录扫进了大树,这个仓库
3979
+ 从此收窄(见 pathsOfInterest)。 */
3980
+ if (full !== true) pathsReadSpent(resolved, Math.round(Date.now() - started))
3687
3981
  if (full) {
3688
- panelBox.fullAt = Date.now()
3982
+ /* 全树那一次花了多久,读数记下来。 */
3983
+ const cost = Math.round(Date.now() - started)
3984
+ panelBox.needFull = false
3985
+ panelBox.costMs = cost
3986
+ setTreeCost(cost)
3689
3987
  setStatus(reply != null && reply.ok === true ? reply : null)
3690
3988
  return
3691
3989
  }
3692
3990
  /* A partial answer says nothing about the rest of the tree: it is
3693
3991
  folded into the snapshot on screen, never put in its place. */
3694
3992
  setStatus(function (previous) { return mergePanelStatus(previous, reply) })
3695
- }).catch(function () { setStatus(null) })
3993
+ }).catch(function () { finished(); setStatus(null) })
3696
3994
  }).catch(function (failure) {
3697
3995
  setError(failureText(failure))
3698
3996
  })
3699
3997
  }
3700
3998
 
3701
- /* How long a snapshot may stand before the whole tree is read again. The
3702
- cheap signature and the pathspec read between them cover everything that
3703
- touches a path the tree is already showing; this is the backstop for the
3704
- one thing they cannot see. Seconds of walking on a slow mount, so it is
3705
- on a clock rather than on every tick. */
3706
- const FULL_STATUS_MS = 30000
3999
+ /* 快照能站多久。便宜的签名和路径读合起来覆盖了「已经显示着的那些路径」,它们
4000
+ 看不见的只有一件事:**本来干净、刚刚被改**的文件(或者一个干净目录里新出现的
4001
+ 文件)。那一次全树读在这台机器上是 5–8s,而且占住整条通道,所以它挂在时钟上,
4002
+ 而且间隔按上一次实测的代价来定(fullReadGapMs:至少 30s,最多 5 分钟)。 */
4003
+ /* 量完就写进全局那一份读数(见 10-state.js):面板是唯一既读全树、又读屏上那些
4004
+ 路径的地方,chip 上那个数字就来自这里 —— 两块屏幕于是不会各说各话。乐观的
4005
+ tick(点击就地改的那份快照)也走这里,所以 chip 上的数字跟着手指走。 */
4006
+ React.useEffect(function () {
4007
+ if (status == null || status.ok !== true) return
4008
+ /* 按答复里的仓库记:没应用过路径时请求里只有会话 id,而这份读数要能被 chip
4009
+ 按它查到的那个路径找到(Host 在答复里把会话的工作区解析成了路径)。 */
4010
+ publishTreeRead(treeKey(status, appliedRepo), status, panelBox.lastFull === true, panelBox.costMs)
4011
+ }, [status, appliedRepo])
3707
4012
 
3708
4013
  /* Whether this read may be about the paths on screen instead of the whole
3709
- tree: only when there is a snapshot to fold it into, and only while one
3710
- is recent enough to be worth trusting for the rest. */
4014
+ tree: whenever there is a snapshot to fold it into. */
3711
4015
  const readChanges = function () {
3712
4016
  const snapshot = panelBox.status
3713
- const recent = panelBox.fullAt > 0 && (Date.now() - panelBox.fullAt) < FULL_STATUS_MS
3714
- const touch = recent && snapshot != null && snapshot.ok === true
3715
- loadWork(appliedRepo, touch ? pathsOfInterest(snapshot) : null)
4017
+ /* 屏上有快照,问的就是屏上那些路径(0.2s);换仓库、或明确要求整棵树时才重读
4018
+ 全部。一次「提交」之后的读因此也是 0.3s,而不是 8–10s。 */
4019
+ const whole = panelBox.needFull === true || snapshot == null || snapshot.ok !== true
4020
+ loadWork(appliedRepo, whole ? null : pathsOfInterest(snapshot, treeKey(snapshot, appliedRepo)))
3716
4021
  }
3717
4022
 
3718
4023
  /* Read the whole tree again, now. The flush is what makes it a read rather
3719
4024
  than a repaint of the Host's cache; the refresh button and the clock both
3720
4025
  go through here. */
3721
4026
  const reloadChanges = function () {
3722
- panelBox.fullAt = 0
4027
+ panelBox.needFull = true
3723
4028
  callHost('git/flush', base(appliedRepo)).then(bump, bump)
3724
4029
  }
3725
4030
 
@@ -3741,8 +4046,8 @@ textarea.dsh-git-input{resize:vertical}
3741
4046
  setAppliedRepo(next)
3742
4047
  setStatus(null)
3743
4048
  panelBox.status = null
3744
- panelBox.fullAt = 0
3745
- panelBox.scope = ''
4049
+ panelBox.needFull = true
4050
+ panelBox.repo = ''
3746
4051
  setMaxCount(PAGE_COMMITS)
3747
4052
  resetFilters()
3748
4053
  setSelected(null)
@@ -3784,6 +4089,13 @@ textarea.dsh-git-input{resize:vertical}
3784
4089
  reloadChanges()
3785
4090
  }
3786
4091
 
4092
+ /* 哪些操作能把整棵树改掉:切分支、pull、以及 merge/cherry-pick/revert(开始、
4093
+ 继续、跳过、中止都算)会重写工作区,它们的答案必须是一次全树读。别的(提交、
4094
+ 暂存、取消暂存、fetch、push、tag、建/删分支)只动索引或引用 —— 那里用屏上那些
4095
+ 路径确认就够了。真机上量到的是:一次全树读 8–10s,而且这期间整条 RPC 通道都被
4096
+ 它占着,为一次「提交」让读者等十秒、十秒内点什么都要排队,是没有道理的。 */
4097
+ const REWRITES_TREE = ['git/checkout', 'git/pull', 'git/sequence', 'git/init']
4098
+
3787
4099
  /* One path for every panel operation. A failed operation still re-reads,
3788
4100
  because the failures that matter — a conflicting cherry-pick, merge or
3789
4101
  revert — leave the repository in a different state than they found it. */
@@ -3793,10 +4105,7 @@ textarea.dsh-git-input{resize:vertical}
3793
4105
  setArmed('')
3794
4106
  setError(null)
3795
4107
  setNeedsUpstream(false)
3796
- /* A repository-level operation can move anything — a checkout rewrites
3797
- the working tree, a commit empties it — so what follows it is a read of
3798
- the whole tree, not of the paths that were on screen before it. */
3799
- panelBox.fullAt = 0
4108
+ panelBox.needFull = REWRITES_TREE.indexOf(method) >= 0
3800
4109
  const request = base(appliedRepo)
3801
4110
  if (payload != null) Object.assign(request, payload)
3802
4111
  rpc(method, request).then(function () {
@@ -3805,7 +4114,18 @@ textarea.dsh-git-input{resize:vertical}
3805
4114
  }, function (failure) {
3806
4115
  setBusy(false)
3807
4116
  setError(failureText(failure))
3808
- if (method === 'git/push' && failureText(failure).indexOf('upstream') >= 0) setNeedsUpstream(true)
4117
+ if (method === 'git/push' && failureText(failure).indexOf('upstream') >= 0) {
4118
+ /* 「这个分支还没有上游」是一次可以自己走完的失败:设置里开了这一条时,
4119
+ 就把横幅本来要问的那一步直接做掉(同一个请求,带上 setUpstream)。问过
4120
+ 的那一次不再自动重试 —— 它要是也失败,横幅照旧出现,读者还有得按。 */
4121
+ const retry = payload == null || payload.setUpstream !== true
4122
+ const remote = refs != null && refs.ok === true && refs.remote.length > 0 ? refs.remote[0].name : ''
4123
+ if (plugin.pushSetUpstream === true && retry && remote.length > 0 && currentName.length > 0) {
4124
+ runOp('git/push', { setUpstream: true, remote: remote, branch: currentName })
4125
+ return
4126
+ }
4127
+ setNeedsUpstream(true)
4128
+ }
3809
4129
  bump()
3810
4130
  })
3811
4131
  }
@@ -3894,14 +4214,12 @@ textarea.dsh-git-input{resize:vertical}
3894
4214
 
3895
4215
  React.useEffect(function () {
3896
4216
  if (props.ready !== true) return undefined
3897
- /* A different repository, or a different tab, is a different question:
3898
- the snapshot on screen is not an answer to it, so the read is a whole
3899
- one. A bump with the same scope is the repository having moved under
3900
- what is already on screen, which the pathspec read answers. */
3901
- const scope = appliedRepo + '\u0000' + tab
3902
- if (panelBox.scope !== scope) {
3903
- panelBox.scope = scope
3904
- panelBox.fullAt = 0
4217
+ /* 换了仓库:屏幕上那份快照是别人的,只能整棵树重读一次。同一个仓库上的一次
4218
+ bump(仓库在屏幕底下动过了)问的是屏上那些路径 —— readChanges。标签页
4219
+ 不进这个判据:从「历史」切到「变更」不改变工作区是什么样。 */
4220
+ if (panelBox.repo !== appliedRepo) {
4221
+ panelBox.repo = appliedRepo
4222
+ panelBox.needFull = true
3905
4223
  }
3906
4224
  readChanges()
3907
4225
  return undefined
@@ -3917,8 +4235,8 @@ textarea.dsh-git-input{resize:vertical}
3917
4235
  if (!repoOk || props.ready !== true || props.active !== true || tab !== 'changes') return undefined
3918
4236
  const timer = ctx.get('timer')
3919
4237
  if (timer === undefined) return undefined
3920
- return timer.interval(function () { reloadChanges() }, FULL_STATUS_MS)
3921
- }, [appliedRepo, repoOk, props.active, props.ready, tab])
4238
+ return timer.interval(function () { reloadChanges() }, fullReadGapMs(treeCost))
4239
+ }, [appliedRepo, repoOk, props.active, props.ready, tab, treeCost])
3922
4240
 
3923
4241
  React.useEffect(function () {
3924
4242
  if (!repoOk || props.ready !== true || tab !== 'log') return undefined
@@ -3999,7 +4317,7 @@ textarea.dsh-git-input{resize:vertical}
3999
4317
  entry to write into. */
4000
4318
  React.useEffect(function () {
4001
4319
  if (status == null || status.ok !== true) return
4002
- setWatchPaths(appliedRepo, sessionId, pathsOfInterest(status))
4320
+ setWatchPaths(appliedRepo, sessionId, pathsOfInterest(status, treeKey(status, appliedRepo)))
4003
4321
  }, [status, appliedRepo, sessionId])
4004
4322
 
4005
4323
  /* One identity for as long as the repository does not change: the commit
@@ -4073,11 +4391,13 @@ textarea.dsh-git-input{resize:vertical}
4073
4391
  const request = base(appliedRepo)
4074
4392
  request.paths = paths
4075
4393
  const epoch = repoEpoch(appliedRepo, sessionId)
4394
+ const finished = treeCountReadStart(appliedRepo)
4076
4395
  callHost('git/panel', request).then(function (reply) {
4396
+ finished()
4077
4397
  if (epoch !== repoEpoch(appliedRepo, sessionId)) return
4078
4398
  if (reply == null || reply.ok !== true) return
4079
4399
  setStatus(function (previous) { return mergePanelStatus(previous, reply) })
4080
- }).catch(function () {})
4400
+ }, function () { finished() })
4081
4401
  }
4082
4402
 
4083
4403
  /* ── one mutation after another, and none of them dims the panel ──
@@ -4161,9 +4481,10 @@ textarea.dsh-git-input{resize:vertical}
4161
4481
  setBusy(false)
4162
4482
  setError(null)
4163
4483
  setMessage('')
4164
- /* A commit empties the index and the list it was showing: the answer is
4165
- a read of the whole tree, not of the paths that were on it. */
4166
- reloadChanges()
4484
+ /* 提交动的是索引和引用,屏上那些路径的读(0.3s)就是这次点击的答案:刚才
4485
+ 提交掉的那几个文件会立刻从列表里消失。整棵树留给时钟和 ⟳。 */
4486
+ panelBox.needFull = false
4487
+ bump()
4167
4488
  }, function (failure) {
4168
4489
  setBusy(false)
4169
4490
  setError(failureText(failure))
@@ -4702,7 +5023,9 @@ textarea.dsh-git-input{resize:vertical}
4702
5023
  h('div', { key: 'gne', className: 'dsh-git-grip dsh-git-grip-ne', title: '拖动调整宽高', onPointerDown: startDrag('ne') }),
4703
5024
  header,
4704
5025
  banner,
4705
- error !== null ? h('div', { className: 'dsh-git-error', style: { padding: '4px 10px' } }, error) : null,
5026
+ /* pre-wrap:这条里出现换行的地方都是「那就是两条命令」,折成一行读起来是
5027
+ 一句话里塞了两条命令。git 自己的多行原话也顺便能按原样读。 */
5028
+ error !== null ? h('div', { className: 'dsh-git-error', style: { padding: '4px 10px', whiteSpace: 'pre-wrap' } }, error) : null,
4706
5029
  body)
4707
5030
  }
4708
5031
 
@@ -4712,6 +5035,191 @@ textarea.dsh-git-input{resize:vertical}
4712
5035
  would be a workaround around its own tree, not a feature. */
4713
5036
  const SETTINGS_NAV_LABEL = 'dsh-git-idea配置'
4714
5037
 
5038
+ /* ── 提交身份:写在 git 自己的配置里 ──
5039
+
5040
+ 这一组和上面那个插件配置文件不是一回事:`user.name` / `user.email` 是 git 的
5041
+ 设置,写进去以后终端里的 git、IDEA、钩子看到的都是同一个作者。所以这里既读又
5042
+ 写,而且写什么由读者挑:这台机器的所有仓库(`--global`),还是只这一个仓库
5043
+ (`--local`)。
5044
+
5045
+ 面板不替你署名:空着的框一个字节都不写(`git config user.name ''` 正是「empty
5046
+ ident name」那个错误的来路),两个都空就什么也不做并说清楚。 */
5047
+ function GitIdentityGroup() {
5048
+ const sessionId = useLastSession()
5049
+ const [state, setState] = React.useState(null)
5050
+ const [name, setName] = React.useState('')
5051
+ const [email, setEmail] = React.useState('')
5052
+ const [scope, setScope] = React.useState('global')
5053
+ const [busy, setBusy] = React.useState(false)
5054
+ const [note, setNote] = React.useState('')
5055
+ const [problem, setProblem] = React.useState('')
5056
+
5057
+ /* 只把 session id 交给 Host,路径由它自己从会话的工作区解出来 —— 和面板、
5058
+ chip 走的是同一条路,也就落在这个会话的沙箱策略里。 */
5059
+ const request = function () {
5060
+ return sessionId.length > 0 ? { sessionId: sessionId } : {}
5061
+ }
5062
+ const load = function () {
5063
+ callHost('git/identity', request()).then(function (data) {
5064
+ setState(data)
5065
+ setProblem('')
5066
+ /* 预填:先给此刻生效的那一份,没有再给机器上的那一份。读者要改的就是它。 */
5067
+ const effectiveName = text(data.name)
5068
+ const effectiveEmail = text(data.email)
5069
+ setName(effectiveName.length > 0 ? effectiveName : text(data.globalName))
5070
+ setEmail(effectiveEmail.length > 0 ? effectiveEmail : text(data.globalEmail))
5071
+ }, function (failure) { setProblem(failureText(failure)) })
5072
+ }
5073
+ React.useEffect(function () { load() }, [sessionId])
5074
+
5075
+ const save = function () {
5076
+ if (busy) return
5077
+ setBusy(true)
5078
+ setNote('')
5079
+ setProblem('')
5080
+ const payload = { scope: scope, name: name, email: email }
5081
+ if (sessionId.length > 0) payload.sessionId = sessionId
5082
+ callHost('git/identity-save', payload).then(function (result) {
5083
+ setBusy(false)
5084
+ setState(result)
5085
+ setNote(result.scope === 'local'
5086
+ ? ('已写进 ' + text(result.repo) + ' 的 .git/config:' + result.written.join('、'))
5087
+ : ('已写进这台机器的 git 配置:' + result.written.join('、')))
5088
+ }, function (failure) {
5089
+ setBusy(false)
5090
+ setProblem(failureText(failure))
5091
+ })
5092
+ }
5093
+
5094
+ const ready = state != null
5095
+ const missing = ready && state.needsIdentity === true
5096
+ const source = function (value, origin) {
5097
+ const one = text(value)
5098
+ if (one.length === 0) return '没有配'
5099
+ const from = text(origin)
5100
+ return from.length === 0 ? one : (one + '(来自 ' + from + ')')
5101
+ }
5102
+ const toggle = function (next) {
5103
+ return h('label', { className: 'dsh-git-set-check' },
5104
+ h('input', {
5105
+ type: 'radio', checked: scope === next, name: 'dsh-git-ident-scope',
5106
+ onChange: function () { setScope(next) },
5107
+ }),
5108
+ h('span', null, next === 'global' ? '这台机器的所有仓库(--global)' : '只对这个仓库(--local)'))
5109
+ }
5110
+
5111
+ return h('div', null,
5112
+ h('div', { className: 'dsh-git-set-group' }, '提交身份(写在 git 自己的配置里)'),
5113
+ h('div', { className: 'dsh-git-set-hint' },
5114
+ 'git 不知道作者是谁时会拒绝提交,而这台机器上终端里的 git 也用同一份配置。面板空着的框一个字节都不写。'),
5115
+
5116
+ h('div', { className: 'dsh-git-set-row' },
5117
+ h('span', { className: 'dsh-git-set-label' }, '此刻生效'),
5118
+ h('span', { className: missing === true ? 'dsh-git-set-hint dsh-git-warn' : 'dsh-git-set-hint' },
5119
+ ready !== true ? '正在读取…'
5120
+ : (missing === true
5121
+ ? '还缺:' + (state.nameMissing === true ? '名字' : '邮箱') + ' —— 提交会被 git 拒绝'
5122
+ : (source(state.name, state.nameOrigin) + ' · ' + source(state.email, state.emailOrigin))))),
5123
+
5124
+ h('div', { className: 'dsh-git-set-row' },
5125
+ h('span', { className: 'dsh-git-set-label' }, '名字'),
5126
+ h('input', {
5127
+ className: 'dsh-git-input dsh-git-set-input',
5128
+ placeholder: '提交里显示的名字',
5129
+ value: name,
5130
+ onChange: function (event) { setName(event.target.value) },
5131
+ })),
5132
+
5133
+ h('div', { className: 'dsh-git-set-row' },
5134
+ h('span', { className: 'dsh-git-set-label' }, '邮箱'),
5135
+ h('input', {
5136
+ className: 'dsh-git-input dsh-git-set-input',
5137
+ placeholder: 'you@example.com',
5138
+ value: email,
5139
+ onChange: function (event) { setEmail(event.target.value) },
5140
+ })),
5141
+
5142
+ h('div', { className: 'dsh-git-set-row' }, h('span', { className: 'dsh-git-set-label' }, '写进哪里'), toggle('global')),
5143
+ h('div', { className: 'dsh-git-set-row' }, h('span', { className: 'dsh-git-set-label' }, ''), toggle('local')),
5144
+ h('div', { className: 'dsh-git-set-row' },
5145
+ h('span', { className: 'dsh-git-set-label' }, ''),
5146
+ h('span', { className: 'dsh-git-set-hint' },
5147
+ text(state != null ? state.repo : '').length > 0
5148
+ ? ('这个仓库 = ' + state.repo)
5149
+ : '这个页面还不知道是哪个会话的仓库 —— 先打开一次面板(或输入框旁的 Git 按钮),或只写全局那一份')),
5150
+
5151
+ h('div', { className: 'dsh-git-set-row' },
5152
+ h('button', {
5153
+ type: 'button', className: 'dsh-git-btn dsh-git-primary',
5154
+ disabled: busy || (scope === 'local' && text(state != null ? state.repo : '').length === 0),
5155
+ onClick: save,
5156
+ }, busy ? '写入中…' : '写入 git 配置'),
5157
+ h('span', { className: 'dsh-git-set-hint' }, '写进去就是以后所有提交的作者,别的工具也看得到')),
5158
+
5159
+ note.length > 0 ? h('div', { className: 'dsh-git-set-row dsh-git-set-hint' }, note) : null,
5160
+ problem.length > 0 ? h('div', { className: 'dsh-git-set-row dsh-git-error' }, problem) : null)
5161
+ }
5162
+
5163
+ /* ── git 位置:这台机器上的哪个 git ──
5164
+
5165
+ 每一行命令都以同一个词开头,而那个词默认来自部署的 PATH。装在不在这条 PATH 上的
5166
+ 地方(Homebrew 前缀、IDE 自带的 git、nix profile)时,面板以前只会说「这台机器
5167
+ 上找不到 git」—— 既是错的,也没给出下一步。所以它是个设置。 */
5168
+ function GitToolchainGroup() {
5169
+ const plugin = usePluginConfig()
5170
+ const committed = usePluginConfigCommitted()
5171
+ const [draftPath, setDraftPath] = React.useState(plugin.gitPath)
5172
+ const [tool, setTool] = React.useState(null)
5173
+ React.useEffect(function () { setDraftPath(plugin.gitPath) }, [plugin.gitPath])
5174
+
5175
+ const probe = function () {
5176
+ callHost('git/toolchain', {}).then(function (data) { setTool(data) }, function (failure) {
5177
+ setTool({ ok: false, path: '', version: '', found: false, reason: 'probe-failed', error: failureText(failure) })
5178
+ })
5179
+ }
5180
+ /* 问的时机是**Host 确认过之后**,不是敲键的时候:草稿是即时的,而
5181
+ `savePluginConfig` 有 400ms 去抖,落盘之后 Host 才回话。挂在草稿上问,
5182
+ 问到的是上一份配置 —— 真机上量到的就是界面永远慢一步(写入坏路径之后那一行
5183
+ 还说「来自 PATH」,要等下一次改动才改口);挂在每次按键上还会把一次询问变成
5184
+ 每个字符一次。`committed` 只在答复带着配置回来时动。 */
5185
+ React.useEffect(function () { probe() }, [committed])
5186
+
5187
+ const commitPath = function (value) {
5188
+ setDraftPath(value)
5189
+ const next = Object.assign({}, plugin)
5190
+ next.gitPath = value
5191
+ savePluginConfig(next)
5192
+ }
5193
+ const found = tool != null && tool.found === true
5194
+ const reason = tool == null ? '' : text(tool.reason)
5195
+ const verdict = tool == null
5196
+ ? '正在检查…'
5197
+ : (found
5198
+ ? ('现在用的是 ' + tool.path + (tool.fromPath === true ? '(来自 PATH)' : '(设置里写的就是它)')
5199
+ + ' · ' + text(tool.version))
5200
+ : (reason === 'configured-missing'
5201
+ ? '设置里写的这个路径不可用:它不存在,或者不是可执行文件。面板里的每条命令都会失败。'
5202
+ : '这台机器的 PATH 上没有 git。装上它,或者在下面写一个绝对路径。'))
5203
+
5204
+ return h('div', null,
5205
+ h('div', { className: 'dsh-git-set-group' }, 'git 位置'),
5206
+ h('div', { className: 'dsh-git-set-row' },
5207
+ h('span', { className: 'dsh-git-set-label' }, '可执行文件'),
5208
+ h('input', {
5209
+ className: 'dsh-git-input dsh-git-set-input',
5210
+ placeholder: '留空 = 用 PATH 里的 git',
5211
+ value: draftPath,
5212
+ onChange: function (event) { commitPath(event.target.value) },
5213
+ })),
5214
+ h('div', { className: 'dsh-git-set-row' },
5215
+ h('span', { className: 'dsh-git-set-label' }, ''),
5216
+ h('span', { className: found === true ? 'dsh-git-set-hint' : 'dsh-git-set-hint dsh-git-warn' }, verdict)),
5217
+ h('div', { className: 'dsh-git-set-row' },
5218
+ h('span', { className: 'dsh-git-set-label' }, ''),
5219
+ h('button', { type: 'button', className: 'dsh-git-btn', onClick: probe }, '再检查一次'),
5220
+ h('span', { className: 'dsh-git-set-hint' }, '面板读、写、初始化用的都是这一个')))
5221
+ }
5222
+
4715
5223
  function GitSettingsSection(props) {
4716
5224
  const settings = useGitSettings()
4717
5225
  const [draft, setDraft] = React.useState(settings)
@@ -4777,10 +5285,49 @@ textarea.dsh-git-input{resize:vertical}
4777
5285
  }),
4778
5286
  h('span', null, 'cherry-pick 时记录来源(-x)'))),
4779
5287
 
5288
+ h('div', { className: 'dsh-git-set-group' }, '远程同步'),
5289
+ h('div', { className: 'dsh-git-set-hint' },
5290
+ '这三条是面板给 git 的实参,不是 git 自己的设置:下面没勾的,就是 git 原本的行为(`push.default`、`pull.rebase` 照旧生效)。'),
5291
+
5292
+ h('div', { className: 'dsh-git-set-row' },
5293
+ h('label', { className: 'dsh-git-set-check' },
5294
+ h('input', {
5295
+ type: 'checkbox', checked: pdraft.fetchPrune !== false,
5296
+ onChange: function (event) { setPlugin('fetchPrune', event.target.checked) },
5297
+ }),
5298
+ h('span', null, 'fetch 时删掉远端已经删了的远程分支(--prune)'))),
5299
+
5300
+ h('div', { className: 'dsh-git-set-row' },
5301
+ h('label', { className: 'dsh-git-set-check' },
5302
+ h('input', {
5303
+ type: 'checkbox', checked: pdraft.pullRebase === true,
5304
+ onChange: function (event) { setPlugin('pullRebase', event.target.checked) },
5305
+ }),
5306
+ h('span', null, 'pull 用 rebase 而不是 merge(--rebase)'))),
5307
+
5308
+ h('div', { className: 'dsh-git-set-row' },
5309
+ h('label', { className: 'dsh-git-set-check' },
5310
+ h('input', {
5311
+ type: 'checkbox', checked: pdraft.pushSetUpstream === true,
5312
+ onChange: function (event) { setPlugin('pushSetUpstream', event.target.checked) },
5313
+ }),
5314
+ h('span', null, '推送没有上游的分支时直接推上去并设上游(push -u)'))),
5315
+
5316
+ h('div', { className: 'dsh-git-set-row' },
5317
+ h('span', { className: 'dsh-git-set-label' }, '也就是'),
5318
+ h('span', { className: 'dsh-git-set-hint' },
5319
+ 'git fetch --all' + (pdraft.fetchPrune !== false ? ' --prune' : '')
5320
+ + ' · git pull' + (pdraft.pullRebase === true ? ' --rebase' : '')
5321
+ + ' · ' + (pdraft.pushSetUpstream === true ? 'git push -u <remote> <branch>(没有上游时)' : 'git push(没有上游时由面板问一句)'))),
5322
+
5323
+ h(GitToolchainGroup),
5324
+
4780
5325
  pluginConfigError.length > 0
4781
5326
  ? h('div', { className: 'dsh-git-set-row dsh-git-error' }, '保存失败:' + pluginConfigError)
4782
5327
  : null,
4783
5328
 
5329
+ h(GitIdentityGroup),
5330
+
4784
5331
  h('div', { className: 'dsh-git-set-group' }, '本浏览器'),
4785
5332
  h('div', { className: 'dsh-git-set-hint' }, '这些只是外观和使用节奏,换浏览器各管各的。'),
4786
5333
 
@@ -4854,24 +5401,37 @@ textarea.dsh-git-input{resize:vertical}
4854
5401
  }, '本浏览器全部恢复默认')))
4855
5402
  }
4856
5403
 
4857
- /* The last count that was actually measured, per repository. Two sessions
4858
- usually point at the same workspace, and the count is a property of the
4859
- repository, not of the session looking at it — so a session opened for the
4860
- first time can show the number instead of a gap while its own full read
4861
- grinds through the working tree. */
4862
- const pendingByRepo = {}
5404
+ /* ── 输入框旁边那个 chip ──
5405
+
5406
+ 屏幕上那个数字不是这块地方自己量的,它来自全局那一份工作区读数(10-state.js)。
5407
+ 一次全树读在这台机器上 8–10s,而且占住整条通道(一次只跑一个处理函数):每次
5408
+ 醒过来都量一遍,面板那条 0.3s 的路径读就排在它后面 —— 屏幕上就是「面板反应过来了,
5409
+ chip 还没反应过来」。所以这里只做三件事:问身份(0.1–0.4s)、把上次那些脏路径
5410
+ 重新问一次(0.2s,和面板问的是同一个问题,Host 那边只起一个进程),以及在这份
5411
+ 读数确实该完整重来一遍时发一次全树读(压后 2 秒,让这次点击的反馈先走)。 */
5412
+
5413
+ /* 全树读压后多久:屏幕上先有这一帧的反馈,再让那条 8–10s 的读去占通道。 */
5414
+ const COUNT_FULL_DELAY_MS = 2000
4863
5415
 
4864
5416
  function GitChip(props) {
4865
5417
  const isOpen = useOpen()
4866
5418
  const switching = useSwitchingTo()
4867
5419
  const [info, setInfo] = React.useState(function () { return chipLabelFor(props.sessionId) })
4868
5420
  const reloadAt = useDataVersion()
5421
+ /* 谁写了那份读数都要重画:面板量完一次,chip 上的数字跟着变。 */
5422
+ useTreeVersion()
4869
5423
  /* Applying a directory in the panel changes which repository this chip is
4870
5424
  about, and this signal is how the chip hears about it: without the render
4871
5425
  it went on reading — and watching — the workspace it started with. */
4872
5426
  const repoVersion = useRepoApplied()
4873
5427
  const watched = sessionRepo(props.sessionId)
4874
5428
  const sessionId = props.sessionId
5429
+ const repo = info.repo.length > 0 ? info.repo : watched
5430
+ const record = treeRecord(repo)
5431
+ const known = record !== null
5432
+ const pending = record === null ? 0 : record.count
5433
+ /* 正在核对:这份读数该重新完整量一次,或者那一次正在飞(几秒)。 */
5434
+ const due = treeReadDue(repo) === true || treeCountReading(repo) === true
4875
5435
 
4876
5436
  React.useEffect(function () {
4877
5437
  loadSettings(chipNode != null ? chipNode.ownerDocument : null)
@@ -4892,8 +5452,14 @@ textarea.dsh-git-input{resize:vertical}
4892
5452
  return watchRepo(watched, sessionId, bumpData, false)
4893
5453
  }, [watched, repoVersion, sessionId, isOpen])
4894
5454
 
5455
+ /* 这个页面在哪个会话里 —— chip 一直挂在输入框旁边,所以它是把这件事记下来的
5456
+ 那个面(设置页是全局的,自己不知道)。放在 effect 里而不是渲染里:渲染期间
5457
+ 通知订阅者就是渲染期间改别人的 state。 */
5458
+ React.useEffect(function () { rememberSession(sessionId) }, [sessionId])
5459
+
4895
5460
  React.useEffect(function () {
4896
5461
  let alive = true
5462
+ let stopFull = null
4897
5463
  const request = { sessionId: sessionId }
4898
5464
  const mine = watched
4899
5465
  if (mine.length > 0) request.repo = mine
@@ -4907,21 +5473,9 @@ textarea.dsh-git-input{resize:vertical}
4907
5473
  const branch = text(data.branch)
4908
5474
  const detached = data.detached === true
4909
5475
  const repo = text(data.repo)
4910
- const measured = data.partial !== true
4911
- const counted = data.staged.length + data.unstaged.length + data.untracked.length + data.unmerged.length
4912
- /* The cheap read answers in a fifth of a second and carries no working
4913
- tree at all, so its "no changes" means "not asked", not "nothing to
4914
- report". Counting it dropped the badge to nothing on every poll tick
4915
- — and on a Windows-mounted worktree it stayed gone for the seven
4916
- seconds the full read takes, which reads as the number having been
4917
- lost. What was last measured is kept until something measures it
4918
- again, and `stale` says so out loud, so a stale number is never
4919
- shown as fact. */
4920
- const known = chipLabels[sessionId]
4921
- const remembered = known !== undefined && known.phase === 'repo' && known.repo === repo
4922
- const carried = remembered ? known.pending : (pendingByRepo[repo] !== undefined ? pendingByRepo[repo] : 0)
4923
- if (measured) pendingByRepo[repo] = counted
4924
- const pending = measured ? counted : carried
5476
+ /* 数字来自全局那一份读数,不是这一次读算出来的:快读(身份)根本不带工作区,
5477
+ 它的「没有改动」意思是「没问过」。 */
5478
+ const pending = treeCount(repo)
4925
5479
  /* Kept outside React state because the hover card needs the count and
4926
5480
  hangs in a different subtree; a switch offer should not have to
4927
5481
  re-derive it with another read. */
@@ -4931,11 +5485,6 @@ textarea.dsh-git-input{resize:vertical}
4931
5485
  phase: 'repo',
4932
5486
  label: detached ? 'HEAD' : (branch.length > 0 ? branch : 'HEAD'),
4933
5487
  pending: pending,
4934
- /* Only a session that has something to carry is stale: the first
4935
- visit of a workspace still shows the last count this browser saw
4936
- for that repository, which is better than a gap that fills in
4937
- seven seconds later. */
4938
- stale: measured !== true && (remembered || pendingByRepo[repo] !== undefined),
4939
5488
  repo: repo,
4940
5489
  reason: '',
4941
5490
  }
@@ -4950,51 +5499,104 @@ textarea.dsh-git-input{resize:vertical}
4950
5499
  setInfo(chipLabels[sessionId])
4951
5500
  }
4952
5501
 
4953
- /* Two reads, cheapest first. The identity read answers in about a fifth
4954
- of a second on a repository where the full one takes seven, and it
4955
- carries everything the chip shows except the change count — so the
4956
- workspace you switched to is named immediately and the badge catches
4957
- up. The full read also leaves the Host's cache warm for the panel,
4958
- which is what usually opens next. */
5502
+ /* 数字怎么来:
5503
+ 1. 这份读数在这个仓库上还没有过 整棵树量一次(不量 chip 上就一个数字都没有);
5504
+ 2. 有过、而且上次那些脏路径还在 只问那些路径(0.2s)。提交之后那几个文件
5505
+ 就是这样立刻消失的,而且和面板屏幕上那份快照是同一个问题;
5506
+ 3. 没有脏路径可以问(上一次量出来是干净的),或者这份读数确实该完整重来一遍
5507
+ (fullAt 太旧)→ 整棵树量一次;已经有数字时压后 2 秒,让这次点击的反馈先走。
5508
+
5509
+ 一次 bump 意味着仓库动过(引用、索引或 HEAD):干净的那份读数这时不能继续当
5510
+ 「现在也干净」用 —— 所以第 3 条也在每次 bump 时成立。 */
5511
+ const refreshCount = function (repoNow) {
5512
+ const current = treeRecord(repoNow)
5513
+ const paths = treeReadPaths(repoNow)
5514
+ if (current !== null && paths.length > 0) {
5515
+ const finished = treeCountReadStart(repoNow)
5516
+ const started = Date.now()
5517
+ callHost('git/panel', Object.assign({ paths: paths }, request)).then(function (reply) {
5518
+ finished()
5519
+ /* 这一次路径读有多贵 —— 贵到一定程度就说明父目录扫进了大树,这个仓库从此
5520
+ 收窄成只问那几条路径本身(见 pathsOfInterest)。 */
5521
+ pathsReadSpent(repoNow, Math.round(Date.now() - started))
5522
+ if (alive !== true || reply == null || reply.ok !== true) return
5523
+ publishTreeRead(repoNow, mergePanelStatus(current.status, reply), false, null)
5524
+ }, function () { finished() })
5525
+ }
5526
+ const nothingToAsk = current === null || paths.length === 0
5527
+ if (treeReadDue(repoNow) !== true && nothingToAsk !== true) return
5528
+ const wholeTree = function () {
5529
+ const started = Date.now()
5530
+ const finished = treeCountReadStart(repoNow)
5531
+ callHost('git/panel', request).then(function (full) {
5532
+ finished()
5533
+ if (alive !== true || full == null || full.ok !== true) return
5534
+ publishTreeRead(repoNow, full, true, Date.now() - started)
5535
+ }, function () { finished() })
5536
+ }
5537
+ /* 没有数字可以报(这个仓库还没量过):现在就得量,8–10s 也认了。上一次量出来
5538
+ 是干净的、或者按间隔该完整重来一遍:那次全树读压后 2 秒,让这次点击的反馈
5539
+ (分支名、面板那条 0.2s 的路径读)先走。 */
5540
+ const defer = current !== null && nothingToAsk !== true
5541
+ if (defer !== true) { wholeTree(); return }
5542
+ const timer = ctx.get('timer')
5543
+ if (timer === undefined) { wholeTree(); return }
5544
+ stopFull = timer.timeout(wholeTree, COUNT_FULL_DELAY_MS)
5545
+ }
5546
+
5547
+ /* The identity read answers in about a fifth of a second on a repository
5548
+ where the full one takes eight, and it carries everything the chip shows
5549
+ except the change count — so the workspace you switched to is named
5550
+ immediately and the badge follows from the shared reading. */
4959
5551
  callHost('git/panel', Object.assign({ quick: true }, request)).then(function (data) {
4960
- if (!alive) return
5552
+ if (alive !== true) return
4961
5553
  apply(data)
4962
- callHost('git/panel', request).then(function (full) {
4963
- if (alive) apply(full)
4964
- }).catch(function () {})
5554
+ const repoNow = data != null && data.ok === true ? text(data.repo) : ''
5555
+ if (repoNow.length === 0) return
5556
+ prefetchBranches(sessionId, repoNow)
5557
+ refreshCount(repoNow)
4965
5558
  }).catch(function () {
4966
- if (alive) setInfo({ phase: 'none', label: null, pending: 0, repo: '', reason: '' })
5559
+ if (alive === true) setInfo({ phase: 'none', label: null, pending: 0, repo: '', reason: '' })
4967
5560
  })
4968
- return function () { alive = false }
5561
+ return function () { alive = false; if (stopFull !== null) stopFull() }
4969
5562
  }, [watched, repoVersion, isOpen, sessionId, reloadAt])
4970
5563
 
4971
5564
  const isRepo = info.phase === 'repo'
4972
5565
  const where = info.repo.length > 0 ? info.repo : '当前会话工作区'
4973
- /* While the cheap read is in flight the count on screen is the last one
4974
- that was measured, so the tooltip says that instead of claiming the
4975
- working tree is clean. */
4976
- const count = info.pending > 0
4977
- ? String(info.pending) + ' 个改动' + (info.stale === true ? '(正在核对)' : '')
4978
- : (info.stale === true ? '正在核对改动…' : '工作区干净')
5566
+ /* 数字来自全局那一份读数,而不是这次快读 —— 快读根本不带工作区。还没量过就说
5567
+ 「正在核对」,不说「工作区干净」:没量出来和没改动是两件事。读数该完整重来
5568
+ 一遍时(due)也这么说,因为那一次全树读确实正在排。 */
5569
+ const count = known !== true
5570
+ ? '正在核对改动…'
5571
+ : (pending > 0
5572
+ ? String(pending) + ' 个改动' + (due === true ? '(正在核对)' : '')
5573
+ : (due === true ? '正在核对改动…' : '工作区干净'))
4979
5574
  let title = 'Git'
4980
5575
  if (info.phase === 'loading') title = 'Git'
4981
5576
  else if (isRepo) title = info.label + ' · ' + info.repo + ' · ' + count
4982
5577
  else if (info.reason === 'missing') title = '目录不存在:' + where + ' —— 点击修改路径'
4983
5578
  else if (info.reason === 'file') title = '这不是一个目录:' + where + ' —— 点击修改路径'
4984
5579
  else if (info.reason === 'git-error') title = where + ' 读取失败 —— 点击查看原因'
5580
+ else if (info.reason === 'no-git') title = where + ':这台机器上找不到 git —— 点击查看'
5581
+ /* 路径还没定:这一页要人填一个目录,所以这里得说「填」,不能说「这个目录不是
5582
+ 仓库」—— 那时候连是哪个目录都还不知道。 */
5583
+ else if (info.reason === 'no-path') title = '还没确定看哪个目录 —— 点击填写'
5584
+ /* 「这个目录不是 Git 仓库」那一页没有路径框(路径不是问题,没什么可填的),
5585
+ 所以这里也不再承诺「点击选择路径」—— 承诺一个点不到的东西比不承诺更坏。 */
5586
+ else if (info.reason === 'not-a-repo') title = where + ' 这个目录不是 Git 仓库 —— 点击查看'
4985
5587
  else if (info.reason === '') title = 'Git —— 点击打开面板'
4986
- else title = where + ' 这个目录不是 Git 仓库 —— 点击选择路径或在这里初始化'
5588
+ else title = where + ' 读不动这个目录 —— 点击查看'
4987
5589
 
4988
5590
  const children = [h(BranchIcon, {
4989
5591
  key: 'icon', size: 14, plus: !isRepo && info.phase === 'none',
4990
5592
  spin: switching !== null,
4991
5593
  })]
4992
5594
  if (isRepo) children.push(h('span', { className: 'dsh-git-chip-label', key: 'label' }, info.label))
4993
- if (isRepo && info.pending > 0) {
5595
+ if (isRepo && known === true && pending > 0) {
4994
5596
  children.push(h('span', {
4995
- className: 'dsh-git-badge' + (info.stale === true ? ' dsh-git-badge-stale' : ''),
5597
+ className: 'dsh-git-badge' + (due === true ? ' dsh-git-badge-stale' : ''),
4996
5598
  key: 'badge',
4997
- }, String(info.pending)))
5599
+ }, String(pending)))
4998
5600
  }
4999
5601
 
5000
5602
  return h('button', {
@@ -5023,6 +5625,9 @@ textarea.dsh-git-input{resize:vertical}
5023
5625
  function GitPopover(props) {
5024
5626
  const isOpen = useOpen()
5025
5627
  const mode = useSwitchMode()
5628
+ /* 分支卡片上那个「几个改动」也来自全局那一份读数:同一个数字在面板、chip 和这张
5629
+ 卡片上必须是同一个。 */
5630
+ useTreeVersion()
5026
5631
  /* Unmounting on close threw away the tab, the filters, the selection and
5027
5632
  the scroll position, and made every reopen a fresh mount that re-read
5028
5633
  everything. Closing now only hides it: the panel keeps its state, and
@@ -5089,7 +5694,9 @@ textarea.dsh-git-input{resize:vertical}
5089
5694
  ? chipInfoFor(props.sessionId).repo
5090
5695
  : sessionRepo(props.sessionId),
5091
5696
  mode: 'hover',
5092
- dirty: chipInfoFor(props.sessionId).pending,
5697
+ dirty: treeCount(chipInfoFor(props.sessionId).repo.length > 0
5698
+ ? chipInfoFor(props.sessionId).repo
5699
+ : sessionRepo(props.sessionId)),
5093
5700
  onDone: function () { setSwitchMode(null) },
5094
5701
  onClose: function () { setSwitchMode(null) },
5095
5702
  }))