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