@muyichengshayu/promptx 0.2.17 → 0.2.19

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 (86) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/apps/server/src/codexRoutes.js +12 -0
  3. package/apps/server/src/index.js +2 -0
  4. package/apps/server/src/workspaceFiles.js +170 -0
  5. package/apps/server/src/workspaceFiles.test.js +48 -0
  6. package/apps/web/dist/assets/{CodexSessionManagerDialog-D1PwOD4T.js → CodexSessionManagerDialog-C5Cht229.js} +3 -3
  7. package/apps/web/dist/assets/{TaskDiffReviewDialog-GKKv-IkZ.js → TaskDiffReviewDialog-D2l8KtRI.js} +5 -5
  8. package/apps/web/dist/assets/{WorkbenchSettingsDialog-IWlkg3kU.js → WorkbenchSettingsDialog-DGj-_tuL.js} +1 -1
  9. package/apps/web/dist/assets/WorkbenchView-CebqJlAz.css +1 -0
  10. package/apps/web/dist/assets/WorkbenchView-ih1G4f79.js +58 -0
  11. package/apps/web/dist/assets/index-4LeM_JQe.js +2 -0
  12. package/apps/web/dist/assets/index-D9Ojj7Lp.css +1 -0
  13. package/apps/web/dist/assets/{vendor-markdown-9aQhqbjm.js → vendor-markdown-C2aIjAg9.js} +1 -1
  14. package/apps/web/dist/assets/{vendor-misc-u-M8sNMf.js → vendor-misc-DqxAnmp9.js} +30 -29
  15. package/apps/web/dist/assets/{vendor-router-Dn8q3tJM.js → vendor-router-4TN1NQvz.js} +1 -1
  16. package/apps/web/dist/assets/{vendor-shiki-core-1BqeTDsZ.js → vendor-shiki-core-UNANE_3B.js} +1 -1
  17. package/apps/web/dist/assets/vendor-shiki-lang-bash-OmlQ8O-f.js +1 -0
  18. package/apps/web/dist/assets/vendor-shiki-lang-c-Bf17QIO6.js +1 -0
  19. package/apps/web/dist/assets/vendor-shiki-lang-cpp-Dz7DCXQD.js +1 -0
  20. package/apps/web/dist/assets/vendor-shiki-lang-csharp-weekbih6.js +1 -0
  21. package/apps/web/dist/assets/vendor-shiki-lang-css-Cc89nYhT.js +1 -0
  22. package/apps/web/dist/assets/vendor-shiki-lang-diff-BKW5_WJa.js +1 -0
  23. package/apps/web/dist/assets/vendor-shiki-lang-dockerfile-DKFA3n9j.js +1 -0
  24. package/apps/web/dist/assets/vendor-shiki-lang-dotenv-BftIlyZY.js +1 -0
  25. package/apps/web/dist/assets/vendor-shiki-lang-fish-DTee4iUj.js +1 -0
  26. package/apps/web/dist/assets/vendor-shiki-lang-go-iEA7Y2np.js +1 -0
  27. package/apps/web/dist/assets/vendor-shiki-lang-html-fEXQcPZb.js +1 -0
  28. package/apps/web/dist/assets/vendor-shiki-lang-ini-BJNPDfrG.js +1 -0
  29. package/apps/web/dist/assets/vendor-shiki-lang-java-CZdBcwkx.js +1 -0
  30. package/apps/web/dist/assets/vendor-shiki-lang-javascript-5n6OdqL_.js +1 -0
  31. package/apps/web/dist/assets/vendor-shiki-lang-json-DkR7bRnH.js +1 -0
  32. package/apps/web/dist/assets/vendor-shiki-lang-less-DN-gfwMv.js +1 -0
  33. package/apps/web/dist/assets/vendor-shiki-lang-markdown-CgfEScgY.js +1 -0
  34. package/apps/web/dist/assets/vendor-shiki-lang-powershell-N9rELqmw.js +1 -0
  35. package/apps/web/dist/assets/vendor-shiki-lang-python-6ZggAICS.js +1 -0
  36. package/apps/web/dist/assets/vendor-shiki-lang-rust-BvkG5Wz9.js +1 -0
  37. package/apps/web/dist/assets/vendor-shiki-lang-sass-DFAXHETo.js +1 -0
  38. package/apps/web/dist/assets/vendor-shiki-lang-scss-ClLYCF3s.js +1 -0
  39. package/apps/web/dist/assets/vendor-shiki-lang-sql-CBrOCv6B.js +1 -0
  40. package/apps/web/dist/assets/vendor-shiki-lang-toml-C0vpllp6.js +1 -0
  41. package/apps/web/dist/assets/vendor-shiki-lang-tsx-DVg_XTuK.js +1 -0
  42. package/apps/web/dist/assets/vendor-shiki-lang-typescript-DV5oo9cD.js +1 -0
  43. package/apps/web/dist/assets/vendor-shiki-lang-vue-QYrDJbPe.js +1 -0
  44. package/apps/web/dist/assets/vendor-shiki-lang-xml-D4OZNqPk.js +1 -0
  45. package/apps/web/dist/assets/vendor-shiki-lang-yaml-BZu-v6Um.js +1 -0
  46. package/apps/web/dist/assets/vendor-shiki-theme-github-dark-default-DVxJvksW.js +1 -0
  47. package/apps/web/dist/assets/vendor-shiki-theme-github-light-BRz4x11q.js +1 -0
  48. package/apps/web/dist/assets/{vendor-tiptap-rwYdQb1L.js → vendor-tiptap-DLVi2vZE.js} +1 -1
  49. package/apps/web/dist/assets/{vendor-ui-BglsaDbv.js → vendor-ui-CnLXY_Zv.js} +31 -26
  50. package/apps/web/dist/index.html +4 -4
  51. package/package.json +1 -1
  52. package/apps/web/dist/assets/WorkbenchView-CK1snPBz.css +0 -1
  53. package/apps/web/dist/assets/WorkbenchView-dXHPTH_M.js +0 -58
  54. package/apps/web/dist/assets/index-BAfqUG7o.css +0 -1
  55. package/apps/web/dist/assets/index-DaIoquOV.js +0 -2
  56. package/apps/web/dist/assets/vendor-shiki-lang-bash-zsQu0xn-.js +0 -1
  57. package/apps/web/dist/assets/vendor-shiki-lang-c-BDF8vgks.js +0 -1
  58. package/apps/web/dist/assets/vendor-shiki-lang-cpp-DyDAaXl6.js +0 -1
  59. package/apps/web/dist/assets/vendor-shiki-lang-csharp-BWfTXJXw.js +0 -1
  60. package/apps/web/dist/assets/vendor-shiki-lang-css-DbAm1fUO.js +0 -1
  61. package/apps/web/dist/assets/vendor-shiki-lang-diff-DmGC1G26.js +0 -1
  62. package/apps/web/dist/assets/vendor-shiki-lang-dockerfile-3x-8396r.js +0 -1
  63. package/apps/web/dist/assets/vendor-shiki-lang-dotenv--IVOlX2Q.js +0 -1
  64. package/apps/web/dist/assets/vendor-shiki-lang-fish-Bpv3hhJP.js +0 -1
  65. package/apps/web/dist/assets/vendor-shiki-lang-go-CIGC59jP.js +0 -1
  66. package/apps/web/dist/assets/vendor-shiki-lang-html-Bt_TLLZN.js +0 -1
  67. package/apps/web/dist/assets/vendor-shiki-lang-ini-BIllfys4.js +0 -1
  68. package/apps/web/dist/assets/vendor-shiki-lang-java-CXxu9crF.js +0 -1
  69. package/apps/web/dist/assets/vendor-shiki-lang-javascript-Cha7GNZm.js +0 -1
  70. package/apps/web/dist/assets/vendor-shiki-lang-json-CCZCjDUh.js +0 -1
  71. package/apps/web/dist/assets/vendor-shiki-lang-less-DAToytC5.js +0 -1
  72. package/apps/web/dist/assets/vendor-shiki-lang-markdown-C6lwMXFp.js +0 -1
  73. package/apps/web/dist/assets/vendor-shiki-lang-powershell-lrpuM0ez.js +0 -1
  74. package/apps/web/dist/assets/vendor-shiki-lang-python-Bi9rmtY7.js +0 -1
  75. package/apps/web/dist/assets/vendor-shiki-lang-rust-GTGDbY6A.js +0 -1
  76. package/apps/web/dist/assets/vendor-shiki-lang-sass-DFtHupN8.js +0 -1
  77. package/apps/web/dist/assets/vendor-shiki-lang-scss-BrfBNyyi.js +0 -1
  78. package/apps/web/dist/assets/vendor-shiki-lang-sql-H6--iFN-.js +0 -1
  79. package/apps/web/dist/assets/vendor-shiki-lang-toml-aTteTWYL.js +0 -1
  80. package/apps/web/dist/assets/vendor-shiki-lang-tsx-DoKZm6AV.js +0 -1
  81. package/apps/web/dist/assets/vendor-shiki-lang-typescript-B5awnqri.js +0 -1
  82. package/apps/web/dist/assets/vendor-shiki-lang-vue-CnrgQ7Z5.js +0 -1
  83. package/apps/web/dist/assets/vendor-shiki-lang-xml-Bv-TNPJt.js +0 -1
  84. package/apps/web/dist/assets/vendor-shiki-lang-yaml-DpxUxQ6M.js +0 -1
  85. package/apps/web/dist/assets/vendor-shiki-theme-github-dark-default-DBX58552.js +0 -1
  86. package/apps/web/dist/assets/vendor-shiki-theme-github-light-RLAvZv0q.js +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.19
