dsh-git-idea 0.1.0 → 0.2.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.
Files changed (4) hide show
  1. package/README.md +164 -62
  2. package/client/client.js +709 -120
  3. package/lib/index.js +634 -981
  4. package/package.json +3 -4
package/client/client.js CHANGED
@@ -292,6 +292,124 @@ return {
292
292
  repoEpochs[repoEpochKey(repo, sessionId)] = repoEpoch(repo, sessionId) + 1
293
293
  }
294
294
 
295
+ /* ── 工作区读数:整个插件只有一份 ──
296
+
297
+ `git status` 全树在这台机器上就是几秒(读者那个 /mnt/d 工作区、5093 个文件:
298
+ 8.1s 冷,`-uno` 也要 5.3s),而这条 RPC 通道一次只跑一个处理函数 —— 一次全树读
299
+ 在飞的时候,屏幕上每一次点击、另一块屏幕的每一次读,全都排在它后面。真机上量到
300
+ 的是:终端里提交一次(引用变了),队列 18–21s 才排空,其中 13s 是两次全树读;
301
+ 面板关着时那一次也要 10.6s,而屏幕上看到的就是「点了没反应」。
302
+
303
+ 所以「工作区现在什么样」全局只留一份:谁读到的都写在这里,面板和 chip 都从这一
304
+ 份渲染 —— 两块屏幕于是不可能各说各话,也不会各量一遍(同一个问题在 Host 那边本来
305
+ 也只会起一个进程)。一条记录里四样东西各有各的用处:
306
+
307
+ status 最后一次合并好的工作区快照(和面板「变更」页上那份是同一个东西)
308
+ at 最后一次**任何**读数的时刻(全树,或只问屏上那几条路径)
309
+ fullAt 最后一次**全树**读数的时刻:只有它证明这份快照是完整的
310
+ costMs 那次全树读占住通道多久;下一次该隔多久由它决定 */
311
+ const treeReads = {}
312
+ let treeVersion = 0
313
+ const treeSignal = createSignal(function () { return treeVersion })
314
+ const useTreeVersion = treeSignal.use
315
+
316
+ function treeRecord(repo) {
317
+ const found = treeReads[repo]
318
+ return found === undefined ? null : found
319
+ }
320
+
321
+ function treeCount(repo) {
322
+ const record = treeRecord(repo)
323
+ return record === null ? 0 : record.count
324
+ }
325
+
326
+ function publishTreeRead(repo, status, full, costMs) {
327
+ if (repo == null || repo.length === 0 || status == null || status.ok !== true) return
328
+ const previous = treeRecord(repo)
329
+ const now = Date.now()
330
+ treeReads[repo] = {
331
+ status: status,
332
+ count: mergeChanges(status).length,
333
+ at: now,
334
+ fullAt: full === true ? now : (previous === null ? 0 : previous.fullAt),
335
+ costMs: typeof costMs === 'number' && isFinite(costMs) && costMs >= 0
336
+ ? costMs
337
+ : (previous === null ? 0 : previous.costMs),
338
+ }
339
+ treeVersion += 1
340
+ treeSignal.notify()
341
+ }
342
+
343
+ /* 一次全树读之后,隔多久才值得再来一次:它占住整条通道 costMs 毫秒,那就让空档至少
344
+ 是它的 8 倍。量得快的仓库照旧 30 秒一次;慢挂载上不会每 30 秒冻 8 秒(上限 5 分钟)。 */
345
+ const FULL_READ_FLOOR_MS = 30000
346
+ const FULL_READ_CEIL_MS = 300000
347
+ const FULL_READ_FACTOR = 8
348
+ function fullReadGapMs(costMs) {
349
+ const cost = typeof costMs === 'number' && isFinite(costMs) && costMs > 0 ? costMs : 0
350
+ const wanted = Math.round(cost * FULL_READ_FACTOR)
351
+ if (wanted <= FULL_READ_FLOOR_MS) return FULL_READ_FLOOR_MS
352
+ return wanted > FULL_READ_CEIL_MS ? FULL_READ_CEIL_MS : wanted
353
+ }
354
+
355
+ /* 这份快照还完整吗:true 表示该有人再整棵树量一次。 */
356
+ function treeReadDue(repo) {
357
+ const record = treeRecord(repo)
358
+ if (record === null || record.fullAt === 0) return true
359
+ return Date.now() - record.fullAt >= fullReadGapMs(record.costMs)
360
+ }
361
+
362
+ /* 有一次**会改变那个数字**的读正在飞(这个仓库)—— 全树读,或者只问几条路径的那种
363
+ 都算。它落地之前,屏幕上那个数字不能当成「刚刚核对过」:chip 这时说的是
364
+ 「正在核对」,而不是继续报一个它还没验证过的数字。 */
365
+ const treeReadings = {}
366
+ function treeCountReadStart(repo) {
367
+ if (repo == null || repo.length === 0) return function () {}
368
+ treeReadings[repo] = (treeReadings[repo] === undefined ? 0 : treeReadings[repo]) + 1
369
+ treeVersion += 1
370
+ treeSignal.notify()
371
+ let done = false
372
+ return function () {
373
+ if (done) return
374
+ done = true
375
+ if (treeReadings[repo] > 0) treeReadings[repo] -= 1
376
+ treeVersion += 1
377
+ treeSignal.notify()
378
+ }
379
+ }
380
+
381
+ function treeCountReading(repo) {
382
+ return repo != null && repo.length > 0 && treeReadings[repo] !== undefined && treeReadings[repo] > 0
383
+ }
384
+
385
+ /* 上一次全树读里那些脏路径。一次 bump 之后拿它问一次(0.2s)就能把屏幕上那份快照
386
+ 更新掉 —— 提交之后那几个文件就是这样立刻消失的,而不是等一次新的全树读。 */
387
+ function treeReadPaths(repo) {
388
+ const record = treeRecord(repo)
389
+ if (record === null || record.status == null) return []
390
+ return pathsOfInterest(record.status, repo)
391
+ }
392
+
393
+ /* 一条路径读比这个还贵,就说明父目录那一层把大树扫进去了:这个仓库从此只问那几条
394
+ 路径本身。读者那个仓库(Windows 挂载)上,12 条含父目录 6138ms、9 条不含 295ms。 */
395
+ const PATHS_READ_MAX_MS = 1500
396
+
397
+ /* 一次「只问屏上那几条路径」的读花了多久。父目录那一层(为了「改动文件旁边新出现的
398
+ 文件」)在某些仓库上会把旁边整棵大树扫一遍 —— 量到一次超时就收窄,只问那几条路径
399
+ 本身(见 52-detail.js 里的数)。 */
400
+ const treeNarrowed = {}
401
+ function treeWide(repo) {
402
+ return treeNarrowed[repo] !== true
403
+ }
404
+
405
+ function pathsReadSpent(repo, ms) {
406
+ if (repo == null || repo.length === 0 || treeNarrowed[repo] === true) return
407
+ if (!(typeof ms === 'number' && isFinite(ms) && ms >= PATHS_READ_MAX_MS)) return
408
+ treeNarrowed[repo] = true
409
+ treeVersion += 1
410
+ treeSignal.notify()
411
+ }
412
+
295
413
  /* Which switcher is showing, if either: the dropdown hanging off the panel
296
414
  header's branch chip ('panel'), or the card the composer chip opens on
297
415
  hover ('hover'). One at a time, never both with the panel. */
