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.
- package/README.md +164 -62
- package/client/client.js +709 -120
- package/lib/index.js +634 -981
- 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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1434
|
-
? ('
|
|
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)
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
1961
|
-
? ('
|
|
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
|
-
|
|
3418
|
-
|
|
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
|
-
|
|
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
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
an edit to a file that is already listed, a stage
|
|
3640
|
-
read of the paths involved. A file that was
|
|
3641
|
-
the one thing a pathspec read cannot see; the
|
|
3642
|
-
catches it, which is why it still happens on a
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
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:
|
|
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
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
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.
|
|
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.
|
|
3745
|
-
panelBox.
|
|
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
|
-
|
|
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)
|
|
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
|
-
/*
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
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() },
|
|
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
|
-
}
|
|
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
|
-
/*
|
|
4165
|
-
|
|
4166
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
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
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
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
|
-
/*
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
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 (
|
|
5534
|
+
if (alive !== true) return
|
|
4961
5535
|
apply(data)
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
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
|
-
/*
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
const count =
|
|
4977
|
-
?
|
|
4978
|
-
: (
|
|
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 + '
|
|
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 &&
|
|
5577
|
+
if (isRepo && known === true && pending > 0) {
|
|
4994
5578
|
children.push(h('span', {
|
|
4995
|
-
className: 'dsh-git-badge' + (
|
|
5579
|
+
className: 'dsh-git-badge' + (due === true ? ' dsh-git-badge-stale' : ''),
|
|
4996
5580
|
key: 'badge',
|
|
4997
|
-
}, String(
|
|
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).
|
|
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
|
}))
|