4
+
5
+ - 工作台 Response 卡片新增图片导出预览:右上角“图片”按钮会按 640px 版式生成 2x 高清 PNG,并在弹窗中展示,方便 PC 右键或手机长按保存、复制和转发。
6
+ - 优化导出图片的移动端可读性:生成图片时会使用导出专用排版,表格和代码块不再横向滚动导致截图不全;宽表和长代码会自动换行,优先保证内容完整。
7
+ - 收敛移动端消息卡片工具栏:Prompt / Response 卡片的“查看”“插入”“图片”等按钮在小屏下只保留图标,桌面端继续显示文字,避免按钮文案被挤成竖排。
8
+
9
+ ## 0.2.18
10
+
11
+ - 源码浏览新增行作者提示:查看代码时默认启用 Git blame,鼠标悬停到源码行会在本行下方靠右显示最近修改作者、提交摘要、短 hash 与时间;非 Git 仓库、未跟踪文件、大文件和二进制文件会平稳降级为不可用提示。
12
+
3
13
  ## 0.2.17
4
14
 
5
15
  - 修复 Windows 上 Codex 任务实际完成后工作台仍显示运行中、任务卡片持续转圈的问题:runner 在收到 `turn.completed` 后会进入短暂收尾等待;如果 Codex 子进程没有及时退出,会按完成状态落库并回收子进程,避免成功任务长时间卡在 `running`。
