dsh-github-flow 0.6.0 → 0.7.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.en.md +102 -15
- package/README.md +105 -15
- package/docs/UPGRADE-NOTES.md +12 -11
- package/docs/architecture.md +67 -14
- package/lib/api/route-helpers.d.ts +19 -0
- package/lib/api/route-helpers.js +54 -0
- package/lib/api/routes-actions.js +126 -2
- package/lib/api/routes-lifecycle.js +8 -1
- package/lib/api/routes-overview.d.ts +26 -0
- package/lib/api/routes-overview.js +276 -34
- package/lib/api/routes-proxy.js +8 -1
- package/lib/api/routes.d.ts +11 -1
- package/lib/api/routes.js +15 -2
- package/lib/cache.d.ts +0 -21
- package/lib/cache.js +0 -37
- package/lib/client.d.ts +2 -1
- package/lib/client.js +2108 -658
- package/lib/destructive.d.ts +59 -0
- package/lib/destructive.js +285 -0
- package/lib/executor/args-guard.d.ts +17 -0
- package/lib/executor/args-guard.js +23 -0
- package/lib/executor/failure-anchors.d.ts +52 -0
- package/lib/executor/failure-anchors.js +116 -0
- package/lib/executor/pr-review.d.ts +95 -0
- package/lib/executor/pr-review.js +175 -0
- package/lib/executor/run-log.d.ts +36 -0
- package/lib/executor/run-log.js +78 -0
- package/lib/executor.d.ts +204 -3
- package/lib/executor.js +670 -28
- package/lib/github-service.d.ts +1 -1
- package/lib/main.d.ts +92 -18
- package/lib/main.js +186 -95
- package/lib/renderers/common.d.ts +0 -3
- package/lib/renderers/common.js +8 -44
- package/lib/renderers/markdown.d.ts +1 -0
- package/lib/renderers/markdown.js +1 -0
- package/lib/renderers/pr.js +26 -1
- package/lib/renderers/task.d.ts +9 -0
- package/lib/renderers/task.js +120 -0
- package/lib/review-rules.d.ts +60 -0
- package/lib/review-rules.js +204 -0
- package/lib/shared/state-badges.d.ts +42 -0
- package/lib/shared/state-badges.js +102 -0
- package/lib/task-primitives.d.ts +109 -0
- package/lib/task-primitives.js +194 -0
- package/lib/tool-tiers.d.ts +57 -0
- package/lib/tool-tiers.js +168 -0
- package/lib/tools/agent-task.d.ts +4 -0
- package/lib/tools/agent-task.js +22 -9
- package/lib/tools/api.d.ts +4 -0
- package/lib/tools/api.js +48 -12
- package/lib/tools/body-input.d.ts +58 -0
- package/lib/tools/body-input.js +123 -0
- package/lib/tools/cache.js +42 -40
- package/lib/tools/codespace.js +4 -12
- package/lib/tools/discussion.d.ts +4 -0
- package/lib/tools/discussion.js +33 -17
- package/lib/tools/gist.d.ts +1 -39
- package/lib/tools/gist.js +4 -14
- package/lib/tools/help.js +65 -13
- package/lib/tools/issue.d.ts +4 -0
- package/lib/tools/issue.js +37 -38
- package/lib/tools/label.js +4 -15
- package/lib/tools/pr.d.ts +51 -0
- package/lib/tools/pr.js +204 -46
- package/lib/tools/project.js +4 -13
- package/lib/tools/release.d.ts +4 -0
- package/lib/tools/release.js +26 -26
- package/lib/tools/repo.js +4 -4
- package/lib/tools/ruleset.js +9 -6
- package/lib/tools/run.js +76 -24
- package/lib/tools/secret.js +11 -17
- package/lib/tools/skill.js +18 -22
- package/lib/tools/status.js +4 -4
- package/lib/tools/task.d.ts +223 -0
- package/lib/tools/task.js +464 -0
- package/lib/types.d.ts +183 -1
- package/lib/ui/commit-modal.js +108 -4
- package/lib/ui/diff-utils.d.ts +51 -0
- package/lib/ui/diff-utils.js +155 -0
- package/lib/ui/file-diff-modal.d.ts +33 -0
- package/lib/ui/file-diff-modal.js +250 -0
- package/lib/ui/help-modal.d.ts +1 -1
- package/lib/ui/help-modal.js +24 -12
- package/lib/ui/issue-modal.js +7 -3
- package/lib/ui/pr-modal.js +50 -11
- package/lib/ui/run-modal.d.ts +26 -0
- package/lib/ui/run-modal.js +486 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +376 -44
- package/lib/ui/sync-settings-modal.js +1 -1
- package/lib/ui/workspace-panel.js +78 -23
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +9 -2
package/lib/types.d.ts
CHANGED
|
@@ -36,6 +36,22 @@ export interface PluginConfig {
|
|
|
36
36
|
* 注意:放在 Cordis Config 意味着修改此项会触发插件 reload(静态默认值,可接受)。
|
|
37
37
|
*/
|
|
38
38
|
proxyUrl?: string;
|
|
39
|
+
/**
|
|
40
|
+
* 工具档位:控制注册给模型的工具数量(默认不填 = `full`,18 个全量,零破坏)。
|
|
41
|
+
*
|
|
42
|
+
* 三种预设 `core`(5 个)/ `ci`(7 个)/ `maintain`(8 个),也可直接写工具名
|
|
43
|
+
* (如 `['github_pr','github_release']`)混用。未知名字会 console.warn 一次后忽略。
|
|
44
|
+
* 存在的理由:18 个工具的 schema 常驻约 24,000 字符,等于每轮对话都在为用不到的工具付费。
|
|
45
|
+
*/
|
|
46
|
+
toolsets?: string[];
|
|
47
|
+
/**
|
|
48
|
+
* 只读档位(默认 false)。
|
|
49
|
+
*
|
|
50
|
+
* 硬语义,不做运行时判断:写 action 在**注册期**就从 `enum` 里被剔除,模型连
|
|
51
|
+
* 「可以写」都看不到,宿主参数校验会直接拒绝。`github_api`(端点与 method 均可任意,
|
|
52
|
+
* 无法降级)在只读模式下整体剔除。
|
|
53
|
+
*/
|
|
54
|
+
read_only?: boolean;
|
|
39
55
|
}
|
|
40
56
|
/** 数据同步与自动化感知偏好配置 */
|
|
41
57
|
export interface SyncSettings {
|
|
@@ -245,6 +261,24 @@ export interface GitLocalStatus {
|
|
|
245
261
|
path: string;
|
|
246
262
|
}>;
|
|
247
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* 工作区干净度闸门结果(`executor.ensureCleanWorkspace`)。
|
|
266
|
+
*
|
|
267
|
+
* 为什么要有 `reason: 'unknown'`:`getLocalGitStatus()` 在 git 不可用或非仓库时返回 null。
|
|
268
|
+
* 旧实现用 `if (gitSt && gitSt.dirtyCount > 0)` 判断,null 时表达式为假 → 闸门静默放行。
|
|
269
|
+
* 安全闸门不能因为「读不到状态」而失效,所以这里把「读不到」与「干净」显式区分开。
|
|
270
|
+
*/
|
|
271
|
+
export type WorkspaceCleanResult = {
|
|
272
|
+
ok: true;
|
|
273
|
+
dirtyCount: 0;
|
|
274
|
+
samples: string[];
|
|
275
|
+
} | {
|
|
276
|
+
ok: false;
|
|
277
|
+
reason: 'dirty' | 'unknown';
|
|
278
|
+
dirtyCount: number;
|
|
279
|
+
samples: string[];
|
|
280
|
+
error: string;
|
|
281
|
+
};
|
|
248
282
|
/** 单条提交记录(结构化解析) */
|
|
249
283
|
export interface GitCommitEntry {
|
|
250
284
|
sha: string;
|
|
@@ -276,7 +310,7 @@ export interface PrItem {
|
|
|
276
310
|
number: number;
|
|
277
311
|
title: string;
|
|
278
312
|
state: 'OPEN' | 'CLOSED' | 'MERGED';
|
|
279
|
-
author
|
|
313
|
+
author?: {
|
|
280
314
|
login: string;
|
|
281
315
|
avatarUrl?: string;
|
|
282
316
|
};
|
|
@@ -361,6 +395,15 @@ export interface PrDetailData {
|
|
|
361
395
|
};
|
|
362
396
|
headRefName: string;
|
|
363
397
|
baseRefName: string;
|
|
398
|
+
/**
|
|
399
|
+
* 分支尖端的 commit SHA。
|
|
400
|
+
*
|
|
401
|
+
* 为什么必须透出:分支名会移动(force-push / 新提交),拿它当审查锚点等于
|
|
402
|
+
* 「我审的到底是哪一版」永远说不清。SHA 是不可移动的事实源,也是行级评注
|
|
403
|
+
* 写入时 `commit_id` 的取值来源。
|
|
404
|
+
*/
|
|
405
|
+
headRefOid?: string;
|
|
406
|
+
baseRefOid?: string;
|
|
364
407
|
headRepoOwner?: string;
|
|
365
408
|
body: string;
|
|
366
409
|
reviewDecision?: string;
|
|
@@ -391,6 +434,25 @@ export interface PrDetailData {
|
|
|
391
434
|
* 静默展示「该文件无差异」会让用户误以为代码没改动——必须显式提示。
|
|
392
435
|
*/
|
|
393
436
|
diffTruncated?: boolean;
|
|
437
|
+
/**
|
|
438
|
+
* PR 实际变更的文件总数(来自 `gh pr view --json changedFiles`)。
|
|
439
|
+
*
|
|
440
|
+
* 为什么必须与 `files.length` 分开:`gh pr view --json files` 在 100 条处
|
|
441
|
+
* **静默截断**(cli/cli#12522),`files.length` 只是「返回了几条」。
|
|
442
|
+
* 把两者混为一谈,界面就会把「被截断」说成「就这么多」。
|
|
443
|
+
*/
|
|
444
|
+
changedFiles?: number;
|
|
445
|
+
/** `files` 清单是否被 gh 的 100 条上限截断(`files.length < changedFiles`) */
|
|
446
|
+
filesTruncated?: boolean;
|
|
447
|
+
/**
|
|
448
|
+
* 是否有文件**没拿到 diff 正文**(该文件在 `files` 里但 `fileDiffs` 无对应切片)。
|
|
449
|
+
*
|
|
450
|
+
* 与 `diffTruncated` 是并列而非同一概念:
|
|
451
|
+
* - `diffTruncated`:整个 diff 输出被阈值裁掉(原因在传输层);
|
|
452
|
+
* - `diffIncomplete`:清单里有这个文件、但切片里没有它(原因在数据缺失)。
|
|
453
|
+
* 两者处置不同(一个是「去看完整 diff」,一个是「这个文件没取到」),不能合并。
|
|
454
|
+
*/
|
|
455
|
+
diffIncomplete?: boolean;
|
|
394
456
|
}
|
|
395
457
|
/**
|
|
396
458
|
* Issue 分类维度契约:
|
|
@@ -535,6 +597,93 @@ export interface WorkflowRunItem {
|
|
|
535
597
|
url: string;
|
|
536
598
|
createdAt: string;
|
|
537
599
|
}
|
|
600
|
+
/**
|
|
601
|
+
* 单次 Run 里的一个 job 步骤(`gh run view --json jobs` 实测字段)。
|
|
602
|
+
*
|
|
603
|
+
* 为什么只有这四个时间/状态字段:step 在 GitHub 上**没有独立 URL**(锚点是页面内
|
|
604
|
+
* `#step:` 片段,API 不返回),也没有自己的一行日志入口。UI 因此只能展示
|
|
605
|
+
* 「序号 + 名称 + 结论 + 耗时」,任何「点击某步看日志」的交互都做不到——
|
|
606
|
+
* 日志的最小粒度是 job。
|
|
607
|
+
*/
|
|
608
|
+
export interface RunStepItem {
|
|
609
|
+
number: number;
|
|
610
|
+
name: string;
|
|
611
|
+
status: string;
|
|
612
|
+
conclusion?: string;
|
|
613
|
+
startedAt?: string;
|
|
614
|
+
completedAt?: string;
|
|
615
|
+
}
|
|
616
|
+
/** 单次 Run 里的一个 job(数据库 ID 即 check-run ID,可用于注解/日志端点) */
|
|
617
|
+
export interface RunJobItem {
|
|
618
|
+
databaseId: number;
|
|
619
|
+
name: string;
|
|
620
|
+
status: string;
|
|
621
|
+
conclusion?: string;
|
|
622
|
+
startedAt?: string;
|
|
623
|
+
completedAt?: string;
|
|
624
|
+
/** 该 job 在 GitHub 的页面地址;step 级别没有对应 URL */
|
|
625
|
+
url?: string;
|
|
626
|
+
steps: RunStepItem[];
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Run 完整详情契约(供 RunDetailModal 在线查看消费)。
|
|
630
|
+
*
|
|
631
|
+
* 与 PrDetailData / IssueDetailData 的关键差异:这里**不携带日志正文**。
|
|
632
|
+
* 日志动辄几十 MB 且实测单次拉取 4–40 秒还会被代理掐断,挂在详情上会让
|
|
633
|
+
* 弹窗首屏被慢链路拖死;日志走独立的 /run-logs 通道按需拉取。
|
|
634
|
+
*/
|
|
635
|
+
export interface RunDetailData {
|
|
636
|
+
runId: number;
|
|
637
|
+
/** 重跑次数(>1 时 UI 显示「第 N 次尝试」) */
|
|
638
|
+
attempt?: number;
|
|
639
|
+
name: string;
|
|
640
|
+
displayTitle?: string;
|
|
641
|
+
status: string;
|
|
642
|
+
conclusion?: string;
|
|
643
|
+
event?: string;
|
|
644
|
+
headBranch?: string;
|
|
645
|
+
headSha?: string;
|
|
646
|
+
url: string;
|
|
647
|
+
createdAt: string;
|
|
648
|
+
startedAt?: string;
|
|
649
|
+
updatedAt: string;
|
|
650
|
+
workflowName?: string;
|
|
651
|
+
jobs: RunJobItem[];
|
|
652
|
+
/**
|
|
653
|
+
* 失败口径只算一次。
|
|
654
|
+
* 为什么在服务端算:UI 与将来的其它消费方(Agent 摘要、测试)必须同一口径,
|
|
655
|
+
* 三处各写一遍 `conclusion === 'failure'` 迟早出现「卡片说 1 个失败、弹窗说 2 个」。
|
|
656
|
+
*/
|
|
657
|
+
failedJobs: number;
|
|
658
|
+
failedSteps: number;
|
|
659
|
+
/** gh 版本过低触发过字段降级(供排查,不弹错) */
|
|
660
|
+
fieldsDegraded?: boolean;
|
|
661
|
+
}
|
|
662
|
+
/** 失败日志按需拉取的结果(一 job 一块,单 job 失败不拖垮整体) */
|
|
663
|
+
export interface RunLogExcerptData {
|
|
664
|
+
runId: number;
|
|
665
|
+
jobs: Array<{
|
|
666
|
+
jobId: number;
|
|
667
|
+
jobName: string;
|
|
668
|
+
/** 锚点萃取后的纯文本片段(含尾部回退与显式截断声明) */
|
|
669
|
+
excerpt: string;
|
|
670
|
+
truncated: boolean;
|
|
671
|
+
}>;
|
|
672
|
+
/** 拉取失败但可重试的 job;UI 必须显式展示,不能静默当成「没有失败日志」 */
|
|
673
|
+
errors?: Array<{
|
|
674
|
+
jobName: string;
|
|
675
|
+
error: string;
|
|
676
|
+
}>;
|
|
677
|
+
/**
|
|
678
|
+
* job 名称未能从详情树解析(服务端详情降级/失败)。
|
|
679
|
+
*
|
|
680
|
+
* 为什么必须透出:此时 jobName 会退化成 `job #<id>`,属于用户可见的降级。
|
|
681
|
+
* 本仓纪律是「降级必须显式」——让界面能说一句「名称未取到」,
|
|
682
|
+
* 而不是让人以为这个 job 本来就叫 `job #111`。
|
|
683
|
+
*/
|
|
684
|
+
namesUnavailable?: boolean;
|
|
685
|
+
fetchedAt: string;
|
|
686
|
+
}
|
|
538
687
|
export interface GitHubOverviewData {
|
|
539
688
|
auth: AuthStatus;
|
|
540
689
|
repo?: RepoMetadata;
|
|
@@ -590,6 +739,30 @@ export interface GitHubOverviewData {
|
|
|
590
739
|
prsTruncated?: boolean;
|
|
591
740
|
/** 单仓库 Issue 列表是否触及条数上限(语义同 prsTruncated) */
|
|
592
741
|
issuesTruncated?: boolean;
|
|
742
|
+
/** Actions 运行列表是否还有更多(语义同 prsTruncated;右侧栏分页用) */
|
|
743
|
+
runsTruncated?: boolean;
|
|
744
|
+
/** 仓库全部 PR 数(含 open/merged/closed) */
|
|
745
|
+
prTotal?: number;
|
|
746
|
+
/** 处于 open 的 PR 数 */
|
|
747
|
+
prOpenTotal?: number;
|
|
748
|
+
/** 已合并的 PR 数 */
|
|
749
|
+
prMergedTotal?: number;
|
|
750
|
+
/** 已关闭的 PR 数(GitHub 语义:含已合并) */
|
|
751
|
+
prClosedTotal?: number;
|
|
752
|
+
/** 仓库全部 Issue 数(不含 PR——用 `is:issue` 限定符区分) */
|
|
753
|
+
issueTotal?: number;
|
|
754
|
+
/** 处于 open 的 Issue 数 */
|
|
755
|
+
issueOpenTotal?: number;
|
|
756
|
+
/** 已关闭的 Issue 数 */
|
|
757
|
+
issueClosedTotal?: number;
|
|
758
|
+
/**
|
|
759
|
+
* Actions 运行总数。
|
|
760
|
+
*
|
|
761
|
+
* 来源是 REST `GET /repos/{owner}/{repo}/actions/runs?per_page=1` 的 `total_count`
|
|
762
|
+
* ——GitHub GraphQL 没有 workflow runs 连接,拿不到 totalCount。
|
|
763
|
+
* 同样与分页解耦:`runs.length` 只是已加载条数。
|
|
764
|
+
*/
|
|
765
|
+
runTotal?: number;
|
|
593
766
|
}
|
|
594
767
|
export interface UserRepoItem {
|
|
595
768
|
name: string;
|
|
@@ -662,6 +835,15 @@ export interface GlobalOverviewData {
|
|
|
662
835
|
* 「列表到此为止」与「数据没取到」。
|
|
663
836
|
*/
|
|
664
837
|
prsTruncated?: boolean;
|
|
838
|
+
/** Issues 三路检索是否任一触及上限(有更多可加载) */
|
|
839
|
+
issuesTruncated?: boolean;
|
|
840
|
+
prTotal?: number;
|
|
841
|
+
/** 账号 open PR 数 */
|
|
842
|
+
prOpenTotal?: number;
|
|
843
|
+
/** 账号已合并 PR 数 */
|
|
844
|
+
prMergedTotal?: number;
|
|
845
|
+
/** 账号已关闭 PR 数(GitHub 语义:含已合并) */
|
|
846
|
+
prClosedTotal?: number;
|
|
665
847
|
lastUpdated: string;
|
|
666
848
|
error?: string;
|
|
667
849
|
/** 网络是否发生异常(超时、连接拒绝、代理阻断等) */
|
package/lib/ui/commit-modal.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { FileDiffModal } from './file-diff-modal.js';
|
|
2
|
+
import { splitUnifiedPatch, findPatchSection, normalizeRenamePath } from './diff-utils.js';
|
|
1
3
|
/**
|
|
2
4
|
* 提交详情模态弹窗组件(与设置弹窗同构)
|
|
3
5
|
*/
|
|
@@ -8,14 +10,76 @@ export function CommitDetailModal(props) {
|
|
|
8
10
|
var h = React.createElement;
|
|
9
11
|
var [fetchedDetail, setFetchedDetail] = React.useState(null);
|
|
10
12
|
var [fetchingDetail, setFetchingDetail] = React.useState(false);
|
|
13
|
+
/**
|
|
14
|
+
* 点某个文件后的 diff 预览状态。
|
|
15
|
+
*
|
|
16
|
+
* 为什么补丁要懒加载而不是随弹窗一起取:多数时候用户只想看提交说明,
|
|
17
|
+
* 大提交的整包 patch 可以到几百 KB;只在真的点开某个文件时才拉一次,且整个
|
|
18
|
+
* 弹窗生命周期内复用同一份 patch(点第二个文件零延迟)。
|
|
19
|
+
*/
|
|
20
|
+
var [patchState, setPatchState] = React.useState(null);
|
|
21
|
+
var [patchLoading, setPatchLoading] = React.useState(false);
|
|
22
|
+
var [patchError, setPatchError] = React.useState(null);
|
|
23
|
+
var [selectedFile, setSelectedFile] = React.useState(null);
|
|
24
|
+
var loadPatch = function () {
|
|
25
|
+
if (patchState || patchLoading)
|
|
26
|
+
return;
|
|
27
|
+
setPatchLoading(true);
|
|
28
|
+
setPatchError(null);
|
|
29
|
+
var url = "/api/github/commit-diff?sha=" + encodeURIComponent(c.sha) + (props.cwd ? "&cwd=" + encodeURIComponent(props.cwd) : "");
|
|
30
|
+
fetch(url)
|
|
31
|
+
.then(function (r) {
|
|
32
|
+
if (r.status === 404) {
|
|
33
|
+
setPatchLoading(false);
|
|
34
|
+
setPatchError("后端未提供提交补丁接口 (404)。请重启 DSH 桌面主程序以载入最新对比引擎。");
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return r.json().catch(function () { return null; });
|
|
38
|
+
})
|
|
39
|
+
.then(function (j) {
|
|
40
|
+
if (!j)
|
|
41
|
+
return;
|
|
42
|
+
setPatchLoading(false);
|
|
43
|
+
if (j.ok && j.data && typeof j.data.patch === "string") {
|
|
44
|
+
setPatchState({ patch: j.data.patch, truncated: Boolean(j.data.truncated) });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
setPatchError((j && j.error) || "获取提交补丁失败");
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.catch(function (err) {
|
|
51
|
+
setPatchLoading(false);
|
|
52
|
+
setPatchError("网络请求异常:" + ((err && err.message) || "未知错误"));
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
var openFileDiff = function (f) {
|
|
56
|
+
setSelectedFile(f);
|
|
57
|
+
loadPatch();
|
|
58
|
+
};
|
|
11
59
|
React.useEffect(function () {
|
|
12
60
|
var onKeyDown = function (e) {
|
|
13
|
-
if (e.key === "Escape" || e.keyCode === 27)
|
|
61
|
+
if (e.key === "Escape" || e.keyCode === 27) {
|
|
62
|
+
// 逐层关闭:先收内层 diff 预览,再按一次才关整个提交详情
|
|
63
|
+
if (selectedFile) {
|
|
64
|
+
setSelectedFile(null);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
14
67
|
props.onClose();
|
|
68
|
+
}
|
|
15
69
|
};
|
|
16
70
|
window.addEventListener("keydown", onKeyDown);
|
|
17
71
|
return function () { window.removeEventListener("keydown", onKeyDown); };
|
|
18
|
-
}, [props.onClose]);
|
|
72
|
+
}, [props.onClose, selectedFile]);
|
|
73
|
+
/**
|
|
74
|
+
* 换提交时清空上一份补丁与选中文件。
|
|
75
|
+
* 当前调用方在关闭时卸载组件(状态自然复位),但组件一旦被复用(不 key、不卸载),
|
|
76
|
+
* 残留的 patch 会把 A 提交的差异套到 B 提交的文件路径上——这类错配极难排查,先兜住。
|
|
77
|
+
*/
|
|
78
|
+
React.useEffect(function () {
|
|
79
|
+
setPatchState(null);
|
|
80
|
+
setPatchError(null);
|
|
81
|
+
setSelectedFile(null);
|
|
82
|
+
}, [c.sha]);
|
|
19
83
|
React.useEffect(function () {
|
|
20
84
|
setFetchedDetail(null);
|
|
21
85
|
if (c.body || c.stat)
|
|
@@ -39,6 +103,13 @@ export function CommitDetailModal(props) {
|
|
|
39
103
|
var parsedStat = React.useMemo(() => {
|
|
40
104
|
return effectiveStat && typeof parseGitStatText === "function" ? parseGitStatText(effectiveStat) : null;
|
|
41
105
|
}, [effectiveStat]);
|
|
106
|
+
/**
|
|
107
|
+
* 切段结果必须缓存:大提交的整包 patch 可达 400k 字符,而本组件在弹窗生命周期里
|
|
108
|
+
* 会因 selectedFile / patchError 等状态多次重渲染——每次重渲染都重新切一遍是纯浪费。
|
|
109
|
+
*/
|
|
110
|
+
var patchSections = React.useMemo(function () {
|
|
111
|
+
return patchState ? splitUnifiedPatch(patchState.patch) : [];
|
|
112
|
+
}, [patchState]);
|
|
42
113
|
return h("div", {
|
|
43
114
|
className: "dsh-github-modal-overlay",
|
|
44
115
|
onClick: props.onClose,
|
|
@@ -142,6 +213,18 @@ export function CommitDetailModal(props) {
|
|
|
142
213
|
return h("div", {
|
|
143
214
|
key: idx,
|
|
144
215
|
className: "dsh-github-stat-row",
|
|
216
|
+
// 可点击 + 可键盘操作:文件行是入口而不是纯展示,缺 aria/键盘会让
|
|
217
|
+
// 只用 Tab 的人根本发现不了「能点开看 diff」
|
|
218
|
+
role: "button",
|
|
219
|
+
tabIndex: 0,
|
|
220
|
+
"aria-label": "查看 " + f.path + " 的差异",
|
|
221
|
+
onClick: function () { openFileDiff(f); },
|
|
222
|
+
onKeyDown: function (e) {
|
|
223
|
+
if (e.key === "Enter" || e.key === " " || e.keyCode === 13 || e.keyCode === 32) {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
openFileDiff(f);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
145
228
|
style: {
|
|
146
229
|
display: "flex",
|
|
147
230
|
alignItems: "center",
|
|
@@ -151,8 +234,10 @@ export function CommitDetailModal(props) {
|
|
|
151
234
|
lineHeight: "1.4",
|
|
152
235
|
borderBottom: idx < parsedStat.files.length - 1 ? "1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.06))" : "none",
|
|
153
236
|
gap: "8px",
|
|
237
|
+
cursor: "pointer",
|
|
238
|
+
userSelect: "none",
|
|
154
239
|
},
|
|
155
|
-
title: f.path + (f.isBinary ? " (二进制文件)" : ` (+${f.additions}, -${f.deletions})`),
|
|
240
|
+
title: f.path + (f.isBinary ? " (二进制文件)" : ` (+${f.additions}, -${f.deletions})`) + "\n点击查看该文件的代码改动",
|
|
156
241
|
}, h("div", {
|
|
157
242
|
style: {
|
|
158
243
|
fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace",
|
|
@@ -193,6 +278,7 @@ export function CommitDetailModal(props) {
|
|
|
193
278
|
style: { display: "flex", gap: "1px", marginLeft: "2px", alignItems: "center" },
|
|
194
279
|
}, ...diffBlocks)
|
|
195
280
|
: null,
|
|
281
|
+
h("span", { key: "chev", style: { opacity: 0.4, marginLeft: "2px", fontSize: "12px" }, "aria-hidden": "true" }, "›"),
|
|
196
282
|
]));
|
|
197
283
|
}))
|
|
198
284
|
: h("pre", {
|
|
@@ -215,5 +301,23 @@ export function CommitDetailModal(props) {
|
|
|
215
301
|
? (fetchingDetail
|
|
216
302
|
? h("div", { style: { fontSize: "12px", opacity: 0.7, padding: "8px 0" } }, "⏳ 正在获取详细变更说明…")
|
|
217
303
|
: h("div", { style: { fontSize: "11px", opacity: 0.6, fontStyle: "italic", padding: "6px 0" } }, "(该提交未包含多行正文;重启 DSH 桌面端可同步最新格式并直接呈现变更统计)"))
|
|
218
|
-
: null))
|
|
304
|
+
: null)),
|
|
305
|
+
// 内层 diff 预览:作为 overlay 的兄弟节点而非 dialog 的子节点,
|
|
306
|
+
// 这样它是独立的一层遮罩(zIndex 更高),点遮罩只关它自己
|
|
307
|
+
selectedFile
|
|
308
|
+
? h(FileDiffModal, {
|
|
309
|
+
file: selectedFile,
|
|
310
|
+
cwd: props.cwd,
|
|
311
|
+
// 传字符串而不是 null:null 会被组件当成「未提供正文 → 自己去拉未提交 diff」
|
|
312
|
+
diffText: (findPatchSection(patchSections, normalizeRenamePath(selectedFile.path)) || { text: "" }).text,
|
|
313
|
+
subtitle: String(c.fullSha || c.sha || "").slice(0, 7),
|
|
314
|
+
loading: patchLoading,
|
|
315
|
+
error: patchError,
|
|
316
|
+
note: patchState && patchState.truncated
|
|
317
|
+
? "该提交差异过大,预览只覆盖补丁被截断前的文件;其余文件请用命令行查看。"
|
|
318
|
+
: null,
|
|
319
|
+
zIndex: 100001,
|
|
320
|
+
onClose: function () { setSelectedFile(null); },
|
|
321
|
+
})
|
|
322
|
+
: null);
|
|
219
323
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified diff 的切分与路径归一(纯函数:零 React、零 IO)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块:提交详情要支持「点文件看该文件的 diff」,而一次 `git show` 拿到的是
|
|
5
|
+
* 整包 patch,必须在前端按 `diff --git` 边界切段。切分规则(边界、重命名路径、二进制判定)
|
|
6
|
+
* 是纯逻辑,抽出来才能脱离 DOM 单测;放进组件里就只能靠肉眼看界面。
|
|
7
|
+
*
|
|
8
|
+
* 为什么不改成「点一次查一次 `git show <sha> -- <path>`」:`git show --stat` 把重命名显示成
|
|
9
|
+
* `src/{old => new}.ts`,拿它当 pathspec 必然查不到;带空格/引号的路径同样脆。整包 patch 的
|
|
10
|
+
* `diff --git a/… b/…` 行才是权威来源,按它切分天然免疫这些形态。
|
|
11
|
+
*/
|
|
12
|
+
export interface PatchSection {
|
|
13
|
+
/** b 侧路径(重命名后的新路径) */
|
|
14
|
+
path: string;
|
|
15
|
+
/** 仅重命名块有值(a 侧旧路径) */
|
|
16
|
+
oldPath?: string;
|
|
17
|
+
/** 该文件的完整补丁块(含 diff --git / index / --- / +++ / hunk 全部行) */
|
|
18
|
+
text: string;
|
|
19
|
+
isBinary: boolean;
|
|
20
|
+
additions: number;
|
|
21
|
+
deletions: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 解析 `diff --git a/<old> b/<new>` 头行。
|
|
25
|
+
*
|
|
26
|
+
* 路径含空格时这行本身是有歧义的(`a/a b.txt b/a b.txt`),所以按**最后一个** ` b/` 切:
|
|
27
|
+
* a 侧取前缀,b 侧取剩余全部——b 侧是我们要的路径,尾部截断不会丢字符。
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseDiffGitLine(line: string): {
|
|
30
|
+
aPath: string;
|
|
31
|
+
bPath: string;
|
|
32
|
+
} | null;
|
|
33
|
+
/**
|
|
34
|
+
* 把 `--stat` 里的重命名写法归一成新路径:
|
|
35
|
+
* - `src/{old => new}.ts` → `src/new.ts`
|
|
36
|
+
* - `old/name.ts => new/name.ts` → `new/name.ts`
|
|
37
|
+
* 非重命名原样返回。
|
|
38
|
+
*/
|
|
39
|
+
export declare function normalizeRenamePath(raw: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* 按 `diff --git` 边界把整包 patch 切成逐文件块。
|
|
42
|
+
* 首个 `diff --git` 之前的内容(如 `--format=` 漏配时的提交头)直接丢弃:
|
|
43
|
+
* 它们不属于任何文件,混进第一段会污染差异渲染。
|
|
44
|
+
*/
|
|
45
|
+
export declare function splitUnifiedPatch(patch: string): PatchSection[];
|
|
46
|
+
/**
|
|
47
|
+
* 在切好的块里找目标文件。
|
|
48
|
+
* 匹配优先级:新路径 → 旧路径 → 归一化后的重命名写法(`--stat` 的原始形态)。
|
|
49
|
+
* 找不到返回 null,由调用方给「该文件在本次提交中无文本差异」的兜底文案。
|
|
50
|
+
*/
|
|
51
|
+
export declare function findPatchSection(sections: PatchSection[], targetPath: string): PatchSection | null;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified diff 的切分与路径归一(纯函数:零 React、零 IO)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块:提交详情要支持「点文件看该文件的 diff」,而一次 `git show` 拿到的是
|
|
5
|
+
* 整包 patch,必须在前端按 `diff --git` 边界切段。切分规则(边界、重命名路径、二进制判定)
|
|
6
|
+
* 是纯逻辑,抽出来才能脱离 DOM 单测;放进组件里就只能靠肉眼看界面。
|
|
7
|
+
*
|
|
8
|
+
* 为什么不改成「点一次查一次 `git show <sha> -- <path>`」:`git show --stat` 把重命名显示成
|
|
9
|
+
* `src/{old => new}.ts`,拿它当 pathspec 必然查不到;带空格/引号的路径同样脆。整包 patch 的
|
|
10
|
+
* `diff --git a/… b/…` 行才是权威来源,按它切分天然免疫这些形态。
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* 解析 `diff --git a/<old> b/<new>` 头行。
|
|
14
|
+
*
|
|
15
|
+
* 路径含空格时这行本身是有歧义的(`a/a b.txt b/a b.txt`),所以按**最后一个** ` b/` 切:
|
|
16
|
+
* a 侧取前缀,b 侧取剩余全部——b 侧是我们要的路径,尾部截断不会丢字符。
|
|
17
|
+
*/
|
|
18
|
+
export function parseDiffGitLine(line) {
|
|
19
|
+
if (typeof line !== "string" || line.indexOf("diff --git ") !== 0)
|
|
20
|
+
return null;
|
|
21
|
+
var rest = line.slice("diff --git ".length);
|
|
22
|
+
var sep = rest.lastIndexOf(" b/");
|
|
23
|
+
if (sep < 0)
|
|
24
|
+
return null;
|
|
25
|
+
var aPath = rest.slice(0, sep);
|
|
26
|
+
var bPath = rest.slice(sep + 1); // 保留 "b/" 前缀,与 git 输出一致
|
|
27
|
+
if (aPath.indexOf("a/") !== 0)
|
|
28
|
+
return null;
|
|
29
|
+
return { aPath: aPath.slice(2), bPath: bPath.slice(2) };
|
|
30
|
+
}
|
|
31
|
+
/** 去掉 git 对特殊路径加的引号(core.quotepath=false 后大多数情况不再出现) */
|
|
32
|
+
function unquote(raw) {
|
|
33
|
+
var s = String(raw == null ? "" : raw).trim();
|
|
34
|
+
if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') {
|
|
35
|
+
return s.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
36
|
+
}
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 把 `--stat` 里的重命名写法归一成新路径:
|
|
41
|
+
* - `src/{old => new}.ts` → `src/new.ts`
|
|
42
|
+
* - `old/name.ts => new/name.ts` → `new/name.ts`
|
|
43
|
+
* 非重命名原样返回。
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeRenamePath(raw) {
|
|
46
|
+
var s = unquote(raw);
|
|
47
|
+
if (s.indexOf("=>") < 0)
|
|
48
|
+
return s;
|
|
49
|
+
var brace = s.match(/^(.*)\{([^{}]*)=>\s*([^{}]*)\}(.*)$/);
|
|
50
|
+
if (brace) {
|
|
51
|
+
var joined = (brace[1] + brace[3] + brace[4]).replace(/\/{2,}/g, "/");
|
|
52
|
+
return joined.replace(/^\s+|\s+$/g, "");
|
|
53
|
+
}
|
|
54
|
+
var parts = s.split("=>");
|
|
55
|
+
return parts[parts.length - 1].replace(/^\s+|\s+$/g, "");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 按 `diff --git` 边界把整包 patch 切成逐文件块。
|
|
59
|
+
* 首个 `diff --git` 之前的内容(如 `--format=` 漏配时的提交头)直接丢弃:
|
|
60
|
+
* 它们不属于任何文件,混进第一段会污染差异渲染。
|
|
61
|
+
*/
|
|
62
|
+
export function splitUnifiedPatch(patch) {
|
|
63
|
+
if (typeof patch !== "string" || !patch)
|
|
64
|
+
return [];
|
|
65
|
+
var lines = patch.split("\n");
|
|
66
|
+
var sections = [];
|
|
67
|
+
var buf = [];
|
|
68
|
+
var current = null;
|
|
69
|
+
/**
|
|
70
|
+
* 是否已进入第一个 hunk。
|
|
71
|
+
*
|
|
72
|
+
* 为什么必须有这个状态位:`--- `/`+++ ` 既是文件头,也可能是**正文**行
|
|
73
|
+
* (删掉一行 `---` 分隔线的补丁行是 `----`,Markdown 文档里很常见)。
|
|
74
|
+
* 只按行首前缀判断会把正文行当文件头,导致增删计数与配色全错。
|
|
75
|
+
* git 的格式保证:文件头只出现在第一个 `@@` 之前,`@@` 之后全是正文字节。
|
|
76
|
+
*/
|
|
77
|
+
var inHunk = false;
|
|
78
|
+
var flush = function () {
|
|
79
|
+
if (current && buf.length > 0) {
|
|
80
|
+
current.text = buf.join("\n");
|
|
81
|
+
sections.push(current);
|
|
82
|
+
}
|
|
83
|
+
buf = [];
|
|
84
|
+
};
|
|
85
|
+
for (var i = 0; i < lines.length; i++) {
|
|
86
|
+
var line = lines[i];
|
|
87
|
+
if (line.indexOf("diff --git ") === 0) {
|
|
88
|
+
flush();
|
|
89
|
+
var parsed = parseDiffGitLine(line);
|
|
90
|
+
current = {
|
|
91
|
+
path: parsed ? unquote(parsed.bPath) : "",
|
|
92
|
+
oldPath: parsed ? unquote(parsed.aPath) : undefined,
|
|
93
|
+
text: "",
|
|
94
|
+
isBinary: false,
|
|
95
|
+
additions: 0,
|
|
96
|
+
deletions: 0,
|
|
97
|
+
};
|
|
98
|
+
buf.push(line);
|
|
99
|
+
inHunk = false; // 新文件块:文件头区重新开始
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!current)
|
|
103
|
+
continue; // 首个 diff --git 之前的头部:丢弃
|
|
104
|
+
buf.push(line);
|
|
105
|
+
if (line.indexOf("@@") === 0) {
|
|
106
|
+
inHunk = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!inHunk) {
|
|
110
|
+
// 文件头区:只解析元信息,**绝不计数**——`+++ b/x` 不是新增行
|
|
111
|
+
if (line.indexOf("rename from ") === 0 && !current.oldPath) {
|
|
112
|
+
current.oldPath = unquote(line.slice("rename from ".length));
|
|
113
|
+
}
|
|
114
|
+
else if (line.indexOf("rename to ") === 0) {
|
|
115
|
+
current.path = unquote(line.slice("rename to ".length));
|
|
116
|
+
}
|
|
117
|
+
else if (line.indexOf("+++ b/") === 0 && !current.path) {
|
|
118
|
+
current.path = unquote(line.slice(6));
|
|
119
|
+
}
|
|
120
|
+
else if (line.indexOf("Binary files ") === 0 || line.indexOf("GIT binary patch") === 0) {
|
|
121
|
+
current.isBinary = true;
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// hunk 内:行首 + / - 就是真实增删(`` 不计)
|
|
126
|
+
if (line.charAt(0) === "+")
|
|
127
|
+
current.additions++;
|
|
128
|
+
else if (line.charAt(0) === "-")
|
|
129
|
+
current.deletions++;
|
|
130
|
+
}
|
|
131
|
+
flush();
|
|
132
|
+
return sections;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 在切好的块里找目标文件。
|
|
136
|
+
* 匹配优先级:新路径 → 旧路径 → 归一化后的重命名写法(`--stat` 的原始形态)。
|
|
137
|
+
* 找不到返回 null,由调用方给「该文件在本次提交中无文本差异」的兜底文案。
|
|
138
|
+
*/
|
|
139
|
+
export function findPatchSection(sections, targetPath) {
|
|
140
|
+
if (!Array.isArray(sections) || sections.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
var target = unquote(targetPath);
|
|
143
|
+
if (!target)
|
|
144
|
+
return null;
|
|
145
|
+
var want = normalizeRenamePath(target);
|
|
146
|
+
for (var i = 0; i < sections.length; i++) {
|
|
147
|
+
if (sections[i].path === target || sections[i].path === want)
|
|
148
|
+
return sections[i];
|
|
149
|
+
}
|
|
150
|
+
for (var j = 0; j < sections.length; j++) {
|
|
151
|
+
if (sections[j].oldPath && (sections[j].oldPath === target || sections[j].oldPath === want))
|
|
152
|
+
return sections[j];
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件 Diff 预览弹窗(未提交清单 / 提交详情共用)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么合成一个组件:
|
|
5
|
+
* 1. 未提交清单原本把整个 section 换成「二级视图」(`activeDiffFile ? 二级视图 : 列表`),
|
|
6
|
+
* 观感像另开一页;改成弹窗后列表留在原位,返回成本从一次重渲染变成一次 Esc;
|
|
7
|
+
* 2. 提交详情也要「点文件看 diff」,两处只是**数据来源**不同(一个是 `/api/github/file-diff`,
|
|
8
|
+
* 一个是整包 patch 切出来的一段),外壳/配色/错误态/复制逻辑完全同构,分两份必然漂移。
|
|
9
|
+
*
|
|
10
|
+
* 404 降级保留:桌面端未重启时新路由不存在,先回退到常驻的 `/api/github/action`,
|
|
11
|
+
* 再不行才显式提示「重启桌面端」——这是 file-diff 链路既有的自愈策略。
|
|
12
|
+
*/
|
|
13
|
+
export declare function FileDiffModal(props: {
|
|
14
|
+
file: {
|
|
15
|
+
path: string;
|
|
16
|
+
status?: string;
|
|
17
|
+
additions?: number;
|
|
18
|
+
deletions?: number;
|
|
19
|
+
isBinary?: boolean;
|
|
20
|
+
};
|
|
21
|
+
cwd?: string | null;
|
|
22
|
+
/** 已有补丁正文(提交详情场景:由整包 patch 切出);缺省时按未提交路由自行拉取 */
|
|
23
|
+
diffText?: string | null;
|
|
24
|
+
/** 顶部副标题(如提交短 SHA) */
|
|
25
|
+
subtitle?: string | null;
|
|
26
|
+
/** 附加提示(如补丁被截断) */
|
|
27
|
+
note?: string | null;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
error?: string | null;
|
|
30
|
+
zIndex?: number;
|
|
31
|
+
onClose: () => void;
|
|
32
|
+
onOpenInDsh?: ((filePath: string) => void) | null;
|
|
33
|
+
}): any;
|