dsh-gufa-code 0.0.0-stage → 1.0.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.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -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-editor.jpg +0 -0
  9. package/docs/screenshot-ide.jpg +0 -0
  10. package/host/assets.js +31 -0
  11. package/host/chat.js +523 -0
  12. package/host/context.js +438 -0
  13. package/host/dev.js +35 -0
  14. package/host/fs.js +306 -0
  15. package/host/git.js +532 -0
  16. package/host/project.js +305 -0
  17. package/host/ssh.js +54 -0
  18. package/host/terminal-pty.js +237 -0
  19. package/host/terminal.js +947 -0
  20. package/icon.svg +18 -0
  21. package/index.js +63 -0
  22. package/locale/en.json +121 -0
  23. package/locale/zh.json +121 -0
  24. package/package.json +66 -4
  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,2272 @@
1
+ /**
2
+ * 90-app:古法编程主界面 —— 标题栏 + 活动栏 + 左侧面板 + 编辑器区 + 终端 + 右侧 AI + 状态栏。
3
+ * 这是 main slot(key = gufa-code)渲染的根组件。
4
+ */
5
+
6
+ const GUFA_LS = {
7
+ project: 'gufa-code:project',
8
+ ui: 'gufa-code:ui:v1',
9
+ tabs: 'gufa-code:tabs:v1',
10
+ }
11
+
12
+ function readJson(key) {
13
+ try {
14
+ const raw = localStorage.getItem(key)
15
+ return raw === null ? null : JSON.parse(raw)
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ function writeJson(key, value) {
22
+ try {
23
+ if (value === null) localStorage.removeItem(key)
24
+ else localStorage.setItem(key, JSON.stringify(value))
25
+ } catch {
26
+ /* 隐私模式:忽略持久化失败 */
27
+ }
28
+ }
29
+
30
+ /** 最近会话列表(JSON 字符串,避免 selector 每次返回新数组导致频繁重渲染)。 */
31
+ function selectRecentSessionsJson(state) {
32
+ const sessions = state === null || state === undefined ? [] : Object.values(state.byId ?? {})
33
+ const rows = sessions
34
+ .filter((session) => session !== null && typeof session === 'object' && typeof session.id === 'string' && session.archived !== true)
35
+ .map((session) => ({
36
+ id: session.id,
37
+ title: typeof session.title === 'string' && session.title !== '' ? session.title : String(session.id).slice(0, 12),
38
+ when: typeof session.updatedAt === 'number' ? session.updatedAt : 0,
39
+ cwd: typeof session.projectionValues?.workingDirectory === 'string'
40
+ ? session.projectionValues.workingDirectory
41
+ : typeof session.cwd === 'string' ? session.cwd : '',
42
+ workspaceId: typeof session.workspaceId === 'string'
43
+ ? session.workspaceId
44
+ : typeof session.projectionValues?.workspaceId === 'string' ? session.projectionValues.workspaceId : '',
45
+ archived: session.archived === true || session.projectionValues?.archived === true || typeof session.archivedAt === 'number' || typeof session.archivedAt === 'string',
46
+ blank: session.blank === true || session.projectionValues?.blank === true,
47
+ }))
48
+ .sort((a, b) => b.when - a.when)
49
+ .slice(0, 15)
50
+ try {
51
+ return JSON.stringify(rows)
52
+ } catch {
53
+ return '[]'
54
+ }
55
+ }
56
+
57
+ /** 当前主视图会话 id(切换会话弹窗的高亮)。 */
58
+ function selectMainSessionId(state) {
59
+ const sessions = state === null || state === undefined ? [] : Object.values(state.byId ?? {})
60
+ if (sessions.length === 0) return null
61
+ const main = sessions.find((session) => (session.retainedBy?.mainView ?? 0) > 0)
62
+ return (main ?? sessions[sessions.length - 1])?.id ?? null
63
+ }
64
+
65
+ /** 当前主视图会话的标题(显示在 AI 停靠栏工具条)。 */
66
+ function selectSessionTitle(state) {
67
+ const sessions = state === null || state === undefined ? [] : Object.values(state.byId ?? {})
68
+ if (sessions.length === 0) return null
69
+ const main = sessions.find((session) => (session.retainedBy?.mainView ?? 0) > 0)
70
+ const picked = main ?? sessions[sessions.length - 1]
71
+ const title = picked?.title ?? picked?.projectionValues?.title
72
+ return typeof title === 'string' && title !== '' ? title : null
73
+ }
74
+
75
+ /** 当前主视图会话的工作目录(用于默认打开项目)。 */
76
+ function selectSessionCwd(state) {
77
+ const sessions = state === null || state === undefined ? [] : Object.values(state.byId ?? {})
78
+ if (sessions.length === 0) return null
79
+ const main = sessions.find((session) => (session.retainedBy?.mainView ?? 0) > 0)
80
+ const picked = main ?? sessions[sessions.length - 1]
81
+ return picked?.projectionValues?.workingDirectory ?? picked?.cwd ?? null
82
+ }
83
+
84
+ /** 可拖拽尺寸 hook:返回 [size, onMouseDown, setSize]。 */
85
+ G.useSplitter = function useSplitter(options) {
86
+ const { initial, min, max, axis = 'x', invert = false, storageKey } = options
87
+ const [size, setSize] = useState(() => {
88
+ const stored = storageKey === undefined ? null : Number(localStorage.getItem(storageKey))
89
+ return Number.isFinite(stored) && stored > 0 ? Math.min(max, Math.max(min, stored)) : initial
90
+ })
91
+ const onMouseDown = useCallback((event) => {
92
+ if (event.button !== 0) return
93
+ event.preventDefault()
94
+ const startPosition = axis === 'x' ? event.clientX : event.clientY
95
+ const startSize = size
96
+ let latest = size
97
+ const onMove = (moveEvent) => {
98
+ const position = axis === 'x' ? moveEvent.clientX : moveEvent.clientY
99
+ const delta = (position - startPosition) * (invert ? -1 : 1)
100
+ latest = Math.min(max, Math.max(min, startSize + delta))
101
+ setSize(latest)
102
+ }
103
+ const onUp = () => {
104
+ window.removeEventListener('mousemove', onMove)
105
+ window.removeEventListener('mouseup', onUp)
106
+ document.body.style.cursor = ''
107
+ document.body.style.userSelect = ''
108
+ if (storageKey !== undefined) {
109
+ try {
110
+ localStorage.setItem(storageKey, String(latest))
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ }
115
+ }
116
+ document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'
117
+ document.body.style.userSelect = 'none'
118
+ window.addEventListener('mousemove', onMove)
119
+ window.addEventListener('mouseup', onUp)
120
+ }, [axis, invert, max, min, size, storageKey])
121
+ return [size, onMouseDown, setSize]
122
+ }
123
+
124
+ /** 主界面组件。props 由 slot 框架注入(useSessions 等标准 hook)。 */
125
+ G.GufaApp = function GufaApp(props) {
126
+ const t = G.useT()
127
+ const theme = G.useTheme()
128
+ const problems = G.useProblems()
129
+ const skinBackdrop = typeof G.useSkinBackdrop === 'function' ? G.useSkinBackdrop() : { available: false }
130
+ const solidPalette = typeof G.usePalette === 'function' ? G.usePalette() : null
131
+ useEffect(() => {
132
+ try {
133
+ if (window.__gufaPaletteLogs === undefined) window.__gufaPaletteLogs = 20
134
+ if (window.__gufaPaletteLogs > 0) {
135
+ window.__gufaPaletteLogs -= 1
136
+ void G.api('dev.log', {
137
+ message: `app-palette null=${solidPalette === null || solidPalette === undefined} isDark=${solidPalette === null || solidPalette === undefined ? '?' : solidPalette.isDark === true} bg=${solidPalette === null || solidPalette === undefined ? '?' : String(solidPalette.bg)}`,
138
+ }).catch(() => {})
139
+ }
140
+ } catch {
141
+ /* ignore */
142
+ }
143
+ }, [solidPalette])
144
+ const [backdropOn, setBackdropOn] = useState(() => G.backdropPref.get())
145
+ const [backdropStrength, setBackdropStrength] = useState(() => (
146
+ G.backdropStrengthPref !== null && G.backdropStrengthPref !== undefined && typeof G.backdropStrengthPref.get === 'function'
147
+ ? G.backdropStrengthPref.get()
148
+ : null
149
+ ))
150
+ const backdropActive = backdropOn && skinBackdrop.available === true
151
+
152
+ // slot 标准 props(main keyed slot 始终提供 useSessions)
153
+ const useSessions = props.useSessions
154
+ const sessionCwd = useSessions !== undefined ? useSessions(selectSessionCwd) : null
155
+ const sessionTitle = useSessions !== undefined ? useSessions(selectSessionTitle) : null
156
+ const currentSessionId = useSessions !== undefined ? useSessions(selectMainSessionId) : null
157
+ const [sessionsOpen, setSessionsOpen] = useState(false)
158
+ const recentSessionsJson = useSessions !== undefined ? useSessions(selectRecentSessionsJson) : '[]'
159
+ const recentSessionsRaw = useMemo(() => {
160
+ let parsed
161
+ try {
162
+ parsed = JSON.parse(recentSessionsJson)
163
+ } catch {
164
+ return []
165
+ }
166
+ return Array.isArray(parsed) ? parsed : []
167
+ }, [recentSessionsJson])
168
+
169
+ const [ui, setUi] = useState(() => {
170
+ const saved = readJson(GUFA_LS.ui) ?? {}
171
+ return {
172
+ view: typeof saved.view === 'string' ? saved.view : 'explorer',
173
+ chatOpen: saved.chatOpen !== false,
174
+ terminalOpen: saved.terminalOpen === true,
175
+ sidebarOpen: saved.sidebarOpen !== false,
176
+ }
177
+ })
178
+ const [project, setProject] = useState(null)
179
+ /* 只列出「当前工作区」的会话:经典模式里会话归属工作区,切换工作区后历史应隔离 */
180
+ const recentSessions = useMemo(() => {
181
+ const root = typeof project?.root === 'string' ? project.root : ''
182
+ if (root === '') return recentSessionsRaw
183
+ return recentSessionsRaw.filter((row) => row !== null && typeof row === 'object' && (row.cwd === root || row.cwd === ''))
184
+ }, [recentSessionsRaw, project?.root])
185
+ const [bootError, setBootError] = useState('')
186
+ const [hostStale, setHostStale] = useState(false)
187
+ const [banner, setBanner] = useState('')
188
+ const [tabs, setTabs] = useState([])
189
+ const [activePath, setActivePath] = useState(null)
190
+ const [cursor, setCursor] = useState(null)
191
+ const [refreshToken, setRefreshToken] = useState(0)
192
+ const [gitRefreshToken, setGitRefreshToken] = useState(0)
193
+ const [sshRefreshToken, setSshRefreshToken] = useState(0)
194
+ const [gitStatus, setGitStatus] = useState(null)
195
+ const [modal, setModal] = useState(null)
196
+ const [shortcutsOpen, setShortcutsOpen] = useState(false)
197
+ const [trashUndo, setTrashUndo] = useState([])
198
+ const [bannerTone, setBannerTone] = useState('error')
199
+ const [branchMenu, setBranchMenu] = useState(null)
200
+ const [workspaceDialog, setWorkspaceDialog] = useState(null)
201
+ const [workspaceSessions, setWorkspaceSessions] = useState({ status: 'idle', sessions: [], groups: [], sessionIds: [], archivedSessionIds: [] })
202
+ const [workspaceIndex, setWorkspaceIndex] = useState([])
203
+ const [recentCurrentIds, setRecentCurrentIds] = useState([])
204
+ const [sessionGroupOpen, setSessionGroupOpen] = useState({})
205
+ const [workspaceBusy, setWorkspaceBusy] = useState(false)
206
+ const [branchInput, setBranchInput] = useState('')
207
+ const [modalPending, setModalPending] = useState(false)
208
+ const [modalError, setModalError] = useState('')
209
+ const bootedRef = useRef(false)
210
+ const [explorerWidth, explorerDrag] = G.useSplitter({ initial: 260, min: 190, max: 520, storageKey: 'gufa-code:w:explorer' })
211
+ const [chatWidth, chatDrag] = G.useSplitter({ initial: 430, min: 320, max: 780, axis: 'x', invert: true, storageKey: 'gufa-code:w:chat' })
212
+ const [terminalHeight, terminalDrag] = G.useSplitter({ initial: 230, min: 120, max: 560, axis: 'y', invert: true, storageKey: 'gufa-code:h:terminal' })
213
+
214
+ const patchUi = useCallback((patch) => {
215
+ setUi((current) => {
216
+ const next = { ...current, ...patch }
217
+ writeJson(GUFA_LS.ui, next)
218
+ return next
219
+ })
220
+ }, [])
221
+
222
+ /* ── 项目打开 ──────────────────────────────────────────────────────── */
223
+ const applyProject = useCallback((value) => {
224
+ setProject(value)
225
+ writeJson(GUFA_LS.project, value?.root ?? null)
226
+ setTabs([])
227
+ setActivePath(null)
228
+ setCursor(null)
229
+ setGitStatus(null)
230
+ setRefreshToken((token) => token + 1)
231
+ setGitRefreshToken((token) => token + 1)
232
+ setSshRefreshToken((token) => token + 1)
233
+ }, [])
234
+
235
+ const openProjectPath = useCallback(async (path) => {
236
+ setBootError('')
237
+ const value = await G.api('project.open', { path })
238
+ applyProject(value)
239
+ return value
240
+ }, [applyProject])
241
+
242
+ // 启动:恢复上次项目 → 当前会话工作目录 → 空状态
243
+ useEffect(() => {
244
+ if (bootedRef.current) return
245
+ bootedRef.current = true
246
+ let cancelled = false
247
+ void (async () => {
248
+ const saved = readJson(GUFA_LS.project)
249
+ if (typeof saved === 'string' && saved !== '') {
250
+ try {
251
+ const value = await G.api('project.open', { path: saved })
252
+ if (!cancelled) applyProject(value)
253
+ return
254
+ } catch {
255
+ writeJson(GUFA_LS.project, null)
256
+ }
257
+ }
258
+ if (typeof sessionCwd === 'string' && sessionCwd !== '') {
259
+ try {
260
+ const value = await G.api('project.open', { path: sessionCwd })
261
+ if (!cancelled) applyProject(value)
262
+ } catch (error) {
263
+ if (!cancelled) setBootError(G.errorText(error))
264
+ }
265
+ }
266
+ })()
267
+ return () => {
268
+ cancelled = true
269
+ }
270
+ }, [sessionCwd, applyProject])
271
+
272
+ // 探测 Host 半版本:插件升级后若 Host 仍是旧代码(未重启 Harness),给出明确提示
273
+ useEffect(() => {
274
+ let cancelled = false
275
+ void (async () => {
276
+ try {
277
+ await G.api('dev.version', {})
278
+ if (!cancelled) setHostStale(false)
279
+ } catch (error) {
280
+ if (!cancelled && error !== null && typeof error === 'object' && error.code === 'not-found') setHostStale(true)
281
+ }
282
+ })()
283
+ return () => {
284
+ cancelled = true
285
+ }
286
+ }, [])
287
+
288
+ /* ── Git 状态(状态栏 / 资源管理器徽标) ────────────────────────────── */
289
+ const refreshGit = useCallback(async () => {
290
+ if (project === null || project.isGit !== true) {
291
+ setGitStatus(null)
292
+ return
293
+ }
294
+ try {
295
+ const value = await G.api('git.status', { root: project.root })
296
+ setGitStatus(value)
297
+ } catch {
298
+ setGitStatus(null)
299
+ }
300
+ }, [project])
301
+
302
+ useEffect(() => {
303
+ void refreshGit()
304
+ }, [refreshGit, gitRefreshToken])
305
+
306
+ const gitStatuses = useMemo(() => {
307
+ const map = {}
308
+ for (const change of gitStatus?.changes ?? []) map[change.path] = change.status
309
+ return map
310
+ }, [gitStatus])
311
+
312
+ /* ── 文件打开 / 保存 / 关闭 ─────────────────────────────────────────── */
313
+ const openFile = useCallback(async (path) => {
314
+ if (project === null) return
315
+ const existing = tabs.find((tab) => tab.path === path)
316
+ if (existing !== undefined) {
317
+ setActivePath(path)
318
+ return
319
+ }
320
+ try {
321
+ const doc = await G.api('fs.read', { root: project.root, path })
322
+ setTabs((current) => [...current, { ...doc, savedContent: doc.content, dirty: false, savedRevision: doc.revision }])
323
+ setActivePath(path)
324
+ } catch (error) {
325
+ setBanner(G.errorText(error))
326
+ }
327
+ }, [project, tabs])
328
+
329
+ const activeTab = useMemo(() => tabs.find((tab) => tab.path === activePath) ?? null, [tabs, activePath])
330
+
331
+ const patchTab = useCallback((path, patch) => {
332
+ setTabs((current) => current.map((tab) => (tab.path === path ? { ...tab, ...patch } : tab)))
333
+ }, [])
334
+
335
+ const changeFile = useCallback((path, content) => {
336
+ setTabs((current) => current.map((tab) => (
337
+ tab.path === path ? { ...tab, content, dirty: content !== tab.savedContent } : tab
338
+ )))
339
+ }, [])
340
+
341
+ const saveFile = useCallback(async (path) => {
342
+ if (project === null) return
343
+ const tab = tabs.find((item) => item.path === path)
344
+ if (tab === undefined || tab.dirty !== true) return
345
+ try {
346
+ const result = await G.api('fs.write', {
347
+ root: project.root,
348
+ path: tab.path,
349
+ content: tab.content,
350
+ expectedRevision: tab.revision,
351
+ })
352
+ patchTab(path, { revision: result.revision, savedRevision: result.revision, savedContent: tab.content, dirty: false, size: result.size })
353
+ setBanner('')
354
+ // 内容保存不改变目录结构:只刷新 Git 状态,不重载文件树(避免整页闪烁)
355
+ setGitRefreshToken((token) => token + 1)
356
+ void refreshGit()
357
+ } catch (error) {
358
+ if (error.code === 'revision-conflict') setBanner(t('editor.saveConflict'))
359
+ else setBanner(G.errorText(error))
360
+ }
361
+ }, [project, tabs, patchTab, refreshGit, t])
362
+
363
+ /** 顶栏提示:错误常驻红色;成功用绿色 toast,1.2 秒后自动消失。 */
364
+ const successTimerRef = useRef(null)
365
+ const showBanner = useCallback((text, tone) => {
366
+ if (successTimerRef.current !== null) {
367
+ window.clearTimeout(successTimerRef.current)
368
+ successTimerRef.current = null
369
+ }
370
+ if (tone === 'success') {
371
+ setBannerTone('success')
372
+ setBanner(text)
373
+ successTimerRef.current = window.setTimeout(() => {
374
+ successTimerRef.current = null
375
+ setBanner('')
376
+ }, 1200)
377
+ return
378
+ }
379
+ setBannerTone('error')
380
+ setBanner(text)
381
+ }, [])
382
+ useEffect(() => () => {
383
+ if (successTimerRef.current !== null) window.clearTimeout(successTimerRef.current)
384
+ }, [])
385
+
386
+ const closeTab = useCallback((path) => {
387
+ setTabs((current) => {
388
+ const next = current.filter((tab) => tab.path !== path)
389
+ if (activePath === path) {
390
+ const index = current.findIndex((tab) => tab.path === path)
391
+ const fallback = next[Math.min(index, next.length - 1)]
392
+ setActivePath(fallback?.path ?? null)
393
+ }
394
+ return next
395
+ })
396
+ }, [activePath])
397
+
398
+ const closeTabWithConfirm = useCallback((path) => {
399
+ const tab = tabs.find((item) => item.path === path)
400
+ if (tab !== undefined && tab.dirty === true) {
401
+ setModal({ type: 'closeTab', path })
402
+ return
403
+ }
404
+ closeTab(path)
405
+ }, [tabs, closeTab])
406
+
407
+ /* ── Git Graph(提交图标签页) ─────────────────────────────────────── */
408
+ const openGitGraph = useCallback(() => {
409
+ if (project === null) return
410
+ const path = 'gufa-graph'
411
+ setTabs((current) => {
412
+ if (current.some((tab) => tab !== null && tab.path === path)) return current
413
+ return [...current, { path, title: 'Git Graph', kind: 'graph' }]
414
+ })
415
+ setActivePath(path)
416
+ }, [project])
417
+
418
+ /* Git Graph:点开某次提交里的文件 → 提交版本 diff(父提交 vs 该提交) */
419
+ const openCommitFile = useCallback(async (commit, file) => {
420
+ if (project === null || commit === null || commit === undefined || file === null || file === undefined) return
421
+ if (typeof file.path !== 'string' || file.path === '' || typeof commit.hash !== 'string') return
422
+ try {
423
+ const [leftResult, rightResult] = await Promise.all([
424
+ G.api('git.fileVersion', { root: project.root, path: file.path, hash: commit.hash, parent: true }).catch(() => ({ text: '' })),
425
+ G.api('git.fileVersion', { root: project.root, path: file.path, hash: commit.hash }).catch(() => ({ text: '' })),
426
+ ])
427
+ const path = `gufa-commit:${commit.hash.slice(0, 10)}:${file.path}`
428
+ const leftText = typeof leftResult?.text === 'string' ? leftResult.text : ''
429
+ const rightText = typeof rightResult?.text === 'string' ? rightResult.text : ''
430
+ const tab = {
431
+ path,
432
+ title: `${G.basename(file.path)}(${commit.short ?? commit.hash.slice(0, 7)})`,
433
+ kind: 'diff',
434
+ content: rightText,
435
+ savedContent: rightText,
436
+ dirty: false,
437
+ language: G.languageOfPath(file.path),
438
+ compareLeft: leftText,
439
+ diff: { left: leftText, staged: false, editable: false },
440
+ diffKey: Date.now(),
441
+ }
442
+ setTabs((current) => {
443
+ const exists = current.some((item) => item !== null && item.path === path)
444
+ if (exists) return current.map((item) => (item !== null && item.path === path ? { ...item, ...tab } : item))
445
+ return [...current, tab]
446
+ })
447
+ setActivePath(path)
448
+ setBanner('')
449
+ } catch (error) {
450
+ setBanner(G.errorText(error))
451
+ }
452
+ }, [project])
453
+
454
+ /* 分支切换 / 新建 ──────────────────────────────────────────────── */
455
+ const openBranchMenu = useCallback((event) => {
456
+ if (project === null || project.isGit !== true) return
457
+ let placement = { x: 12, top: 40 }
458
+ try {
459
+ const rect = event !== null && event !== undefined && event.currentTarget !== null
460
+ ? event.currentTarget.getBoundingClientRect()
461
+ : null
462
+ if (rect !== null) {
463
+ const x = Math.max(8, Math.min(rect.left, window.innerWidth - 280))
464
+ // 锚点在屏幕下半部(例如底部状态栏)时向上弹出,避免被视口裁掉
465
+ if (rect.top > window.innerHeight * 0.55) placement = { x, bottom: Math.max(8, window.innerHeight - rect.top + 6) }
466
+ else placement = { x, top: rect.bottom + 6 }
467
+ }
468
+ } catch {
469
+ /* ignore */
470
+ }
471
+ setBranchInput('')
472
+ setBranchMenu({ ...placement, loading: true, branches: [], error: '' })
473
+ void G.api('git.branches', { root: project.root })
474
+ .then((value) => {
475
+ setBranchMenu((current) => (current === null ? current : { ...current, loading: false, branches: Array.isArray(value?.branches) ? value.branches : [] }))
476
+ })
477
+ .catch((error) => {
478
+ setBranchMenu((current) => (current === null ? current : { ...current, loading: false, error: G.errorText(error) }))
479
+ })
480
+ }, [project])
481
+
482
+ const closeBranchMenu = useCallback(() => {
483
+ setBranchMenu(null)
484
+ setBranchInput('')
485
+ }, [])
486
+
487
+ /* 点其它地方 / Esc 关闭分支弹层 */
488
+ useEffect(() => {
489
+ if (branchMenu === null) return undefined
490
+ const onPointerDown = () => closeBranchMenu()
491
+ const onKeyDown = (event) => {
492
+ if (event.key !== 'Escape') return
493
+ event.preventDefault()
494
+ event.stopPropagation()
495
+ closeBranchMenu()
496
+ }
497
+ window.addEventListener('mousedown', onPointerDown)
498
+ window.addEventListener('keydown', onKeyDown, true)
499
+ return () => {
500
+ window.removeEventListener('mousedown', onPointerDown)
501
+ window.removeEventListener('keydown', onKeyDown, true)
502
+ }
503
+ }, [branchMenu, closeBranchMenu])
504
+
505
+ const checkoutBranch = useCallback(async (name) => {
506
+ if (project === null || typeof name !== 'string' || name === '') return
507
+ try {
508
+ await G.api('git.checkout', { root: project.root, branch: name })
509
+ closeBranchMenu()
510
+ showBanner(`已切换到分支 ${name}`, 'success')
511
+ setGitRefreshToken((token) => token + 1)
512
+ setRefreshToken((token) => token + 1)
513
+ void refreshGit()
514
+ } catch (error) {
515
+ setBranchMenu((current) => (current === null ? current : { ...current, error: G.errorText(error) }))
516
+ }
517
+ }, [project, closeBranchMenu, refreshGit])
518
+
519
+ const createBranch = useCallback(async () => {
520
+ if (project === null) return
521
+ const name = branchInput.trim()
522
+ if (name === '') return
523
+ try {
524
+ await G.api('git.createBranch', { root: project.root, name })
525
+ closeBranchMenu()
526
+ showBanner(`已创建并切换到分支 ${name}`, 'success')
527
+ setGitRefreshToken((token) => token + 1)
528
+ setRefreshToken((token) => token + 1)
529
+ void refreshGit()
530
+ } catch (error) {
531
+ setBranchMenu((current) => (current === null ? current : { ...current, error: G.errorText(error) }))
532
+ }
533
+ }, [project, branchInput, closeBranchMenu, refreshGit])
534
+
535
+ /* ── 删除(无确认弹窗,软删除到临时目录,⌘Z 可撤销) ──────────────── */
536
+ const deleteEntry = useCallback(async (path) => {
537
+ if (project === null || typeof path !== 'string' || path === '') return
538
+ try {
539
+ const result = await G.api('fs.trash', { root: project.root, path })
540
+ if (result !== null && result !== undefined && typeof result.id === 'string') {
541
+ setTrashUndo((current) => [...current.slice(-19), { id: result.id, path: result.path ?? path, root: project.root }])
542
+ }
543
+ setBanner('')
544
+ // 文件/目录已删除:关闭对应编辑标签(目录则关闭其下所有文件)
545
+ const removedPath = typeof result?.path === 'string' ? result.path : path
546
+ setTabs((current) => current.filter((tab) => tab !== null && tab.path !== removedPath && !tab.path.startsWith(`${removedPath}/`)))
547
+ setActivePath((current) => {
548
+ if (current === null) return current
549
+ if (current === removedPath || current.startsWith(`${removedPath}/`)) return null
550
+ return current
551
+ })
552
+ setRefreshToken((token) => token + 1)
553
+ setGitRefreshToken((token) => token + 1)
554
+ void refreshGit()
555
+ props?.onChanged?.()
556
+ } catch (error) {
557
+ setBanner(G.errorText(error))
558
+ }
559
+ }, [project, refreshGit])
560
+
561
+ const undoDelete = useCallback(async () => {
562
+ const last = trashUndo.length > 0 ? trashUndo[trashUndo.length - 1] : null
563
+ if (last === null) return
564
+ try {
565
+ await G.api('fs.untrash', { root: last.root, id: last.id, path: last.path })
566
+ setTrashUndo((current) => current.slice(0, -1))
567
+ setBanner('')
568
+ setRefreshToken((token) => token + 1)
569
+ setGitRefreshToken((token) => token + 1)
570
+ void refreshGit()
571
+ } catch (error) {
572
+ setBanner(G.errorText(error))
573
+ }
574
+ }, [trashUndo, refreshGit])
575
+
576
+ /* ⌘Z 撤销删除:编辑器内交给 Monaco、终端内交给 shell,其余位置撤销删除 */
577
+ useEffect(() => {
578
+ const onKeyDown = (event) => {
579
+ if (event.key !== 'z' && event.key !== 'Z') return
580
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return
581
+ if (trashUndo.length === 0) return
582
+ try {
583
+ if (typeof G.isEditorFocused === 'function' && G.isEditorFocused()) return
584
+ const active = document.activeElement
585
+ if (active !== null && active !== undefined && typeof active.closest === 'function') {
586
+ if (active.closest('.xterm') !== null) return
587
+ // 输入框/文本域里 ⌘Z 仍是文本撤销
588
+ if (active.closest('input, textarea, [contenteditable="true"], [contenteditable=""]') !== null) return
589
+ }
590
+ } catch {
591
+ /* ignore */
592
+ }
593
+ event.preventDefault()
594
+ event.stopPropagation()
595
+ void undoDelete()
596
+ }
597
+ window.addEventListener('keydown', onKeyDown, true)
598
+ return () => window.removeEventListener('keydown', onKeyDown, true)
599
+ }, [trashUndo, undoDelete])
600
+
601
+ /* ── Git diff / 两文件对比(Monaco DiffEditor) ────────────────────── */
602
+ const openDiffForChange = useCallback(async (change) => {
603
+ if (project === null || change === null || typeof change !== 'object' || typeof change.path !== 'string') return
604
+ const existing = tabs.find((item) => item.path === change.path)
605
+ if (existing !== undefined && existing !== null && existing.kind !== 'diff' && existing.dirty === true) {
606
+ setBanner('该文件有未保存修改,请先保存后再查看 diff')
607
+ return
608
+ }
609
+ try {
610
+ const file = await G.api('fs.read', { root: project.root, path: change.path })
611
+ let left = ''
612
+ try {
613
+ const shown = await G.api('git.show', {
614
+ root: project.root,
615
+ path: change.path,
616
+ rev: change.staged === true ? 'head' : 'index',
617
+ })
618
+ left = typeof shown.text === 'string' ? shown.text : ''
619
+ } catch {
620
+ left = ''
621
+ }
622
+ const tab = {
623
+ path: change.path,
624
+ title: `${G.basename(change.path)}(${change.staged === true ? '暂存区' : '工作树'})`,
625
+ kind: 'diff',
626
+ content: file.content ?? '',
627
+ savedContent: file.content ?? '',
628
+ revision: file.revision,
629
+ dirty: false,
630
+ language: file.language ?? 'plaintext',
631
+ size: file.size,
632
+ diff: { left, staged: change.staged === true, editable: change.staged !== true },
633
+ diffKey: Date.now(),
634
+ }
635
+ setTabs((current) => {
636
+ const index = current.findIndex((item) => item.path === change.path)
637
+ if (index === -1) return [...current, tab]
638
+ const next = current.slice()
639
+ next[index] = { ...next[index], ...tab }
640
+ return next
641
+ })
642
+ setActivePath(change.path)
643
+ setBanner('')
644
+ } catch (error) {
645
+ setBanner(G.errorText(error))
646
+ }
647
+ }, [project, tabs])
648
+
649
+ const compareFiles = useCallback(async (leftPath, rightPath) => {
650
+ if (project === null || leftPath === rightPath) return
651
+ try {
652
+ const [leftFile, rightFile] = await Promise.all([
653
+ G.api('fs.read', { root: project.root, path: leftPath }),
654
+ G.api('fs.read', { root: project.root, path: rightPath }),
655
+ ])
656
+ const path = `gufa-diff:${leftPath}::${rightPath}`
657
+ const tab = {
658
+ path,
659
+ title: `${G.basename(leftPath)} ↔ ${G.basename(rightPath)}`,
660
+ kind: 'diff',
661
+ content: rightFile.content ?? '',
662
+ savedContent: rightFile.content ?? '',
663
+ dirty: false,
664
+ language: rightFile.language ?? 'plaintext',
665
+ size: rightFile.size,
666
+ compareLeft: leftFile.content ?? '',
667
+ compareSavedLeft: leftFile.content ?? '',
668
+ leftRevision: leftFile.revision,
669
+ rightRevision: rightFile.revision,
670
+ diff: { left: leftFile.content ?? '', leftPath, rightPath, staged: false, editable: true, compare: true },
671
+ diffKey: Date.now(),
672
+ }
673
+ setTabs((current) => {
674
+ const exists = current.find((item) => item.path === path)
675
+ if (exists !== undefined) return current.map((item) => (item.path === path ? { ...item, ...tab } : item))
676
+ return [...current, tab]
677
+ })
678
+ setActivePath(path)
679
+ setBanner('')
680
+ } catch (error) {
681
+ setBanner(G.errorText(error))
682
+ }
683
+ }, [project])
684
+
685
+ /* Diff 工具条:还原当前文件改动 / 暂存当前文件 */
686
+ const revertDiffFile = useCallback(async (path) => {
687
+ if (project === null) return
688
+ try {
689
+ await G.api('git.discard', { root: project.root, paths: [path] })
690
+ showBanner('已还原该文件的改动', 'success')
691
+ // 改动已还原:关掉该 diff 标签(内容已与暂存区一致)
692
+ setTabs((current) => current.filter((tab) => tab.path !== path))
693
+ setActivePath((current) => (current === path ? null : current))
694
+ setGitRefreshToken((token) => token + 1)
695
+ setRefreshToken((token) => token + 1)
696
+ void refreshGit()
697
+ } catch (error) {
698
+ setBanner(G.errorText(error))
699
+ }
700
+ }, [project, refreshGit])
701
+
702
+ const stageDiffFile = useCallback(async (path) => {
703
+ if (project === null) return
704
+ try {
705
+ await G.api('git.stage', { root: project.root, paths: [path] })
706
+ showBanner('已暂存该文件', 'success')
707
+ setGitRefreshToken((token) => token + 1)
708
+ void refreshGit()
709
+ } catch (error) {
710
+ setBanner(G.errorText(error))
711
+ }
712
+ }, [project, refreshGit])
713
+
714
+ /* 两文件对比:左右两侧都可编辑,⌘S / 自动保存时写回两个文件 */
715
+ const saveCompareTab = useCallback(async (path) => {
716
+ if (project === null) return
717
+ const tab = tabs.find((item) => item.path === path)
718
+ if (tab === undefined || tab.kind !== 'diff' || tab.diff === null || tab.diff === undefined || tab.diff.compare !== true) return
719
+ const leftChanged = String(tab.compareLeft ?? '') !== String(tab.compareSavedLeft ?? '')
720
+ const rightChanged = String(tab.content ?? '') !== String(tab.savedContent ?? '')
721
+ if (!leftChanged && !rightChanged) return
722
+ try {
723
+ const patch = {}
724
+ if (leftChanged && typeof tab.diff.leftPath === 'string') {
725
+ const result = await G.api('fs.write', { root: project.root, path: tab.diff.leftPath, content: tab.compareLeft ?? '', expectedRevision: tab.leftRevision })
726
+ patch.leftRevision = result.revision
727
+ patch.compareSavedLeft = tab.compareLeft ?? ''
728
+ }
729
+ if (rightChanged && typeof tab.diff.rightPath === 'string') {
730
+ const result = await G.api('fs.write', { root: project.root, path: tab.diff.rightPath, content: tab.content ?? '', expectedRevision: tab.rightRevision })
731
+ patch.rightRevision = result.revision
732
+ patch.revision = result.revision
733
+ patch.savedContent = tab.content ?? ''
734
+ }
735
+ patch.dirty = false
736
+ patchTab(path, patch)
737
+ setBanner('')
738
+ setGitRefreshToken((token) => token + 1)
739
+ void refreshGit()
740
+ } catch (error) {
741
+ setBanner(error.code === 'revision-conflict' ? t('editor.saveConflict') : G.errorText(error))
742
+ }
743
+ }, [project, tabs, patchTab, refreshGit, t])
744
+
745
+ /* 自动保存:dirty 后 700ms 静默保存(普通文件 / 工作树 diff / 两文件对比) */
746
+ const saveFileRef = useRef(null)
747
+ const saveCompareRef = useRef(null)
748
+ saveFileRef.current = saveFile
749
+ saveCompareRef.current = saveCompareTab
750
+ const autoSaveTimersRef = useRef(new Map())
751
+ useEffect(() => {
752
+ const timers = autoSaveTimersRef.current
753
+ for (const tab of tabs) {
754
+ if (tab === null || tab.dirty !== true) continue
755
+ if (timers.has(tab.path)) continue
756
+ const timer = window.setTimeout(() => {
757
+ timers.delete(tab.path)
758
+ const isCompare = tab.kind === 'diff' && tab.diff !== null && tab.diff !== undefined && tab.diff.compare === true
759
+ if (isCompare) void saveCompareRef.current?.(tab.path)
760
+ else void saveFileRef.current?.(tab.path)
761
+ }, 700)
762
+ timers.set(tab.path, timer)
763
+ }
764
+ for (const [path, timer] of [...timers]) {
765
+ const tab = tabs.find((item) => item.path === path)
766
+ if (tab === undefined || tab.dirty !== true) {
767
+ window.clearTimeout(timer)
768
+ timers.delete(path)
769
+ }
770
+ }
771
+ return () => {
772
+ for (const timer of timers.values()) window.clearTimeout(timer)
773
+ timers.clear()
774
+ }
775
+ }, [tabs])
776
+
777
+ /* 两文件对比:左右两侧都可编辑(本地草稿,不写回磁盘) */
778
+ const changeDiffSide = useCallback((path, side, value) => {
779
+ setTabs((current) => current.map((tab) => {
780
+ if (tab === null || tab.path !== path || tab.kind !== 'diff') return tab
781
+ if (side === 'left') return { ...tab, compareLeft: value, dirty: true }
782
+ return { ...tab, content: value, dirty: true }
783
+ }))
784
+ }, [])
785
+
786
+ /* ── 右侧 AI 面板与当前工作区保持一致 ─────────────────────────────────
787
+ * ChatDock 停靠的是 DSH「主视图会话」,但主视图会话可能属于别的工作区
788
+ * (重启恢复、手动切换会话后常见)。项目根变化时自动切到当前工作区的会话。 */
789
+ const dockSyncRef = useRef('')
790
+ useEffect(() => {
791
+ if (project === null || typeof project.root !== 'string' || project.root === '') return undefined
792
+ if (dockSyncRef.current === project.root) return undefined
793
+ if (typeof sessionCwd === 'string' && sessionCwd !== '' && sessionCwd === project.root) {
794
+ dockSyncRef.current = project.root
795
+ return undefined
796
+ }
797
+ const timer = window.setTimeout(() => {
798
+ void (async () => {
799
+ try {
800
+ const ui = G.ctx === null || G.ctx === undefined ? undefined : G.ctx.get('uiWorkspace')
801
+ if (ui === undefined || ui === null) return
802
+ const listValue = await G.api('workspaces.list', {})
803
+ const list = Array.isArray(listValue?.workspaces) ? listValue.workspaces : []
804
+ const matched = list.find((item) => item !== null && item.path === project.root)
805
+ if (matched === undefined || typeof matched.id !== 'string') return
806
+ let sessions = []
807
+ try {
808
+ const value = await G.api('workspaces.sessions', { path: project.root, currentSessionId: '' })
809
+ sessions = Array.isArray(value?.sessions) ? value.sessions : []
810
+ } catch {
811
+ sessions = []
812
+ }
813
+ if (sessions.length > 0 && typeof sessions[0].id === 'string' && typeof ui.openSession === 'function') {
814
+ ui.openSession(sessions[0].id)
815
+ } else if (typeof ui.openWorkspace === 'function') {
816
+ await ui.openWorkspace(matched.id)
817
+ }
818
+ dockSyncRef.current = project.root
819
+ } catch {
820
+ /* 同步失败不阻塞界面 */
821
+ }
822
+ })()
823
+ }, 400)
824
+ return () => window.clearTimeout(timer)
825
+ }, [project?.root, sessionCwd])
826
+
827
+ /* ── 进入 IDE 时以「点进来的那个工作区」为准 ─────────────────────────
828
+ * 经典模式下从工作区节点/菜单点「古法编程」时,入口会把目标工作区写进
829
+ * G.pendingEntryWorkspace。这里消费它:
830
+ * - 经典模式当前会话就在这个工作区 → 直接打开该会话(不切换、不打断);
831
+ * - 否则切到该工作区最近的会话,再把 IDE 项目根切过去。 */
832
+ const entryWorkspaceSyncedRef = useRef(false)
833
+ useEffect(() => {
834
+ if (entryWorkspaceSyncedRef.current) return
835
+ if (project === null) return
836
+ const pending = G.pendingEntryWorkspace
837
+ if (pending === null || pending === undefined || typeof pending.key !== 'string') {
838
+ if (typeof sessionCwd !== 'string' || sessionCwd === '') return
839
+ entryWorkspaceSyncedRef.current = true
840
+ if (project.root !== sessionCwd) void openProjectPath(sessionCwd).catch((error) => setBanner(G.errorText(error)))
841
+ return
842
+ }
843
+ entryWorkspaceSyncedRef.current = true
844
+ void (async () => {
845
+ try {
846
+ let targetPath = ''
847
+ try {
848
+ const listValue = await G.api('workspaces.list', {})
849
+ const list = Array.isArray(listValue?.workspaces) ? listValue.workspaces : []
850
+ const matched = list.find((item) => item !== null && item.id === pending.key)
851
+ targetPath = typeof matched?.path === 'string' ? matched.path : ''
852
+ } catch {
853
+ targetPath = ''
854
+ }
855
+ G.pendingEntryWorkspace = null
856
+ const sameWorkspace = typeof sessionCwd === 'string' && sessionCwd !== '' && targetPath !== '' && sessionCwd === targetPath
857
+ if (sameWorkspace) {
858
+ /* 当前正在聊的会话就在这个工作区:直接用它,只把项目根对齐 */
859
+ if (project.root !== targetPath) await openProjectPath(targetPath)
860
+ return
861
+ }
862
+ /* 会话属于别的工作区:切到目标工作区最近的会话,再对齐项目根 */
863
+ try {
864
+ const ui = G.ctx === null || G.ctx === undefined ? undefined : G.ctx.get('uiWorkspace')
865
+ const value = await G.api('workspaces.sessions', { path: targetPath, currentSessionId: '' })
866
+ const rows = Array.isArray(value?.sessions) ? value.sessions : []
867
+ if (rows.length > 0 && typeof rows[0].id === 'string' && ui !== undefined && ui !== null && typeof ui.openSession === 'function') ui.openSession(rows[0].id)
868
+ else if (ui !== undefined && ui !== null && typeof ui.openWorkspace === 'function') await ui.openWorkspace(pending.key)
869
+ } catch {
870
+ /* ignore */
871
+ }
872
+ if (targetPath !== '') await openProjectPath(targetPath)
873
+ } catch (error) {
874
+ setBanner(G.errorText(error))
875
+ }
876
+ })()
877
+ }, [project, sessionCwd, openProjectPath, setBanner])
878
+
879
+ /* ── 已删除文件的标签自动关闭 ──────────────────────────────────────── */
880
+ const pruneMissingTabs = useCallback(async () => {
881
+ if (project === null || typeof project.root !== 'string') return
882
+ const candidates = tabs.filter((tab) => tab !== null && typeof tab.path === 'string'
883
+ && tab.kind !== 'graph'
884
+ && !tab.path.startsWith('gufa-diff:')
885
+ && !tab.path.startsWith('gufa-commit:'))
886
+ if (candidates.length === 0) return
887
+ const missing = []
888
+ await Promise.all(candidates.map(async (tab) => {
889
+ try {
890
+ await G.api('fs.stat', { root: project.root, path: tab.path })
891
+ } catch (error) {
892
+ const code = error !== null && typeof error === 'object' ? error.code ?? error.status : undefined
893
+ const message = error !== null && typeof error === 'object' ? String(error.message ?? '') : ''
894
+ if (code === 'not-found' || code === 404 || /not-found|不存在|ENOENT/.test(message)) missing.push(tab.path)
895
+ }
896
+ }))
897
+ if (missing.length === 0) return
898
+ const missingSet = new Set(missing)
899
+ setTabs((current) => current.filter((tab) => tab !== null && !missingSet.has(tab.path)))
900
+ setActivePath((current) => (current !== null && missingSet.has(current) ? null : current))
901
+ showBanner(`已关闭 ${missing.length} 个已删除文件的标签`, 'success')
902
+ }, [project, tabs, showBanner])
903
+ const pruneMissingTabsRef = useRef(null)
904
+ pruneMissingTabsRef.current = pruneMissingTabs
905
+
906
+ /* 文件树刷新后 / 窗口重新聚焦时检查一次(文件可能在外部被删除) */
907
+ useEffect(() => {
908
+ if (refreshToken === 0) return
909
+ void pruneMissingTabsRef.current?.()
910
+ }, [refreshToken])
911
+ useEffect(() => {
912
+ const onFocus = () => { void pruneMissingTabsRef.current?.() }
913
+ window.addEventListener('focus', onFocus)
914
+ return () => window.removeEventListener('focus', onFocus)
915
+ }, [])
916
+
917
+ /* ── 文件操作(新建 / 重命名 / 删除) ──────────────────────────────── */
918
+ const afterTreeChange = useCallback(() => {
919
+ setRefreshToken((token) => token + 1)
920
+ setGitRefreshToken((token) => token + 1)
921
+ void refreshGit()
922
+ }, [refreshGit])
923
+
924
+ const submitModal = useCallback(async (value) => {
925
+ if (modal === null) return
926
+ setModalPending(true)
927
+ setModalError('')
928
+ try {
929
+ if (modal.type === 'openPath') {
930
+ if (value.trim() === '') throw new Error(t('app.pathPlaceholder'))
931
+ await openProjectPath(value.trim())
932
+ } else if (modal.type === 'newProject') {
933
+ const name = modal.name ?? ''
934
+ if (name.trim() === '') throw new Error(t('app.projectName'))
935
+ const parent = modal.parent ?? ''
936
+ if (parent.trim() === '') throw new Error(t('app.parentFolder'))
937
+ if (project !== null) {
938
+ /* 保持当前项目不变,只新建 */
939
+ }
940
+ const created = await G.api('project.create', { parent: parent.trim(), name: name.trim() })
941
+ applyProject(created)
942
+ } else if (modal.type === 'newFile' || modal.type === 'newFolder') {
943
+ if (project === null) throw new Error(t('explorer.openProjectFirst'))
944
+ if (value.trim() === '') throw new Error(t('explorer.fileName'))
945
+ const target = G.joinPath(modal.dir ?? '', value.trim())
946
+ await G.api('fs.create', { root: project.root, path: target, kind: modal.type === 'newFolder' ? 'dir' : 'file' })
947
+ afterTreeChange()
948
+ if (modal.type === 'newFile') void openFile(target)
949
+ } else if (modal.type === 'rename') {
950
+ if (project === null) throw new Error(t('explorer.openProjectFirst'))
951
+ if (value.trim() === '') throw new Error(t('explorer.rename'))
952
+ const target = G.joinPath(G.dirname(modal.path), value.trim())
953
+ await G.api('fs.rename', { root: project.root, path: modal.path, nextPath: target })
954
+ afterTreeChange()
955
+ setTabs((current) => current.map((tab) => (tab.path === modal.path ? { ...tab, path: target } : tab)))
956
+ if (activePath === modal.path) setActivePath(target)
957
+ } else if (modal.type === 'delete') {
958
+ if (project === null) throw new Error(t('explorer.openProjectFirst'))
959
+ await G.api('fs.delete', { root: project.root, path: modal.path })
960
+ setTabs((current) => current.filter((tab) => tab.path !== modal.path && !tab.path.startsWith(`${modal.path}/`)))
961
+ if (activePath === modal.path) setActivePath(null)
962
+ afterTreeChange()
963
+ } else if (modal.type === 'closeTab') {
964
+ closeTab(modal.path)
965
+ }
966
+ setModal(null)
967
+ } catch (error) {
968
+ setModalError(G.errorText(error))
969
+ } finally {
970
+ setModalPending(false)
971
+ }
972
+ }, [modal, project, activePath, applyProject, afterTreeChange, closeTab, openFile, openProjectPath, t])
973
+
974
+ /* ── 目录选择(复用 Harness 原生选择器,失败回落到手输路径) ────────── */
975
+ const pickDirectory = useCallback(async () => {
976
+ const workspace = G.ctx === null ? undefined : G.ctx.get('uiWorkspace')
977
+ if (workspace !== undefined && workspace !== null && typeof workspace.pickDirectory === 'function') {
978
+ try {
979
+ return await workspace.pickDirectory()
980
+ } catch {
981
+ return null
982
+ }
983
+ }
984
+ return null
985
+ }, [])
986
+
987
+ const chooseAndOpenProject = useCallback(async () => {
988
+ const picked = await pickDirectory()
989
+ if (typeof picked === 'string' && picked !== '') {
990
+ try {
991
+ await openProjectPath(picked)
992
+ setBanner('')
993
+ } catch (error) {
994
+ setBanner(G.errorText(error))
995
+ }
996
+ return
997
+ }
998
+ setModal({ type: 'openPath' })
999
+ }, [openProjectPath, pickDirectory])
1000
+
1001
+ const chooseParentForNewProject = useCallback(async () => {
1002
+ const picked = await pickDirectory()
1003
+ if (typeof picked === 'string' && picked !== '') setModal((current) => ({ ...current, parent: picked }))
1004
+ }, [pickDirectory])
1005
+
1006
+ /* ── 工作区(与经典模式共用 DSH workspaceRegistry / uiWorkspace) ───── */
1007
+ const workspaceService = () => {
1008
+ try {
1009
+ return G.ctx === null || G.ctx === undefined ? undefined : G.ctx.get('uiWorkspace')
1010
+ } catch {
1011
+ return undefined
1012
+ }
1013
+ }
1014
+ const workspacesService = () => {
1015
+ try {
1016
+ return G.ctx === null || G.ctx === undefined ? undefined : G.ctx.get('workspaces')
1017
+ } catch {
1018
+ return undefined
1019
+ }
1020
+ }
1021
+
1022
+ const openWorkspaceDialog = useCallback(async () => {
1023
+ setWorkspaceDialog({ loading: true, available: true, workspaces: [], error: '' })
1024
+ try {
1025
+ const value = await G.api('workspaces.list', {})
1026
+ setWorkspaceDialog({
1027
+ loading: false,
1028
+ available: value?.available !== false,
1029
+ workspaces: Array.isArray(value?.workspaces) ? value.workspaces : [],
1030
+ error: '',
1031
+ })
1032
+ } catch (error) {
1033
+ setWorkspaceDialog({ loading: false, available: false, workspaces: [], error: G.errorText(error) })
1034
+ }
1035
+ }, [])
1036
+
1037
+ const closeWorkspaceDialog = useCallback(() => setWorkspaceDialog(null), [])
1038
+
1039
+ /* 点击左侧工作区名 → 切换已有工作区(与经典模式同一份 workspace 列表) */
1040
+ const switchWorkspace = useCallback(async (workspace) => {
1041
+ if (workspace === null || workspace === undefined) return
1042
+ const service = workspaceService()
1043
+ try {
1044
+ if (service !== undefined && service !== null && typeof service.openWorkspace === 'function' && typeof workspace.id === 'string') {
1045
+ await service.openWorkspace(workspace.id)
1046
+ }
1047
+ /* 右侧 AI 要切到新工作区:打开该工作区最近的一个会话;没有会话就开一个新的空会话 */
1048
+ if (service !== undefined && service !== null && typeof workspace.path === 'string' && workspace.path !== '') {
1049
+ let sessions = []
1050
+ try {
1051
+ const value = await G.api('workspaces.sessions', { path: workspace.path, currentSessionId: '' })
1052
+ sessions = Array.isArray(value?.sessions) ? value.sessions : []
1053
+ } catch {
1054
+ sessions = []
1055
+ }
1056
+ try {
1057
+ if (sessions.length > 0 && typeof sessions[0].id === 'string' && typeof service.openSession === 'function') {
1058
+ service.openSession(sessions[0].id)
1059
+ } else if (typeof workspace.id === 'string' && typeof service.startSession === 'function') {
1060
+ service.startSession(workspace.id)
1061
+ }
1062
+ } catch {
1063
+ /* 会话切换失败不阻塞工作区切换 */
1064
+ }
1065
+ }
1066
+ if (typeof workspace.path === 'string' && workspace.path !== '') {
1067
+ await openProjectPath(workspace.path)
1068
+ }
1069
+ closeWorkspaceDialog()
1070
+ showBanner(`已切换到工作区 ${workspace.title || workspace.path || ''}`, 'success')
1071
+ } catch (error) {
1072
+ setWorkspaceDialog((current) => (current === null ? current : { ...current, error: G.errorText(error) }))
1073
+ }
1074
+ }, [openProjectPath, closeWorkspaceDialog, showBanner])
1075
+
1076
+ /* 新建工作区:走经典模式同样的「选目录 → 创建 workspace → 打开并新建会话」 */
1077
+ const createWorkspace = useCallback(async () => {
1078
+ const ui = workspaceService()
1079
+ const registry = workspacesService()
1080
+ if (ui === undefined || ui === null || typeof ui.pickDirectory !== 'function' || registry === undefined || registry === null || typeof registry.create !== 'function') {
1081
+ setWorkspaceDialog(null)
1082
+ setModal({ type: 'newProject', parent: '', name: '' })
1083
+ return
1084
+ }
1085
+ setWorkspaceBusy(true)
1086
+ try {
1087
+ const directory = await ui.pickDirectory()
1088
+ if (typeof directory !== 'string' || directory === '') return
1089
+ const view = await registry.create({ path: directory })
1090
+ const id = typeof view?.id === 'string' ? view.id : typeof view?.workspace?.id === 'string' ? view.workspace.id : ''
1091
+ if (id !== '' && typeof ui.openWorkspace === 'function') await ui.openWorkspace(id)
1092
+ if (id !== '' && typeof ui.startSession === 'function') {
1093
+ try {
1094
+ ui.startSession(id)
1095
+ } catch {
1096
+ /* 新会话失败不影响工作区创建 */
1097
+ }
1098
+ }
1099
+ await openProjectPath(directory)
1100
+ closeWorkspaceDialog()
1101
+ showBanner('已创建工作区', 'success')
1102
+ } catch (error) {
1103
+ setBanner(G.errorText(error))
1104
+ } finally {
1105
+ setWorkspaceBusy(false)
1106
+ }
1107
+ }, [openProjectPath, closeWorkspaceDialog, showBanner])
1108
+
1109
+ /* ── Esc 关闭所有弹窗/提示框(优先级:模态 > 工作区 > 分支 > 会话切换 > 快捷键) ── */
1110
+ useEffect(() => {
1111
+ const onKeyDown = (event) => {
1112
+ if (event.key !== 'Escape') return
1113
+ const close = () => {
1114
+ event.preventDefault()
1115
+ event.stopPropagation()
1116
+ }
1117
+ if (modal !== null) {
1118
+ close()
1119
+ setModal(null)
1120
+ setModalError('')
1121
+ return
1122
+ }
1123
+ if (workspaceDialog !== null) {
1124
+ close()
1125
+ closeWorkspaceDialog()
1126
+ return
1127
+ }
1128
+ if (branchMenu !== null) {
1129
+ close()
1130
+ closeBranchMenu()
1131
+ return
1132
+ }
1133
+ if (sessionsOpen === true) {
1134
+ close()
1135
+ setSessionsOpen(false)
1136
+ return
1137
+ }
1138
+ if (shortcutsOpen === true) {
1139
+ close()
1140
+ setShortcutsOpen(false)
1141
+ return
1142
+ }
1143
+ }
1144
+ window.addEventListener('keydown', onKeyDown, true)
1145
+ return () => window.removeEventListener('keydown', onKeyDown, true)
1146
+ }, [modal, workspaceDialog, branchMenu, sessionsOpen, shortcutsOpen, closeWorkspaceDialog, closeBranchMenu])
1147
+
1148
+ /* 经典界面里切换工作区时:当前主视图会话的 cwd 变化 → 跟随打开对应项目 */
1149
+ const lastSessionCwdRef = useRef(null)
1150
+ useEffect(() => {
1151
+ if (typeof sessionCwd !== 'string' || sessionCwd === '') return
1152
+ const previous = lastSessionCwdRef.current
1153
+ lastSessionCwdRef.current = sessionCwd
1154
+ if (previous === null || previous === sessionCwd) return
1155
+ if (project !== null && project.root === sessionCwd) return
1156
+ void openProjectPath(sessionCwd).catch((error) => setBanner(G.errorText(error)))
1157
+ }, [sessionCwd, project, openProjectPath])
1158
+
1159
+ /* 记录最近成为「当前会话」的 id:新建空白会话后,列表也要保留上一个对话 */
1160
+ useEffect(() => {
1161
+ if (typeof currentSessionId !== 'string' || currentSessionId === '') return
1162
+ setRecentCurrentIds((current) => (current.includes(currentSessionId) ? current : [currentSessionId, ...current].slice(0, 12)))
1163
+ }, [currentSessionId])
1164
+
1165
+ /* 切换会话弹窗:打开时向 Host 拉取「当前工作区的会话」(经典侧边栏同源数据) */
1166
+ useEffect(() => {
1167
+ if (sessionsOpen !== true) return undefined
1168
+ if (project === null || typeof project.root !== 'string' || project.root === '') {
1169
+ setWorkspaceSessions({ status: 'ready', sessions: [] })
1170
+ return undefined
1171
+ }
1172
+ let cancelled = false
1173
+ setWorkspaceSessions({ status: 'loading', sessions: [], groups: [] })
1174
+ void Promise.all([
1175
+ G.api('workspaces.list', {}).catch(() => ({ workspaces: [] })),
1176
+ G.api('workspaces.sessions', { path: project.root, currentSessionId: currentSessionId ?? '' }).catch(() => ({ groups: [] })),
1177
+ ])
1178
+ .then(([listValue, sessionsValue]) => {
1179
+ if (cancelled) return
1180
+ setWorkspaceIndex(Array.isArray(listValue?.workspaces) ? listValue.workspaces : [])
1181
+ const groups = Array.isArray(sessionsValue?.groups) ? sessionsValue.groups : []
1182
+ try {
1183
+ if (window.__gufaSessionsApiLog === undefined || window.__gufaSessionsApiLog > 0) {
1184
+ window.__gufaSessionsApiLog = (window.__gufaSessionsApiLog ?? 3) - 1
1185
+ void G.api('dev.log', { message: `sessions-api source=${sessionsValue?.source ?? '?'} error=${sessionsValue?.error ?? ''} sessions=${Array.isArray(sessionsValue?.sessions) ? sessionsValue.sessions.length : '?'} list=${JSON.stringify(sessionsValue?.debug?.list ?? {})} roots=${sessionsValue?.debug?.roots ?? '?'} stats=${JSON.stringify(sessionsValue?.debug?.stats ?? {})} cwds=${(sessionsValue?.debug?.sampleCwds ?? []).map((item) => String(item).slice(-28)).join('|')}` }).catch(() => {})
1186
+ }
1187
+ } catch {
1188
+ /* ignore */
1189
+ }
1190
+ const firstGroup = groups.find((group) => group.path === project.root) ?? groups[0]
1191
+ setWorkspaceSessions((previous) => {
1192
+ const nextSessions = Array.isArray(sessionsValue?.sessions)
1193
+ ? sessionsValue.sessions
1194
+ : firstGroup !== undefined && Array.isArray(firstGroup.sessions) ? firstGroup.sessions : []
1195
+ const nextWorkspaceId = typeof sessionsValue?.workspaceId === 'string' ? sessionsValue.workspaceId : ''
1196
+ const sameWorkspace = previous !== null && previous !== undefined && previous.workspaceId !== undefined && previous.workspaceId === nextWorkspaceId
1197
+ const previousSessions = sameWorkspace && Array.isArray(previous?.sessions) ? previous.sessions : []
1198
+ // 某次刷新拿到空列表(服务抖动/标题服务失败)时保留上一次成功结果;切换工作区则不沿用
1199
+ const sessions = nextSessions.length > 0 ? nextSessions : previousSessions
1200
+ const groupsNext = nextSessions.length > 0 ? groups : Array.isArray(previous?.groups) ? previous.groups : []
1201
+ return {
1202
+ status: 'ready',
1203
+ groups: groupsNext,
1204
+ sessions,
1205
+ sessionIds: Array.isArray(sessionsValue?.sessionIds) ? sessionsValue.sessionIds : Array.isArray(previous?.sessionIds) ? previous.sessionIds : [],
1206
+ archivedSessionIds: Array.isArray(sessionsValue?.archivedSessionIds) ? sessionsValue.archivedSessionIds : Array.isArray(previous?.archivedSessionIds) ? previous.archivedSessionIds : [],
1207
+ workspaceId: typeof sessionsValue?.workspaceId === 'string' ? sessionsValue.workspaceId : previous?.workspaceId ?? '',
1208
+ }
1209
+ })
1210
+ })
1211
+ .catch(() => {
1212
+ if (!cancelled) setWorkspaceSessions({ status: 'error', sessions: [], groups: [] })
1213
+ })
1214
+ return () => {
1215
+ cancelled = true
1216
+ }
1217
+ }, [sessionsOpen, project?.root, currentSessionId])
1218
+
1219
+ /* ── 退出(关闭覆盖层,回到 DSH 界面) ─────────────────────────────── */
1220
+ const exitMode = useCallback(() => {
1221
+ G.mode.set(false)
1222
+ }, [])
1223
+
1224
+ // 进入/刷新时确保主视图是 DSH 会话:官方会话必须挂载,才能停靠进右侧 AI 栏
1225
+ useEffect(() => {
1226
+ const layout = G.ctx === null || G.ctx === undefined ? undefined : G.ctx.get('layout')
1227
+ try {
1228
+ layout?.selectPanel?.(null)
1229
+ } catch (error) {
1230
+ console.error('[gufa-code] 确保会话主视图失败', error)
1231
+ }
1232
+ }, [])
1233
+
1234
+ /* ── 终端 / SSH 协作 ───────────────────────────────────────────────── */
1235
+ const openTerminal = useCallback(() => {
1236
+ patchUi({ terminalOpen: true, view: 'terminal' })
1237
+ }, [patchUi])
1238
+
1239
+ const connectSsh = useCallback((alias) => {
1240
+ patchUi({ terminalOpen: true })
1241
+ window.setTimeout(() => G.bus.emit('terminal:send', { text: `ssh ${alias}\r` }), 400)
1242
+ }, [patchUi])
1243
+
1244
+ /* ── 插入 AI 生成代码 ──────────────────────────────────────────────── */
1245
+ const insertCode = useCallback((code) => {
1246
+ G.bus.emit('editor:insert', { text: code })
1247
+ }, [])
1248
+
1249
+ const startNewSession = useCallback(() => {
1250
+ const workspace = G.ctx === null || G.ctx === undefined ? undefined : G.ctx.get('uiWorkspace')
1251
+ if (workspace === undefined || workspace === null || typeof workspace.startSession !== 'function') {
1252
+ setBanner('当前界面不支持新建会话')
1253
+ return
1254
+ }
1255
+ void (async () => {
1256
+ let workspaceId = null
1257
+ try {
1258
+ if (project !== null && typeof project.root === 'string') {
1259
+ const value = await G.api('workspaces.list', {})
1260
+ const list = Array.isArray(value?.workspaces) ? value.workspaces : []
1261
+ const matched = list.find((item) => item !== null && item.path === project.root)
1262
+ if (matched !== undefined && typeof matched.id === 'string') workspaceId = matched.id
1263
+ }
1264
+ } catch {
1265
+ workspaceId = null
1266
+ }
1267
+ try {
1268
+ if (workspaceId !== null) workspace.startSession(workspaceId)
1269
+ else workspace.startSession()
1270
+ setBanner('')
1271
+ } catch (error) {
1272
+ setBanner(G.errorText(error))
1273
+ }
1274
+ })()
1275
+ }, [project])
1276
+
1277
+ const openSession = useCallback((sessionId) => {
1278
+ const workspace = G.ctx === null || G.ctx === undefined ? undefined : G.ctx.get('uiWorkspace')
1279
+ if (workspace === undefined || workspace === null || typeof workspace.openSession !== 'function') {
1280
+ setBanner('当前界面不支持切换会话')
1281
+ return
1282
+ }
1283
+ try {
1284
+ workspace.openSession(sessionId)
1285
+ setBanner('')
1286
+ } catch (error) {
1287
+ setBanner(G.errorText(error))
1288
+ }
1289
+ }, [])
1290
+
1291
+ const activeFileForChat = useMemo(() => {
1292
+ if (activeTab === null || activeTab.kind === 'diff' || activeTab.kind === 'graph') return null
1293
+ return { path: activeTab.path, content: activeTab.content ?? '', language: activeTab.language }
1294
+ }, [activeTab])
1295
+
1296
+ const selectionForChat = useMemo(() => {
1297
+ if (cursor === null || typeof cursor.selectedText !== 'string' || cursor.selectedText === '') return null
1298
+ return { text: cursor.selectedText, path: cursor.path, line: cursor.line, column: cursor.column }
1299
+ }, [cursor])
1300
+
1301
+ /* ── IDE 专属快捷键(参考 VS Code;经典模式下不生效) ──────────────── */
1302
+ const editorFocusedRef = useRef(false)
1303
+ useEffect(() => {
1304
+ if (G.bus === null || G.bus === undefined) return undefined
1305
+ const off = G.bus.on('editor:focus', (focused) => {
1306
+ editorFocusedRef.current = focused === true
1307
+ })
1308
+ return () => {
1309
+ try {
1310
+ off()
1311
+ } catch {
1312
+ /* ignore */
1313
+ }
1314
+ }
1315
+ }, [])
1316
+
1317
+ const ideShortcutHandlers = useMemo(() => ({
1318
+ commit: () => {
1319
+ patchUi({ view: 'git' })
1320
+ if (G.bus !== undefined && G.bus !== null && typeof G.bus.emit === 'function') G.bus.emit('git:commit')
1321
+ },
1322
+ save: () => {
1323
+ const tab = tabs.find((item) => item.path === activePath)
1324
+ if (tab !== undefined && tab !== null && tab.kind === 'graph') return
1325
+ if (tab !== undefined && tab !== null && tab.kind === 'diff') {
1326
+ if (tab.diff !== null && tab.diff !== undefined && tab.diff.compare === true) {
1327
+ if (activePath !== null) void saveCompareTab(activePath)
1328
+ return
1329
+ }
1330
+ if (tab.diff === null || tab.diff === undefined || tab.diff.editable !== true) return
1331
+ }
1332
+ if (activePath !== null) void saveFile(activePath)
1333
+ },
1334
+ clearTerminal: () => {
1335
+ if (ui.terminalOpen !== true) {
1336
+ patchUi({ terminalOpen: true })
1337
+ return
1338
+ }
1339
+ G.bus.emit('terminal:clear')
1340
+ },
1341
+ toggleTerminal: () => patchUi({ terminalOpen: ui.terminalOpen !== true }),
1342
+ toggleSidebar: () => patchUi({ sidebarOpen: ui.sidebarOpen === false }),
1343
+ closeTab: () => {
1344
+ if (activePath !== null) closeTabWithConfirm(activePath)
1345
+ },
1346
+ panelExplorer: () => patchUi({ view: 'explorer', sidebarOpen: true }),
1347
+ panelGit: () => {
1348
+ patchUi({ view: 'git', sidebarOpen: true })
1349
+ setGitRefreshToken((token) => token + 1)
1350
+ },
1351
+ panelSsh: () => {
1352
+ patchUi({ view: 'ssh', sidebarOpen: true })
1353
+ setSshRefreshToken((token) => token + 1)
1354
+ },
1355
+ newTerminal: () => {
1356
+ if (ui.terminalOpen !== true) {
1357
+ patchUi({ terminalOpen: true })
1358
+ return
1359
+ }
1360
+ G.bus.emit('terminal:new')
1361
+ },
1362
+ refresh: () => {
1363
+ setRefreshToken((token) => token + 1)
1364
+ setGitRefreshToken((token) => token + 1)
1365
+ setSshRefreshToken((token) => token + 1)
1366
+ },
1367
+ nextTab: () => {
1368
+ if (tabs.length < 2) return
1369
+ const index = tabs.findIndex((tab) => tab.path === activePath)
1370
+ const next = tabs[(index + 1 + tabs.length) % tabs.length]
1371
+ if (next !== undefined) setActivePath(next.path)
1372
+ },
1373
+ prevTab: () => {
1374
+ if (tabs.length < 2) return
1375
+ const index = tabs.findIndex((tab) => tab.path === activePath)
1376
+ const next = tabs[(index - 1 + tabs.length) % tabs.length]
1377
+ if (next !== undefined) setActivePath(next.path)
1378
+ },
1379
+ shortcutsDialog: () => setShortcutsOpen((open) => open === false),
1380
+ shortcuts: () => {
1381
+ // 编辑器内 ⌘/ 是「切换行注释」(VS Code 一致),其他位置才打开快捷键列表
1382
+ const editorFocused = editorFocusedRef.current || (typeof G.isEditorFocused === 'function' && G.isEditorFocused())
1383
+ try {
1384
+ if (window.__gufaCmdSlashLogs === undefined) window.__gufaCmdSlashLogs = 20
1385
+ if (window.__gufaCmdSlashLogs > 0) {
1386
+ window.__gufaCmdSlashLogs -= 1
1387
+ const active = document.activeElement
1388
+ void G.api('dev.log', {
1389
+ message: `cmd-slash branch editorFocused=${editorFocused} active=${active === null || active === undefined ? 'null' : `${String(active.tagName ?? '?')}.${String(active.className ?? '').slice(0, 60)}`}`,
1390
+ }).catch(() => {})
1391
+ }
1392
+ } catch {
1393
+ /* ignore */
1394
+ }
1395
+ if (editorFocused) {
1396
+ try {
1397
+ const controller = G.__gufaEditorController
1398
+ if (controller !== null && controller !== undefined && typeof controller.comment === 'function') {
1399
+ const ok = controller.comment()
1400
+ void G.api('dev.log', { message: `cmd-slash comment-controller ok=${ok === true}` }).catch(() => {})
1401
+ return
1402
+ }
1403
+ } catch {
1404
+ /* 退回 bus 通道 */
1405
+ }
1406
+ G.bus.emit('editor:command', 'editor.action.commentLine')
1407
+ return
1408
+ }
1409
+ setShortcutsOpen((open) => open === false)
1410
+ },
1411
+ }), [activePath, tabs, ui.terminalOpen, ui.sidebarOpen, saveFile, saveCompareTab, patchUi, closeTabWithConfirm])
1412
+ G.useIdeShortcuts(ideShortcutHandlers)
1413
+
1414
+ /* 原生快捷键服务派发到当前 App 的动作表(IDE 模式内有效)。 */
1415
+ useEffect(() => {
1416
+ const dispatch = (action) => {
1417
+ const handler = ideShortcutHandlers[action]
1418
+ if (typeof handler !== 'function') return
1419
+ handler()
1420
+ try {
1421
+ if (window.__gufaIdeFiredLogs === undefined) window.__gufaIdeFiredLogs = 30
1422
+ if (window.__gufaIdeFiredLogs > 0) {
1423
+ window.__gufaIdeFiredLogs -= 1
1424
+ void G.api('dev.log', { message: `ide-shortcut-fired ${action}` }).catch(() => {})
1425
+ }
1426
+ } catch {
1427
+ /* ignore */
1428
+ }
1429
+ }
1430
+ G.__ideShortcutDispatch = dispatch
1431
+ window.__gufaIdeShortcutDispatch = dispatch
1432
+ return () => {
1433
+ if (G.__ideShortcutDispatch === dispatch) G.__ideShortcutDispatch = null
1434
+ if (window.__gufaIdeShortcutDispatch === dispatch) window.__gufaIdeShortcutDispatch = null
1435
+ }
1436
+ }, [ideShortcutHandlers])
1437
+
1438
+ /* 快捷键说明弹窗:Esc 关闭 */
1439
+ useEffect(() => {
1440
+ if (shortcutsOpen !== true) return undefined
1441
+ const onKey = (event) => {
1442
+ if (event.key !== 'Escape') return
1443
+ event.preventDefault()
1444
+ event.stopPropagation()
1445
+ setShortcutsOpen(false)
1446
+ }
1447
+ window.addEventListener('keydown', onKey, true)
1448
+ return () => window.removeEventListener('keydown', onKey, true)
1449
+ }, [shortcutsOpen])
1450
+
1451
+ /* ── 渲染 ─────────────────────────────────────────────────────────── */
1452
+ const activityItems = [
1453
+ { id: 'explorer', icon: 'files', label: t('activity.explorer'), onClick: () => patchUi({ view: 'explorer', sidebarOpen: true }) },
1454
+ { id: 'git', icon: 'git', label: t('activity.git'), badge: gitStatus?.changes?.length ?? 0, onClick: () => { patchUi({ view: 'git', sidebarOpen: true }); setGitRefreshToken((token) => token + 1) } },
1455
+ { id: 'ssh', icon: 'server', label: t('activity.ssh'), onClick: () => { patchUi({ view: 'ssh', sidebarOpen: true }); setSshRefreshToken((token) => token + 1) } },
1456
+ { id: 'terminal', icon: 'terminal', label: t('activity.terminal'), active: ui.terminalOpen, onClick: () => patchUi({ terminalOpen: !ui.terminalOpen }) },
1457
+ ]
1458
+
1459
+ const leftPanel = h(
1460
+ 'div',
1461
+ { style: { flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' } },
1462
+ h('div', { key: 'explorer', style: { display: ui.view === 'explorer' ? 'flex' : 'none', flex: '1 1 auto', minHeight: 0, flexDirection: 'column' } },
1463
+ h(G.Explorer, {
1464
+ root: project?.root ?? null,
1465
+ activePath,
1466
+ refreshToken,
1467
+ gitStatuses,
1468
+ onOpenFile: (path) => void openFile(path),
1469
+ onChanged: () => {
1470
+ setRefreshToken((token) => token + 1)
1471
+ setGitRefreshToken((token) => token + 1)
1472
+ void refreshGit()
1473
+ },
1474
+ onCompareFiles: (left, right) => void compareFiles(left, right),
1475
+ onNewFile: (dir) => setModal({ type: 'newFile', dir: dir ?? '' }),
1476
+ onNewFolder: (dir) => setModal({ type: 'newFolder', dir: dir ?? '' }),
1477
+ onRename: (path) => setModal({ type: 'rename', path }),
1478
+ onDelete: (path) => void deleteEntry(path),
1479
+ })),
1480
+ h('div', { key: 'git', style: { display: ui.view === 'git' ? 'flex' : 'none', flex: '1 1 auto', minHeight: 0, flexDirection: 'column' } },
1481
+ h(G.GitPanel, {
1482
+ root: project?.root ?? null,
1483
+ refreshToken: gitRefreshToken,
1484
+ onStatus: setGitStatus,
1485
+ onOpenFile: (path) => void openFile(path),
1486
+ onOpenDiff: (change) => void openDiffForChange(change),
1487
+ onOpenBranches: (event) => openBranchMenu(event),
1488
+ onOpenGraph: () => openGitGraph(),
1489
+ onChanged: () => { setRefreshToken((token) => token + 1) },
1490
+ })),
1491
+ h('div', { key: 'ssh', style: { display: ui.view === 'ssh' ? 'flex' : 'none', flex: '1 1 auto', minHeight: 0, flexDirection: 'column' } },
1492
+ h(G.SshPanel, { refreshToken: sshRefreshToken, onConnect: connectSsh })),
1493
+ )
1494
+
1495
+ const editorTabs =
1496
+ tabs.length === 0
1497
+ ? null
1498
+ : h(
1499
+ 'div',
1500
+ { className: 'gufa-tabs' },
1501
+ ...tabs.map((tab) =>
1502
+ h(
1503
+ 'div',
1504
+ {
1505
+ key: tab.path,
1506
+ className: 'gufa-tab',
1507
+ 'data-active': activePath === tab.path ? '' : undefined,
1508
+ role: 'tab',
1509
+ tabIndex: 0,
1510
+ title: tab.title !== undefined && tab.title !== null ? tab.title : tab.path,
1511
+ onClick: () => setActivePath(tab.path),
1512
+ onAuxClick: (event) => { if (event.button === 1) closeTabWithConfirm(tab.path) },
1513
+ },
1514
+ h(G.Icon, { name: 'file', size: 13 }),
1515
+ h('span', { className: 'gufa-tab-name' }, tab.title !== undefined && tab.title !== null ? tab.title : G.basename(tab.path)),
1516
+ problems[tab.path] !== undefined
1517
+ ? h('span', { className: 'gufa-tab-problem', 'data-level': problems[tab.path].errors > 0 ? undefined : 'warning' })
1518
+ : null,
1519
+ tab.dirty === true ? h('span', { className: 'gufa-tab-dot', title: t('status.dirty') }) : null,
1520
+ h(
1521
+ 'button',
1522
+ {
1523
+ type: 'button',
1524
+ className: 'gufa-tab-close',
1525
+ title: t('editor.close'),
1526
+ onClick: (event) => { event.stopPropagation(); closeTabWithConfirm(tab.path) },
1527
+ },
1528
+ h(G.Icon, { name: 'close', size: 11 }),
1529
+ ),
1530
+ ),
1531
+ ),
1532
+ )
1533
+
1534
+ const breadcrumb =
1535
+ activeTab === null
1536
+ ? null
1537
+ : h(
1538
+ 'div',
1539
+ { className: 'gufa-breadcrumb' },
1540
+ ...String(activeTab.path).split('/').map((part, index) =>
1541
+ h('span', { key: `${part}:${index}`, style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
1542
+ index > 0 ? h(G.Icon, { name: 'chevron-right', size: 10 }) : null,
1543
+ index === String(activeTab.path).split('/').length - 1 ? h('b', null, part) : part,
1544
+ ),
1545
+ ),
1546
+ )
1547
+
1548
+ const welcome =
1549
+ project === null
1550
+ ? h(
1551
+ 'div',
1552
+ { className: 'gufa-empty' },
1553
+ h(G.Seal, { size: 64 }),
1554
+ h('div', { className: 'gufa-empty-title' }, t('app.noProject')),
1555
+ h('div', { className: 'gufa-empty-hint' }, t('app.noProjectHint')),
1556
+ bootError !== '' ? h('div', { className: 'gufa-error-banner', style: { margin: 0 } }, bootError) : null,
1557
+ h(
1558
+ 'div',
1559
+ { className: 'gufa-row', style: { justifyContent: 'center', marginTop: 8 } },
1560
+ h(G.Button, { primary: true, icon: 'folder-open', onClick: () => void chooseAndOpenProject() }, t('app.openProject')),
1561
+ h(G.Button, { icon: 'plus', onClick: () => setModal({ type: 'newProject', parent: '', name: '' }) }, t('app.newProject')),
1562
+ ),
1563
+ )
1564
+ : null
1565
+
1566
+ const cursorText =
1567
+ cursor !== null && cursor.path === activePath
1568
+ ? `${t('status.ln')} ${cursor.line},${t('status.col')} ${cursor.column}`
1569
+ : ''
1570
+
1571
+ const statusBranch = project?.isGit === true && gitStatus?.branch ? gitStatus.branch : null
1572
+ const lineCount = activeTab === null || activeTab.kind === 'graph' ? 0 : String(activeTab.content ?? '').split('\n').length
1573
+
1574
+ return h(
1575
+ 'div',
1576
+ {
1577
+ className: 'gufa-root',
1578
+ 'data-backdrop': backdropActive ? 'on' : undefined,
1579
+ style: { '--gufa-dock-width': ui.chatOpen ? `${Math.round(chatWidth)}px` : '0px' },
1580
+ },
1581
+ backdropActive ? h(G.SkinBackdrop, { meta: skinBackdrop, enabled: true, strength: backdropStrength }) : null,
1582
+ h(
1583
+ 'div',
1584
+ { className: 'gufa-shell' },
1585
+ /* 标题栏 */
1586
+ h(
1587
+ 'div',
1588
+ { className: 'gufa-titlebar' },
1589
+ h(
1590
+ 'div',
1591
+ { className: 'gufa-brand' },
1592
+ h(G.Seal, { size: 24, className: 'gufa-brand-logo' }),
1593
+ h('span', { className: 'gufa-brand-name' }, t('app.title')),
1594
+ h('span', { className: 'gufa-brand-sub' }, t('app.subtitle')),
1595
+ ),
1596
+ h(
1597
+ 'button',
1598
+ {
1599
+ type: 'button',
1600
+ className: 'gufa-project',
1601
+ title: project !== null ? `${project.root} · ${t('app.switchWorkspace')}` : t('app.switchWorkspace'),
1602
+ onClick: () => void openWorkspaceDialog(),
1603
+ },
1604
+ h(G.Icon, { name: 'folder-open', size: 13 }),
1605
+ h('span', { className: 'gufa-project-name' }, project !== null ? project.name : t('app.noWorkspace')),
1606
+ project !== null && project.isGit === true ? h(G.Icon, { name: 'branch', size: 12 }) : null,
1607
+ h(G.Icon, { name: 'chevron-down', size: 11 }),
1608
+ ),
1609
+ h('div', { className: 'gufa-titlebar-spacer' }),
1610
+ h(
1611
+ 'div',
1612
+ { className: 'gufa-titlebar-actions' },
1613
+ h(G.Button, { icon: 'plus', title: t('app.newWorkspace'), disabled: workspaceBusy, onClick: () => void createWorkspace() }, t('app.newWorkspace')),
1614
+ skinBackdrop.available === true && backdropActive
1615
+ ? h(G.BackdropStrengthSlider, {
1616
+ value: backdropStrength === null || backdropStrength === undefined ? Math.round((skinBackdrop.portraitOpacity ?? 1) * 100) : backdropStrength,
1617
+ onChange: (value) => {
1618
+ setBackdropStrength(value)
1619
+ G.backdropStrengthPref.set(value)
1620
+ },
1621
+ onReset: () => {
1622
+ setBackdropStrength(null)
1623
+ G.backdropStrengthPref.set(null)
1624
+ },
1625
+ })
1626
+ : null,
1627
+ skinBackdrop.available === true
1628
+ ? h(G.IconButton, {
1629
+ title: backdropActive ? t('app.backdropOff') : t('app.backdropOn'),
1630
+ 'data-active': backdropActive ? '' : undefined,
1631
+ onClick: () => setBackdropOn((value) => {
1632
+ const next = !value
1633
+ G.backdropPref.set(next)
1634
+ return next
1635
+ }),
1636
+ }, h(G.Icon, { name: 'image', size: 16 }))
1637
+ : null,
1638
+ h(G.IconButton, { title: t('app.refresh'), onClick: () => { setRefreshToken((token) => token + 1); setGitRefreshToken((token) => token + 1); setSshRefreshToken((token) => token + 1) } }, h(G.Icon, { name: 'refresh', size: 15 })),
1639
+ h(G.IconButton, {
1640
+ title: ui.chatOpen ? t('activity.collapseChat') : t('activity.expandChat'),
1641
+ 'data-active': ui.chatOpen ? '' : undefined,
1642
+ onClick: () => patchUi({ chatOpen: !ui.chatOpen }),
1643
+ }, h(G.Icon, { name: 'panel-right', size: 16 })),
1644
+ h(G.Button, { icon: 'arrow-left', title: t('app.exitHint'), onClick: exitMode }, t('app.exit')),
1645
+ ),
1646
+ ),
1647
+ /* 主体 */
1648
+ h(
1649
+ 'div',
1650
+ { className: 'gufa-body' },
1651
+ h(
1652
+ 'div',
1653
+ { className: 'gufa-activity' },
1654
+ ...activityItems.map((item) =>
1655
+ h(
1656
+ 'button',
1657
+ {
1658
+ key: item.id,
1659
+ type: 'button',
1660
+ className: 'gufa-activity-btn',
1661
+ 'data-active': (item.active ?? (ui.view === item.id && item.id !== 'terminal' && item.id !== 'chat')) ? '' : undefined,
1662
+ title: item.label,
1663
+ 'aria-label': item.label,
1664
+ onClick: item.onClick,
1665
+ style: { position: 'relative' },
1666
+ },
1667
+ h(G.Icon, { name: item.icon, size: 19 }),
1668
+ item.badge > 0 ? h('span', { className: 'gufa-badge', style: { position: 'absolute', top: 2, right: 2, fontSize: 9, minWidth: 14, height: 14 } }, String(item.badge)) : null,
1669
+ ),
1670
+ ),
1671
+ h('div', { className: 'gufa-activity-fill' }),
1672
+ ),
1673
+ /* 左面板(⌘B 可收起) */
1674
+ ui.sidebarOpen === false
1675
+ ? null
1676
+ : h(
1677
+ 'div',
1678
+ { className: 'gufa-sidepanel', style: { width: explorerWidth } },
1679
+ h('div', { style: { flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' } }, leftPanel),
1680
+ ),
1681
+ ui.sidebarOpen === false ? null : h('div', { className: 'gufa-splitter', onMouseDown: explorerDrag, title: '' }),
1682
+ /* 主列:编辑器 + 终端 */
1683
+ h(
1684
+ 'div',
1685
+ { className: 'gufa-main' },
1686
+ h(
1687
+ 'div',
1688
+ { className: 'gufa-main-row' },
1689
+ h(
1690
+ 'div',
1691
+ { className: 'gufa-editor-area' },
1692
+ banner !== '' && bannerTone === 'success'
1693
+ ? h('div', { className: 'gufa-toast-success' }, h(G.Icon, { name: 'check', size: 13 }), h('span', null, banner))
1694
+ : null,
1695
+ banner !== '' && bannerTone !== 'success'
1696
+ ? h(
1697
+ 'div',
1698
+ { className: 'gufa-error-banner' },
1699
+ h(G.Icon, { name: 'warn', size: 13 }),
1700
+ h('span', { style: { flex: '1 1 auto' } }, banner),
1701
+ h(G.IconButton, { title: t('common.close'), onClick: () => setBanner('') }, h(G.Icon, { name: 'close', size: 12 })),
1702
+ )
1703
+ : null,
1704
+ hostStale ? h('div', { className: 'gufa-error-banner', style: { borderColor: 'var(--dsw-alias-state-warn-primary)', color: 'var(--dsw-alias-state-warn-primary)', background: 'color-mix(in srgb, var(--dsw-alias-state-warn-primary) 10%, transparent)' } }, h(G.Icon, { name: 'warn', size: 13 }), h('span', { style: { flex: '1 1 auto' } }, t('app.hostStale')), h(G.IconButton, { title: t('common.close'), onClick: () => setHostStale(false) }, h(G.Icon, { name: 'close', size: 12 }))) : null,
1705
+ welcome ?? h(
1706
+ React.Fragment,
1707
+ null,
1708
+ editorTabs,
1709
+ breadcrumb,
1710
+ h(
1711
+ 'div',
1712
+ { className: 'gufa-editor-host' },
1713
+ activeTab === null
1714
+ ? h(G.Empty, { icon: 'seal', title: t('editor.noFile'), hint: t('editor.noFileHint') })
1715
+ : activeTab.kind === 'graph'
1716
+ ? h(G.GitGraphView, {
1717
+ root: project?.root ?? null,
1718
+ refreshToken: gitRefreshToken,
1719
+ onRefresh: () => setGitRefreshToken((token) => token + 1),
1720
+ onOpenCommitFile: (commit, file) => void openCommitFile(commit, file),
1721
+ })
1722
+ : h(G.EditorPane, {
1723
+ tabs,
1724
+ activePath,
1725
+ theme,
1726
+ palette: solidPalette,
1727
+ root: project?.root ?? '',
1728
+ onSelectTab: setActivePath,
1729
+ onCloseTab: closeTabWithConfirm,
1730
+ onChange: changeFile,
1731
+ onSave: (path) => void saveFile(path),
1732
+ backdropMode: backdropActive,
1733
+ onCursor: (value) => setCursor({ ...value, lineCount }),
1734
+ }),
1735
+ ),
1736
+ ),
1737
+ ),
1738
+ ),
1739
+ ui.terminalOpen
1740
+ ? h(
1741
+ React.Fragment,
1742
+ null,
1743
+ h('div', { className: 'gufa-hsplitter', onMouseDown: terminalDrag }),
1744
+ h(G.TerminalPane, {
1745
+ root: project?.root ?? null,
1746
+ visible: true,
1747
+ height: terminalHeight,
1748
+ theme,
1749
+ palette: solidPalette,
1750
+ onToggleHeight: () => patchUi({ terminalOpen: false }),
1751
+ }),
1752
+ )
1753
+ : null,
1754
+ ),
1755
+ /* 右侧 AI 对话 */
1756
+ ui.chatOpen
1757
+ ? h(
1758
+ React.Fragment,
1759
+ null,
1760
+ h('div', { className: 'gufa-splitter', onMouseDown: chatDrag }),
1761
+ h(
1762
+ 'div',
1763
+ { className: 'gufa-chatpane', style: { width: chatWidth } },
1764
+ h(G.ChatDock, {
1765
+ project,
1766
+ activeFile: activeFileForChat,
1767
+ selection: selectionForChat,
1768
+ theme,
1769
+ width: chatWidth,
1770
+ active: true,
1771
+ paused: sessionsOpen,
1772
+ backdrop: backdropActive,
1773
+ sessionTitle,
1774
+ onOpenSessions: () => setSessionsOpen(true),
1775
+ onInsertCode: insertCode,
1776
+ }),
1777
+ ),
1778
+ )
1779
+ : null,
1780
+ ),
1781
+ /* 状态栏 */
1782
+ h(
1783
+ 'div',
1784
+ { className: 'gufa-statusbar' },
1785
+ h(G.Icon, { name: 'code', size: 12 }),
1786
+ h('span', { className: 'gufa-status-item' }, project?.name ?? t('app.noProject')),
1787
+ statusBranch !== null
1788
+ ? h(
1789
+ 'button',
1790
+ {
1791
+ type: 'button',
1792
+ className: 'gufa-status-item',
1793
+ title: t('git.branchSwitchHint'),
1794
+ style: { border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', padding: '0 4px' },
1795
+ onClick: (event) => openBranchMenu(event),
1796
+ },
1797
+ h(G.Icon, { name: 'branch', size: 12 }),
1798
+ statusBranch,
1799
+ gitStatus?.ahead > 0 ? ` ↑${gitStatus.ahead}` : '',
1800
+ gitStatus?.behind > 0 ? ` ↓${gitStatus.behind}` : '',
1801
+ gitStatus?.changes?.length > 0 ? ` · ${gitStatus.changes.length}` : '',
1802
+ )
1803
+ : h('span', { className: 'gufa-status-item' }, t('status.noGit')),
1804
+ h('div', { className: 'gufa-status-fill' }),
1805
+ activeTab !== null ? h('span', { className: 'gufa-status-item' }, activeTab.title !== undefined && activeTab.title !== null ? activeTab.title : G.shortPath(activeTab.path, 56)) : null,
1806
+ activeTab !== null ? h('span', { className: 'gufa-status-item' }, `${lineCount} ${t('status.lines')}`) : null,
1807
+ activeTab !== null && problems[activeTab.path] !== undefined
1808
+ ? h('span', { className: 'gufa-status-item', style: { color: problems[activeTab.path].errors > 0 ? '#f85149' : '#e5c07b' } }, `${problems[activeTab.path].errors} ✕ ${problems[activeTab.path].warnings} ⚠`)
1809
+ : null,
1810
+ cursorText !== '' ? h('span', { className: 'gufa-status-item' }, cursorText) : null,
1811
+ activeTab !== null && activeTab.dirty === true ? h('span', { className: 'gufa-status-item', style: { color: 'var(--gufa-accent)' } }, t('status.dirty')) : null,
1812
+ activeTab !== null && activeTab.dirty !== true ? h('span', { className: 'gufa-status-item' }, G.Icon ? h(G.Icon, { name: 'check', size: 11 }) : null, t('status.saved')) : null,
1813
+ h('button', {
1814
+ type: 'button',
1815
+ className: 'gufa-status-item',
1816
+ title: t('shortcuts.title'),
1817
+ style: { border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', padding: '0 4px' },
1818
+ onClick: () => setShortcutsOpen(true),
1819
+ }, G.isMacPlatform() ? '⌘/' : 'Ctrl+/'),
1820
+ h('span', { className: 'gufa-status-item' }, t('status.encoding')),
1821
+ ),
1822
+ ),
1823
+ typeof G.UsageDock === 'function' ? h(G.UsageDock, { active: true }) : null,
1824
+ workspaceDialog === null
1825
+ ? null
1826
+ : h(
1827
+ 'div',
1828
+ {
1829
+ className: 'gufa-branch-dialog-backdrop',
1830
+ onMouseDown: (event) => {
1831
+ if (event.target === event.currentTarget) closeWorkspaceDialog()
1832
+ },
1833
+ },
1834
+ h(
1835
+ 'div',
1836
+ { className: 'gufa-branch-dialog', onMouseDown: (event) => event.stopPropagation() },
1837
+ h(
1838
+ 'div',
1839
+ { className: 'gufa-branch-dialog-head' },
1840
+ h(G.Icon, { name: 'folder-open', size: 14 }),
1841
+ h('span', { className: 'gufa-panel-head-title' }, t('workspace.title')),
1842
+ h('div', { style: { flex: '1 1 auto' } }),
1843
+ h(G.IconButton, { title: t('common.close'), onClick: closeWorkspaceDialog }, h(G.Icon, { name: 'close', size: 12 })),
1844
+ ),
1845
+ workspaceDialog.error !== ''
1846
+ ? h('div', { className: 'gufa-error-banner', style: { margin: '6px 10px' } }, workspaceDialog.error)
1847
+ : null,
1848
+ workspaceDialog.loading === true
1849
+ ? h('div', { className: 'gufa-graph-loading', style: { height: 90 } }, h(G.Spinner), t('common.loading'))
1850
+ : h(
1851
+ 'div',
1852
+ { className: 'gufa-branch-dialog-list gufa-scroll' },
1853
+ workspaceDialog.available !== true
1854
+ ? h('div', { className: 'gufa-hint', style: { padding: '10px 8px' } }, t('workspace.unavailable'))
1855
+ : workspaceDialog.workspaces.length === 0
1856
+ ? h('div', { className: 'gufa-hint', style: { padding: '10px 8px' } }, t('workspace.empty'))
1857
+ : workspaceDialog.workspaces.map((workspace) =>
1858
+ h(
1859
+ 'button',
1860
+ {
1861
+ key: workspace.id,
1862
+ type: 'button',
1863
+ className: 'gufa-branch-row gufa-workspace-row',
1864
+ 'data-current': project !== null && project.root === workspace.path ? '' : undefined,
1865
+ title: workspace.path,
1866
+ onClick: () => void switchWorkspace(workspace),
1867
+ },
1868
+ h(G.Icon, { name: 'folder-open', size: 13 }),
1869
+ h(
1870
+ 'span',
1871
+ { className: 'gufa-branch-name' },
1872
+ h('span', { className: 'gufa-workspace-title' }, workspace.title !== '' ? workspace.title : G.basename(workspace.path)),
1873
+ h('span', { className: 'gufa-workspace-path' }, workspace.path),
1874
+ ),
1875
+ project !== null && project.root === workspace.path
1876
+ ? h('span', { className: 'gufa-branch-tag' }, t('workspace.current'))
1877
+ : null,
1878
+ ),
1879
+ ),
1880
+ ),
1881
+ h(
1882
+ 'div',
1883
+ { className: 'gufa-branch-dialog-new' },
1884
+ h(G.Button, { icon: 'plus', primary: true, disabled: workspaceBusy, onClick: () => void createWorkspace() }, workspaceBusy ? t('common.loading') : t('workspace.create')),
1885
+ ),
1886
+ ),
1887
+ ),
1888
+ branchMenu === null
1889
+ ? null
1890
+ : h(
1891
+ 'div',
1892
+ {
1893
+ className: 'gufa-branch-dialog-backdrop',
1894
+ onMouseDown: (event) => {
1895
+ if (event.target === event.currentTarget) closeBranchMenu()
1896
+ },
1897
+ },
1898
+ h(
1899
+ 'div',
1900
+ { className: 'gufa-branch-dialog', onMouseDown: (event) => event.stopPropagation() },
1901
+ h(
1902
+ 'div',
1903
+ { className: 'gufa-branch-dialog-head' },
1904
+ h(G.Icon, { name: 'branch', size: 14 }),
1905
+ h('span', { className: 'gufa-panel-head-title' }, t('git.branchesTitle')),
1906
+ h('span', { className: 'gufa-branch-dialog-current' }, gitStatus?.branch ? `当前:${gitStatus.branch}` : ''),
1907
+ h('div', { style: { flex: '1 1 auto' } }),
1908
+ h(G.IconButton, { title: t('common.close'), onClick: closeBranchMenu }, h(G.Icon, { name: 'close', size: 12 })),
1909
+ ),
1910
+ branchMenu.loading === true
1911
+ ? h('div', { className: 'gufa-graph-loading', style: { height: 80 } }, h(G.Spinner), t('common.loading'))
1912
+ : null,
1913
+ branchMenu.error !== ''
1914
+ ? h('div', { className: 'gufa-error-banner', style: { margin: '6px 10px' } }, branchMenu.error)
1915
+ : null,
1916
+ h(
1917
+ 'div',
1918
+ { className: 'gufa-branch-dialog-list gufa-scroll' },
1919
+ ...(branchMenu.loading === true
1920
+ ? []
1921
+ : branchMenu.branches.map((branch) =>
1922
+ h(
1923
+ 'button',
1924
+ {
1925
+ key: branch.name,
1926
+ type: 'button',
1927
+ className: 'gufa-branch-row',
1928
+ 'data-current': branch.current === true ? '' : undefined,
1929
+ onClick: () => void checkoutBranch(branch.name),
1930
+ },
1931
+ h(G.Icon, { name: branch.current === true ? 'check' : 'branch', size: 13 }),
1932
+ h('span', { className: 'gufa-branch-name' }, branch.name),
1933
+ branch.current === true ? h('span', { className: 'gufa-branch-tag' }, t('git.branchCurrent')) : null,
1934
+ ),
1935
+ )),
1936
+ ),
1937
+ h(
1938
+ 'div',
1939
+ { className: 'gufa-branch-dialog-new' },
1940
+ h('input', {
1941
+ className: 'gufa-input',
1942
+ value: branchInput,
1943
+ placeholder: t('git.branchNamePlaceholder'),
1944
+ spellCheck: false,
1945
+ onChange: (event) => setBranchInput(event.target.value),
1946
+ onKeyDown: (event) => {
1947
+ if (event.key !== 'Enter') return
1948
+ event.preventDefault()
1949
+ void createBranch()
1950
+ },
1951
+ style: { flex: '1 1 auto', height: 28, fontSize: 12 },
1952
+ }),
1953
+ h(G.Button, { disabled: branchInput.trim() === '', primary: true, onClick: () => void createBranch() }, t('git.branchCreate')),
1954
+ ),
1955
+ ),
1956
+ ),
1957
+ /* 弹窗 */
1958
+ modal === null ? null : renderModal(),
1959
+ sessionsOpen ? renderSessionsDialog() : null,
1960
+ shortcutsOpen ? renderShortcutsDialog() : null,
1961
+ )
1962
+
1963
+ function renderShortcutsDialog() {
1964
+ const close = () => setShortcutsOpen(false)
1965
+ return h(
1966
+ 'div',
1967
+ { className: 'gufa-modal-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) close() } },
1968
+ h(
1969
+ 'div',
1970
+ { className: 'gufa-modal gufa-shortcuts-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('shortcuts.title') },
1971
+ h(
1972
+ 'div',
1973
+ { className: 'gufa-modal-head' },
1974
+ h(G.Icon, { name: 'keyboard', size: 14 }),
1975
+ h('span', { style: { flex: '1 1 auto' } }, t('shortcuts.title')),
1976
+ h(G.IconButton, { className: 'gufa-modal-close', title: t('common.close'), onClick: close }, h(G.Icon, { name: 'close', size: 14 })),
1977
+ ),
1978
+ h(
1979
+ 'div',
1980
+ { className: 'gufa-modal-body', style: { gap: 0 } },
1981
+ G.SHORTCUTS.map((entry) =>
1982
+ h(
1983
+ 'div',
1984
+ { key: entry.action, style: { display: 'flex', alignItems: 'center', gap: 12, padding: '7px 2px', borderBottom: '0.5px solid var(--gufa-border)' } },
1985
+ h('span', { style: { flex: '1 1 auto', fontSize: 12.5 } }, t(entry.labelKey)),
1986
+ h('kbd', { className: 'gufa-kbd' }, G.shortcutKeyText(entry)),
1987
+ ),
1988
+ ),
1989
+ h('div', { style: { padding: '12px 2px 4px', fontSize: 11, opacity: 0.7, letterSpacing: 0.4 } }, t('shortcuts.editorTitle')),
1990
+ G.EDITOR_SHORTCUTS.map((entry) =>
1991
+ h(
1992
+ 'div',
1993
+ { key: entry.labelKey, style: { display: 'flex', alignItems: 'center', gap: 12, padding: '6px 2px', borderBottom: '0.5px solid var(--gufa-border)' } },
1994
+ h('span', { style: { flex: '1 1 auto', fontSize: 12 } }, t(entry.labelKey)),
1995
+ h('kbd', { className: 'gufa-kbd' }, G.isMacPlatform() ? entry.mac : entry.other),
1996
+ ),
1997
+ ),
1998
+ ),
1999
+ h(
2000
+ 'div',
2001
+ { className: 'gufa-modal-foot' },
2002
+ h(G.Button, { onClick: close }, t('common.close')),
2003
+ ),
2004
+ ),
2005
+ )
2006
+ }
2007
+
2008
+ function renderSessionsDialog() {
2009
+ const close = () => setSessionsOpen(false)
2010
+ const localTitles = new Map()
2011
+ const localTimes = new Map()
2012
+ for (const row of recentSessionsRaw) {
2013
+ if (row === null || typeof row !== 'object' || typeof row.id !== 'string') continue
2014
+ localTitles.set(row.id, row.title)
2015
+ localTimes.set(row.id, row.when)
2016
+ }
2017
+ /* 只展示「当前工作区」的会话:以 Host 的工作区会话索引为准(与经典侧边栏同一份数据),
2018
+ 客户端会话状态只用来补标题;Host 不可用时才回退到本地状态。 */
2019
+ const hostGroups = Array.isArray(workspaceSessions.groups) ? workspaceSessions.groups : []
2020
+ const currentRoot = project !== null && typeof project.root === 'string' ? project.root : ''
2021
+ const currentWorkspace = workspaceIndex.find((item) => item !== null && item.path === currentRoot)
2022
+ const currentWorkspaceId = currentWorkspace !== undefined && typeof currentWorkspace.id === 'string' ? currentWorkspace.id : ''
2023
+ const hostGroup = hostGroups.find((group) => (currentWorkspaceId !== '' ? group.workspaceId === currentWorkspaceId : group.path === currentRoot))
2024
+ const localTitleById = new Map(
2025
+ recentSessionsRaw.filter((row) => row !== null && typeof row === 'object' && typeof row.id === 'string').map((row) => [row.id, row.title]),
2026
+ )
2027
+ const localWhenById = new Map(
2028
+ recentSessionsRaw.filter((row) => row !== null && typeof row === 'object' && typeof row.id === 'string').map((row) => [row.id, row.when]),
2029
+ )
2030
+ const membershipIds = Array.isArray(workspaceSessions.sessionIds) ? workspaceSessions.sessionIds : []
2031
+ const archivedSet = new Set(Array.isArray(workspaceSessions.archivedSessionIds) ? workspaceSessions.archivedSessionIds : [])
2032
+ const storeById = new Map(
2033
+ recentSessionsRaw.filter((row) => row !== null && typeof row === 'object' && typeof row.id === 'string').map((row) => [row.id, row]),
2034
+ )
2035
+ /* Host 已经按经典侧边栏的可见性规则算好(成员/归档/subagent/blank/标题/排序),直接展示 */
2036
+ /* 经典侧边栏的手动排序保存在 dsh.workspace.view.v5 里,这里直接复用,保证顺序也一致 */
2037
+ let manualOrder = null
2038
+ try {
2039
+ const raw = window.localStorage.getItem('dsh.workspace.view.v5')
2040
+ if (raw !== null) {
2041
+ const view = JSON.parse(raw)
2042
+ if (view !== null && typeof view === 'object' && view.orderBy === 'manual' && view.sessionOrderByAccount !== null && typeof view.sessionOrderByAccount === 'object') {
2043
+ const key = workspaceSessions.workspaceId !== undefined && workspaceSessions.workspaceId !== '' ? workspaceSessions.workspaceId : ''
2044
+ const order = view.sessionOrderByAccount[key]
2045
+ if (Array.isArray(order)) manualOrder = order
2046
+ }
2047
+ }
2048
+ } catch {
2049
+ manualOrder = null
2050
+ }
2051
+ const hostRows = Array.isArray(workspaceSessions.sessions) ? workspaceSessions.sessions : []
2052
+ const mappedRows = hostRows.map((row) => ({
2053
+ id: row.id,
2054
+ title: typeof row.title === 'string' && row.title !== '' ? row.title : localTitleById.get(row.id) ?? `session-${String(row.id).slice(0, 4)}`,
2055
+ when: typeof row.updatedAt === 'number' && row.updatedAt > 0 ? row.updatedAt : localWhenById.get(row.id) ?? 0,
2056
+ }))
2057
+ const orderedRows = manualOrder !== null
2058
+ ? [
2059
+ ...manualOrder.map((id) => mappedRows.find((row) => row.id === id)).filter((row) => row !== undefined),
2060
+ ...mappedRows.filter((row) => !manualOrder.includes(row.id)),
2061
+ ]
2062
+ : mappedRows
2063
+ const sessionRows = hostRows.length > 0
2064
+ ? orderedRows
2065
+ : hostGroup !== undefined && Array.isArray(hostGroup.sessions)
2066
+ ? hostGroup.sessions.map((row) => ({
2067
+ id: row.id,
2068
+ title: typeof row.title === 'string' && row.title !== ''
2069
+ ? row.title
2070
+ : row.id === currentSessionId && (storeById.get(row.id)?.blank === true || localTitleById.get(row.id) === undefined)
2071
+ ? t('dock.newSession')
2072
+ : localTitleById.get(row.id) ?? `session-${String(row.id).slice(0, 4)}`,
2073
+ when: typeof row.updatedAt === 'number' && row.updatedAt > 0 ? row.updatedAt : localWhenById.get(row.id) ?? 0,
2074
+ }))
2075
+ : []
2076
+ // 保留最近作为「当前会话」出现过的会话(新建空白会话后 Host 列表可能暂时不包含上一个对话)
2077
+ const retained = []
2078
+ for (const id of recentCurrentIds) {
2079
+ if (sessionRows.some((row) => row.id === id) || retained.some((row) => row.id === id)) continue
2080
+ const fromStore = storeById.get(id)
2081
+ // 新建后没说过话的 blank 会话切走后不保留(经典模式同样是隐藏的)
2082
+ if (fromStore !== undefined && fromStore.blank === true) continue
2083
+ // 只保留属于当前工作区的会话(切换工作区后不要带出旧工作区的会话)
2084
+ if (fromStore !== undefined && typeof fromStore.workspaceId === 'string' && fromStore.workspaceId !== '' && workspaceSessions.workspaceId !== undefined && workspaceSessions.workspaceId !== '' && fromStore.workspaceId !== workspaceSessions.workspaceId) continue
2085
+ if (fromStore !== undefined && typeof fromStore.cwd === 'string' && fromStore.cwd !== '' && project !== null && typeof project.root === 'string' && fromStore.cwd !== project.root) continue
2086
+ const title = localTitleById.get(id) ?? (fromStore !== undefined ? fromStore.title : '')
2087
+ if (typeof title !== 'string' || title === '') continue
2088
+ retained.push({ id, title, when: localWhenById.get(id) ?? (fromStore !== undefined ? fromStore.when : Date.now()) })
2089
+ }
2090
+ const rowsWithRetained = retained.length > 0 ? [...retained, ...sessionRows] : sessionRows
2091
+ // 当前会话无论如何都要在列表里(Host 偶发缺失时用本地摘要补上)
2092
+ const hostSessions = currentSessionId !== null && currentSessionId !== undefined && typeof currentSessionId === 'string' && currentSessionId !== '' && !rowsWithRetained.some((row) => row.id === currentSessionId)
2093
+ ? [{ id: currentSessionId, title: localTitleById.get(currentSessionId) ?? `session-${String(currentSessionId).slice(8, 12)}`, when: localWhenById.get(currentSessionId) ?? Date.now() }, ...rowsWithRetained]
2094
+ : rowsWithRetained
2095
+ const visibleSessions = hostSessions.length > 0
2096
+ ? hostSessions
2097
+ : recentSessionsRaw
2098
+ .filter((row) => row !== null && typeof row === 'object' && typeof row.id === 'string' && row.archived !== true)
2099
+ .filter((row) => {
2100
+ const root = typeof row.cwd === 'string' ? row.cwd : ''
2101
+ if (currentRoot !== '' && root !== '' && root !== currentRoot) return false
2102
+ return true
2103
+ })
2104
+ .map((row) => ({ id: row.id, title: row.title, when: row.when }))
2105
+ const renderSessionRow = (session, extraKey) =>
2106
+ h(
2107
+ 'button',
2108
+ {
2109
+ key: extraKey,
2110
+ type: 'button',
2111
+ className: 'gufa-session-row',
2112
+ 'data-current': session.id === currentSessionId ? '' : undefined,
2113
+ title: session.title,
2114
+ onClick: () => {
2115
+ close()
2116
+ openSession(session.id)
2117
+ },
2118
+ },
2119
+ h(G.Icon, { name: 'message', size: 14 }),
2120
+ h('span', { className: 'gufa-session-row-title' }, session.title),
2121
+ typeof session.when === 'number' && session.when > 0 && typeof G.relativeTime === 'function'
2122
+ ? h('span', { className: 'gufa-session-row-when' }, G.relativeTime(session.when))
2123
+ : null,
2124
+ )
2125
+ try {
2126
+ if (window.__gufaSessionsDiag === undefined || window.__gufaSessionsDiag > 0) {
2127
+ window.__gufaSessionsDiag = (window.__gufaSessionsDiag ?? 3) - 1
2128
+ void G.api('dev.log', {
2129
+ message: `sessions-dialog root=${currentRoot} ws=${currentWorkspaceId} hostGroups=${hostGroups.length} hostGroupPath=${hostGroup?.path ?? ''} hostSessions=${hostSessions.length} store=${recentSessionsRaw.length} visible=${visibleSessions.length} titles=${visibleSessions.slice(0, 12).map((row) => String(row.title).slice(0, 16)).join(' / ')}`,
2130
+ }).catch(() => {})
2131
+ }
2132
+ } catch {
2133
+ /* ignore */
2134
+ }
2135
+ return h(
2136
+ 'div',
2137
+ { className: 'gufa-session-backdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) close() } },
2138
+ h(
2139
+ 'div',
2140
+ { className: 'gufa-modal gufa-session-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('dock.sessionsTitle') },
2141
+ h(
2142
+ 'div',
2143
+ { className: 'gufa-modal-head' },
2144
+ h(G.Icon, { name: 'history', size: 14 }),
2145
+ h('span', { style: { flex: '1 1 auto' } }, t('dock.sessionsTitle')),
2146
+ project !== null ? h('span', { className: 'gufa-session-workspace' }, project.name) : null,
2147
+ h(G.IconButton, { className: 'gufa-modal-close', title: t('common.close'), onClick: close }, h(G.Icon, { name: 'close', size: 14 })),
2148
+ ),
2149
+ h(
2150
+ 'div',
2151
+ { className: 'gufa-modal-body' },
2152
+ workspaceSessions.status === 'loading'
2153
+ ? h('div', { className: 'gufa-graph-loading', style: { height: 70 } }, h(G.Spinner), t('common.loading'))
2154
+ : visibleSessions.length === 0
2155
+ ? h('div', { className: 'gufa-hint' }, project !== null ? t('chat.noWorkspaceHistory') : t('chat.noHistory'))
2156
+ : visibleSessions.map((session) => renderSessionRow(session, session.id)),
2157
+ ),
2158
+ h(
2159
+ 'div',
2160
+ { className: 'gufa-modal-foot' },
2161
+ h(G.Button, { icon: 'plus', onClick: () => { close(); startNewSession() } }, t('dock.newSession')),
2162
+ h(G.Button, { onClick: close }, t('common.close')),
2163
+ ),
2164
+ ),
2165
+ )
2166
+ }
2167
+
2168
+ function renderModal() {
2169
+ const cancel = () => { setModal(null); setModalError('') }
2170
+ if (modal.type === 'newProject') {
2171
+ return h(
2172
+ G.Modal,
2173
+ {
2174
+ title: t('app.newProject'),
2175
+ submitLabel: t('app.create'),
2176
+ onCancel: cancel,
2177
+ pending: modalPending,
2178
+ error: modalError,
2179
+ onSubmit: () => void submitModal(),
2180
+ },
2181
+ () =>
2182
+ h(
2183
+ React.Fragment,
2184
+ null,
2185
+ h('label', { className: 'gufa-hint' }, t('app.parentFolder')),
2186
+ h(
2187
+ 'div',
2188
+ { className: 'gufa-row' },
2189
+ h('input', {
2190
+ className: 'gufa-input',
2191
+ value: modal.parent ?? '',
2192
+ placeholder: '/Users/me/projects',
2193
+ onChange: (event) => setModal((current) => ({ ...current, parent: event.target.value })),
2194
+ }),
2195
+ h(G.Button, { onClick: () => void chooseParentForNewProject() }, t('app.chooseFolder')),
2196
+ ),
2197
+ h('label', { className: 'gufa-hint' }, t('app.projectName')),
2198
+ h('input', {
2199
+ className: 'gufa-input',
2200
+ autoFocus: true,
2201
+ value: modal.name ?? '',
2202
+ placeholder: 'my-project',
2203
+ onChange: (event) => setModal((current) => ({ ...current, name: event.target.value })),
2204
+ onKeyDown: (event) => { if (event.key === 'Enter') { event.preventDefault(); void submitModal() } },
2205
+ }),
2206
+ ),
2207
+ )
2208
+ }
2209
+ if (modal.type === 'openPath') {
2210
+ return h(G.Modal, {
2211
+ title: t('app.openProject'),
2212
+ description: t('app.manualPath'),
2213
+ placeholder: t('app.pathPlaceholder'),
2214
+ submitLabel: t('app.openFolder'),
2215
+ onCancel: cancel,
2216
+ onSubmit: (value) => void submitModal(value),
2217
+ pending: modalPending,
2218
+ error: modalError,
2219
+ })
2220
+ }
2221
+ if (modal.type === 'newFile' || modal.type === 'newFolder') {
2222
+ const isFile = modal.type === 'newFile'
2223
+ return h(G.Modal, {
2224
+ title: isFile ? t('explorer.newFile') : t('explorer.newFolder'),
2225
+ description: modal.dir ? `${t('app.parentFolder')}:${modal.dir}` : undefined,
2226
+ placeholder: isFile ? t('explorer.fileName') : t('explorer.folderName'),
2227
+ submitLabel: t('app.create'),
2228
+ onCancel: cancel,
2229
+ onSubmit: (value) => void submitModal(value),
2230
+ pending: modalPending,
2231
+ error: modalError,
2232
+ })
2233
+ }
2234
+ if (modal.type === 'rename') {
2235
+ return h(G.Modal, {
2236
+ title: t('explorer.rename'),
2237
+ description: modal.path,
2238
+ initialValue: G.basename(modal.path),
2239
+ submitLabel: t('common.rename'),
2240
+ onCancel: cancel,
2241
+ onSubmit: (value) => void submitModal(value),
2242
+ pending: modalPending,
2243
+ error: modalError,
2244
+ })
2245
+ }
2246
+ if (modal.type === 'delete') {
2247
+ return h(G.Modal, {
2248
+ title: t('explorer.delete'),
2249
+ description: t('explorer.deleteConfirm', { name: modal.path }),
2250
+ submitLabel: t('common.delete'),
2251
+ onCancel: cancel,
2252
+ onSubmit: () => void submitModal(''),
2253
+ pending: modalPending,
2254
+ error: modalError,
2255
+ children: () => h('div', { className: 'gufa-hint' }, modal.path),
2256
+ })
2257
+ }
2258
+ if (modal.type === 'closeTab') {
2259
+ return h(G.Modal, {
2260
+ title: t('editor.close'),
2261
+ description: modal.path,
2262
+ submitLabel: t('common.close'),
2263
+ onCancel: cancel,
2264
+ onSubmit: () => void submitModal(''),
2265
+ pending: modalPending,
2266
+ error: modalError,
2267
+ children: () => h('div', { className: 'gufa-hint' }, t('status.dirty')),
2268
+ })
2269
+ }
2270
+ return null
2271
+ }
2272
+ }