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 +21 -0
- package/README.md +6 -2
- package/client.js +210 -0
- package/cordis.patch.yml +4 -0
- package/git.js +107 -0
- package/icon.svg +1 -0
- package/index.js +35 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -4
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
|
-
#
|
|
1
|
+
# Git Diff Sidebar
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
})
|
package/cordis.patch.yml
ADDED
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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-asuta-git-diff",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|