@@ -74,6 +74,7 @@ function registerCodexRoutes(app, options = {}) {
74
74
  listTaskSlugsByCodexSessionId = () => [],
75
75
  listWorkspaceSuggestions,
76
76
  listWorkspaceTree,
77
+ readWorkspaceFileBlame,
77
78
  readWorkspaceFileContent,
78
79
  resetPromptxCodexSession = () => null,
79
80
  runDispatchService,
@@ -173,6 +174,17 @@ function registerCodexRoutes(app, options = {}) {
173
174
  })
174
175
  })
175
176
 
177
+ app.get('/api/codex/sessions/:sessionId/files/blame', async (request, reply) => {
178
+ const session = getPromptxCodexSessionById(request.params.sessionId)
179
+ if (!session) {
180
+ return reply.code(404).send({ messageKey: 'errors.sessionNotFound', message: '没有找到对应的 PromptX 项目。' })
181
+ }
182
+
183
+ return readWorkspaceFileBlame(session.cwd, {
184
+ path: request.query?.path,
185
+ })
186
+ })
187
+
176
188
  app.get('/api/codex/sessions/:sessionId/files/content-search', async (request, reply) => {
177
189
  const session = getPromptxCodexSessionById(request.params.sessionId)
178
190
  if (!session) {
@@ -55,6 +55,7 @@ import { listKnownSessionsByEngine, listKnownWorkspacesByEngine } from './agents
55
55
  import {
56
56
  listDirectoryPickerTree,
57
57
  readWorkspaceFileContent,
58
+ readWorkspaceFileBlame,
58
59
  listWorkspaceTree,
59
60
  searchDirectoryPickerEntries,
60
61
  searchWorkspaceFileContent,
@@ -434,6 +435,7 @@ registerCodexRoutes(app, {
434
435
  listTaskSlugsByCodexSessionId,
435
436
  listWorkspaceSuggestions: workspaceSuggestionService.listWorkspaceSuggestions,
436
437
  listWorkspaceTree,
438
+ readWorkspaceFileBlame,
437
439
  readWorkspaceFileContent,
438
440
  resetPromptxCodexSession,
439
441
  runDispatchService,
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
4
5
  import { createApiError } from './apiErrors.js'
5
6
 
6
7
  const WORKSPACE_HIDDEN_DIRECTORY_NAMES = new Set([
@@ -41,6 +42,8 @@ const DEFAULT_CONTENT_SEARCH_LIMIT = 80
41
42
  const MAX_SEARCH_VISITS = 20000
42
43
  const DIRECTORY_PICKER_LIMIT = 240
43
44
  const DEFAULT_FILE_PREVIEW_LIMIT = 200 * 1024
45
+ const DEFAULT_FILE_BLAME_LINE_LIMIT = Math.max(200, Number(process.env.PROMPTX_FILE_BLAME_LINE_LIMIT) || 5000)
46
+ const DEFAULT_FILE_BLAME_TIMEOUT_MS = Math.max(500, Number(process.env.PROMPTX_FILE_BLAME_TIMEOUT_MS) || 6000)
44
47
  const MAX_IMAGE_PREVIEW_BYTES = 2 * 1024 * 1024
45
48
  const MAX_CONTENT_SEARCH_FILE_BYTES = 1024 * 1024
46
49
  const MAX_CONTENT_MATCHES_PER_FILE = 20
@@ -155,6 +158,17 @@ function createHttpError(message, statusCode = 400) {
155
158
  return createApiError('', message, statusCode)
156
159
  }
157
160
 
161
+ function createWorkspaceUnavailablePayload(target, reason = '', message = '') {
162
+ return {
163
+ cwd: target.root,
164
+ path: target.relativePath,
165
+ supported: false,
166
+ reason: String(reason || 'unavailable').trim() || 'unavailable',
167
+ message: String(message || '').trim(),
168
+ items: [],
169
+ }
170
+ }
171
+
158
172
  function toPosixPath(value = '') {
159
173
  return String(value || '').replace(/\\/g, '/')
160
174
  }
@@ -216,6 +230,25 @@ function resolveWorkspaceTarget(workspacePath, relativePath = '') {
216
230
  }
217
231
  }
218
232
 
233
+ function runGit(workspacePath = '', args = [], options = {}) {
234
+ const result = spawnSync('git', ['-C', workspacePath, ...args], {
235
+ encoding: 'utf8',
236
+ maxBuffer: 12 * 1024 * 1024,
237
+ timeout: DEFAULT_FILE_BLAME_TIMEOUT_MS,
238
+ windowsHide: true,
239
+ ...options,
240
+ })
241
+
242
+ return {
243
+ status: typeof result.status === 'number' ? result.status : 1,
244
+ stdout: String(result.stdout || ''),
245
+ stderr: String(result.stderr || ''),
246
+ signal: String(result.signal || ''),
247
+ errorCode: String(result.error?.code || ''),
248
+ timedOut: String(result.error?.code || '') === 'ETIMEDOUT',
249
+ }
250
+ }
251
+
219
252
  function getPathType(absolutePath = '') {
220
253
  try {
221
254
  const stats = fs.statSync(absolutePath)
@@ -232,6 +265,78 @@ function getPathType(absolutePath = '') {
232
265
  return ''
233
266
  }
234
267
 
268
+ function getFileLineCount(absolutePath = '') {
269
+ const content = fs.readFileSync(absolutePath, 'utf8').replace(/\r\n/g, '\n')
270
+ return content ? content.split('\n').length : 0
271
+ }
272
+
273
+ function parseGitBlamePorcelain(output = '') {
274
+ const items = []
275
+ const commitMeta = new Map()
276
+ let current = null
277
+
278
+ String(output || '').replace(/\r\n/g, '\n').split('\n').forEach((line) => {
279
+ const headerMatch = line.match(/^([0-9a-f]{40})\s+\d+\s+(\d+)(?:\s+\d+)?$/i)
280
+ if (headerMatch) {
281
+ const commit = headerMatch[1]
282
+ const lineNumber = Number(headerMatch[2]) || 0
283
+ const cached = commitMeta.get(commit) || {}
284
+ current = {
285
+ line: lineNumber,
286
+ commit,
287
+ author: cached.author || '',
288
+ authorMail: cached.authorMail || '',
289
+ authorTime: cached.authorTime || '',
290
+ summary: cached.summary || '',
291
+ }
292
+ return
293
+ }
294
+
295
+ if (!current) {
296
+ return
297
+ }
298
+
299
+ if (line.startsWith('author ')) {
300
+ current.author = line.slice('author '.length)
301
+ return
302
+ }
303
+ if (line.startsWith('author-mail ')) {
304
+ current.authorMail = line.slice('author-mail '.length).replace(/^<|>$/g, '')
305
+ return
306
+ }
307
+ if (line.startsWith('author-time ')) {
308
+ const timestamp = Number(line.slice('author-time '.length)) || 0
309
+ current.authorTime = timestamp > 0 ? new Date(timestamp * 1000).toISOString() : ''
310
+ return
311
+ }
312
+ if (line.startsWith('summary ')) {
313
+ current.summary = line.slice('summary '.length)
314
+ return
315
+ }
316
+
317
+ if (line.startsWith('\t')) {
318
+ if (current.line > 0) {
319
+ const meta = {
320
+ author: current.author,
321
+ authorMail: current.authorMail,
322
+ authorTime: current.authorTime,
323
+ summary: current.summary,
324
+ }
325
+ commitMeta.set(current.commit, meta)
326
+ items.push({
327
+ line: current.line,
328
+ commit: current.commit,
329
+ shortCommit: current.commit.slice(0, 8),
330
+ ...meta,
331
+ })
332
+ }
333
+ current = null
334
+ }
335
+ })
336
+
337
+ return items.sort((left, right) => left.line - right.line)
338
+ }
339
+
235
340
  function shouldIgnoreDirectory(entry) {
236
341
  return entry?.isDirectory?.() && WORKSPACE_HIDDEN_DIRECTORY_NAMES.has(entry.name)
237
342
  }
@@ -1025,6 +1130,71 @@ export function readWorkspaceFileContent(workspacePath, options = {}) {
1025
1130
  }
1026
1131
  }
1027
1132
 
1133
+ export function readWorkspaceFileBlame(workspacePath, options = {}) {
1134
+ const target = resolveWorkspaceTarget(workspacePath, options.path)
1135
+
1136
+ let stats
1137
+ try {
1138
+ stats = fs.statSync(target.absolutePath)
1139
+ } catch {
1140
+ throw createApiError('errors.fileNotFound', '文件不存在。', 404)
1141
+ }
1142
+
1143
+ if (!stats.isFile()) {
1144
+ throw createApiError('errors.fileOnly', '只能读取文件内容。')
1145
+ }
1146
+
1147
+ if (stats.size > DEFAULT_FILE_PREVIEW_LIMIT) {
1148
+ return createWorkspaceUnavailablePayload(target, 'too_large', '文件较大,暂不加载行作者信息。')
1149
+ }
1150
+
1151
+ const previewBuffer = readFileSlice(target.absolutePath, Math.min(stats.size, DEFAULT_FILE_PREVIEW_LIMIT))
1152
+ const extension = path.extname(path.basename(target.absolutePath)).toLowerCase()
1153
+ const isKnownTextFile = TEXT_FILE_EXTENSIONS.has(extension)
1154
+ if (!isKnownTextFile && isLikelyBinaryBuffer(previewBuffer)) {
1155
+ return createWorkspaceUnavailablePayload(target, 'binary', '当前文件为二进制内容,暂不加载行作者信息。')
1156
+ }
1157
+
1158
+ const fileLineCount = getFileLineCount(target.absolutePath)
1159
+ if (fileLineCount > DEFAULT_FILE_BLAME_LINE_LIMIT) {
1160
+ return createWorkspaceUnavailablePayload(target, 'too_many_lines', '文件行数较多,暂不加载行作者信息。')
1161
+ }
1162
+
1163
+ const gitCheck = runGit(target.root, ['rev-parse', '--is-inside-work-tree'])
1164
+ if (gitCheck.status !== 0 || String(gitCheck.stdout || '').trim() !== 'true') {
1165
+ return createWorkspaceUnavailablePayload(target, 'not_git', '当前目录不是 Git 仓库。')
1166
+ }
1167
+
1168
+ const blame = runGit(target.root, [
1169
+ 'blame',
1170
+ '--line-porcelain',
1171
+ '--',
1172
+ target.relativePath,
1173
+ ])
1174
+
1175
+ if (blame.timedOut) {
1176
+ return createWorkspaceUnavailablePayload(target, 'timeout', '行作者信息加载超时。')
1177
+ }
1178
+
1179
+ if (blame.status !== 0) {
1180
+ const stderr = String(blame.stderr || '').trim()
1181
+ if (/no such path|no such file|not in HEAD|fatal: no such/i.test(stderr)) {
1182
+ return createWorkspaceUnavailablePayload(target, 'untracked', '当前文件尚未被 Git 跟踪。')
1183
+ }
1184
+
1185
+ return createWorkspaceUnavailablePayload(target, 'failed', stderr || '行作者信息加载失败。')
1186
+ }
1187
+
1188
+ return {
1189
+ cwd: target.root,
1190
+ path: target.relativePath,
1191
+ supported: true,
1192
+ reason: '',
1193
+ message: '',
1194
+ items: parseGitBlamePorcelain(blame.stdout),
1195
+ }
1196
+ }
1197
+
1028
1198
  export function listDirectoryPickerTree(options = {}) {
1029
1199
  const isRootRequest = !String(options.path || '').trim()
1030
1200
  const targetPath = normalizeDirectoryPickerPath(options.path) || getDirectoryPickerHomePath()
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { execFileSync } from 'node:child_process'
2
3
  import fs from 'node:fs'
3
4
  import os from 'node:os'
4
5
  import path from 'node:path'
@@ -7,12 +8,20 @@ import test from 'node:test'
7
8
  import {
8
9
  listDirectoryPickerTree,
9
10
  listWorkspaceTree,
11
+ readWorkspaceFileBlame,
10
12
  readWorkspaceFileContent,
11
13
  searchWorkspaceFileContent,
12
14
  searchWorkspaceEntries,
13
15
  searchDirectoryPickerEntries,
14
16
  } from './workspaceFiles.js'
15
17
 
18
+ function git(cwd, args = []) {
19
+ return execFileSync('git', ['-C', cwd, ...args], {
20
+ encoding: 'utf8',
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ }).trim()
23
+ }
24
+
16
25
  test('listDirectoryPickerTree returns filesystem roots when path is empty', () => {
17
26
  const payload = listDirectoryPickerTree()
18
27
 
@@ -277,3 +286,42 @@ test('readWorkspaceFileContent marks binary files', () => {
277
286
  assert.equal(payload.binary, true)
278
287
  assert.equal(payload.content, '')
279
288
  })
289
+
290
+ test('readWorkspaceFileBlame returns author metadata per source line', () => {
291
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-workspace-blame-'))
292
+ const sourcePath = path.join(tempDir, 'src', 'main.js')
293
+
294
+ fs.mkdirSync(path.dirname(sourcePath), { recursive: true })
295
+ fs.writeFileSync(sourcePath, 'const answer = 42\nconsole.log(answer)\n', 'utf8')
296
+ git(tempDir, ['init'])
297
+ git(tempDir, ['config', 'user.email', 'promptx@example.com'])
298
+ git(tempDir, ['config', 'user.name', 'PromptX'])
299
+ git(tempDir, ['add', 'src/main.js'])
300
+ git(tempDir, ['commit', '-m', 'initial source'])
301
+
302
+ const payload = readWorkspaceFileBlame(tempDir, {
303
+ path: 'src/main.js',
304
+ })
305
+
306
+ assert.equal(payload.supported, true)
307
+ assert.equal(payload.path, 'src/main.js')
308
+ assert.equal(payload.items.length, 2)
309
+ assert.equal(payload.items[0].line, 1)
310
+ assert.equal(payload.items[0].author, 'PromptX')
311
+ assert.equal(payload.items[0].authorMail, 'promptx@example.com')
312
+ assert.equal(payload.items[0].summary, 'initial source')
313
+ assert.equal(payload.items[0].shortCommit.length, 8)
314
+ })
315
+
316
+ test('readWorkspaceFileBlame returns unavailable payload outside git repository', () => {
317
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-workspace-blame-nongit-'))
318
+ fs.writeFileSync(path.join(tempDir, 'note.txt'), 'hello\n', 'utf8')
319
+
320
+ const payload = readWorkspaceFileBlame(tempDir, {
321
+ path: 'note.txt',
322
+ })
323
+
324
+ assert.equal(payload.supported, false)
325
+ assert.equal(payload.reason, 'not_git')
326
+ assert.deepEqual(payload.items, [])
327
+ })