dsh-git-changes 0.0.0-stage → 0.1.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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +119 -0
  3. package/README.md +137 -2
  4. package/THIRD_PARTY_NOTICES.md +12 -0
  5. package/assets/vendor/monaco/LICENSE +21 -0
  6. package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
  7. package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  8. package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
  9. package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
  10. package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
  11. package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  12. package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  13. package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  14. package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  15. package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  16. package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  17. package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  18. package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  19. package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  20. package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  21. package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  22. package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  23. package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  24. package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  25. package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  26. package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  27. package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  28. package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  29. package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  30. package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  31. package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  32. package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  33. package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  34. package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  35. package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  36. package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  37. package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  38. package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  39. package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  40. package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  41. package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  42. package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  43. package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  44. package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  45. package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  46. package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  47. package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  48. package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  49. package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  50. package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  51. package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  52. package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  53. package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  54. package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  55. package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  56. package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  57. package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  58. package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  59. package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  60. package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  61. package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  62. package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  63. package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  64. package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  65. package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  66. package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  67. package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  68. package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  69. package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  70. package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  71. package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  72. package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  73. package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  74. package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  75. package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  76. package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  77. package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  78. package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  79. package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  80. package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  81. package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  82. package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  83. package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  84. package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  85. package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  86. package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  87. package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  88. package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  89. package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  90. package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  91. package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
  92. package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
  93. package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
  94. package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
  95. package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
  96. package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
  97. package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  98. package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
  99. package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
  100. package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
  101. package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  102. package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
  103. package/assets/vendor/monaco/vs/loader.js +10 -0
  104. package/cordis.patch.yml +3 -0
  105. package/docs/images/git-changes-diff.png +0 -0
  106. package/docs/regression-checklist.md +47 -0
  107. package/icon.svg +10 -0
  108. package/lib/client.js +9418 -0
  109. package/lib/host/assets.js +161 -0
  110. package/lib/host/commit-message.js +158 -0
  111. package/lib/host/files-index.js +206 -0
  112. package/lib/host/git-write.js +194 -0
  113. package/lib/host/git.js +1147 -0
  114. package/lib/host/lsp.js +842 -0
  115. package/lib/host/routes.js +976 -0
  116. package/lib/host/session.js +84 -0
  117. package/lib/host/util.js +107 -0
  118. package/lib/index.js +33 -0
  119. package/licenses/dsh-vscode-mode-LICENSE +21 -0
  120. package/licenses/vscode-theme-seti-LICENSE +21 -0
  121. package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
  122. package/locale/en.json +6 -0
  123. package/locale/zh.json +6 -0
  124. package/package.json +65 -4
  125. package/src/client/00-const.js +79 -0
  126. package/src/client/05-utils.js +277 -0
  127. package/src/client/10-i18n.js +548 -0
  128. package/src/client/12-vscode-icons.js +460 -0
  129. package/src/client/15-theme.js +509 -0
  130. package/src/client/20-monaco.js +1404 -0
  131. package/src/client/22-lsp.js +592 -0
  132. package/src/client/25-tree.js +563 -0
  133. package/src/client/28-changes.js +1163 -0
  134. package/src/client/30-page.js +2259 -0
  135. package/src/client/35-pill.js +185 -0
  136. package/src/client/40-commands.js +248 -0
  137. package/src/client/45-css.js +357 -0
  138. package/src/client/47-pickers.js +457 -0
  139. package/src/client/50-apply.js +207 -0
  140. package/src/client/99-footer.js +62 -0
