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,161 @@
1
+ /**
2
+ * Static route for the vendored Monaco AMD build.
3
+ *
4
+ * `ctx.webServer.register` routes carry no authentication of their own, so the
5
+ * handler applies the Host Connection fence first (fail closed when the service
6
+ * is unavailable), then resolves the request path against the package's
7
+ * `assets/vendor/monaco` directory with realpath containment.
8
+ */
9
+ import { readFile, realpath, stat } from 'node:fs/promises'
10
+ import { extname, isAbsolute, relative, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ /**
14
+ * Prefix claimed by the plugin; namespaced to avoid any shipped route.
15
+ * NOTE: `ctx.webServer.match()` matches `pathname === prefix || pathname.startsWith(prefix + '/')`,
16
+ * so the registered prefix must NOT carry a trailing slash or nested files never match.
17
+ */
18
+ export const VENDOR_PREFIX = '/dsh-git-changes/monaco'
19
+
20
+ /** Content types present in the AMD build (js/css/ttf). */
21
+ const VENDOR_MIME = {
22
+ '.js': 'text/javascript; charset=utf-8',
23
+ '.mjs': 'text/javascript; charset=utf-8',
24
+ '.css': 'text/css; charset=utf-8',
25
+ '.json': 'application/json; charset=utf-8',
26
+ '.map': 'application/json; charset=utf-8',
27
+ '.ttf': 'font/ttf',
28
+ '.woff': 'font/woff',
29
+ '.woff2': 'font/woff2',
30
+ '.svg': 'image/svg+xml',
31
+ '.png': 'image/png',
32
+ }
33
+
34
+ /**
35
+ * Apply the Connection request policy; returns the rejection status, or
36
+ * undefined when admitted. Never admits on a capability mismatch.
37
+ * @param {object} ctx host context
38
+ * @param {import('node:http').IncomingMessage} req request
39
+ * @returns {number | undefined}
40
+ */
41
+ function rejectionOf(ctx, req) {
42
+ try {
43
+ const connection = ctx.connection
44
+ if (connection && typeof connection.requestRejection === 'function') {
45
+ const rejection = connection.requestRejection(req)
46
+ return rejection === 401 || rejection === 403 ? rejection : undefined
47
+ }
48
+ } catch {
49
+ /* fall through to fail-closed */
50
+ }
51
+ return 503
52
+ }
53
+
54
+ /**
55
+ * Serve one file under the Monaco vendor directory.
56
+ * @param {string} root absolute vendor root
57
+ * @param {import('node:http').IncomingMessage} req request
58
+ * @param {import('node:http').ServerResponse} res response
59
+ */
60
+ async function serveVendor(root, req, res) {
61
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
62
+ res.statusCode = 405
63
+ res.end()
64
+ return
65
+ }
66
+ try {
67
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
68
+ if (pathname !== VENDOR_PREFIX && !pathname.startsWith(VENDOR_PREFIX + '/')) {
69
+ res.statusCode = 404
70
+ res.end()
71
+ return
72
+ }
73
+ let raw
74
+ try {
75
+ raw = decodeURIComponent(pathname.slice(VENDOR_PREFIX.length)).replace(/^\/+/, '')
76
+ } catch {
77
+ res.statusCode = 400
78
+ res.end()
79
+ return
80
+ }
81
+ if (!raw || raw.includes('\u0000')) {
82
+ res.statusCode = 404
83
+ res.end()
84
+ return
85
+ }
86
+ const segments = raw.split('/')
87
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
88
+ res.statusCode = 403
89
+ res.end()
90
+ return
91
+ }
92
+ const canonicalRoot = await realpath(root)
93
+ const target = await realpath(resolve(canonicalRoot, raw))
94
+ const rel = relative(canonicalRoot, target)
95
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
96
+ res.statusCode = 403
97
+ res.end()
98
+ return
99
+ }
100
+ const info = await stat(target)
101
+ if (!info.isFile()) {
102
+ res.statusCode = 404
103
+ res.end()
104
+ return
105
+ }
106
+ const etag = 'W/"' + info.mtimeMs.toString(16) + '-' + info.size.toString(16) + '"'
107
+ res.setHeader('etag', etag)
108
+ res.setHeader('cache-control', 'private, max-age=3600')
109
+ if (req.headers['if-none-match'] === etag) {
110
+ res.statusCode = 304
111
+ res.end()
112
+ return
113
+ }
114
+ res.statusCode = 200
115
+ res.setHeader('content-type', VENDOR_MIME[extname(target).toLowerCase()] ?? 'application/octet-stream')
116
+ res.setHeader('content-length', info.size)
117
+ if (req.method === 'HEAD') {
118
+ res.end()
119
+ return
120
+ }
121
+ res.end(await readFile(target))
122
+ } catch {
123
+ if (!res.headersSent) res.statusCode = 404
124
+ res.end()
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Register the authenticated Monaco asset prefix route.
130
+ * @param {object} ctx host plugin context
131
+ */
132
+ export function registerVendorRoute(ctx) {
133
+ const root = fileURLToPath(new URL('../../assets/vendor/monaco/', import.meta.url))
134
+ const handler = (req, res) => {
135
+ const rejection = rejectionOf(ctx, req)
136
+ if (rejection !== undefined) {
137
+ res.statusCode = rejection
138
+ res.setHeader('cache-control', 'no-store')
139
+ res.end()
140
+ return
141
+ }
142
+ void serveVendor(root, req, res)
143
+ }
144
+ try {
145
+ const web = ctx.webServer
146
+ if (!web || typeof web.register !== 'function') {
147
+ ctx.logger?.warn?.('dsh-git-changes: webServer unavailable; Monaco assets cannot be served')
148
+ return
149
+ }
150
+ const dispose = web.register({ kind: 'prefix', path: VENDOR_PREFIX, handler })
151
+ ctx.effect(() => () => {
152
+ try {
153
+ dispose()
154
+ } catch {
155
+ /* webserver teardown may already own the route */
156
+ }
157
+ }, 'dsh-git-changes: Monaco vendor route')
158
+ } catch (error) {
159
+ ctx.logger?.warn?.(`dsh-git-changes: Monaco vendor route unavailable (${String(error)})`)
160
+ }
161
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * AI commit-message generation for the Git review panel.
3
+ *
4
+ * The Host owns the credentials and the model route, so the browser half never
5
+ * sees a provider key. We reuse the DSH `llm` service with the user's default
6
+ * model and stream one short completion; nothing here writes to the repository.
7
+ */
8
+
9
+ /** Diff characters handed to the model (tail clipped, never mid-line). */
10
+ const DIFF_MAX_CHARS = 16000
11
+ /** Upper bound for one generated message. */
12
+ const MESSAGE_MAX_CHARS = 2000
13
+ /** Subject length cap enforced on the client commit box too. */
14
+ const SUBJECT_MAX_CHARS = 120
15
+ /** Generation timeout. */
16
+ const GENERATE_TIMEOUT_MS = 60000
17
+
18
+ const SYSTEM_PROMPT = [
19
+ '你负责根据 unified diff 生成 Git 提交信息。只输出提交信息本身,必须使用简体中文。',
20
+ '格式遵循 Conventional Commits:`<type>(<scope>): <摘要>`,type 取 feat/fix/refactor/perf/docs/test/build/chore 之一,scope 可选。',
21
+ '首行只能有一行,不超过 50 个中文字符(硬上限 72 字符),用祈使语气,句尾不加句号。',
22
+ '需要时在空行后写正文:用中文短句说明改了什么、为什么,每行约 72 字符换行。',
23
+ '不要输出 markdown 围栏、引号、解释或「提交信息:」之类标签;即使 diff 全是英文代码,描述也必须用中文。',
24
+ ].join(' ')
25
+
26
+ /** Whether the text contains at least one CJK ideograph. */
27
+ function hasChinese(text) {
28
+ return /[\u3400-\u9fff]/.test(String(text || ''))
29
+ }
30
+
31
+ /**
32
+ * Clip one diff to the model input budget without exceeding it.
33
+ * @param {string} text unified diff text
34
+ * @returns {string} clipped text
35
+ */
36
+ function clipDiff(text) {
37
+ const value = String(text || '')
38
+ if (value.length <= DIFF_MAX_CHARS) return value
39
+ return value.slice(0, DIFF_MAX_CHARS) + '\n... (diff truncated)'
40
+ }
41
+
42
+ /**
43
+ * Normalize whatever the model returned into a commit message.
44
+ * @param {string} raw raw model text
45
+ * @returns {string} sanitized message (possibly empty)
46
+ */
47
+ export function sanitizeCommitMessage(raw) {
48
+ let text = String(raw || '').replace(/\r\n?/g, '\n').trim()
49
+ text = text.replace(/^```[a-zA-Z0-9_-]*\n?/, '').replace(/\n?```$/, '').trim()
50
+ if (
51
+ (text.startsWith('"') && text.endsWith('"')) ||
52
+ (text.startsWith('`') && text.endsWith('`')) ||
53
+ (text.startsWith('“') && text.endsWith('”'))
54
+ ) {
55
+ text = text.slice(1, -1).trim()
56
+ }
57
+ text = text.replace(/^(commit message|message|提交信息|commit)\s*[::]\s*/i, '').trim()
58
+ if (text === '') return ''
59
+ const lines = text.split('\n')
60
+ const subject = (lines.shift() || '').trim().slice(0, SUBJECT_MAX_CHARS)
61
+ const body = lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
62
+ const message = [subject, body].filter(Boolean).join('\n\n')
63
+ return message.slice(0, MESSAGE_MAX_CHARS)
64
+ }
65
+
66
+ /**
67
+ * Resolve the provider/model route to generate with.
68
+ * @param {object} ctx Host context
69
+ * @returns {{provider:string, model:string}|null} resolved selection
70
+ */
71
+ function resolveModel(ctx) {
72
+ const selection = ctx.get('agentDefaultModel')?.currentSelection?.()
73
+ const provider = selection && typeof selection.provider === 'string' ? selection.provider : ''
74
+ const model = selection && typeof selection.model === 'string' ? selection.model : ''
75
+ if (provider === '' || model === '') return null
76
+ return { provider, model }
77
+ }
78
+
79
+ /**
80
+ * Generate a commit message from the staged (or unstaged) changes.
81
+ * @param {object} ctx Host context
82
+ * @param {{git:import('./git.js').GitRunner, cwd:string, signal?:AbortSignal}} request request facts
83
+ * @returns {Promise<object>} `{ok:true, message}` or `{ok:false, code, detail?}`
84
+ */
85
+ export async function generateCommitMessage(ctx, request) {
86
+ const { git, cwd, signal } = request
87
+ const llm = ctx.get('llm')
88
+ if (!llm || typeof llm.stream !== 'function') return { ok: false, code: 'llm-unavailable' }
89
+ const model = resolveModel(ctx)
90
+ if (!model) return { ok: false, code: 'model-unavailable' }
91
+
92
+ const staged = await git.run(['diff', '--cached', '--no-color', '--no-ext-diff', '--no-textconv', '-U3', '--', '.'], { cwd, signal })
93
+ if (staged.exitCode !== 0) return { ok: false, code: 'diff-failed', detail: staged.stderr.trim().slice(0, 400) }
94
+ let diff = staged.stdout
95
+ if (diff.trim() === '') {
96
+ const unstaged = await git.run(['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3', '--', '.'], { cwd, signal })
97
+ if (unstaged.exitCode !== 0) return { ok: false, code: 'diff-failed', detail: unstaged.stderr.trim().slice(0, 400) }
98
+ diff = unstaged.stdout
99
+ }
100
+ if (diff.trim() === '') return { ok: false, code: 'no-changes' }
101
+
102
+ const userText = '为下面的 diff 写一条提交信息(简体中文,遵循 Conventional Commits 规范):\n\n' + clipDiff(diff)
103
+ const attempt = async (system, text) => {
104
+ const controller = new AbortController()
105
+ const timeout = setTimeout(() => controller.abort(), GENERATE_TIMEOUT_MS)
106
+ const composite = typeof AbortSignal.any === 'function' && signal
107
+ ? AbortSignal.any([signal, controller.signal])
108
+ : controller.signal
109
+ let output = ''
110
+ let failure = null
111
+ try {
112
+ const stream = llm.stream({
113
+ provider: model.provider,
114
+ model: model.model,
115
+ system,
116
+ messages: [{
117
+ role: 'user',
118
+ content: [{ type: 'text', text }],
119
+ }],
120
+ temperature: 0.2,
121
+ maxTokens: 400,
122
+ signal: composite,
123
+ })
124
+ for await (const chunk of stream) {
125
+ if (!chunk) continue
126
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') output += chunk.text
127
+ else if (chunk.type === 'finish' && chunk.reason && chunk.reason.kind === 'error') failure = chunk.reason.failure
128
+ else if (chunk.type === 'finish' && chunk.reason && chunk.reason.kind === 'aborted') failure = chunk.reason.failure
129
+ }
130
+ } catch (error) {
131
+ return { output: '', failure: null, error }
132
+ } finally {
133
+ clearTimeout(timeout)
134
+ }
135
+ return { output, failure, error: null }
136
+ }
137
+
138
+ let result = await attempt(SYSTEM_PROMPT, userText)
139
+ if (result.error && !signal?.aborted) {
140
+ return { ok: false, code: 'generate-failed', detail: String(result.error?.message || result.error).slice(0, 400) }
141
+ }
142
+ let message = sanitizeCommitMessage(result.output)
143
+ // Some models still answer English for an English diff; retry once with an
144
+ // explicit Chinese-only instruction before giving up.
145
+ if (message !== '' && !hasChinese(message)) {
146
+ const strict = '重要:提交信息必须使用简体中文,禁止输出英文描述。\n\n' + userText
147
+ result = await attempt(SYSTEM_PROMPT, strict)
148
+ if (result.error && !signal?.aborted) {
149
+ return { ok: false, code: 'generate-failed', detail: String(result.error?.message || result.error).slice(0, 400) }
150
+ }
151
+ message = sanitizeCommitMessage(result.output)
152
+ }
153
+ if (signal?.aborted) return { ok: false, code: 'aborted' }
154
+ if (message === '') {
155
+ return { ok: false, code: 'generate-failed', detail: result.failure?.message ? String(result.failure.message).slice(0, 400) : 'empty completion' }
156
+ }
157
+ return { ok: true, message }
158
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Read-only workspace index + text search for Quick Open and Search.
3
+ *
4
+ * Both surfaces walk the session workspace through `workspaceFiles.list` and
5
+ * read through `workspaceFiles.readBytes`; nothing is written and every walk is
6
+ * bounded by depth, entry count, file size, and total scan budget.
7
+ */
8
+
9
+ /** Directories never indexed (dependency/build/VCS noise). */
10
+ const SKIP_DIRS = new Set([
11
+ 'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt',
12
+ '.cache', '.turbo', '.parcel-cache', 'target', 'obj', 'bin', 'vendor', '.venv',
13
+ 'venv', '__pycache__', '.idea', '.vscode', 'coverage', '.dsh', '.pnpm-store',
14
+ ])
15
+
16
+ /** File extensions never read by search. */
17
+ const BINARY_EXT = new Set([
18
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'pdf', 'zip', 'gz', 'tgz', '7z', 'rar',
19
+ 'tar', 'exe', 'dll', 'so', 'dylib', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'mp3', 'mp4',
20
+ 'mov', 'avi', 'webm', 'sqlite', 'db', 'class', 'jar', 'wasm', 'o', 'obj', 'a', 'lib', 'pdb',
21
+ 'bcmap', 'pfb', 'zstd', 'zst', 'asar',
22
+ ])
23
+
24
+ const INDEX_TTL_MS = 30000
25
+ const MAX_FILES = 8000
26
+ const MAX_DEPTH = 14
27
+ const MAX_ENTRIES_PER_DIR = 2000
28
+ const MAX_SEARCH_FILES = 1500
29
+ const MAX_SEARCH_BYTES = 24 * 1024 * 1024
30
+ const MAX_FILE_BYTES = 512 * 1024
31
+ const MAX_RESULTS = 200
32
+ const SEARCH_TIME_BUDGET_MS = 8000
33
+
34
+ /**
35
+ * Join a parent posix path and a child name.
36
+ * @param {string} parent parent path ('' for root)
37
+ * @param {string} name child name
38
+ * @returns {string}
39
+ */
40
+ export function joinPath(parent, name) {
41
+ return parent === '' ? name : parent + '/' + name
42
+ }
43
+
44
+ /**
45
+ * Whether a file name is searchable text.
46
+ * @param {string} name basename
47
+ * @returns {boolean}
48
+ */
49
+ function searchableName(name) {
50
+ const dot = name.toLowerCase().lastIndexOf('.')
51
+ if (dot < 0) return true
52
+ return !BINARY_EXT.has(name.toLowerCase().slice(dot + 1))
53
+ }
54
+
55
+ /**
56
+ * Per-session cached workspace file index (paths only, cwd-relative posix).
57
+ */
58
+ export class WorkspaceIndex {
59
+ /**
60
+ * @param {object} ctx Host context
61
+ * @param {number} [ttlMs] cache lifetime
62
+ */
63
+ constructor(ctx, ttlMs = INDEX_TTL_MS) {
64
+ this.ctx = ctx
65
+ this.ttlMs = ttlMs
66
+ this.cache = new Map()
67
+ }
68
+
69
+ /**
70
+ * Drop one session's cached index.
71
+ * @param {string} sessionId session identity
72
+ */
73
+ invalidate(sessionId) {
74
+ this.cache.delete(sessionId)
75
+ }
76
+
77
+ /**
78
+ * Walk the workspace breadth-first under the given caps.
79
+ * @param {{sessionId:string, workspaceRoot:string}} scope workspace scope
80
+ * @param {AbortSignal} signal cancellation
81
+ * @returns {Promise<{files:string[], truncated:boolean}>}
82
+ */
83
+ async walk(scope, signal) {
84
+ const files = []
85
+ const queue = [{ dir: '', depth: 0 }]
86
+ let truncated = false
87
+ while (queue.length > 0) {
88
+ if (signal?.aborted) throw new Error('aborted')
89
+ const next = queue.shift()
90
+ let listing
91
+ try {
92
+ listing = await this.ctx.workspaceFiles.list(scope, next.dir === '' ? scope.workspaceRoot : next.dir, signal)
93
+ } catch {
94
+ continue
95
+ }
96
+ const entries = Array.isArray(listing?.entries) ? listing.entries.slice(0, MAX_ENTRIES_PER_DIR) : []
97
+ if (listing?.truncated === true || entries.length < (listing?.entries?.length ?? 0)) truncated = true
98
+ for (const entry of entries) {
99
+ const child = joinPath(next.dir, String(entry.name))
100
+ if (entry.type === 'directory') {
101
+ if (SKIP_DIRS.has(String(entry.name).toLowerCase())) continue
102
+ if (next.depth + 1 > MAX_DEPTH) {
103
+ truncated = true
104
+ continue
105
+ }
106
+ queue.push({ dir: child, depth: next.depth + 1 })
107
+ } else if (entry.type === 'file') {
108
+ files.push(child)
109
+ if (files.length >= MAX_FILES) {
110
+ truncated = true
111
+ return { files, truncated }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ return { files, truncated }
117
+ }
118
+
119
+ /**
120
+ * Cached index read.
121
+ * @param {{sessionId:string, workspaceRoot:string}} scope workspace scope
122
+ * @param {AbortSignal} signal cancellation
123
+ * @param {boolean} [force] bypass cache
124
+ * @returns {Promise<{files:string[], truncated:boolean, cached:boolean}>}
125
+ */
126
+ async get(scope, signal, force = false) {
127
+ const key = scope.sessionId + '\n' + scope.workspaceRoot
128
+ const now = Date.now()
129
+ const hit = this.cache.get(key)
130
+ if (!force && hit && now - hit.at < this.ttlMs) return { files: hit.files, truncated: hit.truncated, cached: true }
131
+ const walked = await this.walk(scope, signal)
132
+ this.cache.set(key, { at: now, files: walked.files, truncated: walked.truncated })
133
+ return { ...walked, cached: false }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Case-insensitive substring search across indexed text files.
139
+ * @param {object} ctx Host context
140
+ * @param {WorkspaceIndex} index workspace index
141
+ * @param {{sessionId:string, workspaceRoot:string}} scope workspace scope
142
+ * @param {{query:string, limit?:number, signal?:AbortSignal}} options search options
143
+ * @returns {Promise<{results:Array<object>, scanned:number, truncated:boolean, elapsedMs:number}>}
144
+ */
145
+ export async function searchWorkspace(ctx, index, scope, options) {
146
+ const query = String(options.query || '').trim()
147
+ if (query.length === 0) return { results: [], scanned: 0, truncated: false, elapsedMs: 0 }
148
+ const limit = Math.min(MAX_RESULTS, Math.max(1, Number(options.limit) || MAX_RESULTS))
149
+ const startedAt = Date.now()
150
+ const signal = options.signal
151
+ const walk = await index.get(scope, signal)
152
+ const results = []
153
+ let scanned = 0
154
+ let bytes = 0
155
+ let truncated = walk.truncated
156
+ const needle = query.toLowerCase()
157
+ for (const path of walk.files) {
158
+ if (signal?.aborted) break
159
+ if (Date.now() - startedAt > SEARCH_TIME_BUDGET_MS) {
160
+ truncated = true
161
+ break
162
+ }
163
+ if (results.length >= limit) {
164
+ truncated = true
165
+ break
166
+ }
167
+ if (!searchableName(path)) continue
168
+ if (scanned >= MAX_SEARCH_FILES) {
169
+ truncated = true
170
+ break
171
+ }
172
+ scanned += 1
173
+ let read
174
+ try {
175
+ read = await ctx.workspaceFiles.readBytes(scope, path, {}, signal)
176
+ } catch {
177
+ continue
178
+ }
179
+ if (!read || !(read.data instanceof Uint8Array)) continue
180
+ if (read.data.byteLength > MAX_FILE_BYTES) {
181
+ truncated = true
182
+ continue
183
+ }
184
+ bytes += read.data.byteLength
185
+ if (bytes > MAX_SEARCH_BYTES) {
186
+ truncated = true
187
+ break
188
+ }
189
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(read.data)
190
+ if (text.indexOf('\u0000') >= 0) continue
191
+ const lines = text.split(/\r\n|\r|\n/)
192
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
193
+ const line = lines[lineIndex]
194
+ const at = line.toLowerCase().indexOf(needle)
195
+ if (at < 0) continue
196
+ results.push({
197
+ path,
198
+ line: lineIndex + 1,
199
+ column: at + 1,
200
+ text: line.length > 240 ? line.slice(0, 240) + '…' : line,
201
+ })
202
+ if (results.length >= limit) break
203
+ }
204
+ }
205
+ return { results, scanned, truncated, elapsedMs: Date.now() - startedAt }
206
+ }