dsh-git-changes 0.0.0-stage → 0.1.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.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +105 -0
  3. package/README.md +122 -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/icon.svg +10 -0
  107. package/lib/client.js +9418 -0
  108. package/lib/host/assets.js +161 -0
  109. package/lib/host/commit-message.js +158 -0
  110. package/lib/host/files-index.js +206 -0
  111. package/lib/host/git-write.js +194 -0
  112. package/lib/host/git.js +1147 -0
  113. package/lib/host/lsp.js +842 -0
  114. package/lib/host/routes.js +976 -0
  115. package/lib/host/session.js +84 -0
  116. package/lib/host/util.js +107 -0
  117. package/lib/index.js +33 -0
  118. package/licenses/dsh-vscode-mode-LICENSE +21 -0
  119. package/licenses/vscode-theme-seti-LICENSE +21 -0
  120. package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
  121. package/locale/en.json +6 -0
  122. package/locale/zh.json +6 -0
  123. package/package.json +63 -3
  124. package/src/client/00-const.js +79 -0
  125. package/src/client/05-utils.js +277 -0
  126. package/src/client/10-i18n.js +548 -0
  127. package/src/client/12-vscode-icons.js +460 -0
  128. package/src/client/15-theme.js +509 -0
  129. package/src/client/20-monaco.js +1404 -0
  130. package/src/client/22-lsp.js +592 -0
  131. package/src/client/25-tree.js +563 -0
  132. package/src/client/28-changes.js +1163 -0
  133. package/src/client/30-page.js +2259 -0
  134. package/src/client/35-pill.js +185 -0
  135. package/src/client/40-commands.js +248 -0
  136. package/src/client/45-css.js +357 -0
  137. package/src/client/47-pickers.js +457 -0
  138. package/src/client/50-apply.js +207 -0
  139. package/src/client/99-footer.js +62 -0
