dsh-ide-git 0.5.6 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-ide-git",
3
- "version": "0.5.6",
3
+ "version": "0.7.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/icon.svg ADDED
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" fill="none">
2
+ <circle cx="10" cy="8.5" r="3.2" stroke="#4493f8" stroke-width="1.7"/>
3
+ <circle cx="10" cy="27.5" r="3.2" stroke="#4493f8" stroke-width="1.7"/>
4
+ <circle cx="25" cy="13.5" r="3.2" stroke="#f0883e" stroke-width="1.7"/>
5
+ <path d="M10 11.7v12.6M10 18c0-4 6.4-4.5 11.8-4.5" stroke="#4493f8" stroke-width="1.7" stroke-linecap="round"/>
6
+ <circle cx="25" cy="26" r="3.2" stroke="#4493f8" stroke-width="1.7"/>
7
+ <path d="M25 16.7v6.1" stroke="#f0883e" stroke-width="1.7" stroke-linecap="round"/>
8
+ </svg>
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git Tool Window",
4
+ "description": "An IDE-style Git tool window as a native sidebar tab: branch tree, commit graph, changes and commit details with staged/unstaged diffs, submodules as repos, destructive-action guards with undo handles, and the full argv-only git surface served same-origin."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git 工具窗口",
4
+ "description": "IDE 风格的 Git 工具窗口,作为侧边栏原生标签页:分支树、提交图谱、变更与提交详情(暂存/未暂存 diff)、子模块作为独立仓库、破坏性操作守卫与撤回句柄,git 调用全部 argv 化并走同源信任围栏。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ide-git",
3
- "version": "0.5.6",
3
+ "version": "0.7.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": {
@@ -14,7 +14,8 @@
14
14
  "exports": {
15
15
  ".": "./src/index.js",
16
16
  "./client": "./src/client.js",
17
- "./package.json": "./package.json"
17
+ "./package.json": "./package.json",
18
+ "./locale/*.json": "./locale/*.json"
18
19
  },
19
20
  "engines": {
20
21
  "node": ">=18",
@@ -35,6 +36,8 @@
35
36
  }
36
37
  },
37
38
  "files": [
39
+ "icon.svg",
40
+ "locale",
38
41
  "src/index.js",
39
42
  "src/client.js",
40
43
  "cordis.patch.yml",
@@ -72,5 +75,6 @@
72
75
  "homepage": "https://github.com/KannaKuron/dsh-ide-git",
73
76
  "bugs": {
74
77
  "url": "https://github.com/KannaKuron/dsh-ide-git/issues"
75
- }
78
+ },
79
+ "icon": "./icon.svg"
76
80
  }
