dsh-git-changes 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.en.md +105 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/vendor/monaco/LICENSE +21 -0
- package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
- package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
- package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
- package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
- package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
- package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
- package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
- package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
- package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
- package/assets/vendor/monaco/vs/loader.js +10 -0
- package/cordis.patch.yml +3 -0
- package/docs/images/git-changes-diff.png +0 -0
- package/icon.svg +10 -0
- package/lib/client.js +9418 -0
- package/lib/host/assets.js +161 -0
- package/lib/host/commit-message.js +158 -0
- package/lib/host/files-index.js +206 -0
- package/lib/host/git-write.js +194 -0
- package/lib/host/git.js +1147 -0
- package/lib/host/lsp.js +842 -0
- package/lib/host/routes.js +976 -0
- package/lib/host/session.js +84 -0
- package/lib/host/util.js +107 -0
- package/lib/index.js +33 -0
- package/licenses/dsh-vscode-mode-LICENSE +21 -0
- package/licenses/vscode-theme-seti-LICENSE +21 -0
- package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -3
- package/src/client/00-const.js +79 -0
- package/src/client/05-utils.js +277 -0
- package/src/client/10-i18n.js +548 -0
- package/src/client/12-vscode-icons.js +460 -0
- package/src/client/15-theme.js +509 -0
- package/src/client/20-monaco.js +1404 -0
- package/src/client/22-lsp.js +592 -0
- package/src/client/25-tree.js +563 -0
- package/src/client/28-changes.js +1163 -0
- package/src/client/30-page.js +2259 -0
- package/src/client/35-pill.js +185 -0
- package/src/client/40-commands.js +248 -0
- package/src/client/45-css.js +357 -0
- package/src/client/47-pickers.js +457 -0
- package/src/client/50-apply.js +207 -0
- package/src/client/99-footer.js +62 -0
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
/* dsh-git-changes client half — Git CHANGES panel (staged / unstaged / commit / push). */
|
|
2
|
+
|
|
3
|
+
/** Status codes rendered as a single uppercase letter in the row tail. */
|
|
4
|
+
const CHANGE_CODES = new Set(['M', 'A', 'D', 'R', 'C', 'U', '?', '!'])
|
|
5
|
+
/** Maximum number of file rows rendered per section (defensive cap). */
|
|
6
|
+
const CHANGE_ROW_LIMIT = 2000
|
|
7
|
+
/** Left inset shared by every row under a section header. */
|
|
8
|
+
const SECTION_ROW_INDENT = 8
|
|
9
|
+
/** Inset that lines a file icon up with the folder icon column. */
|
|
10
|
+
const FILE_ICON_OFFSET = 8
|
|
11
|
+
/** Extra inset for file rows so names line up with the folder icon column. */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split a porcelain status payload into the two Cursor-style groups.
|
|
15
|
+
*
|
|
16
|
+
* A file can appear in both groups (for example `MM`), matching VS Code.
|
|
17
|
+
* @param {object | null} status `gitStatus` route payload
|
|
18
|
+
* @returns {{branch: object | null, staged: Array<object>, unstaged: Array<object>}}
|
|
19
|
+
*/
|
|
20
|
+
function groupChanges(status) {
|
|
21
|
+
const files = status && Array.isArray(status.files) ? status.files : []
|
|
22
|
+
const staged = []
|
|
23
|
+
const unstaged = []
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
if (!file || typeof file.path !== 'string') continue
|
|
26
|
+
const index = typeof file.indexStatus === 'string' ? file.indexStatus : ' '
|
|
27
|
+
const work = typeof file.workTreeStatus === 'string' ? file.workTreeStatus : ' '
|
|
28
|
+
if (index !== ' ' && index !== '?' && index !== '!') staged.push(file)
|
|
29
|
+
if (work !== ' ' || index === '?') unstaged.push(file)
|
|
30
|
+
}
|
|
31
|
+
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
32
|
+
staged.sort(byPath)
|
|
33
|
+
unstaged.sort(byPath)
|
|
34
|
+
return { branch: status && status.branch ? status.branch : null, staged, unstaged }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Human label for the branch bar: name, detached marker, ahead/behind counts.
|
|
39
|
+
* @param {object | null} branch status payload branch
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function branchLabel(branch) {
|
|
43
|
+
if (!branch || typeof branch !== 'object') return ''
|
|
44
|
+
if (branch.detached === true) return 'HEAD (detached)'
|
|
45
|
+
const name = typeof branch.name === 'string' && branch.name.length > 0 ? branch.name : 'HEAD'
|
|
46
|
+
const counts = []
|
|
47
|
+
if (branch.ahead > 0) counts.push('↑' + branch.ahead)
|
|
48
|
+
if (branch.behind > 0) counts.push('↓' + branch.behind)
|
|
49
|
+
return counts.length > 0 ? name + ' ' + counts.join(' ') : name
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Split a workspace path into its display name and its parent directory.
|
|
54
|
+
* @param {string} path workspace-relative path
|
|
55
|
+
* @returns {{name: string, dir: string}}
|
|
56
|
+
*/
|
|
57
|
+
function changePathParts(path) {
|
|
58
|
+
const text = String(path || '')
|
|
59
|
+
const cut = text.lastIndexOf('/')
|
|
60
|
+
if (cut < 0) return { name: text, dir: '' }
|
|
61
|
+
return { name: text.slice(cut + 1), dir: text.slice(0, cut) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a directory tree from a flat list of change rows.
|
|
66
|
+
*
|
|
67
|
+
* Directory nodes keep their expanded state in the caller's `Set`; only file
|
|
68
|
+
* rows are actionable (directories toggle). The tree is intentionally local to
|
|
69
|
+
* this module because `FileTree` is driven by lazy Host directory reads.
|
|
70
|
+
* @param {Array<object>} files change rows
|
|
71
|
+
* @returns {object} tree root node
|
|
72
|
+
*/
|
|
73
|
+
function buildChangeTree(files) {
|
|
74
|
+
const root = { type: 'dir', name: '', path: '', children: [] }
|
|
75
|
+
const dirs = new Map([['', root]])
|
|
76
|
+
/** Find or create one directory node. */
|
|
77
|
+
const ensureDir = (dirPath) => {
|
|
78
|
+
if (dirs.has(dirPath)) return dirs.get(dirPath)
|
|
79
|
+
const cut = dirPath.lastIndexOf('/')
|
|
80
|
+
const parentPath = cut < 0 ? '' : dirPath.slice(0, cut)
|
|
81
|
+
const parent = ensureDir(parentPath)
|
|
82
|
+
const node = { type: 'dir', name: dirPath.slice(cut + 1), path: dirPath, children: [] }
|
|
83
|
+
parent.children.push(node)
|
|
84
|
+
dirs.set(dirPath, node)
|
|
85
|
+
return node
|
|
86
|
+
}
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
const { dir } = changePathParts(file.path)
|
|
89
|
+
ensureDir(dir).children.push({ type: 'file', name: changePathParts(file.path).name, path: file.path, file })
|
|
90
|
+
}
|
|
91
|
+
/** Sort directories first, then by name. */
|
|
92
|
+
const sortNode = (node) => {
|
|
93
|
+
if (node.children.length > 1) {
|
|
94
|
+
node.children.sort((a, b) => {
|
|
95
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
|
96
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
for (const child of node.children) if (child.type === 'dir') sortNode(child)
|
|
100
|
+
}
|
|
101
|
+
sortNode(root)
|
|
102
|
+
// Collapse single-child directory chains (packages/ui/theme in one row) the
|
|
103
|
+
// way VS Code and Cursor render the SCM tree.
|
|
104
|
+
const collapseDirs = (node) => {
|
|
105
|
+
for (const child of node.children) {
|
|
106
|
+
if (child.type !== 'dir') continue
|
|
107
|
+
while (child.children.length === 1 && child.children[0].type === 'dir') {
|
|
108
|
+
const only = child.children[0]
|
|
109
|
+
child.name = child.name + '/' + only.name
|
|
110
|
+
child.path = only.path
|
|
111
|
+
child.children = only.children
|
|
112
|
+
}
|
|
113
|
+
collapseDirs(child)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
collapseDirs(root)
|
|
117
|
+
// Aggregate status for folder rows: the dot colour mirrors the most
|
|
118
|
+
// important change below the folder.
|
|
119
|
+
const STATUS_PRIORITY = ['!', 'D', 'A', 'U', 'R', 'C', 'M', '?']
|
|
120
|
+
const worstStatus = (node) => {
|
|
121
|
+
let best = ''
|
|
122
|
+
let bestRank = STATUS_PRIORITY.length + 1
|
|
123
|
+
const walk = (entry) => {
|
|
124
|
+
for (const child of entry.children) {
|
|
125
|
+
if (child.type === 'dir') {
|
|
126
|
+
walk(child)
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
const code = String((child.file && child.file.code) || '').trim()
|
|
130
|
+
if (!code) continue
|
|
131
|
+
const rank = STATUS_PRIORITY.indexOf(code)
|
|
132
|
+
if (rank === -1) continue
|
|
133
|
+
if (rank < bestRank) { best = code; bestRank = rank }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
walk(node)
|
|
137
|
+
return best
|
|
138
|
+
}
|
|
139
|
+
const attachStatus = (node) => {
|
|
140
|
+
for (const child of node.children) {
|
|
141
|
+
if (child.type !== 'dir') continue
|
|
142
|
+
child.status = worstStatus(child)
|
|
143
|
+
attachStatus(child)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
attachStatus(root)
|
|
147
|
+
return root
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Map a failed write payload onto a translated, user-facing message.
|
|
152
|
+
* @param {object | null} body response payload
|
|
153
|
+
* @param {(key: string) => string} t locale translate
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function describeGitError(body, t) {
|
|
157
|
+
const code = body && (body.code || (body.error && body.error.code))
|
|
158
|
+
const key = code ? 'changes.error.' + code : 'changes.error.generic'
|
|
159
|
+
const translated = typeof t === 'function' ? t(key) : key
|
|
160
|
+
if (translated === key) return typeof t === 'function' ? t('changes.error.generic') : 'Operation failed.'
|
|
161
|
+
return translated
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* CSS-safe modifier for one porcelain status code ('?'/! would break selectors).
|
|
166
|
+
* @param {string} code status letter
|
|
167
|
+
* @returns {string} class suffix
|
|
168
|
+
*/
|
|
169
|
+
function changeCodeClass(code) {
|
|
170
|
+
const value = String(code || ' ').trim()
|
|
171
|
+
if (value === '?') return 'u'
|
|
172
|
+
if (value === '!') return 'conflict'
|
|
173
|
+
return value === '' ? 'none' : value.toLowerCase()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Small document glyph used by the "open file" row action.
|
|
178
|
+
* @returns {any} React element
|
|
179
|
+
*/
|
|
180
|
+
function FileGlyph() {
|
|
181
|
+
return h(
|
|
182
|
+
'svg',
|
|
183
|
+
{
|
|
184
|
+
width: 12,
|
|
185
|
+
height: 12,
|
|
186
|
+
viewBox: '0 0 16 16',
|
|
187
|
+
'aria-hidden': 'true',
|
|
188
|
+
focusable: 'false',
|
|
189
|
+
fill: 'none',
|
|
190
|
+
stroke: 'currentColor',
|
|
191
|
+
strokeWidth: 1.3,
|
|
192
|
+
strokeLinejoin: 'round',
|
|
193
|
+
strokeLinecap: 'round',
|
|
194
|
+
},
|
|
195
|
+
h('path', { d: 'M4 1.5h5.2l2.8 2.8V14.5H4z' }),
|
|
196
|
+
h('path', { d: 'M9.2 1.5v2.8H12' }),
|
|
197
|
+
h('path', { d: 'M6 8h4' }),
|
|
198
|
+
h('path', { d: 'M6 11h4' }),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One clickable change row with hover stage/unstage action and a status letter.
|
|
204
|
+
* @param {object} props row props
|
|
205
|
+
* @returns {any} React element
|
|
206
|
+
*/
|
|
207
|
+
function ChangeRow(props) {
|
|
208
|
+
const { file, scope, depth, activePath, onOpen, onStage, onUnstage, onContextMenu, onOpenFile, onRequestDiscard, t, scheme } = props
|
|
209
|
+
const parts = changePathParts(file.path)
|
|
210
|
+
const isActive = activePath === file.path
|
|
211
|
+
const action = scope === 'staged'
|
|
212
|
+
? { label: '\u2212', title: t('changes.unstage'), onClick: () => onUnstage([file.path]) }
|
|
213
|
+
: { label: '+', title: t('changes.stage'), onClick: () => onStage([file.path]) }
|
|
214
|
+
const untracked = file.code === 'U' || file.indexStatus === '?'
|
|
215
|
+
const openFileButton = typeof onOpenFile === 'function'
|
|
216
|
+
? h(
|
|
217
|
+
'button',
|
|
218
|
+
{
|
|
219
|
+
type: 'button',
|
|
220
|
+
className: 'drp-icon-btn drp-change-action drp-file-action',
|
|
221
|
+
title: t('changes.openFile'),
|
|
222
|
+
'aria-label': t('changes.openFile'),
|
|
223
|
+
onClick: (event) => {
|
|
224
|
+
event.stopPropagation()
|
|
225
|
+
onOpenFile(file.path)
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
h(FileGlyph),
|
|
229
|
+
)
|
|
230
|
+
: null
|
|
231
|
+
// 暂存区里的改动已经有「取消暂存」(−);撤销/放弃只对工作区未暂存的改动开放。
|
|
232
|
+
const discardButton = scope !== 'staged' && typeof onRequestDiscard === 'function'
|
|
233
|
+
? h(
|
|
234
|
+
'button',
|
|
235
|
+
{
|
|
236
|
+
type: 'button',
|
|
237
|
+
className: 'drp-icon-btn drp-change-action drp-discard-action',
|
|
238
|
+
title: scope === 'staged' ? t('changes.discardStaged') : t('changes.discard'),
|
|
239
|
+
'aria-label': scope === 'staged' ? t('changes.discardStaged') : t('changes.discard'),
|
|
240
|
+
onClick: (event) => {
|
|
241
|
+
event.stopPropagation()
|
|
242
|
+
onRequestDiscard([file.path], scope, untracked ? [file.path] : [])
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
'\u21ba',
|
|
246
|
+
)
|
|
247
|
+
: null
|
|
248
|
+
return h(
|
|
249
|
+
'div',
|
|
250
|
+
{
|
|
251
|
+
className: cx('drp-tree-row', 'drp-change-row', isActive && 'drp-tree-active'),
|
|
252
|
+
style: { paddingLeft: (SECTION_ROW_INDENT + FILE_ICON_OFFSET + treeIndentOf((depth || 0) + 1)) + 'px' },
|
|
253
|
+
role: 'treeitem',
|
|
254
|
+
tabIndex: 0,
|
|
255
|
+
title: file.path + (file.code ? '\nGit: ' + file.code : ''),
|
|
256
|
+
onClick: () => onOpen(file.path, scope),
|
|
257
|
+
onContextMenu: (event) => {
|
|
258
|
+
if (typeof onContextMenu === 'function') onContextMenu(event, file.path, scope)
|
|
259
|
+
},
|
|
260
|
+
onKeyDown: (event) => {
|
|
261
|
+
if (event.key === 'Enter') onOpen(file.path, scope)
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
h('span', { className: 'drp-change-file-icon' }, h(FileIcon, { path: file.path, scheme })),
|
|
265
|
+
h('span', { className: 'drp-change-name' }, parts.name),
|
|
266
|
+
parts.dir
|
|
267
|
+
? h('span', { className: 'drp-change-dir' }, parts.dir)
|
|
268
|
+
: null,
|
|
269
|
+
openFileButton,
|
|
270
|
+
discardButton,
|
|
271
|
+
h(
|
|
272
|
+
'button',
|
|
273
|
+
{
|
|
274
|
+
type: 'button',
|
|
275
|
+
className: 'drp-icon-btn drp-change-action',
|
|
276
|
+
title: action.title,
|
|
277
|
+
'aria-label': action.title,
|
|
278
|
+
onClick: (event) => {
|
|
279
|
+
event.stopPropagation()
|
|
280
|
+
action.onClick()
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
action.label,
|
|
284
|
+
),
|
|
285
|
+
h('span', { className: cx('drp-change-code', 'drp-code-' + changeCodeClass(file.code)) }, file.code || ''),
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Flatten one change group into a sorted list (the "list" view).
|
|
291
|
+
* @param {Array<object>} files status records
|
|
292
|
+
* @returns {Array<object>} sorted records
|
|
293
|
+
*/
|
|
294
|
+
function buildChangeList(files) {
|
|
295
|
+
return (Array.isArray(files) ? files : [])
|
|
296
|
+
.filter((file) => file && typeof file.path === 'string')
|
|
297
|
+
.slice()
|
|
298
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Collect the file paths below one tree node so folder rows can stage or
|
|
303
|
+
* unstage everything inside them with the existing write API.
|
|
304
|
+
* @param {object} node tree node
|
|
305
|
+
* @returns {Array<string>} descendant file paths
|
|
306
|
+
*/
|
|
307
|
+
function collectDirFiles(node) {
|
|
308
|
+
const paths = []
|
|
309
|
+
const walk = (entry) => {
|
|
310
|
+
if (!entry || !Array.isArray(entry.children)) return
|
|
311
|
+
for (const child of entry.children) {
|
|
312
|
+
if (child.type === 'dir') walk(child)
|
|
313
|
+
else if (child.file && typeof child.file.path === 'string') paths.push(child.file.path)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
walk(node)
|
|
317
|
+
return paths
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Render one tree level of change rows.
|
|
322
|
+
* @param {object} props level props
|
|
323
|
+
* @returns {Array<any>} rows
|
|
324
|
+
*/
|
|
325
|
+
function ChangeTreeLevel(props) {
|
|
326
|
+
const { node, depth, collapsed, onToggle, rowProps } = props
|
|
327
|
+
const { t } = rowProps
|
|
328
|
+
const rows = []
|
|
329
|
+
let files = 0
|
|
330
|
+
for (const child of node.children) {
|
|
331
|
+
if (files >= CHANGE_ROW_LIMIT) break
|
|
332
|
+
if (child.type === 'dir') {
|
|
333
|
+
const isOpen = !collapsed.has(child.path)
|
|
334
|
+
const dirPaths = collectDirFiles(child)
|
|
335
|
+
const dirAction = rowProps.scope === 'staged'
|
|
336
|
+
? { label: '\u2212', title: t('changes.unstageFolder'), click: () => rowProps.onUnstage(dirPaths) }
|
|
337
|
+
: { label: '+', title: t('changes.stageFolder'), click: () => rowProps.onStage(dirPaths) }
|
|
338
|
+
rows.push(h(
|
|
339
|
+
'div',
|
|
340
|
+
{
|
|
341
|
+
key: 'dir:' + child.path,
|
|
342
|
+
className: 'drp-tree-row drp-tree-dir',
|
|
343
|
+
style: { paddingLeft: (SECTION_ROW_INDENT + treeIndentOf(depth)) + 'px' },
|
|
344
|
+
role: 'treeitem',
|
|
345
|
+
onClick: () => onToggle(child.path),
|
|
346
|
+
},
|
|
347
|
+
h('span', { className: 'drp-tree-caret' }, isOpen ? '▾' : '▸'),
|
|
348
|
+
h('span', { className: 'drp-folder-icon' }, h(FolderGlyph)),
|
|
349
|
+
h('span', { className: 'drp-change-dirname' }, child.name),
|
|
350
|
+
child.status
|
|
351
|
+
? h('span', { className: cx('drp-dir-dot', 'drp-code-' + changeCodeClass(child.status)) })
|
|
352
|
+
: null,
|
|
353
|
+
dirPaths.length > 0
|
|
354
|
+
? h(
|
|
355
|
+
'button',
|
|
356
|
+
{
|
|
357
|
+
type: 'button',
|
|
358
|
+
className: 'drp-icon-btn drp-change-action drp-dir-action',
|
|
359
|
+
title: dirAction.title,
|
|
360
|
+
'aria-label': dirAction.title,
|
|
361
|
+
onClick: (event) => {
|
|
362
|
+
event.stopPropagation()
|
|
363
|
+
dirAction.click()
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
dirAction.label,
|
|
367
|
+
)
|
|
368
|
+
: null,
|
|
369
|
+
))
|
|
370
|
+
if (isOpen) {
|
|
371
|
+
rows.push(...ChangeTreeLevel({ ...props, node: child, depth: depth + 1 }))
|
|
372
|
+
}
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
files += 1
|
|
376
|
+
rows.push(h(ChangeRow, { key: 'file:' + child.path, file: child.file, depth, ...rowProps }))
|
|
377
|
+
}
|
|
378
|
+
return rows
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Modal confirmation shown before a discard (destructive, not undoable).
|
|
383
|
+
* @param {object} props component props
|
|
384
|
+
* @returns {any} React element
|
|
385
|
+
*/
|
|
386
|
+
function DiscardConfirm(props) {
|
|
387
|
+
const { t, count, onCancel, onConfirm } = props
|
|
388
|
+
return h(
|
|
389
|
+
'div',
|
|
390
|
+
{ className: 'drp-confirm-overlay', role: 'presentation' },
|
|
391
|
+
h(
|
|
392
|
+
'div',
|
|
393
|
+
{ className: 'drp-confirm', role: 'dialog', 'aria-modal': 'true' },
|
|
394
|
+
h('div', { className: 'drp-confirm-title' }, t('changes.discardConfirmTitle')),
|
|
395
|
+
h('div', { className: 'drp-confirm-body' }, t('changes.discardConfirmBody', { count: String(count) })),
|
|
396
|
+
h(
|
|
397
|
+
'div',
|
|
398
|
+
{ className: 'drp-confirm-actions' },
|
|
399
|
+
h('button', { type: 'button', className: 'drp-btn', onClick: onCancel }, t('changes.discardCancel')),
|
|
400
|
+
h('button', { type: 'button', className: 'drp-btn drp-btn-danger', onClick: onConfirm }, t('changes.discardConfirmOk')),
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* One collapsible section (staged or unstaged) with a header action.
|
|
408
|
+
* @param {object} props section props
|
|
409
|
+
* @returns {any} React element
|
|
410
|
+
*/
|
|
411
|
+
function ChangeSection(props) {
|
|
412
|
+
const { scope, files, collapsedDirs, collapsed, view, onToggleCollapsed, onToggleDir, onStage, onUnstage, onOpen, onContextMenu, activePath, t, onOpenFile, onRequestDiscard, scheme } = props
|
|
413
|
+
const count = files.length
|
|
414
|
+
const allAction = scope === 'staged'
|
|
415
|
+
? { title: t('changes.unstageAll'), label: '−', onClick: () => onUnstage(null) }
|
|
416
|
+
: { title: t('changes.stageAll'), label: '+', onClick: () => onStage(null) }
|
|
417
|
+
const title = scope === 'staged' ? t('changes.staged') : t('changes.unstaged')
|
|
418
|
+
const tree = buildChangeTree(files)
|
|
419
|
+
return h(
|
|
420
|
+
'div',
|
|
421
|
+
{ className: 'drp-changes-section' },
|
|
422
|
+
h(
|
|
423
|
+
'div',
|
|
424
|
+
{
|
|
425
|
+
className: 'drp-changes-section-head',
|
|
426
|
+
role: 'button',
|
|
427
|
+
tabIndex: 0,
|
|
428
|
+
onClick: () => onToggleCollapsed(scope),
|
|
429
|
+
onKeyDown: (event) => { if (event.key === 'Enter') onToggleCollapsed(scope) },
|
|
430
|
+
},
|
|
431
|
+
h('span', { className: 'drp-tree-caret' }, collapsed ? '▸' : '▾'),
|
|
432
|
+
h('span', { className: 'drp-changes-section-title' }, title),
|
|
433
|
+
h('span', { className: 'drp-changes-count' }, String(count)),
|
|
434
|
+
scope === 'unstaged' && count > 0 && typeof onRequestDiscard === 'function'
|
|
435
|
+
? h(
|
|
436
|
+
'button',
|
|
437
|
+
{
|
|
438
|
+
type: 'button',
|
|
439
|
+
className: 'drp-icon-btn drp-changes-bulk',
|
|
440
|
+
title: t('changes.discardAll'),
|
|
441
|
+
'aria-label': t('changes.discardAll'),
|
|
442
|
+
onClick: (event) => {
|
|
443
|
+
event.stopPropagation()
|
|
444
|
+
const paths = files.map((file) => file.path)
|
|
445
|
+
const untracked = files.filter((file) => file.code === 'U' || file.indexStatus === '?').map((file) => file.path)
|
|
446
|
+
onRequestDiscard(paths, 'unstaged', untracked)
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
'\u21ba',
|
|
450
|
+
)
|
|
451
|
+
: null,
|
|
452
|
+
h(
|
|
453
|
+
'button',
|
|
454
|
+
{
|
|
455
|
+
type: 'button',
|
|
456
|
+
className: 'drp-icon-btn drp-changes-bulk',
|
|
457
|
+
title: allAction.title,
|
|
458
|
+
'aria-label': allAction.title,
|
|
459
|
+
disabled: count === 0,
|
|
460
|
+
onClick: (event) => {
|
|
461
|
+
event.stopPropagation()
|
|
462
|
+
allAction.onClick()
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
allAction.label,
|
|
466
|
+
),
|
|
467
|
+
),
|
|
468
|
+
collapsed
|
|
469
|
+
? null
|
|
470
|
+
: h(
|
|
471
|
+
'div',
|
|
472
|
+
{ className: 'drp-changes-tree', role: 'tree' },
|
|
473
|
+
count === 0
|
|
474
|
+
? h('div', { className: 'drp-changes-empty' }, t('changes.sectionEmpty'))
|
|
475
|
+
: view === 'list'
|
|
476
|
+
? buildChangeList(files).slice(0, CHANGE_ROW_LIMIT).map((file) => h(ChangeRow, {
|
|
477
|
+
key: 'file:' + file.path,
|
|
478
|
+
file,
|
|
479
|
+
scope,
|
|
480
|
+
depth: 0,
|
|
481
|
+
activePath,
|
|
482
|
+
onOpen,
|
|
483
|
+
onStage,
|
|
484
|
+
onUnstage,
|
|
485
|
+
onContextMenu,
|
|
486
|
+
onOpenFile,
|
|
487
|
+
onRequestDiscard,
|
|
488
|
+
scheme,
|
|
489
|
+
t,
|
|
490
|
+
}))
|
|
491
|
+
: ChangeTreeLevel({
|
|
492
|
+
node: tree,
|
|
493
|
+
depth: 0,
|
|
494
|
+
collapsed: collapsedDirs,
|
|
495
|
+
onToggle: onToggleDir,
|
|
496
|
+
rowProps: { scope, activePath, onOpen, onStage, onUnstage, onContextMenu, onOpenFile, onRequestDiscard, scheme, t },
|
|
497
|
+
}),
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Branch name, ahead/behind counters, refresh action.
|
|
504
|
+
* @param {object} props bar props
|
|
505
|
+
* @returns {any} React element
|
|
506
|
+
*/
|
|
507
|
+
function BranchBar(props) {
|
|
508
|
+
const { branch, busy, view, onRefresh, onToggleView, t } = props
|
|
509
|
+
const label = branchLabel(branch)
|
|
510
|
+
const nextView = view === 'list' ? 'tree' : 'list'
|
|
511
|
+
return h(
|
|
512
|
+
'div',
|
|
513
|
+
{ className: 'drp-changes-branch' },
|
|
514
|
+
h('span', { className: 'drp-branch-icon', 'aria-hidden': 'true' }, '⎇'),
|
|
515
|
+
h('span', { className: 'drp-branch-name', title: label || t('changes.branchUnknown') }, label || t('changes.branchUnknown')),
|
|
516
|
+
h(
|
|
517
|
+
'button',
|
|
518
|
+
{
|
|
519
|
+
type: 'button',
|
|
520
|
+
className: 'drp-icon-btn drp-branch-view',
|
|
521
|
+
title: t('changes.view.' + nextView),
|
|
522
|
+
'aria-label': t('changes.view.' + nextView),
|
|
523
|
+
'aria-pressed': view === 'list' ? 'true' : 'false',
|
|
524
|
+
onClick: () => {
|
|
525
|
+
if (typeof onToggleView === 'function') onToggleView()
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
view === 'list' ? '▤' : '☰',
|
|
529
|
+
),
|
|
530
|
+
h(
|
|
531
|
+
'button',
|
|
532
|
+
{
|
|
533
|
+
type: 'button',
|
|
534
|
+
className: 'drp-icon-btn drp-branch-refresh',
|
|
535
|
+
title: t('changes.refresh'),
|
|
536
|
+
'aria-label': t('changes.refresh'),
|
|
537
|
+
disabled: busy === true,
|
|
538
|
+
onClick: onRefresh,
|
|
539
|
+
},
|
|
540
|
+
busy === true ? '…' : '⟳',
|
|
541
|
+
),
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Lay out commit history on a compact lane graph.
|
|
547
|
+
* Walks newest-first and keeps one lane head per pending commit, so merges and
|
|
548
|
+
* branches stay on their own column without a full git-graph library.
|
|
549
|
+
* @param {Array<object>} commits commit records from the log route
|
|
550
|
+
* @returns {Array<object>} per-row layout records
|
|
551
|
+
*/
|
|
552
|
+
function buildGraphRows(commits) {
|
|
553
|
+
const lanes = []
|
|
554
|
+
const rows = []
|
|
555
|
+
for (const commit of Array.isArray(commits) ? commits : []) {
|
|
556
|
+
if (!commit || typeof commit.sha !== 'string') continue
|
|
557
|
+
let lane = lanes.indexOf(commit.sha)
|
|
558
|
+
if (lane < 0) {
|
|
559
|
+
lane = lanes.length
|
|
560
|
+
lanes.push(commit.sha)
|
|
561
|
+
}
|
|
562
|
+
const before = lanes.slice()
|
|
563
|
+
const parents = Array.isArray(commit.parents) ? commit.parents : []
|
|
564
|
+
const first = parents[0]
|
|
565
|
+
if (first) lanes[lane] = first
|
|
566
|
+
else lanes.splice(lane, 1)
|
|
567
|
+
for (const parent of parents.slice(1)) {
|
|
568
|
+
if (!lanes.includes(parent)) lanes.push(parent)
|
|
569
|
+
}
|
|
570
|
+
rows.push({
|
|
571
|
+
sha: commit.sha,
|
|
572
|
+
short: commit.short || String(commit.sha).slice(0, 7),
|
|
573
|
+
subject: commit.subject || '',
|
|
574
|
+
author: commit.author || '',
|
|
575
|
+
date: commit.date || '',
|
|
576
|
+
refs: Array.isArray(commit.refs) ? commit.refs : [],
|
|
577
|
+
lane,
|
|
578
|
+
before,
|
|
579
|
+
after: lanes.slice(),
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
return rows
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* One lane-graph row: connector lines, the commit dot, subject and metadata.
|
|
587
|
+
* @param {object} props row props
|
|
588
|
+
* @returns {any} React element
|
|
589
|
+
*/
|
|
590
|
+
function ChangeGraphRow(props) {
|
|
591
|
+
const { row, onOpen, t } = props
|
|
592
|
+
const height = 26
|
|
593
|
+
const column = (index) => index * 14 + 7
|
|
594
|
+
const width = Math.max(14, Math.max(row.before.length, row.after.length) * 14)
|
|
595
|
+
const lines = []
|
|
596
|
+
const count = Math.max(row.before.length, row.after.length)
|
|
597
|
+
for (let index = 0; index < count; index += 1) {
|
|
598
|
+
lines.push(h('line', {
|
|
599
|
+
key: 'lane' + index,
|
|
600
|
+
x1: column(index),
|
|
601
|
+
y1: 0,
|
|
602
|
+
x2: column(index),
|
|
603
|
+
y2: height,
|
|
604
|
+
className: 'drp-graph-lane',
|
|
605
|
+
}))
|
|
606
|
+
}
|
|
607
|
+
lines.push(h('circle', { key: 'dot', cx: column(row.lane), cy: height / 2, r: 3.2, className: 'drp-graph-dot' }))
|
|
608
|
+
const meta = [row.refs[0], row.author, String(row.date || '').slice(0, 10)].filter(Boolean).join(' · ')
|
|
609
|
+
return h(
|
|
610
|
+
'button',
|
|
611
|
+
{
|
|
612
|
+
type: 'button',
|
|
613
|
+
className: 'drp-graph-row',
|
|
614
|
+
title: row.sha,
|
|
615
|
+
onClick: () => {
|
|
616
|
+
if (typeof onOpen === 'function') onOpen(row)
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
h('svg', { className: 'drp-graph-svg', width, height, 'aria-hidden': 'true' }, lines),
|
|
620
|
+
h(
|
|
621
|
+
'span',
|
|
622
|
+
{ className: 'drp-graph-main' },
|
|
623
|
+
h('span', { className: 'drp-graph-subject' }, row.subject || row.short),
|
|
624
|
+
meta ? h('span', { className: 'drp-graph-meta' }, meta) : null,
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Commit message input; Enter submits, Shift+Enter submits and pushes.
|
|
631
|
+
* @param {object} props box props
|
|
632
|
+
* @returns {any} React element
|
|
633
|
+
*/
|
|
634
|
+
function CommitBox(props) {
|
|
635
|
+
const { value, branch, busy, generating, onChange, onSubmit, onGenerate, t } = props
|
|
636
|
+
const placeholder = t('changes.messagePlaceholder', { branch: branchLabel(branch) || 'HEAD' })
|
|
637
|
+
return h(
|
|
638
|
+
'div',
|
|
639
|
+
{ className: 'drp-commit-field' },
|
|
640
|
+
h('textarea', {
|
|
641
|
+
className: 'drp-commit-box',
|
|
642
|
+
rows: 2,
|
|
643
|
+
value: value,
|
|
644
|
+
placeholder,
|
|
645
|
+
spellCheck: false,
|
|
646
|
+
'aria-label': t('changes.message'),
|
|
647
|
+
disabled: busy === true,
|
|
648
|
+
onChange: (event) => onChange(event.target.value),
|
|
649
|
+
onKeyDown: (event) => {
|
|
650
|
+
if (event.key !== 'Enter') return
|
|
651
|
+
if (!(event.metaKey || event.ctrlKey)) return
|
|
652
|
+
event.preventDefault()
|
|
653
|
+
onSubmit(event.shiftKey === true)
|
|
654
|
+
},
|
|
655
|
+
}),
|
|
656
|
+
h(
|
|
657
|
+
'button',
|
|
658
|
+
{
|
|
659
|
+
type: 'button',
|
|
660
|
+
className: 'drp-commit-ai',
|
|
661
|
+
title: t('changes.aiGenerate'),
|
|
662
|
+
'aria-label': t('changes.aiGenerate'),
|
|
663
|
+
disabled: busy === true || generating === true,
|
|
664
|
+
onClick: () => {
|
|
665
|
+
if (typeof onGenerate === 'function') onGenerate()
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
generating === true ? '…' : '✨',
|
|
669
|
+
),
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Primary commit button with a dropdown for "commit and push".
|
|
675
|
+
* @param {object} props button props
|
|
676
|
+
* @returns {any} React element
|
|
677
|
+
*/
|
|
678
|
+
function CommitButton(props) {
|
|
679
|
+
const { busy, disabled, onCommit, onCommitPush, t } = props
|
|
680
|
+
const [menuOpen, setMenuOpen] = React.useState(false)
|
|
681
|
+
const menuRef = React.useRef(null)
|
|
682
|
+
React.useEffect(() => {
|
|
683
|
+
if (!menuOpen) return undefined
|
|
684
|
+
const close = (event) => {
|
|
685
|
+
if (menuRef.current && !menuRef.current.contains(event.target)) setMenuOpen(false)
|
|
686
|
+
}
|
|
687
|
+
document.addEventListener('mousedown', close)
|
|
688
|
+
return () => document.removeEventListener('mousedown', close)
|
|
689
|
+
}, [menuOpen])
|
|
690
|
+
return h(
|
|
691
|
+
'div',
|
|
692
|
+
{ className: 'drp-commit-row' },
|
|
693
|
+
h(
|
|
694
|
+
'button',
|
|
695
|
+
{
|
|
696
|
+
type: 'button',
|
|
697
|
+
className: 'drp-btn drp-btn-primary drp-commit-main',
|
|
698
|
+
disabled: disabled === true || busy === true,
|
|
699
|
+
onClick: () => onCommit(false),
|
|
700
|
+
},
|
|
701
|
+
busy === true ? t('changes.committing') : '✓ ' + t('changes.commit'),
|
|
702
|
+
),
|
|
703
|
+
h(
|
|
704
|
+
'div',
|
|
705
|
+
{ className: 'drp-commit-split', ref: menuRef },
|
|
706
|
+
h(
|
|
707
|
+
'button',
|
|
708
|
+
{
|
|
709
|
+
type: 'button',
|
|
710
|
+
className: 'drp-btn drp-commit-caret',
|
|
711
|
+
'aria-label': t('changes.moreCommitActions'),
|
|
712
|
+
title: t('changes.moreCommitActions'),
|
|
713
|
+
disabled: disabled === true || busy === true,
|
|
714
|
+
onClick: () => setMenuOpen((open) => !open),
|
|
715
|
+
},
|
|
716
|
+
'▾',
|
|
717
|
+
),
|
|
718
|
+
menuOpen
|
|
719
|
+
? h(
|
|
720
|
+
'div',
|
|
721
|
+
{ className: 'drp-commit-menu', role: 'menu' },
|
|
722
|
+
h('button', {
|
|
723
|
+
type: 'button',
|
|
724
|
+
className: 'drp-menu-item',
|
|
725
|
+
role: 'menuitem',
|
|
726
|
+
onClick: () => { setMenuOpen(false); onCommit(false) },
|
|
727
|
+
}, t('changes.commit')),
|
|
728
|
+
h('button', {
|
|
729
|
+
type: 'button',
|
|
730
|
+
className: 'drp-menu-item',
|
|
731
|
+
role: 'menuitem',
|
|
732
|
+
onClick: () => { setMenuOpen(false); onCommit(true) },
|
|
733
|
+
}, t('changes.commitPush')),
|
|
734
|
+
)
|
|
735
|
+
: null,
|
|
736
|
+
),
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Transient toast for operation results.
|
|
742
|
+
* @param {object} props toast props
|
|
743
|
+
* @returns {any} React element or null
|
|
744
|
+
*/
|
|
745
|
+
function Toast(props) {
|
|
746
|
+
const { toast } = props
|
|
747
|
+
if (!toast || typeof toast.text !== 'string' || toast.text.length === 0) return null
|
|
748
|
+
return h('div', { className: cx('drp-toast', toast.kind === 'error' && 'drp-toast-error') }, toast.text)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Context menu for one change row (open / stage / unstage / copy path).
|
|
753
|
+
* @param {object} props menu props
|
|
754
|
+
* @returns {any} React element or null
|
|
755
|
+
*/
|
|
756
|
+
function ChangeContextMenu(props) {
|
|
757
|
+
const { menu, onClose, onOpen, onStage, onUnstage, onRequestDiscard, onNotify, t } = props
|
|
758
|
+
React.useEffect(() => {
|
|
759
|
+
if (!menu) return undefined
|
|
760
|
+
const close = () => onClose()
|
|
761
|
+
document.addEventListener('mousedown', close)
|
|
762
|
+
document.addEventListener('keydown', close)
|
|
763
|
+
return () => {
|
|
764
|
+
document.removeEventListener('mousedown', close)
|
|
765
|
+
document.removeEventListener('keydown', close)
|
|
766
|
+
}
|
|
767
|
+
}, [menu, onClose])
|
|
768
|
+
if (!menu) return null
|
|
769
|
+
const item = (label, run) => h('button', {
|
|
770
|
+
type: 'button',
|
|
771
|
+
className: 'drp-menu-item',
|
|
772
|
+
role: 'menuitem',
|
|
773
|
+
onClick: (event) => { event.stopPropagation(); run() },
|
|
774
|
+
}, label)
|
|
775
|
+
return h(
|
|
776
|
+
'div',
|
|
777
|
+
{
|
|
778
|
+
className: 'drp-ctx-menu',
|
|
779
|
+
role: 'menu',
|
|
780
|
+
style: { left: menu.x + 'px', top: menu.y + 'px' },
|
|
781
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
782
|
+
},
|
|
783
|
+
item(t('changes.openDiff'), () => { onClose(); onOpen(menu.path, menu.scope) }),
|
|
784
|
+
menu.scope === 'staged'
|
|
785
|
+
? item(t('changes.unstage'), () => { onClose(); onUnstage([menu.path]) })
|
|
786
|
+
: item(t('changes.stage'), () => { onClose(); onStage([menu.path]) }),
|
|
787
|
+
menu.scope === 'staged'
|
|
788
|
+
? null
|
|
789
|
+
: item(t('changes.discard'), () => { onClose(); onRequestDiscard([menu.path], menu.scope, []) }),
|
|
790
|
+
item(t('changes.copyPath'), () => {
|
|
791
|
+
onClose()
|
|
792
|
+
try {
|
|
793
|
+
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
794
|
+
void navigator.clipboard.writeText(menu.path)
|
|
795
|
+
if (typeof onNotify === 'function') onNotify(t('changes.copied'), 'info')
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
} catch (error) { /* clipboard unavailable */ }
|
|
799
|
+
if (typeof onNotify === 'function') onNotify(t('changes.copyFailed'), 'error')
|
|
800
|
+
}),
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Git CHANGES panel: branch bar, commit box, commit button, staged/unstaged trees.
|
|
806
|
+
*
|
|
807
|
+
* Status comes from `gitStatus`; writes go through the `stage`/`unstage`/`commit`
|
|
808
|
+
* POST routes, and a successful write's fresh `status` replaces local state
|
|
809
|
+
* (optimistic refresh) before the 30s fallback poll runs again.
|
|
810
|
+
* @param {object} props panel props
|
|
811
|
+
* @returns {any} React element
|
|
812
|
+
*/
|
|
813
|
+
function ChangesPanel(props) {
|
|
814
|
+
const { sessionId, activePath, onOpenChangeDiff, onOpenCommit, onOpenFile, onWorkspaceChanged, onNotify, t, initialStatus, scheme } = props
|
|
815
|
+
const [status, setStatus] = React.useState(initialStatus || null)
|
|
816
|
+
const [error, setError] = React.useState(null)
|
|
817
|
+
const [loading, setLoading] = React.useState(!initialStatus)
|
|
818
|
+
const [busy, setBusy] = React.useState(false)
|
|
819
|
+
const [message, setMessage] = React.useState('')
|
|
820
|
+
const [collapsed, setCollapsed] = React.useState({ staged: false, unstaged: false })
|
|
821
|
+
// Directories are expanded by default; only paths the user collapsed are kept.
|
|
822
|
+
const [collapsedDirs, setCollapsedDirs] = React.useState(() => new Set())
|
|
823
|
+
const [toast, setToast] = React.useState(null)
|
|
824
|
+
const [generating, setGenerating] = React.useState(false)
|
|
825
|
+
/** Changes list layout: tree (folders) or flat list. */
|
|
826
|
+
const [view, setView] = React.useState(() => {
|
|
827
|
+
try {
|
|
828
|
+
return localStorage.getItem('dsh-git-changes.changeView') === 'list' ? 'list' : 'tree'
|
|
829
|
+
} catch (error) {
|
|
830
|
+
return 'tree'
|
|
831
|
+
}
|
|
832
|
+
})
|
|
833
|
+
const [graph, setGraph] = React.useState(null)
|
|
834
|
+
const [graphOpen, setGraphOpen] = React.useState(false)
|
|
835
|
+
const [graphLoading, setGraphLoading] = React.useState(false)
|
|
836
|
+
const [menu, setMenu] = React.useState(null)
|
|
837
|
+
const [pendingDiscard, setPendingDiscard] = React.useState(null)
|
|
838
|
+
const toastTimer = React.useRef(null)
|
|
839
|
+
const aliveRef = React.useRef(true)
|
|
840
|
+
|
|
841
|
+
React.useEffect(() => {
|
|
842
|
+
aliveRef.current = true
|
|
843
|
+
return () => {
|
|
844
|
+
aliveRef.current = false
|
|
845
|
+
if (toastTimer.current) clearTimeout(toastTimer.current)
|
|
846
|
+
}
|
|
847
|
+
}, [])
|
|
848
|
+
|
|
849
|
+
/** Show a transient toast. */
|
|
850
|
+
const notify = React.useCallback((text, kind) => {
|
|
851
|
+
if (!aliveRef.current) return
|
|
852
|
+
setToast({ text, kind: kind || 'info' })
|
|
853
|
+
if (toastTimer.current) clearTimeout(toastTimer.current)
|
|
854
|
+
toastTimer.current = setTimeout(() => {
|
|
855
|
+
if (aliveRef.current) setToast(null)
|
|
856
|
+
}, 3200)
|
|
857
|
+
}, [])
|
|
858
|
+
|
|
859
|
+
/** Load the latest status payload. */
|
|
860
|
+
const refresh = React.useCallback(async (options) => {
|
|
861
|
+
if (!sessionId) return
|
|
862
|
+
if (!(options && options.quiet === true)) setLoading(true)
|
|
863
|
+
try {
|
|
864
|
+
const value = await apiGet('gitStatus', { sessionId })
|
|
865
|
+
if (!aliveRef.current) return
|
|
866
|
+
setStatus(value || { available: false, reason: 'git-failed' })
|
|
867
|
+
setError(null)
|
|
868
|
+
} catch (failure) {
|
|
869
|
+
if (!aliveRef.current) return
|
|
870
|
+
setError(failure && failure.message ? failure.message : String(failure))
|
|
871
|
+
} finally {
|
|
872
|
+
if (aliveRef.current) setLoading(false)
|
|
873
|
+
}
|
|
874
|
+
}, [sessionId])
|
|
875
|
+
|
|
876
|
+
React.useEffect(() => {
|
|
877
|
+
if (initialStatus) return
|
|
878
|
+
void refresh()
|
|
879
|
+
}, [refresh, initialStatus])
|
|
880
|
+
|
|
881
|
+
// 30s fallback poll; skip while a write is in flight or the document is hidden.
|
|
882
|
+
React.useEffect(() => {
|
|
883
|
+
const timer = setInterval(() => {
|
|
884
|
+
if (busy || (typeof document !== 'undefined' && document.hidden)) return
|
|
885
|
+
void refresh({ quiet: true })
|
|
886
|
+
}, 30000)
|
|
887
|
+
return () => clearInterval(timer)
|
|
888
|
+
}, [busy, refresh])
|
|
889
|
+
|
|
890
|
+
/** Run one write request and fold its fresh status back into local state. */
|
|
891
|
+
const runWrite = React.useCallback(async (name, body, successText) => {
|
|
892
|
+
if (!sessionId || busy) return
|
|
893
|
+
setBusy(true)
|
|
894
|
+
try {
|
|
895
|
+
const value = await apiPost(name, { sessionId, ...(body || {}) })
|
|
896
|
+
if (!aliveRef.current) return
|
|
897
|
+
if (value && value.ok === true) {
|
|
898
|
+
if (value.status) setStatus(value.status)
|
|
899
|
+
else void refresh({ quiet: true })
|
|
900
|
+
if (successText) notify(successText, 'info')
|
|
901
|
+
return value
|
|
902
|
+
}
|
|
903
|
+
if (value && value.status) setStatus(value.status)
|
|
904
|
+
const text = describeGitError(value, t)
|
|
905
|
+
setError(text)
|
|
906
|
+
notify(text, 'error')
|
|
907
|
+
return null
|
|
908
|
+
} catch (failure) {
|
|
909
|
+
if (!aliveRef.current) return null
|
|
910
|
+
const text = failure && failure.message ? failure.message : String(failure)
|
|
911
|
+
setError(text)
|
|
912
|
+
notify(text, 'error')
|
|
913
|
+
return null
|
|
914
|
+
} finally {
|
|
915
|
+
if (aliveRef.current) setBusy(false)
|
|
916
|
+
}
|
|
917
|
+
}, [busy, notify, refresh, sessionId, t])
|
|
918
|
+
|
|
919
|
+
const groups = groupChanges(status)
|
|
920
|
+
const canCommit = groups.staged.length > 0 && message.trim().length > 0 && !busy
|
|
921
|
+
|
|
922
|
+
/** Stage the given paths, or everything when `paths` is null. */
|
|
923
|
+
const stage = (paths) => runWrite('stage', paths === null ? { all: true } : { paths }, t('changes.stagedToast'))
|
|
924
|
+
/** Unstage the given paths, or everything when `paths` is null. */
|
|
925
|
+
const unstage = (paths) => runWrite('unstage', paths === null ? { all: true } : { paths }, t('changes.unstagedToast'))
|
|
926
|
+
|
|
927
|
+
/** Commit the index, optionally pushing afterwards. */
|
|
928
|
+
const commit = async (push) => {
|
|
929
|
+
if (!canCommit) return
|
|
930
|
+
const value = await runWrite('commit', { message, push: push === true }, null)
|
|
931
|
+
if (!value) return
|
|
932
|
+
setMessage('')
|
|
933
|
+
if (value.pushed === true) notify(t('changes.committedPushed'), 'info')
|
|
934
|
+
else if (push === true) notify(describeGitError({ code: value.pushCode || 'push-failed' }, t), 'error')
|
|
935
|
+
else notify(t('changes.committed'), 'info')
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const toggleCollapsed = (scope) => setCollapsed((current) => ({ ...current, [scope]: !current[scope] }))
|
|
939
|
+
const toggleDir = (path) => setCollapsedDirs((current) => {
|
|
940
|
+
const next = new Set(current)
|
|
941
|
+
if (next.has(path)) next.delete(path)
|
|
942
|
+
else next.add(path)
|
|
943
|
+
return next
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
const openChangeDiff = (path, scope) => {
|
|
947
|
+
if (typeof onOpenChangeDiff === 'function') onOpenChangeDiff(path, scope)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Ask for confirmation before discarding; the caller never discards inline. */
|
|
951
|
+
const requestDiscard = (paths, scope, untrackedPaths) => {
|
|
952
|
+
if (!Array.isArray(paths) || paths.length === 0) return
|
|
953
|
+
setPendingDiscard({
|
|
954
|
+
paths,
|
|
955
|
+
scope: scope === 'staged' ? 'staged' : 'unstaged',
|
|
956
|
+
untrackedPaths: Array.isArray(untrackedPaths) ? untrackedPaths : [],
|
|
957
|
+
})
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Run the destructive discard after the user confirmed it. */
|
|
961
|
+
const confirmDiscard = () => {
|
|
962
|
+
const pending = pendingDiscard
|
|
963
|
+
if (!pending) return
|
|
964
|
+
setPendingDiscard(null)
|
|
965
|
+
void (async () => {
|
|
966
|
+
const value = await runWrite('discard', {
|
|
967
|
+
paths: pending.paths,
|
|
968
|
+
untrackedPaths: pending.untrackedPaths,
|
|
969
|
+
scope: pending.scope,
|
|
970
|
+
}, t('changes.discarded'))
|
|
971
|
+
if (value && typeof onWorkspaceChanged === 'function') onWorkspaceChanged()
|
|
972
|
+
})()
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Ask the host model for a commit message from the current diff. */
|
|
976
|
+
const generate = async () => {
|
|
977
|
+
if (!sessionId || busy || generating) return
|
|
978
|
+
setGenerating(true)
|
|
979
|
+
try {
|
|
980
|
+
const value = await apiPost('generateMessage', { sessionId })
|
|
981
|
+
if (!aliveRef.current) return
|
|
982
|
+
if (value && value.ok === true && typeof value.message === 'string') {
|
|
983
|
+
setMessage(value.message)
|
|
984
|
+
notify(t('changes.aiGenerated'), 'info')
|
|
985
|
+
} else {
|
|
986
|
+
notify(describeGitError({ code: value && value.code ? value.code : 'generate-failed' }, t), 'error')
|
|
987
|
+
}
|
|
988
|
+
} catch (failure) {
|
|
989
|
+
if (!aliveRef.current) return
|
|
990
|
+
notify(failure && failure.message ? failure.message : t('changes.aiFailed'), 'error')
|
|
991
|
+
} finally {
|
|
992
|
+
if (aliveRef.current) setGenerating(false)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Load the commit history shown by the graph panel. */
|
|
997
|
+
const loadGraph = React.useCallback(async () => {
|
|
998
|
+
if (!sessionId) return
|
|
999
|
+
setGraphLoading(true)
|
|
1000
|
+
try {
|
|
1001
|
+
const value = await apiGet('log', { sessionId, limit: 80 })
|
|
1002
|
+
if (!aliveRef.current) return
|
|
1003
|
+
setGraph(value && value.available === true && Array.isArray(value.commits) ? value.commits : [])
|
|
1004
|
+
} catch (failure) {
|
|
1005
|
+
if (aliveRef.current) setGraph([])
|
|
1006
|
+
} finally {
|
|
1007
|
+
if (aliveRef.current) setGraphLoading(false)
|
|
1008
|
+
}
|
|
1009
|
+
}, [sessionId])
|
|
1010
|
+
|
|
1011
|
+
React.useEffect(() => {
|
|
1012
|
+
if (graphOpen && graph === null && !graphLoading) void loadGraph()
|
|
1013
|
+
}, [graphOpen, graph, graphLoading, loadGraph])
|
|
1014
|
+
|
|
1015
|
+
React.useEffect(() => {
|
|
1016
|
+
try {
|
|
1017
|
+
localStorage.setItem('dsh-git-changes.changeView', view)
|
|
1018
|
+
} catch (error) { /* storage unavailable */ }
|
|
1019
|
+
}, [view])
|
|
1020
|
+
|
|
1021
|
+
if (!sessionId) return null
|
|
1022
|
+
if (status && status.available === false) {
|
|
1023
|
+
const reason = status.reason === 'no-git'
|
|
1024
|
+
? t('changes.noGit')
|
|
1025
|
+
: status.reason === 'not-git'
|
|
1026
|
+
? t('changes.notGit')
|
|
1027
|
+
: status.reason === 'no-changes'
|
|
1028
|
+
? t('changes.noChanges')
|
|
1029
|
+
: t('changes.unavailable')
|
|
1030
|
+
return h('div', { className: 'drp-changes-panel' }, h('div', { className: 'drp-changes-empty-state' }, reason))
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return h(
|
|
1034
|
+
'div',
|
|
1035
|
+
{ className: 'drp-changes-panel' },
|
|
1036
|
+
h(BranchBar, { branch: groups.branch, busy, view, onRefresh: () => void refresh(), onToggleView: () => setView((current) => (current === 'list' ? 'tree' : 'list')), t }),
|
|
1037
|
+
h(CommitBox, {
|
|
1038
|
+
value: message,
|
|
1039
|
+
branch: groups.branch,
|
|
1040
|
+
busy,
|
|
1041
|
+
generating,
|
|
1042
|
+
onChange: setMessage,
|
|
1043
|
+
onSubmit: (push) => void commit(push),
|
|
1044
|
+
onGenerate: () => void generate(),
|
|
1045
|
+
t,
|
|
1046
|
+
}),
|
|
1047
|
+
h(CommitButton, {
|
|
1048
|
+
busy,
|
|
1049
|
+
disabled: !canCommit,
|
|
1050
|
+
onCommit: (push) => void commit(push),
|
|
1051
|
+
onCommitPush: () => void commit(true),
|
|
1052
|
+
t,
|
|
1053
|
+
}),
|
|
1054
|
+
error ? h('div', { className: 'drp-changes-error' }, error) : null,
|
|
1055
|
+
loading && !status
|
|
1056
|
+
? h('div', { className: 'drp-changes-loading' }, t('changes.loading'))
|
|
1057
|
+
: h(
|
|
1058
|
+
'div',
|
|
1059
|
+
{ className: 'drp-changes-groups' },
|
|
1060
|
+
h(ChangeSection, {
|
|
1061
|
+
scope: 'staged',
|
|
1062
|
+
files: groups.staged,
|
|
1063
|
+
collapsedDirs,
|
|
1064
|
+
view,
|
|
1065
|
+
collapsed: collapsed.staged,
|
|
1066
|
+
onToggleCollapsed: toggleCollapsed,
|
|
1067
|
+
onToggleDir: toggleDir,
|
|
1068
|
+
onStage: stage,
|
|
1069
|
+
onUnstage: unstage,
|
|
1070
|
+
onOpen: openChangeDiff,
|
|
1071
|
+
onOpenFile: typeof onOpenFile === 'function' ? onOpenFile : null,
|
|
1072
|
+
onRequestDiscard: requestDiscard,
|
|
1073
|
+
onContextMenu: (event, path, scope) => {
|
|
1074
|
+
event.preventDefault()
|
|
1075
|
+
setMenu({ x: event.clientX, y: event.clientY, path, scope })
|
|
1076
|
+
},
|
|
1077
|
+
activePath,
|
|
1078
|
+
scheme,
|
|
1079
|
+
t,
|
|
1080
|
+
}),
|
|
1081
|
+
h(ChangeSection, {
|
|
1082
|
+
scope: 'unstaged',
|
|
1083
|
+
files: groups.unstaged,
|
|
1084
|
+
collapsedDirs,
|
|
1085
|
+
view,
|
|
1086
|
+
collapsed: collapsed.unstaged,
|
|
1087
|
+
onToggleCollapsed: toggleCollapsed,
|
|
1088
|
+
onToggleDir: toggleDir,
|
|
1089
|
+
onStage: stage,
|
|
1090
|
+
onUnstage: unstage,
|
|
1091
|
+
onOpen: openChangeDiff,
|
|
1092
|
+
onOpenFile: typeof onOpenFile === 'function' ? onOpenFile : null,
|
|
1093
|
+
onRequestDiscard: requestDiscard,
|
|
1094
|
+
onContextMenu: (event, path, scope) => {
|
|
1095
|
+
event.preventDefault()
|
|
1096
|
+
setMenu({ x: event.clientX, y: event.clientY, path, scope })
|
|
1097
|
+
},
|
|
1098
|
+
activePath,
|
|
1099
|
+
scheme,
|
|
1100
|
+
t,
|
|
1101
|
+
}),
|
|
1102
|
+
),
|
|
1103
|
+
SHOW_COMMIT_GRAPH && h(
|
|
1104
|
+
'div',
|
|
1105
|
+
{ className: 'drp-graph' },
|
|
1106
|
+
h(
|
|
1107
|
+
'button',
|
|
1108
|
+
{
|
|
1109
|
+
type: 'button',
|
|
1110
|
+
className: 'drp-changes-section-head drp-graph-head',
|
|
1111
|
+
onClick: () => setGraphOpen((current) => !current),
|
|
1112
|
+
},
|
|
1113
|
+
h('span', { className: 'drp-graph-caret' }, graphOpen ? '⌄' : '›'),
|
|
1114
|
+
h('span', { className: 'drp-changes-section-title' }, t('changes.graph')),
|
|
1115
|
+
h('span', { className: 'drp-changes-count' }, graph === null ? '…' : String(graph.length)),
|
|
1116
|
+
),
|
|
1117
|
+
graphOpen === true
|
|
1118
|
+
? (graphLoading && graph === null
|
|
1119
|
+
? h('div', { className: 'drp-changes-empty' }, t('changes.loading'))
|
|
1120
|
+
: (graph && graph.length > 0)
|
|
1121
|
+
? h('div', { className: 'drp-graph-list' }, buildGraphRows(graph).map((row) => h(ChangeGraphRow, {
|
|
1122
|
+
key: row.sha,
|
|
1123
|
+
row,
|
|
1124
|
+
t,
|
|
1125
|
+
onOpen: (commit) => {
|
|
1126
|
+
if (typeof onOpenCommit === 'function') onOpenCommit(commit)
|
|
1127
|
+
},
|
|
1128
|
+
})))
|
|
1129
|
+
: h('div', { className: 'drp-changes-empty' }, t('changes.graphEmpty')))
|
|
1130
|
+
: null,
|
|
1131
|
+
),
|
|
1132
|
+
pendingDiscard
|
|
1133
|
+
? h(DiscardConfirm, {
|
|
1134
|
+
t,
|
|
1135
|
+
count: pendingDiscard.paths.length + pendingDiscard.untrackedPaths.length,
|
|
1136
|
+
onCancel: () => setPendingDiscard(null),
|
|
1137
|
+
onConfirm: confirmDiscard,
|
|
1138
|
+
})
|
|
1139
|
+
: null,
|
|
1140
|
+
h(Toast, { toast }),
|
|
1141
|
+
h(ChangeContextMenu, {
|
|
1142
|
+
menu,
|
|
1143
|
+
onClose: () => setMenu(null),
|
|
1144
|
+
onOpen: openChangeDiff,
|
|
1145
|
+
onStage: stage,
|
|
1146
|
+
onUnstage: unstage,
|
|
1147
|
+
onRequestDiscard: requestDiscard,
|
|
1148
|
+
onNotify: notify,
|
|
1149
|
+
t,
|
|
1150
|
+
}),
|
|
1151
|
+
)
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Fire one Git write route and return its JSON payload.
|
|
1156
|
+
* @param {'stage'|'unstage'|'commit'|'push'} name route name
|
|
1157
|
+
* @param {object} body request body
|
|
1158
|
+
* @param {AbortSignal} [signal] cancellation
|
|
1159
|
+
* @returns {Promise<object | null>} payload
|
|
1160
|
+
*/
|
|
1161
|
+
async function postGitReview(name, body, signal) {
|
|
1162
|
+
return apiPost(name, body || {}, signal)
|
|
1163
|
+
}
|