@@ -0,0 +1,457 @@
1
+ /* dsh-git-changes client half — Quick Open / workspace search / command palette overlay. */
2
+
3
+ /**
4
+ * Fuzzy subsequence score for Quick Open (higher is better; 0 = no match).
5
+ * Consecutive matches and a basename hit score higher; a path hit still counts.
6
+ * @param {string} query typed query
7
+ * @param {string} target candidate path
8
+ * @returns {number}
9
+ */
10
+ function fuzzyScore(query, target) {
11
+ const needle = String(query || '').toLowerCase()
12
+ const haystack = String(target || '').toLowerCase()
13
+ if (needle === '') return 1
14
+ if (haystack.length === 0) return 0
15
+ const baseStart = haystack.lastIndexOf('/') + 1
16
+ let score = 0
17
+ let index = 0
18
+ let streak = 0
19
+ for (const character of needle) {
20
+ let at = haystack.indexOf(character, index)
21
+ if (at < 0) return 0
22
+ streak = at === index ? streak + 1 : 0
23
+ score += 2 + streak
24
+ if (at >= baseStart) score += 2
25
+ index = at + 1
26
+ }
27
+ if (haystack.endsWith(needle)) score += 6
28
+ return score
29
+ }
30
+
31
+ /**
32
+ * Filter and rank paths for Quick Open.
33
+ * @param {string[]} files candidate paths
34
+ * @param {string} query typed query
35
+ * @param {number} [limit] max results
36
+ * @returns {Array<{path:string, score:number}>}
37
+ */
38
+ function rankQuickOpen(files, query, limit = 100) {
39
+ const needle = String(query || '').trim()
40
+ if (needle === '') {
41
+ return files.slice(0, limit).map((path) => ({ path, score: 1 }))
42
+ }
43
+ const ranked = []
44
+ for (const path of files) {
45
+ const score = fuzzyScore(needle, path)
46
+ if (score > 0) ranked.push({ path, score })
47
+ }
48
+ ranked.sort((left, right) => right.score - left.score || left.path.length - right.path.length || left.path.localeCompare(right.path))
49
+ return ranked.slice(0, limit)
50
+ }
51
+
52
+ /**
53
+ * Short display date for one revision: `MM-DD` for dates in the current calendar
54
+ * year, otherwise the full `YYYY-MM-DD`; unparseable values render as ''.
55
+ * @param {string} iso ISO-8601 date from git
56
+ * @returns {string}
57
+ */
58
+ function formatRevisionDate(iso) {
59
+ // Git dates carry the author's own offset; show that calendar date as-is
60
+ // instead of shifting it into the viewer's time zone (matches `git log`).
61
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''))
62
+ if (!match) return ''
63
+ const year = match[1]
64
+ const monthDay = match[2] + '-' + match[3]
65
+ return year === String(new Date().getFullYear()) ? monthDay : year + '-' + monthDay
66
+ }
67
+
68
+ /**
69
+ * Whether typed picker text should be offered as a raw git revision expression.
70
+ * Shortened to the cases where the query clearly is not a commit subject: a
71
+ * sha-like token, a ref path, a `HEAD~2`-style expression or an `@{...}` ref.
72
+ * A plain word (branch/tag name or subject text) only becomes a raw revision
73
+ * when no loaded commit matches it.
74
+ * @param {string} value typed query
75
+ * @returns {boolean}
76
+ */
77
+ function isRevisionLikeQuery(value) {
78
+ const text = String(value || '').trim()
79
+ if (text === '') return false
80
+ if (/^[0-9a-f]{4,64}$/i.test(text)) return true
81
+ if (/^refs\//.test(text) || text.startsWith('@')) return true
82
+ if (/^HEAD([~^]|$)/.test(text) || /^tag[:/]/.test(text) || /^origin\//.test(text)) return true
83
+ return /[~^@]/.test(text) || /\//.test(text) && /^[A-Za-z0-9_.\/-]+$/.test(text)
84
+ }
85
+
86
+ /**
87
+ * One picker overlay: Quick Open, workspace search, the command palette, or the
88
+ * git-version picker (mode `revisions`).
89
+ * @param {object} props component props
90
+ * @returns {any} React element
91
+ */
92
+ function PickerOverlay(props) {
93
+ const {
94
+ mode, t, sessionId, commands, onPickFile, onPickCommand, onClose, initialQuery,
95
+ activePath, lineCount,
96
+ revisionScope, onRevisionScopeChange, onPickRevision,
97
+ } = props
98
+ const inputRef = React.useRef(null)
99
+ const [query, setQuery] = React.useState(initialQuery || '')
100
+ const [files, setFiles] = React.useState(null)
101
+ const [filesTruncated, setFilesTruncated] = React.useState(false)
102
+ const [fileError, setFileError] = React.useState(null)
103
+ const [searchState, setSearchState] = React.useState({ status: 'idle', results: [], scanned: 0, truncated: false })
104
+ const [revisionState, setRevisionState] = React.useState({ status: 'idle', data: null, message: '' })
105
+ const [activeIndex, setActiveIndex] = React.useState(0)
106
+ const debounceRef = React.useRef(null)
107
+ // Optional props: the picker stays usable when the page omits the version props.
108
+ const scope = revisionScope === 'since' ? 'since' : 'commit'
109
+
110
+ // VS Code Quick Open prefixes: `>` runs a command, `:` jumps to a line.
111
+ const trimmed = query.trimStart()
112
+ const effectiveMode = mode === 'quickopen'
113
+ ? (trimmed.startsWith('>') ? 'commands' : trimmed.startsWith(':') ? 'line' : 'quickopen')
114
+ : mode
115
+ const prefixQuery = effectiveMode === 'commands' && mode === 'quickopen'
116
+ ? trimmed.slice(1).trim()
117
+ : effectiveMode === 'line' && mode === 'quickopen' ? trimmed.slice(1).trim() : query
118
+
119
+ const title = mode === 'revisions'
120
+ ? t('picker.revisions.title')
121
+ : mode === 'locations' ? t(props.locationKind === 'definitions' ? 'picker.definitions.title' : 'picker.locations.title')
122
+ : mode === 'outline' ? t('picker.outline.title')
123
+ : mode === 'keys'
124
+ ? t('picker.keys.title')
125
+ : mode === 'line'
126
+ ? t('picker.line.title')
127
+ : mode === 'quickopen'
128
+ ? t('picker.quickOpen.title')
129
+ : mode === 'search' ? t('picker.search.title') : t('picker.commands.title')
130
+ const placeholder = mode === 'revisions'
131
+ ? t('picker.revisions.placeholder')
132
+ : mode === 'locations' ? t('picker.locations.placeholder')
133
+ : mode === 'outline' ? t('picker.outline.placeholder')
134
+ : mode === 'keys'
135
+ ? t('picker.keys.placeholder')
136
+ : mode === 'line'
137
+ ? t('picker.line.placeholder')
138
+ : mode === 'quickopen'
139
+ ? t('picker.quickOpen.placeholder')
140
+ : mode === 'search' ? t('picker.search.placeholder') : t('picker.commands.placeholder')
141
+
142
+ // Focus the input as soon as the overlay opens.
143
+ React.useEffect(() => {
144
+ const timer = setTimeout(() => {
145
+ try { inputRef.current?.focus() } catch (error) { /* ignore */ }
146
+ }, 20)
147
+ return () => clearTimeout(timer)
148
+ }, [])
149
+
150
+ // Quick Open: load the (cached) workspace index once.
151
+ React.useEffect(() => {
152
+ if (mode !== 'quickopen' || !sessionId) return undefined
153
+ let cancelled = false
154
+ apiGet('files', { sessionId })
155
+ .then((value) => {
156
+ if (cancelled) return
157
+ setFiles(Array.isArray(value?.files) ? value.files : [])
158
+ setFilesTruncated(value?.truncated === true)
159
+ })
160
+ .catch((failure) => {
161
+ if (cancelled) return
162
+ setFiles([])
163
+ setFileError(failure?.message || String(failure))
164
+ })
165
+ return () => { cancelled = true }
166
+ }, [mode, sessionId])
167
+
168
+ // Git versions: load the bounded revision list once per picker open; the token
169
+ // guard drops late responses when the overlay closes or the mode changes.
170
+ React.useEffect(() => {
171
+ if (mode !== 'revisions') return undefined
172
+ let cancelled = false
173
+ setRevisionState({ status: 'loading', data: null, message: '' })
174
+ if (!sessionId) {
175
+ setRevisionState({ status: 'error', data: null, message: t('error.noSession') })
176
+ return () => { cancelled = true }
177
+ }
178
+ apiGet('gitRevisions', { sessionId })
179
+ .then((value) => {
180
+ if (cancelled) return
181
+ setRevisionState({ status: 'ready', data: value || null, message: '' })
182
+ })
183
+ .catch((failure) => {
184
+ if (cancelled) return
185
+ setRevisionState({ status: 'error', data: null, message: failure && failure.message ? failure.message : String(failure) })
186
+ })
187
+ return () => { cancelled = true }
188
+ }, [mode, sessionId])
189
+
190
+ // Workspace search: debounced remote query.
191
+ React.useEffect(() => {
192
+ if (mode !== 'search') return undefined
193
+ const needle = query.trim()
194
+ if (needle.length < 2) {
195
+ setSearchState({ status: 'idle', results: [], scanned: 0, truncated: false })
196
+ return undefined
197
+ }
198
+ setSearchState({ status: 'searching', results: [], scanned: 0, truncated: false })
199
+ if (debounceRef.current) clearTimeout(debounceRef.current)
200
+ let cancelled = false
201
+ debounceRef.current = setTimeout(() => {
202
+ apiGet('search', { sessionId, q: needle, limit: 100 })
203
+ .then((value) => {
204
+ if (cancelled) return
205
+ setSearchState({
206
+ status: 'ready',
207
+ results: Array.isArray(value?.results) ? value.results : [],
208
+ scanned: Number(value?.scanned) || 0,
209
+ truncated: value?.truncated === true,
210
+ })
211
+ setActiveIndex(0)
212
+ })
213
+ .catch((failure) => {
214
+ if (cancelled) return
215
+ setSearchState({ status: 'error', results: [], scanned: 0, truncated: false, message: failure?.message || String(failure) })
216
+ })
217
+ }, 250)
218
+ return () => {
219
+ cancelled = true
220
+ if (debounceRef.current) clearTimeout(debounceRef.current)
221
+ }
222
+ }, [mode, query, sessionId])
223
+
224
+ const commandItems = React.useMemo(() => {
225
+ if (effectiveMode !== 'commands' && effectiveMode !== 'keys' && mode !== 'locations' && mode !== 'outline') return []
226
+ const needle = prefixQuery.trim().toLowerCase()
227
+ const list = (commands || []).map((command) => ({
228
+ id: command.id,
229
+ label: typeof command.label === 'function' ? command.label() : String(command.label || command.id),
230
+ hint: command.hint || '',
231
+ }))
232
+ if (needle === '') return list
233
+ return list.filter((item) => (item.label + ' ' + item.hint).toLowerCase().includes(needle))
234
+ }, [effectiveMode, commands, prefixQuery])
235
+
236
+ const quickItems = React.useMemo(() => {
237
+ if (effectiveMode !== 'quickopen' || !files) return []
238
+ return rankQuickOpen(files, prefixQuery, 100)
239
+ }, [effectiveMode, files, prefixQuery])
240
+
241
+ // Git versions: loaded payload, typed-query filter, scope toggle and offered rows.
242
+ const revisionData = revisionState.data
243
+ const revisionCommits = revisionData && Array.isArray(revisionData.commits) ? revisionData.commits : []
244
+ const revisionNeedle = query.trim().toLowerCase()
245
+ const revisionRaw = query.trim()
246
+ const revisionMatches = revisionNeedle === ''
247
+ ? revisionCommits
248
+ : revisionCommits.filter((commit) => (
249
+ [commit.sha, commit.short, commit.subject, commit.author, Array.isArray(commit.refs) ? commit.refs.join(' ') : '']
250
+ .filter(Boolean).join(' ').toLowerCase().includes(revisionNeedle)
251
+ ))
252
+ // Offer the typed expression as a raw revision unless it plainly looks like a
253
+ // commit-subject search that already matched rows.
254
+ const offerRawRevision = revisionRaw !== '' && (revisionMatches.length === 0 || isRevisionLikeQuery(revisionRaw))
255
+ const revisionRows = effectiveMode !== 'revisions' ? [] : [
256
+ ...(offerRawRevision
257
+ ? [{ key: 'revraw:' + revisionRaw, id: 'revraw:' + revisionRaw, primary: t('picker.revisions.raw', { query: revisionRaw }), raw: revisionRaw }]
258
+ : []),
259
+ ...revisionMatches.map((commit) => ({
260
+ key: 'rev:' + commit.sha,
261
+ id: 'rev:' + commit.sha,
262
+ primary: String(commit.short || commit.sha || '') + (commit.subject ? ' ' + commit.subject : ''),
263
+ secondary: [
264
+ Array.isArray(commit.refs) ? commit.refs.join(' ') : '',
265
+ formatRevisionDate(commit.date),
266
+ commit.author,
267
+ ].filter(Boolean).join(' · '),
268
+ commit,
269
+ })),
270
+ ]
271
+
272
+ /** Status copy for the revisions mode: head/branch/upstream/ahead summary. */
273
+ const revisionStatus = (() => {
274
+ if (revisionState.status === 'error') return revisionState.message || t('picker.empty')
275
+ if (revisionState.status !== 'ready') return t('picker.revisions.loading')
276
+ if (revisionData && revisionData.available === true) {
277
+ const head = revisionData.head
278
+ if (revisionData.unborn === true || !head || revisionCommits.length === 0) return t('picker.revisions.none')
279
+ const parts = [t('picker.revisions.head', { ref: head.short || head.sha || '' })]
280
+ if (head.detached === true) parts.push(t('picker.revisions.detached'))
281
+ else if (head.branch) {
282
+ parts.push(head.upstream
283
+ ? t('picker.revisions.branchUpstream', { branch: head.branch, upstream: head.upstream })
284
+ : t('picker.revisions.branch', { branch: head.branch }))
285
+ }
286
+ if (Number(head.ahead) > 0) parts.push(t('picker.revisions.ahead', { count: String(head.ahead) }))
287
+ if (revisionNeedle !== '' && revisionMatches.length === 0) parts.push(t('picker.revisions.empty'))
288
+ return parts.join(' · ')
289
+ }
290
+ return t('mode.revision.unavailable')
291
+ })()
292
+
293
+ /** Empty-list copy for revisions (no commits yet vs no filter match). */
294
+ const revisionEmptyText = revisionState.status === 'ready' && revisionData && revisionData.available === true
295
+ ? (revisionCommits.length === 0 ? t('picker.revisions.none') : t('picker.revisions.empty'))
296
+ : revisionStatus
297
+
298
+ const rows = effectiveMode === 'revisions'
299
+ ? revisionRows
300
+ : effectiveMode === 'quickopen'
301
+ ? quickItems.map((item) => ({ key: 'f:' + item.path, primary: item.path, secondary: '' }))
302
+ : effectiveMode === 'search'
303
+ ? searchState.results.map((hit) => ({
304
+ key: 's:' + hit.path + ':' + hit.line + ':' + hit.column,
305
+ primary: hit.path + ':' + hit.line,
306
+ secondary: hit.text || '',
307
+ path: hit.path,
308
+ line: hit.line,
309
+ }))
310
+ : effectiveMode === 'commands' || effectiveMode === 'keys' || mode === 'locations' || mode === 'outline'
311
+ ? commandItems.map((item) => ({ key: 'c:' + item.id, primary: item.label, secondary: item.hint, id: item.id }))
312
+ : []
313
+
314
+ // Keep the active row in range while results shrink.
315
+ React.useEffect(() => {
316
+ setActiveIndex((current) => (current < rows.length ? current : Math.max(0, rows.length - 1)))
317
+ }, [rows.length])
318
+
319
+ /** Jump to a typed line number in the active file. */
320
+ const jumpToLine = () => {
321
+ const line = Number(prefixQuery.trim())
322
+ const total = Number(lineCount) || 0
323
+ if (!Number.isFinite(line) || line <= 0 || (total > 0 && line > total)) return false
324
+ onPickFile?.(activePath, line)
325
+ return true
326
+ }
327
+
328
+ /** Activate one result row. */
329
+ const choose = (row) => {
330
+ if (effectiveMode === 'line') { jumpToLine(); return }
331
+ if (!row) return
332
+ if (effectiveMode === 'quickopen') { onPickFile?.(row.primary, undefined); return }
333
+ if (effectiveMode === 'search') { onPickFile?.(row.path, row.line); return }
334
+ if (effectiveMode === 'revisions') {
335
+ if (typeof row.raw === 'string') onPickRevision?.({ raw: row.raw })
336
+ else if (row.commit) onPickRevision?.(row.commit)
337
+ return
338
+ }
339
+ onPickCommand?.(row.id)
340
+ }
341
+
342
+ /** Keyboard handling: arrows, Enter, Escape. */
343
+ const onKeyDown = (event) => {
344
+ if (event.key === 'Escape') {
345
+ try { event.stopPropagation() } catch (error) { /* ignore */ }
346
+ onClose?.()
347
+ return
348
+ }
349
+ if (event.key === 'ArrowDown') {
350
+ try { event.preventDefault() } catch (error) { /* ignore */ }
351
+ setActiveIndex((current) => (rows.length === 0 ? 0 : (current + 1) % rows.length))
352
+ return
353
+ }
354
+ if (event.key === 'ArrowUp') {
355
+ try { event.preventDefault() } catch (error) { /* ignore */ }
356
+ setActiveIndex((current) => (rows.length === 0 ? 0 : (current - 1 + rows.length) % rows.length))
357
+ return
358
+ }
359
+ if (event.key === 'Enter') {
360
+ try { event.preventDefault() } catch (error) { /* ignore */ }
361
+ choose(rows[activeIndex])
362
+ }
363
+ }
364
+
365
+ const totalLines = Number(lineCount) || 0
366
+ const lineNumber = Number(prefixQuery.trim())
367
+ const lineValid = Number.isFinite(lineNumber) && lineNumber > 0 && (totalLines === 0 || lineNumber <= totalLines)
368
+
369
+ const status = effectiveMode === 'line'
370
+ ? (prefixQuery.trim() === ''
371
+ ? t('picker.line.hint', { lines: String(totalLines) })
372
+ : lineValid ? t('picker.line.hint', { lines: String(totalLines) }) : t('picker.line.invalid'))
373
+ : effectiveMode === 'search'
374
+ ? searchState.status === 'searching'
375
+ ? t('picker.search.scanning')
376
+ : searchState.status === 'ready'
377
+ ? (searchState.results.length === 0
378
+ ? t('picker.search.none')
379
+ : t('picker.search.results', { count: String(searchState.results.length), scanned: String(searchState.scanned) }) + (searchState.truncated ? ' · …' : ''))
380
+ : searchState.status === 'error' ? t('picker.empty') : ''
381
+ : effectiveMode === 'revisions'
382
+ ? revisionStatus
383
+ : mode === 'quickopen'
384
+ ? (fileError || (filesTruncated ? t('picker.files.truncated') : (files ? '' : t('picker.loading'))))
385
+ : ''
386
+
387
+ return h(
388
+ 'div',
389
+ {
390
+ className: 'drp-picker-backdrop',
391
+ onMouseDown: (event) => { try { if (event.target === event.currentTarget) onClose?.() } catch (error) { /* ignore */ } },
392
+ },
393
+ h('div', {
394
+ className: 'drp-picker',
395
+ role: 'dialog',
396
+ 'aria-label': title,
397
+ onMouseDown: (event) => { try { event.stopPropagation() } catch (error) { /* ignore */ } },
398
+ },
399
+ h('div', { className: 'drp-picker-head' },
400
+ h('span', { className: 'drp-picker-title' }, title),
401
+ h('button', { type: 'button', className: 'drp-icon-btn', title: t('common.close'), onClick: () => onClose?.() }, '✕')),
402
+ effectiveMode === 'revisions'
403
+ ? h('div', { className: 'drp-picker-scope' },
404
+ h('div', { className: 'drp-seg', role: 'group', 'aria-label': t('picker.revisions.title') },
405
+ h('button', {
406
+ type: 'button',
407
+ className: cx('drp-seg-btn', scope === 'commit' && 'drp-seg-btn-on'),
408
+ 'aria-pressed': scope === 'commit',
409
+ title: t('picker.revisions.scope.commit.hint'),
410
+ onClick: () => onRevisionScopeChange?.('commit'),
411
+ }, t('picker.revisions.scope.commit')),
412
+ h('button', {
413
+ type: 'button',
414
+ className: cx('drp-seg-btn', scope === 'since' && 'drp-seg-btn-on'),
415
+ 'aria-pressed': scope === 'since',
416
+ title: t('picker.revisions.scope.since.hint'),
417
+ onClick: () => onRevisionScopeChange?.('since'),
418
+ }, t('picker.revisions.scope.since'))),
419
+ h('span', { className: 'drp-picker-scope-label' }, t(scope === 'since' ? 'picker.revisions.scope.since.hint' : 'picker.revisions.scope.commit.hint')))
420
+ : null,
421
+ h('input', {
422
+ ref: inputRef,
423
+ className: 'drp-picker-input',
424
+ value: query,
425
+ placeholder,
426
+ spellCheck: false,
427
+ autoComplete: 'off',
428
+ onChange: (event) => { setQuery(event.target.value); setActiveIndex(0) },
429
+ onKeyDown,
430
+ }),
431
+ status ? h('div', { className: 'drp-picker-status' }, status) : null,
432
+ h('div', { className: 'drp-picker-list', role: 'listbox' },
433
+ rows.length === 0
434
+ ? h('div', { className: 'drp-picker-empty' },
435
+ effectiveMode === 'line'
436
+ ? (lineValid ? '↵ ' + t('picker.line.title') : t('picker.line.placeholder'))
437
+ : effectiveMode === 'revisions'
438
+ ? revisionEmptyText
439
+ : mode === 'search' && query.trim().length < 2 ? t('picker.search.placeholder') : t('picker.empty'))
440
+ : rows.slice(0, 200).map((row, index) => h('div', {
441
+ key: row.key,
442
+ className: cx('drp-picker-row', index === activeIndex && 'drp-picker-row-active', row.commit ? 'drp-picker-row-rev' : row.raw !== undefined ? 'drp-picker-row-raw' : ''),
443
+ role: 'option',
444
+ 'aria-selected': index === activeIndex,
445
+ title: row.primary,
446
+ onMouseEnter: () => setActiveIndex(index),
447
+ onMouseDown: (event) => { try { event.preventDefault() } catch (error) { /* ignore */ } },
448
+ onClick: () => choose(row),
449
+ },
450
+ h('span', { className: 'drp-picker-primary' }, row.primary),
451
+ row.secondary ? h('span', { className: 'drp-picker-secondary' }, row.secondary) : null,
452
+ ))),
453
+ h('div', { className: 'drp-picker-foot' }, t('picker.hint.navigate')),
454
+ ),
455
+ )
456
+ }
457
+
@@ -0,0 +1,207 @@
1
+ /* dsh-git-changes client half — registration entry. */
2
+
3
+ /** Cordis client services required for registration; optional services are probed. */
4
+ const inject = ['slots', 'locale']
5
+
6
+ /**
7
+ * Register the plugin's client service catalog: styles, dictionaries, the
8
+ * composer pill, official shortcuts, and the right-sidebar tab type with its
9
+ * body (or the degraded central view when the sidebar services are absent).
10
+ * @param {object} ctx client plugin context
11
+ */
12
+ function apply(ctx) {
13
+ globalCtxRef.current = ctx
14
+ ctx.effect(() => () => lspDispose(), 'dsh-git-changes: client LSP lifecycle')
15
+
16
+ // Optional theme service (snapshot source; DOM markers remain the fallback).
17
+ try {
18
+ themeRuntime.service = safeGet(ctx, 'theme')
19
+ } catch (error) {
20
+ themeRuntime.service = undefined
21
+ }
22
+ themeRuntime.scheme = detectScheme()
23
+
24
+ // Optional conversation service: its per-session binding's `openTurn` source
25
+ // is the precise "turn closed" signal (used by the pill and the page).
26
+ runtimeState.conversation = safeGet(ctx, 'uiConversation')
27
+
28
+ // Plugin-owned stylesheet, cleaned up with the client run.
29
+ ctx.effect(() => {
30
+ const element = document.createElement('style')
31
+ element.setAttribute('data-dsh-git-changes', '1')
32
+ element.textContent = STYLE_TEXT
33
+ document.head.appendChild(element)
34
+ return () => {
35
+ try { element.remove() } catch (error) { /* ignore */ }
36
+ }
37
+ }, 'dsh-git-changes: styles')
38
+
39
+ // VS Code Seti icon font (embedded woff from the generated icon theme asset).
40
+ ctx.effect(() => {
41
+ const css = typeof VSCODE_ICON_THEME !== 'undefined' && VSCODE_ICON_THEME.fontFaceCss
42
+ if (!css) return undefined
43
+ const element = document.createElement('style')
44
+ element.setAttribute('data-dsh-git-changes-icons', '1')
45
+ element.textContent = css
46
+ document.head.appendChild(element)
47
+ return () => {
48
+ try { element.remove() } catch (error) { /* ignore */ }
49
+ }
50
+ }, 'dsh-git-changes: icon font')
51
+
52
+ // Dictionaries follow the DSH language; there is no plugin-local override.
53
+ ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'dsh-git-changes: dictionaries')
54
+ let t
55
+ try {
56
+ t = ctx.locale.bind(NS)
57
+ } catch (error) {
58
+ t = (key) => key
59
+ }
60
+
61
+ // Composer pill (registered independently of the sidebar; it degrades to an
62
+ // inline error if the review tab cannot be opened).
63
+ try {
64
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
65
+ name: 'conversation.input.dock',
66
+ id: 'git-changes-pill',
67
+ order: 30,
68
+ locale: NS,
69
+ }, ChangesPill))
70
+ } catch (error) {
71
+ log('warn', 'composer pill registration failed', error)
72
+ }
73
+
74
+ // Official shortcuts (optional service; bounded probe inside).
75
+ ctx.effect(() => installOfficialShortcuts(ctx, t), 'dsh-git-changes: shortcuts')
76
+
77
+ // Primary container: the official right sidebar's new tab type.
78
+ mountContainer(ctx, t)
79
+ }
80
+
81
+ /**
82
+ * Register the resource tab type and its body on the official right sidebar.
83
+ * @param {object} ctx client plugin context
84
+ * @param {Function} t locale-bound translator
85
+ * @param {{ register: Function }} tabs sidebarRightTabs service
86
+ */
87
+ function registerSidebarTab(ctx, t, tabs) {
88
+ ctx.effect(() => {
89
+ const dispose = tabs.register({
90
+ id: TAB_ID,
91
+ kind: TAB_KIND,
92
+ patterns: ['dsh-resource://git-changes/**'],
93
+ priority: 'extension',
94
+ // Resource opens use our addresses; the sidebar guide opens the page type
95
+ // by kind, whose composed address is `sidebar://<kind>` (format is the
96
+ // sidebar's own; accept any `sidebar://` page address since a named kind
97
+ // is the only way this type is asked to open one).
98
+ canOpen: (address) => typeof address === 'string'
99
+ && (address.startsWith(ADDRESS_PREFIX) || address.startsWith('sidebar://')),
100
+ title: () => t('tab.title'),
101
+ // Advertise the page in the sidebar's guide ("+") so it is always reachable,
102
+ // even before the first turn with changes exists.
103
+ guide: [{
104
+ id: TAB_ID,
105
+ order: 20,
106
+ kind: TAB_KIND,
107
+ title: () => t('tab.title'),
108
+ description: () => t('tab.description'),
109
+ }],
110
+ })
111
+ return () => {
112
+ try { dispose() } catch (error) { /* ignore */ }
113
+ }
114
+ }, 'dsh-git-changes: tab type')
115
+
116
+ try {
117
+ ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
118
+ name: 'sidebar.right.pane.tab',
119
+ key: TAB_ID,
120
+ locale: NS,
121
+ inject: () => ({
122
+ services: { right: runtimeState.right, tabs: runtimeState.tabs },
123
+ }),
124
+ }, ReviewTabBody))
125
+ } catch (error) {
126
+ log('error', 'sidebar tab body registration failed', error)
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Degraded container: a central Conversation view tab with the same page.
132
+ * @param {object} ctx client plugin context
133
+ * @param {Function} t locale-bound translator
134
+ * @param {any} right sidebarRight controller (may be undefined)
135
+ */
136
+ function registerFallbackView(ctx, t, right) {
137
+ runtimeState.right = right
138
+ try {
139
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
140
+ name: 'conversation.view',
141
+ id: 'git-changes-view',
142
+ order: 50,
143
+ label: () => t('tab.title'),
144
+ locale: NS,
145
+ inject: () => ({ services: { right: runtimeState.right } }),
146
+ }, ReviewViewBody))
147
+ log('warn', 'right sidebar services unavailable; registered the fallback conversation view')
148
+ } catch (error) {
149
+ log('error', 'fallback conversation view registration failed', error)
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Probe the optional right-sidebar services with a bounded retry, then mount the
155
+ * primary container (falling back to the central view after ~10s).
156
+ * @param {object} ctx client plugin context
157
+ * @param {Function} t locale-bound translator
158
+ */
159
+ function mountContainer(ctx, t) {
160
+ let stopped = false
161
+ let timer = null
162
+ let attempts = 0
163
+ ctx.effect(() => () => {
164
+ stopped = true
165
+ if (timer) {
166
+ clearTimeout(timer)
167
+ timer = null
168
+ }
169
+ }, 'dsh-git-changes: container probe')
170
+
171
+ const tick = () => {
172
+ if (stopped) return
173
+ const tabs = safeGet(ctx, 'sidebarRightTabs')
174
+ const right = safeGet(ctx, 'sidebarRight')
175
+ const ready = tabs && typeof tabs.register === 'function' && right && typeof right.openResource === 'function'
176
+ if (ready) {
177
+ runtimeState.tabs = tabs
178
+ runtimeState.right = right
179
+ try {
180
+ registerSidebarTab(ctx, t, tabs)
181
+ log('log', 'review tab registered on the official right sidebar')
182
+ } catch (error) {
183
+ log('error', 'right sidebar tab registration failed', error)
184
+ registerFallbackView(ctx, t, right)
185
+ }
186
+ return
187
+ }
188
+ if (attempts >= 20) {
189
+ registerFallbackView(ctx, t, right)
190
+ return
191
+ }
192
+ attempts += 1
193
+ timer = setTimeout(tick, 500)
194
+ }
195
+ tick()
196
+ }
197
+
198
+ /**
199
+ * Open the review tab for a session (used by the composer pill and commands).
200
+ * @param {string} sessionId session identity
201
+ * @returns {boolean} whether the resource open was issued
202
+ */
203
+ function openReviewPage(sessionId) {
204
+ if (!sessionId) return false
205
+ runtimeState.sessionId = sessionId
206
+ return openResourceSafe(runtimeState.right, ADDRESS_PREFIX + encodeURIComponent(sessionId))
207
+ }