dsh-gufa-code 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -2
  3. package/client.js +11548 -0
  4. package/cordis.patch.yml +8 -0
  5. package/docs/icon-180.png +0 -0
  6. package/docs/icon.svg +18 -0
  7. package/docs/market-submission.md +32 -0
  8. package/docs/screenshot-ide.jpg +0 -0
  9. package/host/assets.js +31 -0
  10. package/host/chat.js +523 -0
  11. package/host/context.js +438 -0
  12. package/host/dev.js +35 -0
  13. package/host/fs.js +306 -0
  14. package/host/git.js +532 -0
  15. package/host/project.js +305 -0
  16. package/host/ssh.js +54 -0
  17. package/host/terminal-pty.js +237 -0
  18. package/host/terminal.js +947 -0
  19. package/icon.svg +18 -0
  20. package/index.js +63 -0
  21. package/locale/en.json +121 -0
  22. package/locale/zh.json +121 -0
  23. package/package.json +67 -4
  24. package/screenshots.json +3 -0
  25. package/scripts/build-client.mjs +38 -0
  26. package/scripts/selftest-client.mjs +457 -0
  27. package/scripts/selftest-host.mjs +197 -0
  28. package/scripts/selftest-http.mjs +284 -0
  29. package/scripts/selftest-router.mjs +101 -0
  30. package/scripts/selftest-terminal-client.mjs +314 -0
  31. package/scripts/selftest-terminal-pty.mjs +190 -0
  32. package/scripts/selftest-terminal.mjs +351 -0
  33. package/scripts/test-chat-render.mjs +702 -0
  34. package/scripts/test-chat.mjs +761 -0
  35. package/scripts/test-editor.mjs +516 -0
  36. package/scripts/verify-workspace-sessions.mjs +135 -0
  37. package/src/client/00-core.js +1686 -0
  38. package/src/client/10-icons.js +205 -0
  39. package/src/client/20-editor.js +1318 -0
  40. package/src/client/30-terminal.js +1510 -0
  41. package/src/client/40-chat.js +1184 -0
  42. package/src/client/46-gitgraph.js +278 -0
  43. package/src/client/50-panels.js +916 -0
  44. package/src/client/55-backdrop.js +508 -0
  45. package/src/client/60-dock.js +662 -0
  46. package/src/client/85-shortcuts.js +516 -0
  47. package/src/client/90-app.js +2272 -0
  48. package/src/client/99-main.js +449 -0
  49. package/vendor/monaco/LICENSE +21 -0
  50. package/vendor/monaco/ThirdPartyNotices.txt +448 -0
  51. package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. package/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. package/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. package/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. package/vendor/monaco/vs/loader.js +11 -0
  145. package/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. package/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. package/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. package/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. package/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. package/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. package/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. package/vendor/xterm/LICENSE +21 -0
  155. package/vendor/xterm/LICENSE.addon-fit +19 -0
  156. package/vendor/xterm/addon-fit.js +2 -0
  157. package/vendor/xterm/xterm.css +285 -0
  158. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,916 @@