package/src/index.js CHANGED
@@ -1050,6 +1050,148 @@ 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
+ const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized
1067
+ /* realpath collapses producer spellings of one checkout: git's
1068
+ * --show-toplevel resolves symlinks (/private/var on macOS) while a
1069
+ * directory walk keeps the spelling it was handed (/var), and without this
1070
+ * the same submodule arrived as two rows. An unresolvable path falls back
1071
+ * to the slash-normalized form. */
1072
+ try {
1073
+ return realpathSync(key).replace(/\\/g, '/').replace(/\/+$/, '')
1074
+ } catch {
1075
+ return key
1076
+ }
1077
+ }
1078
+
1079
+ /** One repository row shipped to the client (`kind` drives the picker badge). */
1080
+ function repoRow(root, submodulePath, kind) {
1081
+ return {
1082
+ path: root.replace(/\\/g, '/'),
1083
+ /** Full checkout name; a submodule carries its repo-relative path so two
1084
+ * same-named checkouts stay apart in a long list. */
1085
+ name: submodulePath === undefined ? path.basename(root) : submodulePath,
1086
+ label: submodulePath === undefined ? path.basename(root) : submodulePath.split('/').pop(),
1087
+ branch: null,
1088
+ kind,
1089
+ ...(submodulePath === undefined ? {} : { submodulePath }),
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Every checkout registered as a submodule under `root`, at any depth.
1095
+ *
1096
+ * Git itself is the source of truth: `git submodule status --recursive` lists
1097
+ * the registered paths (relative to the parent repository) even for submodules
1098
+ * that were never initialised, and nested ones are reached by recursing into
1099
+ * each listed path. A path that is present on disk and passes
1100
+ * `rev-parse --show-toplevel` is a real, independent working tree — the parent
1101
+ * only ever sees it as one gitlink line, so this is what makes it selectable
1102
+ * as a repository of its own (the SourceTree behavior).
1103
+ *
1104
+ * The per-level `submodule status` calls are serial (a level's paths are only
1105
+ * known once its parent answered), but the working-tree checks are not: they
1106
+ * all run at once, because a repository with a dozen submodules otherwise
1107
+ * costs a dozen sequential processes on every scan.
1108
+ */
1109
+ async function collectSubmoduleRepos(root, depth = 1, prefix = '') {
1110
+ const found = []
1111
+ const listing = await runGit(root, ['submodule', 'status', '--recursive'])
1112
+ if (listing.code !== 0) return found
1113
+ const candidates = []
1114
+ for (const raw of listing.stdout.split('\n')) {
1115
+ if (candidates.length >= SUBMODULE_LIST_LIMIT) break
1116
+ const line = raw.replace(/\r$/, '')
1117
+ if (line.trim() === '') continue
1118
+ /* `<state><sha> <path> (<describe>)`: the state prefix is ' ', '+', '-' or 'U'. */
1119
+ const match = /^[\s+\-U]([0-9a-f]{4,64})\s+(.+?)(?:\s+\(.*\))?$/.exec(line)
1120
+ const rel = (match === null ? line.trim().replace(/^[\s+\-U]\S*\s+/, '') : match[2]).trim().replace(/\\/g, '/')
1121
+ if (rel === '') continue
1122
+ const absolute = path.resolve(root, rel)
1123
+ /* An uninitialised submodule has no `.git` yet: git still registers it, but
1124
+ there is no working tree to bind the panel to. */
1125
+ if (!existsSync(path.join(absolute, '.git'))) continue
1126
+ candidates.push({ absolute, rel: `${prefix}${rel}` })
1127
+ }
1128
+ const tops = await Promise.all(candidates.map((entry) => runGit(entry.absolute, ['rev-parse', '--show-toplevel'])))
1129
+ const next = []
1130
+ for (let index = 0; index < candidates.length; index += 1) {
1131
+ const top = tops[index]
1132
+ if (top.code !== 0) continue
1133
+ const reported = top.stdout.trim().replace(/\\/g, '/').replace(/\/+$/, '')
1134
+ found.push({ root: reported === '' ? candidates[index].absolute : reported, rel: candidates[index].rel })
1135
+ if (depth < SUBMODULE_MAX_DEPTH) next.push(candidates[index])
1136
+ }
1137
+ if (depth < SUBMODULE_MAX_DEPTH) {
1138
+ const deeper = await Promise.all(next.map((entry) => collectSubmoduleRepos(entry.absolute, depth + 1, `${entry.rel}/`)))
1139
+ for (const list of deeper) {
1140
+ for (const entry of list) {
1141
+ if (found.length >= SUBMODULE_LIST_LIMIT) break
1142
+ found.push({ root: entry.root, rel: entry.rel })
1143
+ }
1144
+ }
1145
+ }
1146
+ return found
1147
+ }
1148
+
1149
+ /** The whole discovery pass, cached briefly per cwd: it walks the tree and
1150
+ * spawns a process per checkout, while the panel polls several times a minute.
1151
+ * A submodule appearing or disappearing is a deliberate reconfiguration, so a
1152
+ * one-minute staleness is the right trade. */
1153
+ const repoScanCache = new Map()
1154
+ const REPO_SCAN_TTL_MS = 60_000
1155
+
1156
+ async function scanRepos(cwd) {
1157
+ const cached = repoScanCache.get(cwd)
1158
+ /* Hand back fresh row objects: the cache is shared by every caller, and a
1159
+ consumer that decorates or reorders what it received must not write
1160
+ through into the next request's answer. */
1161
+ if (cached !== undefined && cached.expires > Date.now()) return cached.rows.map((row) => ({ ...row }))
1162
+ const list = []
1163
+ const self = await runGit(cwd, ['rev-parse', '--show-toplevel'])
1164
+ const isRepo = self.code === 0
1165
+ if (isRepo) {
1166
+ const top = self.stdout.trim()
1167
+ list.push(repoRow(top === '' ? cwd : top, undefined, 'workspace'))
1168
+ }
1169
+ for (const dir of findNestedRepos(cwd, 2)) list.push(repoRow(dir, undefined, 'nested'))
1170
+ /* Submodules of EVERY discovered checkout, not only a workspace that is one:
1171
+ a container workspace (a folder holding several checkouts — the usual
1172
+ shape of a multi-repo project) reaches its repositories through the rows
1173
+ above, and each of those may carry submodules of its own. Without this
1174
+ pass the picker lists `eis` but none of the eight checkouts under it.
1175
+ These rows are built LAST and the map below lets them win: the directory
1176
+ scan can reach the same checkout through a plain path (a submodule whose
1177
+ parent directory is not itself a repository), and the submodule row is the
1178
+ better one — its name carries the repo-relative path. */
1179
+ const roots = list.map((row) => row.path)
1180
+ const submoduleLists = await Promise.all(roots.map((root) => collectSubmoduleRepos(root)))
1181
+ for (const entries of submoduleLists) {
1182
+ for (const entry of entries) list.push(repoRow(entry.root, entry.rel, 'nested'))
1183
+ }
1184
+ const byIdentity = new Map()
1185
+ for (const row of list) byIdentity.set(pathIdentity(row.path), row)
1186
+ const rows = [...byIdentity.values()]
1187
+ rows.sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'workspace' ? -1 : 1))
1188
+ const capped = rows.slice(0, REPO_LIST_LIMIT).map((row) => ({ ...row }))
1189
+ const branches = await Promise.all(capped.map((row) => branchOrNull(row.path)))
1190
+ for (let index = 0; index < capped.length; index += 1) capped[index].branch = branches[index]
1191
+ repoScanCache.set(cwd, { rows: capped, expires: Date.now() + REPO_SCAN_TTL_MS })
1192
+ return capped.map((row) => ({ ...row }))
1193
+ }
1194
+
1053
1195
  /** Depth-capped search for repositories nested in a workspace. */