@@ -371,6 +489,24 @@ return {
371
489
  const repoAppliedSignal = createSignal(function () { return repoApplied })
372
490
  const useRepoApplied = repoAppliedSignal.use
373
491
 
492
+ /* The same answer for the one surface that has no session of its own: the
493
+ settings page is global, so it cannot name a session — and it must not name
494
+ a *path* either, because the Host is the one that resolves a session's
495
+ workspace (see `repoFrom` in the Host half). So what is remembered here is
496
+ only the id, and the settings page hands that back to the Host: the same
497
+ resolution, the same sandbox policy, one source of truth. */
498
+ let lastSessionId = ''
499
+ const lastSessionSignal = createSignal(function () { return lastSessionId })
500
+ const useLastSession = lastSessionSignal.use
501
+
502
+ function rememberSession(sessionId) {
503
+ if (sessionId === undefined || sessionId === null) return
504
+ const one = String(sessionId)
505
+ if (one.length === 0 || one === lastSessionId) return
506
+ lastSessionId = one
507
+ lastSessionSignal.notify()
508
+ }
509
+
374
510
  function sessionRepo(sessionId) {
375
511
  if (sessionId === undefined || sessionId === null) return ''
376
512
  const value = sharedRepos[sessionId]
@@ -492,7 +628,9 @@ return {
492
628
  watchEnabled: true,
493
629
  watchChip: true,
494
630
  watchFastSec: 3,
495
- watchSlowSec: 15,
631
+ /* 面板关着时 chip 那条慢 lane。它现在只发一次便宜签名(真机上 0.12s),所以可以
632
+ 问得比过去勤:15s 的话,终端里提交完要过十几秒 chip 才改口。 */
633
+ watchSlowSec: 5,
496
634
  hoverSwitch: true,
497
635
  /* Which of the two changes views the panel opens in: the directory tree
498
636
  (IDEA's default) or the flat list of paths. A preference of this browser
@@ -551,7 +689,13 @@ return {
551
689
  half, so they travel across browsers and machines. As an ordinary plugin
552
690
  this is exactly what its config section would hold. */
553
691
 
554
- const PLUGIN_CONFIG_DEFAULTS = { initBranch: 'main', cherryPickRecord: false }
692
+ const PLUGIN_CONFIG_DEFAULTS = {
693
+ initBranch: 'main', cherryPickRecord: false,
694
+ /* 空 = 用部署 PATH 里的 git。 */
695
+ gitPath: '',
696
+ /* 这三条是插件自己给 git 的实参,不是 git 设置的副本。 */
697
+ fetchPrune: true, pullRebase: false, pushSetUpstream: false,
698
+ }
555
699
  let pluginConfig = Object.assign({}, PLUGIN_CONFIG_DEFAULTS)
556
700
  let pluginConfigPath = ''
557
701
  let pluginConfigLoaded = false
@@ -559,11 +703,23 @@ return {
559
703
  const pluginConfigSignal = createSignal(function () { return pluginConfig })
560
704
  const usePluginConfig = pluginConfigSignal.use
561
705
 
706
+ /* 每次**Host 确认过**的配置计数。屏幕上那份草稿是即时的(`savePluginConfig` 当场
707
+ 改内存,400ms 后才落盘),所以「问 Host 一件事」不能挂在草稿上:挂上去的话每敲
708
+ 一个键都是一次询问,而且问到的是 Host 手里那份还没更新的配置 —— 真机上量到的
709
+ 是界面永远慢一步。这个计数只在答复带着配置回来时才动(初次读取、保存成功)。 */
710
+ let pluginConfigCommitted = 0
711
+ const pluginConfigCommittedSignal = createSignal(function () { return pluginConfigCommitted })
712
+ const usePluginConfigCommitted = pluginConfigCommittedSignal.use
713
+
562
714
  function normalizePluginConfig(raw) {
563
715
  const out = Object.assign({}, PLUGIN_CONFIG_DEFAULTS)
564
716
  if (raw == null || typeof raw !== 'object') return out
565
717
  if (typeof raw.initBranch === 'string') out.initBranch = raw.initBranch.trim().slice(0, 120)
566
718
  out.cherryPickRecord = raw.cherryPickRecord === true
719
+ if (typeof raw.gitPath === 'string') out.gitPath = raw.gitPath.trim().slice(0, 400)
720
+ out.fetchPrune = raw.fetchPrune !== false
721
+ out.pullRebase = raw.pullRebase === true
722
+ out.pushSetUpstream = raw.pushSetUpstream === true
567
723
  return out
568
724
  }
569
725
 
@@ -573,7 +729,9 @@ return {
573
729
  if (data.config !== undefined) pluginConfig = normalizePluginConfig(data.config)
574
730
  pluginConfigError = ''
575
731
  }
732
+ pluginConfigCommitted += 1
576
733
  pluginConfigSignal.notify()
734
+ pluginConfigCommittedSignal.notify()
577
735
  }
578
736
 
579
737
  function loadPluginConfig() {
@@ -593,9 +751,15 @@ return {
593
751
  let configSaveTimer = null
594
752
 
595
753
  function writePluginConfig() {
596
- callHost('git/config-save', { config: pluginConfig }).then(function (result) {
754
+ const request = { config: pluginConfig }
755
+ /* 写在部署的配置目录里,也就是任何工作区之外:带上这个页面在哪个会话里,Host
756
+ 才能用这个会话的沙箱策略去写(没有会话时写不出去,而那种失败必须说得出来)。 */
757
+ if (lastSessionId.length > 0) request.sessionId = lastSessionId
758
+ callHost('git/config-save', request).then(function (result) {
597
759
  if (result == null || result.ok !== true) {
598
- pluginConfigError = text(result != null ? result.error : '') || '保存失败'
760
+ /* git 那套「这台机器 / 这个沙箱不让我做这件事」的说法是同一份(见
761
+ commandDetail):这里也走它,免得写不进去时屏幕上什么都没有。 */
762
+ pluginConfigError = commandDetail(result) || text(result != null ? result.error : '') || '保存失败'
599
763
  pluginConfigSignal.notify()
600
764
  return
601
765
  }
@@ -1430,12 +1594,19 @@ textarea.dsh-git-input{resize:vertical}
1430
1594
  const win = useVirtualWindow('log', count, ROW_H)
1431
1595
 
1432
1596
  if (commits === null) {
1433
- const reason = graph != null && graph.error === 'not-a-repository'
1434
- ? ('不是 git 仓库:' + text(graph.repo))
1435
- : '无法读取提交历史'
1597
+ const reason = graph != null && graph.noGit === true
1598
+ ? ('找不到 git' + text(graph.repo))
1599
+ : graph != null && graph.error === 'not-a-repository'
1600
+ ? ('不是 git 仓库:' + text(graph.repo))
1601
+ : '无法读取提交历史'
1436
1602
  return h('div', { className: 'dsh-git-pane dsh-git-error' }, reason)
1437
1603
  }
1438
- if (count === 0) return h('div', { className: 'dsh-git-pane dsh-git-dim' }, '没有匹配的提交')
1604
+ if (count === 0) {
1605
+ /* 空历史有两种:这个仓库还没有第一个提交(刚 init),和筛选没匹配到。
1606
+ 前者不是「没有匹配」,说成那样会让人去清筛选。 */
1607
+ return h('div', { className: 'dsh-git-pane dsh-git-dim' },
1608
+ graph != null && graph.unborn === true ? '这个仓库还没有提交' : '没有匹配的提交')
1609
+ }
1439
1610
 
1440
1611
  const laneNum = Math.max(1, graph.lanes)
1441
1612
  const graphWidth = laneNum * LANE_W + 6
@@ -1830,6 +2001,10 @@ textarea.dsh-git-input{resize:vertical}
1830
2001
  ok: true, repo: partial.repo, branch: partial.branch, detached: partial.detached,
1831
2002
  upstream: partial.upstream, ahead: partial.ahead, behind: partial.behind,
1832
2003
  sequencer: partial.sequencer,
2004
+ /* 和 branch、upstream 一样来自这一次读:身份缺不缺是机器上的事实,不随路径
2005
+ 部分读而改变,但也不能因为一次合并就把它丢掉(丢掉的后果是提交区那个提示
2006
+ 闪一下又没了)。 */
2007
+ needsIdentity: partial.needsIdentity === true,
1833
2008
  staged: keep(current.staged).concat(list(partial.staged)),
1834
2009
  unstaged: keep(current.unstaged).concat(list(partial.unstaged)),
1835
2010
  untracked: keep(current.untracked).concat(list(partial.untracked)),
@@ -1845,8 +2020,22 @@ textarea.dsh-git-input{resize:vertical}
1845
2020
  the whole tree again, which is the cost this is here to avoid. */
1846
2021
  const PATHS_MAX = 200
1847
2022
 
1848
- function pathsOfInterest(status) {
2023
+ /* ── 父目录那一层有多贵 ──
2024
+
2025
+ 「改动文件旁边新出现的文件」确实要问它所在的目录才看得见,可是**目录条目
2026
+ (`.../`,git 把没跟踪的目录折叠成一条)本身就是自己的子树**:再带上它的上一层
2027
+ 就是把旁边整棵大树扫一遍。读者那个仓库上量到的是:
2028
+
2029
+ 7 条原始路径(其中 3 条是折叠目录) 280ms
2030
+ 12 条(每条再带上父目录) 6138ms ← `holox-modules` 一条吃掉了全部
2031
+ 9 条(目录条目不带父目录) 295ms
2032
+
2033
+ 所以目录条目不带上父目录;文件的父目录留着(文件旁边新出现的文件还是由它看见)。
2034
+ 另外量到一次路径读本身就很贵(`PATHS_READ_MAX_MS`,见 10-state.js)时,这个仓库
2035
+ 整个收窄成只问那几条路径本身 —— 那种仓库上新文件就交给整棵树的时钟。 */
2036
+ function pathsOfInterest(status, repo) {
1849
2037
  if (status == null || status.ok !== true) return []
2038
+ const wide = repo === undefined || repo === null || repo.length === 0 ? true : treeWide(repo) === true
1850
2039
  const seen = {}
1851
2040
  const out = []
1852
2041
  const add = function (path) {
@@ -1860,8 +2049,10 @@ textarea.dsh-git-input{resize:vertical}
1860
2049
  for (let k = 0; k < entries.length; k += 1) {
1861
2050
  const path = entryPath(entries[k])
1862
2051
  if (path.length === 0) continue
1863
- const bare = path.slice(-1) === '/' ? path.slice(0, -1) : path
2052
+ const collapsed = path.slice(-1) === '/'
2053
+ const bare = collapsed ? path.slice(0, -1) : path
1864
2054
  add(bare)
2055
+ if (collapsed === true || wide !== true) continue
1865
2056
  const cut = bare.lastIndexOf('/')
1866
2057
  if (cut > 0) add(bare.slice(0, cut))
1867
2058
  }
@@ -1883,14 +2074,17 @@ textarea.dsh-git-input{resize:vertical}
1883
2074
  return byPath[path]
1884
2075
  }
1885
2076
  const list = function (value) { return Array.isArray(value) ? value : [] }
2077
+ /* 四条列表都走 `entryPath`:git 那边这三种形状都可能出现(对象最常,裸字符串也
2078
+ 合法),读 `entry.path` 会把裸字符串那一条**整条丢掉** —— 列表里少一行,而
2079
+ 「有几个改动」那个数字(mergeChanges 的长度)也跟着少一个。 */
1886
2080
  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) })
2081
+ for (let i = 0; i < staged.length; i += 1) put(entryPath(staged[i]), { staged: true, indexCode: text(staged[i].code) })
1888
2082
  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) })
2083
+ for (let i = 0; i < unstaged.length; i += 1) put(entryPath(unstaged[i]), { workCode: text(unstaged[i].code) })
1890
2084
  const untracked = list(work.untracked)
1891
2085
  for (let i = 0; i < untracked.length; i += 1) put(entryPath(untracked[i]), { workCode: '??', untracked: true })
1892
2086
  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 })
2087
+ for (let i = 0; i < unmerged.length; i += 1) put(entryPath(unmerged[i]), { workCode: text(unmerged[i].code), conflict: true })
1894
2088
  const out = []
1895
2089
  for (let i = 0; i < order.length; i += 1) {
1896
2090
  const entry = byPath[order[i]]
@@ -1957,9 +2151,11 @@ textarea.dsh-git-input{resize:vertical}
1957
2151
  const work = props.work
1958
2152
  if (work == null) return h('div', { className: 'dsh-git-pane dsh-git-dim' }, '正在读取工作区…')
1959
2153
  if (work.ok !== true) {
1960
- const reason = work.error === 'not-a-repository'
1961
- ? ('不是 git 仓库:' + text(work.repo))
1962
- : '无法读取工作区状态'
2154
+ const reason = work.noGit === true
2155
+ ? ('找不到 git' + text(work.repo))
2156
+ : work.error === 'not-a-repository'
2157
+ ? ('不是 git 仓库:' + text(work.repo))
2158
+ : '无法读取工作区状态'
1963
2159
  return h('div', { className: 'dsh-git-pane dsh-git-error' }, reason)
1964
2160
  }
1965
2161
 
@@ -2262,6 +2458,13 @@ textarea.dsh-git-input{resize:vertical}
2262
2458
 
2263
2459
  const side = h('div', { className: 'dsh-git-commitpane' },
2264
2460
  h('div', { className: 'dsh-git-group-title' }, '提交信息'),
2461
+ /* 先说出来,而不是等读者写完提交信息再被 git 拒一次。两条路都留着:设置页里
2462
+ 能填的那个地方(面板里点得到),和在终端里跑的两条命令(面板不一定开着)。 */
2463
+ props.work.needsIdentity === true
2464
+ ? h('div', { key: 'ident', className: 'dsh-git-hint dsh-git-warn' },
2465
+ '这台机器还没配 git 提交身份,提交会被 git 拒绝。设置页「dsh-git-idea配置 → 提交身份」里能填,'
2466
+ + '或在终端里跑:git config --global user.name "你的名字"、git config --global user.email "你的邮箱"。')
2467
+ : null,
2265
2468
  clearable('msg', h('textarea', {
2266
2469
  className: 'dsh-git-input',
2267
2470
  rows: 6,
@@ -2569,6 +2772,16 @@ textarea.dsh-git-input{resize:vertical}
2569
2772
  /* 路径已经确定,只是这里没有仓库:没有要解释的规则,也没有要填的东西。
2570
2773
  不劝人换目录,也不让人把已经显示在上面的路径再抄一遍。 */
2571
2774
  'not-a-repo': { title: '这个目录不是 Git 仓库', hint: '', editable: false },
2775
+ /* 目录是对的,机器上少了东西:这个页面不能改路径,也不能初始化 —— 两件事
2776
+ 都救不了这个状态,而 `git init` 只会再失败一次。留一个「打开这个目录」
2777
+ 当作装好 git 之后的重试。 */
2778
+ 'no-git': {
2779
+ title: '这台机器上找不到 git',
2780
+ hint: '上面这个目录本身是仓库,但面板读它、改它都要调用 git。'
2781
+ + '装上 git,或让它出现在 dsh 进程的 PATH 里,再点一次「打开这个目录」。',
2782
+ editable: false,
2783
+ init: false,
2784
+ },
2572
2785
  'git-error': { title: 'git 命令执行失败', hint: '目录存在,但 git 没能读取它。下方是 git 的原话。' },
2573
2786
  }
2574
2787
 
@@ -2588,6 +2801,9 @@ textarea.dsh-git-input{resize:vertical}
2588
2801
  /* 只有「路径还没定」或「这个路径有问题」时才需要人改路径。
2589
2802
  路径本身没错、只是这里没有仓库时,上面那行已经说清是哪个目录了。 */
2590
2803
  const editable = info.editable !== false
2804
+ /* 初始化是「这里还没有仓库」的出路。没有 git 的时候它不是出路,是同一个
2805
+ 失败再演一次。 */
2806
+ const canInit = info.init !== false
2591
2807
  const target = draft.trim()
2592
2808
 
2593
2809
  const open = function () {
@@ -2638,11 +2854,11 @@ textarea.dsh-git-input{resize:vertical}
2638
2854
  disabled: busy || target.length === 0,
2639
2855
  onClick: doInit,
2640
2856
  }, busy ? '正在初始化…' : '确认初始化(会写入 .git)')
2641
- : h('button', {
2857
+ : (canInit ? h('button', {
2642
2858
  type: 'button', className: 'dsh-git-btn',
2643
2859
  disabled: busy || target.length === 0,
2644
2860
  onClick: function () { setArmed(true); setProblem(null) },
2645
- }, '在此初始化仓库'),
2861
+ }, '在此初始化仓库') : null),
2646
2862
  armed ? h('button', {
2647
2863
  type: 'button', className: 'dsh-git-btn',
2648
2864
  disabled: busy,
@@ -2659,8 +2875,35 @@ textarea.dsh-git-input{resize:vertical}
2659
2875
  something in it rather than picking one and showing nothing. */
2660
2876
  function commandDetail(result) {
2661
2877
  if (result == null) return ''
2878
+ /* 和下面沙箱那条同一类:失败的原因不在仓库里,而在机器上。这次 git 一个字
2879
+ 都没说 —— 它根本没被启动 —— 所以这里给整句话,不留 bash 的原话:原话是
2880
+ `bash: git: command not found`,而读者已经从上面那行知道这件事了。 */
2881
+ if (result.noGit === true) {
2882
+ return '这台机器上找不到 git:面板读它、改它都要调用 git。'
2883
+ + '装上 git,或让它出现在 dsh 进程的 PATH 里,再试一次。'
2884
+ }
2662
2885
  const err = text(result.stderr).replace(/\s+$/, '')
2663
2886
  const detail = err.length > 0 ? err.slice(0, 400) : text(result.stdout).replace(/\s+$/, '').slice(0, 400)
2887
+ /* git 在这件事上说八行,其中七行是建议("Run git config --global ..."),最后
2888
+ 一行才是拒绝本身。这里说的是同一件事,但先说面板里能点的那个地方(设置页的
2889
+ 提交身份),再给能照抄的命令 —— 两条路都留着,因为面板并不总是开着的。 */
2890
+ if (result.needsIdentity === true) {
2891
+ const lines = err.length > 0 ? err.split('\n') : []
2892
+ let last = ''
2893
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
2894
+ if (lines[i].trim().length > 0) { last = lines[i].trim(); break }
2895
+ }
2896
+ const why = 'git 不知道这次提交该署谁的名字,所以把它拒了 —— 作者身份写在 git 的配置里,'
2897
+ + '不在这个仓库里。设置页「dsh-git-idea配置 → 提交身份」里可以填,'
2898
+ + '或者在终端里跑一遍:\n'
2899
+ + ' git config --global user.name "你的名字"\n'
2900
+ + ' git config --global user.email "你的邮箱"\n'
2901
+ + '不加 --global 只对这个仓库生效。'
2902
+ /* git 的原话照旧留在下面一行:身份缺失是这次提交过不去的一道坎,但不一定是
2903
+ 唯一一道 —— 一个失败的钩子、一次没解决的冲突各自另有话说,把那句话丢掉就是
2904
+ 同一类误诊("这台机器上没有 git" 曾经也这样盖掉过真正的答案)。 */
2905
+ return last.length > 0 ? why + '\n' + last : why
2906
+ }
2664
2907
  /* git says "Unable to create ... .git/index.lock: Permission denied", which
2665
2908
  reads as a broken repository. It is the file sandbox refusing the write,
2666
2909
  and the reader can act on that (widen the session's file policy, or move
@@ -3414,8 +3657,14 @@ textarea.dsh-git-input{resize:vertical}
3414
3657
  h('span', { key: 'n', className: 'dsh-git-bs-count' }, String(group.rows.length))))
3415
3658
  if (shut) continue
3416
3659
  if (group.rows.length === 0) {
3417
- items.push(h('div', { key: 'g:' + group.id + ':none', className: 'dsh-git-bs-empty' },
3418
- needle.length > 0 ? '没有匹配的分支' : '这个仓库还没有本地分支'))
3660
+ /* 一个本地分支都没有有两种:真的没有,和「当前这个分支还没有第一个提交」
3661
+ —— 后者嘴里得说出它叫什么,不然 chip 上写着 main,卡片却说没有分支。 */
3662
+ const empty = needle.length > 0
3663
+ ? '没有匹配的分支'
3664
+ : (data != null && data.unborn === true && text(data.current).length > 0
3665
+ ? '当前在 ' + text(data.current) + ',还没有第一个提交'
3666
+ : '这个仓库还没有本地分支')
3667
+ items.push(h('div', { key: 'g:' + group.id + ':none', className: 'dsh-git-bs-empty' }, empty))
3419
3668
  continue
3420
3669
  }
3421
3670
  for (let r = 0; r < group.rows.length; r += 1) {
@@ -3450,7 +3699,10 @@ textarea.dsh-git-input{resize:vertical}
3450
3699
  onClick: function () { setStash(true); choose(pending, true) },
3451
3700
  }, '先暂存本地改动,再切到 ' + pending))
3452
3701
  }
3453
- if (props.dirty > 0) {
3702
+ /* 还没有第一个提交的仓库里 `git stash` 必然失败("You do not have the initial
3703
+ commit yet"),所以这一个勾选框不能出现 —— 「切完自动恢复」在这里是一句
3704
+ 兑现不了的承诺。改动本身不会丢:切分支时 git 会自己拒绝或带过去。 */
3705
+ if (props.dirty > 0 && (data == null || data.unborn !== true)) {
3454
3706
  foot.push(h('label', {
3455
3707
  key: 'stash', className: 'dsh-git-bs-check',
3456
3708
  title: '把本地改动 stash 起来,切过去之后再自动 pop 回来',
@@ -3633,20 +3885,25 @@ textarea.dsh-git-input{resize:vertical}
3633
3885
  the 36 paths the changes tree was showing. They are the same answer for
3634
3886
  everything that is on screen.
3635
3887
 
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() } })
3888
+ So the whole tree is read on its own clock (opening a repository the
3889
+ plugin has not read yet, the refresh button, and once every fullReadGapMs
3890
+ while the changes tab is on screen), and everything the reader does
3891
+ between those — a tick, an edit to a file that is already listed, a stage,
3892
+ a commit — is confirmed by a read of the paths involved. A file that was
3893
+ clean and is now modified is the one thing a pathspec read cannot see; the
3894
+ whole-tree read is what catches it, which is why it still happens on a
3895
+ clock.
3896
+
3897
+ The snapshot, the cost of the last whole-tree read and whether the next
3898
+ read has to be a whole one live in one object rather than in the state
3899
+ alone: an effect keeps the render it was created in, so a callback
3900
+ registered once would otherwise read a stale `status` for as long as its
3901
+ dependencies do not move. */
3902
+ const [panelBox] = React.useState(function () { return { status: null, needFull: false, repo: '', costMs: 0, lastFull: false, mutations: Promise.resolve() } })
3649
3903
  panelBox.status = status
3904
+ /* 一次全树读占住整条通道多久 —— 下一次该隔多久再量一遍由它决定(fullReadGapMs)。
3905
+ 进 state 的原因只有一个:间隔变了要把那个时钟重新起一遍。 */
3906
+ const [treeCost, setTreeCost] = React.useState(0)
3650
3907
 
3651
3908
  /* work is the only truth about whether this path is a usable repository.
3652
3909
  Everything that reads refs, history or the index is gated on it, so a
@@ -3655,6 +3912,13 @@ textarea.dsh-git-input{resize:vertical}
3655
3912
  const repoOk = work != null && work.ok === true
3656
3913
  const needsSetup = work != null && work.ok !== true
3657
3914
 
3915
+ /* 读数按答复里的仓库记,不是按这次请求写的那个:请求里常常只有会话 id(Host 才知道
3916
+ 这个会话的工作区在哪)。收窄与否也是按这个路径记的。 */
3917
+ const treeKey = function (status, fallback) {
3918
+ const resolved = status != null ? text(status.repo) : ''
3919
+ return resolved.length > 0 ? resolved : fallback
3920
+ }
3921
+
3658
3922
  const base = function (repo) {
3659
3923
  const request = { sessionId: sessionId }
3660
3924
  if (repo.length > 0) request.repo = repo
@@ -3679,47 +3943,70 @@ textarea.dsh-git-input{resize:vertical}
3679
3943
  const full = paths == null || paths.length === 0
3680
3944
  const work = Object.assign({}, request)
3681
3945
  if (!full) work.paths = paths
3946
+ const started = Date.now()
3947
+ /* 全树那一次在飞的时候,全局那份读数就是「还没核对过」:chip 这时说的是
3948
+ 「正在核对」,而不是继续报一个它没验证过的数字。 */
3949
+ /* 读数按**答复里的仓库**记,不是按这次请求写的那个:请求里常常只有会话 id
3950
+ (Host 才知道这个会话的工作区在哪),而读数要能被 chip 按路径查到。 */
3951
+ const resolved = text(data.repo).length > 0 ? text(data.repo) : asked
3952
+ const finished = treeCountReadStart(resolved)
3682
3953
  callHost('git/panel', work).then(function (reply) {
3954
+ finished()
3683
3955
  /* A read that came back after the path changed is not this path's
3684
3956
  answer; the effect below will load the new one anyway. */
3685
3957
  if (asked !== appliedRepo && asked.length > 0) return
3686
3958
  if (epoch !== repoEpoch(asked, sessionId)) return
3959
+ panelBox.lastFull = full
3960
+ /* 只问几条路径的那种读有多贵:贵到一定程度就说明父目录扫进了大树,这个仓库
3961
+ 从此收窄(见 pathsOfInterest)。 */
3962
+ if (full !== true) pathsReadSpent(resolved, Math.round(Date.now() - started))
3687
3963
  if (full) {
3688
- panelBox.fullAt = Date.now()
3964
+ /* 全树那一次花了多久,读数记下来。 */
3965
+ const cost = Math.round(Date.now() - started)
3966
+ panelBox.needFull = false
3967
+ panelBox.costMs = cost
3968
+ setTreeCost(cost)
3689
3969
  setStatus(reply != null && reply.ok === true ? reply : null)
3690
3970
  return
3691
3971
  }
3692
3972
  /* A partial answer says nothing about the rest of the tree: it is
3693
3973
  folded into the snapshot on screen, never put in its place. */
3694
3974
  setStatus(function (previous) { return mergePanelStatus(previous, reply) })
3695
- }).catch(function () { setStatus(null) })
3975
+ }).catch(function () { finished(); setStatus(null) })
3696
3976
  }).catch(function (failure) {
3697
3977
  setError(failureText(failure))
3698
3978
  })
3699
3979
  }
3700
3980
 
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
3981
+ /* 快照能站多久。便宜的签名和路径读合起来覆盖了「已经显示着的那些路径」,它们
3982
+ 看不见的只有一件事:**本来干净、刚刚被改**的文件(或者一个干净目录里新出现的
3983
+ 文件)。那一次全树读在这台机器上是 5–8s,而且占住整条通道,所以它挂在时钟上,
3984
+ 而且间隔按上一次实测的代价来定(fullReadGapMs:至少 30s,最多 5 分钟)。 */
3985
+ /* 量完就写进全局那一份读数(见 10-state.js):面板是唯一既读全树、又读屏上那些
3986
+ 路径的地方,chip 上那个数字就来自这里 —— 两块屏幕于是不会各说各话。乐观的
3987
+ tick(点击就地改的那份快照)也走这里,所以 chip 上的数字跟着手指走。 */
3988
+ React.useEffect(function () {
3989
+ if (status == null || status.ok !== true) return
3990
+ /* 按答复里的仓库记:没应用过路径时请求里只有会话 id,而这份读数要能被 chip
3991
+ 按它查到的那个路径找到(Host 在答复里把会话的工作区解析成了路径)。 */
3992
+ publishTreeRead(treeKey(status, appliedRepo), status, panelBox.lastFull === true, panelBox.costMs)
3993
+ }, [status, appliedRepo])
3707
3994
 
3708
3995
  /* 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. */
3996
+ tree: whenever there is a snapshot to fold it into. */
3711
3997
  const readChanges = function () {
3712
3998
  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)
3999
+ /* 屏上有快照,问的就是屏上那些路径(0.2s);换仓库、或明确要求整棵树时才重读
4000
+ 全部。一次「提交」之后的读因此也是 0.3s,而不是 8–10s。 */
4001
+ const whole = panelBox.needFull === true || snapshot == null || snapshot.ok !== true
4002
+ loadWork(appliedRepo, whole ? null : pathsOfInterest(snapshot, treeKey(snapshot, appliedRepo)))
3716
4003
  }
3717
4004
 
3718
4005
  /* Read the whole tree again, now. The flush is what makes it a read rather
3719
4006
  than a repaint of the Host's cache; the refresh button and the clock both
3720
4007
  go through here. */
3721
4008
  const reloadChanges = function () {
3722
- panelBox.fullAt = 0
4009
+ panelBox.needFull = true
3723
4010
  callHost('git/flush', base(appliedRepo)).then(bump, bump)
3724
4011
  }
3725
4012
 
@@ -3741,8 +4028,8 @@ textarea.dsh-git-input{resize:vertical}
3741
4028
  setAppliedRepo(next)
3742
4029
  setStatus(null)
3743
4030
  panelBox.status = null
3744
- panelBox.fullAt = 0
3745
- panelBox.scope = ''
4031
+ panelBox.needFull = true
4032
+ panelBox.repo = ''
3746
4033
  setMaxCount(PAGE_COMMITS)
3747
4034
  resetFilters()
3748
4035
  setSelected(null)
@@ -3784,6 +4071,13 @@ textarea.dsh-git-input{resize:vertical}
3784
4071
  reloadChanges()
3785
4072
  }
3786
4073
 
4074
+ /* 哪些操作能把整棵树改掉:切分支、pull、以及 merge/cherry-pick/revert(开始、
4075
+ 继续、跳过、中止都算)会重写工作区,它们的答案必须是一次全树读。别的(提交、
4076
+ 暂存、取消暂存、fetch、push、tag、建/删分支)只动索引或引用 —— 那里用屏上那些
4077
+ 路径确认就够了。真机上量到的是:一次全树读 8–10s,而且这期间整条 RPC 通道都被
4078
+ 它占着,为一次「提交」让读者等十秒、十秒内点什么都要排队,是没有道理的。 */
4079
+ const REWRITES_TREE = ['git/checkout', 'git/pull', 'git/sequence', 'git/init']
4080
+
3787
4081
  /* One path for every panel operation. A failed operation still re-reads,
3788
4082
  because the failures that matter — a conflicting cherry-pick, merge or
3789
4083
  revert — leave the repository in a different state than they found it. */
@@ -3793,10 +4087,7 @@ textarea.dsh-git-input{resize:vertical}
3793
4087
  setArmed('')
3794
4088
  setError(null)
3795
4089
  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
4090
+ panelBox.needFull = REWRITES_TREE.indexOf(method) >= 0
3800
4091
  const request = base(appliedRepo)
3801
4092
  if (payload != null) Object.assign(request, payload)
3802
4093
  rpc(method, request).then(function () {
@@ -3805,7 +4096,18 @@ textarea.dsh-git-input{resize:vertical}
3805
4096
  }, function (failure) {
3806
4097
  setBusy(false)
3807
4098
  setError(failureText(failure))
3808
- if (method === 'git/push' && failureText(failure).indexOf('upstream') >= 0) setNeedsUpstream(true)
4099
+ if (method === 'git/push' && failureText(failure).indexOf('upstream') >= 0) {
4100
+ /* 「这个分支还没有上游」是一次可以自己走完的失败:设置里开了这一条时,
4101
+ 就把横幅本来要问的那一步直接做掉(同一个请求,带上 setUpstream)。问过
4102
+ 的那一次不再自动重试 —— 它要是也失败,横幅照旧出现,读者还有得按。 */
4103
+ const retry = payload == null || payload.setUpstream !== true
4104
+ const remote = refs != null && refs.ok === true && refs.remote.length > 0 ? refs.remote[0].name : ''
4105
+ if (plugin.pushSetUpstream === true && retry && remote.length > 0 && currentName.length > 0) {
4106
+ runOp('git/push', { setUpstream: true, remote: remote, branch: currentName })
4107
+ return
4108
+ }
4109
+ setNeedsUpstream(true)
4110
+ }
3809
4111
  bump()
3810
4112
  })
3811
4113
  }
