@chatcode/cco-better-sidebar 0.18.0-alpha.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 (329) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/README_EN.md +563 -0
  4. package/assets/logo.svg +1 -0
  5. package/cordis.patch.yml +79 -0
  6. package/lib/client-editor.js +55744 -0
  7. package/lib/client-mermaid.js +208256 -0
  8. package/lib/client-registry.js +30378 -0
  9. package/lib/client-terminal.js +16894 -0
  10. package/lib/client.js +30378 -0
  11. package/lib/index.js +6897 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-opens.d.ts +65 -0
  14. package/lib/types/agent-pty.d.ts +226 -0
  15. package/lib/types/browser-probe.d.ts +15 -0
  16. package/lib/types/bundle-route.d.ts +12 -0
  17. package/lib/types/chatcode-update.d.ts +47 -0
  18. package/lib/types/client/AccountMenu.d.ts +1 -0
  19. package/lib/types/client/AiRatioSection.d.ts +3 -0
  20. package/lib/types/client/BrowserView.d.ts +39 -0
  21. package/lib/types/client/ChatcodeGate.d.ts +6 -0
  22. package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
  23. package/lib/types/client/DiffTab.d.ts +6 -0
  24. package/lib/types/client/DiffView.d.ts +51 -0
  25. package/lib/types/client/EditorHost.d.ts +13 -0
  26. package/lib/types/client/FenceErrorNotice.d.ts +5 -0
  27. package/lib/types/client/FileTree.d.ts +41 -0
  28. package/lib/types/client/FreeWindow.d.ts +29 -0
  29. package/lib/types/client/GitView.d.ts +12 -0
  30. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  31. package/lib/types/client/OrphanedTab.d.ts +11 -0
  32. package/lib/types/client/PdfView.d.ts +6 -0
  33. package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
  34. package/lib/types/client/RenderBoundary.d.ts +32 -0
  35. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  36. package/lib/types/client/SideCardSection.d.ts +74 -0
  37. package/lib/types/client/Sidebar.d.ts +8 -0
  38. package/lib/types/client/TabBar.d.ts +54 -0
  39. package/lib/types/client/TerminalView.d.ts +24 -0
  40. package/lib/types/client/TextEditor.d.ts +10 -0
  41. package/lib/types/client/TreePanel.d.ts +27 -0
  42. package/lib/types/client/UpdateDialog.d.ts +10 -0
  43. package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
  44. package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
  45. package/lib/types/client/UploadOverlay.d.ts +20 -0
  46. package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
  47. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  48. package/lib/types/client/api.d.ts +345 -0
  49. package/lib/types/client/binary-download.d.ts +12 -0
  50. package/lib/types/client/breakpoints.d.ts +17 -0
  51. package/lib/types/client/browser.d.ts +60 -0
  52. package/lib/types/client/builtins/index.d.ts +18 -0
  53. package/lib/types/client/builtins/tabs.d.ts +11 -0
  54. package/lib/types/client/builtins/viewers.d.ts +3 -0
  55. package/lib/types/client/chat-presentation.d.ts +13 -0
  56. package/lib/types/client/chunk-loader.d.ts +124 -0
  57. package/lib/types/client/chunks/editor.d.ts +9 -0
  58. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  59. package/lib/types/client/chunks/terminal.d.ts +9 -0
  60. package/lib/types/client/cm-themes.d.ts +24 -0
  61. package/lib/types/client/conversation-draft.d.ts +14 -0
  62. package/lib/types/client/desktop-env.d.ts +35 -0
  63. package/lib/types/client/editor-load.d.ts +66 -0
  64. package/lib/types/client/frame-batcher.d.ts +24 -0
  65. package/lib/types/client/icons.d.ts +66 -0
  66. package/lib/types/client/image-types.d.ts +3 -0
  67. package/lib/types/client/ime-guard.d.ts +36 -0
  68. package/lib/types/client/index.d.ts +19 -0
  69. package/lib/types/client/intercept.d.ts +44 -0
  70. package/lib/types/client/lang.d.ts +14 -0
  71. package/lib/types/client/layout-push.d.ts +15 -0
  72. package/lib/types/client/lazy-chunk.d.ts +25 -0
  73. package/lib/types/client/link-intercept.d.ts +42 -0
  74. package/lib/types/client/locales-ar.d.ts +18 -0
  75. package/lib/types/client/locales-de.d.ts +4 -0
  76. package/lib/types/client/locales-fr.d.ts +10 -0
  77. package/lib/types/client/locales-hi.d.ts +17 -0
  78. package/lib/types/client/locales-id.d.ts +15 -0
  79. package/lib/types/client/locales-it.d.ts +8 -0
  80. package/lib/types/client/locales-ja.d.ts +17 -0
  81. package/lib/types/client/locales-ko.d.ts +9 -0
  82. package/lib/types/client/locales-nl.d.ts +16 -0
  83. package/lib/types/client/locales-pl.d.ts +19 -0
  84. package/lib/types/client/locales-pt.d.ts +1 -0
  85. package/lib/types/client/locales-ru.d.ts +13 -0
  86. package/lib/types/client/locales-sv.d.ts +1 -0
  87. package/lib/types/client/locales-th.d.ts +17 -0
  88. package/lib/types/client/locales-tr.d.ts +17 -0
  89. package/lib/types/client/locales-vi.d.ts +17 -0
  90. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  91. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  92. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  93. package/lib/types/client/locales.d.ts +571 -0
  94. package/lib/types/client/markdown-html.d.ts +96 -0
  95. package/lib/types/client/markdown-images.d.ts +45 -0
  96. package/lib/types/client/markdown-labels.d.ts +21 -0
  97. package/lib/types/client/md-toc.d.ts +4 -0
  98. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  99. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  100. package/lib/types/client/mermaid.d.ts +12 -0
  101. package/lib/types/client/open-when-sized.d.ts +20 -0
  102. package/lib/types/client/open-with-settings.d.ts +4 -0
  103. package/lib/types/client/open-with.d.ts +87 -0
  104. package/lib/types/client/openpath-intercept.d.ts +52 -0
  105. package/lib/types/client/paths.d.ts +43 -0
  106. package/lib/types/client/pdf-types.d.ts +2 -0
  107. package/lib/types/client/pinned.d.ts +86 -0
  108. package/lib/types/client/plugin-settings.d.ts +8 -0
  109. package/lib/types/client/plugins-shared.d.ts +27 -0
  110. package/lib/types/client/plugins-tabs.d.ts +3 -0
  111. package/lib/types/client/plugins-viewers.d.ts +3 -0
  112. package/lib/types/client/prefs.d.ts +41 -0
  113. package/lib/types/client/produced-files.d.ts +29 -0
  114. package/lib/types/client/selection-payload.d.ts +27 -0
  115. package/lib/types/client/service.d.ts +445 -0
  116. package/lib/types/client/session-pins.d.ts +14 -0
  117. package/lib/types/client/settings-nav-icon.d.ts +27 -0
  118. package/lib/types/client/shell-presets.d.ts +48 -0
  119. package/lib/types/client/split-pane.d.ts +38 -0
  120. package/lib/types/client/state.d.ts +480 -0
  121. package/lib/types/client/tab-content-memo.d.ts +40 -0
  122. package/lib/types/client/terminal-font.d.ts +78 -0
  123. package/lib/types/client/terminal-links.d.ts +119 -0
  124. package/lib/types/client/theme.d.ts +45 -0
  125. package/lib/types/client/titlebar-strip.d.ts +23 -0
  126. package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
  127. package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
  128. package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
  129. package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
  130. package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
  131. package/lib/types/client/ui-tweaks/index.d.ts +279 -0
  132. package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
  133. package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
  134. package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
  135. package/lib/types/client/upload.d.ts +69 -0
  136. package/lib/types/client/useChatcodeAccount.d.ts +10 -0
  137. package/lib/types/client/wco.d.ts +47 -0
  138. package/lib/types/codingplan.d.ts +37 -0
  139. package/lib/types/config.d.ts +95 -0
  140. package/lib/types/context-types.d.ts +540 -0
  141. package/lib/types/fs-operations.d.ts +30 -0
  142. package/lib/types/fs-search.d.ts +24 -0
  143. package/lib/types/fs-tree.d.ts +51 -0
  144. package/lib/types/git.d.ts +124 -0
  145. package/lib/types/html-route.d.ts +59 -0
  146. package/lib/types/index.d.ts +47 -0
  147. package/lib/types/invariant.d.ts +15 -0
  148. package/lib/types/login-route.d.ts +2 -0
  149. package/lib/types/open-external.d.ts +24 -0
  150. package/lib/types/path-security.d.ts +26 -0
  151. package/lib/types/prefs-shared.d.ts +257 -0
  152. package/lib/types/pty-deps.d.ts +79 -0
  153. package/lib/types/pty-manager.d.ts +168 -0
  154. package/lib/types/tools.d.ts +34 -0
  155. package/lib/types/trust-fence.d.ts +24 -0
  156. package/lib/types/ui-tweaks/archive.d.ts +71 -0
  157. package/lib/types/ui-tweaks/config.d.ts +186 -0
  158. package/lib/types/ui-tweaks/git-web.d.ts +43 -0
  159. package/lib/types/ui-tweaks/git.d.ts +311 -0
  160. package/lib/types/ui-tweaks/index.d.ts +17 -0
  161. package/lib/types/ui-tweaks/mcp.d.ts +103 -0
  162. package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
  163. package/lib/types/ui-tweaks/web.d.ts +55 -0
  164. package/lib/types/wire.d.ts +38 -0
  165. package/package.json +213 -0
  166. package/scripts/install.ps1 +310 -0
  167. package/scripts/install.sh +287 -0
  168. package/src/agent-opens.ts +265 -0
  169. package/src/agent-pty.ts +527 -0
  170. package/src/browser-probe.ts +26 -0
  171. package/src/bundle-route.ts +129 -0
  172. package/src/chatcode-update.ts +193 -0
  173. package/src/client/AccountMenu.tsx +177 -0
  174. package/src/client/AiRatioSection.module.css +329 -0
  175. package/src/client/AiRatioSection.tsx +292 -0
  176. package/src/client/BrowserView.tsx +289 -0
  177. package/src/client/ChatcodeGate.tsx +35 -0
  178. package/src/client/CodingPlanModelsSettings.module.css +118 -0
  179. package/src/client/CodingPlanModelsSettings.tsx +101 -0
  180. package/src/client/DiffTab.tsx +114 -0
  181. package/src/client/DiffView.tsx +305 -0
  182. package/src/client/EditorHost.tsx +563 -0
  183. package/src/client/FenceErrorNotice.tsx +40 -0
  184. package/src/client/FileTree.tsx +701 -0
  185. package/src/client/FreeWindow.tsx +337 -0
  186. package/src/client/GitView.tsx +722 -0
  187. package/src/client/MarkdownHtml.tsx +296 -0
  188. package/src/client/OrphanedTab.tsx +27 -0
  189. package/src/client/PdfView.tsx +110 -0
  190. package/src/client/ReleaseNotesDialog.tsx +91 -0
  191. package/src/client/RenderBoundary.tsx +49 -0
  192. package/src/client/SandboxStatusBar.tsx +60 -0
  193. package/src/client/SideCardSection.module.css +932 -0
  194. package/src/client/SideCardSection.tsx +1169 -0
  195. package/src/client/Sidebar.tsx +1857 -0
  196. package/src/client/TabBar.tsx +365 -0
  197. package/src/client/TerminalView.tsx +424 -0
  198. package/src/client/TextEditor.tsx +468 -0
  199. package/src/client/TreePanel.tsx +279 -0
  200. package/src/client/UpdateDialog.tsx +186 -0
  201. package/src/client/UpdateSettingsPanel.tsx +29 -0
  202. package/src/client/UpdateSettingsSection.tsx +22 -0
  203. package/src/client/UploadOverlay.tsx +62 -0
  204. package/src/client/UsageHeatmapSection.module.css +440 -0
  205. package/src/client/UsageHeatmapSection.tsx +301 -0
  206. package/src/client/add-plugin-modal.tsx +199 -0
  207. package/src/client/api.ts +433 -0
  208. package/src/client/binary-download.tsx +23 -0
  209. package/src/client/breakpoints.ts +61 -0
  210. package/src/client/browser.ts +167 -0
  211. package/src/client/builtins/index.ts +37 -0
  212. package/src/client/builtins/tabs.tsx +275 -0
  213. package/src/client/builtins/viewers.tsx +125 -0
  214. package/src/client/chat-presentation.ts +95 -0
  215. package/src/client/chunk-loader.ts +350 -0
  216. package/src/client/chunks/editor.tsx +9 -0
  217. package/src/client/chunks/mermaid.tsx +10 -0
  218. package/src/client/chunks/terminal.tsx +9 -0
  219. package/src/client/cm-themes.ts +129 -0
  220. package/src/client/conversation-draft.ts +29 -0
  221. package/src/client/css-modules.d.ts +5 -0
  222. package/src/client/desktop-env.ts +72 -0
  223. package/src/client/editor-load.ts +92 -0
  224. package/src/client/frame-batcher.ts +56 -0
  225. package/src/client/icons.tsx +170 -0
  226. package/src/client/image-types.ts +8 -0
  227. package/src/client/ime-guard.ts +47 -0
  228. package/src/client/index.tsx +501 -0
  229. package/src/client/intercept.tsx +172 -0
  230. package/src/client/lang.ts +225 -0
  231. package/src/client/layout-push.ts +37 -0
  232. package/src/client/layout.css +156 -0
  233. package/src/client/lazy-chunk.tsx +89 -0
  234. package/src/client/link-intercept.ts +73 -0
  235. package/src/client/locales-ar.ts +387 -0
  236. package/src/client/locales-de.ts +372 -0
  237. package/src/client/locales-fr.ts +379 -0
  238. package/src/client/locales-hi.ts +386 -0
  239. package/src/client/locales-id.ts +384 -0
  240. package/src/client/locales-it.ts +377 -0
  241. package/src/client/locales-ja.ts +486 -0
  242. package/src/client/locales-ko.ts +378 -0
  243. package/src/client/locales-nl.ts +384 -0
  244. package/src/client/locales-pl.ts +388 -0
  245. package/src/client/locales-pt.ts +369 -0
  246. package/src/client/locales-ru.ts +384 -0
  247. package/src/client/locales-sv.ts +369 -0
  248. package/src/client/locales-th.ts +386 -0
  249. package/src/client/locales-tr.ts +386 -0
  250. package/src/client/locales-vi.ts +386 -0
  251. package/src/client/locales-zh-HK.ts +401 -0
  252. package/src/client/locales-zh-MO.ts +401 -0
  253. package/src/client/locales-zh-TW.ts +401 -0
  254. package/src/client/locales.ts +1229 -0
  255. package/src/client/markdown-html.ts +331 -0
  256. package/src/client/markdown-images.ts +137 -0
  257. package/src/client/markdown-labels.tsx +31 -0
  258. package/src/client/md-toc.tsx +127 -0
  259. package/src/client/mermaid-blocks.ts +110 -0
  260. package/src/client/mermaid-sanitize.ts +93 -0
  261. package/src/client/mermaid.tsx +406 -0
  262. package/src/client/open-when-sized.ts +43 -0
  263. package/src/client/open-with-settings.tsx +135 -0
  264. package/src/client/open-with.ts +221 -0
  265. package/src/client/openpath-intercept.ts +79 -0
  266. package/src/client/paths.ts +63 -0
  267. package/src/client/pdf-types.ts +4 -0
  268. package/src/client/pinned.ts +184 -0
  269. package/src/client/plugin-settings.ts +43 -0
  270. package/src/client/plugins-shared.ts +29 -0
  271. package/src/client/plugins-tabs.ts +153 -0
  272. package/src/client/plugins-viewers.ts +44 -0
  273. package/src/client/prefs.ts +216 -0
  274. package/src/client/produced-files.ts +120 -0
  275. package/src/client/selection-payload.ts +86 -0
  276. package/src/client/service.ts +873 -0
  277. package/src/client/session-pins.tsx +334 -0
  278. package/src/client/settings-nav-icon.ts +55 -0
  279. package/src/client/shell-presets.ts +83 -0
  280. package/src/client/sidebar.module.css +3607 -0
  281. package/src/client/split-pane.tsx +333 -0
  282. package/src/client/state.ts +1646 -0
  283. package/src/client/tab-content-memo.ts +55 -0
  284. package/src/client/terminal-font.ts +224 -0
  285. package/src/client/terminal-links.ts +194 -0
  286. package/src/client/theme.ts +110 -0
  287. package/src/client/titlebar-strip.ts +37 -0
  288. package/src/client/ui-tweaks/appicons.ts +24 -0
  289. package/src/client/ui-tweaks/archive.tsx +281 -0
  290. package/src/client/ui-tweaks/branding.tsx +232 -0
  291. package/src/client/ui-tweaks/cachehit.tsx +122 -0
  292. package/src/client/ui-tweaks/gitbar.tsx +2559 -0
  293. package/src/client/ui-tweaks/index.tsx +1696 -0
  294. package/src/client/ui-tweaks/mcp.tsx +582 -0
  295. package/src/client/ui-tweaks/notifier.ts +514 -0
  296. package/src/client/ui-tweaks/whale.tsx +171 -0
  297. package/src/client/upload.ts +187 -0
  298. package/src/client/useChatcodeAccount.ts +103 -0
  299. package/src/client/wco.ts +120 -0
  300. package/src/codingplan.ts +146 -0
  301. package/src/config.ts +207 -0
  302. package/src/context-types.ts +559 -0
  303. package/src/fs-operations.ts +97 -0
  304. package/src/fs-search.ts +113 -0
  305. package/src/fs-tree.ts +158 -0
  306. package/src/git.ts +446 -0
  307. package/src/html-route.ts +106 -0
  308. package/src/index.ts +1524 -0
  309. package/src/invariant.ts +32 -0
  310. package/src/login-route.ts +26 -0
  311. package/src/open-external.ts +90 -0
  312. package/src/path-security.ts +81 -0
  313. package/src/prefs-shared.ts +303 -0
  314. package/src/pty-deps.ts +241 -0
  315. package/src/pty-manager.ts +376 -0
  316. package/src/tools.ts +482 -0
  317. package/src/trust-fence.ts +84 -0
  318. package/src/ui-tweaks/archive.ts +293 -0
  319. package/src/ui-tweaks/config.ts +257 -0
  320. package/src/ui-tweaks/git-web.ts +421 -0
  321. package/src/ui-tweaks/git.ts +1524 -0
  322. package/src/ui-tweaks/index.ts +61 -0
  323. package/src/ui-tweaks/mcp.ts +535 -0
  324. package/src/ui-tweaks/turn-outcome.ts +105 -0
  325. package/src/ui-tweaks/web.ts +225 -0
  326. package/src/wire.ts +110 -0
  327. package/vendor/addon-fit.js +2 -0
  328. package/vendor/xterm.css +218 -0
  329. package/vendor/xterm.js +5 -0
