dsh-ide-git 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/index.js +141 -16
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-ide-git",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "IDE 级 Git 操作面板:分支树 + 提交图谱 + 变更/暂存/提交 + 提交详情。装了 dsh-better-sidebar 时挂成它的原生 tab(右侧栏 + 底部工作台);没装时直接挂 DSH 原生右侧栏,单独安装即可用。JetBrains 风格 Git 工具窗口 for DeepSeek Harness.",
|
|
6
6
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ide-git",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "DSH plugin: an IDE-grade Git tool window as a native dsh-better-sidebar tab — branch tree, commit graph, changes, commit details, JetBrains-style actions, in the right sidebar and the bottom panel. | DSH 插件:IDE 级 Git 工具窗口,以 dsh-better-sidebar 原生 Tab 挂载——分支树 / 提交图谱 / 变更与提交详情 / JetBrains 风格操作,右侧栏与底部面板双布局自适应。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/index.js
CHANGED
|
@@ -1050,6 +1050,138 @@ async function compare(payload) {
|
|
|
1050
1050
|
/** Directories never descended into while looking for repositories. */
|
|
1051
1051
|
const REPO_SCAN_SKIP = new Set(['node_modules', 'dist', 'build', 'out', 'target', 'vendor', 'venv', '.venv', '__pycache__', 'coverage', 'tmp', 'temp'])
|
|
1052
1052
|
|
|
1053
|
+
/* The submodule walk spawns one short `git submodule status` per level, so the
|
|
1054
|
+
depth is capped the same way the directory scan caps its own, and every list
|
|
1055
|
+
stays bounded so a pathological tree cannot flood the picker. */
|
|
1056
|
+
const SUBMODULE_MAX_DEPTH = 2
|
|
1057
|
+
const SUBMODULE_LIST_LIMIT = 400
|
|
1058
|
+
const REPO_LIST_LIMIT = 60
|
|
1059
|
+
|
|
1060
|
+
/** Slash-normalized path, for comparing paths that came from different
|
|
1061
|
+
* producers (git prints forward slashes; `path.join` prints backslashes on
|
|
1062
|
+
* Windows). Case-insensitive on Windows so the same checkout reached two ways
|
|
1063
|
+
* collapses to one row. */
|
|
1064
|
+
function pathIdentity(value) {
|
|
1065
|
+
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
1066
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** One repository row shipped to the client (`kind` drives the picker badge). */
|
|
1070
|
+
function repoRow(root, submodulePath, kind) {
|
|
1071
|
+
return {
|
|
1072
|
+
path: root.replace(/\\/g, '/'),
|
|
1073
|
+
/** Full checkout name; a submodule carries its repo-relative path so two
|
|
1074
|
+
* same-named checkouts stay apart in a long list. */
|
|
1075
|
+
name: submodulePath === undefined ? path.basename(root) : submodulePath,
|
|
1076
|
+
label: submodulePath === undefined ? path.basename(root) : submodulePath.split('/').pop(),
|
|
1077
|
+
branch: null,
|
|
1078
|
+
kind,
|
|
1079
|
+
...(submodulePath === undefined ? {} : { submodulePath }),
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Every checkout registered as a submodule under `root`, at any depth.
|
|
1085
|
+
*
|
|
1086
|
+
* Git itself is the source of truth: `git submodule status --recursive` lists
|
|
1087
|
+
* the registered paths (relative to the parent repository) even for submodules
|
|
1088
|
+
* that were never initialised, and nested ones are reached by recursing into
|
|
1089
|
+
* each listed path. A path that is present on disk and passes
|
|
1090
|
+
* `rev-parse --show-toplevel` is a real, independent working tree — the parent
|
|
1091
|
+
* only ever sees it as one gitlink line, so this is what makes it selectable
|
|
1092
|
+
* as a repository of its own (the SourceTree behavior).
|
|
1093
|
+
*
|
|
1094
|
+
* The per-level `submodule status` calls are serial (a level's paths are only
|
|
1095
|
+
* known once its parent answered), but the working-tree checks are not: they
|
|
1096
|
+
* all run at once, because a repository with a dozen submodules otherwise
|
|
1097
|
+
* costs a dozen sequential processes on every scan.
|
|
1098
|
+
*/
|
|
1099
|
+
async function collectSubmoduleRepos(root, depth = 1, prefix = '') {
|
|
1100
|
+
const found = []
|
|
1101
|
+
const listing = await runGit(root, ['submodule', 'status', '--recursive'])
|
|
1102
|
+
if (listing.code !== 0) return found
|
|
1103
|
+
const candidates = []
|
|
1104
|
+
for (const raw of listing.stdout.split('\n')) {
|
|
1105
|
+
if (candidates.length >= SUBMODULE_LIST_LIMIT) break
|
|
1106
|
+
const line = raw.replace(/\r$/, '')
|
|
1107
|
+
if (line.trim() === '') continue
|
|
1108
|
+
/* `<state><sha> <path> (<describe>)`: the state prefix is ' ', '+', '-' or 'U'. */
|
|
1109
|
+
const match = /^[\s+\-U]([0-9a-f]{4,64})\s+(.+?)(?:\s+\(.*\))?$/.exec(line)
|
|
1110
|
+
const rel = (match === null ? line.trim().replace(/^[\s+\-U]\S*\s+/, '') : match[2]).trim().replace(/\\/g, '/')
|
|
1111
|
+
if (rel === '') continue
|
|
1112
|
+
const absolute = path.resolve(root, rel)
|
|
1113
|
+
/* An uninitialised submodule has no `.git` yet: git still registers it, but
|
|
1114
|
+
there is no working tree to bind the panel to. */
|
|
1115
|
+
if (!existsSync(path.join(absolute, '.git'))) continue
|
|
1116
|
+
candidates.push({ absolute, rel: `${prefix}${rel}` })
|
|
1117
|
+
}
|
|
1118
|
+
const tops = await Promise.all(candidates.map((entry) => runGit(entry.absolute, ['rev-parse', '--show-toplevel'])))
|
|
1119
|
+
const next = []
|
|
1120
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
1121
|
+
const top = tops[index]
|
|
1122
|
+
if (top.code !== 0) continue
|
|
1123
|
+
const reported = top.stdout.trim().replace(/\\/g, '/').replace(/\/+$/, '')
|
|
1124
|
+
found.push({ root: reported === '' ? candidates[index].absolute : reported, rel: candidates[index].rel })
|
|
1125
|
+
if (depth < SUBMODULE_MAX_DEPTH) next.push(candidates[index])
|
|
1126
|
+
}
|
|
1127
|
+
if (depth < SUBMODULE_MAX_DEPTH) {
|
|
1128
|
+
const deeper = await Promise.all(next.map((entry) => collectSubmoduleRepos(entry.absolute, depth + 1, `${entry.rel}/`)))
|
|
1129
|
+
for (const list of deeper) {
|
|
1130
|
+
for (const entry of list) {
|
|
1131
|
+
if (found.length >= SUBMODULE_LIST_LIMIT) break
|
|
1132
|
+
found.push({ root: entry.root, rel: entry.rel })
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return found
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** The whole discovery pass, cached briefly per cwd: it walks the tree and
|
|
1140
|
+
* spawns a process per checkout, while the panel polls several times a minute.
|
|
1141
|
+
* A submodule appearing or disappearing is a deliberate reconfiguration, so a
|
|
1142
|
+
* one-minute staleness is the right trade. */
|
|
1143
|
+
const repoScanCache = new Map()
|
|
1144
|
+
const REPO_SCAN_TTL_MS = 60_000
|
|
1145
|
+
|
|
1146
|
+
async function scanRepos(cwd) {
|
|
1147
|
+
const cached = repoScanCache.get(cwd)
|
|
1148
|
+
/* Hand back fresh row objects: the cache is shared by every caller, and a
|
|
1149
|
+
consumer that decorates or reorders what it received must not write
|
|
1150
|
+
through into the next request's answer. */
|
|
1151
|
+
if (cached !== undefined && cached.expires > Date.now()) return cached.rows.map((row) => ({ ...row }))
|
|
1152
|
+
const list = []
|
|
1153
|
+
const self = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
1154
|
+
const isRepo = self.code === 0
|
|
1155
|
+
if (isRepo) {
|
|
1156
|
+
const top = self.stdout.trim()
|
|
1157
|
+
list.push(repoRow(top === '' ? cwd : top, undefined, 'workspace'))
|
|
1158
|
+
}
|
|
1159
|
+
for (const dir of findNestedRepos(cwd, 2)) list.push(repoRow(dir, undefined, 'nested'))
|
|
1160
|
+
/* Submodules of EVERY discovered checkout, not only a workspace that is one:
|
|
1161
|
+
a container workspace (a folder holding several checkouts — the usual
|
|
1162
|
+
shape of a multi-repo project) reaches its repositories through the rows
|
|
1163
|
+
above, and each of those may carry submodules of its own. Without this
|
|
1164
|
+
pass the picker lists `eis` but none of the eight checkouts under it.
|
|
1165
|
+
These rows are built LAST and the map below lets them win: the directory
|
|
1166
|
+
scan can reach the same checkout through a plain path (a submodule whose
|
|
1167
|
+
parent directory is not itself a repository), and the submodule row is the
|
|
1168
|
+
better one — its name carries the repo-relative path. */
|
|
1169
|
+
const roots = list.map((row) => row.path)
|
|
1170
|
+
const submoduleLists = await Promise.all(roots.map((root) => collectSubmoduleRepos(root)))
|
|
1171
|
+
for (const entries of submoduleLists) {
|
|
1172
|
+
for (const entry of entries) list.push(repoRow(entry.root, entry.rel, 'nested'))
|
|
1173
|
+
}
|
|
1174
|
+
const byIdentity = new Map()
|
|
1175
|
+
for (const row of list) byIdentity.set(pathIdentity(row.path), row)
|
|
1176
|
+
const rows = [...byIdentity.values()]
|
|
1177
|
+
rows.sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'workspace' ? -1 : 1))
|
|
1178
|
+
const capped = rows.slice(0, REPO_LIST_LIMIT).map((row) => ({ ...row }))
|
|
1179
|
+
const branches = await Promise.all(capped.map((row) => branchOrNull(row.path)))
|
|
1180
|
+
for (let index = 0; index < capped.length; index += 1) capped[index].branch = branches[index]
|
|
1181
|
+
repoScanCache.set(cwd, { rows: capped, expires: Date.now() + REPO_SCAN_TTL_MS })
|
|
1182
|
+
return capped.map((row) => ({ ...row }))
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1053
1185
|
/** Depth-capped search for repositories nested in a workspace. */
|
|
1054
1186
|
function findNestedRepos(root, maxDepth) {
|
|
1055
1187
|
const found = []
|
|
@@ -1077,25 +1209,18 @@ async function branchOrNull(root) {
|
|
|
1077
1209
|
|
|
1078
1210
|
/**
|
|
1079
1211
|
* Repositories this panel may bind to: the session workspace itself when it is
|
|
1080
|
-
* one,
|
|
1081
|
-
*
|
|
1082
|
-
* with
|
|
1212
|
+
* one, repositories nested inside it (a sandbox holding many checkouts), and
|
|
1213
|
+
* the submodules of every checkout discovered that way — git records each
|
|
1214
|
+
* submodule as its own working tree with its own HEAD, index and branches, so
|
|
1215
|
+
* it is a repository the panel can bind to like any other. The client turns
|
|
1216
|
+
* this into the repo picker, and picking a row is all it takes: every later
|
|
1217
|
+
* call carries that path as `repoRoot`, which is the working directory the git
|
|
1218
|
+
* commands run in.
|
|
1083
1219
|
*/
|
|
1084
1220
|
async function repos(payload) {
|
|
1085
1221
|
const cwd = cwdOf(payload)
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
const isRepo = self.code === 0
|
|
1089
|
-
if (isRepo) {
|
|
1090
|
-
const top = self.stdout.trim()
|
|
1091
|
-
list.push({ path: top, name: path.basename(top), branch: await branchOrNull(top), kind: 'workspace' })
|
|
1092
|
-
}
|
|
1093
|
-
for (const dir of findNestedRepos(cwd, 2)) {
|
|
1094
|
-
if (list.some((entry) => entry.path === dir)) continue
|
|
1095
|
-
list.push({ path: dir, name: path.basename(dir), branch: await branchOrNull(dir), kind: 'nested' })
|
|
1096
|
-
}
|
|
1097
|
-
list.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'workspace' ? -1 : 1))
|
|
1098
|
-
return { cwd, isRepo, repos: list }
|
|
1222
|
+
const rows = await scanRepos(cwd)
|
|
1223
|
+
return { cwd, isRepo: rows.some((row) => row.kind === 'workspace'), repos: rows }
|
|
1099
1224
|
}
|
|
1100
1225
|
|
|
1101
1226
|
async function version() {
|