@@ -3894,14 +4196,12 @@ textarea.dsh-git-input{resize:vertical}
3894
4196
 
3895
4197
  React.useEffect(function () {
3896
4198
  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
4199
+ /* 换了仓库:屏幕上那份快照是别人的,只能整棵树重读一次。同一个仓库上的一次
4200
+ bump(仓库在屏幕底下动过了)问的是屏上那些路径 —— readChanges。标签页
4201
+ 不进这个判据:从「历史」切到「变更」不改变工作区是什么样。 */
4202
+ if (panelBox.repo !== appliedRepo) {
4203
+ panelBox.repo = appliedRepo
4204
+ panelBox.needFull = true
3905
4205
  }
3906
4206
  readChanges()
3907
4207
  return undefined
@@ -3917,8 +4217,8 @@ textarea.dsh-git-input{resize:vertical}
3917
4217
  if (!repoOk || props.ready !== true || props.active !== true || tab !== 'changes') return undefined
3918
4218
  const timer = ctx.get('timer')
3919
4219
  if (timer === undefined) return undefined
3920
- return timer.interval(function () { reloadChanges() }, FULL_STATUS_MS)
3921
- }, [appliedRepo, repoOk, props.active, props.ready, tab])
4220
+ return timer.interval(function () { reloadChanges() }, fullReadGapMs(treeCost))
4221
+ }, [appliedRepo, repoOk, props.active, props.ready, tab, treeCost])
3922
4222
 
