dsh-my-observability 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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-my-observability — client half (browser). SOURCE TEMPLATE.
3
+ *
4
+ * 提供两个侧边栏页签:
5
+ * - 轨迹回放(dsh-my-observability:replay):按时间轴查看 agent 行为
6
+ * (agent 状态 / 模型流 / 工具调用与结果),支持会话切换与类型过滤,
7
+ * 数据来自 server 端事件审计(/observability/api/events);
8
+ * - Git 工具 + 增量 diff 审查(dsh-my-observability:git):仓库状态与
9
+ * 差异查看、类型化提交(Conventional Commits)、提交前规则引擎 +
10
+ * 可选 AI 审查(/observability/api/git/* 与 /observability/api/review)。
11
+ *
12
+ * 面板可见(visible)时轮询(REPLAY_POLL_MS),隐藏时暂停(省请求)。
13
+ * 样式走 DSH 语义 token(--dsw-alias-* / --dsw-font-*),随 activation
14
+ * 注入、fiber teardown 卸载(HMR/禁用无残留)。
15
+ *
16
+ * BUILD NOTE: 本文件是模板源码,不是 DSH 实际服务的文件。scripts/build.mjs
17
+ * 将四个片段文件(lib/parts/i18n.js / replay.js / git.js / styles.js,均为
18
+ * 无 import/export 的纯函数声明文本)经下方 __PART_*__ 占位符(函数式
19
+ * replaceAll,避免 $&/$1 特殊解释)拼接进 factory 作用域,写出
20
+ * lib/client.js —— 即 DSH 实际服务的产物。产物必须提交;CI 只对产物执行
21
+ * node --check(见 scripts/test-all.sh / .github/workflows/ci.yml)。
22
+ */
23
+ window.__ModuleLoader__.load({
24
+ id: 'dsh-my-observability',
25
+ factory: (require) => {
26
+ var module = { exports: {} }
27
+ var exports = module.exports
28
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
29
+ const { createElement, useEffect, useState } = require('react')
30
+
31
+ // ── parts(scripts/build.mjs 拼接;顺序固定)───────────────────────
32
+ /*__PART_I18N__*/
33
+ /*__PART_REPLAY__*/
34
+ /*__PART_GIT__*/
35
+ /*__PART_STYLES__*/
36
+
37
+ // ── 插件体:样式注入 + 两个页签注册 ────────────────────────────────
38
+ exports.inject = ['betterSidebar']
39
+
40
+ exports.apply = function apply(ctx) {
41
+ ctx.effect(() => injectStyles(), 'dsh-my-observability: styles')
42
+ const service = ctx.betterSidebar
43
+ if (service === undefined) return
44
+ ctx.effect(() => service.registerTab({
45
+ id: 'dsh-my-observability:replay',
46
+ title: () => strings.replayTitle(),
47
+ order: 40,
48
+ single: true,
49
+ component: (props) => createElement(ReplayPanel, props),
50
+ }), 'dsh-my-observability: replay tab registration')
51
+ ctx.effect(() => service.registerTab({
52
+ id: 'dsh-my-observability:git',
53
+ title: () => strings.gitTitle(),
54
+ order: 41,
55
+ single: true,
56
+ component: (props) => createElement(GitPanel, props),
57
+ }), 'dsh-my-observability: git tab registration')
58
+ }
59
+
60
+ return module.exports
61
+ },
62
+ })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * dsh-my-observability — shared constants.
3
+ */
4
+ export const MAX_ARG_KEYS = 10
5
+ export const MAX_TEXT_LEN = 200
6
+ export const REVIEW_TIMEOUT_MS = 60000
7
+ export const GIT_TIMEOUT_MS = 30000
package/lib/diff.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * dsh-my-observability — unified diff parser (pure functions).
3
+ *
4
+ * 解析 `git diff` 输出的 unified diff 文本,提取每个变更文件的新增/删除
5
+ * 行(含行号)与二进制标记,供规则审查引擎消费。全部为纯函数,可独立
6
+ * 测试。
7
+ */
8
+
9
+ /** 解析 diff 文本:{ files, binary }。 */
10
+ export function parseDiff(text) {
11
+ if (typeof text !== 'string' || text === '') return { files: [], binary: false }
12
+ const files = []
13
+ let current = null
14
+ let hunkLine = 0
15
+ let binary = false
16
+ for (const line of text.split('\n')) {
17
+ const kind = classifyLine(line)
18
+ if (kind === 'file') {
19
+ current = { path: pathOf(line), insertions: 0, deletions: 0, addedLines: [], binary: false }
20
+ files.push(current)
21
+ hunkLine = 0
22
+ continue
23
+ }
24
+ if (current === null) continue
25
+ const applied = applyLine(current, kind, line, hunkLine)
26
+ hunkLine = applied.hunkLine
27
+ if (applied.binary) binary = true
28
+ }
29
+ return { files, binary }
30
+ }
31
+
32
+ /** 应用一行到当前文件(返回新的 hunkLine 与 binary 标记)。 */
33
+ function applyLine(current, kind, line, hunkLine) {
34
+ if (kind === 'binary') {
35
+ current.binary = true
36
+ return { hunkLine, binary: true }
37
+ }
38
+ if (kind === 'hunk') return { hunkLine: hunkLineOf(line) }
39
+ if (kind === 'added') {
40
+ current.insertions += 1
41
+ current.addedLines.push({ line: hunkLine, text: line.slice(1) })
42
+ return { hunkLine: hunkLine + 1 }
43
+ }
44
+ if (kind === 'deleted') {
45
+ current.deletions += 1
46
+ return { hunkLine }
47
+ }
48
+ if (kind === 'context') return { hunkLine: hunkLine + 1 }
49
+ return { hunkLine }
50
+ }
51
+
52
+ /** diff 行分类(file/binary/hunk/added/deleted/context/other)。 */
53
+ function classifyLine(line) {
54
+ if (line.startsWith('diff --git ')) return 'file'
55
+ if (line.startsWith('Binary files ')) return 'binary'
56
+ if (line.startsWith('@@ ')) return 'hunk'
57
+ if (line.startsWith('+') && !line.startsWith('+++')) return 'added'
58
+ if (line.startsWith('-') && !line.startsWith('---')) return 'deleted'
59
+ if (line.startsWith(' ')) return 'context'
60
+ return 'other'
61
+ }
62
+
63
+ /** 文件路径:`diff --git a/x b/y` → y(b/ 前缀剥除)。 */
64
+ function pathOf(line) {
65
+ const parts = line.split(' ')
66
+ const b = parts[parts.length - 1] ?? ''
67
+ return b.startsWith('b/') ? b.slice(2) : b
68
+ }
69
+
70
+ /** hunk 起始行号:`@@ -1,3 +10,4 @@` → 10(新文件侧)。 */
71
+ function hunkLineOf(line) {
72
+ const match = /@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
73
+ return match !== null ? Number(match[1]) : 0
74
+ }
75
+
76
+ /** 是否为测试文件(路径含 test/spec 标记)。 */
77
+ export function isTestFile(path) {
78
+ return /(^|\/)(test|tests|__tests__|spec)(\/|$)/.test(path)
79
+ || /\.(test|spec)\./.test(path)
80
+ }
81
+
82
+ /** 是否为源码文件(排除纯文档/配置类;无扩展名视为源码)。 */
83
+ export function isSourceFile(path) {
84
+ if (isTestFile(path)) return false
85
+ return !/\.(md|txt|json|yml|yaml|lock|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot)$/.test(path)
86
+ }
package/lib/fence.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * dsh-my-observability — Host-header trust fence(与 /api 网关一致的契约)。
3
+ *
4
+ * 判断请求是否来自可信方:host 必须为 loopback 或受信权威(trustedHosts),
5
+ * 且 sec-fetch-site 不得为 cross-site、origin(若存在)必须与 host 同源。
6
+ */
7
+
8
+ /** 请求是否通过信任围栏(loopback 或受信权威 + 同源校验)。 */
9
+ export function isTrustedApiRequest(request, trustedHosts) {
10
+ const host = header(request.headers, 'host')
11
+ if (host === undefined) return false
12
+ const hostUrl = parseAuthority(host)
13
+ if (hostUrl === undefined) return false
14
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
15
+ if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
16
+ const origin = header(request.headers, 'origin')
17
+ if (origin === undefined) return true
18
+ try {
19
+ return new URL(origin).host === hostUrl.host
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ /** 读取字符串型请求头(非字符串视为缺失)。 */
26
+ export function header(headers, name) {
27
+ const value = headers[name]
28
+ return typeof value === 'string' ? value : undefined
29
+ }
30
+
31
+ function parseAuthority(authority) {
32
+ try {
33
+ return new URL(`http://${authority}`)
34
+ } catch {
35
+ return undefined
36
+ }
37
+ }
38
+
39
+ function isLoopbackHostname(hostname) {
40
+ if (hostname === 'localhost' || hostname === '[::1]') return true
41
+ const parts = hostname.split('.')
42
+ return parts.length === 4
43
+ && parts[0] === '127'
44
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
45
+ }
46
+
47
+ function canonicalAuthority(entry, entryUrl) {
48
+ const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
49
+ return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
50
+ }
51
+
52
+ function isTrustedAuthority(hostUrl, trustedHosts) {
53
+ return trustedHosts.some((entry) => {
54
+ const entryUrl = parseAuthority(entry)
55
+ if (entryUrl === undefined) return false
56
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
57
+ ? entryUrl.hostname === hostUrl.hostname
58
+ : entryUrl.host === hostUrl.host
59
+ })
60
+ }
package/lib/git.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * dsh-my-observability — structured Git operations.
3
+ *
4
+ * 结构化 Git 工具:类型化提交(Conventional Commits)+ 状态/差异查询。
5
+ * - formatCommitMessage:纯函数,生成 `<type>(<scope>): <description>` 消息
6
+ * - parseCommitRequest:校验提交请求(type 枚举 / scope 格式 / 描述必填)
7
+ * - gitStatus / gitDiff / gitCommit:execFile 执行 git(不经 shell),
8
+ * 路径必须为存在的 git 仓库(rev-parse 校验),全部带超时与输出上限
9
+ *
10
+ * 提交流程:git add -A → git commit -m <完整消息>(单参数传入,消息由
11
+ * 服务端生成,不拼接用户输入到 shell)。
12
+ */
13
+ import { execFile } from 'node:child_process'
14
+ import { statSync } from 'node:fs'
15
+ import { GIT_TIMEOUT_MS } from './constants.js'
16
+
17
+ /** Conventional Commits 类型枚举。 */
18
+ export const COMMIT_TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore']
19
+
20
+ /** 生成类型化提交消息(纯函数,可独立测试)。 */
21
+ export function formatCommitMessage({ type, scope, description, body }) {
22
+ const head = scope !== undefined && scope !== '' ? `${type}(${scope}): ${description}` : `${type}: ${description}`
23
+ return body !== undefined && body !== '' ? `${head}\n\n${body}` : head
24
+ }
25
+
26
+ /** 校验并规整提交请求;非法返回 undefined(type/scope/description 规则见上)。 */
27
+ export function parseCommitRequest(payload) {
28
+ if (!isObject(payload)) return undefined
29
+ if (!COMMIT_TYPES.includes(payload.type)) return undefined
30
+ const description = stringOf(payload.description).trim()
31
+ if (description === '') return undefined
32
+ const scope = stringOf(payload.scope).trim()
33
+ if (scope !== '' && !/^[a-z0-9-]+$/.test(scope)) return undefined
34
+ if (payload.body !== undefined && typeof payload.body !== 'string') return undefined
35
+ return { type: payload.type, scope, description, body: stringOf(payload.body).trim() }
36
+ }
37
+
38
+ function isObject(value) {
39
+ return value !== null && typeof value === 'object'
40
+ }
41
+
42
+ function stringOf(value) {
43
+ return typeof value === 'string' ? value : ''
44
+ }
45
+
46
+ /** 路径是否为存在的 git 仓库(目录存在且 git rev-parse 成功)。 */
47
+ export async function isGitRepo(repoPath) {
48
+ if (typeof repoPath !== 'string' || repoPath === '') return false
49
+ try {
50
+ if (!statSync(repoPath).isDirectory()) return false
51
+ } catch {
52
+ return false
53
+ }
54
+ const result = await runGit(repoPath, ['rev-parse', '--git-dir'])
55
+ return result.ok
56
+ }
57
+
58
+ /** 执行 git 命令(不经 shell;超时 + 输出上限;失败返回 { ok:false, error })。 */
59
+ function runGit(repoPath, args) {
60
+ return new Promise((resolve) => {
61
+ execFile('git', args, {
62
+ cwd: repoPath,
63
+ timeout: GIT_TIMEOUT_MS,
64
+ maxBuffer: 16 * 1024 * 1024,
65
+ }, (error, stdout, stderr) => {
66
+ if (error !== null) {
67
+ const message = typeof stderr === 'string' && stderr.trim() !== '' ? stderr.trim() : error.message
68
+ resolve({ ok: false, error: { message } })
69
+ return
70
+ }
71
+ resolve({ ok: true, stdout, stderr })
72
+ })
73
+ })
74
+ }
75
+
76
+ /** 仓库状态:分支 + 变更清单(status --short --branch 解析)。 */
77
+ export async function gitStatus(repoPath) {
78
+ if (!(await isGitRepo(repoPath))) return { ok: false, error: { message: 'not a git repository' } }
79
+ const result = await runGit(repoPath, ['status', '--short', '--branch'])
80
+ if (!result.ok) return result
81
+ const lines = result.stdout.split('\n').filter((line) => line !== '')
82
+ const branch = parseBranch(lines[0] ?? '')
83
+ const changes = lines.slice(1).map(parseChangeLine)
84
+ return {
85
+ ok: true,
86
+ branch,
87
+ changes,
88
+ stagedCount: changes.filter((change) => change.staged).length,
89
+ unstagedCount: changes.filter((change) => !change.staged).length,
90
+ clean: changes.length === 0,
91
+ }
92
+ }
93
+
94
+ /** 分支行解析:`## main...origin/main [ahead 1]` → main。 */
95
+ function parseBranch(line) {
96
+ if (!line.startsWith('## ')) return ''
97
+ const head = line.slice(3).split('...')[0].trim()
98
+ return head
99
+ }
100
+
101
+ /** 变更行解析:`XY path`(X=暂存区状态,Y=工作区状态;`??`=未跟踪)。 */
102
+ function parseChangeLine(line) {
103
+ const status = line.slice(0, 2)
104
+ const path = line.slice(3)
105
+ const staged = status[0] !== ' ' && status[0] !== '?'
106
+ return { status, path, staged }
107
+ }
108
+
109
+ /** 差异文本:git diff(工作区)或 git diff --staged(暂存区)。 */
110
+ export async function gitDiff(repoPath, staged) {
111
+ if (!(await isGitRepo(repoPath))) return { ok: false, error: { message: 'not a git repository' } }
112
+ const args = staged ? ['diff', '--staged'] : ['diff']
113
+ const result = await runGit(repoPath, args)
114
+ if (!result.ok) return result
115
+ return { ok: true, text: result.stdout }
116
+ }
117
+
118
+ /** 类型化提交:git add -A → git commit -m <消息>;返回 hash + 提交摘要。 */
119
+ export async function gitCommit(repoPath, request) {
120
+ if (!(await isGitRepo(repoPath))) return { ok: false, error: { message: 'not a git repository' } }
121
+ const parsed = parseCommitRequest(request)
122
+ if (parsed === undefined) return { ok: false, error: { message: 'invalid commit request' } }
123
+ const message = formatCommitMessage(parsed)
124
+ const addResult = await runGit(repoPath, ['add', '-A'])
125
+ if (!addResult.ok) return addResult
126
+ const commitResult = await runGit(repoPath, ['commit', '-m', message])
127
+ if (!commitResult.ok) return commitResult
128
+ const hash = parseCommitHash(commitResult.stdout)
129
+ return { ok: true, hash, message, summary: commitSummary(commitResult.stdout) }
130
+ }
131
+
132
+ /** 从 commit 输出提取 hash(`[main abc1234] ...`)。 */
133
+ function parseCommitHash(stdout) {
134
+ const match = /\[[^\]]+\s+([0-9a-f]{7,40})\]/.exec(stdout)
135
+ return match !== null ? match[1] : ''
136
+ }
137
+
138
+ /** 提交摘要:输出首行(`[main abc1234] message`)。 */
139
+ function commitSummary(stdout) {
140
+ const first = stdout.split('\n')[0].trim()
141
+ return first.length > 120 ? `${first.slice(0, 120)}…` : first
142
+ }
package/lib/index.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * dsh-my-observability — host half.
3
+ *
4
+ * 可观测性 + Git 工程工具:
5
+ * 1. 事件审计:监听 agent/status、llm/stream、tools/* 事件,记录审计
6
+ * 日志(agent 行为可追溯),按会话隔离、重启后恢复(持久化
7
+ * $DSH_HOME/observability/audit.json,防抖 + 原子写);
8
+ * 2. 轨迹回放:/observability/api 查询接口供侧边栏时间轴面板消费;
9
+ * 3. 结构化 Git:类型化提交(Conventional Commits)+ 状态/差异查询;
10
+ * 4. 增量 diff 审查:提交前规则引擎审查 + 可选 AI agent 增强。
11
+ *
12
+ * 模块结构:
13
+ * - fence.js — Host-header 信任围栏(loopback / trustedHosts / 同源)
14
+ * - store.js — 审计事件存储(会话隔离 / 重启恢复 / 上限 / 原子持久化)
15
+ * - audit.js — 事件监听(agent/status、llm/stream、tools/pre-execute、tools/execute)
16
+ * - git.js — 结构化 Git 操作(类型化提交 / status / diff)
17
+ * - diff.js — unified diff 解析(纯函数)
18
+ * - review.js — 增量 diff 审查规则引擎(纯函数)
19
+ * - ai.js — 可选 AI 审查增强(agents.create,失败降级)
20
+ * - routes.js — /observability/api 路由
21
+ */
22
+ import { createStore } from './store.js'
23
+ import { attachAuditListeners } from './audit.js'
24
+ import { registerObservabilityRoutes } from './routes.js'
25
+
26
+ export const name = 'dsh-my-observability'
27
+
28
+ export const inject = ['webServer']
29
+
30
+ export function apply(ctx, config) {
31
+ // ── 配置(应用层 config 覆盖,默认全部开启)─────────────────────────
32
+ const options = {
33
+ aiReview: config?.aiReview !== false,
34
+ aiTimeoutMs: Number.isFinite(config?.aiTimeoutMs) && config.aiTimeoutMs > 0 ? config.aiTimeoutMs : 60000,
35
+ }
36
+
37
+ // ── 审计存储:会话隔离 + 持久化 + 重启恢复 ──────────────────────────
38
+ const store = createStore(ctx)
39
+
40
+ // ── 事件监听(只读观察;waterfall 一律透传 next())──────────────────
41
+ attachAuditListeners(ctx, store.record)
42
+
43
+ // ── 路由(查询 / git 工具 / diff 审查)──────────────────────────────
44
+ registerObservabilityRoutes(ctx, store, options)
45
+
46
+ // ── 卸载冲刷:清防抖定时器 + 立即落盘 ───────────────────────────────
47
+ ctx.effect(() => store.dispose, 'dsh-my-observability: persistence teardown')
48
+ }
@@ -0,0 +1,240 @@
1
+ // ── Git 工具 + 增量 diff 审查面板 ──────────────────────────────────
2
+ const REPO_KEY = 'dsh-my-observability:repo'
3
+ const COMMIT_TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore']
4
+
5
+ function loadRepoKey() {
6
+ try {
7
+ const value = window.localStorage.getItem(REPO_KEY)
8
+ return typeof value === 'string' ? value : ''
9
+ } catch {
10
+ return ''
11
+ }
12
+ }
13
+
14
+ function saveRepoKey(repo) {
15
+ try {
16
+ window.localStorage.setItem(REPO_KEY, repo)
17
+ } catch {
18
+ // storage is best-effort
19
+ }
20
+ }
21
+
22
+ /** 状态条:分支 + 变更计数。 */
23
+ function StatusBar({ status }) {
24
+ if (status === null) return null
25
+ const parts = [`${strings.branch()} ${status.branch}`]
26
+ if (status.clean) parts.push(strings.clean())
27
+ else {
28
+ if (status.stagedCount > 0) parts.push(`${status.stagedCount} ${strings.staged()}`)
29
+ if (status.unstagedCount > 0) parts.push(`${status.unstagedCount} ${strings.unstaged()}`)
30
+ }
31
+ return createElement('div', { className: 'dso-status' }, parts.join(' · '))
32
+ }
33
+
34
+ /** 差异文本预览。 */
35
+ function DiffView({ diff }) {
36
+ return createElement('div', { className: 'dso-section' },
37
+ createElement('div', { className: 'dso-section-title' }, strings.diffTitle()),
38
+ createElement('pre', { className: 'dso-diff' }, diff !== '' ? diff : strings.emptyDiff()),
39
+ )
40
+ }
41
+
42
+ /** 严重级别 → 中文。 */
43
+ function severityText(severity) {
44
+ if (severity === 'error') return strings.severityError()
45
+ if (severity === 'warning') return strings.severityWarning()
46
+ return strings.severityInfo()
47
+ }
48
+
49
+ /** AI 结论文本(未启用/失败/成功三态,尽力而为)。 */
50
+ function aiTextOf(ai) {
51
+ if (ai === undefined || ai === null || !ai.enabled) return ''
52
+ if (ai.failed) return `${strings.aiFailed()}(${ai.note ?? ''})`
53
+ return ai.verdict === 'approve' ? strings.aiVerdictApprove() : strings.aiVerdictChanges()
54
+ }
55
+
56
+ /** 审查报告:问题列表 + AI 结论。 */
57
+ function ReviewReport({ report }) {
58
+ if (report === null) return null
59
+ const issues = report.issues || []
60
+ const rows = issues.map((issue, index) => createElement('div', {
61
+ key: index,
62
+ className: `dso-issue dso-issue-${issue.severity}`,
63
+ },
64
+ createElement('span', { className: 'dso-issue-sev' }, severityText(issue.severity)),
65
+ createElement('span', { className: 'dso-issue-rule' },
66
+ `${issue.rule}${issue.file !== '' ? ` ${issue.file}:${issue.line}` : ''}`,
67
+ ),
68
+ createElement('span', { className: 'dso-issue-msg' }, issue.message),
69
+ ))
70
+ const aiText = aiTextOf(report.ai)
71
+ return createElement('div', { className: 'dso-section' },
72
+ createElement('div', { className: 'dso-section-title' }, strings.reviewResult()),
73
+ issues.length === 0 ? createElement('div', { className: 'dso-review-ok' }, strings.reviewPass()) : null,
74
+ rows,
75
+ aiText !== '' ? createElement('div', { className: 'dso-ai' }, aiText) : null,
76
+ )
77
+ }
78
+
79
+ /** 提交表单字段(type/scope/description/body + 提交按钮)。 */
80
+ function CommitFields({ form, update, busy, submit }) {
81
+ return createElement('div', { className: 'dso-form' },
82
+ createElement('select', { className: 'dso-select dso-type', value: form.type, onChange: update('type') },
83
+ COMMIT_TYPES.map((type) => createElement('option', { key: type, value: type }, type)),
84
+ ),
85
+ createElement('input', {
86
+ className: 'dso-input',
87
+ placeholder: strings.commitScope(),
88
+ value: form.scope,
89
+ onChange: update('scope'),
90
+ }),
91
+ createElement('input', {
92
+ className: 'dso-input',
93
+ placeholder: strings.commitDesc(),
94
+ value: form.description,
95
+ onChange: update('description'),
96
+ }),
97
+ createElement('textarea', {
98
+ className: 'dso-input dso-textarea',
99
+ placeholder: strings.commitBody(),
100
+ value: form.body,
101
+ onChange: update('body'),
102
+ }),
103
+ createElement('div', { className: 'dso-actions' },
104
+ createElement('button', {
105
+ className: 'dso-btn dso-btn-primary',
106
+ disabled: busy,
107
+ onClick: submit,
108
+ }, strings.commit()),
109
+ ),
110
+ )
111
+ }
112
+
113
+ /** 类型化提交表单:type/scope/description/body → POST /git/commit。 */
114
+ function CommitForm({ repo, onCommitted }) {
115
+ const [form, setForm] = useState({ type: 'feat', scope: '', description: '', body: '' })
116
+ const [busy, setBusy] = useState(false)
117
+ const [feedback, setFeedback] = useState('')
118
+ const update = (key) => (e) => setForm({ ...form, [key]: e.target.value })
119
+ const submit = async () => {
120
+ if (form.description.trim() === '') {
121
+ setFeedback(strings.commitError())
122
+ return
123
+ }
124
+ setBusy(true)
125
+ try {
126
+ const value = await apiJson('/observability/api/git/commit', {
127
+ method: 'POST',
128
+ headers: { 'content-type': 'application/json' },
129
+ body: JSON.stringify({ repoPath: repo, ...form }),
130
+ })
131
+ setFeedback(`${strings.committed()}:${value.hash} ${value.message}`)
132
+ setForm({ ...form, scope: '', description: '', body: '' })
133
+ onCommitted()
134
+ } catch (err) {
135
+ setFeedback(`${strings.commitError()}:${err instanceof Error ? err.message : String(err)}`)
136
+ } finally {
137
+ setBusy(false)
138
+ }
139
+ }
140
+ return createElement('div', { className: 'dso-section' },
141
+ createElement('div', { className: 'dso-section-title' }, strings.commitTitle()),
142
+ createElement(CommitFields, { form, update, busy, submit }),
143
+ feedback !== '' ? createElement('div', { className: 'dso-feedback' }, feedback) : null,
144
+ )
145
+ }
146
+
147
+ /** 仓库路径行:输入 + 加载按钮。 */
148
+ function RepoRow({ repo, onRepoChange, onLoad }) {
149
+ return createElement('div', { className: 'dso-repo-row' },
150
+ createElement('input', {
151
+ className: 'dso-input dso-repo-input',
152
+ placeholder: strings.repoPlaceholder(),
153
+ value: repo,
154
+ onChange: (e) => onRepoChange(e.target.value),
155
+ }),
156
+ createElement('button', { className: 'dso-btn', onClick: onLoad }, strings.loadRepo()),
157
+ )
158
+ }
159
+
160
+ /** 操作按钮组:diff / staged diff / 审查。 */
161
+ function GitActions({ onDiff, onReview }) {
162
+ return createElement('div', { className: 'dso-actions' },
163
+ createElement('button', { className: 'dso-btn', onClick: () => onDiff(false) }, strings.showDiff()),
164
+ createElement('button', { className: 'dso-btn', onClick: () => onDiff(true) }, strings.showStagedDiff()),
165
+ createElement('button', { className: 'dso-btn dso-btn-primary', onClick: onReview }, strings.review()),
166
+ )
167
+ }
168
+
169
+ /** 拉取仓库状态(错误写入 setError)。 */
170
+ async function fetchStatus(path, setStatus, setError) {
171
+ if (path === '') return
172
+ try {
173
+ setStatus(await apiJson(`/observability/api/git/status?repo=${encodeURIComponent(path)}`))
174
+ setError('')
175
+ } catch (err) {
176
+ setError(err instanceof Error ? err.message : String(err))
177
+ }
178
+ }
179
+
180
+ /** 拉取差异文本(staged 切换;错误写入 setError)。 */
181
+ async function fetchDiff(path, staged, setDiff, setError) {
182
+ if (path === '') return
183
+ try {
184
+ const value = await apiJson(`/observability/api/git/diff?repo=${encodeURIComponent(path)}&staged=${staged ? 1 : 0}`)
185
+ setDiff(value.text)
186
+ setError('')
187
+ } catch (err) {
188
+ setError(err instanceof Error ? err.message : String(err))
189
+ }
190
+ }
191
+
192
+ /** 运行提交前审查(错误写入 setError)。 */
193
+ async function runReview(path, setReport, setError) {
194
+ if (path === '') return
195
+ try {
196
+ setReport(await apiJson('/observability/api/review', {
197
+ method: 'POST',
198
+ headers: { 'content-type': 'application/json' },
199
+ body: JSON.stringify({ repoPath: path }),
200
+ }))
201
+ setError('')
202
+ } catch (err) {
203
+ setError(err instanceof Error ? err.message : String(err))
204
+ }
205
+ }
206
+
207
+ /** Git 面板主组件:仓库状态 / diff / 审查 / 类型化提交。 */
208
+ function GitPanel() {
209
+ const [repo, setRepo] = useState(loadRepoKey)
210
+ const [status, setStatus] = useState(null)
211
+ const [diff, setDiff] = useState('')
212
+ const [report, setReport] = useState(null)
213
+ const [error, setError] = useState('')
214
+
215
+ const onCommitted = async () => {
216
+ setDiff('')
217
+ setReport(null)
218
+ await fetchStatus(repo, setStatus, setError)
219
+ }
220
+
221
+ return createElement('div', { className: 'dso-panel' },
222
+ createElement(RepoRow, {
223
+ repo,
224
+ onRepoChange: (value) => {
225
+ setRepo(value)
226
+ saveRepoKey(value)
227
+ },
228
+ onLoad: () => fetchStatus(repo, setStatus, setError),
229
+ }),
230
+ error !== '' ? createElement('div', { className: 'dso-empty' }, error) : null,
231
+ createElement(StatusBar, { status }),
232
+ createElement(GitActions, {
233
+ onDiff: (staged) => fetchDiff(repo, staged, setDiff, setError),
234
+ onReview: () => runReview(repo, setReport, setError),
235
+ }),
236
+ createElement(DiffView, { diff }),
237
+ createElement(ReviewReport, { report }),
238
+ createElement(CommitForm, { repo, onCommitted }),
239
+ )
240
+ }