@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,722 @@
1
+ /**
2
+ * The source-control panel: status list (staged vs unstaged), stage/unstage,
3
+ * commit with a message box, branch switch, and a VSCode-like history — rows
4
+ * carry branch decorations, author and relative time. Clicking a changed
5
+ * file or a history row opens a dedicated diff TAB (see {@link DiffTab}),
6
+ * placed below the git pane on first use. File rows and history rows open a
7
+ * right-click context menu with advanced operations (open in editor, discard,
8
+ * revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
9
+ * focus. While visible it polls lightweight porcelain state so model-authored
10
+ * file changes appear without a manual refresh.
11
+ */
12
+ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
13
+ import {
14
+ Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
15
+ IconTrashOutline16, Input, Menu, Modal, writeClipboard,
16
+ } from '@deepseek-ai/dsh-client-ui-primitives'
17
+ import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from './api.ts'
18
+ import { api } from './api.ts'
19
+ import { isWithinWorkspace, relativeTo } from './paths.ts'
20
+ import { resolveSidebarPath } from './produced-files.ts'
21
+ import { relativeTime, t } from './locales.ts'
22
+ import type { SidebarStore, SidebarTab } from './state.ts'
23
+ import css from './sidebar.module.css'
24
+
25
+ /** The XY status letters a row badge shows (X = index, Y = worktree). */
26
+ function badgeOf(entry: GitStatusEntry): string {
27
+ const index = entry.xy[0]
28
+ const worktree = entry.xy[1]
29
+ if (index !== undefined && index !== ' ' && index !== '?') return index
30
+ if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
31
+ return '?'
32
+ }
33
+
34
+ /** Whether the entry carries STAGED (index) changes — the X letter is set. */
35
+ function isStagedEntry(entry: GitStatusEntry): boolean {
36
+ const index = entry.xy[0]
37
+ return index !== undefined && index !== ' ' && index !== '?'
38
+ }
39
+
40
+ /** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
41
+ * (untracked `??` counts as unstaged: it is a worktree-only change). A file
42
+ * with both letters set ('MM') lands in BOTH sections. */
43
+ function isUnstagedEntry(entry: GitStatusEntry): boolean {
44
+ if (entry.xy === '??') return true
45
+ const worktree = entry.xy[1]
46
+ return worktree !== undefined && worktree !== ' ' && worktree !== '?'
47
+ }
48
+
49
+ /** Whether the entry is untracked (`??`): git diff never includes it. */
50
+ function isUntracked(entry: GitStatusEntry): boolean {
51
+ return badgeOf(entry) === '?'
52
+ }
53
+
54
+ /** The last path segment (tab title for a file's diff). */
55
+ function baseName(path: string): string {
56
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
57
+ return at === -1 ? path : path.slice(at + 1)
58
+ }
59
+
60
+ /** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
61
+ function refNames(refs: string): string[] {
62
+ return [...new Set(
63
+ refs
64
+ .split(',')
65
+ .map(ref => ref.trim())
66
+ .filter(ref => ref !== '')
67
+ .map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
68
+ .map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
69
+ )]
70
+ }
71
+
72
+ /** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
73
+ interface ConfirmState {
74
+ title: string
75
+ description: string
76
+ confirmLabel: string
77
+ onConfirm: () => Promise<unknown>
78
+ }
79
+
80
+ /** History batch size: the log loads lazily in pages so a long history never
81
+ * floods the panel at once (the end of the log is reached by paging). */
82
+ const LOG_BATCH = 20
83
+
84
+ export function GitView(props: {
85
+ scope: SessionScope
86
+ /** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
87
+ store: SidebarStore
88
+ onOpenFile: (path: string) => void
89
+ /** Open a diff tab (the shell places it below the git pane on first use). */
90
+ onOpenDiff: (tab: SidebarTab) => void
91
+ /** Poll only while the tab is actually visible. */
92
+ visible: boolean
93
+ }) {
94
+ const { scope, store, onOpenFile, onOpenDiff, visible } = props
95
+ const [status, setStatus] = useState<GitStatusResult | null>(null)
96
+ const [worktrees, setWorktrees] = useState<GitWorktree[]>([])
97
+ const [selectedWorktree, setSelectedWorktree] = useState<string | undefined>()
98
+ const [repoRoot, setRepoRoot] = useState<string | undefined>(undefined)
99
+ const [loading, setLoading] = useState(true)
100
+ const [error, setError] = useState<string | null>(null)
101
+ const [branchNames, setBranchNames] = useState<string[]>([])
102
+ const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
103
+ const [commitMsg, setCommitMsg] = useState('')
104
+ const [busy, setBusy] = useState(false)
105
+ const [commitError, setCommitError] = useState<string | null>(null)
106
+ /** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
107
+ const [logEnded, setLogEnded] = useState(false)
108
+ const [logLoadingMore, setLogLoadingMore] = useState(false)
109
+
110
+ /** The open file-row context menu (cursor position for the portaled Menu). */
111
+ const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
112
+ /** The open history-row context menu. */
113
+ const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
114
+ /** The pending destructive action awaiting confirmation. */
115
+ const [confirm, setConfirm] = useState<ConfirmState | null>(null)
116
+ const refreshInFlight = useRef(false)
117
+ /** Monotonic request id: a manual worktree switch invalidates any older poll
118
+ * before it can publish state from the previous checkout. */
119
+ const refreshGeneration = useRef(0)
120
+ const worktreeChosenByUser = useRef(false)
121
+ /** selectedWorktree read inside refresh without re-creating the callback:
122
+ * avoids a spurious full refresh on every auto-select (the very state
123
+ * change refresh writes back via setSelectedWorktree would recreate the
124
+ * callback and re-trigger the mount effect — an N→N+1 fetch loop). */
125
+ const selectedRef = useRef<string | undefined>(undefined)
126
+ useEffect(() => { selectedRef.current = selectedWorktree }, [selectedWorktree])
127
+
128
+ const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
129
+
130
+ /** Publish a complete checkout-derived view. Status, branch choices and
131
+ * history are one consistency unit: never mix rows from two worktrees. */
132
+ const refreshTarget = useCallback(async (
133
+ target: string | undefined,
134
+ options: { loading: boolean; generation: number },
135
+ ): Promise<void> => {
136
+ if (options.loading) setLoading(true)
137
+ setError(null)
138
+ try {
139
+ const [statusResult, branchResult, logResult] = await Promise.all([
140
+ api.gitStatus(gitScope, target),
141
+ api.gitBranch(gitScope, target).catch(() => ({ current: '', names: [] as string[] })),
142
+ api.gitLog(gitScope, LOG_BATCH, 0, target).catch(() => [] as GitLogEntry[]),
143
+ ])
144
+ if (options.generation !== refreshGeneration.current) return
145
+ setStatus(statusResult)
146
+ if (statusResult.root !== undefined && statusResult.root !== repoRoot) setRepoRoot(statusResult.root)
147
+ setBranchNames(branchResult.names)
148
+ setLogEntries(logResult)
149
+ setLogEnded(logResult.length < LOG_BATCH)
150
+ } catch (reason) {
151
+ if (options.generation === refreshGeneration.current) {
152
+ setError(reason instanceof Error ? reason.message : String(reason))
153
+ }
154
+ } finally {
155
+ if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
156
+ }
157
+ }, [scope.sessionId, scope.cwd, repoRoot])
158
+
159
+ const refresh = useCallback(async (silent = false): Promise<void> => {
160
+ if (refreshInFlight.current) return
161
+ refreshInFlight.current = true
162
+ let generation = refreshGeneration.current
163
+ try {
164
+ const listed = await api.gitWorktrees(scope)
165
+ if (generation !== refreshGeneration.current) return
166
+ setWorktrees(listed)
167
+ const selectedStillExists = listed.some(entry => entry.path === selectedRef.current)
168
+ let target = selectedStillExists ? selectedRef.current : listed.find(entry => entry.current)?.path
169
+ // DSH and other coding agents commonly create one linked checkout while
170
+ // the session remains rooted at the clean primary checkout. Select that
171
+ // checkout automatically only when the choice is unambiguous.
172
+ const current = listed.find(entry => entry.current)
173
+ const dirtyLinked = listed.filter(entry => !entry.current && entry.changes > 0)
174
+ if (!worktreeChosenByUser.current) {
175
+ target = (current?.changes ?? 0) === 0 && dirtyLinked.length === 1
176
+ ? dirtyLinked[0]!.path
177
+ : current?.path
178
+ }
179
+ const targetChanged = target !== selectedRef.current
180
+ if (targetChanged) {
181
+ // Changing the automatically selected checkout invalidates any direct
182
+ // target refresh that may still be resolving for the previous one.
183
+ generation = refreshGeneration.current += 1
184
+ selectedRef.current = target
185
+ setSelectedWorktree(target)
186
+ // Remove rows owned by the previous checkout immediately: keeping them
187
+ // interactive while the target changes could apply a destructive action
188
+ // to the new checkout with stale history from the old one.
189
+ setStatus(null)
190
+ setBranchNames([])
191
+ setLogEntries([])
192
+ setLogEnded(false)
193
+ setLogLoadingMore(false)
194
+ }
195
+ // A poll may update status alone only while staying on the same checkout.
196
+ // Any automatic selection change refreshes the complete derived view.
197
+ if (silent && !targetChanged) {
198
+ const statusResult = await api.gitStatus(gitScope, target)
199
+ if (generation === refreshGeneration.current) setStatus(statusResult)
200
+ return
201
+ }
202
+ await refreshTarget(target, { loading: !silent, generation })
203
+ } catch (reason) {
204
+ if (generation === refreshGeneration.current) {
205
+ setError(reason instanceof Error ? reason.message : String(reason))
206
+ if (!silent) setLoading(false)
207
+ }
208
+ } finally {
209
+ refreshInFlight.current = false
210
+ }
211
+ }, [scope.sessionId, scope.cwd, refreshTarget])
212
+
213
+ useEffect(() => {
214
+ refreshGeneration.current += 1
215
+ refreshInFlight.current = false
216
+ worktreeChosenByUser.current = false
217
+ selectedRef.current = undefined
218
+ setSelectedWorktree(undefined)
219
+ }, [scope.sessionId, scope.cwd])
220
+ useEffect(() => { void refresh() }, [refresh])
221
+
222
+ /** A user choice invalidates any older poll and atomically refreshes every
223
+ * checkout-derived surface before destructive history actions can run. */
224
+ const chooseWorktree = (target: string): void => {
225
+ worktreeChosenByUser.current = true
226
+ selectedRef.current = target
227
+ setSelectedWorktree(target)
228
+ setStatus(null)
229
+ setBranchNames([])
230
+ setLogEntries([])
231
+ setLogEnded(false)
232
+ setLogLoadingMore(false)
233
+ const generation = refreshGeneration.current += 1
234
+ void refreshTarget(target, { loading: true, generation })
235
+ }
236
+ /** Switching the selected child repository must invalidate every
237
+ * target-derived surface (status/history/log) before the asynchronous
238
+ * refresh resolves; otherwise stale rows remain actionable while their
239
+ * handlers already address the new repository. Mirrors chooseWorktree. */
240
+ const chooseRepo = (target: string): void => {
241
+ setRepoRoot(target)
242
+ setStatus(null)
243
+ setBranchNames([])
244
+ setLogEntries([])
245
+ setLogEnded(false)
246
+ setLogLoadingMore(false)
247
+ // Re-list worktrees for the selected child (a workspace container's
248
+ // own worktree list is empty); keep the current linked-checkout choice
249
+ // unless it does not belong to the new repository.
250
+ const generation = refreshGeneration.current += 1
251
+ void refreshTarget(selectedRef.current ?? '', { loading: true, generation })
252
+ }
253
+ useEffect(() => {
254
+ if (!visible) return
255
+ const timer = window.setInterval(() => { void refresh(true) }, 2_000)
256
+ return () => { window.clearInterval(timer) }
257
+ }, [visible, refresh])
258
+
259
+ /** Append the next history page (lazy: only when the user asks for more). */
260
+ const loadMoreLog = async (): Promise<void> => {
261
+ if (logLoadingMore || logEnded) return
262
+ const generation = refreshGeneration.current
263
+ const target = selectedRef.current
264
+ setLogLoadingMore(true)
265
+ try {
266
+ const next = await api.gitLog(gitScope, LOG_BATCH, logEntries.length, target)
267
+ // A worktree switch clears the old history and increments generation.
268
+ // Never append a late page from that checkout into the new one.
269
+ if (generation !== refreshGeneration.current || target !== selectedRef.current) return
270
+ setLogEntries(entries => [...entries, ...next])
271
+ if (next.length < LOG_BATCH) setLogEnded(true)
272
+ } catch (reason) {
273
+ if (generation === refreshGeneration.current && target === selectedRef.current) {
274
+ setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
275
+ }
276
+ } finally {
277
+ if (generation === refreshGeneration.current && target === selectedRef.current) setLogLoadingMore(false)
278
+ }
279
+ }
280
+
281
+ /** The diff tab for one changed file (one tab per path+side; same id = focused). */
282
+ const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
283
+ onOpenDiff({
284
+ id: `diff:w:${encodeURIComponent(selectedWorktree ?? '')}:${staged ? 's' : 'u'}:${entry.path}`,
285
+ type: 'diff',
286
+ title: baseName(entry.path),
287
+ diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree, repoRoot },
288
+ })
289
+ }
290
+
291
+ /** The diff tab for one commit (one tab per commit). */
292
+ const openCommitDiff = (entry: GitLogEntry): void => {
293
+ onOpenDiff({
294
+ id: `diff:c:${encodeURIComponent(selectedWorktree ?? '')}:${entry.hashFull}`,
295
+ type: 'diff',
296
+ title: `${entry.hash} ${entry.subject}`,
297
+ diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject, worktree: selectedWorktree, repoRoot },
298
+ })
299
+ }
300
+
301
+ const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
302
+ setBusy(true)
303
+ try {
304
+ if (staged) await api.gitUnstage(gitScope, entry.path, selectedWorktree)
305
+ else await api.gitStage(gitScope, entry.path, selectedWorktree)
306
+ await refresh()
307
+ } finally {
308
+ setBusy(false)
309
+ }
310
+ }
311
+
312
+ const stageAll = async (staged: boolean): Promise<void> => {
313
+ setBusy(true)
314
+ try {
315
+ if (staged) await api.gitUnstage(gitScope, undefined, selectedWorktree)
316
+ else await api.gitStage(gitScope, undefined, selectedWorktree)
317
+ await refresh()
318
+ } finally {
319
+ setBusy(false)
320
+ }
321
+ }
322
+
323
+ const commit = async (): Promise<void> => {
324
+ const message = commitMsg.trim()
325
+ if (message === '' || busy) return
326
+ setBusy(true)
327
+ setCommitError(null)
328
+ try {
329
+ await api.gitCommit(gitScope, message, selectedWorktree)
330
+ setCommitMsg('')
331
+ await refresh()
332
+ } catch (reason) {
333
+ setCommitError(reason instanceof Error ? reason.message : String(reason))
334
+ } finally {
335
+ setBusy(false)
336
+ }
337
+ }
338
+
339
+ const checkout = async (branch: string): Promise<void> => {
340
+ if (branch === status?.branch || busy) return
341
+ setBusy(true)
342
+ setCommitError(null)
343
+ try {
344
+ await api.gitCheckout(gitScope, branch, selectedWorktree)
345
+ await refresh()
346
+ } catch (reason) {
347
+ setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
348
+ } finally {
349
+ setBusy(false)
350
+ }
351
+ }
352
+
353
+ /** Run one destructive operation after the confirm modal, then refresh. */
354
+ const runConfirmed = (confirmState: ConfirmState): void => {
355
+ setConfirm({ ...confirmState, onConfirm: async () => {
356
+ setBusy(true)
357
+ setCommitError(null)
358
+ try {
359
+ await confirmState.onConfirm()
360
+ await refresh()
361
+ } catch (reason) {
362
+ setCommitError(reason instanceof Error ? reason.message : String(reason))
363
+ } finally {
364
+ setBusy(false)
365
+ }
366
+ } })
367
+ }
368
+
369
+ /** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
370
+ const copy = (text: string): void => {
371
+ void writeClipboard(text)
372
+ }
373
+
374
+ const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
375
+ event.preventDefault()
376
+ event.stopPropagation()
377
+ setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
378
+ }
379
+
380
+ const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
381
+ event.preventDefault()
382
+ event.stopPropagation()
383
+ setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
384
+ }
385
+
386
+ const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
387
+ const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
388
+
389
+ const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
390
+ return (
391
+ <div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
392
+ <button
393
+ type="button"
394
+ className={css.gitRowMain}
395
+ title={entry.path}
396
+ onClick={() => { openWorktreeDiff(entry, staged) }}
397
+ onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
398
+ >
399
+ <span className={css.gitBadge}>{badgeOf(entry)}</span>
400
+ <span className={css.gitName}>{entry.path}</span>
401
+ </button>
402
+ <button
403
+ type="button"
404
+ className={css.iconButton}
405
+ aria-label={staged ? t('unstage') : t('stage')}
406
+ title={staged ? t('unstage') : t('stage')}
407
+ disabled={busy}
408
+ onClick={() => { void stageEntry(entry, staged) }}
409
+ >
410
+ {staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
411
+ </button>
412
+ </div>
413
+ )
414
+ }
415
+
416
+ return (
417
+ <div className={css.git}>
418
+ {worktrees.length > 1 && (
419
+ <div className={css.gitWorktreeRow}>
420
+ <span className={css.gitWorktreeLabel}>{t('worktree')}</span>
421
+ <select
422
+ className={css.gitBranchSelect}
423
+ value={selectedWorktree ?? ''}
424
+ title={selectedWorktree}
425
+ disabled={busy}
426
+ onChange={(event) => { chooseWorktree(event.target.value) }}
427
+ >
428
+ {worktrees.map(entry => (
429
+ <option key={entry.path} value={entry.path}>
430
+ {entry.branch} · {baseName(entry.path)} ({entry.changes})
431
+ </option>
432
+ ))}
433
+ </select>
434
+ </div>
435
+ )}
436
+ <div className={css.gitHeader}>
437
+ {(status?.repositories?.length ?? 0) > 1 && (
438
+ <select
439
+ className={css.gitBranchSelect}
440
+ value={repoRoot ?? ''}
441
+ title={repoRoot}
442
+ onChange={(event) => { chooseRepo(event.target.value) }}
443
+ disabled={busy}
444
+ >
445
+ {status!.repositories!.map(root => <option key={root} value={root}>{baseName(root)}</option>)}
446
+ </select>
447
+ )}
448
+ <select
449
+ className={css.gitBranchSelect}
450
+ value={status?.branch ?? ''}
451
+ onChange={(event) => { void checkout(event.target.value) }}
452
+ disabled={busy || (status !== null && !status.isRepo)}
453
+ >
454
+ {(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
455
+ {branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
456
+ </select>
457
+ <button
458
+ type="button"
459
+ className={css.iconButton}
460
+ aria-label={t('refresh')}
461
+ title={t('refresh')}
462
+ onClick={() => { void refresh() }}
463
+ >
464
+ <IconRefreshOutline16 size={14} />
465
+ </button>
466
+ </div>
467
+
468
+ {loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
469
+ {!loading && error !== null && <div className={css.gitError}>{error}</div>}
470
+ {!loading && status !== null && !status.isRepo && (
471
+ <div className={css.gitPlaceholder}>{t('notRepo')}</div>
472
+ )}
473
+
474
+ {status !== null && status.isRepo && (
475
+ <>
476
+ {status.truncated === true && (
477
+ <div className={css.gitEmpty}>{t('statusTruncated')}</div>
478
+ )}
479
+ <div className={css.gitSection}>
480
+ <div className={css.gitSectionHeader}>
481
+ <span>{t('staged')} ({stagedEntries.length})</span>
482
+ {stagedEntries.length > 0 && (
483
+ <button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
484
+ {t('unstageAll')}
485
+ </button>
486
+ )}
487
+ </div>
488
+ {stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
489
+ {stagedEntries.map(entry => renderEntry(entry, true))}
490
+ </div>
491
+ <div className={css.gitSection}>
492
+ <div className={css.gitSectionHeader}>
493
+ <span>{t('unstaged')} ({unstagedEntries.length})</span>
494
+ {unstagedEntries.length > 0 && (
495
+ <button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
496
+ {t('stageAll')}
497
+ </button>
498
+ )}
499
+ </div>
500
+ {unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
501
+ {unstagedEntries.map(entry => renderEntry(entry, false))}
502
+ </div>
503
+
504
+ <div className={css.gitCommit}>
505
+ <Input
506
+ className={css.gitCommitInput}
507
+ placeholder={t('commitPlaceholder')}
508
+ value={commitMsg}
509
+ disabled={busy}
510
+ onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
511
+ onKeyDown={(event) => {
512
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
513
+ }}
514
+ />
515
+ <button
516
+ type="button"
517
+ className={css.gitCommitButton}
518
+ disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
519
+ onClick={() => { void commit() }}
520
+ >
521
+ {t('commit')}
522
+ </button>
523
+ </div>
524
+ {commitError !== null && <div className={css.gitError}>{commitError}</div>}
525
+
526
+ <div className={css.gitSection}>
527
+ <div className={css.gitSectionHeader}><span>{t('history')}</span></div>
528
+ {logEntries.map(entry => (
529
+ <div
530
+ key={entry.hashFull}
531
+ role="button"
532
+ tabIndex={0}
533
+ className={css.gitLogRow}
534
+ title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
535
+ onClick={() => { openCommitDiff(entry) }}
536
+ onKeyDown={(event) => {
537
+ if (event.key === 'Enter' || event.key === ' ') {
538
+ event.preventDefault()
539
+ openCommitDiff(entry)
540
+ }
541
+ }}
542
+ onContextMenu={(event) => { openHistoryMenu(event, entry) }}
543
+ >
544
+ <span className={css.gitLogLine1}>
545
+ <span className={css.gitLogHash}>{entry.hash}</span>
546
+ <span className={css.gitLogSubject}>{entry.subject}</span>
547
+ </span>
548
+ <span className={css.gitLogLine2}>
549
+ {refNames(entry.refs).map(ref => (
550
+ <span key={ref} className={css.gitLogRef}>{ref}</span>
551
+ ))}
552
+ <span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
553
+ </span>
554
+ </div>
555
+ ))}
556
+ {!logEnded && (
557
+ <button
558
+ type="button"
559
+ className={css.gitLogMore}
560
+ disabled={logLoadingMore || busy}
561
+ onClick={() => { void loadMoreLog() }}
562
+ >
563
+ {logLoadingMore ? t('loading') : t('loadMore')}
564
+ </button>
565
+ )}
566
+ </div>
567
+
568
+ {/*
569
+ The one shared file-row context menu, positioned at the right-click
570
+ cursor (portal so the panel's overflow clip cannot crop it).
571
+ */}
572
+ <Menu
573
+ open={fileMenu !== null}
574
+ onClose={() => { setFileMenu(null) }}
575
+ items={[
576
+ // A linked worktree outside the session workspace cannot be
577
+ // opened in the editor while the host's workspace fence is
578
+ // armed: it rejects every path under that checkout. Hide the
579
+ // action for that checkout so the menu does not offer a no-op
580
+ // that confuses the user; with the fence disarmed (the
581
+ // `workspaceFence` pref) the open is allowed through.
582
+ ...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path)))
583
+ ? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> }]
584
+ : []),
585
+ fileMenu?.staged === true
586
+ ? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
587
+ : { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
588
+ ...(fileMenu !== null && !isUntracked(fileMenu.entry)
589
+ ? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
590
+ : []),
591
+ { type: 'separator', id: 'sep1' },
592
+ { id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
593
+ { id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
594
+ ]}
595
+ onSelect={(id) => {
596
+ const target = fileMenu
597
+ if (target === null) return
598
+ setFileMenu(null)
599
+ if (id === 'open') {
600
+ const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path)
601
+ // Defense-in-depth: the menu hides this action when the
602
+ // resolved path escapes the session workspace, but a
603
+ // racing repo switch could still reach here with a path
604
+ // the host would reject. No-op in that case — unless the
605
+ // workspace fence is disarmed by pref.
606
+ if (store.getPrefs().workspaceFence !== false && !isWithinWorkspace(scope.cwd ?? '', resolved)) return
607
+ onOpenFile(resolved)
608
+ return
609
+ }
610
+ if (id === 'stage') {
611
+ void stageEntry(target.entry, target.staged)
612
+ return
613
+ }
614
+ if (id === 'discard') {
615
+ runConfirmed({
616
+ title: t('discardTitle'),
617
+ description: t('discardDesc', { path: target.entry.path }),
618
+ confirmLabel: t('discard'),
619
+ onConfirm: () => api.gitDiscard(gitScope, target.entry.path, selectedWorktree),
620
+ })
621
+ return
622
+ }
623
+ if (id === 'relative') {
624
+ copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.entry.path))
625
+ return
626
+ }
627
+ if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
628
+ }}
629
+ portal
630
+ align="start"
631
+ getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
632
+ anchor={<span />}
633
+ />
634
+
635
+ {/* The shared history-row context menu. */}
636
+ <Menu
637
+ open={historyMenu !== null}
638
+ onClose={() => { setHistoryMenu(null) }}
639
+ items={[
640
+ { id: 'view', label: t('viewCommitDiff') },
641
+ { id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
642
+ { id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
643
+ { id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
644
+ { type: 'separator', id: 'sep2' },
645
+ { id: 'revert', label: t('revertCommit'), danger: true },
646
+ { id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
647
+ ]}
648
+ onSelect={(id) => {
649
+ const target = historyMenu
650
+ if (target === null) return
651
+ setHistoryMenu(null)
652
+ if (id === 'view') {
653
+ openCommitDiff(target.entry)
654
+ return
655
+ }
656
+ if (id === 'copyShort') {
657
+ copy(target.entry.hash)
658
+ return
659
+ }
660
+ if (id === 'copyFull') {
661
+ copy(target.entry.hashFull)
662
+ return
663
+ }
664
+ if (id === 'copySubject') {
665
+ copy(target.entry.subject)
666
+ return
667
+ }
668
+ if (id === 'revert') {
669
+ runConfirmed({
670
+ title: t('revertTitle'),
671
+ description: t('revertDesc', { subject: target.entry.subject }),
672
+ confirmLabel: t('revertCommit'),
673
+ onConfirm: () => api.gitRevert(gitScope, target.entry.hashFull, selectedWorktree),
674
+ })
675
+ return
676
+ }
677
+ if (id === 'cherryPick') {
678
+ runConfirmed({
679
+ title: t('cherryPickTitle'),
680
+ description: t('cherryPickDesc', { subject: target.entry.subject }),
681
+ confirmLabel: t('cherryPickCommit'),
682
+ onConfirm: () => api.gitCherryPick(gitScope, target.entry.hashFull, selectedWorktree),
683
+ })
684
+ }
685
+ }}
686
+ portal
687
+ align="start"
688
+ getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
689
+ anchor={<span />}
690
+ />
691
+
692
+ {/* Destructive actions land here first: Cancel / Confirm. */}
693
+ <Modal
694
+ open={confirm !== null}
695
+ onClose={() => { setConfirm(null) }}
696
+ title={confirm?.title ?? ''}
697
+ closeLabel={t('cancel')}
698
+ footer={(
699
+ <>
700
+ <Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
701
+ <Button
702
+ variant="primary"
703
+ disabled={busy}
704
+ onClick={() => {
705
+ const pending = confirm
706
+ if (pending === null) return
707
+ setConfirm(null)
708
+ void pending.onConfirm()
709
+ }}
710
+ >
711
+ {confirm?.confirmLabel ?? ''}
712
+ </Button>
713
+ </>
714
+ )}
715
+ >
716
+ <p className={css.gitConfirmDesc}>{confirm?.description}</p>
717
+ </Modal>
718
+ </>
719
+ )}
720
+ </div>
721
+ )
722
+ }