3923
4223
  React.useEffect(function () {
3924
4224
  if (!repoOk || props.ready !== true || tab !== 'log') return undefined
@@ -3999,7 +4299,7 @@ textarea.dsh-git-input{resize:vertical}
3999
4299
  entry to write into. */
4000
4300
  React.useEffect(function () {
4001
4301
  if (status == null || status.ok !== true) return
4002
- setWatchPaths(appliedRepo, sessionId, pathsOfInterest(status))
4302
+ setWatchPaths(appliedRepo, sessionId, pathsOfInterest(status, treeKey(status, appliedRepo)))
4003
4303
  }, [status, appliedRepo, sessionId])
4004
4304
 
4005
4305
  /* One identity for as long as the repository does not change: the commit
@@ -4073,11 +4373,13 @@ textarea.dsh-git-input{resize:vertical}
4073
4373
  const request = base(appliedRepo)
4074
4374
  request.paths = paths
4075
4375
  const epoch = repoEpoch(appliedRepo, sessionId)
4376
+ const finished = treeCountReadStart(appliedRepo)
4076
4377
  callHost('git/panel', request).then(function (reply) {
4378
+ finished()
4077
4379
  if (epoch !== repoEpoch(appliedRepo, sessionId)) return
4078
4380
  if (reply == null || reply.ok !== true) return
4079
4381
  setStatus(function (previous) { return mergePanelStatus(previous, reply) })
4080
- }).catch(function () {})
4382
+ }, function () { finished() })
4081
4383
  }
4082
4384
 
4083
4385
  /* ── one mutation after another, and none of them dims the panel ──
@@ -4161,9 +4463,10 @@ textarea.dsh-git-input{resize:vertical}
4161
4463
  setBusy(false)
4162
4464
  setError(null)
4163
4465
  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()
4466
+ /* 提交动的是索引和引用,屏上那些路径的读(0.3s)就是这次点击的答案:刚才
4467
+ 提交掉的那几个文件会立刻从列表里消失。整棵树留给时钟和 ⟳。 */
4468
+ panelBox.needFull = false
4469
+ bump()
4167
4470
  }, function (failure) {
4168
4471
  setBusy(false)
4169
4472
  setError(failureText(failure))
@@ -4702,7 +5005,9 @@ textarea.dsh-git-input{resize:vertical}
4702
5005
  h('div', { key: 'gne', className: 'dsh-git-grip dsh-git-grip-ne', title: '拖动调整宽高', onPointerDown: startDrag('ne') }),
4703
5006
  header,
4704
5007
  banner,
4705
- error !== null ? h('div', { className: 'dsh-git-error', style: { padding: '4px 10px' } }, error) : null,
5008
+ /* pre-wrap:这条里出现换行的地方都是「那就是两条命令」,折成一行读起来是
5009
+ 一句话里塞了两条命令。git 自己的多行原话也顺便能按原样读。 */
5010
+ error !== null ? h('div', { className: 'dsh-git-error', style: { padding: '4px 10px', whiteSpace: 'pre-wrap' } }, error) : null,
4706
5011
  body)
4707
5012
  }
