dsh-asuta-git-diff 0.0.0-stage → 0.1.3

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asuta and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
- # Temporary Holding Version
1
+ # Git Diff Sidebar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read-only Git changes panel in the right sidebar. The button appears at the right end of the conversation view tabs when multiple views are open, or in the conversation header for a single view. It opens four comparison ranges: uncommitted (HEAD to working tree, including untracked files), unstaged (index to working tree), staged (HEAD to index), and branch (merge base of a selected local/remote branch to HEAD). Branch comparison does not include working tree changes. Refresh updates the file list and line diff. No Git state is changed.
4
+
5
+ Install with `dsh plugin --profile desktop add <plugin-directory>`. The conversation must have a Git working directory.
6
+
7
+ The Changes toolbar provides an optional `data-git-diff-actions` mount keyed by session ID. Worktree Control uses it to place its environment commands immediately to the right of Changes; neither plugin requires the other to be enabled.
package/client.js ADDED
@@ -0,0 +1,210 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-asuta-git-diff',
3
+ factory(require) {
4
+ const React = require('react'), h = React.createElement
5
+ const TAB_ID = 'dsh-asuta-git-diff/panel', KIND = 'git-diff'
6
+ const SCOPES = [['uncommitted', '未提交'], ['unstaged', '未暂存'], ['staged', '已暂存'], ['branch', '分支']]
7
+ const CSS = [
8
+ '.gd-panel{display:flex;flex-direction:column;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font:13px/1.5 var(--dsw-font-family),system-ui,sans-serif}',
9
+ '.gd-header{padding:14px;border-bottom:1px solid var(--dsw-alias-border-l2);display:flex;gap:8px;align-items:center;flex-wrap:wrap}.gd-header strong{flex:1}.gd-scroll{overflow:auto;min-height:0;flex:1;padding:12px 14px}',
10
+ '.gd-panel button,.gd-panel select{color:inherit;font:inherit;cursor:pointer}.gd-panel button:focus-visible,.gd-panel select:focus-visible,.gd-trigger:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}',
11
+ '.gd-tabs{display:flex;gap:5px;flex-wrap:wrap}.gd-tabs button,.gd-refresh,.gd-file,.gd-trigger{border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;padding:5px 8px}.gd-tabs button[aria-pressed=true]{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-state-business-primary)}',
12
+ '.gd-select{max-width:100%;min-width:0;padding:6px;border-radius:7px;background:var(--dsw-specific-sidebar-fill,transparent);border:1px solid var(--dsw-alias-border-l2)}.gd-muted{color:var(--dsw-alias-label-secondary);font-size:12px;overflow-wrap:anywhere}.gd-error{color:#e88e82;overflow-wrap:anywhere}',
13
+ '.gd-file{width:100%;display:flex;gap:8px;align-items:center;text-align:left;margin-top:6px}.gd-file span:first-child{flex:1;min-width:0;overflow-wrap:anywhere}.gd-add{color:#75c788}.gd-del{color:#df8a80}',
14
+ '.gd-patch{white-space:pre;overflow:auto;font:12px/1.5 ui-monospace,Consolas,monospace;margin:0 0 10px;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-top:0;border-radius:0 0 7px 7px}.gd-line{display:block;min-height:18px}.gd-line[data-kind=add]{background:#2f7a3230}.gd-line[data-kind=remove]{background:#aa353530}',
15
+ '.gd-toolbar{display:inline-flex;align-items:center;gap:6px;flex:none;align-self:flex-start;margin:-6px 16px 0 auto;-webkit-app-region:no-drag}.gd-toolbar-fallback{align-self:auto;margin:0}header:has([data-conversation-tabs]) .gd-toolbar-fallback{display:none}.gd-toolbar-actions{display:inline-flex;align-items:center}.gd-toolbar-actions:empty{display:none}.gd-trigger{display:inline-flex;align-items:center;gap:5px;flex:none;margin:0;color:var(--dsw-alias-label-secondary);-webkit-app-region:no-drag}.gd-trigger:hover,.gd-file:hover,.gd-refresh:hover{background:var(--dsw-alias-interactive-bg-hover)}',
16
+ ].join('\n')
17
+ async function call(action, body, signal) {
18
+ const response = await fetch('/git-diff/api/' + action, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal })
19
+ const result = await response.json()
20
+ if (!response.ok || !result.ok) throw new Error(result.error || '读取 Git 差异失败。')
21
+ return result.value
22
+ }
23
+ function useSnapshot(store) { return React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) }
24
+ function Patch({ patch }) {
25
+ return h('pre', { className: 'gd-patch' }, patch.split('\n').map((line, index) => h('span', {
26
+ className: 'gd-line', key: index,
27
+ 'data-kind': line.startsWith('+') && !line.startsWith('+++') ? 'add' : line.startsWith('-') && !line.startsWith('---') ? 'remove' : undefined,
28
+ }, line)))
29
+ }
30
+ function Panel({ ctx }) {
31
+ const sessionId = useSnapshot(ctx.sidebarRight.mounted)
32
+ const [scope, setScope] = React.useState('uncommitted'), [ref, setRef] = React.useState('')
33
+ const [catalog, setCatalog] = React.useState(null), [result, setResult] = React.useState(null)
34
+ const [error, setError] = React.useState(''), [loading, setLoading] = React.useState(false)
35
+ const [revision, refresh] = React.useReducer(value => value + 1, 0)
36
+ const [expanded, setExpanded] = React.useState('')
37
+ const previousSession = React.useRef(null)
38
+ React.useEffect(() => {
39
+ if (previousSession.current !== sessionId) {
40
+ previousSession.current = sessionId
41
+ setRef(''); setScope('uncommitted')
42
+ }
43
+ setCatalog(null); setResult(null); setError(''); setExpanded('')
44
+ if (!sessionId) return
45
+ const controller = new AbortController()
46
+ call('catalog', { sessionId }, controller.signal).then(value => {
47
+ setCatalog(value)
48
+ setRef(previous => previous && value.branches.includes(previous) ? previous : value.upstream && value.branches.includes(value.upstream) ? value.upstream : value.branches[0] || '')
49
+ }).catch(cause => { if (!controller.signal.aborted) setError(cause.message) })
50
+ return () => controller.abort()
51
+ }, [sessionId, revision])
52
+ React.useEffect(() => {
53
+ setResult(null); setExpanded('')
54
+ if (!sessionId || !catalog || (scope === 'branch' && !ref)) return
55
+ const controller = new AbortController()
56
+ setLoading(true); setError('')
57
+ call('inspect', { sessionId, scope, ref }, controller.signal).then(value => {
58
+ setResult(value)
59
+ if (scope === 'uncommitted') window.dispatchEvent(new CustomEvent('git-diff:updated', { detail: { sessionId, value } }))
60
+ }).catch(cause => { if (!controller.signal.aborted) setError(cause.message) }).finally(() => { if (!controller.signal.aborted) setLoading(false) })
61
+ return () => controller.abort()
62
+ }, [sessionId, catalog, scope, ref, revision])
63
+ if (!sessionId) return h('div', { className: 'gd-panel gd-scroll' }, '请先打开有工作目录的对话。')
64
+ return h('div', { className: 'gd-panel', 'data-plugin': 'dsh-asuta-git-diff' },
65
+ h('div', { className: 'gd-header' }, h('strong', null, '变更'), h('button', { type: 'button', className: 'gd-refresh', onClick: () => refresh(), 'aria-label': '刷新差异' }, '刷新')),
66
+ h('div', { className: 'gd-scroll' },
67
+ h('div', { className: 'gd-muted' }, catalog?.root || '正在读取仓库…', catalog ? ' · ' + (catalog.branch || 'detached HEAD') : ''),
68
+ h('div', { className: 'gd-tabs', role: 'group', 'aria-label': '比较范围' }, SCOPES.map(([id, title]) => h('button', { key: id, type: 'button', 'aria-pressed': scope === id, onClick: () => setScope(id) }, title))),
69
+ scope === 'branch' && catalog ? h('label', null, h('div', { className: 'gd-muted' }, '与所选分支的共同祖先比较当前提交'), h('select', { className: 'gd-select', 'aria-label': '对比分支', value: ref, onChange: event => setRef(event.target.value) }, catalog.branches.map(branch => h('option', { key: branch, value: branch }, branch)))) : null,
70
+ error ? h('p', { role: 'alert', className: 'gd-error' }, error) : null,
71
+ loading ? h('p', { className: 'gd-muted' }, '正在读取差异…') : null,
72
+ result ? h(React.Fragment, null,
73
+ h('p', { className: 'gd-muted' }, result.files.length + ' 个文件 · ', h('span', { className: 'gd-add' }, '+' + result.additions), ' ', h('span', { className: 'gd-del' }, '-' + result.deletions)),
74
+ result.files.length === 0 ? h('p', null, '此范围没有改动。') : result.files.map(file => h('div', { key: file.path },
75
+ h('button', { type: 'button', className: 'gd-file', 'aria-expanded': expanded === file.path, onClick: () => setExpanded(expanded === file.path ? '' : file.path) },
76
+ h('span', null, file.path, file.untracked ? ' · 未跟踪' : ''), h('span', { className: 'gd-add' }, '+' + file.additions), h('span', { className: 'gd-del' }, '-' + file.deletions)),
77
+ expanded === file.path ? file.skipped ? h('p', { className: 'gd-muted' }, file.skipped) : h(Patch, { patch: file.patch || '无文本差异' }) : null,
78
+ )),
79
+ ) : null,
80
+ ),
81
+ )
82
+ }
83
+ function Trigger({ ctx }) {
84
+ const sessionId = useSnapshot(ctx.sidebarRight.mounted)
85
+ const [value, setValue] = React.useState(null)
86
+ React.useEffect(() => {
87
+ setValue(null)
88
+ if (!sessionId) return
89
+ const controller = new AbortController()
90
+ const update = event => { if (event.detail?.sessionId === sessionId) setValue(event.detail.value) }
91
+ window.addEventListener('git-diff:updated', update)
92
+ call('summary', { sessionId }, controller.signal).then(setValue).catch(() => {})
93
+ return () => { controller.abort(); window.removeEventListener('git-diff:updated', update) }
94
+ }, [sessionId])
95
+ if (!sessionId) return null
96
+ return h('div', { className: 'gd-toolbar gd-toolbar-fallback' },
97
+ h('button', { type: 'button', className: 'gd-trigger', title: '查看 Git 变更', 'aria-label': '查看 Git 变更', onClick: () => ctx.sidebarRight.openTabIn(sessionId, KIND) },
98
+ '变更', value ? h(React.Fragment, null,
99
+ h('span', { className: 'gd-add' }, '+' + value.additions),
100
+ h('span', { className: 'gd-del' }, '-' + value.deletions),
101
+ ) : null),
102
+ h('div', { className: 'gd-toolbar-actions', 'data-git-diff-actions': sessionId }))
103
+ }
104
+ function installTabTrigger(ctx) {
105
+ const mounts = new Map()
106
+ let scheduled = false, disposed = false
107
+ const selector = '[data-conversation-tabs]'
108
+ let sessionId = ctx.sidebarRight.mounted.getSnapshot(), summary = null
109
+ const renderSummary = button => {
110
+ button.replaceChildren('变更')
111
+ if (!summary) return
112
+ for (const [className, value] of [['gd-add', summary.additions], ['gd-del', summary.deletions]]) {
113
+ const count = document.createElement('span')
114
+ count.className = className
115
+ count.textContent = (className === 'gd-add' ? '+' : '-') + value
116
+ button.appendChild(count)
117
+ }
118
+ }
119
+ const updateSummary = value => {
120
+ summary = value
121
+ for (const button of mounts.values()) renderSummary(button)
122
+ }
123
+ const refreshSummary = () => {
124
+ sessionId = ctx.sidebarRight.mounted.getSnapshot()
125
+ for (const button of mounts.values()) button.nextElementSibling.dataset.gitDiffActions = sessionId || ''
126
+ updateSummary(null)
127
+ const target = sessionId
128
+ if (target) call('summary', { sessionId: target }).then(value => {
129
+ if (!disposed && sessionId === target) updateSummary(value)
130
+ }).catch(() => {})
131
+ }
132
+ const onUpdated = event => {
133
+ if (event.detail?.sessionId === sessionId) updateSummary(event.detail.value)
134
+ }
135
+ const scan = () => {
136
+ scheduled = false
137
+ if (disposed) return
138
+ const rows = new Set([...document.querySelectorAll(selector)]
139
+ .filter(tabs => !tabs.closest('[data-sidebar-right-session]'))
140
+ .map(tabs => tabs.parentElement).filter(Boolean))
141
+ for (const [row, button] of mounts) {
142
+ if (rows.has(row) && button.parentElement?.parentElement === row) continue
143
+ button.parentElement?.remove()
144
+ mounts.delete(row)
145
+ }
146
+ for (const row of rows) {
147
+ if (mounts.has(row)) continue
148
+ const button = document.createElement('button')
149
+ button.type = 'button'
150
+ button.className = 'gd-trigger'
151
+ button.dataset.gitDiffTrigger = ''
152
+ renderSummary(button)
153
+ button.title = '查看 Git 变更'
154
+ button.setAttribute('aria-label', '查看 Git 变更')
155
+ button.addEventListener('click', () => {
156
+ const sessionId = ctx.sidebarRight.mounted.getSnapshot()
157
+ if (sessionId) ctx.sidebarRight.openTabIn(sessionId, KIND)
158
+ })
159
+ const toolbar = document.createElement('div')
160
+ toolbar.className = 'gd-toolbar'
161
+ const actions = document.createElement('div')
162
+ actions.className = 'gd-toolbar-actions'
163
+ actions.dataset.gitDiffActions = sessionId || ''
164
+ toolbar.append(button, actions)
165
+ row.appendChild(toolbar)
166
+ mounts.set(row, button)
167
+ }
168
+ }
169
+ const schedule = () => {
170
+ if (disposed || scheduled) return
171
+ scheduled = true
172
+ queueMicrotask(scan)
173
+ }
174
+ const containsTabs = node => node.nodeType === 1 &&
175
+ (node.matches(selector) || node.querySelector(selector) !== null)
176
+ const observer = new MutationObserver(records => {
177
+ for (const record of records) {
178
+ if (record.target.nodeType === 1 && record.target.querySelector(':scope > ' + selector)) { schedule(); return }
179
+ if ([...record.addedNodes, ...record.removedNodes].some(containsTabs)) { schedule(); return }
180
+ }
181
+ })
182
+ observer.observe(document.body, { childList: true, subtree: true })
183
+ const unsubscribe = ctx.sidebarRight.mounted.subscribe(refreshSummary)
184
+ window.addEventListener('git-diff:updated', onUpdated)
185
+ refreshSummary()
186
+ scan()
187
+ return () => {
188
+ disposed = true
189
+ observer.disconnect()
190
+ unsubscribe()
191
+ window.removeEventListener('git-diff:updated', onUpdated)
192
+ for (const button of mounts.values()) button.parentElement?.remove()
193
+ mounts.clear()
194
+ }
195
+ }
196
+ return {
197
+ inject: ['slots', 'sidebarRight', 'sidebarRightTabs'],
198
+ apply(ctx) {
199
+ ctx.effect(() => { const style = document.createElement('style'); style.dataset.plugin = 'dsh-asuta-git-diff'; style.textContent = CSS; document.head.appendChild(style); return () => style.remove() }, 'git-diff: styles')
200
+ ctx.effect(() => ctx.sidebarRightTabs.register({
201
+ id: TAB_ID, kind: KIND, patterns: ['sidebar://git-diff', 'sidebar://git-diff/**'], title: () => '变更',
202
+ guide: [{ id: 'new', order: 42, title: () => '变更', description: () => '查看当前工作目录的 Git 差异' }],
203
+ }), 'git-diff: tab type')
204
+ ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: TAB_ID }, props => h(Panel, { ...props, ctx }))), 'git-diff: panel')
205
+ ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'git-diff.trigger', order: 24 }, props => h(Trigger, { ...props, ctx }))), 'git-diff: single-view trigger')
206
+ ctx.effect(() => installTabTrigger(ctx), 'git-diff: tab-row trigger')
207
+ },
208
+ }
209
+ },
210
+ })
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: local-git-diff
3
+ name: "dsh-asuta-git-diff"
4
+ config: {}
package/git.js ADDED
@@ -0,0 +1,107 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { readFile, lstat } from 'node:fs/promises'
4
+ import { resolve } from 'node:path'
5
+
6
+ const execute = promisify(execFile)
7
+ const LIMIT = 8 * 1024 * 1024
8
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
9
+ const splitPaths = value => value.split('\0').filter(Boolean)
10
+
11
+ export async function git(cwd, args) {
12
+ const { stdout } = await execute('git', ['--no-pager', '-c', 'core.quotepath=false', '-c', 'core.fsmonitor=false', ...args], {
13
+ cwd, windowsHide: true, encoding: 'utf8', timeout: 20000, maxBuffer: LIMIT,
14
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' },
15
+ })
16
+ return stdout
17
+ }
18
+
19
+ export async function catalog(cwd) {
20
+ const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim()
21
+ const head = (await git(root, ['rev-parse', '--verify', 'HEAD']).catch(() => '')).trim()
22
+ const branch = (await git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => '')).trim()
23
+ const branches = (await git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'])).trim().split('\n').filter(Boolean).filter(value => !value.endsWith('/HEAD'))
24
+ const upstream = (await git(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']).catch(() => '')).trim()
25
+ return { root, head, branch, branches, upstream }
26
+ }
27
+
28
+ function syntheticPatch(path, data) {
29
+ if (data.includes(0)) return { patch: '', skipped: '二进制文件' }
30
+ const text = data.toString('utf8')
31
+ const lines = text.split('\n')
32
+ if (lines.at(-1) === '') lines.pop()
33
+ const header = ['diff --git a/' + path + ' b/' + path, 'new file mode 100644', '--- /dev/null', '+++ b/' + path]
34
+ const patch = [...header, ...(lines.length ? ['@@ -0,0 +1,' + lines.length + ' @@', ...lines.map(line => '+' + line), ...(text.endsWith('\n') ? [] : ['\'])] : [])].join('\n') + '\n'
35
+ return { patch }
36
+ }
37
+
38
+ function counts(patch) {
39
+ let additions = 0, deletions = 0
40
+ for (const line of patch.split('\n')) {
41
+ if (line.startsWith('+') && !line.startsWith('+++')) additions++
42
+ if (line.startsWith('-') && !line.startsWith('---')) deletions++
43
+ }
44
+ return { additions, deletions }
45
+ }
46
+
47
+ export async function summary(cwd) {
48
+ const repo = await catalog(cwd)
49
+ const base = repo.head || EMPTY_TREE
50
+ const numbers = splitPaths(await git(repo.root, ['diff', '--no-ext-diff', '--no-textconv', '--no-renames', '--numstat', '-z', base, '--']))
51
+ const untracked = splitPaths(await git(repo.root, ['ls-files', '--others', '--exclude-standard', '-z']))
52
+ let additions = 0, deletions = 0
53
+ for (const entry of numbers) {
54
+ const match = /^(\d+|-)\t(\d+|-)\t/.exec(entry)
55
+ if (match) { additions += Number(match[1]) || 0; deletions += Number(match[2]) || 0 }
56
+ }
57
+ for (const path of untracked) {
58
+ const file = resolve(repo.root, path)
59
+ const info = await lstat(file).catch(error => { if (error.code === 'ENOENT') return null; throw error })
60
+ if (!info?.isFile() || info.isSymbolicLink() || info.size > LIMIT) continue
61
+ const data = await readFile(file)
62
+ if (!data.includes(0) && data.length) {
63
+ let lines = 1
64
+ for (const byte of data) if (byte === 10) lines++
65
+ additions += lines - (data.at(-1) === 10 ? 1 : 0)
66
+ }
67
+ }
68
+ return { files: numbers.length + untracked.length, additions, deletions }
69
+ }
70
+
71
+ export async function inspect(cwd, scope = 'uncommitted', ref = '') {
72
+ if (!['uncommitted', 'unstaged', 'staged', 'branch'].includes(scope)) throw new Error('未知的比较范围。')
73
+ const repo = await catalog(cwd)
74
+ const base = repo.head || EMPTY_TREE
75
+ let args
76
+ if (scope === 'branch') {
77
+ if (!repo.head) throw new Error('当前仓库尚无提交,不能比较分支。')
78
+ if (!repo.branches.includes(ref)) throw new Error('请选择列表中的分支。')
79
+ const commit = (await git(repo.root, ['rev-parse', '--verify', '--end-of-options', ref + '^{commit}'])).trim()
80
+ const ancestor = (await git(repo.root, ['merge-base', repo.head, commit])).trim()
81
+ args = [ancestor, repo.head]
82
+ } else if (scope === 'staged') args = ['--cached', base]
83
+ else if (scope === 'uncommitted') args = [base]
84
+ else args = []
85
+
86
+ const flags = ['--no-ext-diff', '--no-textconv', '--no-renames']
87
+ const tracked = splitPaths(await git(repo.root, ['diff', ...flags, '--name-only', '-z', ...args, '--']))
88
+ const untracked = scope === 'uncommitted' ? splitPaths(await git(repo.root, ['ls-files', '--others', '--exclude-standard', '-z'])) : []
89
+ const files = []
90
+ let totalBytes = 0, additions = 0, deletions = 0
91
+ for (const path of [...new Set([...tracked, ...untracked])]) {
92
+ let item
93
+ if (untracked.includes(path)) {
94
+ const info = await lstat(resolve(repo.root, path)).catch(error => { if (error.code === 'ENOENT') return null; throw error })
95
+ if (!info) continue
96
+ if (!info.isFile() || info.isSymbolicLink()) item = { patch: '', skipped: '非普通文件' }
97
+ else if (info.size > LIMIT) item = { patch: '', skipped: '文件超过 8 MiB' }
98
+ else item = syntheticPatch(path, await readFile(resolve(repo.root, path)))
99
+ } else item = { patch: await git(repo.root, ['diff', ...flags, '--unified=3', ...args, '--', path]) }
100
+ totalBytes += Buffer.byteLength(item.patch) + Buffer.byteLength(path)
101
+ if (totalBytes > LIMIT) throw new Error('差异超过 8 MiB,请缩小比较范围。')
102
+ const count = counts(item.patch)
103
+ additions += count.additions; deletions += count.deletions
104
+ files.push({ path, ...item, ...count, untracked: untracked.includes(path) })
105
+ }
106
+ return { root: repo.root, branch: repo.branch, head: repo.head, scope, ref: scope === 'branch' ? ref : '', files, additions, deletions }
107
+ }
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="8" y="8" width="20" height="20" rx="5" fill="#6e9fff"/><rect x="36" y="8" width="20" height="20" rx="5" fill="#8bc9b2"/><rect x="8" y="36" width="20" height="20" rx="5" fill="#b4a0e8"/><rect x="36" y="36" width="20" height="20" rx="5" fill="#e9ba87"/></svg>
package/index.js ADDED
@@ -0,0 +1,35 @@
1
+ import { catalog, inspect, summary } from './git.js'
2
+
3
+ export const name = 'local-git-diff'
4
+ export const inject = ['webServer', 'connection', 'agents', 'workingDirectory']
5
+
6
+ export function apply(ctx) {
7
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/git-diff/api', handler: async (req, res) => {
8
+ const send = (status, value) => {
9
+ res.statusCode = status
10
+ res.setHeader('content-type', 'application/json; charset=utf-8')
11
+ res.setHeader('cache-control', 'no-store')
12
+ res.end(JSON.stringify(value))
13
+ }
14
+ if (ctx.connection.requestRejection(req) !== undefined) return send(403, { ok: false, error: 'forbidden' })
15
+ if (req.method !== 'POST') return send(405, { ok: false, error: 'POST required' })
16
+ const action = new URL(req.url, 'http://dsh.local').pathname.slice('/git-diff/api/'.length)
17
+ if (!['catalog', 'inspect', 'summary'].includes(action)) return send(404, { ok: false, error: 'unknown action' })
18
+ try {
19
+ const chunks = []; let size = 0
20
+ for await (const chunk of req) {
21
+ size += chunk.length
22
+ if (size > 4096) throw new Error('请求过大。')
23
+ chunks.push(chunk)
24
+ }
25
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
26
+ if (typeof body?.sessionId !== 'string' || !/^[\w-]{1,100}$/.test(body.sessionId)) throw new Error('无效的会话 ID。')
27
+ const agent = ctx.agents.get(body.sessionId)
28
+ if (!agent) throw new Error('请先打开主对话,等待会话连接。')
29
+ const cwd = ctx.workingDirectory.get(agent.session) ?? agent.session.header.cwd
30
+ if (!cwd) throw new Error('当前对话没有工作目录。')
31
+ const value = action === 'catalog' ? await catalog(cwd) : action === 'summary' ? await summary(cwd) : await inspect(cwd, body.scope, body.ref)
32
+ send(200, { ok: true, value })
33
+ } catch (error) { send(400, { ok: false, error: String(error.message ?? error) }) }
34
+ } }), 'git-diff: api')
35
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git changes",
4
+ "description": "Inspect staged, unstaged, uncommitted and branch diffs beside a DSH conversation."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git 变更",
4
+ "description": "在 DSH 对话旁查看暂存、未暂存、未提交改动及分支差异。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "dsh-asuta-git-diff",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.3",
4
+ "type": "module",
5
+ "description": "Inspect staged, unstaged, uncommitted and branch diffs beside a DSH conversation. · 在 DSH 对话旁查看暂存、未暂存、未提交改动及分支差异。",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./package.json": "./package.json",
11
+ "./cordis.patch.yml": "./cordis.patch.yml",
12
+ "./locale/*.json": "./locale/*.json"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "git.js",
17
+ "client.js",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "icon.svg",
21
+ "locale/*.json",
22
+ "LICENSE*",
23
+ "NOTICE*",
24
+ "THIRD_PARTY_NOTICES.md"
25
+ ],
26
+ "engines": {
27
+ "node": "^22.19.0 || >=24",
28
+ "dsh": "0.2.2-rc.2"
29
+ },
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ },
34
+ "client": {
35
+ "platform": "web",
36
+ "immediately": true,
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-ui-slots",
39
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
40
+ ]
41
+ }
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/Asuta/dsh-cdx-plus.git",
49
+ "directory": "packages/git-diff"
50
+ },
51
+ "homepage": "https://github.com/Asuta/dsh-cdx-plus/tree/main/packages/git-diff",
52
+ "bugs": {
53
+ "url": "https://github.com/Asuta/dsh-cdx-plus/issues"
54
+ },
55
+ "keywords": [
56
+ "dsh",
57
+ "dsh-plugin",
58
+ "deepseek-harness",
59
+ "cdx-plus",
60
+ "git",
61
+ "git-diff",
62
+ "diff-viewer"
63
+ ],
64
+ "license": "MIT",
65
+ "icon": "./icon.svg"
66
+ }