@@ -0,0 +1,2559 @@
1
+ /**
2
+ * dsh-ui-tweaks — GitBar (browser half).
3
+ *
4
+ * Two compact DSH-native pills that live INSIDE the composer's tool row
5
+ * (`conversation.input.left` / `.right`), styled to match the input bar's
6
+ * resident chrome (access mode / model select):
7
+ *
8
+ * - **branch** (`conversation.input.left`, right after the access-mode
9
+ * control): current branch; opens a popup with local/remote branches and a
10
+ * new-branch field.
11
+ * - **diff** (`conversation.input.right`, just before the model select): ±line
12
+ * counts; opens a right slide-over panel with the changed-file list and
13
+ * per-file diff (changed hunks by default, whole file on toggle). The panel
14
+ * keeps its commit band at the foot, so committing still works from the diff
15
+ * view.
16
+ *
17
+ * The standalone commit pill (a third pill on its own row above the composer)
18
+ * is gone — the input area no longer carries a separate GitBar row above the
19
+ * card, which is what kept it tall.
20
+ *
21
+ * All colors ride the DSH theme tokens (`--dsw-alias-*`), so light and dark
22
+ * both work. Each pill renders nothing when the setting is off, the session
23
+ * has no cwd, or the directory is not a git repository.
24
+ * @module dsh-ui-tweaks/client/gitbar
25
+ */
26
+
27
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
28
+ import { createPortal } from 'react-dom'
29
+ import { createRoot } from 'react-dom/client'
30
+ import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
31
+ import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
32
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
33
+ import type { SettingsClient } from './index.tsx'
34
+ import { APP_ICONS } from './appicons.ts'
35
+
36
+ /** Route prefix matching the host half (src/git-web.ts). */
37
+ const GIT_ROUTE = '/_dsh/ui-tweaks/git'
38
+
39
+ /** Poll interval for the status snapshot, in ms. */
40
+ const POLL_MS = 10000
41
+
42
+ /** Default cap of the file list while its height is still automatic, in px. */
43
+ const FILES_AUTO_MAX = 150
44
+ /** Floors for the two resizable diff-panel sections, in px. */
45
+ const MIN_FILES_H = 30
46
+ const MIN_DIFF_H = 60
47
+ const MIN_COMMIT_H = 62
48
+ /** Share of the panel a dragged section may never exceed, so the diff survives. */
49
+ const SECTION_MAX = '45%'
50
+
51
+ /** Locale keys the GitBar reads off the `ui-tweaks` dictionary. */
52
+ type GitBarLabelKey =
53
+ | 'commitMessage' | 'diffFiles' | 'branchLocal' | 'branchRemote' | 'branchNew'
54
+ | 'branchNewPlaceholder' | 'branchCreate' | 'diffTitle' | 'diffOnly' | 'diffFull'
55
+ | 'commitTitle' | 'commitPlaceholder' | 'commitHint' | 'commitWillCommit' | 'commitViewDiff'
56
+ | 'commitEmpty' | 'commitCancel' | 'commitSubmit' | 'commitSubmitPush'
57
+ | 'commitBusy'
58
+ | 'dirty' | 'clean' | 'noChanges' | 'loading' | 'branchDelete' | 'branchDeleteConfirm'
59
+ | 'includeFile' | 'excludeFile' | 'branchPushRemote'
60
+ | 'branchRemoteDelete' | 'branchFrom' | 'branchFromHead' | 'branchGraph'
61
+ | 'branchRefresh' | 'branchCancel' | 'graphTitle'
62
+ | 'graphColGraph' | 'graphColCommit' | 'graphColSubject' | 'graphColAuthor' | 'graphColDate'
63
+ | 'openProject' | 'terminal' | 'openExplorer' | 'openVscode' | 'openIdea'
64
+ | 'openGoland' | 'openWebstorm' | 'openPycharm'
65
+ | 'termConnecting' | 'termExited' | 'termUnavailable' | 'termLost'
66
+
67
+ type Translate = (key: GitBarLabelKey) => string
68
+
69
+ interface ApiSuccess<T> { ok: true; value: T }
70
+ interface ApiFailure { ok: false; error: { code: string; message: string } }
71
+
72
+ interface GitFileChange {
73
+ path: string
74
+ status: string
75
+ added: number
76
+ deleted: number
77
+ untracked: boolean
78
+ }
79
+
80
+ interface GitSnapshot {
81
+ isRepo: boolean
82
+ /** Server could not resolve the target to a cwd — transient (the host is
83
+ * still materializing its session/workspace store right after a restart).
84
+ * Never cached as final; the client retries on a short backoff. */
85
+ unresolved?: boolean
86
+ cwd?: string
87
+ branch: string | null
88
+ detachedHead?: string
89
+ upstream?: string
90
+ ahead: number
91
+ behind: number
92
+ hasRemote: boolean
93
+ clean: boolean
94
+ files: GitFileChange[]
95
+ totalAdded: number
96
+ totalDeleted: number
97
+ }
98
+
99
+ interface GitBranches {
100
+ current: string | null
101
+ local: string[]
102
+ remote: string[]
103
+ }
104
+
105
+ /** One commit row of the graph dialog. */
106
+ interface GitGraphCommit {
107
+ graph: string
108
+ fullHash: string
109
+ hash: string
110
+ subject: string
111
+ author: string
112
+ date: string
113
+ dateRelative: string
114
+ refs: string
115
+ }
116
+
117
+ /** Git commit graph table returned by the `git/graph` endpoint. */
118
+ interface GitGraph {
119
+ commits: GitGraphCommit[]
120
+ truncated: boolean
121
+ }
122
+
123
+ interface DiffLine {
124
+ type: 'hunk' | 'add' | 'del' | 'ctx'
125
+ old: number | null
126
+ new: number | null
127
+ text: string
128
+ }
129
+
130
+ interface GitDiffResult {
131
+ path: string
132
+ mode: 'hunk' | 'full'
133
+ lines: DiffLine[]
134
+ truncated: boolean
135
+ }
136
+
137
+ async function apiGet<T>(path: string): Promise<T> {
138
+ const response = await fetch(path, { credentials: 'same-origin' })
139
+ const body = await response.json() as ApiSuccess<T> | ApiFailure
140
+ if (!response.ok || !body.ok) {
141
+ const failure = body as ApiFailure
142
+ throw new Error(failure.error?.message ?? `Git request failed with HTTP ${response.status}`)
143
+ }
144
+ return body.value
145
+ }
146
+
147
+ async function apiPost<T>(path: string, payload: unknown): Promise<T> {
148
+ const response = await fetch(path, {
149
+ method: 'POST',
150
+ credentials: 'same-origin',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify(payload),
153
+ })
154
+ const body = await response.json() as ApiSuccess<T> | ApiFailure
155
+ if (!response.ok || !body.ok) {
156
+ const failure = body as ApiFailure
157
+ throw new Error(failure.error?.message ?? `Git action failed with HTTP ${response.status}`)
158
+ }
159
+ return body.value
160
+ }
161
+
162
+ /**
163
+ * Where git ops resolve their working directory: a real session id, or a
164
+ * workspace title for the hero/new-session screen (which has no materialized
165
+ * session yet — the server resolves the picked workspace's path instead).
166
+ */
167
+ export interface GitTarget { session?: string; ws?: string }
168
+
169
+ function targetQuery(t: GitTarget): string {
170
+ if (t.session !== undefined) return `session=${encodeURIComponent(t.session)}`
171
+ if (t.ws !== undefined) return `ws=${encodeURIComponent(t.ws)}`
172
+ return ''
173
+ }
174
+
175
+ function targetFields(t: GitTarget): Record<string, string> {
176
+ const out: Record<string, string> = {}
177
+ if (t.session !== undefined) out.session = t.session
178
+ if (t.ws !== undefined) out.ws = t.ws
179
+ return out
180
+ }
181
+
182
+ /** Basename of a cwd path ('' / undefined → '—'), for the chip's folder label. */
183
+ function basenameOf(path: string | undefined): string {
184
+ if (path === undefined || path === '') return '—'
185
+ const norm = path.replace(/[\\/]+$/, '')
186
+ const idx = Math.max(norm.lastIndexOf('\\'), norm.lastIndexOf('/'))
187
+ return idx >= 0 ? norm.slice(idx + 1) : norm
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // GitBar styles — DSH theme tokens only (light + dark).
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export const GITBAR_CSS = `
195
+ /* DSH does not force border-box globally; normalize it for the whole GitBar
196
+ subtree (including the body-portaled panels/modal), or widths overflow. */
197
+ .gbar,.gbar *,.gbar-side,.gbar-side *,.gbar-modal-wrap,.gbar-modal-wrap *,.gbar-notice,.gbar-notice *{box-sizing:border-box}
198
+ /* Anchor around each pill inside the composer tool row
199
+ (conversation.input.left / .right): the branch pill's containing block, so
200
+ its popup opens from the pill itself. flex:0 1 auto + min-width:0 let the
201
+ pill compress when the row is squeezed instead of spilling over neighbours. */
202
+ .gbar{position:relative;display:inline-flex;align-items:center;gap:6px;flex:0 1 auto;min-width:0}
203
+ /* Pills match the input bar's resident chrome (access mode / model select):
204
+ 28px tall, fully-rounded, transparent at rest with a soft hover fill.
205
+ overflow:hidden clips the pill's content while it compresses. */
206
+ .gbar-pill{
207
+ display:inline-flex;align-items:center;gap:6px;
208
+ height:28px;padding:0 8px;min-width:0;overflow:hidden;
209
+ background:transparent;
210
+ border:none;
211
+ border-radius:24px;
212
+ color:var(--dsw-alias-label-secondary);
213
+ font:inherit;font-size:13px;font-weight:500;line-height:20px;
214
+ cursor:pointer;
215
+ transition:background .15s ease;
216
+ white-space:nowrap;
217
+ -webkit-user-select:none;user-select:none;
218
+ }
219
+ .gbar-pill:hover{background:var(--dsw-alias-interactive-bg-hover)}
220
+ .gbar-pill:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}
221
+ /* Long branch names ellipsize instead of stretching the row. */
222
+ .gbar-pill .gbar-branch{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:140px}
223
+ /* Icons match the resident access-mode trigger exactly: 14px, full color,
224
+ no fade, and box-centered (same vertical centre as the access icon). */
225
+ .gbar-pill .gbar-ico{width:14px;height:14px;flex:none;opacity:1}
226
+ .gbar-pill .gbar-caret{font-size:8px;color:var(--dsw-alias-label-tertiary);margin-left:1px}
227
+ .gbar-pill .gbar-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warn-primary);flex:none}
228
+ .gbar-pill .gbar-add{color:var(--dsw-alias-state-success-primary);font-weight:600;font-variant-numeric:tabular-nums}
229
+ .gbar-pill .gbar-del{color:var(--dsw-alias-state-error-primary);font-weight:600;font-variant-numeric:tabular-nums}
230
+ .gbar-pill .gbar-meta{color:var(--dsw-alias-label-secondary)}
231
+ .gbar-pill .gbar-hint{color:var(--dsw-alias-label-tertiary);font-weight:400}
232
+ .gbar-pill.gbar-active{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-business-primary)}
233
+ .gbar-pill.gbar-active .gbar-hint{color:var(--dsw-alias-state-business-primary)}
234
+ .gbar-pill:disabled{opacity:.5;cursor:default}
235
+ /* The composer tool row is an inline-size container (DSH sets
236
+ container-type:inline-size on it), so these container queries track the
237
+ row's actual width. When the stretched diff panel pushes the conversation
238
+ column and the row tightens — the same squeeze that makes the resident
239
+ access / model chrome collapse its labels to icons — the pills follow suit
240
+ instead of ever overlapping their neighbours: first drop the "· K 个文件"
241
+ meta, then go icon-only (before the row gets narrow enough for the branch
242
+ name to collide with the access control). */
243
+ @container (max-width: 700px){
244
+ .gbar-pill .gbar-meta{display:none}
245
+ }
246
+ @container (max-width: 620px){
247
+ .gbar-pill .gbar-text{display:none}
248
+ .gbar-pill .gbar-caret{display:none}
249
+ }
250
+
251
+ /* branch popup — the chip lives in the session HEADER now, so the menu opens
252
+ DOWNWARD (top-anchored, small arrow pointing up). Pinned header (current
253
+ branch + worktree state) and pinned action bar surround a freely scrolling
254
+ branch list. */
255
+ .gbar-pop{
256
+ position:absolute;left:0;top:calc(100% + 8px);z-index:120;
257
+ width:300px;
258
+ background:var(--dsw-alias-bg-layer-1);
259
+ border:1px solid var(--dsw-alias-border-l2);border-radius:14px;
260
+ box-shadow:var(--dsw-shadow-lv2);
261
+ display:flex;flex-direction:column;overflow:hidden;
262
+ animation:gpop-down .16s cubic-bezier(.32,.72,0,1);
263
+ }
264
+ .gbar-pop::before{content:"";position:absolute;top:-5px;left:22px;width:9px;height:9px;
265
+ background:var(--dsw-alias-bg-layer-1);border-left:1px solid var(--dsw-alias-border-l2);
266
+ border-top:1px solid var(--dsw-alias-border-l2);transform:rotate(45deg)}
267
+ @keyframes gpop-down{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:none}}
268
+ .gbar-pop-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-module-platform)}
269
+ .gbar-pop-head .gbar-bicon{width:14px;height:14px;flex:none}
270
+ .gbar-pop-head .gbar-curname{display:flex;align-items:center;gap:7px;min-width:0;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
271
+ .gbar-pop-head .gbar-curname .gbar-bicon{color:var(--dsw-alias-state-business-primary)}
272
+ .gbar-pop-head .gbar-curname>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
273
+ .gbar-pop-head .gbar-state{margin-left:auto;flex:none;display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
274
+ .gbar-pop-head .gbar-dot{width:6px;height:6px;border-radius:50%;flex:none}
275
+ .gbar-pop-head .gbar-dot.gbar-dirty{background:var(--dsw-alias-state-warn-primary)}
276
+ .gbar-pop-head .gbar-dot.gbar-clean{background:var(--dsw-alias-state-success-primary)}
277
+ .gbar-pop-body{max-height:min(330px,calc(100vh - 250px));overflow-y:auto;padding:3px 6px 5px;scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent) transparent}
278
+ .gbar-pop-body::-webkit-scrollbar{width:5px}
279
+ .gbar-pop-body::-webkit-scrollbar-track{background:transparent}
280
+ .gbar-pop-body::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent);border-radius:4px}
281
+ .gbar-pop .gbar-sec{display:flex;align-items:center;gap:6px;font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);padding:8px 9px 3px}
282
+ .gbar-pop .gbar-sec .gbar-count{margin-left:auto;font-size:10px;font-weight:500;letter-spacing:0;line-height:15px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform);border-radius:999px;padding:0 7px}
283
+ .gbar-pop .gbar-loading{display:flex;align-items:center;justify-content:center;gap:8px;padding:14px 9px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
284
+ .gbar-pop .gbar-row{
285
+ display:flex;align-items:center;gap:8px;width:100%;
286
+ padding:6px 8px;border:none;border-radius:8px;
287
+ background:transparent;color:var(--dsw-alias-label-primary);
288
+ font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
289
+ transition:background .12s ease;
290
+ }
291
+ .gbar-pop .gbar-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
292
+ .gbar-pop .gbar-row.gbar-cur{color:var(--dsw-alias-state-business-primary);font-weight:600}
293
+ .gbar-pop .gbar-row .gbar-bicon{width:13px;height:13px;flex:none;color:var(--dsw-alias-label-tertiary);opacity:.85}
294
+ .gbar-pop .gbar-row.gbar-cur .gbar-bicon{color:var(--dsw-alias-state-business-primary);opacity:1}
295
+ .gbar-pop .gbar-row .gbar-check{margin-left:auto;color:var(--dsw-alias-state-business-primary);font-weight:700}
296
+ .gbar-pop .gbar-row .gbar-rm{flex:none;font-size:10px;line-height:15px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform);border-radius:999px;padding:0 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px}
297
+ /* branch row wrapper: main switch button + delete button */
298
+ .gbar-pop .gbar-rowwrap{display:flex;align-items:center;gap:2px;border-radius:8px;transition:background .12s ease}
299
+ .gbar-pop .gbar-rowwrap:hover{background:var(--dsw-alias-interactive-bg-hover)}
300
+ .gbar-pop .gbar-rowwrap .gbar-row{background:transparent}
301
+ .gbar-pop .gbar-del{
302
+ flex:none;display:inline-flex;align-items:center;justify-content:center;
303
+ width:26px;height:26px;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);
304
+ font:inherit;font-size:10.5px;cursor:pointer;border-radius:7px;margin-right:4px;
305
+ transition:color .12s ease,background .12s ease;
306
+ }
307
+ .gbar-pop .gbar-del svg{width:13px;height:13px;display:block}
308
+ .gbar-pop .gbar-del:hover{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
309
+ .gbar-pop .gbar-del.gbar-arm{width:auto;padding:0 8px;color:var(--dsw-alias-state-error-primary);font-weight:600;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
310
+ .gbar-pushrow{display:flex;align-items:center;gap:7px;padding:0 8px 7px}
311
+ .gbar-pushrow .gbar-pushlabel{font-size:12px;color:var(--dsw-alias-label-secondary)}
312
+ .gbar-pop .gbar-newrow{display:flex;gap:6px;padding:8px 6px 6px}
313
+ .gbar-pop input{
314
+ flex:1;min-width:0;height:32px;padding:0 11px;
315
+ border:1px solid var(--dsw-alias-border-l2);border-radius:9px;
316
+ background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
317
+ font:inherit;font-size:13px;outline:none;
318
+ }
319
+ .gbar-pop input:focus{border-color:var(--dsw-alias-state-business-primary)}
320
+ .gbar-pop input::placeholder{color:var(--dsw-alias-label-tertiary)}
321
+ .gbar-mini{
322
+ flex:none;height:32px;padding:0 14px;border:none;border-radius:9px;
323
+ background:var(--dsw-alias-state-business-primary);color:#fff;
324
+ font:inherit;font-size:13px;font-weight:500;cursor:pointer;
325
+ }
326
+ .gbar-mini:hover{opacity:.92}
327
+ .gbar-mini:disabled{opacity:.5;cursor:default}
328
+
329
+ /* branch popup action entries (new branch / graph) — pinned below the
330
+ scrolling list; the border-top separates it from the body. */
331
+ .gbar-actions{display:flex;gap:6px;padding:8px;border-top:1px solid var(--dsw-alias-border-l1)}
332
+ .gbar-act{
333
+ flex:1;display:inline-flex;align-items:center;justify-content:center;gap:7px;
334
+ height:34px;border:none;border-radius:10px;
335
+ background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);
336
+ font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;
337
+ }
338
+ .gbar-act:hover{background:var(--dsw-alias-interactive-bg-hover-solid)}
339
+ .gbar-act svg{width:14px;height:14px;opacity:.9;flex:none}
340
+ /* base-branch selector (new-branch dialog) */
341
+ .gbar-baserow{display:flex;align-items:center;gap:7px;padding:0}
342
+ .gbar-baselabel{font-size:12px;color:var(--dsw-alias-label-secondary);flex:none}
343
+ .gbar-base{
344
+ flex:1;min-width:0;height:32px;padding:0 10px;
345
+ border:1px solid var(--dsw-alias-border-l2);border-radius:9px;
346
+ background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
347
+ font:inherit;font-size:12.5px;outline:none;cursor:pointer;
348
+ }
349
+ .gbar-base:focus{border-color:var(--dsw-alias-state-business-primary)}
350
+ .gbar-modal input{
351
+ width:100%;height:38px;padding:0 13px;
352
+ border:1px solid var(--dsw-alias-border-l2);border-radius:10px;
353
+ background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
354
+ font:inherit;font-size:13.5px;outline:none;
355
+ }
356
+ .gbar-modal input:focus{border-color:var(--dsw-alias-state-business-primary)}
357
+ .gbar-modal input::placeholder{color:var(--dsw-alias-label-tertiary)}
358
+ .gbar-modal .gbar-head{padding-bottom:10px;border-bottom:1px solid var(--dsw-alias-border-l1)}
359
+ /* "push to remote" row becomes an inset card so it reads as one option group. */
360
+ .gbar-modal .gbar-pushrow{padding:9px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-2)}
361
+ .gbar-modal .gbar-pushrow .gbar-pushlabel{font-size:12.5px;color:var(--dsw-alias-label-primary)}
362
+ .gbar-x:disabled{opacity:.5;cursor:default}
363
+ /* commit-graph dialog */
364
+ .gbar-modal.gbar-graph-modal{width:min(760px,calc(100vw - 40px));gap:10px}
365
+ /* Refresh sits immediately left of close, both pinned to the right edge: the
366
+ refresh takes the auto margin (specificity .gbar-x.gbar-refresh beats the
367
+ .gbar-x override below), close drops it so they stay adjacent. */
368
+ .gbar-modal.gbar-graph-modal .gbar-head .gbar-x.gbar-refresh{margin-left:auto}
369
+ .gbar-modal.gbar-graph-modal .gbar-head .gbar-x{margin-left:0}
370
+ .gbar-modal.gbar-graph-modal .gbar-x svg{width:15px;height:15px;display:block}
371
+ .gbar-graph-empty{padding:28px 12px;text-align:center;font-size:13px;color:var(--dsw-alias-label-tertiary)}
372
+ .gbar-graph-table{display:flex;flex-direction:column;max-height:min(60vh,540px);overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-2)}
373
+ .gbar-graph-row{display:grid;grid-template-columns:62px 1fr 96px 90px;align-items:center;gap:8px;padding:5px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);transition:background .12s ease}
374
+ .gbar-graph-row:last-child{border-bottom:none}
375
+ /* Zebra striping for scanability; the hover rule matches its specificity and
376
+ comes later in the sheet, so pointing at a row still wins. */
377
+ .gbar-graph-row:not(.gbar-graph-head):nth-child(odd){background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 35%,transparent)}
378
+ .gbar-graph-row:not(.gbar-graph-head):hover{background:var(--dsw-alias-interactive-bg-hover)}
379
+ .gbar-graph-head{position:sticky;top:0;background:var(--dsw-alias-bg-module-platform);font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);z-index:1;border-bottom:1px solid var(--dsw-alias-border-l1)}
380
+ .gbar-c-hash code{font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform);border-radius:6px;padding:2px 6px}
381
+ .gbar-c-subject{min-width:0;display:flex;align-items:center;gap:8px}
382
+ .gbar-subject{font-size:12.5px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
383
+ .gbar-refs{flex:none;font-size:10.5px;color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);border-radius:999px;padding:1px 8px;white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
384
+ .gbar-c-author{font-size:12px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
385
+ .gbar-c-date{font-size:11.5px;color:var(--dsw-alias-label-tertiary);text-align:right;white-space:nowrap}
386
+
387
+ /* diff side panel — fixed on the right; the conversation is pushed left via
388
+ #root { margin-right } so the panel never overlaps the message column. */
389
+ .gbar-side{
390
+ position:fixed;right:0;top:0;bottom:0;z-index:80;
391
+ display:flex;flex-direction:column;overflow:hidden;
392
+ /* bg-base instead of bg-layer-1: in dark mode layer-1 (#232324) reads gray
393
+ next to the conversation's base (#151517); base matches it exactly, and in
394
+ light mode both aliases are #fff so nothing changes. */
395
+ background:var(--dsw-alias-bg-base);
396
+ border-left:1px solid var(--dsw-alias-border-l2);
397
+ box-shadow:var(--dsw-shadow-lv2);
398
+ animation:gbar-in .18s cubic-bezier(.32,.72,0,1);
399
+ }
400
+ /* drag handle on the panel's left edge */
401
+ .gbar-resize{
402
+ position:absolute;left:-5px;top:0;bottom:0;width:10px;cursor:col-resize;
403
+ touch-action:none;
404
+ }
405
+ .gbar-resize::after{
406
+ content:"";position:absolute;left:4px;top:0;bottom:0;width:2px;
407
+ background:transparent;transition:background .15s ease;
408
+ }
409
+ .gbar-side:hover .gbar-resize::after{background:var(--dsw-alias-border-l2)}
410
+ .gbar-resize:active::after{background:var(--dsw-alias-state-business-primary)}
411
+ @keyframes gbar-in{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}
412
+ .gbar-side-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l1)}
413
+ .gbar-side-head .gbar-title{font-size:13.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
414
+ .gbar-side-head .gbar-sub{font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none;font-variant-numeric:tabular-nums}
415
+ .gbar-side-head .gbar-sub .gbar-a{color:var(--dsw-alias-state-success-primary)}
416
+ .gbar-side-head .gbar-sub .gbar-d{color:var(--dsw-alias-state-error-primary)}
417
+ .gbar-side-head .gbar-spacer{flex:1}
418
+ .gbar-seg{display:inline-flex;padding:3px;gap:2px;border-radius:10px;background:var(--dsw-alias-bg-module-platform);flex:none}
419
+ .gbar-seg button{border:none;border-radius:8px;padding:4px 10px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
420
+ .gbar-seg button.gbar-on{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-shadow-lv1)}
421
+ .gbar-side-x{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:15px;cursor:pointer;width:28px;height:28px;border-radius:8px;flex:none}
422
+ .gbar-side-x:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
423
+ .gbar-side-body{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}
424
+ /* The three stacked sections (files / diff / commit) are sized by drag: the two
425
+ neighbours carry an explicit inline height and the diff pane absorbs whatever
426
+ is left, so a drag can never overflow the panel. The dividing hairlines are
427
+ painted by the splitters, not by the sections' own borders. */
428
+ .gbar-files{flex:none;overflow-y:auto;scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent) transparent}
429
+ .gbar-files::-webkit-scrollbar{width:5px}
430
+ .gbar-files::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent);border-radius:4px}
431
+ /* horizontal drag splitters between the sections */
432
+ .gbar-vsplit{
433
+ flex:none;position:relative;height:7px;
434
+ cursor:row-resize;touch-action:none;-webkit-user-select:none;user-select:none;
435
+ }
436
+ .gbar-vsplit::after{
437
+ content:"";position:absolute;left:0;right:0;top:3px;height:1px;
438
+ background:var(--dsw-alias-border-l1);transition:background .15s ease,height .15s ease;
439
+ }
440
+ .gbar-vsplit:hover::after{background:var(--dsw-alias-border-l2);height:2px}
441
+ .gbar-vsplit.gbar-dragging::after{background:var(--dsw-alias-state-business-primary);height:2px}
442
+ .gbar-file{
443
+ display:flex;align-items:center;gap:6px;width:100%;
444
+ padding:3px 14px;background:transparent;
445
+ }
446
+ .gbar-file .gbar-file-main{
447
+ flex:1;min-width:0;display:flex;align-items:center;gap:9px;
448
+ padding:5px 4px;border:none;border-radius:7px;background:transparent;
449
+ color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
450
+ }
451
+ .gbar-file .gbar-file-main:hover{background:var(--dsw-alias-interactive-bg-hover)}
452
+ .gbar-file.gbar-on .gbar-file-main{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 9%,transparent);box-shadow:inset 2px 0 0 var(--dsw-alias-state-business-primary)}
453
+ .gbar-file.gbar-excl{opacity:.55}
454
+ .gbar-chk{
455
+ flex:none;width:16px;height:16px;padding:0;
456
+ border:1px solid var(--dsw-alias-border-l2);border-radius:5px;
457
+ background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-business-primary);
458
+ font-size:11px;line-height:1;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;
459
+ }
460
+ .gbar-chk:hover{border-color:var(--dsw-alias-state-business-primary)}
461
+ .gbar-chk.gbar-off{color:transparent}
462
+ .gbar-file .gbar-st{
463
+ flex:none;min-width:17px;height:17px;padding:0 4px;
464
+ display:inline-flex;align-items:center;justify-content:center;
465
+ font-weight:700;font-size:10.5px;border-radius:5px;
466
+ }
467
+ .gbar-file .gbar-st.gbar-m{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 13%,transparent)}
468
+ .gbar-file .gbar-st.gbar-a{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 13%,transparent)}
469
+ .gbar-file .gbar-st.gbar-d{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}
470
+ .gbar-file .gbar-st.gbar-u{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform)}
471
+ .gbar-file .gbar-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:12px}
472
+ /* Directory part dims, filename stays bright — GitHub-style path rendering. */
473
+ .gbar-file .gbar-path .gbar-dir{color:var(--dsw-alias-label-tertiary)}
474
+ .gbar-file .gbar-nums{flex:none;font-variant-numeric:tabular-nums;font-size:11.5px}
475
+ .gbar-file .gbar-nums .gbar-a{color:var(--dsw-alias-state-success-primary)}
476
+ .gbar-file .gbar-nums .gbar-d{color:var(--dsw-alias-state-error-primary)}
477
+ .gbar-diff{
478
+ flex:1;min-height:0;overflow:auto;
479
+ font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:12px;line-height:1.7;padding:8px 0;
480
+ scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent) transparent;
481
+ }
482
+ .gbar-diff::-webkit-scrollbar{width:5px;height:5px}
483
+ .gbar-diff::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent);border-radius:4px}
484
+ /* Hunk header: a centered pill strip (GitHub-style), gutter numbers hidden but
485
+ still occupying space so the code column stays aligned. */
486
+ .gbar-diff .gbar-line.gbar-hunk{background:transparent;padding-top:7px;padding-bottom:3px}
487
+ .gbar-diff .gbar-line.gbar-hunk .gbar-ln{visibility:hidden}
488
+ .gbar-diff .gbar-line.gbar-hunk .gbar-code{color:var(--dsw-alias-state-business-primary)}
489
+ .gbar-diff .gbar-line{display:flex;align-items:center;min-height:21px;padding:0 14px 0 0;white-space:pre}
490
+ .gbar-diff .gbar-line .gbar-ln{flex:none;width:34px;text-align:right;padding-right:8px;color:var(--dsw-alias-label-tertiary);font-size:11px;-webkit-user-select:none;user-select:none}
491
+ .gbar-diff .gbar-line .gbar-ln+.gbar-ln{border-right:1px solid var(--dsw-alias-border-l1);margin-right:10px}
492
+ .gbar-diff .gbar-line .gbar-code{flex:1;white-space:pre;color:var(--dsw-alias-label-primary);padding-right:14px}
493
+ .gbar-diff .gbar-line.gbar-add{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 13%,transparent);box-shadow:inset 2px 0 0 color-mix(in srgb,var(--dsw-alias-state-success-primary) 55%,transparent)}
494
+ .gbar-diff .gbar-line.gbar-add .gbar-ln{color:var(--dsw-alias-state-success-primary)}
495
+ .gbar-diff .gbar-line.gbar-del{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);box-shadow:inset 2px 0 0 color-mix(in srgb,var(--dsw-alias-state-error-primary) 50%,transparent)}
496
+ .gbar-diff .gbar-line.gbar-del .gbar-ln{color:var(--dsw-alias-state-error-primary)}
497
+ .gbar-diff .gbar-line.gbar-ctx .gbar-code{color:var(--dsw-alias-label-secondary)}
498
+ .gbar-diff .gbar-empty{display:flex;align-items:center;justify-content:center;min-height:140px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
499
+ /* The hairline above the foot is drawn by the commit splitter that precedes it. */
500
+ .gbar-side-foot{flex:none;padding:8px 14px;font-size:11.5px;color:var(--dsw-alias-label-tertiary);display:flex;gap:8px;align-items:center}
501
+ .gbar-side-foot .gbar-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warn-primary);flex:none}
502
+ /* commit row inside the diff panel — height is drag-adjustable, so the message
503
+ field stretches to fill whatever the user gives this section. */
504
+ .gbar-side-commit{flex:none;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:7px;padding:9px 12px;border-top:1px solid var(--dsw-alias-border-l1)}
505
+ .gbar-side-commit .gbar-crow{flex:1;min-height:0;display:flex;align-items:stretch;gap:6px}
506
+ .gbar-side-commit textarea{
507
+ flex:1;min-width:0;min-height:30px;padding:6px 10px;resize:none;
508
+ border:1px solid var(--dsw-alias-border-l2);border-radius:8px;
509
+ background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
510
+ font:inherit;font-size:12px;line-height:1.6;outline:none;overflow-y:auto;
511
+ }
512
+ .gbar-side-commit textarea:focus{border-color:var(--dsw-alias-state-business-primary)}
513
+ .gbar-side-commit textarea::placeholder{color:var(--dsw-alias-label-tertiary)}
514
+ .gbar-side-commit .gbar-actions{flex:none;display:flex;align-items:center;justify-content:flex-end;gap:6px}
515
+ .gbar-side-commit .gbar-btn{height:28px;padding:0 12px;font-size:12px;border-radius:8px}
516
+
517
+ /* commit modal */
518
+ .gbar-modal-wrap{
519
+ position:fixed;inset:0;z-index:120;
520
+ display:flex;align-items:center;justify-content:center;
521
+ background:rgba(0,0,0,.35);
522
+ }
523
+ .gbar-modal{
524
+ width:min(520px,calc(100vw - 40px));max-height:calc(100vh - 48px);overflow:hidden auto;
525
+ background:var(--dsw-alias-bg-layer-1);
526
+ border:1px solid var(--dsw-alias-border-l2);border-radius:16px;
527
+ box-shadow:var(--dsw-shadow-lv2);
528
+ padding:18px;
529
+ display:flex;flex-direction:column;gap:12px;
530
+ animation:gbar-in .18s cubic-bezier(.32,.72,0,1);
531
+ }
532
+ .gbar-modal .gbar-head{display:flex;align-items:center;gap:10px}
533
+ .gbar-modal .gbar-head .gbar-title{font-size:15px;font-weight:600}
534
+ .gbar-modal .gbar-head .gbar-branch{font-size:12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform);border-radius:999px;padding:3px 10px}
535
+ .gbar-modal .gbar-head .gbar-x{margin-left:auto;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:16px;cursor:pointer;width:28px;height:28px;border-radius:8px}
536
+ .gbar-modal .gbar-head .gbar-x:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
537
+ .gbar-modal textarea{
538
+ width:100%;min-height:64px;height:auto;max-height:220px;resize:none;
539
+ overflow-y:auto;scrollbar-width:none;
540
+ border:1px solid var(--dsw-alias-border-l2);border-radius:12px;
541
+ background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
542
+ font:inherit;font-size:14px;line-height:1.6;padding:10px 14px;outline:none;
543
+ }
544
+ .gbar-modal textarea::-webkit-scrollbar{display:none}
545
+ .gbar-modal textarea:focus{border-color:var(--dsw-alias-state-business-primary)}
546
+ .gbar-modal textarea::placeholder{color:var(--dsw-alias-label-tertiary)}
547
+ .gbar-modal .gbar-hint{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:1.6}
548
+ .gbar-modal .gbar-files-head{display:flex;align-items:center;gap:8px;font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);margin-top:2px}
549
+ .gbar-modal .gbar-files-head .gbar-hint{font-size:11px;font-weight:400;text-transform:none;letter-spacing:0}
550
+ .gbar-modal .gbar-files{
551
+ display:flex;flex-direction:column;gap:1px;max-height:132px;overflow-y:auto;
552
+ border:1px solid var(--dsw-alias-border-l1);border-radius:10px;
553
+ background:var(--dsw-alias-bg-layer-2);padding:4px;
554
+ }
555
+ .gbar-modal .gbar-file{
556
+ display:flex;align-items:center;gap:6px;width:100%;
557
+ padding:2px 6px;border-radius:7px;background:transparent;
558
+ }
559
+ .gbar-modal .gbar-file .gbar-file-main{
560
+ padding:4px 4px;font-size:12px;border-radius:6px;
561
+ }
562
+ .gbar-modal .gbar-file .gbar-file-main:hover{background:var(--dsw-alias-interactive-bg-hover)}
563
+ .gbar-modal .gbar-file.gbar-on .gbar-file-main{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}
564
+ .gbar-modal .gbar-file .gbar-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:11.5px}
565
+ .gbar-modal .gbar-file .gbar-goto{flex:none;color:var(--dsw-alias-label-tertiary);font-size:10px}
566
+ .gbar-modal .gbar-file.gbar-on .gbar-goto{color:var(--dsw-alias-state-business-primary)}
567
+ .gbar-modal .gbar-foot{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:2px;flex-wrap:wrap}
568
+ .gbar-btn{
569
+ height:34px;padding:0 16px;border:none;border-radius:10px;
570
+ font:inherit;font-size:13.5px;cursor:pointer;
571
+ display:inline-flex;align-items:center;gap:6px;
572
+ }
573
+ .gbar-btn.gbar-ghost{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary)}
574
+ .gbar-btn.gbar-ghost:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-solid)}
575
+ .gbar-btn.gbar-soft{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);color:var(--dsw-alias-state-business-primary)}
576
+ .gbar-btn.gbar-soft:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent)}
577
+ .gbar-btn.gbar-primary{background:var(--dsw-alias-state-business-primary);color:#fff;font-weight:500}
578
+ .gbar-btn.gbar-primary:hover:not(:disabled){opacity:.92}
579
+ /* Disabled buttons (e.g. while a task is running) must NOT light up on hover —
580
+ they keep their muted look, though the tooltip hint still shows. */
581
+ .gbar-btn:disabled{opacity:.5;cursor:default}
582
+ .gbar-btn:disabled:hover{opacity:.5}
583
+ .gbar-notice{
584
+ position:fixed;z-index:150;left:50%;bottom:28px;transform:translateX(-50%);
585
+ max-width:min(520px,90vw);padding:9px 16px;border-radius:10px;font-size:12.5px;line-height:1.5;
586
+ box-shadow:var(--dsw-shadow-lv2);animation:gbar-in .18s cubic-bezier(.32,.72,0,1);
587
+ }
588
+ .gbar-notice.gbar-ok{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 16%,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-state-success-primary)}
589
+ .gbar-notice.gbar-err{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 14%,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-state-error-primary)}
590
+ .gbar-spin{width:12px;height:12px;border-radius:50%;border:2px solid color-mix(in srgb,var(--dsw-alias-label-tertiary) 30%,transparent);border-top-color:currentColor;animation:gbar-spin .7s linear infinite}
591
+ @keyframes gbar-spin{to{transform:rotate(360deg)}}
592
+
593
+ /* branch capsule — the only interactive half of the header seat: 22px tall
594
+ like the resident agent-preset badge (same 12px type), fully-rounded,
595
+ quiet fill on hover, active tint while the popup is open. */
596
+ .gbar-chip{position:relative;display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 9px;
597
+ border:none;border-radius:16px;background:transparent;color:var(--dsw-alias-label-secondary);
598
+ font:inherit;font-size:12px;font-weight:400;line-height:22px;cursor:pointer;white-space:nowrap;
599
+ -webkit-user-select:none;user-select:none;transition:background .15s ease}
600
+ .gbar-chip:hover,.gbar-chip:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}
601
+ .gbar-chip.gbar-open{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-business-primary)}
602
+ .gbar-chip svg{width:14px;height:14px;flex:none;opacity:.9}
603
+ .gbar-chip .gbar-bname{max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
604
+ /* folder label — plain context beside the capsule: NO background (not a
605
+ capsule), not clickable and no hover, so only the branch carries the pill.
606
+ Same 22px / 12px type as the agent-preset badge beside it, and like that
607
+ label the folder name stays selectable so it can be copied with the mouse;
608
+ cursor:auto lets the browser show the text caret over the selectable name,
609
+ exactly like that label. */
610
+ .gbar-fchip{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 2px;
611
+ color:var(--dsw-alias-label-secondary);
612
+ font:inherit;font-size:12px;font-weight:400;line-height:22px;white-space:nowrap;
613
+ -webkit-user-select:text;user-select:text;cursor:auto}
614
+ .gbar-fchip svg{width:14px;height:14px;flex:none;opacity:.9}
615
+ .gbar-fchip .gbar-folder{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
616
+ /* wraps the interactive branch capsule so its popup anchors to the capsule,
617
+ not to the whole folder + branch row */
618
+ .gbar-bwrap{position:relative;display:inline-flex;align-items:center}
619
+
620
+ /* header utilities row: 打开项目 / 终端 / 差异 */
621
+ .gbar-hicons{margin-left:auto;display:inline-flex;align-items:center;gap:2px;position:relative}
622
+ /* The three header utility icons are injected into the title row's utilities
623
+ slot (beside "Session log"), but they belong in the view-tabs row (对话 /
624
+ 轨迹) right below it. The session header is their containing block: anchor
625
+ them to the right edge, vertically centred on the 16px tab text line that
626
+ starts under the 32px title row (12px header top padding + 32px title row
627
+ + 4px tab margin). The 24px button starts exactly at the title row's
628
+ bottom edge so its hover surface never touches "Session log" above. The
629
+ :has() guard keeps the in-flow position for the rare header without a
630
+ tablist, and z-index keeps them above the tabs' own stacking context. */
631
+ header:has([role="tablist"]) .gbar-hicons{
632
+ position:absolute;
633
+ top:calc(12px + 32px + 4px + (16px - 24px)/2);
634
+ right:28px;
635
+ z-index:2;
636
+ }
637
+ .gbar-hicon{position:relative;width:32px;height:24px;display:inline-flex;align-items:center;justify-content:center;
638
+ border:none;background:transparent;border-radius:8px;color:var(--dsw-alias-label-secondary);cursor:pointer;
639
+ transition:background .14s ease,color .14s ease}
640
+ .gbar-hicon:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
641
+ .gbar-hicon.gbar-on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);color:var(--dsw-alias-state-business-primary)}
642
+ .gbar-hicon svg{width:16px;height:16px}
643
+ /* uncommitted-changes dot on the diff icon — warn-primary, the same color
644
+ the branch chip / diff footer use for the dirty state */
645
+ .gbar-hicon-dot{position:absolute;top:2.5px;right:2.5px;width:6px;height:6px;border-radius:50%;
646
+ background:var(--dsw-alias-state-warn-primary);pointer-events:none}
647
+
648
+ /* 「打开项目」menu — a flat dropdown opening leftward from the icon group:
649
+ right edge sits 20px inside the window (the group anchors 28px from the
650
+ edge, so right:-8px), no bubble tail, compact rows, width fits content */
651
+ .gbar-openmenu{position:absolute;top:calc(100% + 6px);right:-8px;min-width:150px;max-width:260px;width:max-content;z-index:130;
652
+ background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;
653
+ box-shadow:var(--dsw-shadow-lv2);padding:4px;text-align:left;animation:gpop-down .16s cubic-bezier(.32,.72,0,1)}
654
+ .gbar-omrow{display:flex;align-items:center;gap:9px;width:100%;padding:5px 8px;border:none;border-radius:7px;
655
+ background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;line-height:1.2;cursor:pointer;text-align:left;
656
+ white-space:nowrap}
657
+ .gbar-omrow:hover{background:var(--dsw-alias-interactive-bg-hover)}
658
+ /* brand app icons (official full-color SVGs) — fixed slot, sized by CSS */
659
+ .gbar-omrow .gbar-aicon{width:15px;height:15px;flex:none;display:inline-flex}
660
+ .gbar-aicon svg{width:100%;height:100%;display:block}
661
+
662
+ /* terminal panel body — follows the app theme surface (tokens live on body,
663
+ and the panel portal renders inside body, so the var resolves); the xterm
664
+ viewport is transparent, so this is also the terminal's own backdrop */
665
+ .gbar-term{flex:1;min-height:0;background:var(--dsw-alias-bg-base,#16161b);color:var(--dsw-alias-label-primary,#d6d6dc);cursor:text;
666
+ font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:12px;line-height:1.75;
667
+ padding:10px 14px;overflow:auto;scrollbar-width:thin}
668
+ .gbar-term:focus-visible{outline:none}
669
+ .gbar-term .gbar-ps1{color:#7d94ff}
670
+ .gbar-term .gbar-tcmd{color:#ffffff}
671
+ .gbar-term .gbar-tout{white-space:pre-wrap;word-break:break-all;opacity:.88}
672
+ .gbar-term .gbar-tdim{opacity:.45}
673
+ .gbar-term .gbar-terr{color:#ef6b70}
674
+ .gbar-term .gbar-tin{display:flex;align-items:baseline}
675
+ .gbar-term .gbar-tin input{flex:1;min-width:0;background:transparent;border:none;outline:none;color:#ffffff;
676
+ font:inherit;font-size:12px;line-height:1.75;caret-color:#7d94ff;padding:0}
677
+
678
+ /* xterm host variant — the emulator fills the body and sizes itself via the
679
+ fit addon, so the container clips instead of scrolling. */
680
+ .gbar-xterm{padding:4px 6px;overflow:hidden;cursor:normal}
681
+ .gbar-xterm .xterm{height:100%}
682
+ .gbar-xterm .xterm .xterm-viewport{background:transparent !important}
683
+
684
+ /* fatal terminal error banner (pty unavailable / socket refused) */
685
+ .gbar-term-error{position:absolute;left:12px;right:12px;bottom:12px;z-index:6;display:flex;align-items:center;gap:10px;
686
+ padding:9px 12px;border-radius:10px;border:1px solid color-mix(in srgb,#ef6b70 45%,transparent);
687
+ background:var(--dsw-alias-bg-base,#16161b);box-shadow:0 8px 24px rgba(0,0,0,.25)}
688
+ .gbar-term-error-text{flex:1;min-width:0;font-size:12px;color:var(--dsw-alias-label-primary);
689
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
690
+ .gbar-term-error button{border:1px solid var(--dsw-alias-border-l1);background:transparent;border-radius:7px;
691
+ color:var(--dsw-alias-label-secondary);width:26px;height:26px;cursor:pointer;flex:none;font-size:13px}
692
+ .gbar-term-error button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
693
+
694
+ /* half-screen toggle button in side-panel heads */
695
+ .gbar-side-half{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-tertiary);
696
+ width:28px;height:28px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;
697
+ cursor:pointer;flex:none;transition:background .15s ease,color .15s ease,border-color .15s ease}
698
+ .gbar-side-half:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
699
+ .gbar-side-half.gbar-on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);
700
+ color:var(--dsw-alias-state-business-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 40%,transparent)}
701
+ .gbar-side-half svg{width:15px;height:15px;display:block}
702
+
703
+ /* hero (new-session) floating chip wrapper */
704
+ .gbar-hero{position:fixed;z-index:110;transform:translateY(-50%);display:inline-flex;align-items:center;gap:6px}
705
+ @media (prefers-reduced-motion:reduce){.gbar-side,.gbar-modal,.gbar-notice,.gbar-pop,.gbar-openmenu{animation:none}}
706
+ `
707
+
708
+ /** Install the GitBar stylesheet once (idempotent); returns the disposer. */
709
+ export function installGitBarStyles(): () => void {
710
+ const id = 'dsh-ui-tweaks-gitbar'
711
+ const existing = document.querySelector(`style[data-plugin-css="${id}"]`)
712
+ if (existing !== null) return () => {}
713
+ const style = document.createElement('style')
714
+ style.dataset.plugin = 'dsh-ui-tweaks'
715
+ style.dataset.pluginCss = id
716
+ style.textContent = GITBAR_CSS
717
+ document.head.appendChild(style)
718
+ return () => { style.remove() }
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Status / data plumbing.
723
+ // ---------------------------------------------------------------------------
724
+
725
+ /** Extract a short branch name for display (origin/main → main). */
726
+ function shortBranch(name: string): string {
727
+ const idx = name.lastIndexOf('/')
728
+ return idx >= 0 && name.startsWith('origin/') ? name.slice(idx + 1) : name
729
+ }
730
+
731
+ /** Branch names that must never be deletable (main/master). */
732
+ function isProtectedBranch(name: string): boolean {
733
+ return name === 'main' || name === 'master'
734
+ }
735
+
736
+ function statusLetter(status: string): string {
737
+ if (status === 'U') return 'U'
738
+ return status.length > 1 ? status[0] ?? 'M' : status
739
+ }
740
+
741
+ function statusClass(status: string): string {
742
+ const s = statusLetter(status)
743
+ if (s === 'A') return 'gbar-a'
744
+ if (s === 'D') return 'gbar-d'
745
+ if (s === 'U') return 'gbar-u'
746
+ return 'gbar-m'
747
+ }
748
+
749
+ // ---------------------------------------------------------------------------
750
+ // Git status polling hook (shared by both pills and both panels).
751
+ // ---------------------------------------------------------------------------
752
+
753
+ /**
754
+ * Module-level snapshot cache keyed by target. Every useGitStatus instance
755
+ * (header icons, diff panel, terminal panel) reads and writes through it, so
756
+ * a panel mounting on click starts from the warm snapshot the header poller
757
+ * already fetched instead of a blank state — this is what makes the diff
758
+ * panel paint instantly instead of waiting for its own /status round-trip.
759
+ */
760
+ const snapshotCache = new Map<string, GitSnapshot>()
761
+
762
+ // A localStorage mirror of this cache existed briefly and was retired; sweep
763
+ // away any copy an older build left behind in this browser.
764
+ try { window.localStorage.removeItem('dsh-ui-tweaks.git.snapshots.v1') } catch { /* storage unavailable */ }
765
+
766
+ /** In-flight warmups keyed by target, so the dock warmup and a mounting chip
767
+ * never double-fetch the same target. */
768
+ const warmInflight = new Map<string, Promise<void>>()
769
+
770
+ /**
771
+ * Fetch a target's status into the shared cache WITHOUT rendering anything.
772
+ *
773
+ * The session-header chips mount only after the conversation projection
774
+ * finishes loading — seconds on large inactive sessions — so their own fetch
775
+ * would start just as late. This warmup runs from the input dock, which mounts
776
+ * immediately on session switch: by the time the header chip appears it reads
777
+ * a warm cache and paints folder+branch instantly.
778
+ */
779
+ export function warmGitStatus(target: GitTarget): void {
780
+ const key = target.session ?? target.ws ?? ''
781
+ if (key === '' || snapshotCache.has(key) || warmInflight.has(key)) return
782
+ const flight = apiGet<GitSnapshot>(`${GIT_ROUTE}/status?${targetQuery(target)}`)
783
+ .then(next => {
784
+ // An unresolved answer (host still starting up) must not poison the
785
+ // cache as a final "not a repo"; the mounting chip re-fetches anyway.
786
+ if (!next.unresolved) snapshotCache.set(key, next)
787
+ })
788
+ .catch(() => {
789
+ // Warmup is best-effort; the mounting chip re-fetches anyway.
790
+ })
791
+ .finally(() => { warmInflight.delete(key) })
792
+ warmInflight.set(key, flight)
793
+ }
794
+
795
+ /** Input-dock seat for {@link warmGitStatus}: mounts immediately on session
796
+ * switch (long before the session header does) and kicks off the prefetch. */
797
+ export function GitWarmup({ sessionId }: { sessionId: SessionId }) {
798
+ useEffect(() => {
799
+ warmGitStatus({ session: String(sessionId) })
800
+ }, [sessionId])
801
+ return null
802
+ }
803
+
804
+ /** Fast retry steps (ms) for the startup race: right after a restart the host
805
+ * has not materialized its session/workspace store yet, so the first /status
806
+ * answers `unresolved`. Without these the UI sat blank for a full POLL_MS. */
807
+ const RETRY_DELAYS_MS = [400, 800, 1600, 3200] as const
808
+ /** Upper bound on fast retries before falling back to the steady poll. */
809
+ const FAST_RETRY_MAX = 6
810
+
811
+ /** Load the git snapshot on session change, then poll; returns [snapshot, refresh]. */
812
+ function useGitStatus(enabled: boolean, target: GitTarget): [GitSnapshot | null, () => Promise<void>] {
813
+ const key = target.session ?? target.ws ?? ''
814
+ // Lazy initializer: a freshly mounted panel is warm on its very first render.
815
+ const [snapshot, setSnapshot] = useState<GitSnapshot | null>(() => snapshotCache.get(key) ?? null)
816
+
817
+ useEffect(() => {
818
+ if (!enabled || key === '') {
819
+ setSnapshot(null)
820
+ return
821
+ }
822
+ // A remount (panel open) must not regress to blank when the cache is warm.
823
+ setSnapshot(snapshotCache.get(key) ?? null)
824
+ let cancelled = false
825
+ let pollTimer: number | undefined
826
+ let retryTimer: number | undefined
827
+ let attempt = 0
828
+ // Whether a resolved snapshot exists for this target (cache or fetched).
829
+ // Drives the cold-start chase: fast backoff only while it is false.
830
+ let haveGood = snapshotCache.has(key)
831
+
832
+ /** Store one fetch result. An `unresolved` answer is transient — never
833
+ * cached, never regressing an already-good snapshot. Returns whether a
834
+ * resolved answer landed. */
835
+ const apply = (next: GitSnapshot): boolean => {
836
+ if (next.unresolved === true) {
837
+ if (!cancelled && !haveGood) setSnapshot(null)
838
+ return false
839
+ }
840
+ haveGood = true
841
+ attempt = 0
842
+ snapshotCache.set(key, next)
843
+ if (!cancelled) setSnapshot(next)
844
+ return true
845
+ }
846
+
847
+ const refreshOnce = async (): Promise<boolean> => {
848
+ try {
849
+ return apply(await apiGet<GitSnapshot>(`${GIT_ROUTE}/status?${targetQuery(target)}`))
850
+ } catch {
851
+ // Transport/transient git failure: chase it too while no good snapshot
852
+ // exists; afterwards keep showing the old one until the next poll.
853
+ return haveGood
854
+ }
855
+ }
856
+
857
+ /** Cold-start chase: short backoff instead of idling a whole poll period. */
858
+ const scheduleRetry = (): void => {
859
+ if (cancelled || haveGood || attempt >= FAST_RETRY_MAX) return
860
+ const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ?? 3200
861
+ attempt += 1
862
+ retryTimer = window.setTimeout(() => {
863
+ if (cancelled) return
864
+ void refreshOnce().then(settled => { if (!settled) scheduleRetry() })
865
+ }, delay)
866
+ }
867
+
868
+ void refreshOnce().then(settled => { if (!settled) scheduleRetry() })
869
+
870
+ pollTimer = window.setInterval(() => {
871
+ if (document.visibilityState === 'visible') void refreshOnce()
872
+ }, POLL_MS)
873
+ return () => {
874
+ cancelled = true
875
+ if (pollTimer !== undefined) clearInterval(pollTimer)
876
+ if (retryTimer !== undefined) clearTimeout(retryTimer)
877
+ }
878
+ }, [enabled, key])
879
+
880
+ const refresh = useCallback(async (): Promise<void> => {
881
+ if (key === '') return
882
+ try {
883
+ const next = await apiGet<GitSnapshot>(`${GIT_ROUTE}/status?${targetQuery(target)}`)
884
+ // Same rule as the hook body: an unresolved answer is transient and must
885
+ // not evict a good snapshot or enter the cache.
886
+ if (!next.unresolved) {
887
+ snapshotCache.set(key, next)
888
+ setSnapshot(next)
889
+ }
890
+ } catch {
891
+ // Keep the previous snapshot on transient failures.
892
+ }
893
+ }, [target.session, target.ws])
894
+
895
+ return [snapshot, refresh]
896
+ }
897
+
898
+ // ---------------------------------------------------------------------------
899
+ // Branch chip — `conversation.session.header.actions` (beside the title,
900
+ // before the mode badge). Native header-seat styling; the menu opens DOWNWARD
901
+ // from the chip. The folder label comes from the snapshot's cwd.
902
+ // ---------------------------------------------------------------------------
903
+
904
+ export interface BranchChipProps {
905
+ /** Framework session kit: the definite current session id. */
906
+ sessionId: SessionId
907
+ /** Injected: the client sessions service (list feed carrying each row's cwd). */
908
+ sessionsService: ISessions
909
+ /** Injected: the ui-tweaks settings store (reads `gitBarEnabled`). */
910
+ controller: SettingsClient
911
+ /** Locale-bound translator for the GitBar labels. */
912
+ t: Translate
913
+ }
914
+
915
+ export function BranchChipEntry({ sessionId, sessionsService, controller, t }: BranchChipProps) {
916
+ const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
917
+ const enabled = settingsState.value?.gitBarEnabled ?? true
918
+ const sessionStr = String(sessionId)
919
+ const target: GitTarget = { session: sessionStr }
920
+
921
+ // Folder straight off the live sessions list feed (the same rows the left
922
+ // sidebar shows) — zero network, correct from the very first paint even
923
+ // before /status answers.
924
+ const list = sessionsService.list
925
+ const subscribeList = useMemo(() => list.subscribe.bind(list), [list])
926
+ const feedCwd = useSyncExternalStore(
927
+ subscribeList,
928
+ () => list.getSnapshot().byId[sessionId]?.cwd,
929
+ () => undefined,
930
+ )
931
+
932
+ const [snapshot, refresh] = useGitStatus(enabled, target)
933
+ const [branchOpen, setBranchOpen] = useState(false)
934
+ const [branches, setBranches] = useState<GitBranches | null>(null)
935
+ const [newBranchName, setNewBranchName] = useState('')
936
+ const [pushToRemote, setPushToRemote] = useState(false)
937
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
938
+ const [baseBranch, setBaseBranch] = useState('')
939
+ const [newBranchOpen, setNewBranchOpen] = useState(false)
940
+ const [graphOpen, setGraphOpen] = useState(false)
941
+ const [graph, setGraph] = useState<GitGraph | null>(null)
942
+ const [graphBusy, setGraphBusy] = useState(false)
943
+ const [busy, setBusy] = useState<string | null>(null)
944
+ const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
945
+ const branchRef = useRef<HTMLButtonElement | null>(null)
946
+ const branchPopRef = useRef<HTMLDivElement | null>(null)
947
+
948
+ // Drop any open popup/dialog when the session or the toggle changes.
949
+ useEffect(() => {
950
+ if (!enabled || sessionStr === undefined) {
951
+ setBranchOpen(false)
952
+ setNewBranchOpen(false)
953
+ setGraphOpen(false)
954
+ }
955
+ }, [enabled, sessionStr])
956
+
957
+ const showNotice = (kind: 'ok' | 'err', text: string): void => {
958
+ setNotice({ kind, text })
959
+ window.setTimeout(() => setNotice(null), 4000)
960
+ }
961
+
962
+ const run = async (label: string, fn: () => Promise<void>): Promise<boolean> => {
963
+ if (busy !== null) return false
964
+ setBusy(label)
965
+ try {
966
+ await fn()
967
+ return true
968
+ } catch (cause) {
969
+ showNotice('err', cause instanceof Error ? cause.message : String(cause))
970
+ return false
971
+ } finally {
972
+ setBusy(null)
973
+ }
974
+ }
975
+
976
+ const loadBranches = (): void => {
977
+ if (sessionStr === undefined) return
978
+ void apiGet<GitBranches>(`${GIT_ROUTE}/branches?${targetQuery(target)}`)
979
+ .then(setBranches)
980
+ .catch(cause => { showNotice('err', cause instanceof Error ? cause.message : String(cause)) })
981
+ }
982
+
983
+ const toggleBranch = (): void => {
984
+ const next = !branchOpen
985
+ setBranchOpen(next)
986
+ if (next) loadBranches()
987
+ }
988
+
989
+ const pickBranch = (name: string): void => {
990
+ if (sessionStr === undefined || name === branches?.current) return
991
+ void run('checkout', async () => {
992
+ await apiPost(`${GIT_ROUTE}/checkout`, { session: sessionStr, branch: name })
993
+ setBranchOpen(false)
994
+ await refresh()
995
+ showNotice('ok', `→ ${name}`)
996
+ })
997
+ }
998
+
999
+ const createBranch = (): void => {
1000
+ if (sessionStr === undefined || newBranchName.trim() === '') return
1001
+ void run(pushToRemote ? 'create-push' : 'create', async () => {
1002
+ await apiPost(`${GIT_ROUTE}/create`, {
1003
+ session: sessionStr,
1004
+ name: newBranchName.trim(),
1005
+ base: baseBranch === '' ? undefined : baseBranch,
1006
+ push: pushToRemote,
1007
+ })
1008
+ setBranchOpen(false)
1009
+ setNewBranchOpen(false)
1010
+ setNewBranchName('')
1011
+ setPushToRemote(false)
1012
+ setBaseBranch('')
1013
+ await refresh()
1014
+ if (pushToRemote) showNotice('ok', `☁ ${newBranchName.trim()}`)
1015
+ })
1016
+ }
1017
+
1018
+ const openNewBranch = (): void => {
1019
+ setNewBranchName('')
1020
+ setBaseBranch('')
1021
+ setPushToRemote(false)
1022
+ setNewBranchOpen(true)
1023
+ }
1024
+
1025
+ // Two-step branch deletion: first click arms a confirm, second click deletes.
1026
+ const deleteBranch = (name: string): void => {
1027
+ if (sessionStr === undefined) return
1028
+ if (confirmDelete === name) {
1029
+ setConfirmDelete(null)
1030
+ void run('branch-delete', async () => {
1031
+ await apiPost(`${GIT_ROUTE}/branch-delete`, { session: sessionStr, name })
1032
+ loadBranches()
1033
+ await refresh()
1034
+ showNotice('ok', `🗑 ${name}`)
1035
+ })
1036
+ } else {
1037
+ setConfirmDelete(name)
1038
+ window.setTimeout(() => {
1039
+ setConfirmDelete(current => (current === name ? null : current))
1040
+ }, 3000)
1041
+ }
1042
+ }
1043
+
1044
+ // Two-step remote-branch deletion (`git push origin --delete <branch>`).
1045
+ const deleteRemoteBranch = (name: string): void => {
1046
+ if (sessionStr === undefined) return
1047
+ if (confirmDelete === name) {
1048
+ setConfirmDelete(null)
1049
+ void run('remote-delete', async () => {
1050
+ await apiPost(`${GIT_ROUTE}/remote-delete`, { session: sessionStr, name })
1051
+ loadBranches()
1052
+ await refresh()
1053
+ showNotice('ok', `🗑 ${name}`)
1054
+ })
1055
+ } else {
1056
+ setConfirmDelete(name)
1057
+ window.setTimeout(() => {
1058
+ setConfirmDelete(current => (current === name ? null : current))
1059
+ }, 3000)
1060
+ }
1061
+ }
1062
+
1063
+ // --- git commit graph dialog ------------------------------------------------
1064
+ const fetchGraph = (): void => {
1065
+ if (sessionStr === undefined) return
1066
+ setGraphBusy(true)
1067
+ void apiGet<GitGraph>(`${GIT_ROUTE}/graph?session=${encodeURIComponent(sessionStr)}&limit=150`)
1068
+ .then(setGraph)
1069
+ .catch(cause => { showNotice('err', cause instanceof Error ? cause.message : String(cause)) })
1070
+ .finally(() => { setGraphBusy(false) })
1071
+ }
1072
+
1073
+ const openGraph = (): void => {
1074
+ setGraphOpen(true)
1075
+ fetchGraph()
1076
+ }
1077
+
1078
+ // Close the branch popup when clicking outside it or pressing Escape.
1079
+ useEffect(() => {
1080
+ if (!branchOpen) return
1081
+ const onDown = (event: MouseEvent): void => {
1082
+ const target = event.target as Node | null
1083
+ if (target === null) return
1084
+ if (branchRef.current?.contains(target) || branchPopRef.current?.contains(target)) return
1085
+ setBranchOpen(false)
1086
+ }
1087
+ const onKey = (event: KeyboardEvent): void => {
1088
+ if (event.key === 'Escape') setBranchOpen(false)
1089
+ }
1090
+ document.addEventListener('mousedown', onDown)
1091
+ document.addEventListener('keydown', onKey)
1092
+ return () => {
1093
+ document.removeEventListener('mousedown', onDown)
1094
+ document.removeEventListener('keydown', onKey)
1095
+ }
1096
+ }, [branchOpen])
1097
+
1098
+ if (!enabled || sessionStr === undefined) return null
1099
+ // A genuine non-repo directory hides the chip entirely. An UNRESOLVED target
1100
+ // (host still materializing sessions right after a restart) is transient:
1101
+ // fall through to the folder-only paint below instead of going blank.
1102
+ if (snapshot !== null && snapshot.unresolved !== true && !snapshot.isRepo) return null
1103
+
1104
+ // While the first /status round-trip is in flight — or while the host has
1105
+ // not resolved this session yet — paint the folder alone straight off the
1106
+ // sessions-list feed (the sidebar's own rows — zero network, so it appears
1107
+ // the moment the sidebar row does); the branch capsule joins as soon as the
1108
+ // backoff chase lands a real snapshot.
1109
+ if (snapshot === null || snapshot.unresolved === true) {
1110
+ if (feedCwd === undefined || feedCwd === '') return null
1111
+ return (
1112
+ <span className="gbar" data-slot-plugin="dsh-ui-tweaks-gitbar">
1113
+ <span className="gbar-fchip" title={feedCwd}>
1114
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1115
+ <path d="M1.5 4A1.5 1.5 0 0 1 3 2.5h3l1.5 2H13A1.5 1.5 0 0 1 14.5 6v6A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4Z" />
1116
+ </svg>
1117
+ <span className="gbar-folder">{basenameOf(feedCwd)}</span>
1118
+ </span>
1119
+ </span>
1120
+ )
1121
+ }
1122
+
1123
+ const dirty = !snapshot.clean
1124
+
1125
+ return (
1126
+ <span className="gbar" data-slot-plugin="dsh-ui-tweaks-gitbar">
1127
+ {/* Folder label — plain (no capsule), not clickable */}
1128
+ <span className="gbar-fchip" title={feedCwd ?? snapshot.cwd}>
1129
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1130
+ <path d="M1.5 4A1.5 1.5 0 0 1 3 2.5h3l1.5 2H13A1.5 1.5 0 0 1 14.5 6v6A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4Z" />
1131
+ </svg>
1132
+ <span className="gbar-folder">{basenameOf(feedCwd ?? snapshot.cwd)}</span>
1133
+ </span>
1134
+ {/* Branch capsule — clickable, opens the branch popup */}
1135
+ <span className="gbar-bwrap">
1136
+ <button
1137
+ type="button"
1138
+ ref={branchRef}
1139
+ className={'gbar-chip' + (branchOpen ? ' gbar-open' : '')}
1140
+ onClick={toggleBranch}
1141
+ title={dirty ? t('dirty') : t('clean')}
1142
+ aria-expanded={branchOpen}
1143
+ >
1144
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1145
+ <circle cx="4.5" cy="3.5" r="1.8" />
1146
+ <circle cx="4.5" cy="12.5" r="1.8" />
1147
+ <circle cx="12" cy="5.5" r="1.8" />
1148
+ <path d="M4.5 5.3v5.4" />
1149
+ <path d="M12 7.3c0 3-3.5 4.8-7.5 3.4" />
1150
+ </svg>
1151
+ <span className="gbar-bname">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
1152
+ </button>
1153
+
1154
+ {/* Branch popup — pinned header (current branch + worktree state),
1155
+ scrolling local/remote lists, pinned action entries */}
1156
+ {branchOpen ? (
1157
+ <div className="gbar-pop" role="menu" aria-label={snapshot.branch ?? 'git'} ref={branchPopRef}>
1158
+ <div className="gbar-pop-head">
1159
+ <span className="gbar-curname" title={dirty ? t('dirty') : t('clean')}>
1160
+ <svg className="gbar-bicon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1161
+ <circle cx="4.5" cy="3.5" r="1.8" />
1162
+ <circle cx="4.5" cy="12.5" r="1.8" />
1163
+ <circle cx="12" cy="5.5" r="1.8" />
1164
+ <path d="M4.5 5.3v5.4" />
1165
+ <path d="M12 7.3c0 3-3.5 4.8-7.5 3.4" />
1166
+ </svg>
1167
+ <span>{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
1168
+ </span>
1169
+ <span className="gbar-state">
1170
+ <span className={'gbar-dot ' + (dirty ? 'gbar-dirty' : 'gbar-clean')} aria-hidden />
1171
+ {snapshot.ahead > 0 ? <span>↑{snapshot.ahead}</span> : null}
1172
+ {snapshot.behind > 0 ? <span>↓{snapshot.behind}</span> : null}
1173
+ </span>
1174
+ </div>
1175
+ <div className="gbar-pop-body">
1176
+ <div className="gbar-sec">{t('branchLocal')}{branches !== null ? <span className="gbar-count">{branches.local.length}</span> : null}</div>
1177
+ {branches === null ? (
1178
+ <div className="gbar-loading"><span className="gbar-spin" />{t('loading')}</div>
1179
+ ) : branches.local.map(name => (
1180
+ <div key={name} className="gbar-rowwrap">
1181
+ <button
1182
+ type="button"
1183
+ className={'gbar-row' + (name === branches.current ? ' gbar-cur' : '')}
1184
+ onClick={() => { pickBranch(name) }}
1185
+ >
1186
+ <svg className="gbar-bicon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1187
+ <circle cx="4.5" cy="3.5" r="1.8" />
1188
+ <circle cx="4.5" cy="12.5" r="1.8" />
1189
+ <path d="M4.5 5.3v5.4" />
1190
+ <path d="M11.5 3.7v3a2.6 2.6 0 0 1-2.6 2.6H4.5" />
1191
+ </svg>
1192
+ <span>{name}</span>
1193
+ {name === branches.current ? <span className="gbar-check" aria-hidden>✓</span> : null}
1194
+ </button>
1195
+ {name !== branches.current && !isProtectedBranch(name) ? (
1196
+ <button
1197
+ type="button"
1198
+ className={'gbar-del' + (confirmDelete === name ? ' gbar-arm' : '')}
1199
+ onClick={() => { deleteBranch(name) }}
1200
+ title={t('branchDelete')}
1201
+ >
1202
+ {confirmDelete === name ? t('branchDeleteConfirm') : (
1203
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1204
+ <path d="M2.5 4.5h11" />
1205
+ <path d="M5.5 4.5V3.3c0-.44.36-.8.8-.8h3.4c.44 0 .8.36.8.8v1.2" />
1206
+ <path d="M4 4.5l.6 8.2c.04.5.45.8.95.8h4.9c.5 0 .91-.3.95-.8l.6-8.2" />
1207
+ </svg>
1208
+ )}
1209
+ </button>
1210
+ ) : null}
1211
+ </div>
1212
+ ))}
1213
+ <div className="gbar-sec">{t('branchRemote')}{branches !== null ? <span className="gbar-count">{branches.remote.length}</span> : null}</div>
1214
+ {branches?.remote.map(name => (
1215
+ <div key={name} className="gbar-rowwrap">
1216
+ <button
1217
+ type="button"
1218
+ className={'gbar-row' + (name === branches.current ? ' gbar-cur' : '')}
1219
+ onClick={() => { pickBranch(name) }}
1220
+ >
1221
+ <svg className="gbar-bicon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1222
+ <path d="M5.2 13a3.2 3.2 0 1 1 .5-6.36 4 4 0 0 1 7.75 1.06 2.65 2.65 0 0 1-.55 5.3H5.2Z" />
1223
+ </svg>
1224
+ <span>{shortBranch(name)}</span>
1225
+ <span className="gbar-rm">{name.split('/')[0]}</span>
1226
+ </button>
1227
+ {!isProtectedBranch(shortBranch(name)) ? (
1228
+ <button
1229
+ type="button"
1230
+ className={'gbar-del' + (confirmDelete === name ? ' gbar-arm' : '')}
1231
+ onClick={() => { deleteRemoteBranch(name) }}
1232
+ title={t('branchRemoteDelete')}
1233
+ >
1234
+ {confirmDelete === name ? t('branchDeleteConfirm') : (
1235
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1236
+ <path d="M2.5 4.5h11" />
1237
+ <path d="M5.5 4.5V3.3c0-.44.36-.8.8-.8h3.4c.44 0 .8.36.8.8v1.2" />
1238
+ <path d="M4 4.5l.6 8.2c.04.5.45.8.95.8h4.9c.5 0 .91-.3.95-.8l.6-8.2" />
1239
+ </svg>
1240
+ )}
1241
+ </button>
1242
+ ) : null}
1243
+ </div>
1244
+ ))}
1245
+ </div>
1246
+ <div className="gbar-actions">
1247
+ <button type="button" className="gbar-act" onClick={openNewBranch}>
1248
+ <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden>
1249
+ <path d="M7.4 2.6a1.2 1.2 0 1 1 2.2 0l3.2 1.6a1.2 1.2 0 1 1-1.1 2.1l-2.3-1.2a2.6 2.6 0 0 1-1.1.8l.6 4.2a1.2 1.2 0 1 1-1.7.2l-.6-4.2a2.6 2.6 0 0 1-1.2-.7l-2.9 1.5a1.2 1.2 0 1 1-1.1-2.1l2.9-1.5a2.6 2.6 0 0 1 .2-1.5l-3.2-1.6a1.2 1.2 0 1 1 1.1-2.1z" />
1250
+ </svg>
1251
+ {t('branchNew')}
1252
+ </button>
1253
+ <button type="button" className="gbar-act" onClick={openGraph}>
1254
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
1255
+ <path d="M3 1.5v3.4a4.4 4.4 0 0 0 4.4 4.4h5.1" />
1256
+ <path d="M13 1.5v4.9a3.2 3.2 0 0 1-3.2 3.2H3" />
1257
+ <circle cx="3" cy="1.5" r="1.2" fill="currentColor" stroke="none" />
1258
+ <circle cx="13" cy="12" r="1.2" fill="currentColor" stroke="none" />
1259
+ </svg>
1260
+ {t('branchGraph')}
1261
+ </button>
1262
+ </div>
1263
+ </div>
1264
+ ) : null}
1265
+ </span>
1266
+
1267
+ {/* New-branch dialog */}
1268
+ {newBranchOpen ? createPortal(
1269
+ <div className="gbar-modal-wrap" onMouseDown={event => { if (event.target === event.currentTarget) setNewBranchOpen(false) }}>
1270
+ <div className="gbar-modal" role="dialog" aria-label={t('branchNew')}>
1271
+ <div className="gbar-head">
1272
+ <span className="gbar-title">{t('branchNew')}</span>
1273
+ <span className="gbar-branch">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
1274
+ <button type="button" className="gbar-x" onClick={() => { setNewBranchOpen(false) }} aria-label="✕">✕</button>
1275
+ </div>
1276
+ <input
1277
+ value={newBranchName}
1278
+ onChange={event => { setNewBranchName(event.target.value) }}
1279
+ onKeyDown={event => { if (event.key === 'Enter') createBranch() }}
1280
+ placeholder={t('branchNewPlaceholder')}
1281
+ aria-label={t('branchNew')}
1282
+ autoFocus
1283
+ />
1284
+ <div className="gbar-baserow">
1285
+ <label className="gbar-baselabel" htmlFor="gbar-base-dlg">{t('branchFrom')}</label>
1286
+ <select
1287
+ id="gbar-base-dlg"
1288
+ className="gbar-base"
1289
+ value={baseBranch}
1290
+ onChange={event => { setBaseBranch(event.target.value) }}
1291
+ aria-label={t('branchFrom')}
1292
+ >
1293
+ <option value="">{t('branchFromHead')}</option>
1294
+ {branches?.local.map(name => <option key={name} value={name}>{name}</option>)}
1295
+ {branches?.remote.map(name => <option key={name} value={name}>{name}</option>)}
1296
+ </select>
1297
+ </div>
1298
+ <div className="gbar-pushrow">
1299
+ <button
1300
+ type="button"
1301
+ className={'gbar-chk' + (pushToRemote ? '' : ' gbar-off')}
1302
+ onClick={() => { setPushToRemote(value => !value) }}
1303
+ aria-label={t('branchPushRemote')}
1304
+ >
1305
+ {pushToRemote ? '✓' : '✕'}
1306
+ </button>
1307
+ <span className="gbar-pushlabel">{t('branchPushRemote')}</span>
1308
+ </div>
1309
+ <div className="gbar-foot">
1310
+ <button type="button" className="gbar-btn gbar-ghost" onClick={() => { setNewBranchOpen(false) }}>{t('branchCancel')}</button>
1311
+ <button type="button" className="gbar-btn gbar-primary" onClick={createBranch} disabled={busy !== null || newBranchName.trim() === ''}>
1312
+ {busy === 'create' || busy === 'create-push' ? <span className="gbar-spin" /> : null} {t('branchCreate')}
1313
+ </button>
1314
+ </div>
1315
+ </div>
1316
+ </div>,
1317
+ document.body,
1318
+ ) : null}
1319
+
1320
+ {/* Commit-graph dialog */}
1321
+ {graphOpen ? createPortal(
1322
+ <div className="gbar-modal-wrap" onMouseDown={event => { if (event.target === event.currentTarget) setGraphOpen(false) }}>
1323
+ <div className="gbar-modal gbar-graph-modal" role="dialog" aria-label={t('graphTitle')}>
1324
+ <div className="gbar-head">
1325
+ <span className="gbar-title">{t('graphTitle')}</span>
1326
+ <span className="gbar-branch">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
1327
+ <span className="gbar-spacer" />
1328
+ <button type="button" className="gbar-x gbar-refresh" onClick={fetchGraph} aria-label={t('branchRefresh')} title={t('branchRefresh')} disabled={graphBusy}>
1329
+ {graphBusy ? <span className="gbar-spin" /> : (
1330
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1331
+ <path d="M13.4 8a5.4 5.4 0 1 1-1.6-3.8" />
1332
+ <path d="M13.4 1.5v3h-3" />
1333
+ </svg>
1334
+ )}
1335
+ </button>
1336
+ <button type="button" className="gbar-x" onClick={() => { setGraphOpen(false) }} aria-label="✕">✕</button>
1337
+ </div>
1338
+ {graph === null ? (
1339
+ <div className="gbar-graph-empty">{graphBusy ? t('loading') : t('noChanges')}</div>
1340
+ ) : graph.commits.length === 0 ? (
1341
+ <div className="gbar-graph-empty">{t('noChanges')}</div>
1342
+ ) : (
1343
+ <div className="gbar-graph-table" role="table" aria-label={t('graphTitle')}>
1344
+ <div className="gbar-graph-row gbar-graph-head" role="row">
1345
+ <span className="gbar-c-hash" role="columnheader">{t('graphColCommit')}</span>
1346
+ <span className="gbar-c-subject" role="columnheader">{t('graphColSubject')}</span>
1347
+ <span className="gbar-c-author" role="columnheader">{t('graphColAuthor')}</span>
1348
+ <span className="gbar-c-date" role="columnheader">{t('graphColDate')}</span>
1349
+ </div>
1350
+ {graph.commits.map(commit => (
1351
+ <div key={commit.fullHash} className="gbar-graph-row" role="row">
1352
+ <span className="gbar-c-hash" role="cell"><code className="gbar-hash">{commit.hash}</code></span>
1353
+ <span className="gbar-c-subject" role="cell">
1354
+ <span className="gbar-subject">{commit.subject}</span>
1355
+ {commit.refs !== '' ? <span className="gbar-refs">{commit.refs}</span> : null}
1356
+ </span>
1357
+ <span className="gbar-c-author" role="cell">{commit.author}</span>
1358
+ <span className="gbar-c-date" role="cell" title={commit.date}>{commit.dateRelative}</span>
1359
+ </div>
1360
+ ))}
1361
+ </div>
1362
+ )}
1363
+ </div>
1364
+ </div>,
1365
+ document.body,
1366
+ ) : null}
1367
+
1368
+ {/* Transient notice */}
1369
+ {notice !== null ? createPortal(
1370
+ <div className={'gbar-notice gbar-' + notice.kind} role="status">{notice.text}</div>,
1371
+ document.body,
1372
+ ) : null}
1373
+ </span>
1374
+ )
1375
+ }
1376
+
1377
+ // ---------------------------------------------------------------------------
1378
+ // Diff panel — opened from the header utilities' diff icon (always available,
1379
+ // even on a clean tree). Slide-over on the right; commit band at its foot.
1380
+ // ---------------------------------------------------------------------------
1381
+
1382
+ export interface DiffPanelProps {
1383
+ /** Framework session kit: the definite current session id. */
1384
+ sessionId: SessionId
1385
+ /** Framework session kit: live conversation snapshot selector. */
1386
+ useSession: <T>(selector: (snapshot: SessionSnapshot) => T) => T
1387
+ /** Injected: the ui-tweaks settings store (reads `gitBarEnabled`). */
1388
+ controller: SettingsClient
1389
+ /** Locale-bound translator for the GitBar labels. */
1390
+ t: Translate
1391
+ /** Parent closes the panel (icon toggles; outside clicks route here too). */
1392
+ onClose: () => void
1393
+ }
1394
+
1395
+ /**
1396
+ * Shared side-panel layout: anchor the panel below the session header, and
1397
+ * while it is open push ONLY the message area (the conversation scrollport)
1398
+ * left by `width`. Never touch `#root`: in the DSH 0.1.1 column grid `#root`
1399
+ * wraps the whole app (left sidebar + center + details), so a `#root` margin
1400
+ * collapses the left sidebar and shoves the top header — the "showing the
1401
+ * panel moves the top" bug. The scrollport is the center column's message
1402
+ * region; pushing only it leaves the sidebar and header untouched, and fixed
1403
+ * overlays docked inside the scrollport stay visible.
1404
+ *
1405
+ * @param width - panel width; the scrollport gets this much right margin.
1406
+ * @param active - whether the panel is actually showing; when false (e.g. a
1407
+ * non-repo session opened the diff icon) no layout is pushed.
1408
+ * @returns the panel top (scrollport top) for `style.top`.
1409
+ */
1410
+ function useSidePanelLayout(width: number, active = true): number {
1411
+ const [panelTop, setPanelTop] = useState(0)
1412
+
1413
+ useEffect(() => {
1414
+ const measure = (): void => {
1415
+ const sp = document.querySelector('[data-conversation-scroll]')
1416
+ const top = sp === null ? 0 : Math.round(sp.getBoundingClientRect().top)
1417
+ setPanelTop(prev => Math.abs(prev - top) < 2 ? prev : top)
1418
+ }
1419
+ measure()
1420
+ window.addEventListener('resize', measure)
1421
+ const observer = new ResizeObserver(measure)
1422
+ const sp = document.querySelector('[data-conversation-scroll]')
1423
+ if (sp !== null) observer.observe(sp)
1424
+ return () => {
1425
+ window.removeEventListener('resize', measure)
1426
+ observer.disconnect()
1427
+ }
1428
+ }, [])
1429
+
1430
+ useEffect(() => {
1431
+ if (!active) return
1432
+ const scrollport = document.querySelector('[data-conversation-scroll]') as HTMLElement | null
1433
+ if (scrollport !== null) scrollport.style.marginRight = `${width}px`
1434
+ return () => { if (scrollport !== null) scrollport.style.marginRight = '' }
1435
+ }, [width, active])
1436
+
1437
+ return panelTop
1438
+ }
1439
+
1440
+ export function DiffPanel({ sessionId, useSession, controller, t, onClose }: DiffPanelProps) {
1441
+ const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
1442
+ const enabled = settingsState.value?.gitBarEnabled ?? true
1443
+ const sessionStr = String(sessionId)
1444
+ const agentRunning = useSession(snapshot => snapshot.running) ?? false
1445
+
1446
+ const [snapshot, refresh] = useGitStatus(enabled, { session: sessionStr })
1447
+ const [diff, setDiff] = useState<GitDiffResult | null>(null)
1448
+ const [diffMode, setDiffMode] = useState<'hunk' | 'full'>('hunk')
1449
+ const [diffPath, setDiffPath] = useState<string | null>(null)
1450
+ const [message, setMessage] = useState('')
1451
+ const [busy, setBusy] = useState<string | null>(null)
1452
+ const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
1453
+ const [excluded, setExcluded] = useState<ReadonlySet<string>>(new Set())
1454
+ const [diffWidth, setDiffWidth] = useState(440)
1455
+ const panelTop = useSidePanelLayout(diffWidth, enabled && sessionStr !== undefined && snapshot !== null && snapshot.isRepo)
1456
+ const [filesHeight, setFilesHeight] = useState<number | null>(null)
1457
+ const [commitHeight, setCommitHeight] = useState<number | null>(null)
1458
+ const [dragging, setDragging] = useState<'files' | 'commit' | null>(null)
1459
+
1460
+ const showNotice = (kind: 'ok' | 'err', text: string): void => {
1461
+ setNotice({ kind, text })
1462
+ window.setTimeout(() => setNotice(null), 4000)
1463
+ }
1464
+
1465
+ const run = async (label: string, fn: () => Promise<void>): Promise<boolean> => {
1466
+ if (busy !== null) return false
1467
+ setBusy(label)
1468
+ try {
1469
+ await fn()
1470
+ return true
1471
+ } catch (cause) {
1472
+ showNotice('err', cause instanceof Error ? cause.message : String(cause))
1473
+ return false
1474
+ } finally {
1475
+ setBusy(null)
1476
+ }
1477
+ }
1478
+
1479
+ const toggleExclude = (path: string): void => {
1480
+ setExcluded(current => {
1481
+ const next = new Set(current)
1482
+ if (next.has(path)) next.delete(path)
1483
+ else next.add(path)
1484
+ return next
1485
+ })
1486
+ }
1487
+
1488
+ // Auto-select the first changed file so the panel opens with a diff. Runs
1489
+ // when the snapshot first arrives (it is null on mount), and again only if
1490
+ // the user has not picked a file yet.
1491
+ useEffect(() => {
1492
+ void refresh()
1493
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1494
+ }, [])
1495
+
1496
+ useEffect(() => {
1497
+ setDiffPath(current => current ?? snapshot?.files[0]?.path ?? null)
1498
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1499
+ }, [snapshot])
1500
+
1501
+ const selectDiffFile = (path: string): void => {
1502
+ setDiffPath(path)
1503
+ }
1504
+
1505
+ const switchDiffMode = (mode: 'hunk' | 'full'): void => {
1506
+ setDiffMode(mode)
1507
+ }
1508
+
1509
+ // Load the selected file's diff when the selection/mode changes.
1510
+ useEffect(() => {
1511
+ if (sessionStr === undefined || diffPath === null) return
1512
+ let cancelled = false
1513
+ setDiff(null)
1514
+ void apiGet<GitDiffResult>(
1515
+ `${GIT_ROUTE}/diff?${targetQuery({ session: sessionStr })}&file=${encodeURIComponent(diffPath)}&mode=${diffMode}`,
1516
+ ).then(result => {
1517
+ if (!cancelled) setDiff(result)
1518
+ }).catch(cause => {
1519
+ if (!cancelled) showNotice('err', cause instanceof Error ? cause.message : String(cause))
1520
+ })
1521
+ return () => { cancelled = true }
1522
+ }, [sessionStr, diffPath, diffMode])
1523
+
1524
+ const doCommit = (push: boolean): void => {
1525
+ if (agentRunning) return
1526
+ void run(push ? 'commit-push' : 'commit', async () => {
1527
+ const msg = message.trim()
1528
+ if (msg === '') {
1529
+ showNotice('err', t('commitEmpty'))
1530
+ return
1531
+ }
1532
+ const result = await apiPost<{ hash?: string; pushed: boolean }>(`${GIT_ROUTE}/commit`, {
1533
+ session: sessionStr,
1534
+ message: msg,
1535
+ push,
1536
+ exclude: [...excluded],
1537
+ })
1538
+ onClose()
1539
+ setMessage('')
1540
+ setExcluded(new Set())
1541
+ await refresh()
1542
+ showNotice('ok', push ? `✓ ${result.hash ?? 'committed'} · pushed` : `✓ ${result.hash ?? 'committed'}`)
1543
+ })
1544
+ }
1545
+
1546
+ // Close the diff panel when clicking outside it (the 差异 icon toggles).
1547
+ // The icon lives in `.gbar-hicons`, which is NOT a descendant of `.gbar`,
1548
+ // so that container must be excluded too — otherwise mousedown on the icon
1549
+ // fires onClose() and the following click re-opens the panel ("sometimes
1550
+ // it opens by itself").
1551
+ useEffect(() => {
1552
+ const onDown = (event: MouseEvent): void => {
1553
+ const target = event.target as Node | null
1554
+ if (target === null) return
1555
+ if (document.querySelector('.gbar-side')?.contains(target)) return
1556
+ if (document.querySelector('.gbar-hicons')?.contains(target)) return
1557
+ if (document.querySelector('.gbar')?.contains(target)) return
1558
+ onClose()
1559
+ }
1560
+ document.addEventListener('mousedown', onDown)
1561
+ return () => { document.removeEventListener('mousedown', onDown) }
1562
+ }, [onClose])
1563
+
1564
+ // Drag the panel's left edge to resize it. A press WITHOUT any drag counts
1565
+ // as a click: it snaps the panel to half the screen (or back to the width
1566
+ // it had before expanding), so the edge doubles as a one-button maximize.
1567
+ const prevWidthRef = useRef<number | null>(null)
1568
+ const toggleHalfWidth = (): void => {
1569
+ const half = Math.round(window.innerWidth / 2)
1570
+ if (diffWidth >= half - 4) {
1571
+ const restore = prevWidthRef.current ?? 440
1572
+ prevWidthRef.current = null
1573
+ setDiffWidth(restore)
1574
+ } else {
1575
+ prevWidthRef.current = diffWidth
1576
+ setDiffWidth(half)
1577
+ }
1578
+ }
1579
+
1580
+ const startResize = (event: { clientX: number; preventDefault: () => void }): void => {
1581
+ event.preventDefault()
1582
+ const startX = event.clientX
1583
+ const startWidth = diffWidth
1584
+ // Manual drags may reach half the screen too — otherwise an expanded
1585
+ // half-screen panel would instantly clamp back to the old 900px ceiling.
1586
+ const maxWidth = Math.max(900, Math.round(window.innerWidth / 2))
1587
+ let moved = false
1588
+ const onMove = (move: PointerEvent): void => {
1589
+ if (!moved && Math.abs(move.clientX - startX) > 3) moved = true
1590
+ setDiffWidth(Math.min(maxWidth, Math.max(320, startWidth - (move.clientX - startX))))
1591
+ }
1592
+ const onUp = (): void => {
1593
+ window.removeEventListener('pointermove', onMove)
1594
+ window.removeEventListener('pointerup', onUp)
1595
+ if (!moved) toggleHalfWidth()
1596
+ }
1597
+ window.addEventListener('pointermove', onMove)
1598
+ window.addEventListener('pointerup', onUp)
1599
+ }
1600
+
1601
+ // Drag a horizontal splitter to redistribute height between the panel's
1602
+ // sections. Only the dragged section gets an explicit height; the diff pane is
1603
+ // the flexible one, so every pixel a neighbour gains comes out of the diff and
1604
+ // the total never exceeds the panel. Double-click restores automatic sizing.
1605
+ const startVResize = (
1606
+ target: 'files' | 'commit',
1607
+ event: { clientY: number; preventDefault: () => void },
1608
+ ): void => {
1609
+ event.preventDefault()
1610
+ const panel = document.querySelector('.gbar-side')
1611
+ const measure = (selector: string): number => {
1612
+ const el = panel?.querySelector(selector) ?? null
1613
+ return el === null ? 0 : el.getBoundingClientRect().height
1614
+ }
1615
+ const startY = event.clientY
1616
+ const startFiles = measure('.gbar-files')
1617
+ const startCommit = measure('.gbar-side-commit')
1618
+ const startDiff = measure('.gbar-diff')
1619
+ setDragging(target)
1620
+ const onMove = (move: PointerEvent): void => {
1621
+ const delta = move.clientY - startY
1622
+ if (target === 'files') {
1623
+ // Dragging down grows the file list and shrinks the diff.
1624
+ const ceiling = Math.max(MIN_FILES_H, startFiles + startDiff - MIN_DIFF_H)
1625
+ setFilesHeight(Math.round(Math.min(ceiling, Math.max(MIN_FILES_H, startFiles + delta))))
1626
+ } else {
1627
+ // Dragging up grows the commit band and shrinks the diff.
1628
+ const ceiling = Math.max(MIN_COMMIT_H, startCommit + startDiff - MIN_DIFF_H)
1629
+ setCommitHeight(Math.round(Math.min(ceiling, Math.max(MIN_COMMIT_H, startCommit - delta))))
1630
+ }
1631
+ }
1632
+ const onUp = (): void => {
1633
+ setDragging(null)
1634
+ window.removeEventListener('pointermove', onMove)
1635
+ window.removeEventListener('pointerup', onUp)
1636
+ }
1637
+ window.addEventListener('pointermove', onMove)
1638
+ window.addEventListener('pointerup', onUp)
1639
+ }
1640
+
1641
+ if (!enabled || sessionStr === undefined) return null
1642
+ // Not a repo: no panel at all (the pills hide for non-repos too).
1643
+ if (snapshot !== null && !snapshot.isRepo) return null
1644
+
1645
+ // Cold start (no cached snapshot yet): paint the panel SHELL at once with a
1646
+ // loading body instead of returning null — the click must give immediate
1647
+ // feedback even while the first /status round-trip is in flight. With the
1648
+ // shared snapshot cache this state is rare and brief.
1649
+ if (snapshot === null) {
1650
+ return createPortal(
1651
+ <div className="gbar-side" role="dialog" aria-label={t('diffTitle')} style={{ width: `${diffWidth}px`, top: `${panelTop}px` }}>
1652
+ <div className="gbar-resize" onPointerDown={startResize} title="拖动调整宽度 · 点击展开到半屏" />
1653
+ <div className="gbar-side-head">
1654
+ <span className="gbar-title">{t('diffTitle')}</span>
1655
+ <span className="gbar-spacer" />
1656
+ <button type="button" className="gbar-side-x" onClick={onClose} aria-label="✕">✕</button>
1657
+ </div>
1658
+ <div className="gbar-side-body">
1659
+ <div className="gbar-diff"><div className="gbar-empty"><span className="gbar-spin" /> {t('loading')}</div></div>
1660
+ </div>
1661
+ </div>,
1662
+ document.body,
1663
+ )
1664
+ }
1665
+
1666
+ const dirty = !snapshot.clean
1667
+ const halfActive = diffWidth >= Math.round(window.innerWidth / 2) - 4
1668
+
1669
+ return createPortal(
1670
+ <>
1671
+ <div className="gbar-side" role="dialog" aria-label={t('diffTitle')} style={{ width: `${diffWidth}px`, top: `${panelTop}px` }}>
1672
+ <div className="gbar-resize" onPointerDown={startResize} title="拖动调整宽度 · 点击展开到半屏" />
1673
+ <div className="gbar-side-head">
1674
+ <span className="gbar-title">{diffPath ?? t('diffTitle')}</span>
1675
+ <span className="gbar-sub">
1676
+ {snapshot.files.length} {t('diffFiles')} · <span className="gbar-a">+{snapshot.totalAdded}</span> <span className="gbar-d">−{snapshot.totalDeleted}</span>
1677
+ </span>
1678
+ <span className="gbar-spacer" />
1679
+ <div className="gbar-seg" role="group">
1680
+ <button type="button" className={diffMode === 'hunk' ? 'gbar-on' : ''} onClick={() => { switchDiffMode('hunk') }}>{t('diffOnly')}</button>
1681
+ <button type="button" className={diffMode === 'full' ? 'gbar-on' : ''} onClick={() => { switchDiffMode('full') }}>{t('diffFull')}</button>
1682
+ </div>
1683
+ <button
1684
+ type="button"
1685
+ className={'gbar-side-half' + (halfActive ? ' gbar-on' : '')}
1686
+ onClick={toggleHalfWidth}
1687
+ title="展开到半屏 / 恢复宽度"
1688
+ aria-pressed={halfActive}
1689
+ >
1690
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
1691
+ <path d="M6 3H3v3" /><path d="M10 13h3v-3" /><path d="M3 3l4 4" /><path d="M13 13L9 9" />
1692
+ </svg>
1693
+ </button>
1694
+ <button type="button" className="gbar-side-x" onClick={onClose} aria-label="✕">✕</button>
1695
+ </div>
1696
+ <div className="gbar-side-body">
1697
+ <div
1698
+ className="gbar-files"
1699
+ style={filesHeight === null
1700
+ ? { maxHeight: FILES_AUTO_MAX }
1701
+ : { height: filesHeight, maxHeight: SECTION_MAX }}
1702
+ >
1703
+ {snapshot.files.map(file => {
1704
+ const isExcluded = excluded.has(file.path)
1705
+ // Split the path so the directory renders dim and the filename
1706
+ // bright — the eye scans by filename, not by folder.
1707
+ const slash = file.path.lastIndexOf('/')
1708
+ const dir = slash >= 0 ? file.path.slice(0, slash + 1) : ''
1709
+ const base = slash >= 0 ? file.path.slice(slash + 1) : file.path
1710
+ return (
1711
+ <div key={file.path} className={'gbar-file' + (diffPath === file.path ? ' gbar-on' : '') + (isExcluded ? ' gbar-excl' : '')}>
1712
+ <button
1713
+ type="button"
1714
+ className={'gbar-chk' + (isExcluded ? ' gbar-off' : '')}
1715
+ onClick={() => { toggleExclude(file.path) }}
1716
+ aria-label={isExcluded ? t('excludeFile') : t('includeFile')}
1717
+ >
1718
+ {isExcluded ? '✕' : '✓'}
1719
+ </button>
1720
+ <button
1721
+ type="button"
1722
+ className="gbar-file-main"
1723
+ onClick={() => { selectDiffFile(file.path) }}
1724
+ title={file.path}
1725
+ >
1726
+ <span className={'gbar-st ' + statusClass(file.status)}>{statusLetter(file.status)}</span>
1727
+ <span className="gbar-path">{dir !== '' ? <span className="gbar-dir">{dir}</span> : null}<span>{base}</span></span>
1728
+ <span className="gbar-nums">
1729
+ <span className="gbar-a">+{file.added}</span> <span className="gbar-d">−{file.deleted}</span>
1730
+ </span>
1731
+ </button>
1732
+ </div>
1733
+ )
1734
+ })}
1735
+ </div>
1736
+ <div
1737
+ className={'gbar-vsplit' + (dragging === 'files' ? ' gbar-dragging' : '')}
1738
+ role="separator"
1739
+ aria-orientation="horizontal"
1740
+ title="拖动调整文件列表高度(双击复位)"
1741
+ onPointerDown={event => { startVResize('files', event) }}
1742
+ onDoubleClick={() => { setFilesHeight(null) }}
1743
+ />
1744
+ {diff === null ? (
1745
+ <div className="gbar-diff"><div className="gbar-empty">{snapshot.clean || snapshot.files.length === 0 ? t('clean') : t('loading')}</div></div>
1746
+ ) : diff.lines.length === 0 ? (
1747
+ <div className="gbar-diff"><div className="gbar-empty">{t('noChanges')}</div></div>
1748
+ ) : (
1749
+ <div className="gbar-diff">
1750
+ {diff.lines.map((line, i) => (
1751
+ <div key={i} className={'gbar-line gbar-' + line.type}>
1752
+ <span className="gbar-ln">{line.old ?? ''}</span>
1753
+ <span className="gbar-ln">{line.new ?? ''}</span>
1754
+ <span className="gbar-code">{line.text}</span>
1755
+ </div>
1756
+ ))}
1757
+ {diff.truncated ? <div className="gbar-empty">…</div> : null}
1758
+ </div>
1759
+ )}
1760
+ {dirty ? (
1761
+ <div
1762
+ className={'gbar-vsplit' + (dragging === 'commit' ? ' gbar-dragging' : '')}
1763
+ role="separator"
1764
+ aria-orientation="horizontal"
1765
+ title="拖动调整提交区高度(双击复位)"
1766
+ onPointerDown={event => { startVResize('commit', event) }}
1767
+ onDoubleClick={() => { setCommitHeight(null) }}
1768
+ />
1769
+ ) : null}
1770
+ </div>
1771
+ <div className="gbar-side-foot">
1772
+ <span className="gbar-dot" />
1773
+ {snapshot.branch ?? '—'} · {dirty ? t('dirty') : t('clean')}
1774
+ </div>
1775
+ {dirty ? (
1776
+ <div
1777
+ className="gbar-side-commit"
1778
+ style={commitHeight === null ? undefined : { height: commitHeight, maxHeight: SECTION_MAX }}
1779
+ >
1780
+ <div className="gbar-crow">
1781
+ <textarea
1782
+ value={message}
1783
+ onChange={event => { setMessage(event.target.value) }}
1784
+ onKeyDown={event => {
1785
+ // Enter commits; Shift+Enter adds a line, now that this field
1786
+ // can be dragged tall enough for a multi-line message.
1787
+ if (event.key === 'Enter' && !event.shiftKey) {
1788
+ event.preventDefault()
1789
+ doCommit(false)
1790
+ }
1791
+ }}
1792
+ placeholder={t('commitPlaceholder')}
1793
+ aria-label={t('commitMessage')}
1794
+ rows={1}
1795
+ />
1796
+ </div>
1797
+ <div className="gbar-actions">
1798
+ <button
1799
+ type="button"
1800
+ className="gbar-btn gbar-soft"
1801
+ onClick={() => { doCommit(false) }}
1802
+ disabled={busy !== null || agentRunning}
1803
+ title={agentRunning ? t('commitBusy') : undefined}
1804
+ >
1805
+ {busy === 'commit' ? <span className="gbar-spin" /> : null} {t('commitSubmit')}
1806
+ </button>
1807
+ <button
1808
+ type="button"
1809
+ className="gbar-btn gbar-primary"
1810
+ onClick={() => { doCommit(true) }}
1811
+ disabled={busy !== null || agentRunning}
1812
+ title={agentRunning ? t('commitBusy') : undefined}
1813
+ >
1814
+ {busy === 'commit-push' ? <span className="gbar-spin" /> : null} {t('commitSubmitPush')}
1815
+ </button>
1816
+ </div>
1817
+ </div>
1818
+ ) : null}
1819
+ </div>
1820
+ {/* Transient notice */}
1821
+ {notice !== null ? (
1822
+ <div className={'gbar-notice gbar-' + notice.kind} role="status">{notice.text}</div>
1823
+ ) : null}
1824
+ </>,
1825
+ document.body,
1826
+ )
1827
+ }
1828
+
1829
+ // ---------------------------------------------------------------------------
1830
+ // Terminal panel — a REAL terminal: xterm.js in the browser over a WebSocket
1831
+ // to the host's persistent node-pty shell (the dsh-better-sidebar design,
1832
+ // its src/pty-manager.ts + TerminalView.tsx). Full emulation: colors, cursor
1833
+ // control, Ctrl+C, command history, interactive apps. The xterm UMD builds
1834
+ // and stylesheet are vendored by the plugin and lazy-loaded on first panel
1835
+ // open, so the always-resident GitBar bundle stays lean.
1836
+ //
1837
+ // Session lifetime mirrors better-sidebar tabs: closing the panel only drops
1838
+ // the socket — the host keeps the shell alive behind a reconnect grace, so
1839
+ // re-opening the panel reattaches to the SAME session with its transcript
1840
+ // replayed. Page refreshes recover the same way.
1841
+ // ---------------------------------------------------------------------------
1842
+
1843
+ /** Vendored xterm assets served by the host half (src/git-web.ts). */
1844
+ const VENDOR_BASE = `${GIT_ROUTE}/vendor`
1845
+
1846
+ /** Duck-typed face of the xterm Terminal instance this panel touches. */
1847
+ interface XTermLike {
1848
+ cols: number
1849
+ rows: number
1850
+ /** Live option updates (`theme` re-render is supported by xterm 5). */
1851
+ options: { theme?: Record<string, string> | undefined }
1852
+ open(host: HTMLElement): void
1853
+ write(data: string): void
1854
+ focus(): void
1855
+ resize(cols: number, rows: number): void
1856
+ dispose(): void
1857
+ loadAddon(addon: unknown): void
1858
+ onData(listener: (data: string) => void): { dispose(): void }
1859
+ onResize(listener: (dims: { cols: number; rows: number }) => void): { dispose(): void }
1860
+ }
1861
+
1862
+ /** Duck-typed face of @xterm/addon-fit. */
1863
+ interface FitAddonLike {
1864
+ fit(): void
1865
+ }
1866
+
1867
+ type XTermCtor = new (options: Record<string, unknown>) => XTermLike
1868
+ type FitAddonCtor = new () => FitAddonLike
1869
+
1870
+ interface XTermGlobals { Terminal: XTermCtor; FitAddon: FitAddonCtor }
1871
+
1872
+ let xtermGlobalsPromise: Promise<XTermGlobals> | null = null
1873
+
1874
+ /** Inject one vendored script exactly once; resolves on load, rejects on error. */
1875
+ function loadVendorScript(src: string): Promise<void> {
1876
+ return new Promise((resolve, reject) => {
1877
+ const existing = document.querySelector(`script[data-gbar-vendor="${CSS.escape(src)}"]`)
1878
+ if (existing !== null) {
1879
+ // An already-complete script never fires `load` again.
1880
+ if (existing.getAttribute('data-gbar-loaded') === '1') {
1881
+ resolve()
1882
+ return
1883
+ }
1884
+ existing.addEventListener('load', () => resolve())
1885
+ existing.addEventListener('error', () => reject(new Error(`failed to load ${src}`)))
1886
+ return
1887
+ }
1888
+ const script = document.createElement('script')
1889
+ script.src = src
1890
+ script.async = true
1891
+ script.dataset.gbarVendor = src
1892
+ script.addEventListener('load', () => {
1893
+ script.setAttribute('data-gbar-loaded', '1')
1894
+ resolve()
1895
+ })
1896
+ script.addEventListener('error', () => reject(new Error(`failed to load ${src}`)))
1897
+ document.head.appendChild(script)
1898
+ })
1899
+ }
1900
+
1901
+ /** Load the vendored xterm UMD builds once per page; caches the globals. */
1902
+ function loadXterm(): Promise<XTermGlobals> {
1903
+ xtermGlobalsPromise ??= (async () => {
1904
+ try {
1905
+ if (document.querySelector('link[data-gbar-xterm-css]') === null) {
1906
+ const link = document.createElement('link')
1907
+ link.rel = 'stylesheet'
1908
+ link.href = `${VENDOR_BASE}/xterm.css`
1909
+ link.dataset.gbarXtermCss = '1'
1910
+ document.head.appendChild(link)
1911
+ }
1912
+ await loadVendorScript(`${VENDOR_BASE}/xterm.js`)
1913
+ await loadVendorScript(`${VENDOR_BASE}/addon-fit.js`)
1914
+ const win = window as unknown as Record<string, unknown>
1915
+ const TerminalCtor = win.Terminal as XTermCtor | undefined
1916
+ const fitNamespace = win.FitAddon as { FitAddon?: FitAddonCtor } | undefined
1917
+ if (typeof TerminalCtor !== 'function' || typeof fitNamespace?.FitAddon !== 'function') {
1918
+ throw new Error('xterm assets loaded but globals are missing')
1919
+ }
1920
+ return { Terminal: TerminalCtor, FitAddon: fitNamespace.FitAddon }
1921
+ } catch (cause) {
1922
+ // Allow a retry on the next panel open instead of caching the failure.
1923
+ xtermGlobalsPromise = null
1924
+ throw cause
1925
+ }
1926
+ })()
1927
+ return xtermGlobalsPromise
1928
+ }
1929
+
1930
+ // Curated ANSI palettes (one-dark / one-light families), matching the
1931
+ // dsh-better-sidebar terminal so both plugins render shells identically.
1932
+ const ANSI_DARK: Record<string, string> = {
1933
+ black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
1934
+ blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
1935
+ brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
1936
+ brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
1937
+ brightCyan: '#56b6c2', brightWhite: '#ffffff',
1938
+ }
1939
+ const ANSI_LIGHT: Record<string, string> = {
1940
+ black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
1941
+ blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
1942
+ brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
1943
+ brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
1944
+ brightCyan: '#0997b3', brightWhite: '#fafafa',
1945
+ }
1946
+
1947
+ /**
1948
+ * Whether the DSH app is currently in its dark theme. The real switch is the
1949
+ * `data-ds-dark-theme` attribute the theme plugin puts on <body> — the same
1950
+ * signal the DSW token stylesheet keys on (`body[data-ds-dark-theme]{…}`), so
1951
+ * it is correct no matter how the theme was chosen (app setting or system
1952
+ * follow). Tokens being readable without the attribute means the light block
1953
+ * is active; only a composition with neither falls back to the system media
1954
+ * query.
1955
+ */
1956
+ function isDarkScheme(): boolean {
1957
+ if (document.body.hasAttribute('data-ds-dark-theme')) return true
1958
+ const style = getComputedStyle(document.body)
1959
+ if (style.getPropertyValue('--dsw-alias-bg-base').trim() !== '') return false
1960
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
1961
+ }
1962
+
1963
+ /**
1964
+ * The xterm theme for the current scheme (surface from tokens, curated ANSI).
1965
+ * The DSW alias tokens are defined on <body> (not :root), and custom
1966
+ * properties inherit downward only — reading them off documentElement always
1967
+ * yields '', so read the body's computed values.
1968
+ */
1969
+ function xtermTheme(): Record<string, string> & { background: string; foreground: string } {
1970
+ const dark = isDarkScheme()
1971
+ const style = getComputedStyle(document.body)
1972
+ const background = style.getPropertyValue('--dsw-alias-bg-base').trim() || (dark ? '#151517' : '#ffffff')
1973
+ const foreground = style.getPropertyValue('--dsw-alias-label-primary').trim() || (dark ? '#e6e6e6' : '#1a1a1a')
1974
+ return {
1975
+ background,
1976
+ foreground,
1977
+ cursor: foreground,
1978
+ cursorAccent: background,
1979
+ selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
1980
+ ...(dark ? ANSI_DARK : ANSI_LIGHT),
1981
+ }
1982
+ }
1983
+
1984
+ export function TerminalPanel({ sessionId, controller, t, onClose }: {
1985
+ sessionId: SessionId
1986
+ controller: SettingsClient
1987
+ t: Translate
1988
+ onClose: () => void
1989
+ }) {
1990
+ const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
1991
+ const enabled = settingsState.value?.gitBarEnabled ?? true
1992
+ const target: GitTarget = { session: String(sessionId) }
1993
+ // Warm from the shared snapshot cache, so the cwd label paints instantly.
1994
+ const [snapshot] = useGitStatus(enabled, target)
1995
+ const [width, setWidth] = useState(520)
1996
+ const [status, setStatus] = useState<'boot' | 'connecting' | 'live' | 'exited' | 'error'>('boot')
1997
+ const [errorText, setErrorText] = useState('')
1998
+ const [retryNonce, setRetryNonce] = useState(0)
1999
+ const panelTop = useSidePanelLayout(width, enabled)
2000
+ const prevWidthRef = useRef<number | null>(null)
2001
+ const hostRef = useRef<HTMLDivElement | null>(null)
2002
+
2003
+ const toggleHalfWidth = (): void => {
2004
+ const half = Math.round(window.innerWidth / 2)
2005
+ if (width >= half - 4) {
2006
+ const restore = prevWidthRef.current ?? 520
2007
+ prevWidthRef.current = null
2008
+ setWidth(restore)
2009
+ } else {
2010
+ prevWidthRef.current = width
2011
+ setWidth(half)
2012
+ }
2013
+ }
2014
+
2015
+ // Attach xterm + the WebSocket bridge. Re-runs on retryNonce (manual retry
2016
+ // after a fatal error). Teardown closes the socket WITHOUT a close frame,
2017
+ // so the host's reconnect grace keeps the shell alive for the next panel
2018
+ // open — exactly like switching tabs in dsh-better-sidebar.
2019
+ useEffect(() => {
2020
+ const host = hostRef.current
2021
+ if (!enabled || host === null) return
2022
+ let disposed = false
2023
+ let socket: WebSocket | null = null
2024
+ let retryTimer: number | undefined
2025
+ let failures = 0
2026
+ let term: XTermLike | null = null
2027
+ const cleanups: Array<() => void> = []
2028
+
2029
+ const sendFrame = (frame: Record<string, unknown>): void => {
2030
+ if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame))
2031
+ }
2032
+
2033
+ const connect = (): void => {
2034
+ if (disposed || term === null) return
2035
+ setStatus('connecting')
2036
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
2037
+ socket = new WebSocket(`${proto}//${location.host}${GIT_ROUTE}/terminal-ws?${targetQuery(target)}&cols=${term.cols}&rows=${term.rows}`)
2038
+ socket.onopen = () => {
2039
+ failures = 0
2040
+ setStatus('live')
2041
+ setErrorText('')
2042
+ if (term !== null) sendFrame({ type: 'resize', cols: term.cols, rows: term.rows })
2043
+ }
2044
+ socket.onmessage = (event) => {
2045
+ if (typeof event.data !== 'string' || term === null) return
2046
+ if (event.data.startsWith('{')) {
2047
+ try {
2048
+ const frame = JSON.parse(event.data) as { type?: unknown }
2049
+ if (frame.type === 'exit') {
2050
+ setStatus('exited')
2051
+ return
2052
+ }
2053
+ } catch {
2054
+ // Literal braces typed into the shell — render them.
2055
+ }
2056
+ }
2057
+ term.write(event.data)
2058
+ }
2059
+ socket.onclose = (event) => {
2060
+ socket = null
2061
+ if (disposed) return
2062
+ // A reasoned 1011 refusal is fatal (spawn failure / pty missing);
2063
+ // every other drop recovers with backoff — the host replays the
2064
+ // transcript on reconnect, so the retry is seamless.
2065
+ if (event.code === 1011 && event.reason !== '') {
2066
+ setStatus('error')
2067
+ setErrorText(event.reason === 'pty-unavailable' ? t('termUnavailable') : event.reason)
2068
+ return
2069
+ }
2070
+ failures += 1
2071
+ if (failures > 5) {
2072
+ setStatus('error')
2073
+ setErrorText(`${t('termLost')} (${event.code})`)
2074
+ return
2075
+ }
2076
+ retryTimer = window.setTimeout(connect, Math.min(8000, 400 * failures))
2077
+ }
2078
+ }
2079
+
2080
+ void (async () => {
2081
+ try {
2082
+ const globals = await loadXterm()
2083
+ if (disposed) return
2084
+ // Surface colors track the app theme: the host backdrop matches the
2085
+ // theme background exactly (the xterm viewport is transparent), and a
2086
+ // theme flip while the panel is open re-applies live.
2087
+ const applyTheme = (): void => {
2088
+ const theme = xtermTheme()
2089
+ host.style.backgroundColor = theme.background
2090
+ if (term !== null) term.options.theme = theme
2091
+ }
2092
+ term = new globals.Terminal({
2093
+ cursorBlink: true,
2094
+ fontSize: 12.5,
2095
+ fontFamily: '"SF Mono",ui-monospace,Consolas,"Courier New",monospace',
2096
+ scrollback: 4000,
2097
+ theme: xtermTheme(),
2098
+ })
2099
+ applyTheme()
2100
+ const fit = new globals.FitAddon()
2101
+ term.loadAddon(fit)
2102
+ term.open(host)
2103
+ try { fit.fit() } catch { /* zero-size host before layout settles */ }
2104
+ term.onData(data => sendFrame({ type: 'input', data }))
2105
+ term.onResize(dims => sendFrame({ type: 'resize', cols: dims.cols, rows: dims.rows }))
2106
+ const observer = new ResizeObserver(() => { try { fit.fit() } catch { /* mid-layout */ } })
2107
+ observer.observe(host)
2108
+ // DSH flips themes by toggling body's data-ds-dark-theme attribute.
2109
+ const themeObserver = new MutationObserver(applyTheme)
2110
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
2111
+ cleanups.push(() => observer.disconnect(), () => themeObserver.disconnect(), () => term?.dispose())
2112
+ connect()
2113
+ } catch (cause) {
2114
+ if (disposed) return
2115
+ setStatus('error')
2116
+ setErrorText(cause instanceof Error ? cause.message : String(cause))
2117
+ }
2118
+ })()
2119
+
2120
+ return () => {
2121
+ disposed = true
2122
+ if (retryTimer !== undefined) clearTimeout(retryTimer)
2123
+ socket?.close()
2124
+ for (const cleanup of cleanups.reverse()) cleanup()
2125
+ }
2126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2127
+ }, [enabled, retryNonce])
2128
+
2129
+ if (!enabled) return null
2130
+
2131
+ return createPortal(
2132
+ <div className="gbar-side" role="dialog" aria-label={t('terminal')} style={{ width: `${width}px`, top: `${panelTop}px` }}>
2133
+ <div className="side-head gbar-side-head">
2134
+ <span className="stitle gbar-title">{t('terminal')}</span>
2135
+ <span className="ssub gbar-sub" style={{ fontFamily: '"SF Mono",ui-monospace,Consolas,monospace' }}>{basenameOf(snapshot?.cwd)}</span>
2136
+ <span className="sp gbar-spacer" />
2137
+ {status !== 'live' && status !== 'error' ? (
2138
+ <span className="ssub gbar-sub">{status === 'exited' ? t('termExited') : t('loading')}</span>
2139
+ ) : null}
2140
+ <button
2141
+ type="button"
2142
+ className={'gbar-side-half' + (width >= Math.round(window.innerWidth / 2) - 4 ? ' gbar-on' : '')}
2143
+ onClick={toggleHalfWidth}
2144
+ title="展开到半屏 / 恢复宽度"
2145
+ >
2146
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
2147
+ <path d="M6 3H3v3" /><path d="M10 13h3v-3" /><path d="M3 3l4 4" /><path d="M13 13L9 9" />
2148
+ </svg>
2149
+ </button>
2150
+ <button type="button" className="gbar-side-x" onClick={onClose} aria-label="✕">✕</button>
2151
+ </div>
2152
+ <div className="gbar-term gbar-xterm" ref={hostRef} />
2153
+ {status === 'error' ? (
2154
+ <div className="gbar-term-error" role="alert">
2155
+ <span className="gbar-term-error-text">{errorText}</span>
2156
+ <button type="button" onClick={() => { setStatus('boot'); setErrorText(''); setRetryNonce(n => n + 1) }}>↻</button>
2157
+ </div>
2158
+ ) : null}
2159
+ </div>,
2160
+ document.body,
2161
+ )
2162
+ }
2163
+
2164
+ // ---------------------------------------------------------------------------
2165
+ // Header utilities — `conversation.session.header.utilities` (right-aligned):
2166
+ // 打开项目 / 终端 / 差异. The two panels are INDEPENDENT (same position, one
2167
+ // at a time); each icon toggles its own.
2168
+ // ---------------------------------------------------------------------------
2169
+
2170
+ export interface HeaderUtilitiesProps {
2171
+ sessionId: SessionId
2172
+ useSession: <T>(selector: (snapshot: SessionSnapshot) => T) => T
2173
+ controller: SettingsClient
2174
+ t: Translate
2175
+ }
2176
+
2177
+ export function HeaderUtilities({ sessionId, useSession, controller, t }: HeaderUtilitiesProps) {
2178
+ const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
2179
+ const enabled = settingsState.value?.gitBarEnabled ?? true
2180
+ const target: GitTarget = { session: String(sessionId) }
2181
+ const [snapshot] = useGitStatus(enabled, target)
2182
+
2183
+ const [openMenu, setOpenMenu] = useState(false)
2184
+ const [diffOpen, setDiffOpen] = useState(false)
2185
+ const [termOpen, setTermOpen] = useState(false)
2186
+ const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
2187
+ const wrapRef = useRef<HTMLSpanElement | null>(null)
2188
+ const iconRef = useRef<HTMLButtonElement | null>(null)
2189
+ const menuRef = useRef<HTMLDivElement | null>(null)
2190
+ const dirty = snapshot !== null && snapshot.isRepo && !snapshot.clean
2191
+
2192
+ // Close the open-project menu on outside click / Escape.
2193
+ useEffect(() => {
2194
+ if (!openMenu) return
2195
+ const onDown = (event: MouseEvent): void => {
2196
+ const node = event.target as Node | null
2197
+ if (node === null) return
2198
+ if (wrapRef.current?.contains(node) || menuRef.current?.contains(node)) return
2199
+ setOpenMenu(false)
2200
+ }
2201
+ const onKey = (event: KeyboardEvent): void => {
2202
+ if (event.key === 'Escape') setOpenMenu(false)
2203
+ }
2204
+ document.addEventListener('mousedown', onDown)
2205
+ document.addEventListener('keydown', onKey)
2206
+ return () => {
2207
+ document.removeEventListener('mousedown', onDown)
2208
+ document.removeEventListener('keydown', onKey)
2209
+ }
2210
+ }, [openMenu])
2211
+
2212
+ const showNotice = (kind: 'ok' | 'err', text: string): void => {
2213
+ setNotice({ kind, text })
2214
+ window.setTimeout(() => setNotice(null), 4000)
2215
+ }
2216
+
2217
+ const runOpen = async (app: 'explorer' | 'vscode' | 'idea' | 'goland' | 'webstorm' | 'pycharm'): Promise<void> => {
2218
+ try {
2219
+ await apiPost(`${GIT_ROUTE}/open`, { ...targetFields(target), target: app })
2220
+ setOpenMenu(false)
2221
+ showNotice('ok', `✓ ${app}`)
2222
+ } catch (cause) {
2223
+ showNotice('err', cause instanceof Error ? cause.message : String(cause))
2224
+ }
2225
+ }
2226
+
2227
+ if (!enabled) return null
2228
+
2229
+ return (
2230
+ <span className="gbar-hicons" ref={wrapRef}>
2231
+ <button
2232
+ type="button"
2233
+ ref={iconRef}
2234
+ className={'gbar-hicon' + (openMenu ? ' gbar-on' : '')}
2235
+ onClick={() => { setOpenMenu(value => !value); setTermOpen(false); setDiffOpen(false) }}
2236
+ title={t('openProject')}
2237
+ aria-expanded={openMenu}
2238
+ >
2239
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
2240
+ <path d="M1.5 4A1.5 1.5 0 0 1 3 2.5h3l1.5 2H13A1.5 1.5 0 0 1 14.5 6v6A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4Z" />
2241
+ <path d="M9.5 8.25l2.2 1.35-2.2 1.35v-2.7Z" fill="currentColor" stroke="none" />
2242
+ </svg>
2243
+ </button>
2244
+ <button
2245
+ type="button"
2246
+ className={'gbar-hicon' + (termOpen ? ' gbar-on' : '')}
2247
+ onClick={() => { setTermOpen(value => !value); setDiffOpen(false); setOpenMenu(false) }}
2248
+ title={t('terminal')}
2249
+ aria-expanded={termOpen}
2250
+ >
2251
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
2252
+ <rect x="1.5" y="2.5" width="13" height="11" rx="2" />
2253
+ <path d="M4.5 6l2.5 2-2.5 2" />
2254
+ <path d="M8.5 10h3" />
2255
+ </svg>
2256
+ </button>
2257
+ <button
2258
+ type="button"
2259
+ className={'gbar-hicon' + (diffOpen ? ' gbar-on' : '')}
2260
+ onClick={() => { setDiffOpen(value => !value); setTermOpen(false); setOpenMenu(false) }}
2261
+ title={t('diffTitle')}
2262
+ aria-expanded={diffOpen}
2263
+ >
2264
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" strokeLinecap="round" strokeLinejoin="round">
2265
+ <path d="M9.5 1.5H4A1.5 1.5 0 0 0 2.5 3v10A1.5 1.5 0 0 0 4 14.5h8A1.5 1.5 0 0 0 13.5 13V5.5l-4-4Z" />
2266
+ <path d="M9.5 1.5V5.5h4" />
2267
+ <path d="M5.75 10h4.5M5.75 7.5h2" />
2268
+ </svg>
2269
+ {/* Uncommitted-changes dot — the same dirty signal the branch chip's
2270
+ tooltip carries, surfaced on the icon so the state reads at a
2271
+ glance without hovering or opening the diff panel. */}
2272
+ {dirty ? <span className="gbar-hicon-dot" aria-hidden /> : null}
2273
+ </button>
2274
+
2275
+ {openMenu ? (
2276
+ <div className="gbar-openmenu" ref={menuRef} role="menu">
2277
+ <button type="button" className="gbar-omrow" onClick={() => { void runOpen('explorer') }}>
2278
+ <span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.explorer }} />
2279
+ {t('openExplorer')}
2280
+ </button>
2281
+ <button type="button" className="gbar-omrow" onClick={() => { void runOpen('vscode') }}>
2282
+ <span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.vscode }} />
2283
+ {t('openVscode')}
2284
+ </button>
2285
+ <button type="button" className="gbar-omrow" onClick={() => { void runOpen('idea') }}>
2286
+ <span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.idea }} />
2287
+ {t('openIdea')}
2288
+ </button>
2289
+ <button type="button" className="gbar-omrow" onClick={() => { void runOpen('goland') }}>
2290
+ <span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.goland }} />
2291
+ {t('openGoland')}
2292
+ </button>
2293
+ <button type="button" className="gbar-omrow" onClick={() => { void runOpen('webstorm') }}>
2294
+ <span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.webstorm }} />
2295
+ {t('openWebstorm')}
2296
+ </button>
2297
+ <button type="button" className="gbar-omrow" onClick={() => { void runOpen('pycharm') }}>
2298
+ <span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.pycharm }} />
2299
+ {t('openPycharm')}
2300
+ </button>
2301
+ </div>
2302
+ ) : null}
2303
+
2304
+ {termOpen ? <TerminalPanel sessionId={sessionId} controller={controller} t={t} onClose={() => { setTermOpen(false) }} /> : null}
2305
+ {/* The diff panel opens even while the model is running (it only reads
2306
+ git state; committing inside stays disabled while the agent works). */}
2307
+ {diffOpen ? <DiffPanel sessionId={sessionId} useSession={useSession} controller={controller} t={t} onClose={() => { setDiffOpen(false) }} /> : null}
2308
+
2309
+ {notice !== null ? createPortal(
2310
+ <div className={'gbar-notice gbar-' + notice.kind} role="status">{notice.text}</div>,
2311
+ document.body,
2312
+ ) : null}
2313
+ </span>
2314
+ )
2315
+ }
2316
+
2317
+ // ---------------------------------------------------------------------------
2318
+ // Hero (new-session) branch chip — the session header does not mount on the
2319
+ // blank/new-session screen, so the chip floats there instead: anchored just
2320
+ // left of the workspace picker (`[class$="_workspace"]`, CSS-module suffix is
2321
+ // stable within a pinned build). Git ops resolve via the workspace title
2322
+ // (`ws` target); if there is no repo, nothing renders.
2323
+ // ---------------------------------------------------------------------------
2324
+
2325
+ const HERO_ANCHOR_SELECTOR = '[class$="_workspace"]'
2326
+
2327
+ function HeroBranchChip({ t, right, top }: {
2328
+ t: Translate
2329
+ right: number
2330
+ top: number
2331
+ }) {
2332
+ const wsName = useMemo(
2333
+ () => document.querySelector<HTMLElement>(HERO_ANCHOR_SELECTOR)?.textContent?.trim() ?? '',
2334
+ [],
2335
+ )
2336
+ const [snapshot, refresh] = useGitStatus(true, { ws: wsName })
2337
+ const [branchOpen, setBranchOpen] = useState(false)
2338
+ const [branches, setBranches] = useState<GitBranches | null>(null)
2339
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
2340
+ const [busy, setBusy] = useState<string | null>(null)
2341
+ const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
2342
+ const chipRef = useRef<HTMLButtonElement | null>(null)
2343
+ const popRef = useRef<HTMLDivElement | null>(null)
2344
+
2345
+ const showNotice = (kind: 'ok' | 'err', text: string): void => {
2346
+ setNotice({ kind, text })
2347
+ window.setTimeout(() => setNotice(null), 4000)
2348
+ }
2349
+
2350
+ const run = async (label: string, fn: () => Promise<void>): Promise<boolean> => {
2351
+ if (busy !== null) return false
2352
+ setBusy(label)
2353
+ try {
2354
+ await fn()
2355
+ return true
2356
+ } catch (cause) {
2357
+ showNotice('err', cause instanceof Error ? cause.message : String(cause))
2358
+ return false
2359
+ } finally {
2360
+ setBusy(null)
2361
+ }
2362
+ }
2363
+
2364
+ const loadBranches = (): void => {
2365
+ void apiGet<GitBranches>(`${GIT_ROUTE}/branches?${targetQuery({ ws: wsName })}`)
2366
+ .then(setBranches)
2367
+ .catch(cause => { showNotice('err', cause instanceof Error ? cause.message : String(cause)) })
2368
+ }
2369
+
2370
+ const pickBranch = (name: string): void => {
2371
+ if (name === branches?.current) return
2372
+ void run('checkout', async () => {
2373
+ await apiPost(`${GIT_ROUTE}/checkout`, { ws: wsName, branch: name })
2374
+ setBranchOpen(false)
2375
+ await refresh()
2376
+ loadBranches()
2377
+ showNotice('ok', `→ ${name}`)
2378
+ })
2379
+ }
2380
+
2381
+ const deleteLocal = (name: string): void => {
2382
+ if (confirmDelete === name) {
2383
+ setConfirmDelete(null)
2384
+ void run('branch-delete', async () => {
2385
+ await apiPost(`${GIT_ROUTE}/branch-delete`, { ws: wsName, name })
2386
+ loadBranches()
2387
+ showNotice('ok', `🗑 ${name}`)
2388
+ })
2389
+ } else {
2390
+ setConfirmDelete(name)
2391
+ window.setTimeout(() => { setConfirmDelete(current => (current === name ? null : current)) }, 3000)
2392
+ }
2393
+ }
2394
+
2395
+ const deleteRemote = (name: string): void => {
2396
+ if (confirmDelete === name) {
2397
+ setConfirmDelete(null)
2398
+ void run('remote-delete', async () => {
2399
+ await apiPost(`${GIT_ROUTE}/remote-delete`, { ws: wsName, name })
2400
+ loadBranches()
2401
+ showNotice('ok', `🗑 ${name}`)
2402
+ })
2403
+ } else {
2404
+ setConfirmDelete(name)
2405
+ window.setTimeout(() => { setConfirmDelete(current => (current === name ? null : current)) }, 3000)
2406
+ }
2407
+ }
2408
+
2409
+ // Close on outside click / Escape.
2410
+ useEffect(() => {
2411
+ if (!branchOpen) return
2412
+ const onDown = (event: MouseEvent): void => {
2413
+ const node = event.target as Node | null
2414
+ if (node === null) return
2415
+ if (chipRef.current?.contains(node) || popRef.current?.contains(node)) return
2416
+ setBranchOpen(false)
2417
+ }
2418
+ const onKey = (event: KeyboardEvent): void => {
2419
+ if (event.key === 'Escape') setBranchOpen(false)
2420
+ }
2421
+ document.addEventListener('mousedown', onDown)
2422
+ document.addEventListener('keydown', onKey)
2423
+ return () => {
2424
+ document.removeEventListener('mousedown', onDown)
2425
+ document.removeEventListener('keydown', onKey)
2426
+ }
2427
+ }, [branchOpen])
2428
+
2429
+ if (snapshot === null || !snapshot.isRepo) return null
2430
+
2431
+ const dirty = !snapshot.clean
2432
+
2433
+ return (
2434
+ <div className="gbar-hero" style={{ top, right }}>
2435
+ <span className="gbar-fchip" title={snapshot.cwd}>
2436
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
2437
+ <path d="M1.5 4A1.5 1.5 0 0 1 3 2.5h3l1.5 2H13A1.5 1.5 0 0 1 14.5 6v6A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4Z" />
2438
+ </svg>
2439
+ <span className="gbar-folder">{basenameOf(snapshot.cwd)}</span>
2440
+ </span>
2441
+ <span className="gbar-bwrap">
2442
+ <button
2443
+ type="button"
2444
+ ref={chipRef}
2445
+ className={'gbar-chip' + (branchOpen ? ' gbar-open' : '')}
2446
+ onClick={() => { setBranchOpen(value => { if (!value) loadBranches(); return !value }) }}
2447
+ title={dirty ? t('dirty') : t('clean')}
2448
+ aria-expanded={branchOpen}
2449
+ >
2450
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
2451
+ <circle cx="4.5" cy="3.5" r="1.8" />
2452
+ <circle cx="4.5" cy="12.5" r="1.8" />
2453
+ <circle cx="12" cy="5.5" r="1.8" />
2454
+ <path d="M4.5 5.3v5.4" />
2455
+ <path d="M12 7.3c0 3-3.5 4.8-7.5 3.4" />
2456
+ </svg>
2457
+ <span className="gbar-bname">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
2458
+ </button>
2459
+ {branchOpen ? (
2460
+ <div className="gbar-pop" ref={popRef} role="menu">
2461
+ <div className="gbar-pop-head">
2462
+ <svg style={{ width: 14, height: 14, color: 'var(--dsw-alias-state-business-primary)', flex: 'none' }} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"><circle cx="4.5" cy="3.5" r="1.8" /><circle cx="4.5" cy="12.5" r="1.8" /><circle cx="12" cy="5.5" r="1.8" /><path d="M4.5 5.3v5.4" /><path d="M12 7.3c0 3-3.5 4.8-7.5 3.4" /></svg>
2463
+ <span className="cname">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
2464
+ <span className="state">
2465
+ <span style={{ width: 6, height: 6, borderRadius: 999, background: dirty ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)' }} />
2466
+ {snapshot.ahead > 0 ? <span>↑{snapshot.ahead}</span> : null}
2467
+ {snapshot.behind > 0 ? <span>↓{snapshot.behind}</span> : null}
2468
+ </span>
2469
+ </div>
2470
+ <div className="gbar-pop-body">
2471
+ <div className="gbar-sec">{t('branchLocal')}{branches !== null ? <span className="gbar-count">{branches.local.length}</span> : null}</div>
2472
+ {branches === null ? (
2473
+ <div className="gbar-loading"><span className="gbar-spin" />{t('loading')}</div>
2474
+ ) : branches.local.map(name => (
2475
+ <div key={name} className="gbar-rowwrap">
2476
+ <button type="button" className={'gbar-row' + (name === branches.current ? ' gbar-cur' : '')} onClick={() => { pickBranch(name) }}>
2477
+ <span>{name}</span>
2478
+ {name === branches.current ? <span className="gbar-check" aria-hidden>✓</span> : null}
2479
+ </button>
2480
+ {name !== branches.current && !isProtectedBranch(name) ? (
2481
+ <button type="button" className={'gbar-del' + (confirmDelete === name ? ' gbar-arm' : '')} onClick={() => { deleteLocal(name) }} title={t('branchDelete')}>
2482
+ {confirmDelete === name ? t('branchDeleteConfirm') : '🗑'}
2483
+ </button>
2484
+ ) : null}
2485
+ </div>
2486
+ ))}
2487
+ <div className="gbar-sec">{t('branchRemote')}{branches !== null ? <span className="gbar-count">{branches.remote.length}</span> : null}</div>
2488
+ {branches?.remote.map(name => (
2489
+ <div key={name} className="gbar-rowwrap">
2490
+ <button type="button" className="gbar-row" onClick={() => { pickBranch(shortBranch(name)) }}>
2491
+ <span>{shortBranch(name)}</span>
2492
+ <span className="gbar-rm">{name.split('/')[0]}</span>
2493
+ </button>
2494
+ {!isProtectedBranch(shortBranch(name)) ? (
2495
+ <button type="button" className={'gbar-del' + (confirmDelete === name ? ' gbar-arm' : '')} onClick={() => { deleteRemote(name) }} title={t('branchRemoteDelete')}>
2496
+ {confirmDelete === name ? t('branchDeleteConfirm') : '🗑'}
2497
+ </button>
2498
+ ) : null}
2499
+ </div>
2500
+ ))}
2501
+ </div>
2502
+ </div>
2503
+ ) : null}
2504
+ </span>
2505
+ {notice !== null ? createPortal(
2506
+ <div className={'gbar-notice gbar-' + notice.kind} role="status">{notice.text}</div>,
2507
+ document.body,
2508
+ ) : null}
2509
+ </div>
2510
+ )
2511
+ }
2512
+
2513
+ /**
2514
+ * Mount the hero floating chip: watch for the workspace picker appearing
2515
+ * (hero mounts/unmounts as sessions open and close), keep a fixed-position
2516
+ * React root anchored just left of it, and re-render on settings changes.
2517
+ */
2518
+ export function installHeroChip(controller: SettingsClient, t: Translate): () => void {
2519
+ const host = document.createElement('div')
2520
+ host.dataset.plugin = 'dsh-ui-tweaks-gitbar-hero'
2521
+ document.body.appendChild(host)
2522
+ const root = createRoot(host)
2523
+ let rect: { right: number; top: number } | null = null
2524
+
2525
+ const render = (): void => {
2526
+ const enabled = controller.getSnapshot().value?.gitBarEnabled ?? true
2527
+ root.render(rect === null || !enabled
2528
+ ? null
2529
+ : <HeroBranchChip t={t} right={rect.right} top={rect.top} />)
2530
+ }
2531
+ const measure = (): void => {
2532
+ const el = document.querySelector<HTMLElement>(HERO_ANCHOR_SELECTOR)
2533
+ rect = el === null
2534
+ ? null
2535
+ : (() => {
2536
+ const box = el.getBoundingClientRect()
2537
+ return { right: Math.max(0, Math.round(window.innerWidth - box.left + 8)), top: Math.round(box.top + box.height / 2) }
2538
+ })()
2539
+ render()
2540
+ }
2541
+ let raf = 0
2542
+ const observer = new MutationObserver(() => {
2543
+ cancelAnimationFrame(raf)
2544
+ raf = requestAnimationFrame(measure)
2545
+ })
2546
+ observer.observe(document.body, { childList: true, subtree: true })
2547
+ window.addEventListener('resize', measure)
2548
+ const unsubscribe = controller.subscribe(render)
2549
+ measure()
2550
+
2551
+ return () => {
2552
+ observer.disconnect()
2553
+ cancelAnimationFrame(raf)
2554
+ window.removeEventListener('resize', measure)
2555
+ unsubscribe()
2556
+ root.render(null)
2557
+ host.remove()
2558
+ }
2559
+ }