4708
5013
 
@@ -4712,6 +5017,191 @@ textarea.dsh-git-input{resize:vertical}
4712
5017
  would be a workaround around its own tree, not a feature. */
4713
5018
  const SETTINGS_NAV_LABEL = 'dsh-git-idea配置'
4714
5019
 
5020
+ /* ── 提交身份:写在 git 自己的配置里 ──
5021
+
5022
+ 这一组和上面那个插件配置文件不是一回事:`user.name` / `user.email` 是 git 的
5023
+ 设置,写进去以后终端里的 git、IDEA、钩子看到的都是同一个作者。所以这里既读又
5024
+ 写,而且写什么由读者挑:这台机器的所有仓库(`--global`),还是只这一个仓库
5025
+ (`--local`)。
5026
+
5027
+ 面板不替你署名:空着的框一个字节都不写(`git config user.name ''` 正是「empty
5028
+ ident name」那个错误的来路),两个都空就什么也不做并说清楚。 */
5029
+ function GitIdentityGroup() {
5030
+ const sessionId = useLastSession()
5031
+ const [state, setState] = React.useState(null)
5032
+ const [name, setName] = React.useState('')
5033
+ const [email, setEmail] = React.useState('')
5034
+ const [scope, setScope] = React.useState('global')
5035
+ const [busy, setBusy] = React.useState(false)
5036
+ const [note, setNote] = React.useState('')
5037
+ const [problem, setProblem] = React.useState('')
5038
+
5039
+ /* 只把 session id 交给 Host,路径由它自己从会话的工作区解出来 —— 和面板、
5040
+ chip 走的是同一条路,也就落在这个会话的沙箱策略里。 */
5041
+ const request = function () {
5042
+ return sessionId.length > 0 ? { sessionId: sessionId } : {}
5043
+ }
5044
+ const load = function () {
5045
+ callHost('git/identity', request()).then(function (data) {
5046
+ setState(data)
5047
+ setProblem('')
5048
+ /* 预填:先给此刻生效的那一份,没有再给机器上的那一份。读者要改的就是它。 */
5049
+ const effectiveName = text(data.name)
5050
+ const effectiveEmail = text(data.email)
5051
+ setName(effectiveName.length > 0 ? effectiveName : text(data.globalName))
5052
+ setEmail(effectiveEmail.length > 0 ? effectiveEmail : text(data.globalEmail))
5053
+ }, function (failure) { setProblem(failureText(failure)) })
5054
+ }
5055
+ React.useEffect(function () { load() }, [sessionId])
5056
+
5057
+ const save = function () {
5058
+ if (busy) return
5059
+ setBusy(true)
5060
+ setNote('')
5061
+ setProblem('')
5062
+ const payload = { scope: scope, name: name, email: email }
5063
+ if (sessionId.length > 0) payload.sessionId = sessionId
5064
+ callHost('git/identity-save', payload).then(function (result) {
5065
+ setBusy(false)
5066
+ setState(result)
5067
+ setNote(result.scope === 'local'
5068
+ ? ('已写进 ' + text(result.repo) + ' 的 .git/config:' + result.written.join('、'))
5069
+ : ('已写进这台机器的 git 配置:' + result.written.join('、')))
5070
+ }, function (failure) {
5071
+ setBusy(false)
5072
+ setProblem(failureText(failure))
5073
+ })
5074
+ }
5075
+
5076
+ const ready = state != null
5077
+ const missing = ready && state.needsIdentity === true
5078
+ const source = function (value, origin) {
5079
+ const one = text(value)
5080
+ if (one.length === 0) return '没有配'
5081
+ const from = text(origin)
5082
+ return from.length === 0 ? one : (one + '(来自 ' + from + ')')
5083
+ }
5084
+ const toggle = function (next) {
5085
+ return h('label', { className: 'dsh-git-set-check' },
5086
+ h('input', {
5087
+ type: 'radio', checked: scope === next, name: 'dsh-git-ident-scope',
5088
+ onChange: function () { setScope(next) },
5089
+ }),
5090
+ h('span', null, next === 'global' ? '这台机器的所有仓库(--global)' : '只对这个仓库(--local)'))
5091
+ }
5092
+
5093
+ return h('div', null,
5094
+ h('div', { className: 'dsh-git-set-group' }, '提交身份(写在 git 自己的配置里)'),
5095
+ h('div', { className: 'dsh-git-set-hint' },
5096
+ 'git 不知道作者是谁时会拒绝提交,而这台机器上终端里的 git 也用同一份配置。面板空着的框一个字节都不写。'),
5097
+
5098
+ h('div', { className: 'dsh-git-set-row' },
5099
+ h('span', { className: 'dsh-git-set-label' }, '此刻生效'),
5100
+ h('span', { className: missing === true ? 'dsh-git-set-hint dsh-git-warn' : 'dsh-git-set-hint' },
5101
+ ready !== true ? '正在读取…'
5102
+ : (missing === true
5103
+ ? '还缺:' + (state.nameMissing === true ? '名字' : '邮箱') + ' —— 提交会被 git 拒绝'
5104
+ : (source(state.name, state.nameOrigin) + ' · ' + source(state.email, state.emailOrigin))))),
5105
+
5106
+ h('div', { className: 'dsh-git-set-row' },
5107
+ h('span', { className: 'dsh-git-set-label' }, '名字'),
5108
+ h('input', {
5109
+ className: 'dsh-git-input dsh-git-set-input',
5110
+ placeholder: '提交里显示的名字',
5111
+ value: name,
5112
+ onChange: function (event) { setName(event.target.value) },
5113
+ })),
5114
+
5115
+ h('div', { className: 'dsh-git-set-row' },
5116
+ h('span', { className: 'dsh-git-set-label' }, '邮箱'),
5117
+ h('input', {
5118
+ className: 'dsh-git-input dsh-git-set-input',
5119
+ placeholder: 'you@example.com',
5120
+ value: email,
5121
+ onChange: function (event) { setEmail(event.target.value) },
5122
+ })),
5123
+
5124
+ h('div', { className: 'dsh-git-set-row' }, h('span', { className: 'dsh-git-set-label' }, '写进哪里'), toggle('global')),
5125
+ h('div', { className: 'dsh-git-set-row' }, h('span', { className: 'dsh-git-set-label' }, ''), toggle('local')),
5126
+ h('div', { className: 'dsh-git-set-row' },
5127
+ h('span', { className: 'dsh-git-set-label' }, ''),
5128
+ h('span', { className: 'dsh-git-set-hint' },
5129
+ text(state != null ? state.repo : '').length > 0
5130
+ ? ('这个仓库 = ' + state.repo)
5131
+ : '这个页面还不知道是哪个会话的仓库 —— 先打开一次面板(或输入框旁的 Git 按钮),或只写全局那一份')),
5132
+
5133
+ h('div', { className: 'dsh-git-set-row' },
5134
+ h('button', {
5135
+ type: 'button', className: 'dsh-git-btn dsh-git-primary',
5136
+ disabled: busy || (scope === 'local' && text(state != null ? state.repo : '').length === 0),
5137
+ onClick: save,
5138
+ }, busy ? '写入中…' : '写入 git 配置'),
5139
+ h('span', { className: 'dsh-git-set-hint' }, '写进去就是以后所有提交的作者,别的工具也看得到')),
5140
+
5141
+ note.length > 0 ? h('div', { className: 'dsh-git-set-row dsh-git-set-hint' }, note) : null,
5142
+ problem.length > 0 ? h('div', { className: 'dsh-git-set-row dsh-git-error' }, problem) : null)
5143
+ }
5144
+
5145
+ /* ── git 位置:这台机器上的哪个 git ──
5146
+
5147
+ 每一行命令都以同一个词开头,而那个词默认来自部署的 PATH。装在不在这条 PATH 上的
5148
+ 地方(Homebrew 前缀、IDE 自带的 git、nix profile)时,面板以前只会说「这台机器
5149
+ 上找不到 git」—— 既是错的,也没给出下一步。所以它是个设置。 */
5150
+ function GitToolchainGroup() {
5151
+ const plugin = usePluginConfig()
5152
+ const committed = usePluginConfigCommitted()
5153
+ const [draftPath, setDraftPath] = React.useState(plugin.gitPath)
5154
+ const [tool, setTool] = React.useState(null)
5155
+ React.useEffect(function () { setDraftPath(plugin.gitPath) }, [plugin.gitPath])
5156
+
5157
+ const probe = function () {
5158
+ callHost('git/toolchain', {}).then(function (data) { setTool(data) }, function (failure) {
5159
+ setTool({ ok: false, path: '', version: '', found: false, reason: 'probe-failed', error: failureText(failure) })
5160
+ })
5161
+ }
5162
+ /* 问的时机是**Host 确认过之后**,不是敲键的时候:草稿是即时的,而
5163
+ `savePluginConfig` 有 400ms 去抖,落盘之后 Host 才回话。挂在草稿上问,
5164
+ 问到的是上一份配置 —— 真机上量到的就是界面永远慢一步(写入坏路径之后那一行
5165
+ 还说「来自 PATH」,要等下一次改动才改口);挂在每次按键上还会把一次询问变成
5166
+ 每个字符一次。`committed` 只在答复带着配置回来时动。 */
5167
+ React.useEffect(function () { probe() }, [committed])
5168
+
5169
+ const commitPath = function (value) {
5170
+ setDraftPath(value)
5171
+ const next = Object.assign({}, plugin)
5172
+ next.gitPath = value
5173
+ savePluginConfig(next)
5174
+ }
5175
+ const found = tool != null && tool.found === true
5176
+ const reason = tool == null ? '' : text(tool.reason)
5177
+ const verdict = tool == null
5178
+ ? '正在检查…'
5179
+ : (found
5180
+ ? ('现在用的是 ' + tool.path + (tool.fromPath === true ? '(来自 PATH)' : '(设置里写的就是它)')
5181
+ + ' · ' + text(tool.version))
5182
+ : (reason === 'configured-missing'
5183
+ ? '设置里写的这个路径不可用:它不存在,或者不是可执行文件。面板里的每条命令都会失败。'
5184
+ : '这台机器的 PATH 上没有 git。装上它,或者在下面写一个绝对路径。'))
5185
+
5186
+ return h('div', null,
5187
+ h('div', { className: 'dsh-git-set-group' }, 'git 位置'),
5188
+ h('div', { className: 'dsh-git-set-row' },
5189
+ h('span', { className: 'dsh-git-set-label' }, '可执行文件'),
5190
+ h('input', {
5191
+ className: 'dsh-git-input dsh-git-set-input',
5192
+ placeholder: '留空 = 用 PATH 里的 git',
5193
+ value: draftPath,
5194
+ onChange: function (event) { commitPath(event.target.value) },
5195
+ })),
5196
+ h('div', { className: 'dsh-git-set-row' },
5197
+ h('span', { className: 'dsh-git-set-label' }, ''),
5198
+ h('span', { className: found === true ? 'dsh-git-set-hint' : 'dsh-git-set-hint dsh-git-warn' }, verdict)),
5199
+ h('div', { className: 'dsh-git-set-row' },
5200
+ h('span', { className: 'dsh-git-set-label' }, ''),
5201
+ h('button', { type: 'button', className: 'dsh-git-btn', onClick: probe }, '再检查一次'),
5202
+ h('span', { className: 'dsh-git-set-hint' }, '面板读、写、初始化用的都是这一个')))
5203
+ }
5204
+
4715
5205
  function GitSettingsSection(props) {
4716
5206
  const settings = useGitSettings()
4717
5207
  const [draft, setDraft] = React.useState(settings)
@@ -4777,10 +5267,49 @@ textarea.dsh-git-input{resize:vertical}
4777
5267
  }),
