dsh-gitnexus-plus 0.1.2 → 0.1.3
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-CN.md +174 -0
- package/README.md +186 -597
- package/lib/client.js +404 -37
- package/lib/gitnexus/analyze.d.ts.map +1 -1
- package/lib/gitnexus/analyze.js +40 -0
- package/lib/gitnexus/analyze.js.map +1 -1
- package/lib/gitnexus/coverage.d.ts +16 -0
- package/lib/gitnexus/coverage.d.ts.map +1 -1
- package/lib/gitnexus/coverage.js +58 -1
- package/lib/gitnexus/coverage.js.map +1 -1
- package/lib/gitnexus/git-activity.d.ts +85 -0
- package/lib/gitnexus/git-activity.d.ts.map +1 -1
- package/lib/gitnexus/git-activity.js +286 -1
- package/lib/gitnexus/git-activity.js.map +1 -1
- package/lib/gitnexus/scan.d.ts +2 -0
- package/lib/gitnexus/scan.d.ts.map +1 -1
- package/lib/gitnexus/scan.js +3 -2
- package/lib/gitnexus/scan.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -1
- package/lib/routes/coverage.d.ts +2 -1
- package/lib/routes/coverage.d.ts.map +1 -1
- package/lib/routes/coverage.js +53 -1
- package/lib/routes/coverage.js.map +1 -1
- package/lib/routes/file-history.d.ts +21 -0
- package/lib/routes/file-history.d.ts.map +1 -0
- package/lib/routes/file-history.js +69 -0
- package/lib/routes/file-history.js.map +1 -0
- package/package.json +2 -1
package/lib/client.js
CHANGED
|
@@ -818,10 +818,160 @@ function activityView(activity) {
|
|
|
818
818
|
* whether the graph knows about it.
|
|
819
819
|
* @param {{file: any}} props
|
|
820
820
|
*/
|
|
821
|
-
function ActivityFileRow({ file }) {
|
|
821
|
+
function ActivityFileRow({ file, onSelect, }) {
|
|
822
822
|
const tone = file.verdict === 'indexed' ? 'solid' : file.verdict === 'missing' ? 'outline' : 'neutral';
|
|
823
823
|
const label = file.verdict === 'indexed' ? '已索引' : file.verdict === 'missing' ? '缺失' : '排除';
|
|
824
|
-
|
|
824
|
+
const interactive = typeof onSelect === 'function';
|
|
825
|
+
return h('div', {
|
|
826
|
+
role: interactive ? 'button' : undefined,
|
|
827
|
+
tabIndex: interactive ? 0 : undefined,
|
|
828
|
+
title: interactive ? '查看该文件的提交时间线' : undefined,
|
|
829
|
+
onClick: interactive ? () => onSelect?.(file.path) : undefined,
|
|
830
|
+
onKeyDown: interactive
|
|
831
|
+
? (event) => {
|
|
832
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
833
|
+
event.preventDefault();
|
|
834
|
+
onSelect?.(file.path);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
: undefined,
|
|
838
|
+
style: { ...styles.fileRow, cursor: interactive ? 'pointer' : undefined },
|
|
839
|
+
}, h(FileTypeIcon, { path: file.path, size: 16 }), h('span', { style: { minWidth: 0, flex: '1 1 auto' } }, h(PathLabel, { path: file.path })), h('span', { style: styles.fileMeta }, interactive
|
|
840
|
+
? h('span', { style: { ...MUTED, fontSize: '10px', whiteSpace: 'nowrap' } }, '时间线')
|
|
841
|
+
: null, h(Tag, { tone }, label), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `${file.changes}×`)));
|
|
842
|
+
}
|
|
843
|
+
/** Shared labels/tones for the three activity verdicts. */
|
|
844
|
+
function activityVerdict(verdict) {
|
|
845
|
+
if (verdict === 'indexed')
|
|
846
|
+
return { label: '已索引', tone: 'solid', dot: 'done' };
|
|
847
|
+
if (verdict === 'missing')
|
|
848
|
+
return { label: '索引缺失', tone: 'outline', dot: 'warning' };
|
|
849
|
+
return { label: '设计排除', tone: 'neutral', dot: 'idle' };
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* The three-way verdict with the volume behind it.
|
|
853
|
+
*
|
|
854
|
+
* The old UI had counts only, which made "2 missing" look as urgent as "2 excluded"
|
|
855
|
+
* even when the missing files accounted for 40 commits. This table adds the volume
|
|
856
|
+
* without changing the verdict definition.
|
|
857
|
+
*/
|
|
858
|
+
function ActivityCrossTable({ cross }) {
|
|
859
|
+
const rows = Array.isArray(cross.rows) ? cross.rows : [];
|
|
860
|
+
if (rows.length === 0)
|
|
861
|
+
return null;
|
|
862
|
+
const cell = { padding: '3px 0', textAlign: 'right' };
|
|
863
|
+
return h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, `活动 × 索引交叉对比 · ${compactNumber(cross.totalFiles ?? 0)} 文件 / ` +
|
|
864
|
+
`${compactNumber(cross.totalChanges ?? 0)} 次变更`), h('div', {
|
|
865
|
+
style: {
|
|
866
|
+
display: 'grid',
|
|
867
|
+
gridTemplateColumns: 'minmax(0, 1.5fr) repeat(4, minmax(0, 0.65fr))',
|
|
868
|
+
gap: '4px',
|
|
869
|
+
alignItems: 'center',
|
|
870
|
+
fontSize: '10px',
|
|
871
|
+
},
|
|
872
|
+
}, h('span', { style: MUTED }, '分类'), h('span', { style: { ...MUTED, ...cell } }, '文件'), h('span', { style: { ...MUTED, ...cell } }, '变更'), h('span', { style: { ...MUTED, ...cell } }, '+行'), h('span', { style: { ...MUTED, ...cell } }, '−行'), rows.map((row) => {
|
|
873
|
+
const verdict = activityVerdict(row.verdict);
|
|
874
|
+
return [
|
|
875
|
+
h('span', { key: `${row.verdict}-label`, style: { display: 'flex', alignItems: 'center', gap: '5px' } }, h(StateDot, { state: verdict.dot }), verdict.label),
|
|
876
|
+
h('span', { key: `${row.verdict}-files`, style: cell }, compactNumber(row.files)),
|
|
877
|
+
h('span', { key: `${row.verdict}-changes`, style: cell }, compactNumber(row.changes)),
|
|
878
|
+
h('span', { key: `${row.verdict}-ins`, style: cell }, `+${compactNumber(row.insertions)}`),
|
|
879
|
+
h('span', { key: `${row.verdict}-del`, style: cell }, `−${compactNumber(row.deletions)}`),
|
|
880
|
+
];
|
|
881
|
+
})));
|
|
882
|
+
}
|
|
883
|
+
/** HEAD against the commit the index was built from. */
|
|
884
|
+
function ActivityBaseline({ baseline, onSelect, }) {
|
|
885
|
+
const changedFiles = Array.isArray(baseline.changedFiles) ? baseline.changedFiles : [];
|
|
886
|
+
const short = baseline.shortSha ?? baseline.ref?.slice(0, 8) ?? '未知';
|
|
887
|
+
const date = typeof baseline.date === 'string' ? baseline.date.slice(0, 10) : '';
|
|
888
|
+
const windowBaseline = baseline.source === 'window';
|
|
889
|
+
return h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, `${windowBaseline ? '活动窗口基线审查' : '索引基线审查'} · ${short}` +
|
|
890
|
+
(date === '' ? '' : ` · ${date}`)), windowBaseline
|
|
891
|
+
? h('div', { style: { ...MUTED, fontSize: '10px', marginBottom: '3px' } }, '索引没有记录基线提交,使用活动窗口最早的提交作为基线。')
|
|
892
|
+
: null, baseline.available === false
|
|
893
|
+
? h('div', { style: { display: 'flex', gap: '6px', alignItems: 'flex-start' } }, h(StateDot, { state: 'warning' }), h('span', null, baseline.reason ?? '基线提交不可读'))
|
|
894
|
+
: h('div', { style: { ...MUTED } }, (baseline.commits ?? 0) === 0 && (baseline.files ?? 0) === 0
|
|
895
|
+
? '工作区与索引基线一致,没有新增变更。'
|
|
896
|
+
: `自基线以来 ${compactNumber(baseline.commits ?? 0)} 个提交 · ` +
|
|
897
|
+
`${compactNumber(baseline.files ?? 0)} 个文件 · ` +
|
|
898
|
+
`+${compactNumber(baseline.insertions ?? 0)} / −${compactNumber(baseline.deletions ?? 0)}`), changedFiles.length > 0
|
|
899
|
+
? h('div', { style: { marginTop: '4px' } }, changedFiles
|
|
900
|
+
.slice(0, 8)
|
|
901
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file, onSelect })))
|
|
902
|
+
: null);
|
|
903
|
+
}
|
|
904
|
+
/** Newest-first commit list with per-commit index verdict. */
|
|
905
|
+
function ActivityTimeline({ timeline, indexKnown, }) {
|
|
906
|
+
if (timeline.length === 0) {
|
|
907
|
+
return h('div', { style: { ...MUTED } }, '该时间范围内没有提交。');
|
|
908
|
+
}
|
|
909
|
+
return h('div', null, timeline.slice(0, 15).map((commit) => {
|
|
910
|
+
const verdict = indexKnown
|
|
911
|
+
? activityVerdict(commit.verdict)
|
|
912
|
+
: { label: '索引未知', tone: 'neutral', dot: 'idle' };
|
|
913
|
+
return h('div', {
|
|
914
|
+
key: commit.sha,
|
|
915
|
+
style: {
|
|
916
|
+
display: 'flex',
|
|
917
|
+
flexDirection: 'column',
|
|
918
|
+
gap: '2px',
|
|
919
|
+
padding: '4px 0',
|
|
920
|
+
borderTop: HAIRLINE,
|
|
921
|
+
minWidth: 0,
|
|
922
|
+
},
|
|
923
|
+
}, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } }, h(StateDot, { state: verdict.dot }), h('span', { style: { ...styles.mono, color: 'inherit', whiteSpace: 'nowrap' } }, commit.shortSha ?? commit.sha.slice(0, 8)), h('span', {
|
|
924
|
+
style: {
|
|
925
|
+
minWidth: 0,
|
|
926
|
+
flex: '1 1 auto',
|
|
927
|
+
overflow: 'hidden',
|
|
928
|
+
textOverflow: 'ellipsis',
|
|
929
|
+
whiteSpace: 'nowrap',
|
|
930
|
+
},
|
|
931
|
+
title: commit.subject,
|
|
932
|
+
}, commit.subject === '' ? '(无提交说明)' : commit.subject)), h('div', {
|
|
933
|
+
style: {
|
|
934
|
+
...MUTED,
|
|
935
|
+
fontSize: '10px',
|
|
936
|
+
display: 'flex',
|
|
937
|
+
flexWrap: 'wrap',
|
|
938
|
+
alignItems: 'center',
|
|
939
|
+
gap: '6px',
|
|
940
|
+
marginLeft: '14px',
|
|
941
|
+
},
|
|
942
|
+
}, h(Tag, { tone: verdict.tone }, verdict.label), h('span', { style: { whiteSpace: 'nowrap' } }, commit.date.slice(0, 10)), h('span', { style: { whiteSpace: 'nowrap' } }, commit.author), h('span', { style: { whiteSpace: 'nowrap' } }, `+${compactNumber(commit.insertions)}/−${compactNumber(commit.deletions)}`), h('span', { style: { whiteSpace: 'nowrap' } }, `${compactNumber(commit.files)} 文件`)));
|
|
943
|
+
}));
|
|
944
|
+
}
|
|
945
|
+
/** On-demand commit timeline for one file selected from the activity lists. */
|
|
946
|
+
function FileHistorySection({ filePath, history, busy, error, onClose, }) {
|
|
947
|
+
if (filePath === null)
|
|
948
|
+
return null;
|
|
949
|
+
return h('div', { style: { borderTop: HAIRLINE, paddingTop: '6px', marginTop: '2px' } }, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } }, h('span', { style: { ...MUTED, flexShrink: 0 } }, '文件时间线'), h('span', { style: { minWidth: 0, flex: '1 1 auto' } }, h(PathLabel, { path: filePath })), h(Button, { variant: 'ghost', onClick: onClose }, '关闭')), busy
|
|
950
|
+
? h('div', { style: { ...MUTED, marginTop: '4px' } }, '读取文件历史…')
|
|
951
|
+
: error !== null
|
|
952
|
+
? h('div', { style: { ...MUTED, color: 'var(--dsw-alias-state-error-primary, inherit)' } }, error)
|
|
953
|
+
: history === null
|
|
954
|
+
? null
|
|
955
|
+
: history.available === false
|
|
956
|
+
? h('div', { style: { ...MUTED, marginTop: '4px' } }, history.reason ?? '文件历史不可用')
|
|
957
|
+
: h('div', { style: { marginTop: '4px' } }, h('div', { style: { ...MUTED, fontSize: '10px', marginBottom: '3px' } }, `近 ${history.days} 天 · ${history.commits} 提交 · ` +
|
|
958
|
+
`+${compactNumber(history.insertions)} / −${compactNumber(history.deletions)}`), history.timeline.length === 0
|
|
959
|
+
? h('div', { style: { ...MUTED } }, '该时间范围内没有提交。')
|
|
960
|
+
: history.timeline.length > 30
|
|
961
|
+
? h('div', { style: { ...MUTED, fontSize: '10px', marginBottom: '2px' } }, `仅显示最近 30 个提交(共 ${history.timeline.length} 个)`)
|
|
962
|
+
: null, history.timeline.slice(0, 30).map((commit) => h('div', {
|
|
963
|
+
key: commit.sha,
|
|
964
|
+
style: { ...styles.fileRow, alignItems: 'flex-start' },
|
|
965
|
+
}, h('span', { style: { ...styles.mono, color: 'inherit', whiteSpace: 'nowrap' } }, commit.shortSha ?? commit.sha.slice(0, 8)), h('span', {
|
|
966
|
+
style: {
|
|
967
|
+
minWidth: 0,
|
|
968
|
+
flex: '1 1 auto',
|
|
969
|
+
overflow: 'hidden',
|
|
970
|
+
textOverflow: 'ellipsis',
|
|
971
|
+
whiteSpace: 'nowrap',
|
|
972
|
+
},
|
|
973
|
+
title: commit.subject,
|
|
974
|
+
}, commit.subject === '' ? '(无提交说明)' : commit.subject), h('span', { style: { ...styles.fileMeta, flexWrap: 'wrap' } }, h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, commit.date.slice(0, 10)), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, commit.author), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `+${compactNumber(commit.insertions)}/−${compactNumber(commit.deletions)}`))))));
|
|
825
975
|
}
|
|
826
976
|
/**
|
|
827
977
|
* Git activity, crossed with the index.
|
|
@@ -831,7 +981,16 @@ function ActivityFileRow({ file }) {
|
|
|
831
981
|
* never expands it learns whether recent work is visible to the graph.
|
|
832
982
|
* @param {{activity: any}} props
|
|
833
983
|
*/
|
|
834
|
-
function GitActivitySection({ activity: rawActivity }) {
|
|
984
|
+
function GitActivitySection({ activity: rawActivity, projectPath, }) {
|
|
985
|
+
// The project timeline defaults to 7 days and caps at 30; the file timeline
|
|
986
|
+
// reuses the same range. Hooks are before the "activity unavailable" return so
|
|
987
|
+
// the hook order cannot change with the data.
|
|
988
|
+
const [timelineDays, setTimelineDays] = React.useState(7);
|
|
989
|
+
const [selectedFile, setSelectedFile] = React.useState(null);
|
|
990
|
+
const [fileHistory, setFileHistory] = React.useState(null);
|
|
991
|
+
const [fileHistoryBusy, setFileHistoryBusy] = React.useState(false);
|
|
992
|
+
const [fileHistoryError, setFileHistoryError] = React.useState(null);
|
|
993
|
+
const fileHistorySeq = React.useRef(0);
|
|
835
994
|
// Narrowed to a local const before use. `activityView` returns null for an absent
|
|
836
995
|
// activity, but TypeScript cannot carry that back to the parameter, so every later
|
|
837
996
|
// read would need its own guard — and a guard per read is how one gets forgotten.
|
|
@@ -841,6 +1000,62 @@ function GitActivitySection({ activity: rawActivity }) {
|
|
|
841
1000
|
return h('div', { style: { ...MUTED } }, 'Git 活动不可用');
|
|
842
1001
|
}
|
|
843
1002
|
const missing = view.verdicts.find((v) => v.key === 'missing')?.value ?? 0;
|
|
1003
|
+
const requestFileHistory = (path, days) => {
|
|
1004
|
+
if (projectPath === null)
|
|
1005
|
+
return;
|
|
1006
|
+
const seq = fileHistorySeq.current + 1;
|
|
1007
|
+
fileHistorySeq.current = seq;
|
|
1008
|
+
setSelectedFile(path);
|
|
1009
|
+
setFileHistory(null);
|
|
1010
|
+
setFileHistoryError(null);
|
|
1011
|
+
setFileHistoryBusy(true);
|
|
1012
|
+
const query = `/api/gitnexus/file-history?repo=${encodeURIComponent(projectPath)}` +
|
|
1013
|
+
`&path=${encodeURIComponent(path)}&days=${days}`;
|
|
1014
|
+
fetch(routeUrl(query))
|
|
1015
|
+
.then(async (res) => ({ status: res.status, body: await res.json().catch(() => null) }))
|
|
1016
|
+
.then(({ status, body }) => {
|
|
1017
|
+
if (seq !== fileHistorySeq.current)
|
|
1018
|
+
return;
|
|
1019
|
+
// The client bundle can hot-reload before the host does. An unregistered
|
|
1020
|
+
// route comes back as the desktop carrier's 401/404, not the route's own
|
|
1021
|
+
// not-a-project JSON; say that instead of a generic status.
|
|
1022
|
+
if ((status === 404 && body?.code !== 'not-a-project') ||
|
|
1023
|
+
status === 401) {
|
|
1024
|
+
throw new Error('当前宿主没有文件时间线路由;重启 DSH 后再试。');
|
|
1025
|
+
}
|
|
1026
|
+
if (!body?.ok) {
|
|
1027
|
+
throw new Error(typeof body?.message === 'string' ? body.message : `请求失败 (${status})`);
|
|
1028
|
+
}
|
|
1029
|
+
setFileHistory(body);
|
|
1030
|
+
})
|
|
1031
|
+
.catch((cause) => {
|
|
1032
|
+
if (seq !== fileHistorySeq.current)
|
|
1033
|
+
return;
|
|
1034
|
+
setFileHistoryError(String(cause?.message ?? cause));
|
|
1035
|
+
})
|
|
1036
|
+
.finally(() => {
|
|
1037
|
+
if (seq === fileHistorySeq.current)
|
|
1038
|
+
setFileHistoryBusy(false);
|
|
1039
|
+
});
|
|
1040
|
+
};
|
|
1041
|
+
const openFileHistory = (path) => requestFileHistory(path, timelineDays);
|
|
1042
|
+
const closeFileHistory = () => {
|
|
1043
|
+
fileHistorySeq.current += 1;
|
|
1044
|
+
setSelectedFile(null);
|
|
1045
|
+
setFileHistory(null);
|
|
1046
|
+
setFileHistoryError(null);
|
|
1047
|
+
setFileHistoryBusy(false);
|
|
1048
|
+
};
|
|
1049
|
+
const changeTimelineDays = (days) => {
|
|
1050
|
+
setTimelineDays(days);
|
|
1051
|
+
if (selectedFile !== null)
|
|
1052
|
+
requestFileHistory(selectedFile, days);
|
|
1053
|
+
};
|
|
1054
|
+
const cutoff = Date.now() - timelineDays * 86_400_000;
|
|
1055
|
+
const projectTimeline = (Array.isArray(activity.timeline) ? activity.timeline : []).filter((commit) => {
|
|
1056
|
+
const at = Date.parse(commit.date);
|
|
1057
|
+
return !Number.isFinite(at) || at >= cutoff;
|
|
1058
|
+
});
|
|
844
1059
|
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '4px' } }, h('div', { style: MUTED }, view.totals), view.truncated
|
|
845
1060
|
? h('div', { style: MUTED }, `活动窗口达到提交上限,更早的改动未统计`)
|
|
846
1061
|
: null, h(MetricGrid, {
|
|
@@ -852,27 +1067,61 @@ function GitActivitySection({ activity: rawActivity }) {
|
|
|
852
1067
|
activity.histogram !== undefined && activity.histogram !== null
|
|
853
1068
|
? h(Histogram, { histogram: activity.histogram })
|
|
854
1069
|
: null,
|
|
1070
|
+
// Three views over the same window: what the index sees, what changed since the
|
|
1071
|
+
// index baseline, and the individual commits in order. Old hosts omit these
|
|
1072
|
+
// fields; each block disappears rather than rendering a false empty state.
|
|
1073
|
+
activity.totals?.indexKnown === false
|
|
1074
|
+
? h('div', { style: { ...MUTED } }, '项目尚未建立索引:无法把活动文件与图谱交叉,也没有索引基线可审查。')
|
|
1075
|
+
: activity.cross !== undefined && activity.cross !== null
|
|
1076
|
+
? h(ActivityCrossTable, { cross: activity.cross })
|
|
1077
|
+
: null, activity.baseline !== undefined && activity.baseline !== null
|
|
1078
|
+
? h(ActivityBaseline, { baseline: activity.baseline, onSelect: openFileHistory })
|
|
1079
|
+
: null, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } }, h('span', { style: { ...MUTED } }, `提交时间线 · 近 ${timelineDays} 天 · ${projectTimeline.length} 个`), h('span', { style: { flex: 1 } }), h('select', {
|
|
1080
|
+
value: String(timelineDays),
|
|
1081
|
+
'aria-label': '项目提交时间线范围',
|
|
1082
|
+
onChange: (event) => changeTimelineDays(Number(event.target.value)),
|
|
1083
|
+
style: {
|
|
1084
|
+
font: 'inherit',
|
|
1085
|
+
fontSize: '10px',
|
|
1086
|
+
padding: '2px 4px',
|
|
1087
|
+
border: HAIRLINE,
|
|
1088
|
+
borderRadius: '4px',
|
|
1089
|
+
background: SOFT_BG,
|
|
1090
|
+
color: 'inherit',
|
|
1091
|
+
},
|
|
1092
|
+
}, [7, 14, 30].map((days) => h('option', { key: days, value: String(days) }, `${days} 天`)))), h(ActivityTimeline, {
|
|
1093
|
+
timeline: projectTimeline,
|
|
1094
|
+
indexKnown: activity.totals?.indexKnown === true,
|
|
1095
|
+
}), activity.timelineTruncated === true
|
|
1096
|
+
? h('div', { style: { ...MUTED, fontSize: '10px' } }, '时间线最多显示最近 100 个提交;缩短范围可看更早记录。')
|
|
1097
|
+
: null,
|
|
855
1098
|
// Missing files come from their own list, not from the display-capped
|
|
856
1099
|
// by-changes slice — otherwise the summary can report a count with no rows.
|
|
857
1100
|
missing > 0 && Array.isArray(activity.missingFiles) && activity.missingFiles.length > 0
|
|
858
1101
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '应被索引却缺失'), activity.missingFiles
|
|
859
1102
|
.slice(0, 8)
|
|
860
|
-
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
1103
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file, onSelect: openFileHistory })))
|
|
861
1104
|
: null, Array.isArray(activity.byChanges) && activity.byChanges.length > 0
|
|
862
1105
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '改动最频繁'), activity.byChanges
|
|
863
1106
|
.filter((f) => f.verdict !== 'excluded')
|
|
864
1107
|
.slice(0, 8)
|
|
865
|
-
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
1108
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file, onSelect: openFileHistory })))
|
|
866
1109
|
: null, Array.isArray(activity.byRecency) && activity.byRecency.length > 0
|
|
867
1110
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '最近改动'), activity.byRecency
|
|
868
1111
|
.filter((f) => f.verdict !== 'excluded')
|
|
869
1112
|
.slice(0, 5)
|
|
870
|
-
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
1113
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file, onSelect: openFileHistory })))
|
|
871
1114
|
: null, Array.isArray(activity.authors) && activity.authors.length > 0
|
|
872
1115
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '提交者'), activity.authors.slice(0, 6).map((author) => h('div', { key: `${author.name}-${author.email}`, style: styles.fileRow }, h('span', { style: { minWidth: 0, flex: '1 1 auto' } }, author.name), h('span', { style: styles.fileMeta }, author.bot ? h(Tag, { tone: 'neutral' }, 'bot') : null, h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `${author.commits} 提交`), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `${author.files} 文件`)))))
|
|
873
1116
|
: null, Array.isArray(activity.branches) && activity.branches.length > 0
|
|
874
1117
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, `分支 · ${activity.branches.length}`), activity.branches.slice(0, 6).map((branch) => h('div', { key: branch.name, style: styles.fileRow }, h('span', { style: { minWidth: 0, flex: '1 1 auto', ...styles.mono } }, branch.name), h('span', { style: styles.fileMeta }, branch.current ? h(Tag, { tone: 'solid' }, '当前') : null, h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, branch.lastCommitDate ? branch.lastCommitDate.slice(0, 10) : '')))))
|
|
875
|
-
: null
|
|
1118
|
+
: null, h(FileHistorySection, {
|
|
1119
|
+
filePath: selectedFile,
|
|
1120
|
+
history: fileHistory,
|
|
1121
|
+
busy: fileHistoryBusy,
|
|
1122
|
+
error: fileHistoryError,
|
|
1123
|
+
onClose: closeFileHistory,
|
|
1124
|
+
}));
|
|
876
1125
|
}
|
|
877
1126
|
/**
|
|
878
1127
|
* Resolve the project this panel is about.
|
|
@@ -1344,7 +1593,37 @@ function MaintenanceSection({ maintenance, projectPath, indexed, run, runError,
|
|
|
1344
1593
|
},
|
|
1345
1594
|
}));
|
|
1346
1595
|
}
|
|
1347
|
-
function
|
|
1596
|
+
function LoadingBar({ label, percent }) {
|
|
1597
|
+
const value = percent === null || percent === undefined
|
|
1598
|
+
? undefined
|
|
1599
|
+
: Math.max(0, Math.min(100, Math.round(percent)));
|
|
1600
|
+
const text = value === undefined ? label : `${label} · ${value}%`;
|
|
1601
|
+
return h('div', {
|
|
1602
|
+
style: {
|
|
1603
|
+
gridColumn: '1 / -1',
|
|
1604
|
+
display: 'flex',
|
|
1605
|
+
flexDirection: 'column',
|
|
1606
|
+
gap: '4px',
|
|
1607
|
+
},
|
|
1608
|
+
}, h('progress', {
|
|
1609
|
+
value,
|
|
1610
|
+
max: 100,
|
|
1611
|
+
'aria-label': label,
|
|
1612
|
+
'aria-valuetext': text,
|
|
1613
|
+
'aria-valuenow': value,
|
|
1614
|
+
style: {
|
|
1615
|
+
width: '100%',
|
|
1616
|
+
height: '4px',
|
|
1617
|
+
border: 0,
|
|
1618
|
+
borderRadius: '999px',
|
|
1619
|
+
overflow: 'hidden',
|
|
1620
|
+
background: 'var(--dsw-alias-bg-layer-3, rgba(127, 127, 127, 0.16))',
|
|
1621
|
+
accentColor: 'var(--dsw-alias-brand-primary, #4f8cff)',
|
|
1622
|
+
color: 'var(--dsw-alias-brand-primary, #4f8cff)',
|
|
1623
|
+
},
|
|
1624
|
+
}), h('div', { style: { ...MUTED, fontSize: '10px' } }, text));
|
|
1625
|
+
}
|
|
1626
|
+
function CoverageView({ report, maintenance, projectPath, context, busy, progress, error, readAt, run, runError, now, onRefresh, onStartRun, onCancelRun, installRun, installError, onInstall, onCancelInstall, search, }) {
|
|
1348
1627
|
// Whether an index exists decides which maintenance command is applicable. When
|
|
1349
1628
|
// the project could not even be identified there is nothing to claim, so it is
|
|
1350
1629
|
// false rather than assumed.
|
|
@@ -1365,6 +1644,16 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1365
1644
|
title: '重新读取报告(不会重建索引;重建见下方「维护命令」)',
|
|
1366
1645
|
'aria-label': '重新读取报告',
|
|
1367
1646
|
}, h(IconRefreshOutlineRegular, { size: 14 })));
|
|
1647
|
+
// A report already on screen stays readable while a manual refresh runs; the bar
|
|
1648
|
+
// rides above it. Before the first report exists, the bar is the whole body.
|
|
1649
|
+
// `percent` is null when the host does not stream (old host, or a cached read):
|
|
1650
|
+
// the native progress element is then indeterminate, not a made-up number.
|
|
1651
|
+
const progressBar = report === null || busy
|
|
1652
|
+
? h(LoadingBar, {
|
|
1653
|
+
label: progress?.label ?? (report === null ? '正在读取索引…' : '正在刷新索引…'),
|
|
1654
|
+
percent: progress?.percent ?? null,
|
|
1655
|
+
})
|
|
1656
|
+
: null;
|
|
1368
1657
|
// "I cannot tell which project this is" and "this project has no index" are
|
|
1369
1658
|
// different facts, and the first revision conflated them: an unresolved project
|
|
1370
1659
|
// fell through to the no-index branch and told the operator to initialise an
|
|
@@ -1385,7 +1674,7 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1385
1674
|
}, h(MaintenanceSection, { maintenance, projectPath, indexed: false, run, runError, now, onStartRun, onCancelRun })));
|
|
1386
1675
|
}
|
|
1387
1676
|
if (report === null) {
|
|
1388
|
-
return h('div', { style: styles.root }, header,
|
|
1677
|
+
return h('div', { style: styles.root }, header, progressBar);
|
|
1389
1678
|
}
|
|
1390
1679
|
// Defaults are resolved before the no-index branch: a scan-only report still has
|
|
1391
1680
|
// totals, a cap and languages even though it has no graph. Building them only for
|
|
@@ -1403,7 +1692,7 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1403
1692
|
// only "没有索引" and a command, which made an otherwise healthy repository look
|
|
1404
1693
|
// empty. The scan is the "at least something to look at" floor.
|
|
1405
1694
|
if (report.repo?.indexed !== true) {
|
|
1406
|
-
return h('div', { style: styles.root }, h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header), h(ToolchainNotice, {
|
|
1695
|
+
return h('div', { style: styles.root }, h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header), progressBar, h(ToolchainNotice, {
|
|
1407
1696
|
toolchain: report.toolchain,
|
|
1408
1697
|
run: installRun,
|
|
1409
1698
|
runError: installError,
|
|
@@ -1430,7 +1719,7 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1430
1719
|
return h('div', { style: styles.root },
|
|
1431
1720
|
// The header and the two status lines span everything: they describe the whole
|
|
1432
1721
|
// panel, so giving them a column would make them look like one card's heading.
|
|
1433
|
-
h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header),
|
|
1722
|
+
h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header), progressBar,
|
|
1434
1723
|
// Immediately after the header, before any index content. If GitNexus is missing,
|
|
1435
1724
|
// that single fact explains everything the panel is about to show or fail to show,
|
|
1436
1725
|
// so it belongs above the numbers rather than beside them.
|
|
@@ -1566,7 +1855,7 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1566
1855
|
meta: h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '5px', ...MUTED, fontSize: '11px' } }, h(StateDot, {
|
|
1567
1856
|
state: activityView(report.activity)?.verdicts.find((v) => v.key === 'missing')?.tone ?? 'done',
|
|
1568
1857
|
}), activityCollapsed(report.activity)),
|
|
1569
|
-
}, h(GitActivitySection, { activity: report.activity })),
|
|
1858
|
+
}, h(GitActivitySection, { activity: report.activity, projectPath })),
|
|
1570
1859
|
// Wide, and always open: the commands are the action this panel offers.
|
|
1571
1860
|
maintenance === null || maintenance === undefined
|
|
1572
1861
|
? null
|
|
@@ -1596,6 +1885,7 @@ function CoveragePanel(props) {
|
|
|
1596
1885
|
const [maintenance, setMaintenance] = React.useState(null);
|
|
1597
1886
|
const [error, setError] = React.useState(null);
|
|
1598
1887
|
const [busy, setBusy] = React.useState(false);
|
|
1888
|
+
const [progress, setProgress] = React.useState(null);
|
|
1599
1889
|
const [reloadToken, setReloadToken] = React.useState(0);
|
|
1600
1890
|
// When the report on screen was read. Without it a refresh of an unchanged
|
|
1601
1891
|
// repository produces a byte-identical panel, so the control looks inert even
|
|
@@ -1933,32 +2223,107 @@ function CoveragePanel(props) {
|
|
|
1933
2223
|
const controller = new AbortController();
|
|
1934
2224
|
setBusy(true);
|
|
1935
2225
|
setError(null);
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2226
|
+
setProgress({ percent: 0, label: '连接索引服务…' });
|
|
2227
|
+
const query = `/api/gitnexus/coverage?repo=${encodeURIComponent(projectPath)}` +
|
|
2228
|
+
(wantsFresh ? '&refresh=1' : '') +
|
|
2229
|
+
'&stream=1';
|
|
2230
|
+
const load = async () => {
|
|
2231
|
+
try {
|
|
2232
|
+
const res = await fetch(routeUrl(query), { signal: controller.signal });
|
|
2233
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
2234
|
+
if (contentType.includes('application/x-ndjson') && res.body !== null) {
|
|
2235
|
+
const reader = res.body.getReader();
|
|
2236
|
+
const decoder = new TextDecoder();
|
|
2237
|
+
const reports = [];
|
|
2238
|
+
let buffer = '';
|
|
2239
|
+
const consume = (line) => {
|
|
2240
|
+
if (line.trim() === '' || controller.signal.aborted)
|
|
2241
|
+
return;
|
|
2242
|
+
let message;
|
|
2243
|
+
try {
|
|
2244
|
+
message = JSON.parse(line);
|
|
2245
|
+
}
|
|
2246
|
+
catch {
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (message.type === 'progress') {
|
|
2250
|
+
const raw = typeof message.percent === 'number' ? message.percent : 0;
|
|
2251
|
+
const percent = Math.max(0, Math.min(100, Math.round(raw)));
|
|
2252
|
+
const label = typeof message.label === 'string' ? message.label : '读取索引…';
|
|
2253
|
+
setProgress({ percent, label });
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (message.type === 'report') {
|
|
2257
|
+
reports.push(message);
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
if (message.type === 'error') {
|
|
2261
|
+
throw new Error(typeof message.message === 'string' ? message.message : '读取索引失败。');
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
for (;;) {
|
|
2265
|
+
const chunk = await reader.read();
|
|
2266
|
+
if (chunk.done)
|
|
2267
|
+
break;
|
|
2268
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
2269
|
+
let newline = buffer.indexOf('\n');
|
|
2270
|
+
while (newline >= 0) {
|
|
2271
|
+
const line = buffer.slice(0, newline);
|
|
2272
|
+
buffer = buffer.slice(newline + 1);
|
|
2273
|
+
consume(line);
|
|
2274
|
+
newline = buffer.indexOf('\n');
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
buffer += decoder.decode();
|
|
2278
|
+
if (buffer.trim() !== '')
|
|
2279
|
+
consume(buffer);
|
|
2280
|
+
const payload = reports[0];
|
|
2281
|
+
if (payload === undefined)
|
|
2282
|
+
throw new Error('读取中断:没有收到完整报告。');
|
|
2283
|
+
if (controller.signal.aborted)
|
|
2284
|
+
return;
|
|
2285
|
+
setReport(payload.report);
|
|
2286
|
+
setMaintenance(payload.maintenance ?? null);
|
|
2287
|
+
setReadAt(typeof payload.readAt === 'string' ? payload.readAt : new Date().toISOString());
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
// An older host ignores `stream=1` and answers plain JSON. Drop the numeric
|
|
2291
|
+
// channel and let the native progress element go indeterminate rather than
|
|
2292
|
+
// leaving a frozen 0%.
|
|
2293
|
+
setProgress(null);
|
|
2294
|
+
const body = (await res.json().catch(() => null));
|
|
2295
|
+
// `not-indexed` is a state the panel renders, not a failure: the whole
|
|
2296
|
+
// point of leading with the initialize command is that reaching it is
|
|
2297
|
+
// expected on a project that has never been analysed.
|
|
2298
|
+
if (res.status === 404 && body?.code === 'not-indexed') {
|
|
2299
|
+
setReport({ repo: { indexed: false, path: projectPath } });
|
|
2300
|
+
setMaintenance(null);
|
|
2301
|
+
setReadAt(body?.readAt ?? new Date().toISOString());
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
if (!body?.ok || body.report === undefined) {
|
|
2305
|
+
throw new Error(body?.message ?? `请求失败 (${res.status})`);
|
|
2306
|
+
}
|
|
2307
|
+
setReport(body.report);
|
|
2308
|
+
setMaintenance(body.maintenance ?? null);
|
|
2309
|
+
setReadAt(typeof body.readAt === 'string' ? body.readAt : new Date().toISOString());
|
|
1950
2310
|
}
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2311
|
+
catch (cause) {
|
|
2312
|
+
if (controller.signal.aborted)
|
|
2313
|
+
return;
|
|
2314
|
+
const error = cause;
|
|
2315
|
+
if (error?.name === 'AbortError')
|
|
2316
|
+
return;
|
|
2317
|
+
setError(String(error?.message ?? cause));
|
|
2318
|
+
}
|
|
2319
|
+
finally {
|
|
2320
|
+
if (!controller.signal.aborted) {
|
|
2321
|
+
setBusy(false);
|
|
2322
|
+
setProgress(null);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
void load();
|
|
1962
2327
|
return () => controller.abort();
|
|
1963
2328
|
}, [projectPath, reloadToken]);
|
|
1964
2329
|
const fallbackMaintenance = React.useMemo(() => (maintenance === null && projectPath !== null ? { commands: [], rationale: [] } : maintenance), [maintenance, projectPath]);
|
|
@@ -1968,6 +2333,7 @@ function CoveragePanel(props) {
|
|
|
1968
2333
|
projectPath,
|
|
1969
2334
|
context: projectPath === null ? contextDiagnostic(props) : null,
|
|
1970
2335
|
busy,
|
|
2336
|
+
progress,
|
|
1971
2337
|
error,
|
|
1972
2338
|
readAt,
|
|
1973
2339
|
run,
|
|
@@ -2117,6 +2483,7 @@ module.exports = {
|
|
|
2117
2483
|
MetricGrid,
|
|
2118
2484
|
Histogram,
|
|
2119
2485
|
GitActivitySection,
|
|
2486
|
+
FileHistorySection,
|
|
2120
2487
|
ProvenanceBlock,
|
|
2121
2488
|
MaintenanceSection,
|
|
2122
2489
|
CapabilityStrip,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../../src/gitnexus/analyze.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;
|
|
1
|
+
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../../src/gitnexus/analyze.ts"],"names":[],"mappings":"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"}
|
package/lib/gitnexus/analyze.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* deployment's decision. A denial is then reported as a denial, not retried wider:
|
|
28
28
|
* the same rule the subagent prompt states, for the same reason.
|
|
29
29
|
*/
|
|
30
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
30
31
|
import path from 'node:path';
|
|
31
32
|
import { defaultGitnexusHome, effectiveSkipFts } from './commands.js';
|
|
32
33
|
import { findGitnexusCli } from './toolchain.js';
|
|
@@ -103,6 +104,34 @@ export function analyzeCommandLine(cliPath, repoPath, options = {}) {
|
|
|
103
104
|
function looksDenied(text) {
|
|
104
105
|
return /\[sandbox:[^\]]*denied/i.test(text) || /access is denied|permission denied|EPERM/i.test(text);
|
|
105
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* GitNexus 1.6.12 creates these cache directories from the parse worker.
|
|
109
|
+
*
|
|
110
|
+
* A worker running under a restricted host token can be denied `mkdir` on the
|
|
111
|
+
* project's inherited `.gitnexus` ACL even though it can write files there. The
|
|
112
|
+
* failure is EPERM at the first shard write and takes the whole parse phase down
|
|
113
|
+
* with it. Creating the directories from the host, before the shell starts, makes
|
|
114
|
+
* the worker use the directories that already exist. Returns an actionable error
|
|
115
|
+
* instead of letting the worker fail on a path the panel then has to explain.
|
|
116
|
+
*/
|
|
117
|
+
const ANALYZE_CACHE_DIRS = ['parsedfile-store', 'parsedfile-cache', 'parse-cache'];
|
|
118
|
+
function prepareAnalyzeCacheDirs(repoPath) {
|
|
119
|
+
const indexDir = path.join(repoPath, '.gitnexus');
|
|
120
|
+
if (!existsSync(indexDir))
|
|
121
|
+
return null;
|
|
122
|
+
for (const name of ANALYZE_CACHE_DIRS) {
|
|
123
|
+
const target = path.join(indexDir, name);
|
|
124
|
+
try {
|
|
125
|
+
mkdirSync(target, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
catch (cause) {
|
|
128
|
+
return (`无法创建 GitNexus 缓存目录(${path.join('.gitnexus', name)}):` +
|
|
129
|
+
`${String(cause?.message ?? cause)}。` +
|
|
130
|
+
'请给项目 .gitnexus 目录写权限,或在普通终端执行下面的命令。');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
106
135
|
/** Keep the last `limit` characters, on a line boundary when one is available. */
|
|
107
136
|
function tail(text, limit) {
|
|
108
137
|
if (text.length <= limit)
|
|
@@ -201,6 +230,17 @@ export function createAnalyzeRunner(options) {
|
|
|
201
230
|
skipFts,
|
|
202
231
|
gitnexusHome,
|
|
203
232
|
});
|
|
233
|
+
const cacheError = prepareAnalyzeCacheDirs(key);
|
|
234
|
+
if (cacheError !== null) {
|
|
235
|
+
return finish(key, {
|
|
236
|
+
state: 'failed',
|
|
237
|
+
force,
|
|
238
|
+
command,
|
|
239
|
+
startedAt: now(),
|
|
240
|
+
output: '',
|
|
241
|
+
message: cacheError,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
204
244
|
const controller = new AbortController();
|
|
205
245
|
inFlight.set(key, controller);
|
|
206
246
|
const started = {
|