1054
1196
  function findNestedRepos(root, maxDepth) {
1055
1197
  const found = []
@@ -1077,25 +1219,18 @@ async function branchOrNull(root) {
1077
1219
 
1078
1220
  /**
1079
1221
  * Repositories this panel may bind to: the session workspace itself when it is
1080
- * one, plus repositories nested inside it (a sandbox holding many checkouts).
1081
- * The client turns this into the repo picker — an empty workspace is answered
1082
- * with the picker, not with an error.
1222
+ * one, repositories nested inside it (a sandbox holding many checkouts), and
1223
+ * the submodules of every checkout discovered that way — git records each
1224
+ * submodule as its own working tree with its own HEAD, index and branches, so
1225
+ * it is a repository the panel can bind to like any other. The client turns
1226
+ * this into the repo picker, and picking a row is all it takes: every later
1227
+ * call carries that path as `repoRoot`, which is the working directory the git
1228
+ * commands run in.
1083
1229
  */
1084
1230
  async function repos(payload) {
1085
1231
  const cwd = cwdOf(payload)
1086
- const list = []
1087
- const self = await runGit(cwd, ['rev-parse', '--show-toplevel'])
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 }
1232
+ const rows = await scanRepos(cwd)
1233
+ return { cwd, isRepo: rows.some((row) => row.kind === 'workspace'), repos: rows }
1099
1234
  }
1100
1235
 
1101
1236
  async function version() {