4778
5268
  h('span', null, 'cherry-pick 时记录来源(-x)'))),
4779
5269
 
5270
+ h('div', { className: 'dsh-git-set-group' }, '远程同步'),
5271
+ h('div', { className: 'dsh-git-set-hint' },
5272
+ '这三条是面板给 git 的实参,不是 git 自己的设置:下面没勾的,就是 git 原本的行为(`push.default`、`pull.rebase` 照旧生效)。'),
5273
+
5274
+ h('div', { className: 'dsh-git-set-row' },
5275
+ h('label', { className: 'dsh-git-set-check' },
5276
+ h('input', {
5277
+ type: 'checkbox', checked: pdraft.fetchPrune !== false,
5278
+ onChange: function (event) { setPlugin('fetchPrune', event.target.checked) },
5279
+ }),
5280
+ h('span', null, 'fetch 时删掉远端已经删了的远程分支(--prune)'))),
5281
+
5282
+ h('div', { className: 'dsh-git-set-row' },
5283
+ h('label', { className: 'dsh-git-set-check' },
5284
+ h('input', {
5285
+ type: 'checkbox', checked: pdraft.pullRebase === true,
5286
+ onChange: function (event) { setPlugin('pullRebase', event.target.checked) },
5287
+ }),
5288
+ h('span', null, 'pull 用 rebase 而不是 merge(--rebase)'))),
5289
+
5290
+ h('div', { className: 'dsh-git-set-row' },
5291
+ h('label', { className: 'dsh-git-set-check' },
5292
+ h('input', {
5293
+ type: 'checkbox', checked: pdraft.pushSetUpstream === true,
5294
+ onChange: function (event) { setPlugin('pushSetUpstream', event.target.checked) },
5295
+ }),
5296
+ h('span', null, '推送没有上游的分支时直接推上去并设上游(push -u)'))),
5297
+
5298
+ h('div', { className: 'dsh-git-set-row' },
5299
+ h('span', { className: 'dsh-git-set-label' }, '也就是'),
5300
+ h('span', { className: 'dsh-git-set-hint' },
5301
+ 'git fetch --all' + (pdraft.fetchPrune !== false ? ' --prune' : '')
5302
+ + ' · git pull' + (pdraft.pullRebase === true ? ' --rebase' : '')
5303
+ + ' · ' + (pdraft.pushSetUpstream === true ? 'git push -u <remote> <branch>(没有上游时)' : 'git push(没有上游时由面板问一句)'))),
5304
+
5305
+ h(GitToolchainGroup),
5306
+
4780
5307
  pluginConfigError.length > 0