@@ -0,0 +1,563 @@
1
+ /* dsh-git-changes client half — lazy workspace file tree styled like the VS Code Explorer. */
2
+
3
+ /** Monaco language id → VS Code/Seti language id (only the ones that differ). */
4
+ const SETI_LANG_ALIAS = {
5
+ shell: 'shellscript',
6
+ bat: 'bat',
7
+ dockerfile: 'dockerfile',
8
+ plaintext: '',
9
+ }
10
+
11
+ /** Codicon codepoints used by the page chrome (from the Monaco codicon font). */
12
+ const CODICON_GLYPHS = {
13
+ chevronRight: '\uEAB6',
14
+ chevronDown: '\uEAB4',
15
+ close: '\uEA76',
16
+ ellipsis: '\uEA7C',
17
+ search: '\uEA6D',
18
+ refresh: '\uEB37',
19
+ diff: '\uEAE1',
20
+ files: '\uEAF0',
21
+ filter: '\uEAF1',
22
+ add: '\uEA60',
23
+ }
24
+
25
+ /**
26
+ * Resolve the Seti file icon for one path (VS Code icon theme precedence:
27
+ * exact file name, longest extension chain, language id, default file icon).
28
+ * @param {string} path workspace-relative or absolute path
29
+ * @param {'light'|'dark'} scheme active color scheme
30
+ * @returns {{char:string,color:string,lightColor:string,lightChar?:string}|null}
31
+ */
32
+ function vscodeIconFor(path, scheme) {
33
+ const theme = typeof VSCODE_ICON_THEME !== 'undefined' ? VSCODE_ICON_THEME : null
34
+ if (!theme) return null
35
+ let icon = theme.forPath(path)
36
+ if (!icon) {
37
+ const language = (typeof languageOf === 'function' ? languageOf(path) : '') || ''
38
+ const alias = Object.prototype.hasOwnProperty.call(SETI_LANG_ALIAS, language) ? SETI_LANG_ALIAS[language] : language
39
+ icon = alias ? theme.languageIds[alias] : undefined
40
+ }
41
+ if (!icon) icon = theme.file
42
+ return icon || null
43
+ }
44
+
45
+ /**
46
+ * One VS Code codicon glyph (chevrons, close, ellipsis…).
47
+ * @param {object} props `char` glyph key, `size` optional font size
48
+ * @returns {any} React element
49
+ */
50
+ function Codicon(props) {
51
+ const { char, size, className } = props
52
+ return h('span', {
53
+ className: cx('drp-codicon', className),
54
+ style: size ? { fontSize: size } : undefined,
55
+ 'aria-hidden': true,
56
+ }, CODICON_GLYPHS[char] || '')
57
+ }
58
+
59
+ /**
60
+ * One Seti file icon at VS Code metrics (16 px box, 6 px gap, 150 % glyph).
61
+ * @param {object} props `path` file path, `scheme` active scheme
62
+ * @returns {any} React element
63
+ */
64
+ function FileIcon(props) {
65
+ const { path, scheme } = props
66
+ const icon = vscodeIconFor(path, scheme)
67
+ if (!icon) return h('span', { className: 'drp-tree-icon' })
68
+ const char = icon.lightChar && scheme === 'light' ? icon.lightChar : icon.char
69
+ const color = scheme === 'light' ? icon.lightColor || icon.color : icon.color
70
+ return h('span', {
71
+ className: 'drp-tree-icon drp-seti-icon',
72
+ style: { color },
73
+ 'data-drp-icon': char,
74
+ }, char)
75
+ }
76
+
77
+ /**
78
+ * Folder glyph shared by the change tree and the project tree.
79
+ * @returns {any} React element
80
+ */
81
+ function FolderGlyph() {
82
+ return h(
83
+ 'svg',
84
+ {
85
+ width: 12,
86
+ height: 12,
87
+ viewBox: '0 0 16 16',
88
+ 'aria-hidden': 'true',
89
+ focusable: 'false',
90
+ fill: 'currentColor',
91
+ fillOpacity: 0.18,
92
+ stroke: 'currentColor',
93
+ strokeWidth: 1.2,
94
+ strokeLinejoin: 'round',
95
+ },
96
+ h('path', { d: 'M1.6 3.4h4.3l1.4 1.7h7.1v7.5H1.6z' }),
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Build a map of normalized path → change badge data.
102
+ * @param {Array<object> | null} files summary files
103
+ * @param {boolean} enabled whether badges are shown
104
+ * @returns {Map<string, object>}
105
+ */
106
+ function changedMapOf(files, enabled) {
107
+ const map = new Map()
108
+ if (!enabled || !Array.isArray(files)) return map
109
+ for (const file of files) {
110
+ if (!file || typeof file.path !== 'string') continue
111
+ const key = normalizeRel(file.path)
112
+ if (key !== '') map.set(key, file)
113
+ }
114
+ return map
115
+ }
116
+
117
+ /**
118
+ * One change badge: +added −deleted, or a binary/oversized marker.
119
+ * @param {object} file changed-file record
120
+ * @returns {any} React element
121
+ */
122
+ function ChangeBadge(file) {
123
+ if (file.binary === true) return h('span', { className: 'drp-badge drp-badge-muted', title: 'binary' }, 'bin')
124
+ if (file.oversized === true) return h('span', { className: 'drp-badge drp-badge-muted', title: 'too large' }, 'big')
125
+ return h(
126
+ 'span',
127
+ { className: 'drp-badge' },
128
+ h('span', { className: 'drp-badge-add' }, '+' + fmtInt(file.added)),
129
+ h('span', { className: 'drp-badge-del' }, '−' + fmtInt(file.deleted)),
130
+ )
131
+ }
132
+
133
+ /**
134
+ * Build an absolute host path from the session cwd and a workspace-relative path.
135
+ * @param {string | undefined} cwd session working directory
136
+ * @param {string} rel workspace-relative posix path
137
+ * @returns {string} absolute path using the cwd's separators
138
+ */
139
+ function absolutePathOf(cwd, rel) {
140
+ const root = String(cwd || '').replace(/[\\/]+$/, '')
141
+ const tail = String(rel || '').replace(/^[\\/]+/, '')
142
+ if (!root) return tail
143
+ const separator = root.includes('\\') ? '\\' : '/'
144
+ return root + separator + (separator === '\\' ? tail.replace(/\//g, '\\') : tail)
145
+ }
146
+
147
+ /**
148
+ * One git status letter badge (M/A/D/R/C/U), coloured like VS Code's
149
+ * git decorations. Diff-range statuses are folded onto the four badge colours
150
+ * (`T` type change reads as modified, `U`/`?`/anything else as unmerged).
151
+ * @param {{path:string, code:string}} entry git status record
152
+ * @returns {any} React element
153
+ */
154
+ function GitBadge(entry) {
155
+ const raw = typeof entry.code === 'string' ? entry.code : ''
156
+ const code = raw === 'T' ? 'M' : /^[MADRC]$/.test(raw) ? raw : 'U'
157
+ const className = code === 'U' ? 'drp-git-other' : 'drp-git-' + code
158
+ return h('span', { className: cx('drp-git-badge', className), title: 'Git: ' + (raw || '?') }, code)
159
+ }
160
+
161
+ /**
162
+ * Indentation (px) of a row's left edge inside the tree, matching VS Code:
163
+ * 0 for depth 0, then `8 + (depth-1)*8` (the 4 px row inset comes from CSS).
164
+ * @param {number} depth zero-based row depth
165
+ * @returns {number}
166
+ */
167
+ function treeIndentOf(depth) {
168
+ const level = Math.max(0, Number(depth) || 0)
169
+ return level === 0 ? 0 : 8 + (level - 1) * 8
170
+ }
171
+
172
+ /**
173
+ * A directory row plus its lazily loaded children. The implicit workspace root
174
+ * is not rendered as a row (VS Code's Explorer lists the top-level entries
175
+ * directly under the section header).
176
+ * @param {object} props component props
177
+ * @returns {any} React element
178
+ */
179
+ function TreeDir(props) {
180
+ const { relPath, depth, expanded, loadState, onToggle, onOpen, onContextMenu, activePath, changed, badgesEnabled, gitBadges, scheme } = props
181
+ const isExpanded = expanded.has(relPath)
182
+ const state = loadState(relPath)
183
+ const children = state && Array.isArray(state.entries) ? state.entries : []
184
+ const content = []
185
+ if (state && state.status === 'loading' && children.length === 0) {
186
+ content.push(h('div', {
187
+ key: relPath + '::loading',
188
+ className: 'drp-tree-note',
189
+ style: { paddingLeft: (treeIndentOf(depth + 1) + 22) + 'px' },
190
+ }, '…'))
191
+ }
192
+ for (const entry of children) {
193
+ const childRel = joinRel(relPath, entry.name)
194
+ if (entry.type === 'directory') {
195
+ content.push(h(TreeDir, {
196
+ key: childRel,
197
+ relPath: childRel,
198
+ depth: depth + 1,
199
+ expanded,
200
+ loadState,
201
+ onToggle,
202
+ onOpen,
203
+ onContextMenu,
204
+ activePath,
205
+ changed,
206
+ badgesEnabled,
207
+ gitBadges,
208
+ scheme,
209
+ }))
210
+ continue
211
+ }
212
+ const badge = badgesEnabled ? changed.get(childRel) : undefined
213
+ const gitEntry = gitBadges ? gitBadges.get(childRel) : undefined
214
+ const isActive = activePath === childRel
215
+ content.push(h(
216
+ 'div',
217
+ {
218
+ key: childRel,
219
+ className: cx('drp-tree-row drp-tree-file', isActive && 'drp-tree-active'),
220
+ style: { paddingLeft: (8 + treeIndentOf(depth + 1)) + 'px' },
221
+ onClick: () => onOpen(childRel),
222
+ onContextMenu: (event) => onContextMenu(event, childRel),
223
+ title: childRel + (gitEntry ? '\nGit: ' + gitEntry.code : ''),
224
+ role: 'treeitem',
225
+ 'data-drp-path': childRel,
226
+ },
227
+ h('span', { className: 'drp-tree-twisty' }),
228
+ h(FileIcon, { path: childRel, scheme }),
229
+ h('span', { className: 'drp-tree-name' }, entry.name),
230
+ gitEntry ? GitBadge(gitEntry) : null,
231
+ badge ? h(ChangeBadge, badge) : null,
232
+ ))
233
+ }
234
+ if (state && state.truncated) {
235
+ content.push(h('div', {
236
+ key: relPath + '::truncated',
237
+ className: 'drp-tree-note',
238
+ style: { paddingLeft: (treeIndentOf(depth + 1) + 22) + 'px' },
239
+ }, '… truncated'))
240
+ }
241
+ if (relPath === '') {
242
+ // Implicit workspace root: only its children are rendered.
243
+ return h('div', { className: 'drp-tree-branch drp-tree-root' }, content)
244
+ }
245
+ return h(
246
+ 'div',
247
+ { className: 'drp-tree-branch' },
248
+ h(
249
+ 'div',
250
+ {
251
+ className: cx('drp-tree-row drp-tree-dir', isExpanded && 'drp-tree-dir-open'),
252
+ style: { paddingLeft: (8 + treeIndentOf(depth)) + 'px' },
253
+ onClick: () => onToggle(relPath),
254
+ role: 'treeitem',
255
+ 'aria-expanded': isExpanded,
256
+ 'data-drp-dir': relPath,
257
+ title: relPath,
258
+ },
259
+ h('span', { className: cx('drp-tree-twisty', isExpanded && 'drp-tree-twisty-open') },
260
+ h(Codicon, { char: isExpanded ? 'chevronDown' : 'chevronRight' })),
261
+ h('span', { className: 'drp-folder-icon' }, h(FolderGlyph)),
262
+ h('span', { className: 'drp-tree-name' }, basename(relPath)),
263
+ state && state.status === 'loading' ? h('span', { className: 'drp-tree-note' }, '…') : null,
264
+ state && state.status === 'error' ? h('span', { className: 'drp-tree-note drp-tree-error' }, '!') : null,
265
+ ),
266
+ isExpanded ? h('div', { className: 'drp-tree-children' }, content) : null,
267
+ )
268
+ }
269
+
270
+ /**
271
+ * Workspace tree panel: lazy directories, change badges, "changes only" flat list,
272
+ * reveal-in-tree and manual refresh. Visual metrics follow VS Code's Explorer
273
+ * (22 px rows, 8 px indent, 13 px UI font, Seti icons, 28 px section header).
274
+ * @param {object} props component props
275
+ * @returns {any} React element
276
+ */
277
+ function FileTree(props) {
278
+ const {
279
+ sessionId, cwd, summaryFiles, gitStatus, activePath, treeMode,
280
+ onOpen, reveal, refreshToken, t, onPreview, onNotify, scheme,
281
+ } = props
282
+ const activeScheme = scheme === 'light' ? 'light' : 'dark'
283
+ const [expanded, setExpanded] = React.useState(() => new Set(['']))
284
+ const [cache, setCache] = React.useState(() => new Map())
285
+ const [error, setError] = React.useState(null)
286
+ const [ctxMenu, setCtxMenu] = React.useState(null)
287
+ const loadingRef = React.useRef(new Map())
288
+ const generationRef = React.useRef(0)
289
+ const rootRef = React.useRef(null)
290
+ const gitBadges = React.useMemo(() => {
291
+ if ((treeMode !== 'git' && treeMode !== 'revision') || !gitStatus || gitStatus.available !== true) return null
292
+ const map = new Map()
293
+ for (const entry of Array.isArray(gitStatus.files) ? gitStatus.files : []) {
294
+ if (!entry || typeof entry.path !== 'string') continue
295
+ const key = normalizeRel(entry.path)
296
+ if (key !== '') map.set(key, entry)
297
+ }
298
+ return map
299
+ }, [treeMode, gitStatus])
300
+ const cacheRef = React.useRef(cache)
301
+ cacheRef.current = cache
302
+ const expandedRef = React.useRef(expanded)
303
+ expandedRef.current = expanded
304
+ const changed = React.useMemo(() => changedMapOf(summaryFiles, treeMode === 'turn'), [summaryFiles, treeMode])
305
+
306
+ /** Open the tree context menu at the pointer for one file row. */
307
+ const showContextMenu = React.useCallback((event, relPath) => {
308
+ try {
309
+ event.preventDefault()
310
+ event.stopPropagation()
311
+ } catch (error) { /* ignore */ }
312
+ const width = 196
313
+ const height = 168
314
+ const x = Math.max(4, Math.min(Number(event.clientX) || 0, (window.innerWidth || 1024) - width - 4))
315
+ const y = Math.max(4, Math.min(Number(event.clientY) || 0, (window.innerHeight || 768) - height - 4))
316
+ setCtxMenu({ x, y, path: normalizeRel(relPath) })
317
+ }, [])
318
+
319
+ // Close the context menu on outside pointer, Escape, scroll or resize.
320
+ React.useEffect(() => {
321
+ if (!ctxMenu) return undefined
322
+ const close = () => setCtxMenu(null)
323
+ const onPointer = (event) => {
324
+ try {
325
+ const target = event.target
326
+ const menu = rootRef.current && rootRef.current.querySelector('.drp-ctx-menu')
327
+ if (menu && target instanceof Node && menu.contains(target)) return
328
+ } catch (error) { /* ignore */ }
329
+ close()
330
+ }
331
+ const onKey = (event) => { if (event.key === 'Escape') close() }
332
+ window.addEventListener('mousedown', onPointer)
333
+ window.addEventListener('keydown', onKey)
334
+ window.addEventListener('resize', close)
335
+ window.addEventListener('scroll', close, true)
336
+ return () => {
337
+ window.removeEventListener('mousedown', onPointer)
338
+ window.removeEventListener('keydown', onKey)
339
+ window.removeEventListener('resize', close)
340
+ window.removeEventListener('scroll', close, true)
341
+ }
342
+ }, [ctxMenu])
343
+
344
+ /** Copy one path from the context menu and notify the page. */
345
+ const copyFromMenu = async (relPath, absolute) => {
346
+ const value = absolute ? absolutePathOf(cwd, relPath) : relPath
347
+ const ok = await copyText(value)
348
+ setCtxMenu(null)
349
+ if (typeof onNotify === 'function') onNotify(ok ? t('common.copied') + ':' + value : t('action.copyPath'), ok ? 'info' : 'warn')
350
+ }
351
+
352
+ /**
353
+ * Read one cached directory state.
354
+ * @param {string} relPath directory path
355
+ * @returns {object | undefined}
356
+ */
357
+ const loadState = (relPath) => cache.get(relPath)
358
+
359
+ /** Retire every request before replacing the cache or workspace. */
360
+ const cancelLoads = React.useCallback(() => {
361
+ generationRef.current += 1
362
+ for (const controller of loadingRef.current.values()) {
363
+ try { controller?.abort() } catch (error) { /* ignore */ }
364
+ }
365
+ loadingRef.current = new Map()
366
+ }, [])
367
+
368
+ /**
369
+ * Load one directory's direct children (idempotent while in flight).
370
+ * @param {string} relPath directory path
371
+ */
372
+ const loadDir = React.useCallback((relPath) => {
373
+ if (!sessionId || !cwd) return
374
+ if (loadingRef.current.has(relPath)) return
375
+ const existing = cacheRef.current.get(relPath)
376
+ if (existing && (existing.status === 'ready' || existing.status === 'loading')) return
377
+ const generation = generationRef.current
378
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
379
+ loadingRef.current.set(relPath, controller)
380
+ cacheRef.current.set(relPath, { status: 'loading', entries: [] })
381
+ setCache(new Map(cacheRef.current))
382
+ apiGet('tree', { sessionId, path: relPath }, controller ? controller.signal : undefined)
383
+ .then((listing) => {
384
+ if (generation !== generationRef.current) return
385
+ const entries = Array.isArray(listing && listing.entries) ? listing.entries.slice() : []
386
+ const sorted = entries.sort((a, b) => {
387
+ const aDir = a.type === 'directory' ? 0 : 1
388
+ const bDir = b.type === 'directory' ? 0 : 1
389
+ if (aDir !== bDir) return aDir - bDir
390
+ return String(a.name).localeCompare(String(b.name))
391
+ })
392
+ cacheRef.current.set(relPath, { status: 'ready', entries: sorted, truncated: listing && listing.truncated === true })
393
+ setError(null)
394
+ })
395
+ .catch((failure) => {
396
+ if (generation !== generationRef.current) return
397
+ cacheRef.current.set(relPath, { status: 'error', entries: [] })
398
+ setError(failure && failure.message ? failure.message : String(failure))
399
+ })
400
+ .finally(() => {
401
+ // An older finally must not clear a newer in-flight request for the
402
+ // same directory, even when the old transport ignored cancellation.
403
+ if (generation !== generationRef.current) return
404
+ loadingRef.current.delete(relPath)
405
+ setCache(new Map(cacheRef.current))
406
+ })
407
+ }, [sessionId, cwd])
408
+
409
+ /**
410
+ * Refresh every visible directory while keeping the expansion state.
411
+ * Dropping the cache alone would leave expanded directories empty (they only
412
+ * load on first expand), so every expanded directory is re-requested here.
413
+ */
414
+ const refreshTree = React.useCallback(() => {
415
+ cancelLoads()
416
+ cacheRef.current = new Map()
417
+ setCache(new Map())
418
+ setError(null)
419
+ const dirs = new Set(expandedRef.current)
420
+ dirs.add('')
421
+ for (const dir of dirs) loadDir(dir)
422
+ }, [loadDir, cancelLoads])
423
+
424
+ // Page-level refresh (single toolbar ⟳) drives the tree through this token.
425
+ const lastRefreshRef = React.useRef(refreshToken)
426
+ React.useEffect(() => {
427
+ if (refreshToken === undefined) return
428
+ if (lastRefreshRef.current === refreshToken) return
429
+ lastRefreshRef.current = refreshToken
430
+ refreshTree()
431
+ }, [refreshToken, refreshTree])
432
+
433
+ // Reset when the session/workspace changes.
434
+ React.useEffect(() => {
435
+ cancelLoads()
436
+ cacheRef.current = new Map()
437
+ setCache(new Map())
438
+ expandedRef.current = new Set([''])
439
+ setExpanded(expandedRef.current)
440
+ setError(null)
441
+ if (sessionId && cwd) loadDir('')
442
+ return cancelLoads
443
+ }, [sessionId, cwd, loadDir, cancelLoads])
444
+
445
+ // Auto-expand ancestors of the active file.
446
+ React.useEffect(() => {
447
+ if (!activePath) return
448
+ const parts = segmentsOf(activePath)
449
+ parts.pop()
450
+ const next = new Set(expandedRef.current)
451
+ let prefix = ''
452
+ for (const segment of parts) {
453
+ prefix = prefix === '' ? segment : prefix + '/' + segment
454
+ next.add(prefix)
455
+ }
456
+ expandedRef.current = next
457
+ setExpanded(next)
458
+ for (const dir of next) loadDir(dir)
459
+ }, [activePath, loadDir])
460
+
461
+ // Reveal request: expand ancestors, then scroll the row into view.
462
+ const revealNonce = reveal && reveal.nonce
463
+ React.useEffect(() => {
464
+ if (!reveal || !reveal.path) return
465
+ const parts = segmentsOf(reveal.path)
466
+ parts.pop()
467
+ const next = new Set(expandedRef.current)
468
+ let prefix = ''
469
+ for (const segment of parts) {
470
+ prefix = prefix === '' ? segment : prefix + '/' + segment
471
+ next.add(prefix)
472
+ }
473
+ expandedRef.current = next
474
+ setExpanded(next)
475
+ for (const dir of next) loadDir(dir)
476
+ const timer = setTimeout(() => {
477
+ try {
478
+ const node = rootRef.current && rootRef.current.querySelector('[data-drp-path="' + String(reveal.path).replace(/"/g, '\\"') + '"]')
479
+ if (node && node.scrollIntoView) node.scrollIntoView({ block: 'center' })
480
+ } catch (err) { /* ignore */ }
481
+ }, 120)
482
+ return () => clearTimeout(timer)
483
+ }, [revealNonce, reveal && reveal.path, loadDir])
484
+
485
+ /**
486
+ * Toggle one directory open/closed.
487
+ * @param {string} relPath directory path
488
+ */
489
+ const toggle = (relPath) => {
490
+ const next = new Set(expandedRef.current)
491
+ if (next.has(relPath)) next.delete(relPath)
492
+ else {
493
+ next.add(relPath)
494
+ loadDir(relPath)
495
+ }
496
+ expandedRef.current = next
497
+ setExpanded(next)
498
+ }
499
+
500
+ /**
501
+ * Open one file row.
502
+ * @param {string} relPath file path
503
+ */
504
+ const open = (relPath) => {
505
+ onOpen(relPath)
506
+ }
507
+
508
+
509
+ const rootName = cwd ? basename(cwd) : t('toolbar.tree.all')
510
+
511
+ return h(
512
+ 'div',
513
+ { className: 'drp-tree', ref: rootRef, 'data-drp-scheme': activeScheme },
514
+ h(
515
+ 'div',
516
+ { className: 'drp-tree-head' },
517
+ h('span', { className: 'drp-tree-head-chevron' }, h(Codicon, { char: 'chevronDown' })),
518
+ h('span', { className: 'drp-tree-title', title: cwd || rootName }, rootName),
519
+ ),
520
+ error ? h('div', { className: 'drp-tree-note drp-tree-error', style: { padding: '4px 8px' } }, error) : null,
521
+ h(
522
+ 'div',
523
+ { className: 'drp-tree-body' },
524
+ h(TreeDir, {
525
+ relPath: '',
526
+ depth: 0,
527
+ expanded,
528
+ loadState,
529
+ onToggle: toggle,
530
+ onOpen: open,
531
+ onContextMenu: showContextMenu,
532
+ activePath,
533
+ changed,
534
+ badgesEnabled: treeMode === 'turn',
535
+ gitBadges,
536
+ scheme: activeScheme,
537
+ }),
538
+ ),
539
+ ctxMenu
540
+ ? h(
541
+ 'div',
542
+ {
543
+ className: 'drp-ctx-menu',
544
+ style: { left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' },
545
+ role: 'menu',
546
+ onMouseDown: (event) => { try { event.stopPropagation() } catch (error) { /* ignore */ } },
547
+ },
548
+ h('button', { type: 'button', className: 'drp-menu-item', onClick: () => { void copyFromMenu(ctxMenu.path, false) } }, t('action.copyPath')),
549
+ h('button', { type: 'button', className: 'drp-menu-item', onClick: () => { void copyFromMenu(ctxMenu.path, true) } }, t('action.copyAbsolutePath')),
550
+ h('div', { className: 'drp-menu-sep' }),
551
+ h('button', {
552
+ type: 'button',
553
+ className: 'drp-menu-item',
554
+ onClick: () => {
555
+ const path = ctxMenu.path
556
+ setCtxMenu(null)
557
+ if (typeof onPreview === 'function') onPreview(path)
558
+ },
559
+ }, t('action.openInPreview')),
560
+ )
561
+ : null,
562
+ )
563
+ }