1
+ /**
2
+ * 50-panels:左侧三块面板 —— 资源管理器(文件树)、源代码管理(Git)、SSH 主机。
3
+ * 纯展示 + 直接调用 Host API,状态自持;容器在 90-app.js。
4
+ */
5
+
6
+ /* ── 资源管理器 ────────────────────────────────────────────────────────── */
7
+ G.Explorer = function Explorer(props) {
8
+ const t = G.useT()
9
+ const root = props.root
10
+ const [dirs, setDirs] = useState({})
11
+ const [expanded, setExpanded] = useState({})
12
+ const [selected, setSelected] = useState(() => (props.activePath ? { path: props.activePath, kind: 'file' } : null))
13
+ const [multi, setMulti] = useState([])
14
+ const [clipboard, setClipboard] = useState(null)
15
+ const [menu, setMenu] = useState(null)
16
+ const [menuError, setMenuError] = useState('')
17
+ const flatRows = []
18
+ const kindByPathRef = useRef(new Map())
19
+ const problems = G.useProblems()
20
+ /* 目录里含改动的文件 → 目录也给个提示点 */
21
+ const changedDirs = useMemo(() => {
22
+ const set = new Set()
23
+ const statuses = props.gitStatuses
24
+ if (statuses !== null && typeof statuses === 'object') {
25
+ for (const path of Object.keys(statuses)) {
26
+ const parts = path.split('/')
27
+ parts.pop()
28
+ let dir = ''
29
+ for (const part of parts) {
30
+ if (part === '') continue
31
+ dir = dir === '' ? part : `${dir}/${part}`
32
+ set.add(dir)
33
+ }
34
+ }
35
+ }
36
+ return set
37
+ }, [props.gitStatuses])
38
+
39
+ const loadDir = useCallback(async (path) => {
40
+ // 已有内容时后台静默刷新:不要把整棵树换成 loading(否则每次保存都会闪一下)
41
+ setDirs((current) => {
42
+ const existing = current[path]
43
+ if (existing !== undefined && existing.status !== 'error' && Array.isArray(existing.entries) && existing.entries.length >= 0 && existing.entries !== undefined) {
44
+ return { ...current, [path]: { ...existing, refreshing: true } }
45
+ }
46
+ return { ...current, [path]: { status: 'loading', entries: [] } }
47
+ })
48
+ try {
49
+ const value = await G.api('fs.list', { root, path })
50
+ setDirs((current) => ({ ...current, [path]: { status: 'ready', entries: value.entries } }))
51
+ } catch (error) {
52
+ setDirs((current) => ({ ...current, [path]: { status: 'error', entries: [], error: G.errorText(error) } }))
53
+ }
54
+ }, [root])
55
+
56
+ useEffect(() => {
57
+ if (!root) return
58
+ setDirs({})
59
+ setExpanded({})
60
+ void loadDir('')
61
+ }, [root, loadDir])
62
+
63
+ // 刷新令牌:重新读取已展开过的目录,保留展开状态
64
+ const dirsRef = useRef(dirs)
65
+ dirsRef.current = dirs
66
+ useEffect(() => {
67
+ if (!root || props.refreshToken === undefined) return
68
+ if (Object.keys(dirsRef.current).length === 0) return
69
+ for (const path of Object.keys(dirsRef.current)) void loadDir(path)
70
+ }, [props.refreshToken, root, loadDir])
71
+
72
+ useEffect(() => {
73
+ if (props.activePath) setSelected({ path: props.activePath, kind: 'file' })
74
+ }, [props.activePath])
75
+
76
+ useEffect(() => {
77
+ if (menu === null) return undefined
78
+ const close = () => setMenu(null)
79
+ window.addEventListener('mousedown', close)
80
+ window.addEventListener('blur', close)
81
+ return () => {
82
+ window.removeEventListener('mousedown', close)
83
+ window.removeEventListener('blur', close)
84
+ }
85
+ }, [menu])
86
+
87
+ const toggleDir = (path) => {
88
+ setSelected({ path, kind: 'dir' })
89
+ setExpanded((current) => {
90
+ const next = { ...current, [path]: !current[path] }
91
+ return next
92
+ })
93
+ if (dirs[path] === undefined || dirs[path].status === 'error') void loadDir(path)
94
+ }
95
+
96
+ const selectedDir = () => {
97
+ if (selected === null) return ''
98
+ return selected.kind === 'dir' ? selected.path : projectDirOf(selected.path)
99
+ }
100
+
101
+ const selectedPaths = () => (multi.length > 0 ? multi.slice() : selected === null ? [] : [selected.path])
102
+
103
+ const copySelection = (paths) => {
104
+ if (!Array.isArray(paths) || paths.length === 0) return
105
+ setClipboard({ entries: paths.slice() })
106
+ setMenuError('')
107
+ }
108
+
109
+ const pasteInto = async (dir) => {
110
+ if (clipboard === null || clipboard.entries.length === 0) return
111
+ const target = typeof dir === 'string' ? dir : ''
112
+ try {
113
+ await G.api('fs.copy', { root, from: clipboard.entries, to: target })
114
+ setMenuError('')
115
+ await loadDir(target)
116
+ await loadDir('')
117
+ props.onChanged?.()
118
+ } catch (error) {
119
+ setMenuError(G.errorText(error))
120
+ }
121
+ }
122
+
123
+ const revealPath = (path) => {
124
+ void G.api('fs.reveal', { root, path }).catch((error) => setMenuError(G.errorText(error)))
125
+ }
126
+
127
+ const rememberKind = (entry) => {
128
+ try {
129
+ kindByPathRef.current.set(entry.path, entry.kind)
130
+ } catch {
131
+ /* ignore */
132
+ }
133
+ }
134
+
135
+ const toggleMulti = (entry) => {
136
+ rememberKind(entry)
137
+ // ⌘/Ctrl+点击:把「当前单选的文件」也并入多选,再切换本次点击项
138
+ setMulti((current) => {
139
+ const base = current.length > 0
140
+ ? current
141
+ : selected !== null && selected.path !== entry.path && selected.path !== undefined
142
+ ? [selected.path]
143
+ : []
144
+ return base.includes(entry.path) ? base.filter((item) => item !== entry.path) : [...base, entry.path]
145
+ })
146
+ setSelected({ path: entry.path, kind: entry.kind })
147
+ }
148
+
149
+ const selectRangeTo = (entry) => {
150
+ for (const row of flatRows) kindByPathRef.current.set(row.path, row.kind)
151
+ if (selected === null) {
152
+ setMulti([entry.path])
153
+ return
154
+ }
155
+ const from = flatRows.findIndex((row) => row.path === selected.path)
156
+ const to = flatRows.findIndex((row) => row.path === entry.path)
157
+ if (from === -1 || to === -1) {
158
+ setMulti([entry.path])
159
+ return
160
+ }
161
+ const [start, end] = from <= to ? [from, to] : [to, from]
162
+ setMulti(flatRows.slice(start, end + 1).map((row) => row.path))
163
+ }
164
+
165
+ const openContextMenu = (event, entry) => {
166
+ event.preventDefault()
167
+ event.stopPropagation()
168
+ const inside = multi.includes(entry.path)
169
+ const paths = inside && multi.length > 0 ? multi.slice() : [entry.path]
170
+ if (!(inside && multi.length > 1)) setMulti([entry.path])
171
+ setSelected({ path: entry.path, kind: entry.kind })
172
+ const entries = paths.map((path) => {
173
+ const remembered = kindByPathRef.current.get(path)
174
+ const row = flatRows.find((item) => item.path === path)
175
+ return { path, kind: remembered ?? (row === undefined ? 'file' : row.kind) }
176
+ })
177
+ kindByPathRef.current.set(entry.path, entry.kind)
178
+ const pos = clampMenuPosition(event.clientX, event.clientY, 240, 320)
179
+ setMenu({ x: pos.x, y: pos.y, path: entry.path, kind: entry.kind, entries })
180
+ }
181
+
182
+ const renderRows = (path, depth) => {
183
+ const state = dirs[path]
184
+ if (state === undefined) {
185
+ return [h('div', { key: `${path}:idle`, className: 'gufa-hint', style: { padding: '4px 12px' } }, t('explorer.loading'))]
186
+ }
187
+ if (state.status === 'loading') {
188
+ return [h('div', { key: `${path}:loading`, className: 'gufa-hint', style: { padding: '4px 12px', display: 'flex', gap: 8, alignItems: 'center' } }, h(G.Spinner), t('explorer.loading'))]
189
+ }
190
+ if (state.status === 'error') {
191
+ return [h('div', { key: `${path}:error`, className: 'gufa-error-banner' }, state.error)]
192
+ }
193
+ if (state.entries.length === 0) {
194
+ return [h('div', { key: `${path}:empty`, className: 'gufa-hint', style: { padding: '4px 12px' } }, t('explorer.empty'))]
195
+ }
196
+ const rows = []
197
+ for (const entry of state.entries) {
198
+ const isDir = entry.kind === 'dir'
199
+ const isExpanded = expanded[entry.path] === true
200
+ const status = props.gitStatuses?.[entry.path]
201
+ rows.push(
202
+ h(
203
+ 'button',
204
+ {
205
+ key: entry.path,
206
+ type: 'button',
207
+ className: 'gufa-tree-row',
208
+ 'data-selected': multi.length > 0 ? (multi.includes(entry.path) ? '' : undefined) : selected !== null && selected.path === entry.path ? '' : undefined,
209
+ 'data-kind': entry.kind,
210
+ 'data-git-status': status !== undefined ? status : undefined,
211
+ style: { paddingLeft: 8 + depth * 14 },
212
+ title: entry.path,
213
+ onClick: (event) => {
214
+ rememberKind(entry)
215
+ if (event.metaKey || event.ctrlKey) {
216
+ event.preventDefault()
217
+ toggleMulti(entry)
218
+ return
219
+ }
220
+ if (event.shiftKey) {
221
+ event.preventDefault()
222
+ selectRangeTo(entry)
223
+ return
224
+ }
225
+ setMulti([])
226
+ setSelected({ path: entry.path, kind: entry.kind })
227
+ if (isDir) toggleDir(entry.path)
228
+ else props.onOpenFile?.(entry.path)
229
+ },
230
+ onContextMenu: (event) => openContextMenu(event, entry),
231
+ },
232
+ h(
233
+ 'span',
234
+ { className: 'gufa-tree-chevron' },
235
+ isDir ? h(G.Icon, { name: isExpanded ? 'chevron-down' : 'chevron-right', size: 12 }) : null,
236
+ ),
237
+ h('span', { className: 'gufa-tree-icon' }, h(G.Icon, { name: isDir ? (isExpanded ? 'folder-open' : 'folder') : 'file', size: 14 })),
238
+ h('span', { className: 'gufa-tree-name' }, entry.name),
239
+ isDir && changedDirs.has(entry.path) ? h('span', { className: 'gufa-tree-problem', 'data-level': 'dir', title: t('explorer.changedDir') }) : null,
240
+ !isDir && problems[entry.path] !== undefined
241
+ ? h('span', {
242
+ className: 'gufa-tree-problem',
243
+ 'data-level': problems[entry.path].errors > 0 ? undefined : 'warning',
244
+ title: `${problems[entry.path].errors} error(s), ${problems[entry.path].warnings} warning(s)`,
245
+ })
246
+ : null,
247
+ status ? h('span', { className: 'gufa-tree-badge', title: status }, status) : null,
248
+ ),
249
+ )
250
+ if (isDir && isExpanded) rows.push(...renderRows(entry.path, depth + 1))
251
+ }
252
+ return rows
253
+ }
254
+
255
+ return h(
256
+ 'div',
257
+ {
258
+ style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, outline: 'none' },
259
+ tabIndex: 0,
260
+ onKeyDown: (event) => {
261
+ if (event.key === 'Enter') {
262
+ if (selected === null) return
263
+ event.preventDefault()
264
+ props.onRename?.(selected.path)
265
+ return
266
+ }
267
+ const mod = event.metaKey || event.ctrlKey
268
+ if (!mod || event.altKey) {
269
+ if (event.key === 'Delete' && selected !== null) {
270
+ event.preventDefault()
271
+ props.onDelete?.(selected.path)
272
+ }
273
+ return
274
+ }
275
+ const key = typeof event.key === 'string' ? event.key.toLowerCase() : ''
276
+ if (key === 'c') {
277
+ event.preventDefault()
278
+ copySelection(selectedPaths())
279
+ } else if (key === 'v' && clipboard !== null) {
280
+ event.preventDefault()
281
+ void pasteInto(selectedDir())
282
+ } else if (key === 'backspace' || event.key === 'Delete') {
283
+ event.preventDefault()
284
+ if (selected !== null) props.onDelete?.(selected.path)
285
+ }
286
+ },
287
+ },
288
+ h(
289
+ 'div',
290
+ { className: 'gufa-panel-head' },
291
+ h('span', { className: 'gufa-panel-head-title' }, t('activity.explorer')),
292
+ multi.length > 1 ? h('span', { className: 'gufa-badge' }, String(multi.length)) : null,
293
+ h(G.IconButton, { title: t('explorer.newFile'), onClick: () => props.onNewFile?.(selectedDir()) }, h(G.Icon, { name: 'plus', size: 14 })),
294
+ h(G.IconButton, { title: t('explorer.newFolder'), onClick: () => props.onNewFolder?.(selectedDir()) }, h(G.Icon, { name: 'folder', size: 14 })),
295
+ h(G.IconButton, { title: t('explorer.refresh'), onClick: () => void loadDir('') }, h(G.Icon, { name: 'refresh', size: 13 })),
296
+ ),
297
+ h(
298
+ 'div',
299
+ { className: 'gufa-panel-body gufa-scroll' },
300
+ menuError !== ''
301
+ ? h('div', { className: 'gufa-error-banner' }, h(G.Icon, { name: 'warn', size: 13 }), h('span', { style: { flex: '1 1 auto' } }, menuError), h(G.IconButton, { title: t('common.close'), onClick: () => setMenuError('') }, h(G.Icon, { name: 'close', size: 11 })))
302
+ : null,
303
+ root ? h('div', { className: 'gufa-tree' }, renderRows('', 0)) : h(G.Empty, { icon: 'folder', title: t('explorer.openProjectFirst') }),
304
+ ),
305
+ menu === null
306
+ ? null
307
+ : (() => {
308
+ const entries = Array.isArray(menu.entries) && menu.entries.length > 0 ? menu.entries : [{ path: menu.path, kind: menu.kind }]
309
+ const files = entries.filter((item) => item.kind === 'file')
310
+ const single = entries.length === 1
311
+ const targetDir = entries.length === 1 && menu.kind === 'dir' ? menu.path : projectDirOf(menu.path)
312
+ const closeAnd = (fn) => () => {
313
+ setMenu(null)
314
+ fn()
315
+ }
316
+ return h(
317
+ 'div',
318
+ {
319
+ className: 'gufa-menu',
320
+ style: { left: menu.x, top: menu.y, position: 'fixed' },
321
+ onMouseDown: (event) => event.stopPropagation(),
322
+ },
323
+ single && menu.kind === 'dir'
324
+ ? h(G.MenuItem, { icon: 'plus', label: t('explorer.newFile'), onClick: closeAnd(() => props.onNewFile?.(menu.path)) })
325
+ : null,
326
+ single && menu.kind === 'dir'
327
+ ? h(G.MenuItem, { icon: 'folder', label: t('explorer.newFolder'), onClick: closeAnd(() => props.onNewFolder?.(menu.path)) })
328
+ : null,
329
+ entries.length === 2 && files.length === 2 && files.every((item) => item.kind === 'file')
330
+ ? h(G.MenuItem, {
331
+ icon: 'code',
332
+ label: t('explorer.compareSelected'),
333
+ onClick: closeAnd(() => props.onCompareFiles?.(files[0].path, files[1].path)),
334
+ })
335
+ : null,
336
+ h(G.MenuItem, { icon: 'files', label: t('explorer.copy'), onClick: closeAnd(() => copySelection(entries.map((item) => item.path))) }),
337
+ clipboard !== null
338
+ ? h(G.MenuItem, { icon: 'folder-open', label: t('explorer.paste'), onClick: closeAnd(() => void pasteInto(targetDir)) })
339
+ : null,
340
+ single
341
+ ? h(G.MenuItem, { icon: 'folder-open', label: t('explorer.revealInFinder'), onClick: closeAnd(() => revealPath(menu.path)) })
342
+ : null,
343
+ single && menu.kind !== 'dir'
344
+ ? h(G.MenuItem, { icon: 'pencil', label: t('explorer.rename'), onClick: closeAnd(() => props.onRename?.(menu.path)) })
345
+ : null,
346
+ single
347
+ ? h(G.MenuItem, { icon: 'trash', label: t('explorer.delete'), danger: true, onClick: closeAnd(() => props.onDelete?.(menu.path)) })
348
+ : null,
349
+ )
350
+ })(),
351
+ )
352
+ }
353
+
354
+ /** 菜单定位:保证不超出视口(右下角靠近边缘时向内收)。 */
355
+ function clampMenuPosition(x, y, width, height) {
356
+ const vw = typeof window !== 'undefined' ? window.innerWidth : 1440
357
+ const vh = typeof window !== 'undefined' ? window.innerHeight : 900
358
+ return {
359
+ x: Math.max(8, Math.min(x, vw - width - 8)),
360
+ y: Math.max(8, Math.min(y, vh - height - 8)),
361
+ }
362
+ }
363
+
364
+ /** 选中路径所在目录(目录本身返回自身)。 */
365
+ function projectDirOf(path) {
366
+ if (!path) return ''
367
+ const parts = path.split('/')
368
+ return parts.length > 1 ? parts.slice(0, -1).join('/') : ''
369
+ }
370
+
371
+ G.MenuItem = function MenuItem(props) {
372
+ return h(
373
+ 'button',
374
+ {
375
+ type: 'button',
376
+ className: 'gufa-menu-item',
377
+ 'data-danger': props.danger ? '' : undefined,
378
+ onMouseDown: (event) => event.stopPropagation(),
379
+ onClick: props.onClick,
380
+ },
381
+ props.icon ? h(G.Icon, { name: props.icon, size: 14 }) : null,
382
+ props.label,
383
+ )
384
+ }
385
+
386
+ /* ── 源代码管理(Git) ─────────────────────────────────────────────────── */
387
+ G.GitPanel = function GitPanel(props) {
388
+ const t = G.useT()
389
+ const root = props.root
390
+ const [status, setStatus] = useState(null)
391
+ const [error, setError] = useState('')
392
+ const [pending, setPending] = useState(false)
393
+ const [message, setMessage] = useState('')
394
+ const [info, setInfo] = useState('')
395
+
396
+ /* 操作结果提示(已推送 / 拉取完成 等)自动消失 */
397
+ useEffect(() => {
398
+ if (info === '') return undefined
399
+ const timer = window.setTimeout(() => setInfo(''), 1500)
400
+ return () => window.clearTimeout(timer)
401
+ }, [info])
402
+ const [discardArmed, setDiscardArmed] = useState(false)
403
+ const [headMenu, setHeadMenu] = useState(null)
404
+ const [rowMenu, setRowMenu] = useState(null)
405
+
406
+ /* 回调放 ref:避免父级每次重渲染都换 refresh 身份,导致刷新 effect 无限重置 → 一直「加载中」 */
407
+ const onStatusRef = useRef(props.onStatus)
408
+ onStatusRef.current = props.onStatus
409
+ const rootRef = useRef(root)
410
+ rootRef.current = root
411
+
412
+ const refresh = useCallback(async () => {
413
+ const target = rootRef.current
414
+ if (!target) {
415
+ setStatus(null)
416
+ return
417
+ }
418
+ setError('')
419
+ try {
420
+ const value = await G.api('git.status', { root: target })
421
+ setStatus(value)
422
+ try {
423
+ onStatusRef.current?.(value)
424
+ } catch {
425
+ /* 通知失败不影响面板 */
426
+ }
427
+ } catch (err) {
428
+ setError(G.errorText(err))
429
+ // 失败也不要停在「加载中」:给一个空状态让错误可见
430
+ setStatus((current) => current ?? { isGit: true, branch: '', ahead: 0, behind: 0, changes: [] })
431
+ }
432
+ }, [])
433
+
434
+ /* 项目变化:清空并重新拉取 */
435
+ useEffect(() => {
436
+ setStatus(null)
437
+ setMessage('')
438
+ void refresh()
439
+ }, [root, refresh])
440
+
441
+ /* 外部刷新令牌:保留当前内容,仅后台刷新 */
442
+ useEffect(() => {
443
+ if (props.refreshToken === undefined) return
444
+ void refresh()
445
+ }, [props.refreshToken, refresh])
446
+
447
+ const act = async (fn) => {
448
+ setPending(true)
449
+ setError('')
450
+ setInfo('')
451
+ try {
452
+ await fn()
453
+ await refresh()
454
+ props.onChanged?.()
455
+ } catch (err) {
456
+ setError(G.errorText(err))
457
+ } finally {
458
+ setPending(false)
459
+ }
460
+ }
461
+
462
+ /* 推送 / 拉取:点 ↑/↓ 直接执行 */
463
+ const push = () => void act(async () => {
464
+ await G.api('git.push', { root })
465
+ setInfo(t('git.pushDone'))
466
+ })
467
+ const pull = () => void act(async () => {
468
+ await G.api('git.pull', { root })
469
+ setInfo(t('git.pullDone'))
470
+ })
471
+
472
+ /* 推送 / 拉取(新建分支还没有上游时也能直接发布:Host 会自动 -u <remote> <branch>) */
473
+ const pushBranch = () => void act(async () => {
474
+ await G.api('git.push', { root })
475
+ setInfo(t('git.pushDone'))
476
+ })
477
+ const pullBranch = () => void act(async () => {
478
+ await G.api('git.pull', { root })
479
+ setInfo(t('git.pullDone'))
480
+ })
481
+
482
+ /* 一键撤销所有未暂存改动:第一次点击进入「再点确认」,3 秒后自动取消 */
483
+ const discardAll = () => {
484
+ if (discardArmed !== true) {
485
+ setDiscardArmed(true)
486
+ window.setTimeout(() => setDiscardArmed(false), 3000)
487
+ return
488
+ }
489
+ setDiscardArmed(false)
490
+ void act(async () => {
491
+ await G.api('git.discardAll', { root })
492
+ setInfo(t('git.discardDone'))
493
+ })
494
+ }
495
+
496
+ const commitRef = useRef(null)
497
+ const commit = () => {
498
+ if (root === null || pending) return
499
+ const text = typeof message === 'string' ? message.trim() : ''
500
+ if (text === '') {
501
+ setInfo(t('git.commitNeedsMessage'))
502
+ return
503
+ }
504
+ void act(async () => {
505
+ // 直接点「提交」或按快捷键时:未暂存的改动自动全部暂存,再提交
506
+ const toStage = changes.filter((item) => item.staged !== true).map((item) => item.path)
507
+ if (toStage.length > 0) await G.api('git.stage', { root, paths: toStage })
508
+ await G.api('git.commit', { root, message: text })
509
+ setMessage('')
510
+ setInfo(t('git.commitDone'))
511
+ })
512
+ }
513
+ commitRef.current = commit
514
+
515
+ /* 快捷键 ⌘⇧↵ 触发提交(App 通过总线转发;面板保持挂载,隐藏时也能用) */
516
+ useEffect(() => {
517
+ if (G.bus === undefined || G.bus === null || typeof G.bus.on !== 'function') return undefined
518
+ return G.bus.on('git:commit', () => {
519
+ commitRef.current?.()
520
+ })
521
+ }, [])
522
+
523
+ /* 点击其它位置关闭菜单 */
524
+ useEffect(() => {
525
+ if (headMenu === null && rowMenu === null) return undefined
526
+ const close = () => {
527
+ setHeadMenu(null)
528
+ setRowMenu(null)
529
+ }
530
+ window.addEventListener('mousedown', close)
531
+ window.addEventListener('blur', close)
532
+ return () => {
533
+ window.removeEventListener('mousedown', close)
534
+ window.removeEventListener('blur', close)
535
+ }
536
+ }, [headMenu, rowMenu])
537
+
538
+ /** 菜单项公共动作:执行后关闭菜单。 */
539
+ const menuAction = (fn) => () => {
540
+ setHeadMenu(null)
541
+ setRowMenu(null)
542
+ fn()
543
+ }
544
+
545
+ const copyText = (text) => {
546
+ try {
547
+ void navigator.clipboard?.writeText(text)
548
+ } catch {
549
+ /* ignore */
550
+ }
551
+ }
552
+
553
+ const changes = status?.changes ?? []
554
+ const staged = changes.filter((item) => item.staged)
555
+ const unstaged = changes.filter((item) => !item.staged)
556
+
557
+ return h(
558
+ 'div',
559
+ { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
560
+ h(
561
+ 'div',
562
+ { className: 'gufa-panel-head' },
563
+ h('span', { className: 'gufa-panel-head-title' }, t('activity.git')),
564
+ changes.length > 0 ? h('span', { className: 'gufa-badge' }, String(changes.length)) : null,
565
+ unstaged.length > 0
566
+ ? h(G.IconButton, {
567
+ title: t('git.stageAll'),
568
+ disabled: pending,
569
+ onClick: () => void act(async () => { await G.api('git.stage', { root, paths: unstaged.map((item) => item.path) }) }),
570
+ }, h(G.Icon, { name: 'plus', size: 13 }))
571
+ : null,
572
+ staged.length > 0
573
+ ? h(G.IconButton, {
574
+ title: t('git.unstageAll'),
575
+ disabled: pending,
576
+ onClick: () => void act(async () => { await G.api('git.unstage', { root, paths: staged.map((item) => item.path) }) }),
577
+ }, h(G.Icon, { name: 'minus', size: 13 }))
578
+ : null,
579
+ unstaged.length > 0
580
+ ? h(G.IconButton, {
581
+ title: discardArmed === true ? t('git.discardConfirm') : t('git.discardAll'),
582
+ disabled: pending,
583
+ onClick: discardAll,
584
+ style: { color: 'var(--dsw-alias-state-error-primary, #f85149)' },
585
+ }, h(G.Icon, { name: 'trash', size: 13 }))
586
+ : null,
587
+ h(
588
+ 'button',
589
+ {
590
+ type: 'button',
591
+ className: 'gufa-git-sync',
592
+ 'data-active': (status?.ahead ?? 0) > 0 || status?.hasUpstream !== true ? '' : undefined,
593
+ disabled: pending,
594
+ title: status?.hasUpstream !== true
595
+ ? t('git.pushNoUpstream')
596
+ : `${t('git.push')} · ${t('git.aheadCount')} ${status?.ahead ?? 0} · ${t('git.behindCount')} ${status?.behind ?? 0}`,
597
+ onClick: pushBranch,
598
+ },
599
+ h(G.Icon, { name: 'arrow-up', size: 13 }),
600
+ h('span', { className: 'gufa-git-sync-count' }, status?.hasUpstream === true ? String(status?.ahead ?? 0) : '↑'),
601
+ ),
602
+ status?.hasUpstream === true
603
+ ? h(
604
+ 'button',
605
+ {
606
+ type: 'button',
607
+ className: 'gufa-git-sync',
608
+ 'data-active': (status?.behind ?? 0) > 0 ? '' : undefined,
609
+ disabled: pending,
610
+ title: `${t('git.pull')} · ${t('git.behindCount')} ${status?.behind ?? 0}`,
611
+ onClick: pullBranch,
612
+ },
613
+ h(G.Icon, { name: 'arrow-down', size: 13 }),
614
+ h('span', { className: 'gufa-git-sync-count' }, String(status?.behind ?? 0)),
615
+ )
616
+ : null,
617
+ h(G.IconButton, { title: t('graph.open'), onClick: () => props.onOpenGraph?.() }, h(G.Icon, { name: 'graph', size: 13 })),
618
+ h(G.IconButton, { title: t('git.refresh'), disabled: pending, onClick: () => void refresh() }, h(G.Icon, { name: 'refresh', size: 13 })),
619
+ h(G.IconButton, {
620
+ title: t('git.more'),
621
+ onClick: (event) => {
622
+ const rect = event.currentTarget.getBoundingClientRect()
623
+ setRowMenu(null)
624
+ setHeadMenu({ x: rect.right - 200, y: rect.bottom + 4 })
625
+ },
626
+ }, h('span', { style: { fontSize: 14, lineHeight: 1 } }, '⋯')),
627
+ ),
628
+ h(
629
+ 'div',
630
+ { className: 'gufa-panel-body gufa-scroll', style: { display: 'flex', flexDirection: 'column' } },
631
+ error !== '' ? h('div', { className: 'gufa-error-banner' }, G.Icon ? h(G.Icon, { name: 'warn', size: 13 }) : null, error) : null,
632
+ info !== '' ? h('div', { className: 'gufa-info-banner' }, info) : null,
633
+ !root
634
+ ? h(G.Empty, { icon: 'git', title: t('explorer.openProjectFirst') })
635
+ : status === null
636
+ ? h(G.Empty, { title: t('common.loading') })
637
+ : status.isGit === false
638
+ ? h(G.Empty, { icon: 'git', title: t('git.noRepo'), hint: t('git.noRepoHint') })
639
+ : h(
640
+ 'div',
641
+ { style: { padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 } },
642
+ h(
643
+ 'div',
644
+ {
645
+ className: 'gufa-row',
646
+ style: { color: 'var(--gufa-text-dim)', fontSize: 11.5, cursor: 'pointer' },
647
+ title: t('git.branchSwitchHint'),
648
+ onClick: (event) => props.onOpenBranches?.(event),
649
+ },
650
+ h(G.Icon, { name: 'branch', size: 13 }),
651
+ h('span', { style: { color: 'var(--gufa-text)', fontWeight: 600 } }, status.branch || '—'),
652
+ status.ahead > 0
653
+ ? h('button', {
654
+ type: 'button',
655
+ className: 'gufa-sync',
656
+ title: t('git.pushHint'),
657
+ disabled: pending,
658
+ onClick: (event) => { event.stopPropagation(); push() },
659
+ }, `↑${status.ahead}`)
660
+ : null,
661
+ status.behind > 0
662
+ ? h('button', {
663
+ type: 'button',
664
+ className: 'gufa-sync',
665
+ title: t('git.pullHint'),
666
+ disabled: pending,
667
+ onClick: (event) => { event.stopPropagation(); pull() },
668
+ }, `↓${status.behind}`)
669
+ : null,
670
+ ),
671
+ status?.hasUpstream !== true && status?.branch
672
+ ? h('div', { className: 'gufa-hint', style: { marginTop: 2 } }, t('git.noUpstreamHint'))
673
+ : null,
674
+ changes.length === 0
675
+ ? h(G.Empty, { icon: 'check', title: t('git.noChanges'), hint: '' })
676
+ : null,
677
+ changes.length > 0
678
+ ? h(
679
+ 'textarea',
680
+ {
681
+ className: 'gufa-textarea',
682
+ placeholder: t('git.commitPlaceholder'),
683
+ value: message,
684
+ rows: 2,
685
+ onChange: (event) => setMessage(event.target.value),
686
+ onKeyDown: (event) => {
687
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
688
+ event.preventDefault()
689
+ commit()
690
+ }
691
+ },
692
+ },
693
+ )
694
+ : null,
695
+ changes.length > 0 && unstaged.length > 0
696
+ ? h('div', { className: 'gufa-hint' }, t('git.autoStageHint'))
697
+ : null,
698
+ changes.length > 0
699
+ ? h(
700
+ 'div',
701
+ { className: 'gufa-row', style: { justifyContent: 'flex-end', alignItems: 'center', gap: 8 } },
702
+ staged.length > 0 ? h('span', { className: 'gufa-hint' }, `${t('git.staged')} (${staged.length})`) : null,
703
+ h(G.Button, { disabled: pending || message.trim() === '', primary: true, onClick: commit }, pending ? h(G.Spinner) : null, t('git.commit')),
704
+ )
705
+ : null,
706
+ unstaged.length > 0 ? h('div', { className: 'gufa-hint' }, `${t('git.unstaged')} (${unstaged.length})`) : null,
707
+ ...unstaged.map((change) =>
708
+ h(G.GitChangeRow, {
709
+ key: `u:${change.path}`,
710
+ change,
711
+ pending,
712
+ onOpen: () => props.onOpenDiff?.(change),
713
+ onContextMenu: (event, rowChange) => setRowMenu({ ...clampMenuPosition(event.clientX, event.clientY, 200, 200), change: rowChange }),
714
+ onRevert: () => void act(async () => G.api('git.discard', { root, paths: [change.path] })),
715
+ revertLabel: t('git.discardFile'),
716
+ onAction: () => void act(async () => G.api('git.stage', { root, paths: [change.path] })),
717
+ actionLabel: t('git.stage'),
718
+ actionIcon: 'plus',
719
+ }),
720
+ ),
721
+ staged.length > 0 ? h('div', { className: 'gufa-hint' }, `${t('git.staged')} (${staged.length})`) : null,
722
+ ...staged.map((change) =>
723
+ h(G.GitChangeRow, {
724
+ key: `s:${change.path}`,
725
+ change,
726
+ pending,
727
+ staged: true,
728
+ onOpen: () => props.onOpenDiff?.(change),
729
+ onContextMenu: (event, rowChange) => setRowMenu({ ...clampMenuPosition(event.clientX, event.clientY, 200, 200), change: rowChange }),
730
+ onAction: () => void act(async () => G.api('git.unstage', { root, paths: [change.path] })),
731
+ actionLabel: t('git.unstage'),
732
+ actionIcon: 'minus',
733
+ }),
734
+ ),
735
+ ),
736
+ ),
737
+ headMenu === null
738
+ ? null
739
+ : h(
740
+ 'div',
741
+ {
742
+ className: 'gufa-menu',
743
+ style: { left: headMenu.x, top: headMenu.y, position: 'fixed', minWidth: 210 },
744
+ onMouseDown: (event) => event.stopPropagation(),
745
+ },
746
+ unstaged.length > 0
747
+ ? h(G.MenuItem, {
748
+ icon: 'plus',
749
+ label: t('git.stageAll'),
750
+ onClick: menuAction(() => void act(async () => { await G.api('git.stage', { root, paths: unstaged.map((item) => item.path) }) })),
751
+ })
752
+ : null,
753
+ staged.length > 0
754
+ ? h(G.MenuItem, {
755
+ icon: 'minus',
756
+ label: t('git.unstageAll'),
757
+ onClick: menuAction(() => void act(async () => { await G.api('git.unstage', { root, paths: staged.map((item) => item.path) }) })),
758
+ })
759
+ : null,
760
+ unstaged.length > 0
761
+ ? h(G.MenuItem, {
762
+ icon: 'undo',
763
+ label: t('git.discardAll'),
764
+ danger: true,
765
+ onClick: menuAction(() => void act(async () => { await G.api('git.discardAll', { root }) })),
766
+ })
767
+ : null,
768
+ h(G.MenuItem, {
769
+ icon: 'arrow-left',
770
+ label: t('git.undoLastCommit'),
771
+ onClick: menuAction(() => void act(async () => { await G.api('git.undoLastCommit', { root }) })),
772
+ }),
773
+ h(G.MenuItem, {
774
+ icon: 'graph',
775
+ label: t('graph.open'),
776
+ onClick: menuAction(() => props.onOpenGraph?.()),
777
+ }),
778
+ ),
779
+ rowMenu === null
780
+ ? null
781
+ : h(
782
+ 'div',
783
+ {
784
+ className: 'gufa-menu',
785
+ style: { left: rowMenu.x, top: rowMenu.y, position: 'fixed', minWidth: 190 },
786
+ onMouseDown: (event) => event.stopPropagation(),
787
+ },
788
+ h(G.MenuItem, { icon: 'code', label: t('git.viewDiff'), onClick: menuAction(() => props.onOpenDiff?.(rowMenu.change)) }),
789
+ rowMenu.change.staged === true
790
+ ? h(G.MenuItem, {
791
+ icon: 'minus',
792
+ label: t('git.unstage'),
793
+ onClick: menuAction(() => void act(async () => { await G.api('git.unstage', { root, paths: [rowMenu.change.path] }) })),
794
+ })
795
+ : h(G.MenuItem, {
796
+ icon: 'plus',
797
+ label: t('git.stage'),
798
+ onClick: menuAction(() => void act(async () => { await G.api('git.stage', { root, paths: [rowMenu.change.path] }) })),
799
+ }),
800
+ rowMenu.change.staged !== true
801
+ ? h(G.MenuItem, {
802
+ icon: 'undo',
803
+ label: t('git.discardFile'),
804
+ danger: true,
805
+ onClick: menuAction(() => void act(async () => { await G.api('git.discard', { root, paths: [rowMenu.change.path] }) })),
806
+ })
807
+ : null,
808
+ h(G.MenuItem, { icon: 'files', label: t('git.copyPath'), onClick: menuAction(() => copyText(rowMenu.change.path)) }),
809
+ ),
810
+ )
811
+ }
812
+
813
+ G.GitChangeRow = function GitChangeRow(props) {
814
+ const change = props.change
815
+ return h(
816
+ 'div',
817
+ {
818
+ className: 'gufa-git-change',
819
+ title: `${change.path}(点击查看改动对比)`,
820
+ onClick: () => props.onOpen?.(),
821
+ onContextMenu: (event) => {
822
+ event.preventDefault()
823
+ event.stopPropagation()
824
+ props.onContextMenu?.(event, change)
825
+ },
826
+ style: { cursor: 'pointer' },
827
+ },
828
+ h('span', { className: 'gufa-git-status', 'data-kind': change.status }, change.status),
829
+ h('span', { className: 'gufa-git-path', style: { flex: '1 1 auto' } }, change.path),
830
+ props.staged !== true && typeof props.onRevert === 'function'
831
+ ? h(
832
+ 'button',
833
+ {
834
+ type: 'button',
835
+ className: 'gufa-iconbtn',
836
+ title: props.revertLabel ?? '还原该文件的改动',
837
+ disabled: props.pending,
838
+ onClick: (event) => {
839
+ event.stopPropagation()
840
+ props.onRevert?.()
841
+ },
842
+ },
843
+ h(G.Icon, { name: 'undo', size: 13 }),
844
+ )
845
+ : null,
846
+ h(
847
+ 'button',
848
+ {
849
+ type: 'button',
850
+ className: 'gufa-iconbtn',
851
+ title: props.actionLabel,
852
+ disabled: props.pending,
853
+ onClick: (event) => {
854
+ event.stopPropagation()
855
+ props.onAction?.()
856
+ },
857
+ },
858
+ h(G.Icon, { name: props.actionIcon, size: 13 }),
859
+ ),
860
+ )
861
+ }
862
+
863
+ /* ── SSH 主机 ──────────────────────────────────────────────────────────── */
864
+ G.SshPanel = function SshPanel(props) {
865
+ const t = G.useT()
866
+ const [state, setState] = useState({ status: 'loading', hosts: [], error: '', configPath: '' })
867
+
868
+ const refresh = useCallback(async () => {
869
+ setState((current) => ({ ...current, status: 'loading', error: '' }))
870
+ try {
871
+ const value = await G.api('ssh.hosts', {})
872
+ setState({ status: 'ready', hosts: value.hosts ?? [], error: '', configPath: value.configPath ?? '' })
873
+ } catch (error) {
874
+ setState({ status: 'error', hosts: [], error: G.errorText(error), configPath: '' })
875
+ }
876
+ }, [])
877
+
878
+ useEffect(() => {
879
+ void refresh()
880
+ }, [refresh, props.refreshToken])
881
+
882
+ return h(
883
+ 'div',
884
+ { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
885
+ h(
886
+ 'div',
887
+ { className: 'gufa-panel-head' },
888
+ h('span', { className: 'gufa-panel-head-title' }, t('ssh.hosts')),
889
+ h(G.IconButton, { title: t('ssh.refresh'), onClick: () => void refresh() }, h(G.Icon, { name: 'refresh', size: 13 })),
890
+ ),
891
+ h(
892
+ 'div',
893
+ { className: 'gufa-panel-body gufa-scroll' },
894
+ state.error !== '' ? h('div', { className: 'gufa-error-banner' }, state.error) : null,
895
+ state.status === 'loading'
896
+ ? h(G.Empty, { title: t('common.loading') })
897
+ : state.hosts.length === 0
898
+ ? h(G.Empty, { icon: 'server', title: t('ssh.noHosts'), hint: t('ssh.configHint') })
899
+ : h(
900
+ 'div',
901
+ { style: { padding: '6px 6px', display: 'flex', flexDirection: 'column', gap: 1 } },
902
+ ...state.hosts.map((host) =>
903
+ h(
904
+ 'div',
905
+ { key: host.alias, className: 'gufa-ssh-host', title: t('ssh.connectHint'), onClick: () => props.onConnect?.(host.alias) },
906
+ h(G.Icon, { name: 'server', size: 14 }),
907
+ h('span', { className: 'gufa-ssh-alias' }, host.alias),
908
+ h('span', { className: 'gufa-ssh-hostname' }, [host.user ? `${host.user}@` : '', host.host !== host.alias ? host.host : '', host.port ? `:${host.port}` : ''].join('')),
909
+ h(G.Icon, { name: 'arrow-right', size: 13 }),
910
+ ),
911
+ ),
912
+ ),
913
+ state.configPath ? h('div', { className: 'gufa-hint', style: { padding: '8px 10px' } }, state.configPath) : null,
914
+ ),
915
+ )
916
+ }