4781
5308
  ? h('div', { className: 'dsh-git-set-row dsh-git-error' }, '保存失败:' + pluginConfigError)
4782
5309
  : null,
4783
5310
 
5311
+ h(GitIdentityGroup),
5312
+
4784
5313
  h('div', { className: 'dsh-git-set-group' }, '本浏览器'),
4785
5314
  h('div', { className: 'dsh-git-set-hint' }, '这些只是外观和使用节奏,换浏览器各管各的。'),
4786
5315
 
@@ -4854,24 +5383,37 @@ textarea.dsh-git-input{resize:vertical}
4854
5383
  }, '本浏览器全部恢复默认')))
4855
5384
  }
4856
5385
 
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 = {}
5386
+ /* ── 输入框旁边那个 chip ──
5387
+
5388
+ 屏幕上那个数字不是这块地方自己量的,它来自全局那一份工作区读数(10-state.js)。
5389
+ 一次全树读在这台机器上 8–10s,而且占住整条通道(一次只跑一个处理函数):每次
5390
+ 醒过来都量一遍,面板那条 0.3s 的路径读就排在它后面 —— 屏幕上就是「面板反应过来了,
5391
+ chip 还没反应过来」。所以这里只做三件事:问身份(0.1–0.4s)、把上次那些脏路径
5392
+ 重新问一次(0.2s,和面板问的是同一个问题,Host 那边只起一个进程),以及在这份
5393
+ 读数确实该完整重来一遍时发一次全树读(压后 2 秒,让这次点击的反馈先走)。 */
5394
+
5395
+ /* 全树读压后多久:屏幕上先有这一帧的反馈,再让那条 8–10s 的读去占通道。 */
5396
+ const COUNT_FULL_DELAY_MS = 2000
4863
5397
 
4864
5398
  function GitChip(props) {
4865
5399
  const isOpen = useOpen()
4866
5400
  const switching = useSwitchingTo()
4867
5401
  const [info, setInfo] = React.useState(function () { return chipLabelFor(props.sessionId) })
4868
5402
  const reloadAt = useDataVersion()
5403
+ /* 谁写了那份读数都要重画:面板量完一次,chip 上的数字跟着变。 */
5404
+ useTreeVersion()
4869
5405
  /* Applying a directory in the panel changes which repository this chip is
4870
5406
  about, and this signal is how the chip hears about it: without the render
4871
5407
  it went on reading — and watching — the workspace it started with. */
4872
5408
  const repoVersion = useRepoApplied()
4873
5409
  const watched = sessionRepo(props.sessionId)
4874
5410
  const sessionId = props.sessionId
5411
+ const repo = info.repo.length > 0 ? info.repo : watched
5412
+ const record = treeRecord(repo)
5413
+ const known = record !== null
5414
+ const pending = record === null ? 0 : record.count
5415
+ /* 正在核对:这份读数该重新完整量一次,或者那一次正在飞(几秒)。 */
5416
+ const due = treeReadDue(repo) === true || treeCountReading(repo) === true
4875
5417
 
4876
5418
  React.useEffect(function () {
4877
5419
  loadSettings(chipNode != null ? chipNode.ownerDocument : null)
@@ -4892,8 +5434,14 @@ textarea.dsh-git-input{resize:vertical}
4892
5434
  return watchRepo(watched, sessionId, bumpData, false)
4893
5435
  }, [watched, repoVersion, sessionId, isOpen])
4894
5436
 
5437
+ /* 这个页面在哪个会话里 —— chip 一直挂在输入框旁边,所以它是把这件事记下来的
5438
+ 那个面(设置页是全局的,自己不知道)。放在 effect 里而不是渲染里:渲染期间
5439
+ 通知订阅者就是渲染期间改别人的 state。 */
5440
+ React.useEffect(function () { rememberSession(sessionId) }, [sessionId])
5441
+
4895
5442
  React.useEffect(function () {
4896
5443
  let alive = true
5444
+ let stopFull = null
4897
5445
  const request = { sessionId: sessionId }
4898
5446
  const mine = watched
4899
5447
  if (mine.length > 0) request.repo = mine
@@ -4907,21 +5455,9 @@ textarea.dsh-git-input{resize:vertical}
4907
5455
  const branch = text(data.branch)
4908
5456
  const detached = data.detached === true
4909
5457
  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
5458
+ /* 数字来自全局那一份读数,不是这一次读算出来的:快读(身份)根本不带工作区,
5459
+ 它的「没有改动」意思是「没问过」。 */
5460
+ const pending = treeCount(repo)
4925
5461
  /* Kept outside React state because the hover card needs the count and
4926
5462
  hangs in a different subtree; a switch offer should not have to
4927
5463
  re-derive it with another read. */
@@ -4931,11 +5467,6 @@ textarea.dsh-git-input{resize:vertical}
4931
5467
  phase: 'repo',
4932
5468
  label: detached ? 'HEAD' : (branch.length > 0 ? branch : 'HEAD'),
4933
5469
  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
5470
  repo: repo,
4940
5471
  reason: '',
4941
5472
  }
@@ -4950,51 +5481,104 @@ textarea.dsh-git-input{resize:vertical}
4950
5481
  setInfo(chipLabels[sessionId])
4951
5482
  }
4952
5483
 
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. */
5484
+ /* 数字怎么来:
5485
+ 1. 这份读数在这个仓库上还没有过 整棵树量一次(不量 chip 上就一个数字都没有);
5486
+ 2. 有过、而且上次那些脏路径还在 只问那些路径(0.2s)。提交之后那几个文件
5487
+ 就是这样立刻消失的,而且和面板屏幕上那份快照是同一个问题;
5488
+ 3. 没有脏路径可以问(上一次量出来是干净的),或者这份读数确实该完整重来一遍
5489
+ (fullAt 太旧)→ 整棵树量一次;已经有数字时压后 2 秒,让这次点击的反馈先走。
5490
+
5491
+ 一次 bump 意味着仓库动过(引用、索引或 HEAD):干净的那份读数这时不能继续当
5492
+ 「现在也干净」用 —— 所以第 3 条也在每次 bump 时成立。 */
5493
+ const refreshCount = function (repoNow) {
5494
+ const current = treeRecord(repoNow)
5495
+ const paths = treeReadPaths(repoNow)
5496
+ if (current !== null && paths.length > 0) {
5497
+ const finished = treeCountReadStart(repoNow)
5498
+ const started = Date.now()
5499
+ callHost('git/panel', Object.assign({ paths: paths }, request)).then(function (reply) {
5500
+ finished()
5501
+ /* 这一次路径读有多贵 —— 贵到一定程度就说明父目录扫进了大树,这个仓库从此
5502
+ 收窄成只问那几条路径本身(见 pathsOfInterest)。 */
5503
+ pathsReadSpent(repoNow, Math.round(Date.now() - started))
5504
+ if (alive !== true || reply == null || reply.ok !== true) return
5505
+ publishTreeRead(repoNow, mergePanelStatus(current.status, reply), false, null)
5506
+ }, function () { finished() })
5507
+ }
5508
+ const nothingToAsk = current === null || paths.length === 0
5509
+ if (treeReadDue(repoNow) !== true && nothingToAsk !== true) return
5510
+ const wholeTree = function () {
5511
+ const started = Date.now()
5512
+ const finished = treeCountReadStart(repoNow)
5513
+ callHost('git/panel', request).then(function (full) {
5514
+ finished()
5515
+ if (alive !== true || full == null || full.ok !== true) return
5516
+ publishTreeRead(repoNow, full, true, Date.now() - started)
5517
+ }, function () { finished() })
5518
+ }
5519
+ /* 没有数字可以报(这个仓库还没量过):现在就得量,8–10s 也认了。上一次量出来
5520
+ 是干净的、或者按间隔该完整重来一遍:那次全树读压后 2 秒,让这次点击的反馈
5521
+ (分支名、面板那条 0.2s 的路径读)先走。 */
5522
+ const defer = current !== null && nothingToAsk !== true
5523
+ if (defer !== true) { wholeTree(); return }
5524
+ const timer = ctx.get('timer')
5525
+ if (timer === undefined) { wholeTree(); return }
5526
+ stopFull = timer.timeout(wholeTree, COUNT_FULL_DELAY_MS)
5527
+ }
5528
+
5529
+ /* The identity read answers in about a fifth of a second on a repository
5530
+ where the full one takes eight, and it carries everything the chip shows
5531
+ except the change count — so the workspace you switched to is named
5532
+ immediately and the badge follows from the shared reading. */
4959
5533
  callHost('git/panel', Object.assign({ quick: true }, request)).then(function (data) {
4960
- if (!alive) return
5534
+ if (alive !== true) return
4961
5535
  apply(data)
4962
- callHost('git/panel', request).then(function (full) {
4963
- if (alive) apply(full)
4964
- }).catch(function () {})
5536
+ const repoNow = data != null && data.ok === true ? text(data.repo) : ''
5537
+ if (repoNow.length === 0) return
5538
+ prefetchBranches(sessionId, repoNow)
5539
+ refreshCount(repoNow)
4965
5540
  }).catch(function () {
4966
- if (alive) setInfo({ phase: 'none', label: null, pending: 0, repo: '', reason: '' })
5541
+ if (alive === true) setInfo({ phase: 'none', label: null, pending: 0, repo: '', reason: '' })
4967
5542
  })
4968
- return function () { alive = false }
5543
+ return function () { alive = false; if (stopFull !== null) stopFull() }
4969
5544
  }, [watched, repoVersion, isOpen, sessionId, reloadAt])
4970
5545
 
4971
5546
  const isRepo = info.phase === 'repo'
4972
5547
  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 ? '正在核对改动…' : '工作区干净')
5548
+ /* 数字来自全局那一份读数,而不是这次快读 —— 快读根本不带工作区。还没量过就说
5549
+ 「正在核对」,不说「工作区干净」:没量出来和没改动是两件事。读数该完整重来
5550
+ 一遍时(due)也这么说,因为那一次全树读确实正在排。 */
5551
+ const count = known !== true
5552
+ ? '正在核对改动…'
5553
+ : (pending > 0
5554
+ ? String(pending) + ' 个改动' + (due === true ? '(正在核对)' : '')
5555
+ : (due === true ? '正在核对改动…' : '工作区干净'))
4979
5556
  let title = 'Git'
4980
5557
  if (info.phase === 'loading') title = 'Git'
4981
5558
  else if (isRepo) title = info.label + ' · ' + info.repo + ' · ' + count
4982
5559
  else if (info.reason === 'missing') title = '目录不存在:' + where + ' —— 点击修改路径'
4983
5560
  else if (info.reason === 'file') title = '这不是一个目录:' + where + ' —— 点击修改路径'
4984
5561
  else if (info.reason === 'git-error') title = where + ' 读取失败 —— 点击查看原因'
5562
+ else if (info.reason === 'no-git') title = where + ':这台机器上找不到 git —— 点击查看'
5563
+ /* 路径还没定:这一页要人填一个目录,所以这里得说「填」,不能说「这个目录不是
5564
+ 仓库」—— 那时候连是哪个目录都还不知道。 */
5565
+ else if (info.reason === 'no-path') title = '还没确定看哪个目录 —— 点击填写'
5566
+ /* 「这个目录不是 Git 仓库」那一页没有路径框(路径不是问题,没什么可填的),
5567
+ 所以这里也不再承诺「点击选择路径」—— 承诺一个点不到的东西比不承诺更坏。 */
5568
+ else if (info.reason === 'not-a-repo') title = where + ' 这个目录不是 Git 仓库 —— 点击查看'
4985
5569
  else if (info.reason === '') title = 'Git —— 点击打开面板'
4986
- else title = where + ' 这个目录不是 Git 仓库 —— 点击选择路径或在这里初始化'
5570
+ else title = where + ' 读不动这个目录 —— 点击查看'
4987
5571
 
4988
5572
  const children = [h(BranchIcon, {
4989
5573
  key: 'icon', size: 14, plus: !isRepo && info.phase === 'none',
4990
5574
  spin: switching !== null,
4991
5575
  })]
4992
5576
  if (isRepo) children.push(h('span', { className: 'dsh-git-chip-label', key: 'label' }, info.label))
4993
- if (isRepo && info.pending > 0) {
5577
+ if (isRepo && known === true && pending > 0) {
4994
5578
  children.push(h('span', {
4995
- className: 'dsh-git-badge' + (info.stale === true ? ' dsh-git-badge-stale' : ''),
5579
+ className: 'dsh-git-badge' + (due === true ? ' dsh-git-badge-stale' : ''),
4996
5580
  key: 'badge',
4997
- }, String(info.pending)))
5581
+ }, String(pending)))
4998
5582
  }
4999
5583
 
5000
5584
  return h('button', {
@@ -5023,6 +5607,9 @@ textarea.dsh-git-input{resize:vertical}
5023
5607
  function GitPopover(props) {
5024
5608
  const isOpen = useOpen()
5025
5609
  const mode = useSwitchMode()
5610
+ /* 分支卡片上那个「几个改动」也来自全局那一份读数:同一个数字在面板、chip 和这张
5611
+ 卡片上必须是同一个。 */
5612
+ useTreeVersion()
5026
5613
  /* Unmounting on close threw away the tab, the filters, the selection and
5027
5614
  the scroll position, and made every reopen a fresh mount that re-read
5028
5615
  everything. Closing now only hides it: the panel keeps its state, and
@@ -5089,7 +5676,9 @@ textarea.dsh-git-input{resize:vertical}
5089
5676
  ? chipInfoFor(props.sessionId).repo
5090
5677
  : sessionRepo(props.sessionId),
5091
5678
  mode: 'hover',
5092
- dirty: chipInfoFor(props.sessionId).pending,
5679
+ dirty: treeCount(chipInfoFor(props.sessionId).repo.length > 0
5680
+ ? chipInfoFor(props.sessionId).repo
5681
+ : sessionRepo(props.sessionId)),
5093
5682
  onDone: function () { setSwitchMode(null) },
5094
5683
  onClose: function () { setSwitchMode(null) },
5095
5684
  }))