trident-git 0.2.0 → 0.2.2

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 (311) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-path-routes-manifest.json +23 -0
  3. package/.next/build-manifest.json +20 -0
  4. package/.next/export-marker.json +6 -0
  5. package/.next/images-manifest.json +73 -0
  6. package/.next/next-minimal-server.js.nft.json +1 -0
  7. package/.next/next-server.js.nft.json +1 -0
  8. package/.next/package.json +1 -0
  9. package/.next/prerender-manifest.json +282 -0
  10. package/.next/react-loadable-manifest.json +1 -0
  11. package/.next/required-server-files.js +330 -0
  12. package/.next/required-server-files.json +330 -0
  13. package/.next/routes-manifest.json +170 -0
  14. package/.next/server/app/_global-error/page.js +3 -0
  15. package/.next/server/app/_global-error/page.js.nft.json +1 -0
  16. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_global-error.html +2 -0
  18. package/.next/server/app/_global-error.meta +16 -0
  19. package/.next/server/app/_global-error.rsc +12 -0
  20. package/.next/server/app/_global-error.segments/_full.segment.rsc +12 -0
  21. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +5 -0
  22. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +4 -0
  23. package/.next/server/app/_global-error.segments/_head.segment.rsc +5 -0
  24. package/.next/server/app/_global-error.segments/_index.segment.rsc +4 -0
  25. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -0
  26. package/.next/server/app/_not-found/page.js +2 -0
  27. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  28. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  29. package/.next/server/app/_not-found.html +1 -0
  30. package/.next/server/app/_not-found.meta +16 -0
  31. package/.next/server/app/_not-found.rsc +19 -0
  32. package/.next/server/app/_not-found.segments/_full.segment.rsc +19 -0
  33. package/.next/server/app/_not-found.segments/_head.segment.rsc +6 -0
  34. package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -0
  35. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +5 -0
  36. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +4 -0
  37. package/.next/server/app/_not-found.segments/_tree.segment.rsc +6 -0
  38. package/.next/server/app/api/credentials/route.js +1 -0
  39. package/.next/server/app/api/credentials/route.js.nft.json +1 -0
  40. package/.next/server/app/api/credentials/route_client-reference-manifest.js +1 -0
  41. package/.next/server/app/api/custom-scripts/route.js +3 -0
  42. package/.next/server/app/api/custom-scripts/route.js.nft.json +1 -0
  43. package/.next/server/app/api/custom-scripts/route_client-reference-manifest.js +1 -0
  44. package/.next/server/app/api/fs/route.js +1 -0
  45. package/.next/server/app/api/fs/route.js.nft.json +1 -0
  46. package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
  47. package/.next/server/app/api/git/action/route.js +1 -0
  48. package/.next/server/app/api/git/action/route.js.nft.json +1 -0
  49. package/.next/server/app/api/git/action/route_client-reference-manifest.js +1 -0
  50. package/.next/server/app/api/git/branches/route.js +1 -0
  51. package/.next/server/app/api/git/branches/route.js.nft.json +1 -0
  52. package/.next/server/app/api/git/branches/route_client-reference-manifest.js +1 -0
  53. package/.next/server/app/api/git/diff/route.js +1 -0
  54. package/.next/server/app/api/git/diff/route.js.nft.json +1 -0
  55. package/.next/server/app/api/git/diff/route_client-reference-manifest.js +1 -0
  56. package/.next/server/app/api/git/log/route.js +1 -0
  57. package/.next/server/app/api/git/log/route.js.nft.json +1 -0
  58. package/.next/server/app/api/git/log/route_client-reference-manifest.js +1 -0
  59. package/.next/server/app/api/git/status/route.js +1 -0
  60. package/.next/server/app/api/git/status/route.js.nft.json +1 -0
  61. package/.next/server/app/api/git/status/route_client-reference-manifest.js +1 -0
  62. package/.next/server/app/api/repos/route.js +1 -0
  63. package/.next/server/app/api/repos/route.js.nft.json +1 -0
  64. package/.next/server/app/api/repos/route_client-reference-manifest.js +1 -0
  65. package/.next/server/app/api/settings/route.js +1 -0
  66. package/.next/server/app/api/settings/route.js.nft.json +1 -0
  67. package/.next/server/app/api/settings/route_client-reference-manifest.js +1 -0
  68. package/.next/server/app/credentials/page.js +4 -0
  69. package/.next/server/app/credentials/page.js.nft.json +1 -0
  70. package/.next/server/app/credentials/page_client-reference-manifest.js +1 -0
  71. package/.next/server/app/credentials.html +1 -0
  72. package/.next/server/app/credentials.meta +15 -0
  73. package/.next/server/app/credentials.rsc +23 -0
  74. package/.next/server/app/credentials.segments/_full.segment.rsc +23 -0
  75. package/.next/server/app/credentials.segments/_head.segment.rsc +6 -0
  76. package/.next/server/app/credentials.segments/_index.segment.rsc +8 -0
  77. package/.next/server/app/credentials.segments/_tree.segment.rsc +6 -0
  78. package/.next/server/app/credentials.segments/credentials/__PAGE__.segment.rsc +9 -0
  79. package/.next/server/app/credentials.segments/credentials.segment.rsc +4 -0
  80. package/.next/server/app/icon.png/route.js +1 -0
  81. package/.next/server/app/icon.png/route.js.nft.json +1 -0
  82. package/.next/server/app/icon.png.meta +1 -0
  83. package/.next/server/app/index.html +1 -0
  84. package/.next/server/app/index.meta +14 -0
  85. package/.next/server/app/index.rsc +20 -0
  86. package/.next/server/app/index.segments/__PAGE__.segment.rsc +6 -0
  87. package/.next/server/app/index.segments/_full.segment.rsc +20 -0
  88. package/.next/server/app/index.segments/_head.segment.rsc +6 -0
  89. package/.next/server/app/index.segments/_index.segment.rsc +8 -0
  90. package/.next/server/app/index.segments/_tree.segment.rsc +6 -0
  91. package/.next/server/app/page.js +2 -0
  92. package/.next/server/app/page.js.nft.json +1 -0
  93. package/.next/server/app/page_client-reference-manifest.js +1 -0
  94. package/.next/server/app/workspace/changes/page.js +4 -0
  95. package/.next/server/app/workspace/changes/page.js.nft.json +1 -0
  96. package/.next/server/app/workspace/changes/page_client-reference-manifest.js +1 -0
  97. package/.next/server/app/workspace/changes.html +1 -0
  98. package/.next/server/app/workspace/changes.meta +16 -0
  99. package/.next/server/app/workspace/changes.rsc +26 -0
  100. package/.next/server/app/workspace/changes.segments/_full.segment.rsc +26 -0
  101. package/.next/server/app/workspace/changes.segments/_head.segment.rsc +6 -0
  102. package/.next/server/app/workspace/changes.segments/_index.segment.rsc +8 -0
  103. package/.next/server/app/workspace/changes.segments/_tree.segment.rsc +7 -0
  104. package/.next/server/app/workspace/changes.segments/workspace/changes/__PAGE__.segment.rsc +10 -0
  105. package/.next/server/app/workspace/changes.segments/workspace/changes.segment.rsc +4 -0
  106. package/.next/server/app/workspace/changes.segments/workspace.segment.rsc +7 -0
  107. package/.next/server/app/workspace/custom-scripts/page.js +2 -0
  108. package/.next/server/app/workspace/custom-scripts/page.js.nft.json +1 -0
  109. package/.next/server/app/workspace/custom-scripts/page_client-reference-manifest.js +1 -0
  110. package/.next/server/app/workspace/custom-scripts.html +1 -0
  111. package/.next/server/app/workspace/custom-scripts.meta +16 -0
  112. package/.next/server/app/workspace/custom-scripts.rsc +25 -0
  113. package/.next/server/app/workspace/custom-scripts.segments/_full.segment.rsc +25 -0
  114. package/.next/server/app/workspace/custom-scripts.segments/_head.segment.rsc +6 -0
  115. package/.next/server/app/workspace/custom-scripts.segments/_index.segment.rsc +8 -0
  116. package/.next/server/app/workspace/custom-scripts.segments/_tree.segment.rsc +6 -0
  117. package/.next/server/app/workspace/custom-scripts.segments/workspace/custom-scripts/__PAGE__.segment.rsc +9 -0
  118. package/.next/server/app/workspace/custom-scripts.segments/workspace/custom-scripts.segment.rsc +4 -0
  119. package/.next/server/app/workspace/custom-scripts.segments/workspace.segment.rsc +7 -0
  120. package/.next/server/app/workspace/history/page.js +2 -0
  121. package/.next/server/app/workspace/history/page.js.nft.json +1 -0
  122. package/.next/server/app/workspace/history/page_client-reference-manifest.js +1 -0
  123. package/.next/server/app/workspace/history.html +1 -0
  124. package/.next/server/app/workspace/history.meta +16 -0
  125. package/.next/server/app/workspace/history.rsc +26 -0
  126. package/.next/server/app/workspace/history.segments/_full.segment.rsc +26 -0
  127. package/.next/server/app/workspace/history.segments/_head.segment.rsc +6 -0
  128. package/.next/server/app/workspace/history.segments/_index.segment.rsc +8 -0
  129. package/.next/server/app/workspace/history.segments/_tree.segment.rsc +7 -0
  130. package/.next/server/app/workspace/history.segments/workspace/history/__PAGE__.segment.rsc +10 -0
  131. package/.next/server/app/workspace/history.segments/workspace/history.segment.rsc +4 -0
  132. package/.next/server/app/workspace/history.segments/workspace.segment.rsc +7 -0
  133. package/.next/server/app/workspace/page.js +2 -0
  134. package/.next/server/app/workspace/page.js.nft.json +1 -0
  135. package/.next/server/app/workspace/page_client-reference-manifest.js +1 -0
  136. package/.next/server/app/workspace/settings/page.js +2 -0
  137. package/.next/server/app/workspace/settings/page.js.nft.json +1 -0
  138. package/.next/server/app/workspace/settings/page_client-reference-manifest.js +1 -0
  139. package/.next/server/app/workspace/settings.html +1 -0
  140. package/.next/server/app/workspace/settings.meta +16 -0
  141. package/.next/server/app/workspace/settings.rsc +25 -0
  142. package/.next/server/app/workspace/settings.segments/_full.segment.rsc +25 -0
  143. package/.next/server/app/workspace/settings.segments/_head.segment.rsc +6 -0
  144. package/.next/server/app/workspace/settings.segments/_index.segment.rsc +8 -0
  145. package/.next/server/app/workspace/settings.segments/_tree.segment.rsc +6 -0
  146. package/.next/server/app/workspace/settings.segments/workspace/settings/__PAGE__.segment.rsc +9 -0
  147. package/.next/server/app/workspace/settings.segments/workspace/settings.segment.rsc +4 -0
  148. package/.next/server/app/workspace/settings.segments/workspace.segment.rsc +7 -0
  149. package/.next/server/app/workspace/stashes/page.js +2 -0
  150. package/.next/server/app/workspace/stashes/page.js.nft.json +1 -0
  151. package/.next/server/app/workspace/stashes/page_client-reference-manifest.js +1 -0
  152. package/.next/server/app/workspace/stashes.html +1 -0
  153. package/.next/server/app/workspace/stashes.meta +16 -0
  154. package/.next/server/app/workspace/stashes.rsc +26 -0
  155. package/.next/server/app/workspace/stashes.segments/_full.segment.rsc +26 -0
  156. package/.next/server/app/workspace/stashes.segments/_head.segment.rsc +6 -0
  157. package/.next/server/app/workspace/stashes.segments/_index.segment.rsc +8 -0
  158. package/.next/server/app/workspace/stashes.segments/_tree.segment.rsc +7 -0
  159. package/.next/server/app/workspace/stashes.segments/workspace/stashes/__PAGE__.segment.rsc +10 -0
  160. package/.next/server/app/workspace/stashes.segments/workspace/stashes.segment.rsc +4 -0
  161. package/.next/server/app/workspace/stashes.segments/workspace.segment.rsc +7 -0
  162. package/.next/server/app/workspace.html +1 -0
  163. package/.next/server/app/workspace.meta +15 -0
  164. package/.next/server/app/workspace.rsc +26 -0
  165. package/.next/server/app/workspace.segments/_full.segment.rsc +26 -0
  166. package/.next/server/app/workspace.segments/_head.segment.rsc +6 -0
  167. package/.next/server/app/workspace.segments/_index.segment.rsc +8 -0
  168. package/.next/server/app/workspace.segments/_tree.segment.rsc +7 -0
  169. package/.next/server/app/workspace.segments/workspace/__PAGE__.segment.rsc +10 -0
  170. package/.next/server/app/workspace.segments/workspace.segment.rsc +7 -0
  171. package/.next/server/app-paths-manifest.json +23 -0
  172. package/.next/server/chunks/116.js +1 -0
  173. package/.next/server/chunks/170.js +1 -0
  174. package/.next/server/chunks/309.js +6 -0
  175. package/.next/server/chunks/356.js +1 -0
  176. package/.next/server/chunks/392.js +1 -0
  177. package/.next/server/chunks/443.js +37 -0
  178. package/.next/server/chunks/445.js +22 -0
  179. package/.next/server/chunks/471.js +13 -0
  180. package/.next/server/chunks/557.js +5 -0
  181. package/.next/server/chunks/680.js +1 -0
  182. package/.next/server/chunks/739.js +5 -0
  183. package/.next/server/chunks/781.js +1 -0
  184. package/.next/server/chunks/813.js +1 -0
  185. package/.next/server/chunks/816.js +13 -0
  186. package/.next/server/functions-config-manifest.json +6 -0
  187. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  188. package/.next/server/middleware-build-manifest.js +1 -0
  189. package/.next/server/middleware-manifest.json +6 -0
  190. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  191. package/.next/server/next-font-manifest.js +1 -0
  192. package/.next/server/next-font-manifest.json +1 -0
  193. package/.next/server/pages/404.html +1 -0
  194. package/.next/server/pages/500.html +2 -0
  195. package/.next/server/pages-manifest.json +4 -0
  196. package/.next/server/server-reference-manifest.js +1 -0
  197. package/.next/server/server-reference-manifest.json +1 -0
  198. package/.next/server/webpack-runtime.js +1 -0
  199. package/.next/static/VTWJwhFJd5VcC6GP5bbli/_buildManifest.js +1 -0
  200. package/.next/static/VTWJwhFJd5VcC6GP5bbli/_ssgManifest.js +1 -0
  201. package/.next/static/chunks/131-6237c77d708182b1.js +1 -0
  202. package/.next/static/chunks/147-d4cb3e03218d75df.js +6 -0
  203. package/.next/static/chunks/176-b068120641913226.js +1 -0
  204. package/.next/static/chunks/200-3664e65687e716a2.js +1 -0
  205. package/.next/static/chunks/26-d93e82c9c09ff972.js +1 -0
  206. package/.next/static/chunks/4bd1b696-096d35a2bd1da3af.js +1 -0
  207. package/.next/static/chunks/500-5d45efbf86b8d36e.js +1 -0
  208. package/.next/static/chunks/772-b39107b40c31b6b6.js +3 -0
  209. package/.next/static/chunks/785-e6d755147ff87934.js +1 -0
  210. package/.next/static/chunks/790-3e8fa6e595d97940.js +6 -0
  211. package/.next/static/chunks/794-37dad9bbc14b04b8.js +2 -0
  212. package/.next/static/chunks/841-4d865378216d36eb.js +13 -0
  213. package/.next/static/chunks/app/_global-error/page-9eb8a19109bbd711.js +1 -0
  214. package/.next/static/chunks/app/_not-found/page-5bea07380837254f.js +1 -0
  215. package/.next/static/chunks/app/api/credentials/route-9eb8a19109bbd711.js +1 -0
  216. package/.next/static/chunks/app/api/custom-scripts/route-9eb8a19109bbd711.js +1 -0
  217. package/.next/static/chunks/app/api/fs/route-9eb8a19109bbd711.js +1 -0
  218. package/.next/static/chunks/app/api/git/action/route-9eb8a19109bbd711.js +1 -0
  219. package/.next/static/chunks/app/api/git/branches/route-9eb8a19109bbd711.js +1 -0
  220. package/.next/static/chunks/app/api/git/diff/route-9eb8a19109bbd711.js +1 -0
  221. package/.next/static/chunks/app/api/git/log/route-9eb8a19109bbd711.js +1 -0
  222. package/.next/static/chunks/app/api/git/status/route-9eb8a19109bbd711.js +1 -0
  223. package/.next/static/chunks/app/api/repos/route-9eb8a19109bbd711.js +1 -0
  224. package/.next/static/chunks/app/api/settings/route-9eb8a19109bbd711.js +1 -0
  225. package/.next/static/chunks/app/credentials/page-23c1807ad9f68a40.js +1 -0
  226. package/.next/static/chunks/app/layout-a06241c385e50ae0.js +1 -0
  227. package/.next/static/chunks/app/page-c8404790f5191fd7.js +1 -0
  228. package/.next/static/chunks/app/workspace/changes/page-8cca17f80e66f534.js +3 -0
  229. package/.next/static/chunks/app/workspace/custom-scripts/page-ee24b9dd7779244f.js +1 -0
  230. package/.next/static/chunks/app/workspace/history/page-b7017fea285c804c.js +1 -0
  231. package/.next/static/chunks/app/workspace/layout-cb86c00fdcd8db08.js +1 -0
  232. package/.next/static/chunks/app/workspace/page-21211acc90c08509.js +1 -0
  233. package/.next/static/chunks/app/workspace/settings/page-7ccb58ece31492e9.js +1 -0
  234. package/.next/static/chunks/app/workspace/stashes/page-0e1b8304c4b0f4f3.js +1 -0
  235. package/.next/static/chunks/framework-75892d61b920805f.js +1 -0
  236. package/.next/static/chunks/main-app-221e1b945693a142.js +1 -0
  237. package/.next/static/chunks/main-f1d55fffd3cc9809.js +5 -0
  238. package/.next/static/chunks/next/dist/client/components/builtin/app-error-9eb8a19109bbd711.js +1 -0
  239. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-9eb8a19109bbd711.js +1 -0
  240. package/.next/static/chunks/next/dist/client/components/builtin/global-error-f2a2c324f3740ed8.js +1 -0
  241. package/.next/static/chunks/next/dist/client/components/builtin/not-found-9eb8a19109bbd711.js +1 -0
  242. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-9eb8a19109bbd711.js +1 -0
  243. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  244. package/.next/static/chunks/webpack-7dec27dc3bfe207a.js +1 -0
  245. package/.next/static/css/36968b90b2339533.css +1 -0
  246. package/.next/static/css/c56c0e57397a3a60.css +1 -0
  247. package/.next/static/css/de70bee13400563f.css +1 -0
  248. package/.next/static/media/4cf2300e9c8272f7-s.p.woff2 +0 -0
  249. package/.next/static/media/747892c23ea88013-s.woff2 +0 -0
  250. package/.next/static/media/8d697b304b401681-s.woff2 +0 -0
  251. package/.next/static/media/93f479601ee12b01-s.p.woff2 +0 -0
  252. package/.next/static/media/9610d9e46709d722-s.woff2 +0 -0
  253. package/.next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
  254. package/README.md +2 -0
  255. package/bin/trident-git.mjs +9 -11
  256. package/{next.config.ts → next.config.mjs} +7 -8
  257. package/package.json +8 -6
  258. package/eslint.config.mjs +0 -18
  259. package/postcss.config.mjs +0 -7
  260. package/src/app/api/credentials/route.ts +0 -113
  261. package/src/app/api/custom-scripts/route.ts +0 -203
  262. package/src/app/api/fs/route.ts +0 -75
  263. package/src/app/api/git/action/route.ts +0 -383
  264. package/src/app/api/git/branches/route.ts +0 -20
  265. package/src/app/api/git/diff/route.ts +0 -104
  266. package/src/app/api/git/log/route.ts +0 -28
  267. package/src/app/api/git/status/route.ts +0 -28
  268. package/src/app/api/repos/route.ts +0 -84
  269. package/src/app/api/settings/route.ts +0 -37
  270. package/src/app/credentials/page.tsx +0 -408
  271. package/src/app/globals.css +0 -109
  272. package/src/app/layout.tsx +0 -38
  273. package/src/app/page.tsx +0 -10
  274. package/src/app/providers.tsx +0 -21
  275. package/src/app/workspace/changes/page.tsx +0 -27
  276. package/src/app/workspace/custom-scripts/page.tsx +0 -247
  277. package/src/app/workspace/history/page.tsx +0 -27
  278. package/src/app/workspace/layout.tsx +0 -26
  279. package/src/app/workspace/page.tsx +0 -27
  280. package/src/app/workspace/settings/page.tsx +0 -233
  281. package/src/app/workspace/stashes/page.tsx +0 -395
  282. package/src/components/command-palette.tsx +0 -178
  283. package/src/components/context-menu.tsx +0 -200
  284. package/src/components/fs-browser.tsx +0 -154
  285. package/src/components/git/diff-view.tsx +0 -137
  286. package/src/components/git/git-graph.tsx +0 -489
  287. package/src/components/git/grouped-diff-viewer.tsx +0 -332
  288. package/src/components/git/history-view.tsx +0 -4862
  289. package/src/components/git/image-diff-view.tsx +0 -342
  290. package/src/components/git/status-view.tsx +0 -597
  291. package/src/components/home-settings-modal.tsx +0 -192
  292. package/src/components/layout/sidebar.tsx +0 -256
  293. package/src/components/repo-list.tsx +0 -206
  294. package/src/components/theme-toggle.tsx +0 -37
  295. package/src/components/toaster.tsx +0 -36
  296. package/src/components/workspace-repo-open-tracker.tsx +0 -39
  297. package/src/hooks/use-credentials.ts +0 -123
  298. package/src/hooks/use-escape-dismiss.ts +0 -72
  299. package/src/hooks/use-git.ts +0 -448
  300. package/src/hooks/use-toast.ts +0 -280
  301. package/src/hooks/use-workspace-title.ts +0 -23
  302. package/src/lib/api-utils.ts +0 -24
  303. package/src/lib/branch-colors.ts +0 -98
  304. package/src/lib/credentials.ts +0 -404
  305. package/src/lib/git.ts +0 -1510
  306. package/src/lib/graph-utils.ts +0 -253
  307. package/src/lib/store.ts +0 -145
  308. package/src/lib/types.ts +0 -95
  309. package/src/lib/utils.ts +0 -266
  310. package/tsconfig.json +0 -34
  311. /package/{src/app/icon.png → .next/server/app/icon.png.body} +0 -0
@@ -1,4862 +0,0 @@
1
- 'use client';
2
-
3
- import { useGitLog, useGitBranches, useGitStatus, useGitAction, useCommitDiff, useCommitFileDiff, CommitFile, useRepository, useUpdateRepository, useSettings, useUpdateSettings } from '@/hooks/use-git';
4
- import { Repository, RepositoryCustomScript, BranchTrackingInfo } from '@/lib/types';
5
- import { GitGraph, GitGraphHandle } from './git-graph';
6
- import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
7
- import { useTheme } from 'next-themes';
8
- import { cn, sanitizeBranchName, isFileBinary, isImageFile, getChangedLineCountFromDiff } from '@/lib/utils';
9
- import { ContextMenu, ContextMenuItem } from '@/components/context-menu';
10
- import { GroupedDiffViewer } from './grouped-diff-viewer';
11
- import { ImageDiffView } from './image-diff-view';
12
- import { useEscapeDismiss } from '@/hooks/use-escape-dismiss';
13
- import { toast } from '@/hooks/use-toast';
14
-
15
-
16
- // Visibility state for branches/folders
17
- type VisibilityState = 'visible' | 'hidden' | null;
18
-
19
- // Map of path -> visibility state
20
- type VisibilityMap = Record<string, VisibilityState>;
21
-
22
- // Tree node type for branch hierarchy
23
- interface BranchTreeNode {
24
- name: string;
25
- fullPath?: string; // Only set for leaf nodes (actual branches)
26
- children: Map<string, BranchTreeNode>;
27
- }
28
-
29
- const MIN_HISTORY_PANEL_HEIGHT = 100;
30
- const MAX_HISTORY_PANEL_HEIGHT = 900;
31
- const MIN_COMMIT_DETAILS_MESSAGE_RATIO = 0.15;
32
- const MAX_COMMIT_DETAILS_MESSAGE_RATIO = 0.75;
33
- const DEFAULT_COMMIT_DETAILS_MESSAGE_RATIO = 0.28;
34
- type MergeConflictStatus = 'checking' | 'no-conflict' | 'has-conflicts';
35
- type CommitRowSelectModifiers = { isMultiSelect: boolean; isRangeSelect: boolean };
36
- type BranchRowSelectModifiers = { isMultiSelect: boolean; isRangeSelect: boolean };
37
- type BranchOperation = { sourceBranch: string; targetBranch: string };
38
- type ScriptExecutionStatus = 'idle' | 'starting' | 'running' | 'completed' | 'failed' | 'canceled';
39
- type ScriptExecutionState = {
40
- isOpen: boolean;
41
- executionId: string | null;
42
- scriptName: string;
43
- branchRef: string;
44
- output: string;
45
- status: ScriptExecutionStatus;
46
- error: string | null;
47
- };
48
-
49
- function clampHistoryPanelHeight(height: number): number {
50
- return Math.min(Math.max(height, MIN_HISTORY_PANEL_HEIGHT), MAX_HISTORY_PANEL_HEIGHT);
51
- }
52
-
53
- function clampCommitDetailsMessageRatio(ratio: number): number {
54
- return Math.min(Math.max(ratio, MIN_COMMIT_DETAILS_MESSAGE_RATIO), MAX_COMMIT_DETAILS_MESSAGE_RATIO);
55
- }
56
-
57
- function buildCommitMessage(subject: string, body: string): string {
58
- const trimmedSubject = subject.trim();
59
- const normalizedBody = body.replace(/\r\n/g, '\n');
60
- return normalizedBody.trim() ? `${trimmedSubject}\n\n${normalizedBody}` : trimmedSubject;
61
- }
62
-
63
- async function copyText(text: string): Promise<boolean> {
64
- try {
65
- await navigator.clipboard.writeText(text);
66
- return true;
67
- } catch {
68
- const textArea = document.createElement('textarea');
69
- textArea.value = text;
70
- textArea.style.position = 'fixed';
71
- textArea.style.left = '-9999px';
72
- document.body.appendChild(textArea);
73
- textArea.select();
74
-
75
- try {
76
- document.execCommand('copy');
77
- return true;
78
- } catch {
79
- return false;
80
- } finally {
81
- document.body.removeChild(textArea);
82
- }
83
- }
84
- }
85
-
86
- const DEFAULT_SCRIPT_EXECUTION: ScriptExecutionState = {
87
- isOpen: false,
88
- executionId: null,
89
- scriptName: '',
90
- branchRef: '',
91
- output: '',
92
- status: 'idle',
93
- error: null,
94
- };
95
-
96
-
97
- // File status icon component
98
- function FileStatusIcon({ status }: { status: string }) {
99
- switch (status) {
100
- case 'A':
101
- return <i className="iconoir-plus-circle text-[16px] text-success" aria-hidden="true" />;
102
- case 'D':
103
- return <i className="iconoir-minus-circle text-[16px] text-error" aria-hidden="true" />;
104
- case 'M':
105
- return <i className="iconoir-edit-pencil text-[16px] text-warning" aria-hidden="true" />;
106
- default:
107
- return <i className="iconoir-page text-[16px] opacity-50" aria-hidden="true" />;
108
- }
109
- }
110
-
111
- interface CommitFileTreeNode {
112
- name: string;
113
- path: string;
114
- file?: CommitFile;
115
- children: Map<string, CommitFileTreeNode>;
116
- }
117
-
118
- function buildCommitFileTree(files: CommitFile[]): CommitFileTreeNode {
119
- const root: CommitFileTreeNode = {
120
- name: '',
121
- path: '',
122
- children: new Map(),
123
- };
124
-
125
- for (const file of files) {
126
- const parts = file.path.split('/').filter(Boolean);
127
- let current = root;
128
- let currentPath = '';
129
-
130
- for (let i = 0; i < parts.length; i++) {
131
- const part = parts[i];
132
- currentPath = currentPath ? `${currentPath}/${part}` : part;
133
-
134
- if (!current.children.has(part)) {
135
- current.children.set(part, {
136
- name: part,
137
- path: currentPath,
138
- children: new Map(),
139
- });
140
- }
141
-
142
- current = current.children.get(part)!;
143
-
144
- if (i === parts.length - 1) {
145
- current.file = file;
146
- }
147
- }
148
- }
149
-
150
- return root;
151
- }
152
-
153
- function collectCommitFolderPaths(node: CommitFileTreeNode): string[] {
154
- const paths: string[] = [];
155
- const children = Array.from(node.children.values());
156
-
157
- children.forEach((child) => {
158
- if (child.children.size > 0) {
159
- paths.push(child.path);
160
- paths.push(...collectCommitFolderPaths(child));
161
- }
162
- });
163
-
164
- return paths;
165
- }
166
-
167
- function getParentPaths(filePath: string): string[] {
168
- const parts = filePath.split('/').filter(Boolean);
169
- const parentPaths: string[] = [];
170
-
171
- for (let i = 1; i < parts.length; i++) {
172
- parentPaths.push(parts.slice(0, i).join('/'));
173
- }
174
-
175
- return parentPaths;
176
- }
177
-
178
- function CommitFileTreeItem({
179
- node,
180
- selectedFile,
181
- expandedFolders,
182
- onToggleFolder,
183
- onSelectFile,
184
- depth = 0,
185
- }: {
186
- node: CommitFileTreeNode;
187
- selectedFile: string | null;
188
- expandedFolders: Set<string>;
189
- onToggleFolder: (path: string) => void;
190
- onSelectFile: (path: string) => void;
191
- depth?: number;
192
- }) {
193
- const children = Array.from(node.children.values()).sort((a, b) => {
194
- const aIsFolder = a.children.size > 0;
195
- const bIsFolder = b.children.size > 0;
196
-
197
- if (aIsFolder && !bIsFolder) return -1;
198
- if (!aIsFolder && bIsFolder) return 1;
199
- return a.name.localeCompare(b.name);
200
- });
201
-
202
- return (
203
- <>
204
- {children.map((child) => {
205
- const isFolder = child.children.size > 0;
206
-
207
- if (isFolder) {
208
- const isExpanded = expandedFolders.has(child.path);
209
-
210
- return (
211
- <div key={child.path}>
212
- <div
213
- className="flex items-center gap-1 px-2 py-1.5 text-xs rounded cursor-pointer hover:bg-base-200 transition-colors opacity-80"
214
- style={{ paddingLeft: `${depth * 12 + 8}px` }}
215
- onClick={() => onToggleFolder(child.path)}
216
- title={child.path}
217
- >
218
- <span className="text-[10px] opacity-70">{isExpanded ? '▼' : '▶'}</span>
219
- <i className="iconoir-folder text-[14px] opacity-70" aria-hidden="true" />
220
- <span className="truncate flex-1">{child.name}</span>
221
- </div>
222
- {isExpanded && (
223
- <CommitFileTreeItem
224
- node={child}
225
- selectedFile={selectedFile}
226
- expandedFolders={expandedFolders}
227
- onToggleFolder={onToggleFolder}
228
- onSelectFile={onSelectFile}
229
- depth={depth + 1}
230
- />
231
- )}
232
- </div>
233
- );
234
- }
235
-
236
- if (!child.file) return null;
237
- const file = child.file;
238
-
239
- return (
240
- <div
241
- key={child.path}
242
- className={cn(
243
- "flex items-center gap-2 px-2 py-1.5 text-xs rounded cursor-pointer hover:bg-base-200 transition-colors",
244
- selectedFile === file.path && "bg-base-200 font-medium"
245
- )}
246
- style={{ paddingLeft: `${depth * 12 + 8}px` }}
247
- onClick={() => onSelectFile(file.path)}
248
- title={file.path}
249
- >
250
- <FileStatusIcon status={file.status} />
251
- <span className="truncate flex-1 font-mono">{child.name}</span>
252
- </div>
253
- );
254
- })}
255
- </>
256
- );
257
- }
258
-
259
- // Component to show commit file diff
260
- function CommitFileDiffView({ repoPath, commitHash, filePath, splitView }: { repoPath: string; commitHash: string; filePath: string; splitView: boolean }) {
261
- const { data, isLoading } = useCommitFileDiff(repoPath, commitHash, filePath);
262
- const { resolvedTheme } = useTheme();
263
- const [renderAnyway, setRenderAnyway] = useState(false);
264
-
265
- // Reset renderAnyway when file or commit changes
266
- useEffect(() => {
267
- setRenderAnyway(false);
268
- }, [filePath, commitHash]);
269
-
270
- if (isLoading) {
271
- return <div className="flex items-center justify-center p-8"><span className="loading loading-spinner text-base-content/50"></span></div>;
272
- }
273
-
274
- if (!data) {
275
- return <div className="flex items-center justify-center p-8 opacity-50">No diff available</div>;
276
- }
277
-
278
- if (isImageFile(filePath)) {
279
- return <ImageDiffView filePath={filePath} imageDiff={data.imageDiff} />;
280
- }
281
-
282
- // Check if file is binary (first by extension, then by content if unknown)
283
- const isBinary = isFileBinary(filePath, data.left, data.right);
284
-
285
- if (isBinary) {
286
- return <div className="flex items-center justify-center p-8 opacity-50">Binary file - diff not available</div>;
287
- }
288
-
289
- // Large file protection
290
- const MAX_DIFF_SIZE = 100 * 1024; // 100KB
291
- const MAX_DIFF_LINES = 3000;
292
-
293
- const diffContent = data.diff || '';
294
- const contentSize = diffContent.length;
295
- const lineCount = getChangedLineCountFromDiff(diffContent);
296
-
297
- const isLargeDiff = (contentSize > MAX_DIFF_SIZE || lineCount > MAX_DIFF_LINES);
298
-
299
- if (isLargeDiff && !renderAnyway) {
300
- return (
301
- <div className="flex flex-col items-center justify-center h-full gap-4 text-center p-8">
302
- <i className="iconoir-warning-triangle text-[32px] text-warning" aria-hidden="true" />
303
- <div className="space-y-2">
304
- <h3 className="font-bold text-lg">Large Diff Detected</h3>
305
- <p className="opacity-70">
306
- This diff is large ({Math.round(contentSize / 1024)}KB, ~{lineCount} changed lines) and may freeze your browser if rendered.
307
- </p>
308
- </div>
309
- <button className="btn btn-outline" onClick={() => setRenderAnyway(true)}>
310
- Show Diff Anyway
311
- </button>
312
- </div>
313
- );
314
- }
315
-
316
- return (
317
- <div className="overflow-auto h-full">
318
- <GroupedDiffViewer
319
- oldValue={data.left || ''}
320
- newValue={data.right || ''}
321
- splitView={splitView}
322
- useDarkTheme={resolvedTheme === 'dark'}
323
- />
324
- </div>
325
- );
326
- }
327
-
328
- // Component to show commit changes
329
- function CommitChangesView({ repoPath, commitHash }: { repoPath: string; commitHash: string }) {
330
- const { data, isLoading } = useCommitDiff(repoPath, commitHash);
331
- const [selectedFile, setSelectedFile] = useState<string | null>(null);
332
- const [collapsedFoldersByCommit, setCollapsedFoldersByCommit] = useState<Record<string, Set<string>>>({});
333
- const fileTree = useMemo(() => buildCommitFileTree(data?.files ?? []), [data?.files]);
334
- const allFolderPaths = useMemo(() => collectCommitFolderPaths(fileTree), [fileTree]);
335
- const collapsedFolders = useMemo(
336
- () => collapsedFoldersByCommit[commitHash] ?? new Set<string>(),
337
- [collapsedFoldersByCommit, commitHash]
338
- );
339
-
340
- // Storage key for split view preference - same as in DiffView
341
- const storageKey = 'git-web:diff-view-split';
342
-
343
- const [splitView, setSplitView] = useState(() => {
344
- if (typeof window === 'undefined') return true;
345
- try {
346
- const stored = localStorage.getItem(storageKey);
347
- return stored !== null ? JSON.parse(stored) : true;
348
- } catch (e) {
349
- console.error('Failed to load split view preference:', e);
350
- return true;
351
- }
352
- });
353
-
354
- // Save split view preference when it changes
355
- useEffect(() => {
356
- try {
357
- localStorage.setItem(storageKey, JSON.stringify(splitView));
358
- } catch (e) {
359
- console.error('Failed to save split view preference:', e);
360
- }
361
- }, [splitView]);
362
-
363
- // Reset selected file when commit changes
364
- useEffect(() => {
365
- setSelectedFile(null);
366
- }, [commitHash]);
367
-
368
- // Auto-select first file when data loads and no file is selected
369
- useEffect(() => {
370
- if (!selectedFile && data?.files && data.files.length > 0) {
371
- setSelectedFile(data.files[0].path);
372
- }
373
- }, [data?.files, selectedFile]);
374
-
375
- const expandedFolders = useMemo(() => {
376
- const expanded = new Set<string>();
377
-
378
- allFolderPaths.forEach((path) => {
379
- if (!collapsedFolders.has(path)) {
380
- expanded.add(path);
381
- }
382
- });
383
-
384
- if (selectedFile) {
385
- getParentPaths(selectedFile).forEach((path) => expanded.add(path));
386
- }
387
-
388
- return expanded;
389
- }, [allFolderPaths, collapsedFolders, selectedFile]);
390
-
391
- const handleToggleFolder = useCallback((path: string) => {
392
- setCollapsedFoldersByCommit((prev) => {
393
- const current = prev[commitHash] ?? new Set<string>();
394
- const next = new Set(current);
395
- if (next.has(path)) {
396
- next.delete(path);
397
- } else {
398
- next.add(path);
399
- }
400
- return {
401
- ...prev,
402
- [commitHash]: next,
403
- };
404
- });
405
- }, [commitHash]);
406
-
407
- if (isLoading) {
408
- return <div className="flex items-center justify-center p-8 h-full"><span className="loading loading-spinner text-base-content/50"></span></div>;
409
- }
410
-
411
- if (!data || data.files.length === 0) {
412
- return <div className="flex items-center justify-center p-8 h-full opacity-50">No changes in this commit</div>;
413
- }
414
-
415
- return (
416
- <div className="flex h-full">
417
- {/* File list */}
418
- <div className="w-64 border-r border-base-300 flex flex-col bg-base-200/30 shrink-0">
419
- <div className="px-3 py-2 text-xs font-bold opacity-70 border-b border-base-300 bg-base-100">
420
- {data.files.length} file{data.files.length !== 1 ? 's' : ''} changed
421
- </div>
422
- <div className="flex-1 overflow-y-auto min-h-0">
423
- <div className="p-1">
424
- <CommitFileTreeItem
425
- node={fileTree}
426
- selectedFile={selectedFile}
427
- expandedFolders={expandedFolders}
428
- onToggleFolder={handleToggleFolder}
429
- onSelectFile={setSelectedFile}
430
- />
431
- </div>
432
- </div>
433
- </div>
434
-
435
- {/* Diff view */}
436
- <div className="flex-1 overflow-hidden">
437
- {selectedFile ? (
438
- <div className="h-full flex flex-col">
439
- <div className="px-4 py-2 text-xs font-mono opacity-70 border-b border-base-300 bg-base-100 shrink-0 truncate flex items-center justify-between">
440
- <span className="truncate">{selectedFile}</span>
441
- <div className="flex items-center gap-2 shrink-0 ml-4">
442
- <label htmlFor="commit-diff-split-view" className="text-[10px] uppercase tracking-wider font-bold cursor-pointer opacity-70">Split View</label>
443
- <input
444
- type="checkbox"
445
- id="commit-diff-split-view"
446
- checked={splitView}
447
- onChange={(e) => setSplitView(e.target.checked)}
448
- className="toggle toggle-xs toggle-primary"
449
- />
450
- </div>
451
- </div>
452
- <div className="flex-1 overflow-auto diff-viewer-wrapper">
453
- <CommitFileDiffView repoPath={repoPath} commitHash={commitHash} filePath={selectedFile} splitView={splitView} />
454
- </div>
455
- </div>
456
- ) : (
457
- <div className="flex items-center justify-center h-full opacity-70 text-sm">
458
- Select a file to view changes
459
- </div>
460
- )}
461
- </div>
462
- </div>
463
- );
464
- }
465
-
466
- // Build tree structure from flat branch list
467
- function buildBranchTree(branches: string[], pathPrefix: string = ''): BranchTreeNode {
468
- const root: BranchTreeNode = { name: '', children: new Map() };
469
-
470
- for (const branch of branches) {
471
- const parts = branch.split('/');
472
- let current = root;
473
-
474
- for (let i = 0; i < parts.length; i++) {
475
- const part = parts[i];
476
- if (!current.children.has(part)) {
477
- current.children.set(part, {
478
- name: part,
479
- children: new Map(),
480
- });
481
- }
482
- current = current.children.get(part)!;
483
-
484
- // If this is the last part, mark it as a leaf with full path
485
- if (i === parts.length - 1) {
486
- // Use the full path with prefix for remote branches
487
- current.fullPath = pathPrefix ? `${pathPrefix}/${branch}` : branch;
488
- }
489
- }
490
- }
491
-
492
- return root;
493
- }
494
-
495
- // Build tree structure for remote branches, grouped by remote name
496
- function buildRemoteBranchTree(remotes: Record<string, string[]>): Map<string, BranchTreeNode> {
497
- const result = new Map<string, BranchTreeNode>();
498
-
499
- for (const [remoteName, branches] of Object.entries(remotes)) {
500
- // Build tree for this remote's branches, with full ref path prefix
501
- result.set(remoteName, buildBranchTree(branches, `remotes/${remoteName}`));
502
- }
503
-
504
- return result;
505
- }
506
-
507
- // Get the effective visibility for a path (considering parent inheritance)
508
- // groupPath is used for checking group-level visibility (e.g., "__local__" or "__remotes__" or "__remotes__/origin")
509
- function getEffectiveVisibility(
510
- path: string,
511
- visibilityMap: VisibilityMap,
512
- groupPath?: string
513
- ): VisibilityState {
514
- // Check if this path has explicit visibility
515
- if (visibilityMap[path]) {
516
- return visibilityMap[path];
517
- }
518
-
519
- // Check parent paths for inherited visibility
520
- const parts = path.split('/');
521
- for (let i = parts.length - 1; i > 0; i--) {
522
- const parentPath = parts.slice(0, i).join('/');
523
- if (visibilityMap[parentPath]) {
524
- return visibilityMap[parentPath];
525
- }
526
- }
527
-
528
- // Check group-level visibility
529
- if (groupPath) {
530
- // Check if any parent group has visibility set
531
- const groupParts = groupPath.split('/');
532
- for (let i = groupParts.length; i > 0; i--) {
533
- const parentGroupPath = groupParts.slice(0, i).join('/');
534
- if (visibilityMap[parentGroupPath]) {
535
- return visibilityMap[parentGroupPath];
536
- }
537
- }
538
- }
539
-
540
- return null;
541
- }
542
-
543
- // Group header component with visibility controls
544
- function GroupHeader({
545
- name,
546
- groupPath,
547
- icon,
548
- isExpanded,
549
- onToggle,
550
- visibilityMap,
551
- onToggleVisibility,
552
- depth = 0,
553
- }: {
554
- name: string;
555
- groupPath: string;
556
- icon: React.ReactNode;
557
- isExpanded: boolean;
558
- onToggle: () => void;
559
- visibilityMap: VisibilityMap;
560
- onToggleVisibility: (path: string, type: 'visible' | 'hidden') => void;
561
- depth?: number;
562
- }) {
563
- const directVisibility = visibilityMap[groupPath];
564
- // Check parent group visibility for inheritance
565
- const parentGroupPath = groupPath.includes('/')
566
- ? groupPath.split('/').slice(0, -1).join('/')
567
- : undefined;
568
- const parentVisibility = parentGroupPath ? visibilityMap[parentGroupPath] : null;
569
- const effectiveVisibility = directVisibility || parentVisibility;
570
- const isInherited = !directVisibility && parentVisibility !== null;
571
-
572
- return (
573
- <div
574
- className={cn(
575
- "group flex items-center gap-1 px-2 py-1.5 text-sm rounded-md cursor-pointer hover:bg-base-200 transition-colors font-medium",
576
- )}
577
- style={{ paddingLeft: `${depth * 12 + 8}px` }}
578
- >
579
- <div className="flex items-center gap-1.5 flex-1 min-w-0" onClick={onToggle}>
580
- <span className="text-xs opacity-70">{isExpanded ? '▼' : '▶'}</span>
581
- <span className="shrink-0">{icon}</span>
582
- <span className="truncate min-w-0 flex-1">{name}</span>
583
- </div>
584
- <div className="flex items-center gap-0.5 ml-auto">
585
- <VisibilityToggle
586
- type="visible"
587
- isActive={directVisibility === 'visible' || (isInherited && effectiveVisibility === 'visible')}
588
- isInherited={isInherited && effectiveVisibility === 'visible'}
589
- onClick={(e) => { e.stopPropagation(); onToggleVisibility(groupPath, 'visible'); }}
590
- showOnHover={directVisibility === 'visible' || (isInherited && effectiveVisibility === 'visible')}
591
- />
592
- <VisibilityToggle
593
- type="hidden"
594
- isActive={directVisibility === 'hidden' || (isInherited && effectiveVisibility === 'hidden')}
595
- isInherited={isInherited && effectiveVisibility === 'hidden'}
596
- onClick={(e) => { e.stopPropagation(); onToggleVisibility(groupPath, 'hidden'); }}
597
- showOnHover={directVisibility === 'hidden' || (isInherited && effectiveVisibility === 'hidden')}
598
- />
599
- </div>
600
- </div>
601
- );
602
- }
603
-
604
-
605
- // Visibility toggle button component
606
- function VisibilityToggle({
607
- type,
608
- isActive,
609
- isInherited,
610
- onClick,
611
- showOnHover,
612
- }: {
613
- type: 'visible' | 'hidden';
614
- isActive: boolean;
615
- isInherited: boolean;
616
- onClick: (e: React.MouseEvent) => void;
617
- showOnHover: boolean;
618
- }) {
619
- const title = type === 'visible'
620
- ? (isActive ? 'Remove visible filter' : 'Show only this branch')
621
- : (isActive ? 'Remove hide filter' : 'Hide this branch');
622
-
623
- return (
624
- <button
625
- className={cn(
626
- "p-0.5 rounded hover:bg-base-300 transition-colors shrink-0 cursor-pointer text-xs",
627
- isActive && "bg-primary/10",
628
- isInherited && "opacity-50",
629
- !isActive && !showOnHover && "opacity-0 group-hover:opacity-100"
630
- )}
631
- onClick={onClick}
632
- title={title}
633
- >
634
- {type === 'visible' ? <i className="iconoir-eye text-[14px]" aria-hidden="true" /> : <i className="iconoir-eye-closed text-[14px]" aria-hidden="true" />}
635
- </button>
636
- );
637
- }
638
-
639
- interface BranchMenuCallbacks {
640
- onCheckout: (branch: string) => void;
641
- onCheckoutToLocal: (remoteBranch: string) => void;
642
- onCreateBranch: (sourceBranch: string) => void;
643
- onDeleteBranch: (branch: string) => void;
644
- onDeleteBranches: (branches: string[]) => void;
645
- onRenameBranch: (branch: string) => void;
646
- onRenameRemoteBranch: (branch: string) => void;
647
- onRebase: (operation: BranchOperation) => void;
648
- onMerge: (operation: BranchOperation) => void;
649
- onPushToRemote: (branch: string) => void;
650
- onPullFromRemote: (branch: string) => void;
651
- }
652
-
653
- interface BranchMenuOptions {
654
- branchRef: string;
655
- branchLeafName: string;
656
- currentBranch?: string;
657
- isRemote: boolean;
658
- selectedBranchRefs?: string[];
659
- }
660
-
661
- function sortBranchTreeChildren(node: BranchTreeNode): BranchTreeNode[] {
662
- return Array.from(node.children.values()).sort((a, b) => {
663
- const aIsFolder = a.children.size > 0 && !a.fullPath;
664
- const bIsFolder = b.children.size > 0 && !b.fullPath;
665
- if (aIsFolder && !bIsFolder) return -1;
666
- if (!aIsFolder && bIsFolder) return 1;
667
- return a.name.localeCompare(b.name);
668
- });
669
- }
670
-
671
- function collectVisibleBranchRefs(
672
- node: BranchTreeNode,
673
- expandedFolders: Set<string>,
674
- parentPath = ''
675
- ): string[] {
676
- const refs: string[] = [];
677
- const sortedChildren = sortBranchTreeChildren(node);
678
-
679
- for (const child of sortedChildren) {
680
- const isLeaf = child.fullPath !== undefined;
681
- if (isLeaf) {
682
- refs.push(child.fullPath!);
683
- continue;
684
- }
685
-
686
- const itemPath = parentPath ? `${parentPath}/${child.name}` : child.name;
687
- if (expandedFolders.has(itemPath)) {
688
- refs.push(...collectVisibleBranchRefs(child, expandedFolders, itemPath));
689
- }
690
- }
691
-
692
- return refs;
693
- }
694
-
695
- function collectAllBranchRefs(node: BranchTreeNode): string[] {
696
- const refs: string[] = [];
697
- const sortedChildren = sortBranchTreeChildren(node);
698
-
699
- for (const child of sortedChildren) {
700
- if (child.fullPath) {
701
- refs.push(child.fullPath);
702
- continue;
703
- }
704
-
705
- refs.push(...collectAllBranchRefs(child));
706
- }
707
-
708
- return refs;
709
- }
710
-
711
- function buildBranchContextMenuItems(
712
- options: BranchMenuOptions,
713
- callbacks: BranchMenuCallbacks
714
- ): ContextMenuItem[] {
715
- const { branchRef, branchLeafName, currentBranch, isRemote, selectedBranchRefs } = options;
716
- const isCurrent = !isRemote && branchRef === currentBranch;
717
- const selectedRefs = selectedBranchRefs && selectedBranchRefs.length > 0 ? selectedBranchRefs : [branchRef];
718
- const hasMultiSelection = selectedRefs.length > 1;
719
- const menuItems: ContextMenuItem[] = [];
720
-
721
- if (!isCurrent && !isRemote) menuItems.push({ label: 'Checkout', onClick: () => callbacks.onCheckout(branchRef) });
722
- if (isRemote) menuItems.push({ label: 'Checkout to local', onClick: () => callbacks.onCheckoutToLocal(branchRef) });
723
- menuItems.push({ label: 'Create Branch', onClick: () => callbacks.onCreateBranch(branchRef) });
724
- if (!isRemote) menuItems.push({ label: 'Rename Branch', onClick: () => callbacks.onRenameBranch(branchRef) });
725
- if (isRemote) menuItems.push({ label: 'Rename branch', onClick: () => callbacks.onRenameRemoteBranch(branchRef) });
726
- if (!isRemote) menuItems.push({ label: 'Push to Remote', onClick: () => callbacks.onPushToRemote(branchRef) });
727
- if (!isRemote) menuItems.push({ label: 'Pull from Remote', onClick: () => callbacks.onPullFromRemote(branchRef) });
728
- if (!isCurrent && currentBranch) {
729
- menuItems.push({
730
- label: `Rebase ${currentBranch} onto ${branchLeafName}`,
731
- labelNode: <>Rebase <span className="font-bold">{currentBranch}</span> onto {branchLeafName}</>,
732
- onClick: () => callbacks.onRebase({ sourceBranch: currentBranch, targetBranch: branchRef }),
733
- });
734
- menuItems.push({
735
- label: `Rebase ${branchLeafName} onto ${currentBranch}`,
736
- labelNode: <>Rebase {branchLeafName} onto <span className="font-bold">{currentBranch}</span></>,
737
- onClick: () => callbacks.onRebase({ sourceBranch: branchRef, targetBranch: currentBranch }),
738
- });
739
- menuItems.push({
740
- label: `Merge ${currentBranch} into ${branchLeafName}`,
741
- labelNode: <>Merge <span className="font-bold">{currentBranch}</span> into {branchLeafName}</>,
742
- onClick: () => callbacks.onMerge({ sourceBranch: currentBranch, targetBranch: branchRef }),
743
- });
744
- menuItems.push({
745
- label: `Merge ${branchLeafName} into ${currentBranch}`,
746
- labelNode: <>Merge {branchLeafName} into <span className="font-bold">{currentBranch}</span></>,
747
- onClick: () => callbacks.onMerge({ sourceBranch: branchRef, targetBranch: currentBranch }),
748
- });
749
- }
750
- if (!isCurrent) {
751
- if (hasMultiSelection) {
752
- menuItems.push({
753
- label: `Delete Selected Branches (${selectedRefs.length})`,
754
- onClick: () => callbacks.onDeleteBranches(selectedRefs),
755
- danger: true,
756
- });
757
- } else {
758
- menuItems.push({
759
- label: isRemote ? 'Delete Remote Branch' : 'Delete Branch',
760
- onClick: () => callbacks.onDeleteBranch(branchRef),
761
- danger: true,
762
- });
763
- }
764
- }
765
-
766
- return menuItems;
767
- }
768
-
769
- // Recursive component to render branch tree
770
- function BranchTreeItem({
771
- node,
772
- currentBranch,
773
- expandedFolders,
774
- onToggleFolder,
775
- onCheckout,
776
- onCheckoutToLocal,
777
- onCreateBranch,
778
- onDeleteBranch,
779
- onRenameBranch,
780
- onRenameRemoteBranch,
781
- onRebase,
782
- onMerge,
783
- onPushToRemote,
784
- onPullFromRemote,
785
- getBranchContextMenuItems,
786
- onBranchClick,
787
- onBranchContextMenu,
788
- onDeleteBranchGroup,
789
- selectedBranches,
790
- visibilityMap,
791
- onToggleVisibility,
792
- parentPath = '',
793
- depth = 0,
794
- groupPath,
795
- isRemote = false,
796
- trackingInfo,
797
- }: {
798
- node: BranchTreeNode;
799
- currentBranch?: string;
800
- expandedFolders: Set<string>;
801
- onToggleFolder: (path: string) => void;
802
- onCheckout: (branch: string) => void;
803
- onCheckoutToLocal: (remoteBranch: string) => void;
804
- onCreateBranch: () => void;
805
- onDeleteBranch: (branch: string) => void;
806
- onRenameBranch: (branch: string) => void;
807
- onRenameRemoteBranch: (branch: string) => void;
808
- onRebase: (operation: BranchOperation) => void;
809
- onMerge: (operation: BranchOperation) => void;
810
- onPushToRemote: (branch: string) => void;
811
- onPullFromRemote: (branch: string) => void;
812
- getBranchContextMenuItems: (options: BranchMenuOptions) => ContextMenuItem[];
813
- onBranchClick?: (branch: string, modifiers?: BranchRowSelectModifiers) => void;
814
- onBranchContextMenu?: (branch: string) => void;
815
- onDeleteBranchGroup: (branches: string[]) => void;
816
- selectedBranches: Set<string>;
817
- visibilityMap: VisibilityMap;
818
- onToggleVisibility: (path: string, type: 'visible' | 'hidden') => void;
819
- parentPath?: string;
820
- depth?: number;
821
- groupPath?: string;
822
- isRemote?: boolean;
823
- trackingInfo?: Record<string, BranchTrackingInfo>;
824
- }) {
825
- const children = Array.from(node.children.values());
826
- const sortedChildren = children.sort((a, b) => {
827
- // Folders (non-leaf) come first, then alphabetical
828
- const aIsFolder = a.children.size > 0 && !a.fullPath;
829
- const bIsFolder = b.children.size > 0 && !b.fullPath;
830
- if (aIsFolder && !bIsFolder) return -1;
831
- if (!aIsFolder && bIsFolder) return 1;
832
- return a.name.localeCompare(b.name);
833
- });
834
-
835
- return (
836
- <>
837
- {sortedChildren.map((child) => {
838
- const isLeaf = child.fullPath !== undefined;
839
- const isFolder = child.children.size > 0 && !isLeaf;
840
- const itemPath = child.fullPath || (parentPath ? `${parentPath}/${child.name}` : child.name);
841
- const isExpanded = expandedFolders.has(itemPath);
842
- const isCurrent = isLeaf && child.fullPath === currentBranch;
843
-
844
- // Get visibility state for this item
845
- const directVisibility = visibilityMap[itemPath];
846
- const effectiveVisibility = getEffectiveVisibility(itemPath, visibilityMap, groupPath);
847
- const isInherited = !directVisibility && effectiveVisibility !== null;
848
-
849
- if (isFolder) {
850
- const childBranchRefs = collectAllBranchRefs(child);
851
- const deletableChildBranchRefs = childBranchRefs.filter((branchRef) => branchRef !== currentBranch);
852
-
853
- // Render folder
854
- return (
855
- <div key={itemPath}>
856
- <ContextMenu
857
- items={[
858
- {
859
- label: 'Delete',
860
- onClick: () => onDeleteBranchGroup(deletableChildBranchRefs),
861
- danger: true,
862
- disabled: deletableChildBranchRefs.length === 0,
863
- },
864
- ]}
865
- >
866
- <div
867
- className={cn(
868
- "group flex items-center gap-1 px-2 py-1.5 text-sm rounded-md cursor-pointer hover:bg-base-200 transition-colors opacity-70 select-none",
869
- )}
870
- style={{ paddingLeft: `${depth * 12 + 8}px` }}
871
- >
872
- <div className="flex items-center gap-1 flex-1 min-w-0" onClick={() => onToggleFolder(itemPath)}>
873
- <span className="text-xs opacity-70">{isExpanded ? '▼' : '▶'}</span>
874
- <i className="iconoir-folder text-[16px] shrink-0" aria-hidden="true" />
875
- <span className="truncate min-w-0 flex-1">{child.name}</span>
876
- </div>
877
- <div className="flex items-center gap-0.5 ml-auto">
878
- <VisibilityToggle
879
- type="visible"
880
- isActive={directVisibility === 'visible' || (isInherited && effectiveVisibility === 'visible')}
881
- isInherited={isInherited && effectiveVisibility === 'visible'}
882
- onClick={(e) => { e.stopPropagation(); onToggleVisibility(itemPath, 'visible'); }}
883
- showOnHover={directVisibility === 'visible' || (isInherited && effectiveVisibility === 'visible')}
884
- />
885
- <VisibilityToggle
886
- type="hidden"
887
- isActive={directVisibility === 'hidden' || (isInherited && effectiveVisibility === 'hidden')}
888
- isInherited={isInherited && effectiveVisibility === 'hidden'}
889
- onClick={(e) => { e.stopPropagation(); onToggleVisibility(itemPath, 'hidden'); }}
890
- showOnHover={directVisibility === 'hidden' || (isInherited && effectiveVisibility === 'hidden')}
891
- />
892
- </div>
893
- </div>
894
- </ContextMenu>
895
- {isExpanded && (
896
- <BranchTreeItem
897
- node={child}
898
- currentBranch={currentBranch}
899
- expandedFolders={expandedFolders}
900
- onToggleFolder={onToggleFolder}
901
- onCheckout={onCheckout}
902
- onCheckoutToLocal={onCheckoutToLocal}
903
- onCreateBranch={onCreateBranch}
904
- onDeleteBranch={onDeleteBranch}
905
- onRenameBranch={onRenameBranch}
906
- onRenameRemoteBranch={onRenameRemoteBranch}
907
- onRebase={onRebase}
908
- onMerge={onMerge}
909
- onPushToRemote={onPushToRemote}
910
- onPullFromRemote={onPullFromRemote}
911
- getBranchContextMenuItems={getBranchContextMenuItems}
912
- onBranchClick={onBranchClick}
913
- onBranchContextMenu={onBranchContextMenu}
914
- onDeleteBranchGroup={onDeleteBranchGroup}
915
- selectedBranches={selectedBranches}
916
- visibilityMap={visibilityMap}
917
- onToggleVisibility={onToggleVisibility}
918
- parentPath={itemPath}
919
- depth={depth + 1}
920
- groupPath={groupPath}
921
- isRemote={isRemote}
922
- trackingInfo={trackingInfo}
923
- />
924
- )}
925
- </div>
926
- );
927
- }
928
-
929
- // Render leaf (actual branch)
930
- const branchTracking = !isRemote && child.fullPath ? trackingInfo?.[child.fullPath] : undefined;
931
- const hasDivergence = branchTracking && (branchTracking.ahead > 0 || branchTracking.behind > 0);
932
-
933
- const menuItems = getBranchContextMenuItems({
934
- branchRef: child.fullPath!,
935
- branchLeafName: child.name,
936
- currentBranch,
937
- isRemote,
938
- selectedBranchRefs: selectedBranches.has(child.fullPath!) ? Array.from(selectedBranches) : [child.fullPath!],
939
- });
940
- const isSelected = selectedBranches.has(child.fullPath!);
941
-
942
-
943
- return (
944
- <ContextMenu key={child.fullPath} items={menuItems}>
945
- <div
946
- className={cn(
947
- "group flex items-center gap-2 px-2 py-1.5 text-sm rounded-md cursor-pointer transition-colors select-none",
948
- !isSelected && "hover:bg-base-200",
949
- isSelected && "bg-primary/10 hover:bg-primary/20",
950
- isCurrent && "bg-base-200 font-medium text-primary"
951
- )}
952
- style={{ paddingLeft: `${depth * 12 + 8}px` }}
953
- onContextMenu={() => onBranchContextMenu?.(child.fullPath!)}
954
- >
955
- <div
956
- className="flex items-center gap-2 flex-1 min-w-0"
957
- onClick={(e) => onBranchClick?.(child.fullPath!, { isMultiSelect: e.metaKey || e.ctrlKey, isRangeSelect: e.shiftKey })}
958
- onDoubleClick={() => !isCurrent && onCheckout(child.fullPath!)}
959
- >
960
- {isCurrent ? (
961
- <span className="w-3 h-3 flex items-center justify-center shrink-0">
962
- <span className="w-2 h-2 rounded-full bg-primary" />
963
- </span>
964
- ) : isRemote ? (
965
- <i className="iconoir-globe text-[14px] opacity-50 shrink-0" aria-hidden="true" />
966
- ) : (
967
- <i className="iconoir-git-branch text-[14px] opacity-50 shrink-0" aria-hidden="true" />
968
- )}
969
- <span className="truncate min-w-0 flex-1" title={child.fullPath}>{child.name}</span>
970
- {hasDivergence && (
971
- <span
972
- className="flex items-center gap-1 text-xs opacity-70 shrink-0"
973
- title={`${branchTracking.ahead} ahead, ${branchTracking.behind} behind ${branchTracking.upstream}`}
974
- >
975
- {branchTracking.ahead > 0 && (
976
- <span className="flex items-center gap-0.5 text-xs">
977
- <i className="iconoir-arrow-up text-[12px]" aria-hidden="true" />
978
- <span>{branchTracking.ahead}</span>
979
- </span>
980
- )}
981
- {branchTracking.behind > 0 && (
982
- <span className="flex items-center gap-0.5 text-xs">
983
- <i className="iconoir-arrow-down text-[12px]" aria-hidden="true" />
984
- <span>{branchTracking.behind}</span>
985
- </span>
986
- )}
987
- </span>
988
- )}
989
- </div>
990
- <div className="flex items-center gap-0.5 ml-auto">
991
- <VisibilityToggle
992
- type="visible"
993
- isActive={directVisibility === 'visible' || (isInherited && effectiveVisibility === 'visible')}
994
- isInherited={isInherited && effectiveVisibility === 'visible'}
995
- onClick={(e) => { e.stopPropagation(); onToggleVisibility(itemPath, 'visible'); }}
996
- showOnHover={directVisibility === 'visible' || (isInherited && effectiveVisibility === 'visible')}
997
- />
998
- <VisibilityToggle
999
- type="hidden"
1000
- isActive={directVisibility === 'hidden' || (isInherited && effectiveVisibility === 'hidden')}
1001
- isInherited={isInherited && effectiveVisibility === 'hidden'}
1002
- onClick={(e) => { e.stopPropagation(); onToggleVisibility(itemPath, 'hidden'); }}
1003
- showOnHover={directVisibility === 'hidden' || (isInherited && effectiveVisibility === 'hidden')}
1004
- />
1005
- </div>
1006
- </div>
1007
- </ContextMenu>
1008
- );
1009
- })}
1010
- </>
1011
- );
1012
- }
1013
-
1014
- export function HistoryView({ repoPath }: { repoPath: string }) {
1015
- const { data: settings } = useSettings();
1016
- const updateSettings = useUpdateSettings();
1017
-
1018
- const [limit, setLimit] = useState(100);
1019
- const { data: log, isLoading, isError, error, refetch, isFetching } = useGitLog(repoPath, limit);
1020
- const { data: branchData, isLoading: isBranchesLoading } = useGitBranches(repoPath);
1021
- const { data: statusData } = useGitStatus(repoPath);
1022
- const [selectedHash, setSelectedHash] = useState<string | null>(null);
1023
- const [selectedCommitHashes, setSelectedCommitHashes] = useState<string[]>([]);
1024
- const [selectionAnchorHash, setSelectionAnchorHash] = useState<string | null>(null);
1025
-
1026
- const selectSingleCommit = useCallback((hash: string | null) => {
1027
- setSelectedHash(hash);
1028
- setSelectedCommitHashes(hash ? [hash] : []);
1029
- setSelectionAnchorHash(hash);
1030
- }, []);
1031
-
1032
- // Clear selected commit and close commit details panel when repository changes
1033
- useEffect(() => {
1034
- selectSingleCommit(null);
1035
- setSelectedBranchRefs([]);
1036
- setBranchSelectionAnchor(null);
1037
- }, [repoPath, selectSingleCommit]);
1038
-
1039
- const { mutateAsync: runGitAction } = useGitAction();
1040
- const [iscreateBranchOpen, setIsCreateBranchOpen] = useState(false);
1041
- const [newBranchName, setNewBranchName] = useState('');
1042
- const [createBranchFromRef, setCreateBranchFromRef] = useState<string | null>(null);
1043
- const [isCreating, setIsCreating] = useState(false);
1044
- const [isCreateTagOpen, setIsCreateTagOpen] = useState(false);
1045
- const [createTagCommitHash, setCreateTagCommitHash] = useState<string | null>(null);
1046
- const [newTagName, setNewTagName] = useState('');
1047
- const [pushTagToRemote, setPushTagToRemote] = useState(false);
1048
- const [isCreatingTag, setIsCreatingTag] = useState(false);
1049
-
1050
- const [isDeleteOpen, setIsDeleteOpen] = useState(false);
1051
- const [branchesToDelete, setBranchesToDelete] = useState<string[]>([]);
1052
- const [deleteRemoteBranch, setDeleteRemoteBranch] = useState(false);
1053
- const [isDeleting, setIsDeleting] = useState(false);
1054
- const [isDeleteTagOpen, setIsDeleteTagOpen] = useState(false);
1055
- const [tagToDelete, setTagToDelete] = useState<string | null>(null);
1056
- const [deleteRemoteTag, setDeleteRemoteTag] = useState(false);
1057
- const [isDeletingTag, setIsDeletingTag] = useState(false);
1058
-
1059
- const [isCherryPickOpen, setIsCherryPickOpen] = useState(false);
1060
- const [commitsToCherryPick, setCommitsToCherryPick] = useState<{ hash: string; message: string }[]>([]);
1061
- const [isCherryPicking, setIsCherryPicking] = useState(false);
1062
- const [isAbortCherryPickOpen, setIsAbortCherryPickOpen] = useState(false);
1063
- const [isAbortingCherryPick, setIsAbortingCherryPick] = useState(false);
1064
-
1065
- const [isRenameOpen, setIsRenameOpen] = useState(false);
1066
- const [branchToRename, setBranchToRename] = useState<string | null>(null);
1067
- const [remoteBranchToRename, setRemoteBranchToRename] = useState<{ remote: string; branch: string } | null>(null);
1068
- const [newBranchNameForRename, setNewBranchNameForRename] = useState('');
1069
- const [renameTrackingRemoteBranch, setRenameTrackingRemoteBranch] = useState(false);
1070
- const [isRenaming, setIsRenaming] = useState(false);
1071
- const [isRenameRemoteOpen, setIsRenameRemoteOpen] = useState(false);
1072
- const [remoteToRename, setRemoteToRename] = useState<string | null>(null);
1073
- const [newRemoteNameForRename, setNewRemoteNameForRename] = useState('');
1074
- const [isRenamingRemote, setIsRenamingRemote] = useState(false);
1075
- const [isDeleteRemoteOpen, setIsDeleteRemoteOpen] = useState(false);
1076
- const [remoteToDelete, setRemoteToDelete] = useState<string | null>(null);
1077
- const [isDeletingRemote, setIsDeletingRemote] = useState(false);
1078
- const [isAddRemoteOpen, setIsAddRemoteOpen] = useState(false);
1079
- const [newRemoteName, setNewRemoteName] = useState('origin');
1080
- const [newRemoteUrl, setNewRemoteUrl] = useState('');
1081
- const [isAddingRemote, setIsAddingRemote] = useState(false);
1082
-
1083
- const [isRebaseOpen, setIsRebaseOpen] = useState(false);
1084
- const [rebaseSourceBranch, setRebaseSourceBranch] = useState<string | null>(null);
1085
- const [rebaseTargetBranch, setRebaseTargetBranch] = useState<string | null>(null);
1086
- const [rebaseStashChanges, setRebaseStashChanges] = useState(true);
1087
- const [isRebasing, setIsRebasing] = useState(false);
1088
- const [rebaseConflictStatus, setRebaseConflictStatus] = useState<MergeConflictStatus>('checking');
1089
-
1090
- const closeRebaseDialog = useCallback(() => {
1091
- setIsRebaseOpen(false);
1092
- setRebaseSourceBranch(null);
1093
- setRebaseTargetBranch(null);
1094
- setRebaseConflictStatus('checking');
1095
- }, []);
1096
-
1097
- const [isMergeOpen, setIsMergeOpen] = useState(false);
1098
- const [mergeTargetBranch, setMergeTargetBranch] = useState<string | null>(null);
1099
- const [mergeSourceBranch, setMergeSourceBranch] = useState<string | null>(null);
1100
- const [mergeRebaseBeforeMerge, setMergeRebaseBeforeMerge] = useState(false);
1101
- const [mergeSquash, setMergeSquash] = useState(false);
1102
- const [mergeFastForward, setMergeFastForward] = useState(false);
1103
- const [mergeSquashMessage, setMergeSquashMessage] = useState('');
1104
- const [isMerging, setIsMerging] = useState(false);
1105
- const [mergeConflictStatus, setMergeConflictStatus] = useState<MergeConflictStatus>('checking');
1106
-
1107
- const closeMergeDialog = useCallback(() => {
1108
- setIsMergeOpen(false);
1109
- setMergeTargetBranch(null);
1110
- setMergeSourceBranch(null);
1111
- setMergeConflictStatus('checking');
1112
- }, []);
1113
-
1114
- // Push to remote dialog state
1115
- const [isPushOpen, setIsPushOpen] = useState(false);
1116
- const [pushBranch, setPushBranch] = useState<string | null>(null);
1117
- const [pushRemotes, setPushRemotes] = useState<string[]>([]);
1118
- const [pushSelectedRemote, setPushSelectedRemote] = useState<string>('');
1119
- const [pushRemoteBranches, setPushRemoteBranches] = useState<string[]>([]);
1120
- const [pushSelectedRemoteBranch, setPushSelectedRemoteBranch] = useState<string>('');
1121
- const [pushTrackingBranch, setPushTrackingBranch] = useState<{ remote: string; branch: string } | null>(null);
1122
- const [pushRebaseFirst, setPushRebaseFirst] = useState(false);
1123
- const [pushForcePush, setPushForcePush] = useState(false);
1124
- const [pushLocalOnlyTags, setPushLocalOnlyTags] = useState(true);
1125
- const [pushSquash, setPushSquash] = useState(false);
1126
- const [pushSquashMessage, setPushSquashMessage] = useState('');
1127
- const [isPushing, setIsPushing] = useState(false);
1128
- const [pushError, setPushError] = useState<string | null>(null);
1129
- const [pushLoadingRemotes, setPushLoadingRemotes] = useState(false);
1130
- const [pushLoadingBranches, setPushLoadingBranches] = useState(false);
1131
-
1132
- // Pull from remote dialog state
1133
- const [isPullOpen, setIsPullOpen] = useState(false);
1134
- const [pullBranch, setPullBranch] = useState<string | null>(null);
1135
- const [pullRemotes, setPullRemotes] = useState<string[]>([]);
1136
- const [pullSelectedRemote, setPullSelectedRemote] = useState<string>('');
1137
- const [pullRemoteBranches, setPullRemoteBranches] = useState<string[]>([]);
1138
- const [pullSelectedRemoteBranch, setPullSelectedRemoteBranch] = useState<string>('');
1139
- const [pullTrackingBranch, setPullTrackingBranch] = useState<{ remote: string; branch: string } | null>(null);
1140
- const [pullRebase, setPullRebase] = useState(true);
1141
- const [isPulling, setIsPulling] = useState(false);
1142
- const [pullError, setPullError] = useState<string | null>(null);
1143
- const [pullLoadingRemotes, setPullLoadingRemotes] = useState(false);
1144
- const [pullLoadingBranches, setPullLoadingBranches] = useState(false);
1145
- const [isFetchingAllRemotes, setIsFetchingAllRemotes] = useState(false);
1146
-
1147
- // Checkout to local dialog state
1148
- const [isCheckoutToLocalOpen, setIsCheckoutToLocalOpen] = useState(false);
1149
- const [checkoutRemoteBranch, setCheckoutRemoteBranch] = useState<string | null>(null);
1150
- const [checkoutLocalBranchName, setCheckoutLocalBranchName] = useState('');
1151
- const [isCheckingOutToLocal, setIsCheckingOutToLocal] = useState(false);
1152
-
1153
- // Reset to commit dialog state
1154
- const [isResetOpen, setIsResetOpen] = useState(false);
1155
- const [resetCommitHash, setResetCommitHash] = useState<string | null>(null);
1156
- const [isResetting, setIsResetting] = useState(false);
1157
- const [isRevertOpen, setIsRevertOpen] = useState(false);
1158
- const [commitToRevert, setCommitToRevert] = useState<{ hash: string; message: string } | null>(null);
1159
- const [isReverting, setIsReverting] = useState(false);
1160
-
1161
- const [isRewordOpen, setIsRewordOpen] = useState(false);
1162
- const [commitToReword, setCommitToReword] = useState<{ hash: string; subject: string; body: string; branch: string } | null>(null);
1163
- const [newMessageSubject, setNewMessageSubject] = useState('');
1164
- const [newMessageBody, setNewMessageBody] = useState('');
1165
- const [isRewording, setIsRewording] = useState(false);
1166
-
1167
- // Ref for GitGraph to scroll to commits
1168
- const gitGraphRef = useRef<GitGraphHandle>(null);
1169
-
1170
- // State for pending scroll to branch commit
1171
- const [pendingScrollCommit, setPendingScrollCommit] = useState<string | null>(null);
1172
- const [selectedBranchRefs, setSelectedBranchRefs] = useState<string[]>([]);
1173
- const [branchSelectionAnchor, setBranchSelectionAnchor] = useState<string | null>(null);
1174
- const [isBranchPopoverOpen, setIsBranchPopoverOpen] = useState(false);
1175
- const branchPopoverRef = useRef<HTMLDivElement>(null);
1176
- const [scriptExecution, setScriptExecution] = useState<ScriptExecutionState>(DEFAULT_SCRIPT_EXECUTION);
1177
- const [isCancelingScriptExecution, setIsCancelingScriptExecution] = useState(false);
1178
- const [isCopyingScriptOutput, setIsCopyingScriptOutput] = useState(false);
1179
- const [didCopyScriptOutput, setDidCopyScriptOutput] = useState(false);
1180
- const isScriptExecutionRunning = scriptExecution.status === 'starting' || scriptExecution.status === 'running';
1181
- const isScriptExecutionFinished = scriptExecution.status === 'completed' || scriptExecution.status === 'failed' || scriptExecution.status === 'canceled';
1182
-
1183
- const closeRenameBranchDialog = useCallback(() => {
1184
- setIsRenameOpen(false);
1185
- setBranchToRename(null);
1186
- setRemoteBranchToRename(null);
1187
- setNewBranchNameForRename('');
1188
- setRenameTrackingRemoteBranch(false);
1189
- }, []);
1190
-
1191
- const closeRenameRemoteDialog = useCallback(() => {
1192
- setIsRenameRemoteOpen(false);
1193
- setRemoteToRename(null);
1194
- setNewRemoteNameForRename('');
1195
- }, []);
1196
-
1197
- const closeDeleteRemoteDialog = useCallback(() => {
1198
- setIsDeleteRemoteOpen(false);
1199
- setRemoteToDelete(null);
1200
- }, []);
1201
-
1202
- const closeAddRemoteDialog = useCallback(() => {
1203
- setIsAddRemoteOpen(false);
1204
- setNewRemoteName('origin');
1205
- setNewRemoteUrl('');
1206
- }, []);
1207
-
1208
- const closeRewordDialog = useCallback(() => {
1209
- setIsRewordOpen(false);
1210
- setCommitToReword(null);
1211
- setNewMessageSubject('');
1212
- setNewMessageBody('');
1213
- }, []);
1214
-
1215
- const closeTopPopup = useCallback(() => {
1216
- if (isAbortCherryPickOpen) {
1217
- setIsAbortCherryPickOpen(false);
1218
- setCommitsToCherryPick([]);
1219
- return;
1220
- }
1221
- if (isCheckoutToLocalOpen) {
1222
- setIsCheckoutToLocalOpen(false);
1223
- return;
1224
- }
1225
- if (iscreateBranchOpen) {
1226
- setIsCreateBranchOpen(false);
1227
- setCreateBranchFromRef(null);
1228
- return;
1229
- }
1230
- if (isCreateTagOpen) {
1231
- setIsCreateTagOpen(false);
1232
- setCreateTagCommitHash(null);
1233
- setNewTagName('');
1234
- setPushTagToRemote(false);
1235
- return;
1236
- }
1237
- if (isPullOpen) {
1238
- setIsPullOpen(false);
1239
- return;
1240
- }
1241
- if (isPushOpen) {
1242
- setIsPushOpen(false);
1243
- return;
1244
- }
1245
- if (isMergeOpen) {
1246
- closeMergeDialog();
1247
- return;
1248
- }
1249
- if (isRebaseOpen) {
1250
- closeRebaseDialog();
1251
- return;
1252
- }
1253
- if (isRenameOpen) {
1254
- closeRenameBranchDialog();
1255
- return;
1256
- }
1257
- if (isRenameRemoteOpen) {
1258
- closeRenameRemoteDialog();
1259
- return;
1260
- }
1261
- if (isDeleteRemoteOpen) {
1262
- closeDeleteRemoteDialog();
1263
- return;
1264
- }
1265
- if (isAddRemoteOpen) {
1266
- closeAddRemoteDialog();
1267
- return;
1268
- }
1269
- if (isCherryPickOpen) {
1270
- setIsCherryPickOpen(false);
1271
- setCommitsToCherryPick([]);
1272
- return;
1273
- }
1274
- if (isDeleteOpen) {
1275
- setIsDeleteOpen(false);
1276
- setBranchesToDelete([]);
1277
- setDeleteRemoteBranch(false);
1278
- return;
1279
- }
1280
- if (isDeleteTagOpen) {
1281
- setIsDeleteTagOpen(false);
1282
- setTagToDelete(null);
1283
- setDeleteRemoteTag(false);
1284
- return;
1285
- }
1286
- if (isRewordOpen) {
1287
- closeRewordDialog();
1288
- return;
1289
- }
1290
- if (isResetOpen) {
1291
- setIsResetOpen(false);
1292
- return;
1293
- }
1294
- if (isRevertOpen) {
1295
- setIsRevertOpen(false);
1296
- setCommitToRevert(null);
1297
- return;
1298
- }
1299
- if (isBranchPopoverOpen) {
1300
- setIsBranchPopoverOpen(false);
1301
- return;
1302
- }
1303
- if (scriptExecution.isOpen && isScriptExecutionFinished) {
1304
- setScriptExecution(DEFAULT_SCRIPT_EXECUTION);
1305
- setDidCopyScriptOutput(false);
1306
- setIsCancelingScriptExecution(false);
1307
- }
1308
- }, [
1309
- isAbortCherryPickOpen,
1310
- isCheckoutToLocalOpen,
1311
- iscreateBranchOpen,
1312
- isCreateTagOpen,
1313
- isPullOpen,
1314
- isPushOpen,
1315
- closeMergeDialog,
1316
- isMergeOpen,
1317
- closeRebaseDialog,
1318
- isRebaseOpen,
1319
- isRenameOpen,
1320
- closeRenameBranchDialog,
1321
- isRenameRemoteOpen,
1322
- closeRenameRemoteDialog,
1323
- isDeleteRemoteOpen,
1324
- closeDeleteRemoteDialog,
1325
- isAddRemoteOpen,
1326
- closeAddRemoteDialog,
1327
- isCherryPickOpen,
1328
- isDeleteOpen,
1329
- isDeleteTagOpen,
1330
- isRewordOpen,
1331
- closeRewordDialog,
1332
- isResetOpen,
1333
- isRevertOpen,
1334
- isBranchPopoverOpen,
1335
- scriptExecution.isOpen,
1336
- isScriptExecutionFinished,
1337
- ]);
1338
-
1339
- const isAnyPopupOpen =
1340
- isResetOpen ||
1341
- isRevertOpen ||
1342
- isRewordOpen ||
1343
- isDeleteOpen ||
1344
- isDeleteTagOpen ||
1345
- isAbortCherryPickOpen ||
1346
- isCherryPickOpen ||
1347
- isRenameOpen ||
1348
- isRenameRemoteOpen ||
1349
- isDeleteRemoteOpen ||
1350
- isAddRemoteOpen ||
1351
- isRebaseOpen ||
1352
- isMergeOpen ||
1353
- isPushOpen ||
1354
- isPullOpen ||
1355
- iscreateBranchOpen ||
1356
- isCreateTagOpen ||
1357
- isCheckoutToLocalOpen ||
1358
- isBranchPopoverOpen ||
1359
- scriptExecution.isOpen;
1360
-
1361
- useEscapeDismiss(isAnyPopupOpen, closeTopPopup);
1362
-
1363
- // Resizable bottom panel state - load from global settings or fallback to localStorage
1364
- const panelHeightStorageKey = 'git-web:history-panel-height';
1365
- const commitDetailsMessageRatioStorageKey = 'git-web:history-commit-details-message-ratio';
1366
- const [panelHeight, setPanelHeight] = useState(300);
1367
- const [isResizing, setIsResizing] = useState(false);
1368
- const resizeRef = useRef<{ startY: number; startHeight: number } | null>(null);
1369
- const [commitDetailsMessageRatio, setCommitDetailsMessageRatio] = useState(DEFAULT_COMMIT_DETAILS_MESSAGE_RATIO);
1370
- const [isCommitDetailsRatioResizing, setIsCommitDetailsRatioResizing] = useState(false);
1371
- const commitDetailsContentRef = useRef<HTMLDivElement | null>(null);
1372
-
1373
- // Track if user has manually resized the panel to avoid sync loops
1374
- const userHasResized = useRef(false);
1375
-
1376
- // Load panel height from settings or localStorage
1377
- useEffect(() => {
1378
- if (settings?.historyPanelHeight) {
1379
- setPanelHeight(clampHistoryPanelHeight(settings.historyPanelHeight));
1380
- } else {
1381
- try {
1382
- const stored = localStorage.getItem(panelHeightStorageKey);
1383
- if (stored) {
1384
- const parsed = parseInt(stored, 10);
1385
- if (!isNaN(parsed) && parsed >= MIN_HISTORY_PANEL_HEIGHT && parsed <= MAX_HISTORY_PANEL_HEIGHT) {
1386
- setPanelHeight(parsed);
1387
- }
1388
- }
1389
- } catch (e) {
1390
- console.error('Failed to load panel height from localStorage:', e);
1391
- }
1392
- }
1393
- }, [settings?.historyPanelHeight]);
1394
-
1395
- // Save panel height to localStorage for immediate persistence
1396
- useEffect(() => {
1397
- try {
1398
- localStorage.setItem(panelHeightStorageKey, String(panelHeight));
1399
- } catch (e) {
1400
- console.error('Failed to save panel height to localStorage:', e);
1401
- }
1402
- }, [panelHeight]);
1403
-
1404
- // Load commit details message/diff ratio from localStorage
1405
- useEffect(() => {
1406
- try {
1407
- const stored = localStorage.getItem(commitDetailsMessageRatioStorageKey);
1408
- if (!stored) return;
1409
- const parsed = parseFloat(stored);
1410
- if (!Number.isNaN(parsed)) {
1411
- setCommitDetailsMessageRatio(clampCommitDetailsMessageRatio(parsed));
1412
- }
1413
- } catch (e) {
1414
- console.error('Failed to load commit details ratio from localStorage:', e);
1415
- }
1416
- }, []);
1417
-
1418
- // Persist commit details message/diff ratio in localStorage
1419
- useEffect(() => {
1420
- try {
1421
- localStorage.setItem(commitDetailsMessageRatioStorageKey, String(commitDetailsMessageRatio));
1422
- } catch (e) {
1423
- console.error('Failed to save commit details ratio to localStorage:', e);
1424
- }
1425
- }, [commitDetailsMessageRatio]);
1426
-
1427
- // Sync to global settings when resizing stops
1428
- useEffect(() => {
1429
- if (!isResizing && userHasResized.current) {
1430
- updateSettings.mutate({ historyPanelHeight: panelHeight });
1431
- userHasResized.current = false;
1432
- }
1433
- }, [isResizing, panelHeight, updateSettings]);
1434
-
1435
- // Handle resize drag
1436
- const handleResizeStart = useCallback((e: React.MouseEvent) => {
1437
- e.preventDefault();
1438
- setIsResizing(true);
1439
- resizeRef.current = { startY: e.clientY, startHeight: panelHeight };
1440
- }, [panelHeight]);
1441
-
1442
- useEffect(() => {
1443
- if (!isResizing) return;
1444
-
1445
- const handleMouseMove = (e: MouseEvent) => {
1446
- if (!resizeRef.current) return;
1447
- const delta = resizeRef.current.startY - e.clientY;
1448
- const newHeight = clampHistoryPanelHeight(resizeRef.current.startHeight + delta);
1449
- setPanelHeight(newHeight);
1450
- userHasResized.current = true;
1451
- };
1452
-
1453
- const handleMouseUp = () => {
1454
- setIsResizing(false);
1455
- resizeRef.current = null;
1456
- };
1457
-
1458
- document.addEventListener('mousemove', handleMouseMove);
1459
- document.addEventListener('mouseup', handleMouseUp);
1460
-
1461
- return () => {
1462
- document.removeEventListener('mousemove', handleMouseMove);
1463
- document.removeEventListener('mouseup', handleMouseUp);
1464
- };
1465
- }, [isResizing]);
1466
-
1467
- const handleCommitDetailsRatioResizeStart = useCallback((e: React.MouseEvent) => {
1468
- e.preventDefault();
1469
- setIsCommitDetailsRatioResizing(true);
1470
- }, []);
1471
-
1472
- useEffect(() => {
1473
- if (!isCommitDetailsRatioResizing) return;
1474
-
1475
- const handleMouseMove = (e: MouseEvent) => {
1476
- const container = commitDetailsContentRef.current;
1477
- if (!container) return;
1478
- const rect = container.getBoundingClientRect();
1479
- if (rect.height <= 0) return;
1480
-
1481
- const nextRatio = clampCommitDetailsMessageRatio((e.clientY - rect.top) / rect.height);
1482
- setCommitDetailsMessageRatio(nextRatio);
1483
- };
1484
-
1485
- const handleMouseUp = () => {
1486
- setIsCommitDetailsRatioResizing(false);
1487
- };
1488
-
1489
- document.addEventListener('mousemove', handleMouseMove);
1490
- document.addEventListener('mouseup', handleMouseUp);
1491
- document.body.style.cursor = 'ns-resize';
1492
- document.body.style.userSelect = 'none';
1493
-
1494
- return () => {
1495
- document.removeEventListener('mousemove', handleMouseMove);
1496
- document.removeEventListener('mouseup', handleMouseUp);
1497
- document.body.style.cursor = '';
1498
- document.body.style.userSelect = '';
1499
- };
1500
- }, [isCommitDetailsRatioResizing]);
1501
-
1502
- useEffect(() => {
1503
- if (!isBranchPopoverOpen) return;
1504
-
1505
- const handleMouseDown = (event: MouseEvent) => {
1506
- if (branchPopoverRef.current && !branchPopoverRef.current.contains(event.target as Node)) {
1507
- setIsBranchPopoverOpen(false);
1508
- }
1509
- };
1510
-
1511
- document.addEventListener('mousedown', handleMouseDown);
1512
-
1513
- return () => {
1514
- document.removeEventListener('mousedown', handleMouseDown);
1515
- };
1516
- }, [isBranchPopoverOpen]);
1517
-
1518
- // Build branch trees for local and remote branches
1519
- const localBranchTree = useMemo(() => {
1520
- if (!branchData?.branches) return null;
1521
- return buildBranchTree(branchData.branches);
1522
- }, [branchData?.branches]);
1523
-
1524
- const remoteBranchTrees = useMemo(() => {
1525
- if (!branchData?.remotes) return null;
1526
- return buildRemoteBranchTree(branchData.remotes);
1527
- }, [branchData?.remotes]);
1528
-
1529
- const allBranchRefs = useMemo(() => {
1530
- const refs = new Set<string>();
1531
- for (const branch of branchData?.branches ?? []) {
1532
- refs.add(branch);
1533
- }
1534
- for (const [remoteName, branches] of Object.entries(branchData?.remotes ?? {})) {
1535
- for (const branch of branches) {
1536
- refs.add(`remotes/${remoteName}/${branch}`);
1537
- }
1538
- }
1539
- return refs;
1540
- }, [branchData?.branches, branchData?.remotes]);
1541
-
1542
- const selectedBranchSet = useMemo(() => new Set(selectedBranchRefs), [selectedBranchRefs]);
1543
-
1544
- const repository = useRepository(repoPath);
1545
- const updateRepository = useUpdateRepository();
1546
- const customBranchScripts = useMemo(() => {
1547
- const scripts = repository?.customScripts ?? [];
1548
- return scripts.filter((script) => (
1549
- script.target === 'branch' &&
1550
- script.action === 'run-bash-script' &&
1551
- script.name.trim().length > 0 &&
1552
- script.content.trim().length > 0
1553
- ));
1554
- }, [repository?.customScripts]);
1555
-
1556
- // Group expanded state (for "Branches" and "Remotes" group headers)
1557
- const [localGroupExpanded, setLocalGroupExpanded] = useState(true);
1558
- const [remotesGroupExpanded, setRemotesGroupExpanded] = useState(true);
1559
-
1560
- // Visibility state for branches/folders
1561
- const [visibilityMap, setVisibilityMap] = useState<VisibilityMap>({});
1562
- const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
1563
-
1564
- const orderedVisibleBranchRefs = useMemo(() => {
1565
- const ordered: string[] = [];
1566
- if (localGroupExpanded && localBranchTree) {
1567
- ordered.push(...collectVisibleBranchRefs(localBranchTree, expandedFolders));
1568
- }
1569
- if (remotesGroupExpanded && remoteBranchTrees) {
1570
- for (const [remoteName, tree] of Array.from(remoteBranchTrees.entries())) {
1571
- const remoteGroupPath = `__remotes__/${remoteName}`;
1572
- if (!expandedFolders.has(remoteGroupPath)) continue;
1573
- ordered.push(...collectVisibleBranchRefs(tree, expandedFolders, `remotes/${remoteName}`));
1574
- }
1575
- }
1576
- return ordered;
1577
- }, [expandedFolders, localBranchTree, localGroupExpanded, remoteBranchTrees, remotesGroupExpanded]);
1578
-
1579
- useEffect(() => {
1580
- if (selectedBranchRefs.length === 0) return;
1581
- const nextSelected = selectedBranchRefs.filter((branch) => allBranchRefs.has(branch));
1582
- if (nextSelected.length !== selectedBranchRefs.length) {
1583
- setSelectedBranchRefs(nextSelected);
1584
- }
1585
- if (branchSelectionAnchor && !allBranchRefs.has(branchSelectionAnchor)) {
1586
- setBranchSelectionAnchor(nextSelected.length > 0 ? nextSelected[nextSelected.length - 1] : null);
1587
- }
1588
- }, [allBranchRefs, branchSelectionAnchor, selectedBranchRefs]);
1589
-
1590
- // Load settings from repository data when it's available
1591
- const lastInitializedRepo = useRef<string | null>(null);
1592
- useEffect(() => {
1593
- if (repository && repository.path !== lastInitializedRepo.current) {
1594
- lastInitializedRepo.current = repository.path;
1595
- if (repository.localGroupExpanded !== undefined) setLocalGroupExpanded(repository.localGroupExpanded);
1596
- if (repository.remotesGroupExpanded !== undefined) setRemotesGroupExpanded(repository.remotesGroupExpanded);
1597
- if (repository.expandedFolders) setExpandedFolders(new Set(repository.expandedFolders));
1598
- if (repository.visibilityMap) setVisibilityMap(repository.visibilityMap as VisibilityMap);
1599
- }
1600
- }, [repository]);
1601
-
1602
- // Helper to save settings to the backend
1603
- const saveSettings = useCallback((updates: Partial<Repository>) => {
1604
- updateRepository.mutate({
1605
- path: repoPath,
1606
- updates
1607
- });
1608
- }, [repoPath, updateRepository]);
1609
-
1610
- const handleToggleLocalGroup = useCallback(() => {
1611
- const newValue = !localGroupExpanded;
1612
- setLocalGroupExpanded(newValue);
1613
- saveSettings({ localGroupExpanded: newValue });
1614
- }, [localGroupExpanded, saveSettings]);
1615
-
1616
- const handleToggleRemotesGroup = useCallback(() => {
1617
- const newValue = !remotesGroupExpanded;
1618
- setRemotesGroupExpanded(newValue);
1619
- saveSettings({ remotesGroupExpanded: newValue });
1620
- }, [remotesGroupExpanded, saveSettings]);
1621
-
1622
- // Toggle folder expansion
1623
- const toggleFolder = useCallback((path: string) => {
1624
- setExpandedFolders(prev => {
1625
- const next = new Set(prev);
1626
- if (next.has(path)) {
1627
- next.delete(path);
1628
- } else {
1629
- next.add(path);
1630
- }
1631
- saveSettings({ expandedFolders: Array.from(next) });
1632
- return next;
1633
- });
1634
- }, [saveSettings]);
1635
-
1636
- // Toggle visibility for a path
1637
- const handleToggleVisibility = useCallback((path: string, type: 'visible' | 'hidden') => {
1638
- setVisibilityMap(prev => {
1639
- const next = { ...prev };
1640
- // If currently set to this type, remove it (toggle off)
1641
- if (next[path] === type) {
1642
- delete next[path];
1643
- } else {
1644
- // Set to this type (auto-removes the other one since we're replacing)
1645
- next[path] = type;
1646
- }
1647
- const persistedVisibilityMap: Record<string, 'visible' | 'hidden'> = {};
1648
- Object.entries(next).forEach(([key, value]) => {
1649
- if (value === 'visible' || value === 'hidden') {
1650
- persistedVisibilityMap[key] = value;
1651
- }
1652
- });
1653
- saveSettings({ visibilityMap: persistedVisibilityMap });
1654
- return next;
1655
- });
1656
- }, [saveSettings]);
1657
-
1658
- // Clear all visibility filters
1659
- const handleClearAllFilters = useCallback(() => {
1660
- setVisibilityMap({});
1661
- saveSettings({ visibilityMap: {} });
1662
- }, [saveSettings]);
1663
-
1664
- // Helper to get effective visibility for a branch considering group paths
1665
- const getBranchEffectiveVisibility = useCallback((branch: string, isRemoteBranch: boolean) => {
1666
- // First check the branch itself
1667
- const directVis = getEffectiveVisibility(branch, visibilityMap);
1668
- if (directVis) return directVis;
1669
-
1670
- // Check group-level visibility
1671
- if (isRemoteBranch) {
1672
- // Remote branch format: remotes/origin/branch-name
1673
- const parts = branch.split('/');
1674
- if (parts.length >= 2 && parts[0] === 'remotes') {
1675
- const remoteName = parts[1];
1676
- // Check remote-specific group
1677
- const remoteGroupVis = visibilityMap[`__remotes__/${remoteName}`];
1678
- if (remoteGroupVis) return remoteGroupVis;
1679
- // Check all remotes group
1680
- const remotesVis = visibilityMap['__remotes__'];
1681
- if (remotesVis) return remotesVis;
1682
- }
1683
- } else {
1684
- // Local branch - check __local__ group
1685
- const localVis = visibilityMap['__local__'];
1686
- if (localVis) return localVis;
1687
- }
1688
-
1689
- return null;
1690
- }, [visibilityMap]);
1691
-
1692
- // Compute which branches should be visible based on visibility map
1693
- const filteredCommits = useMemo(() => {
1694
- if (!log?.all || !branchData?.branches || !branchData?.branchCommits) return log?.all || [];
1695
-
1696
- const hasVisibleMarkers = Object.values(visibilityMap).some(v => v === 'visible');
1697
- const hasHiddenMarkers = Object.values(visibilityMap).some(v => v === 'hidden');
1698
-
1699
- // If no visibility markers are set, show all commits
1700
- if (!hasVisibleMarkers && !hasHiddenMarkers) {
1701
- return log.all;
1702
- }
1703
-
1704
- // Get all branches (local + remote)
1705
- const allBranches: { branch: string; isRemote: boolean }[] = [
1706
- ...branchData.branches.map(b => ({ branch: b, isRemote: false })),
1707
- ];
1708
-
1709
- // Add remote branches
1710
- if (branchData.remotes) {
1711
- for (const [remoteName, branches] of Object.entries(branchData.remotes)) {
1712
- for (const branch of branches) {
1713
- allBranches.push({ branch: `remotes/${remoteName}/${branch}`, isRemote: true });
1714
- }
1715
- }
1716
- }
1717
-
1718
- // Calculate effective visibility for each branch
1719
- const visibleBranches = new Set<string>();
1720
- const hiddenBranchesSet = new Set<string>();
1721
- const nonHiddenBranches = new Set<string>(); // Branches that are not hidden (for hidden-only mode)
1722
-
1723
- for (const { branch, isRemote } of allBranches) {
1724
- const effectiveVis = getBranchEffectiveVisibility(branch, isRemote);
1725
- if (effectiveVis === 'visible') {
1726
- visibleBranches.add(branch);
1727
- } else if (effectiveVis === 'hidden') {
1728
- hiddenBranchesSet.add(branch);
1729
- } else {
1730
- // No visibility set - this branch is "neutral" (non-hidden)
1731
- nonHiddenBranches.add(branch);
1732
- }
1733
- }
1734
-
1735
- // Build a map from commit hash to commit for quick lookup
1736
- const commitMap = new Map(log.all.map(c => [c.hash, c]));
1737
-
1738
- // Helper to mark all ancestors as reachable
1739
- const markReachable = (startHash: string, reachableSet: Set<string>) => {
1740
- const stack = [startHash];
1741
- while (stack.length > 0) {
1742
- const hash = stack.pop()!;
1743
- if (reachableSet.has(hash)) continue;
1744
-
1745
- const commit = commitMap.get(hash);
1746
- if (!commit) continue;
1747
-
1748
- reachableSet.add(hash);
1749
-
1750
- // Add parents to process
1751
- for (const parentHash of commit.parents || []) {
1752
- if (!reachableSet.has(parentHash)) {
1753
- stack.push(parentHash);
1754
- }
1755
- }
1756
- }
1757
- };
1758
-
1759
- // Find commits reachable from visible branches
1760
- const reachableFromVisible = new Set<string>();
1761
- for (const branch of visibleBranches) {
1762
- const headHash = branchData.branchCommits[branch];
1763
- if (headHash) {
1764
- markReachable(headHash, reachableFromVisible);
1765
- }
1766
- }
1767
-
1768
- // If there are visible markers, only show commits reachable from visible branches
1769
- if (hasVisibleMarkers) {
1770
- return log.all.filter(commit => reachableFromVisible.has(commit.hash));
1771
- }
1772
-
1773
- // If only hidden markers exist, show commits reachable from any non-hidden branch
1774
- // A commit should only be hidden if it's EXCLUSIVELY reachable from hidden branches
1775
- if (hasHiddenMarkers) {
1776
- const reachableFromNonHidden = new Set<string>();
1777
- for (const branch of nonHiddenBranches) {
1778
- const headHash = branchData.branchCommits[branch];
1779
- if (headHash) {
1780
- markReachable(headHash, reachableFromNonHidden);
1781
- }
1782
- }
1783
-
1784
- return log.all.filter(commit => reachableFromNonHidden.has(commit.hash));
1785
- }
1786
-
1787
- return log.all;
1788
- }, [log?.all, branchData?.branches, branchData?.branchCommits, branchData?.remotes, visibilityMap, getBranchEffectiveVisibility]);
1789
-
1790
- const selectedCommitHashSet = useMemo(() => new Set(selectedCommitHashes), [selectedCommitHashes]);
1791
- const filteredCommitHashes = useMemo(() => filteredCommits.map((commit) => commit.hash), [filteredCommits]);
1792
-
1793
- useEffect(() => {
1794
- if (filteredCommitHashes.length === 0) {
1795
- if (selectedHash || selectedCommitHashes.length > 0 || selectionAnchorHash) {
1796
- selectSingleCommit(null);
1797
- }
1798
- return;
1799
- }
1800
-
1801
- const filteredHashSet = new Set(filteredCommitHashes);
1802
- const nextSelected = selectedCommitHashes.filter((hash) => filteredHashSet.has(hash));
1803
-
1804
- if (nextSelected.length !== selectedCommitHashes.length) {
1805
- setSelectedCommitHashes(nextSelected);
1806
- }
1807
-
1808
- if (selectedHash && !filteredHashSet.has(selectedHash)) {
1809
- setSelectedHash(nextSelected.length > 0 ? nextSelected[nextSelected.length - 1] : null);
1810
- }
1811
-
1812
- if (selectionAnchorHash && !filteredHashSet.has(selectionAnchorHash)) {
1813
- setSelectionAnchorHash(nextSelected.length > 0 ? nextSelected[nextSelected.length - 1] : null);
1814
- }
1815
- }, [filteredCommitHashes, selectedHash, selectedCommitHashes, selectionAnchorHash, selectSingleCommit]);
1816
-
1817
- const handleSelectCommit = useCallback((hash: string, modifiers?: CommitRowSelectModifiers) => {
1818
- const isRangeSelect = modifiers?.isRangeSelect ?? false;
1819
- const isMultiSelect = modifiers?.isMultiSelect ?? false;
1820
-
1821
- if (isRangeSelect) {
1822
- const anchor = selectionAnchorHash ?? selectedHash ?? hash;
1823
- const anchorIndex = filteredCommitHashes.indexOf(anchor);
1824
- const targetIndex = filteredCommitHashes.indexOf(hash);
1825
-
1826
- if (anchorIndex !== -1 && targetIndex !== -1) {
1827
- const [start, end] = anchorIndex < targetIndex ? [anchorIndex, targetIndex] : [targetIndex, anchorIndex];
1828
- const rangeSelection = filteredCommitHashes.slice(start, end + 1);
1829
- setSelectedCommitHashes(rangeSelection);
1830
- setSelectedHash(hash);
1831
- setSelectionAnchorHash(anchor);
1832
- return;
1833
- }
1834
- }
1835
-
1836
- if (isMultiSelect) {
1837
- if (selectedCommitHashSet.has(hash)) {
1838
- const nextSelected = selectedCommitHashes.filter((selected) => selected !== hash);
1839
- setSelectedCommitHashes(nextSelected);
1840
- setSelectedHash((prev) => {
1841
- if (prev !== hash) return prev;
1842
- return nextSelected.length > 0 ? nextSelected[nextSelected.length - 1] : null;
1843
- });
1844
- if (selectionAnchorHash === hash) {
1845
- setSelectionAnchorHash(nextSelected.length > 0 ? nextSelected[nextSelected.length - 1] : null);
1846
- }
1847
- } else {
1848
- setSelectedCommitHashes([...selectedCommitHashes, hash]);
1849
- setSelectedHash(hash);
1850
- setSelectionAnchorHash(hash);
1851
- }
1852
- return;
1853
- }
1854
-
1855
- selectSingleCommit(hash);
1856
- }, [filteredCommitHashes, selectedCommitHashSet, selectedCommitHashes, selectedHash, selectionAnchorHash, selectSingleCommit]);
1857
-
1858
- const selectedCommitsForCherryPick = useMemo(
1859
- () => filteredCommits.filter((commit) => selectedCommitHashSet.has(commit.hash)).reverse(),
1860
- [filteredCommits, selectedCommitHashSet]
1861
- );
1862
-
1863
- // Check if visibility filters are active
1864
- const hasVisibilityFilters = useMemo(() => {
1865
- return Object.values(visibilityMap).some(v => v === 'visible' || v === 'hidden');
1866
- }, [visibilityMap]);
1867
-
1868
- // Compute hidden branches set for filtering branch tags in git graph
1869
- const hiddenBranches = useMemo(() => {
1870
- const hidden = new Set<string>();
1871
-
1872
- // Check local branches
1873
- if (branchData?.branches) {
1874
- for (const branch of branchData.branches) {
1875
- const effectiveVis = getBranchEffectiveVisibility(branch, false);
1876
- if (effectiveVis === 'hidden') {
1877
- hidden.add(branch);
1878
- }
1879
- }
1880
- }
1881
-
1882
- // Check remote branches
1883
- if (branchData?.remotes) {
1884
- for (const [remoteName, branches] of Object.entries(branchData.remotes)) {
1885
- for (const branch of branches) {
1886
- const fullRef = `remotes/${remoteName}/${branch}`;
1887
- const effectiveVis = getBranchEffectiveVisibility(fullRef, true);
1888
- if (effectiveVis === 'hidden') {
1889
- hidden.add(fullRef);
1890
- }
1891
- }
1892
- }
1893
- }
1894
-
1895
- return hidden;
1896
- }, [branchData?.branches, branchData?.remotes, getBranchEffectiveVisibility]);
1897
-
1898
- // Auto-fetch more commits when filtered results are too few
1899
- const MIN_FILTERED_COMMITS = 50;
1900
- const MAX_AUTO_FETCH_LIMIT = 5000;
1901
-
1902
- useEffect(() => {
1903
- if (
1904
- hasVisibilityFilters &&
1905
- filteredCommits.length < MIN_FILTERED_COMMITS &&
1906
- !isFetching &&
1907
- limit < MAX_AUTO_FETCH_LIMIT &&
1908
- log?.all && log.all.length >= limit
1909
- ) {
1910
- // Fetch more commits - increase limit by 100
1911
- setLimit(l => Math.min(l + 100, MAX_AUTO_FETCH_LIMIT));
1912
- }
1913
- }, [hasVisibilityFilters, filteredCommits.length, isFetching, limit, log?.all]);
1914
-
1915
- // Handle scrolling to branch commit when it's loaded
1916
- useEffect(() => {
1917
- if (!pendingScrollCommit || !log?.all || isFetching) return;
1918
-
1919
- // Check if commit exists in current loaded commits
1920
- const commitExists = log.all.some(c => c.hash === pendingScrollCommit);
1921
-
1922
- if (commitExists) {
1923
- // Use requestAnimationFrame to ensure DOM is updated
1924
- requestAnimationFrame(() => {
1925
- const scrolled = gitGraphRef.current?.scrollToCommit(pendingScrollCommit);
1926
- if (scrolled) {
1927
- selectSingleCommit(pendingScrollCommit);
1928
- setPendingScrollCommit(null);
1929
- }
1930
- });
1931
- } else {
1932
- // Need to load more commits - increase limit
1933
- // Set a reasonable max limit to avoid infinite loading
1934
- if (limit < 5000) {
1935
- setLimit(l => l + 100);
1936
- } else {
1937
- // Give up after 5000 commits
1938
- console.warn('Could not find commit after loading 5000 commits');
1939
- setPendingScrollCommit(null);
1940
- }
1941
- }
1942
- }, [pendingScrollCommit, log?.all, isFetching, limit, selectSingleCommit]);
1943
-
1944
- const scrollToBranchHeadCommit = useCallback((branch: string) => {
1945
- if (!branchData?.branchCommits) return;
1946
-
1947
- const commitHash = branchData.branchCommits[branch];
1948
- if (!commitHash) return;
1949
-
1950
- const commitExists = log?.all?.some(c => c.hash === commitHash);
1951
- if (commitExists && gitGraphRef.current) {
1952
- const scrolled = gitGraphRef.current.scrollToCommit(commitHash);
1953
- if (scrolled) {
1954
- selectSingleCommit(commitHash);
1955
- return;
1956
- }
1957
- }
1958
-
1959
- setPendingScrollCommit(commitHash);
1960
- selectSingleCommit(commitHash);
1961
- }, [branchData?.branchCommits, log?.all, selectSingleCommit]);
1962
-
1963
- const handleBranchClick = useCallback((branch: string, modifiers?: BranchRowSelectModifiers) => {
1964
- const isRangeSelect = modifiers?.isRangeSelect ?? false;
1965
- const isMultiSelect = modifiers?.isMultiSelect ?? false;
1966
-
1967
- if (isRangeSelect) {
1968
- const anchor = branchSelectionAnchor ?? branch;
1969
- const anchorIndex = orderedVisibleBranchRefs.indexOf(anchor);
1970
- const targetIndex = orderedVisibleBranchRefs.indexOf(branch);
1971
- if (anchorIndex !== -1 && targetIndex !== -1) {
1972
- const [start, end] = anchorIndex < targetIndex ? [anchorIndex, targetIndex] : [targetIndex, anchorIndex];
1973
- const rangeSelection = orderedVisibleBranchRefs.slice(start, end + 1);
1974
- const shouldUnselect = selectedBranchSet.has(branch);
1975
- if (shouldUnselect) {
1976
- setSelectedBranchRefs(selectedBranchRefs.filter((selected) => !rangeSelection.includes(selected)));
1977
- } else {
1978
- setSelectedBranchRefs(Array.from(new Set([...selectedBranchRefs, ...rangeSelection])));
1979
- }
1980
- setBranchSelectionAnchor(anchor);
1981
- } else {
1982
- setSelectedBranchRefs([branch]);
1983
- setBranchSelectionAnchor(branch);
1984
- }
1985
- return;
1986
- }
1987
-
1988
- if (isMultiSelect) {
1989
- if (selectedBranchSet.has(branch)) {
1990
- const nextSelected = selectedBranchRefs.filter((selected) => selected !== branch);
1991
- setSelectedBranchRefs(nextSelected);
1992
- } else {
1993
- setSelectedBranchRefs([...selectedBranchRefs, branch]);
1994
- }
1995
- setBranchSelectionAnchor(branch);
1996
- return;
1997
- }
1998
-
1999
- setSelectedBranchRefs([branch]);
2000
- setBranchSelectionAnchor(branch);
2001
- scrollToBranchHeadCommit(branch);
2002
- }, [branchSelectionAnchor, orderedVisibleBranchRefs, scrollToBranchHeadCommit, selectedBranchRefs, selectedBranchSet]);
2003
-
2004
- const handleBranchContextMenu = useCallback((branch: string) => {
2005
- if (selectedBranchSet.has(branch)) return;
2006
- setSelectedBranchRefs([branch]);
2007
- setBranchSelectionAnchor(branch);
2008
- }, [selectedBranchSet]);
2009
-
2010
- const handleRunCustomScript = useCallback(async (script: RepositoryCustomScript, branchRef: string) => {
2011
- setDidCopyScriptOutput(false);
2012
- setIsCancelingScriptExecution(false);
2013
- setScriptExecution({
2014
- isOpen: true,
2015
- executionId: null,
2016
- scriptName: script.name,
2017
- branchRef,
2018
- output: '',
2019
- status: 'starting',
2020
- error: null,
2021
- });
2022
-
2023
- try {
2024
- const response = await fetch('/api/custom-scripts', {
2025
- method: 'POST',
2026
- headers: { 'Content-Type': 'application/json' },
2027
- body: JSON.stringify({
2028
- command: 'start',
2029
- repoPath,
2030
- branchRef,
2031
- scriptContent: script.content,
2032
- }),
2033
- });
2034
-
2035
- const result = await response.json();
2036
- if (!response.ok) {
2037
- throw new Error(result.error || 'Failed to start script execution');
2038
- }
2039
-
2040
- const prelude: string[] = [];
2041
- if (result.previousBranch && result.checkedOutBranch && result.previousBranch !== result.checkedOutBranch) {
2042
- prelude.push(`[info] Checked out ${result.checkedOutBranch} (from ${result.previousBranch})`);
2043
- }
2044
-
2045
- setScriptExecution((prev) => ({
2046
- ...prev,
2047
- executionId: result.executionId,
2048
- output: [prelude.join('\n'), result.output].filter(Boolean).join('\n'),
2049
- status: result.status as ScriptExecutionStatus,
2050
- error: null,
2051
- }));
2052
- } catch (error) {
2053
- setScriptExecution((prev) => ({
2054
- ...prev,
2055
- status: 'failed',
2056
- error: (error as Error).message,
2057
- output: prev.output
2058
- ? `${prev.output}\n[error] ${(error as Error).message}`
2059
- : `[error] ${(error as Error).message}`,
2060
- }));
2061
- }
2062
- }, [repoPath]);
2063
-
2064
- const handleCancelCustomScriptExecution = useCallback(async () => {
2065
- if (!scriptExecution.executionId || !isScriptExecutionRunning) return;
2066
-
2067
- setIsCancelingScriptExecution(true);
2068
- try {
2069
- const response = await fetch('/api/custom-scripts', {
2070
- method: 'POST',
2071
- headers: { 'Content-Type': 'application/json' },
2072
- body: JSON.stringify({
2073
- command: 'cancel',
2074
- executionId: scriptExecution.executionId,
2075
- }),
2076
- });
2077
- const result = await response.json();
2078
- if (!response.ok) {
2079
- throw new Error(result.error || 'Failed to cancel script execution');
2080
- }
2081
-
2082
- setScriptExecution((prev) => ({
2083
- ...prev,
2084
- output: result.output,
2085
- status: result.status as ScriptExecutionStatus,
2086
- }));
2087
-
2088
- if (result.status !== 'running' && result.status !== 'starting') {
2089
- setIsCancelingScriptExecution(false);
2090
- }
2091
- } catch (error) {
2092
- setScriptExecution((prev) => ({
2093
- ...prev,
2094
- output: `${prev.output}\n[error] ${(error as Error).message}`,
2095
- }));
2096
- setIsCancelingScriptExecution(false);
2097
- }
2098
- }, [scriptExecution.executionId, isScriptExecutionRunning]);
2099
-
2100
- const handleCopyCustomScriptOutput = useCallback(async () => {
2101
- if (isCopyingScriptOutput) return;
2102
-
2103
- setIsCopyingScriptOutput(true);
2104
- const copied = await copyText(scriptExecution.output);
2105
- setIsCopyingScriptOutput(false);
2106
- setDidCopyScriptOutput(copied);
2107
- if (copied) {
2108
- setTimeout(() => setDidCopyScriptOutput(false), 1500);
2109
- }
2110
- }, [isCopyingScriptOutput, scriptExecution.output]);
2111
-
2112
- useEffect(() => {
2113
- if (!scriptExecution.executionId || !isScriptExecutionRunning) return;
2114
-
2115
- let disposed = false;
2116
- let pollTimer: ReturnType<typeof setTimeout> | null = null;
2117
-
2118
- const poll = async () => {
2119
- try {
2120
- const response = await fetch('/api/custom-scripts', {
2121
- method: 'POST',
2122
- headers: { 'Content-Type': 'application/json' },
2123
- body: JSON.stringify({
2124
- command: 'status',
2125
- executionId: scriptExecution.executionId,
2126
- }),
2127
- });
2128
- const result = await response.json();
2129
-
2130
- if (!response.ok) {
2131
- throw new Error(result.error || 'Failed to fetch script execution output');
2132
- }
2133
-
2134
- if (disposed) return;
2135
-
2136
- const nextStatus = result.status as ScriptExecutionStatus;
2137
- setScriptExecution((prev) => {
2138
- if (prev.executionId !== result.executionId) return prev;
2139
- return {
2140
- ...prev,
2141
- output: result.output,
2142
- status: nextStatus,
2143
- error: null,
2144
- };
2145
- });
2146
-
2147
- if (nextStatus === 'running') {
2148
- pollTimer = setTimeout(poll, 450);
2149
- } else {
2150
- setIsCancelingScriptExecution(false);
2151
- }
2152
- } catch (error) {
2153
- if (disposed) return;
2154
- setIsCancelingScriptExecution(false);
2155
- setScriptExecution((prev) => ({
2156
- ...prev,
2157
- status: 'failed',
2158
- error: (error as Error).message,
2159
- output: prev.output
2160
- ? `${prev.output}\n[error] ${(error as Error).message}`
2161
- : `[error] ${(error as Error).message}`,
2162
- }));
2163
- }
2164
- };
2165
-
2166
- void poll();
2167
-
2168
- return () => {
2169
- disposed = true;
2170
- if (pollTimer) {
2171
- clearTimeout(pollTimer);
2172
- }
2173
- };
2174
- }, [scriptExecution.executionId, isScriptExecutionRunning]);
2175
-
2176
- const confirmDeleteBranches = (branches: string[]) => {
2177
- const deletableBranches = branches.filter((branch) => branch !== currentBranch);
2178
- if (deletableBranches.length === 0) return;
2179
- setBranchesToDelete(deletableBranches);
2180
- setDeleteRemoteBranch(false);
2181
- setIsDeleteOpen(true);
2182
- };
2183
-
2184
- const confirmDeleteBranch = (branch: string) => {
2185
- confirmDeleteBranches([branch]);
2186
- };
2187
-
2188
- const closeDeleteTagDialog = useCallback(() => {
2189
- setIsDeleteTagOpen(false);
2190
- setTagToDelete(null);
2191
- setDeleteRemoteTag(false);
2192
- }, []);
2193
-
2194
- const confirmDeleteTag = useCallback((tagName: string) => {
2195
- if (!tagName) return;
2196
- setTagToDelete(tagName);
2197
- setDeleteRemoteTag(false);
2198
- setIsDeleteTagOpen(true);
2199
- }, []);
2200
-
2201
- const handleDeleteBranch = async () => {
2202
- if (branchesToDelete.length === 0) return;
2203
- setIsDeleting(true);
2204
- try {
2205
- const deleteRequests = new Map<string, { branchRef: string; run: () => Promise<unknown> }>();
2206
- const addDeleteRequest = (key: string, branchRef: string, run: () => Promise<unknown>) => {
2207
- if (deleteRequests.has(key)) return;
2208
- deleteRequests.set(key, { branchRef, run });
2209
- };
2210
-
2211
- const trackingRemotesToDelete = new Set<string>();
2212
- if (deleteRemoteBranch) {
2213
- for (const branchRef of branchesToDelete) {
2214
- if (branchRef.startsWith('remotes/')) continue;
2215
- const tracking = branchData?.trackingInfo?.[branchRef];
2216
- if (tracking?.upstream) {
2217
- trackingRemotesToDelete.add(tracking.upstream);
2218
- }
2219
- }
2220
- }
2221
-
2222
- for (const upstream of trackingRemotesToDelete) {
2223
- const [remote, ...branchParts] = upstream.split('/');
2224
- const branch = branchParts.join('/');
2225
- if (remote && branch) {
2226
- const branchRef = `${remote}/${branch}`;
2227
- addDeleteRequest(`remote:${branchRef}`, branchRef, () =>
2228
- runGitAction({
2229
- repoPath,
2230
- action: 'delete-remote-branch',
2231
- data: { remote, branch },
2232
- suppressErrorToast: true,
2233
- })
2234
- );
2235
- }
2236
- }
2237
-
2238
- for (const branchRef of branchesToDelete) {
2239
- if (branchRef.startsWith('remotes/')) {
2240
- const parts = branchRef.split('/');
2241
- if (parts.length >= 3) {
2242
- const remote = parts[1];
2243
- const branch = parts.slice(2).join('/');
2244
- const remoteBranchRef = `${remote}/${branch}`;
2245
- addDeleteRequest(`remote:${remoteBranchRef}`, remoteBranchRef, () =>
2246
- runGitAction({
2247
- repoPath,
2248
- action: 'delete-remote-branch',
2249
- data: { remote, branch },
2250
- suppressErrorToast: true,
2251
- })
2252
- );
2253
- }
2254
- continue;
2255
- }
2256
-
2257
- addDeleteRequest(`local:${branchRef}`, branchRef, () =>
2258
- runGitAction({
2259
- repoPath,
2260
- action: 'delete-branch',
2261
- data: { branch: branchRef },
2262
- suppressErrorToast: true,
2263
- })
2264
- );
2265
- }
2266
-
2267
- const requests = Array.from(deleteRequests.values());
2268
- const results = await Promise.allSettled(requests.map((request) => request.run()));
2269
- const failedBranches = results.flatMap((result, index) => {
2270
- if (result.status === 'fulfilled') return [];
2271
- const failedBranch = requests[index].branchRef;
2272
- console.error(`Failed to delete branch "${failedBranch}":`, result.reason);
2273
- return [failedBranch];
2274
- });
2275
-
2276
- if (failedBranches.length > 0) {
2277
- toast({
2278
- type: 'error',
2279
- title: failedBranches.length === 1
2280
- ? 'Failed to Delete 1 Branch'
2281
- : `Failed to Delete ${failedBranches.length} Branches`,
2282
- description: (
2283
- <div>
2284
- <div>The following branches could not be deleted:</div>
2285
- <ul className="mt-1 max-h-40 overflow-y-auto list-disc pl-5">
2286
- {failedBranches.map((branch) => (
2287
- <li key={branch} className="break-all">{branch}</li>
2288
- ))}
2289
- </ul>
2290
- </div>
2291
- ),
2292
- duration: 10000,
2293
- });
2294
- }
2295
-
2296
- setIsDeleteOpen(false);
2297
- setBranchesToDelete([]);
2298
- setDeleteRemoteBranch(false);
2299
- setSelectedBranchRefs([]);
2300
- setBranchSelectionAnchor(null);
2301
- } catch (e) {
2302
- console.error(e);
2303
- } finally {
2304
- setIsDeleting(false);
2305
- }
2306
- };
2307
-
2308
- const handleDeleteTag = async () => {
2309
- if (!tagToDelete) return;
2310
- setIsDeletingTag(true);
2311
- try {
2312
- await runGitAction({
2313
- repoPath,
2314
- action: 'delete-tag',
2315
- data: { tag: tagToDelete },
2316
- });
2317
-
2318
- if (deleteRemoteTag && remoteNameForTagDelete) {
2319
- try {
2320
- await runGitAction({
2321
- repoPath,
2322
- action: 'delete-remote-tag',
2323
- data: { remote: remoteNameForTagDelete, tag: tagToDelete },
2324
- });
2325
- } catch (error) {
2326
- console.error('Failed to delete remote tag:', error);
2327
- }
2328
- }
2329
-
2330
- closeDeleteTagDialog();
2331
- } catch (e) {
2332
- console.error(e);
2333
- } finally {
2334
- setIsDeletingTag(false);
2335
- }
2336
- };
2337
-
2338
- const confirmRenameBranch = (branch: string) => {
2339
- setBranchToRename(branch);
2340
- setRemoteBranchToRename(null);
2341
- // Pre-fill with current branch name
2342
- setNewBranchNameForRename(branch);
2343
- setRenameTrackingRemoteBranch(false);
2344
- setIsRenameOpen(true);
2345
- }
2346
-
2347
- const confirmRenameRemoteBranch = (fullRemoteBranch: string) => {
2348
- const parts = fullRemoteBranch.split('/');
2349
- if (parts.length < 3 || parts[0] !== 'remotes') return;
2350
-
2351
- const remote = parts[1];
2352
- const branch = parts.slice(2).join('/');
2353
- if (!remote || !branch) return;
2354
-
2355
- setBranchToRename(fullRemoteBranch);
2356
- setRemoteBranchToRename({ remote, branch });
2357
- setNewBranchNameForRename(branch);
2358
- setRenameTrackingRemoteBranch(false);
2359
- setIsRenameOpen(true);
2360
- }
2361
-
2362
- const confirmRenameRemote = (remote: string) => {
2363
- setRemoteToRename(remote);
2364
- setNewRemoteNameForRename(remote);
2365
- setIsRenameRemoteOpen(true);
2366
- }
2367
-
2368
- const confirmDeleteRemote = (remote: string) => {
2369
- if (!remote) return;
2370
- setRemoteToDelete(remote);
2371
- setIsDeleteRemoteOpen(true);
2372
- }
2373
-
2374
- const confirmAddRemote = () => {
2375
- setNewRemoteName('origin');
2376
- setNewRemoteUrl('');
2377
- setIsAddRemoteOpen(true);
2378
- }
2379
-
2380
- const handleRenameBranch = async () => {
2381
- if (!branchToRename || !newBranchNameForRename) return;
2382
- const isSameName = remoteBranchToRename
2383
- ? remoteBranchToRename.branch === newBranchNameForRename
2384
- : branchToRename === newBranchNameForRename;
2385
-
2386
- if (isSameName) {
2387
- closeRenameBranchDialog();
2388
- return;
2389
- }
2390
- setIsRenaming(true);
2391
- try {
2392
- if (remoteBranchToRename) {
2393
- await runGitAction({
2394
- repoPath,
2395
- action: 'rename-remote-branch',
2396
- data: {
2397
- remote: remoteBranchToRename.remote,
2398
- oldName: remoteBranchToRename.branch,
2399
- newName: newBranchNameForRename,
2400
- }
2401
- });
2402
- } else {
2403
- await runGitAction({
2404
- repoPath,
2405
- action: 'rename-branch',
2406
- data: {
2407
- oldName: branchToRename,
2408
- newName: newBranchNameForRename,
2409
- renameTrackingRemote: renameTrackingRemoteBranch,
2410
- }
2411
- });
2412
- }
2413
- closeRenameBranchDialog();
2414
- } catch (e) {
2415
- console.error(e);
2416
- } finally {
2417
- setIsRenaming(false);
2418
- }
2419
- }
2420
-
2421
- const handleRenameRemote = async () => {
2422
- if (!remoteToRename) return;
2423
- const trimmedOldName = remoteToRename.trim();
2424
- const trimmedNewName = newRemoteNameForRename.trim();
2425
- if (!trimmedOldName || !trimmedNewName) return;
2426
-
2427
- if (trimmedOldName === trimmedNewName) {
2428
- closeRenameRemoteDialog();
2429
- return;
2430
- }
2431
-
2432
- setIsRenamingRemote(true);
2433
- try {
2434
- await runGitAction({
2435
- repoPath,
2436
- action: 'rename-remote',
2437
- data: {
2438
- oldName: trimmedOldName,
2439
- newName: trimmedNewName,
2440
- },
2441
- });
2442
- closeRenameRemoteDialog();
2443
- } catch (e) {
2444
- console.error(e);
2445
- } finally {
2446
- setIsRenamingRemote(false);
2447
- }
2448
- }
2449
-
2450
- const handleDeleteRemote = async () => {
2451
- if (!remoteToDelete) return;
2452
- const trimmedRemoteName = remoteToDelete.trim();
2453
- if (!trimmedRemoteName) return;
2454
-
2455
- setIsDeletingRemote(true);
2456
- try {
2457
- await runGitAction({
2458
- repoPath,
2459
- action: 'delete-remote',
2460
- data: {
2461
- name: trimmedRemoteName,
2462
- },
2463
- });
2464
- closeDeleteRemoteDialog();
2465
- } catch (e) {
2466
- console.error(e);
2467
- } finally {
2468
- setIsDeletingRemote(false);
2469
- }
2470
- }
2471
-
2472
- const handleAddRemote = async () => {
2473
- const trimmedName = newRemoteName.trim();
2474
- const trimmedUrl = newRemoteUrl.trim();
2475
- if (!trimmedName || !trimmedUrl) return;
2476
-
2477
- setIsAddingRemote(true);
2478
- try {
2479
- await runGitAction({
2480
- repoPath,
2481
- action: 'add-remote',
2482
- data: {
2483
- name: trimmedName,
2484
- url: trimmedUrl,
2485
- },
2486
- });
2487
- closeAddRemoteDialog();
2488
- } catch (e) {
2489
- console.error(e);
2490
- } finally {
2491
- setIsAddingRemote(false);
2492
- }
2493
- }
2494
-
2495
- const confirmRebase = ({ sourceBranch, targetBranch }: BranchOperation) => {
2496
- setRebaseSourceBranch(sourceBranch);
2497
- setRebaseTargetBranch(targetBranch);
2498
- setRebaseStashChanges(true);
2499
- setRebaseConflictStatus('checking');
2500
- setIsRebaseOpen(true);
2501
- }
2502
-
2503
- const handleRebase = async () => {
2504
- if (!rebaseSourceBranch || !rebaseTargetBranch) return;
2505
- setIsRebasing(true);
2506
- try {
2507
- await runGitAction({
2508
- repoPath,
2509
- action: 'checkout',
2510
- data: { branch: rebaseSourceBranch }
2511
- });
2512
-
2513
- await runGitAction({
2514
- repoPath,
2515
- action: 'rebase',
2516
- data: { ontoBranch: rebaseTargetBranch, stashChanges: rebaseStashChanges }
2517
- });
2518
- closeRebaseDialog();
2519
- } catch (e) {
2520
- console.error(e);
2521
- } finally {
2522
- setIsRebasing(false);
2523
- }
2524
- }
2525
-
2526
- useEffect(() => {
2527
- const sourceBranch = rebaseSourceBranch;
2528
- const ontoBranch = rebaseTargetBranch;
2529
-
2530
- if (!isRebaseOpen || !sourceBranch || !ontoBranch) {
2531
- return;
2532
- }
2533
-
2534
- let cancelled = false;
2535
- setRebaseConflictStatus('checking');
2536
-
2537
- const checkRebaseConflicts = async () => {
2538
- try {
2539
- const result = await runGitAction({
2540
- repoPath,
2541
- action: 'check-rebase-conflicts',
2542
- data: {
2543
- sourceBranch,
2544
- ontoBranch,
2545
- },
2546
- });
2547
-
2548
- if (!cancelled) {
2549
- setRebaseConflictStatus(result.hasConflicts ? 'has-conflicts' : 'no-conflict');
2550
- }
2551
- } catch (e) {
2552
- console.error(e);
2553
- if (!cancelled) {
2554
- // Be conservative when the check cannot be completed.
2555
- setRebaseConflictStatus('has-conflicts');
2556
- }
2557
- }
2558
- };
2559
-
2560
- void checkRebaseConflicts();
2561
-
2562
- return () => {
2563
- cancelled = true;
2564
- };
2565
- }, [isRebaseOpen, rebaseSourceBranch, rebaseTargetBranch, repoPath, runGitAction]);
2566
-
2567
- const confirmMerge = ({ sourceBranch, targetBranch }: BranchOperation) => {
2568
- setMergeTargetBranch(targetBranch);
2569
- setMergeSourceBranch(sourceBranch);
2570
- setMergeRebaseBeforeMerge(false);
2571
- setMergeSquash(false);
2572
- setMergeFastForward(false);
2573
- setMergeSquashMessage('');
2574
- setMergeConflictStatus('checking');
2575
- setIsMergeOpen(true);
2576
- }
2577
-
2578
- const handleMergeSquashToggle = useCallback((enabled: boolean) => {
2579
- setMergeSquash(enabled);
2580
-
2581
- if (!enabled) {
2582
- return;
2583
- }
2584
-
2585
- if (!mergeSourceBranch) {
2586
- setMergeSquashMessage('');
2587
- return;
2588
- }
2589
-
2590
- void (async () => {
2591
- try {
2592
- const result = await runGitAction({
2593
- repoPath,
2594
- action: 'get-latest-commit-message',
2595
- data: { branch: mergeSourceBranch },
2596
- });
2597
- setMergeSquashMessage(typeof result?.message === 'string' ? result.message : '');
2598
- } catch (e) {
2599
- console.error(e);
2600
- setMergeSquashMessage('');
2601
- }
2602
- })();
2603
- }, [mergeSourceBranch, repoPath, runGitAction]);
2604
-
2605
- const handleMerge = async () => {
2606
- if (!mergeTargetBranch || !mergeSourceBranch) return;
2607
- setIsMerging(true);
2608
- try {
2609
- await runGitAction({
2610
- repoPath,
2611
- action: 'checkout',
2612
- data: { branch: mergeTargetBranch }
2613
- });
2614
-
2615
- await runGitAction({
2616
- repoPath,
2617
- action: 'merge',
2618
- data: {
2619
- targetBranch: mergeSourceBranch,
2620
- rebaseBeforeMerge: mergeRebaseBeforeMerge,
2621
- squash: mergeSquash,
2622
- fastForward: mergeFastForward,
2623
- squashMessage: mergeSquash ? mergeSquashMessage : undefined,
2624
- }
2625
- });
2626
- closeMergeDialog();
2627
- } catch (e) {
2628
- console.error(e);
2629
- } finally {
2630
- setIsMerging(false);
2631
- }
2632
- }
2633
-
2634
- useEffect(() => {
2635
- const sourceBranch = mergeSourceBranch;
2636
- const targetBranch = mergeTargetBranch;
2637
-
2638
- if (!isMergeOpen || !sourceBranch || !targetBranch) {
2639
- return;
2640
- }
2641
-
2642
- let cancelled = false;
2643
- setMergeConflictStatus('checking');
2644
-
2645
- const checkMergeConflicts = async () => {
2646
- try {
2647
- const result = await runGitAction({
2648
- repoPath,
2649
- action: 'check-merge-conflicts',
2650
- data: {
2651
- sourceBranch,
2652
- targetBranch,
2653
- },
2654
- });
2655
-
2656
- if (!cancelled) {
2657
- setMergeConflictStatus(result.hasConflicts ? 'has-conflicts' : 'no-conflict');
2658
- }
2659
- } catch (e) {
2660
- console.error(e);
2661
- if (!cancelled) {
2662
- // Be conservative when the check cannot be completed.
2663
- setMergeConflictStatus('has-conflicts');
2664
- }
2665
- }
2666
- };
2667
-
2668
- void checkMergeConflicts();
2669
-
2670
- return () => {
2671
- cancelled = true;
2672
- };
2673
- }, [isMergeOpen, mergeSourceBranch, mergeTargetBranch, repoPath, runGitAction]);
2674
-
2675
- const confirmPushToRemote = async (branch: string) => {
2676
- setPushBranch(branch);
2677
- setPushError(null);
2678
- setPushRemotes([]);
2679
- setPushRemoteBranches([]);
2680
- setPushSelectedRemote('');
2681
- setPushSelectedRemoteBranch('');
2682
- setPushTrackingBranch(null);
2683
- setPushRebaseFirst(false);
2684
- setPushForcePush(false);
2685
- setPushLocalOnlyTags(true);
2686
- setPushSquash(false);
2687
- setPushSquashMessage('');
2688
- setIsPushOpen(true);
2689
-
2690
- // Load remotes
2691
- setPushLoadingRemotes(true);
2692
- try {
2693
- const result = await runGitAction({
2694
- repoPath,
2695
- action: 'get-remotes',
2696
- data: {}
2697
- });
2698
-
2699
- if (!result.remotes || result.remotes.length === 0) {
2700
- setPushError('No remote repository configured. Please add a remote first.');
2701
- setPushLoadingRemotes(false);
2702
- return;
2703
- }
2704
-
2705
- setPushRemotes(result.remotes);
2706
-
2707
- // Get tracking branch info
2708
- const trackingResult = await runGitAction({
2709
- repoPath,
2710
- action: 'get-tracking-branch',
2711
- data: { branch }
2712
- });
2713
-
2714
- setPushTrackingBranch(trackingResult.tracking);
2715
-
2716
- // Set default remote - use tracking remote if available and exists in remotes list, otherwise first remote
2717
- const trackingRemote = trackingResult.tracking?.remote;
2718
- const defaultRemote = (trackingRemote && result.remotes.includes(trackingRemote))
2719
- ? trackingRemote
2720
- : result.remotes[0];
2721
- setPushSelectedRemote(defaultRemote);
2722
-
2723
- // Load branches for the default remote
2724
- setPushLoadingBranches(true);
2725
- const branchesResult = await runGitAction({
2726
- repoPath,
2727
- action: 'get-remote-branches',
2728
- data: { remote: defaultRemote }
2729
- });
2730
-
2731
- setPushRemoteBranches(branchesResult.branches || []);
2732
-
2733
- // Set default remote branch - use tracking branch if on same remote, otherwise use branch name
2734
- if (trackingResult.tracking?.remote === defaultRemote && trackingResult.tracking?.branch) {
2735
- setPushSelectedRemoteBranch(trackingResult.tracking.branch);
2736
- } else {
2737
- // Default to same name as local branch, or first branch if local branch name doesn't exist
2738
- const localBranchName = branch;
2739
- if (branchesResult.branches?.includes(localBranchName)) {
2740
- setPushSelectedRemoteBranch(localBranchName);
2741
- } else {
2742
- // Will create new branch with local branch name
2743
- setPushSelectedRemoteBranch(localBranchName);
2744
- }
2745
- }
2746
- } catch (e) {
2747
- console.error(e);
2748
- setPushError((e as Error).message || 'Failed to load remote information');
2749
- } finally {
2750
- setPushLoadingRemotes(false);
2751
- setPushLoadingBranches(false);
2752
- }
2753
- }
2754
-
2755
- const handlePushRemoteChange = async (remote: string) => {
2756
- setPushSelectedRemote(remote);
2757
- setPushLoadingBranches(true);
2758
- setPushRemoteBranches([]);
2759
-
2760
- try {
2761
- const branchesResult = await runGitAction({
2762
- repoPath,
2763
- action: 'get-remote-branches',
2764
- data: { remote }
2765
- });
2766
-
2767
- setPushRemoteBranches(branchesResult.branches || []);
2768
-
2769
- // Set default branch - tracking branch if on this remote, otherwise local branch name
2770
- if (pushTrackingBranch?.remote === remote && pushTrackingBranch?.branch) {
2771
- setPushSelectedRemoteBranch(pushTrackingBranch.branch);
2772
- } else {
2773
- setPushSelectedRemoteBranch(pushBranch || '');
2774
- }
2775
- } catch (e) {
2776
- console.error(e);
2777
- setPushError((e as Error).message || 'Failed to load remote branches');
2778
- } finally {
2779
- setPushLoadingBranches(false);
2780
- }
2781
- }
2782
-
2783
- const handlePushSquashToggle = useCallback((enabled: boolean) => {
2784
- setPushSquash(enabled);
2785
-
2786
- if (!enabled) {
2787
- return;
2788
- }
2789
-
2790
- if (!pushBranch) {
2791
- setPushSquashMessage('');
2792
- return;
2793
- }
2794
-
2795
- void (async () => {
2796
- try {
2797
- const result = await runGitAction({
2798
- repoPath,
2799
- action: 'get-latest-commit-message',
2800
- data: { branch: pushBranch },
2801
- });
2802
- setPushSquashMessage(typeof result?.message === 'string' ? result.message : '');
2803
- } catch (e) {
2804
- console.error(e);
2805
- setPushSquashMessage('');
2806
- }
2807
- })();
2808
- }, [pushBranch, repoPath, runGitAction]);
2809
-
2810
- const handlePushToRemote = async () => {
2811
- if (!pushBranch || !pushSelectedRemote || !pushSelectedRemoteBranch) return;
2812
-
2813
- setIsPushing(true);
2814
- setPushError(null);
2815
-
2816
- try {
2817
- // Determine if we need to set upstream
2818
- const isNewBranch = !pushRemoteBranches.includes(pushSelectedRemoteBranch);
2819
- const needsSetUpstream = isNewBranch ||
2820
- pushTrackingBranch?.remote !== pushSelectedRemote ||
2821
- pushTrackingBranch?.branch !== pushSelectedRemoteBranch;
2822
-
2823
- await runGitAction({
2824
- repoPath,
2825
- action: 'push-to-remote',
2826
- data: {
2827
- localBranch: pushBranch,
2828
- remote: pushSelectedRemote,
2829
- remoteBranch: pushSelectedRemoteBranch,
2830
- rebaseFirst: pushForcePush ? false : pushRebaseFirst,
2831
- forcePush: pushForcePush,
2832
- pushLocalOnlyTags,
2833
- setUpstream: needsSetUpstream,
2834
- squash: pushSquash,
2835
- squashMessage: pushSquashMessage,
2836
- }
2837
- });
2838
-
2839
- // Fetch from the remote we just pushed to
2840
- await runGitAction({
2841
- repoPath,
2842
- action: 'fetch',
2843
- data: { remote: pushSelectedRemote }
2844
- });
2845
-
2846
- setIsPushOpen(false);
2847
- setPushBranch(null);
2848
- } catch (e) {
2849
- console.error(e);
2850
- setPushError((e as Error).message || 'Failed to push to remote');
2851
- } finally {
2852
- setIsPushing(false);
2853
- }
2854
- }
2855
-
2856
- const confirmPullFromRemote = async (branch: string) => {
2857
- setPullBranch(branch);
2858
- setPullError(null);
2859
- setPullRemotes([]);
2860
- setPullRemoteBranches([]);
2861
- setPullSelectedRemote('');
2862
- setPullSelectedRemoteBranch('');
2863
- setPullTrackingBranch(null);
2864
- setPullRebase(true);
2865
- setIsPullOpen(true);
2866
-
2867
- // Load remotes
2868
- setPullLoadingRemotes(true);
2869
- try {
2870
- const result = await runGitAction({
2871
- repoPath,
2872
- action: 'get-remotes',
2873
- data: {}
2874
- });
2875
-
2876
- if (!result.remotes || result.remotes.length === 0) {
2877
- setPullError('No remote repository configured. Please add a remote first.');
2878
- setPullLoadingRemotes(false);
2879
- return;
2880
- }
2881
-
2882
- setPullRemotes(result.remotes);
2883
-
2884
- // Get tracking branch info
2885
- const trackingResult = await runGitAction({
2886
- repoPath,
2887
- action: 'get-tracking-branch',
2888
- data: { branch }
2889
- });
2890
-
2891
- setPullTrackingBranch(trackingResult.tracking);
2892
-
2893
- // Set default remote - use tracking remote if available and exists in remotes list, otherwise first remote
2894
- const trackingRemote = trackingResult.tracking?.remote;
2895
- const defaultRemote = (trackingRemote && result.remotes.includes(trackingRemote))
2896
- ? trackingRemote
2897
- : result.remotes[0];
2898
- setPullSelectedRemote(defaultRemote);
2899
-
2900
- // Load branches for the default remote
2901
- setPullLoadingBranches(true);
2902
- const branchesResult = await runGitAction({
2903
- repoPath,
2904
- action: 'get-remote-branches',
2905
- data: { remote: defaultRemote }
2906
- });
2907
-
2908
- setPullRemoteBranches(branchesResult.branches || []);
2909
-
2910
- // Set default remote branch - use tracking branch if on same remote
2911
- if (trackingResult.tracking?.remote === defaultRemote && trackingResult.tracking?.branch) {
2912
- setPullSelectedRemoteBranch(trackingResult.tracking.branch);
2913
- } else {
2914
- // No tracking branch on this remote - leave empty to show error
2915
- setPullSelectedRemoteBranch('');
2916
- }
2917
- } catch (e) {
2918
- console.error(e);
2919
- setPullError((e as Error).message || 'Failed to load remote information');
2920
- } finally {
2921
- setPullLoadingRemotes(false);
2922
- setPullLoadingBranches(false);
2923
- }
2924
- }
2925
-
2926
- const handlePullRemoteChange = async (remote: string) => {
2927
- setPullSelectedRemote(remote);
2928
- setPullLoadingBranches(true);
2929
- setPullRemoteBranches([]);
2930
- setPullSelectedRemoteBranch('');
2931
-
2932
- try {
2933
- const branchesResult = await runGitAction({
2934
- repoPath,
2935
- action: 'get-remote-branches',
2936
- data: { remote }
2937
- });
2938
-
2939
- setPullRemoteBranches(branchesResult.branches || []);
2940
-
2941
- // Set default branch - tracking branch if on this remote
2942
- if (pullTrackingBranch?.remote === remote && pullTrackingBranch?.branch) {
2943
- setPullSelectedRemoteBranch(pullTrackingBranch.branch);
2944
- } else {
2945
- // No tracking branch on this remote - leave empty
2946
- setPullSelectedRemoteBranch('');
2947
- }
2948
- } catch (e) {
2949
- console.error(e);
2950
- setPullError((e as Error).message || 'Failed to load remote branches');
2951
- } finally {
2952
- setPullLoadingBranches(false);
2953
- }
2954
- }
2955
-
2956
- const handlePullFromRemote = async () => {
2957
- if (!pullBranch || !pullSelectedRemote || !pullSelectedRemoteBranch) return;
2958
-
2959
- setIsPulling(true);
2960
- setPullError(null);
2961
-
2962
- try {
2963
- await runGitAction({
2964
- repoPath,
2965
- action: 'pull-from-remote',
2966
- data: {
2967
- localBranch: pullBranch,
2968
- remote: pullSelectedRemote,
2969
- remoteBranch: pullSelectedRemoteBranch,
2970
- rebase: pullRebase,
2971
- }
2972
- });
2973
-
2974
- setIsPullOpen(false);
2975
- setPullBranch(null);
2976
- } catch (e) {
2977
- console.error(e);
2978
- setPullError((e as Error).message || 'Failed to pull from remote');
2979
- } finally {
2980
- setIsPulling(false);
2981
- }
2982
- }
2983
-
2984
- const confirmCheckoutToLocal = (remoteBranch: string) => {
2985
- setCheckoutRemoteBranch(remoteBranch);
2986
- // Extract the branch name from remotes/origin/branch-name
2987
- const parts = remoteBranch.split('/');
2988
- // Skip 'remotes' and remote name (e.g., 'origin'), take the rest as branch name
2989
- const branchName = parts.slice(2).join('/');
2990
- setCheckoutLocalBranchName(branchName);
2991
- setIsCheckoutToLocalOpen(true);
2992
- }
2993
-
2994
- const handleCheckoutToLocal = async () => {
2995
- if (!checkoutRemoteBranch || !checkoutLocalBranchName) return;
2996
- setIsCheckingOutToLocal(true);
2997
- try {
2998
- await runGitAction({
2999
- repoPath,
3000
- action: 'checkout-to-local',
3001
- data: { remoteBranch: checkoutRemoteBranch, localBranch: checkoutLocalBranchName }
3002
- });
3003
- setIsCheckoutToLocalOpen(false);
3004
- setCheckoutRemoteBranch(null);
3005
- setCheckoutLocalBranchName('');
3006
- } catch (e) {
3007
- console.error(e);
3008
- } finally {
3009
- setIsCheckingOutToLocal(false);
3010
- }
3011
- }
3012
-
3013
- const handleFetchFromAllRemotes = async () => {
3014
- setIsFetchingAllRemotes(true);
3015
- try {
3016
- await runGitAction({
3017
- repoPath,
3018
- action: 'fetch',
3019
- data: { allRemotes: true }
3020
- });
3021
- } catch (e) {
3022
- console.error(e);
3023
- } finally {
3024
- setIsFetchingAllRemotes(false);
3025
- }
3026
- }
3027
-
3028
- const handleFetchFromRemote = async (remote: string) => {
3029
- try {
3030
- await runGitAction({
3031
- repoPath,
3032
- action: 'fetch',
3033
- data: { remote }
3034
- });
3035
- } catch (e) {
3036
- console.error(e);
3037
- }
3038
- }
3039
-
3040
- const handleCheckout = async (branchName: string) => {
3041
-
3042
- try {
3043
- await runGitAction({
3044
- repoPath,
3045
- action: 'checkout',
3046
- data: { branch: branchName }
3047
- });
3048
- } catch (e) {
3049
- console.error(e);
3050
- }
3051
- };
3052
-
3053
- const handleResetToCommit = async (commitHash: string) => {
3054
- setResetCommitHash(commitHash);
3055
- setIsResetOpen(true);
3056
- };
3057
-
3058
- const handleConfirmReset = async () => {
3059
- if (!resetCommitHash) return;
3060
- setIsResetting(true);
3061
- try {
3062
- await runGitAction({
3063
- repoPath,
3064
- action: 'reset',
3065
- data: { commitHash: resetCommitHash, mode: 'hard' }
3066
- });
3067
- setIsResetOpen(false);
3068
- setResetCommitHash(null);
3069
- } catch (e) {
3070
- console.error(e);
3071
- } finally {
3072
- setIsResetting(false);
3073
- }
3074
- };
3075
-
3076
- const confirmRevertCommit = (commitHash: string, commitMessage: string) => {
3077
- setCommitToRevert({ hash: commitHash, message: commitMessage });
3078
- setIsRevertOpen(true);
3079
- };
3080
-
3081
- const handleConfirmRevert = async () => {
3082
- if (!commitToRevert) return;
3083
- setIsReverting(true);
3084
- try {
3085
- await runGitAction({
3086
- repoPath,
3087
- action: 'revert',
3088
- data: { commitHash: commitToRevert.hash }
3089
- });
3090
- setIsRevertOpen(false);
3091
- setCommitToRevert(null);
3092
- } catch (e) {
3093
- console.error(e);
3094
- } finally {
3095
- setIsReverting(false);
3096
- }
3097
- };
3098
-
3099
- const confirmRewordCommit = (hash: string, subject: string, body: string, branch: string) => {
3100
- setCommitToReword({ hash, subject, body, branch });
3101
- setNewMessageSubject(subject);
3102
- setNewMessageBody(body);
3103
- setIsRewordOpen(true);
3104
- };
3105
-
3106
- const handleReword = async () => {
3107
- if (!commitToReword || !newMessageSubject.trim()) return;
3108
- setIsRewording(true);
3109
- try {
3110
- await runGitAction({
3111
- repoPath,
3112
- action: 'reword',
3113
- data: {
3114
- commitHash: commitToReword.hash,
3115
- message: buildCommitMessage(newMessageSubject, newMessageBody),
3116
- branch: commitToReword.branch,
3117
- }
3118
- });
3119
- closeRewordDialog();
3120
- } catch (e) {
3121
- console.error(e);
3122
- } finally {
3123
- setIsRewording(false);
3124
- }
3125
- };
3126
-
3127
- const confirmCherryPickCommit = (commitHash: string, commitMessage: string) => {
3128
- setCommitsToCherryPick([{ hash: commitHash, message: commitMessage }]);
3129
- setIsCherryPickOpen(true);
3130
- };
3131
-
3132
- const confirmCherryPickSelectedCommits = () => {
3133
- if (selectedCommitsForCherryPick.length < 2) return;
3134
- setCommitsToCherryPick(selectedCommitsForCherryPick.map((commit) => ({
3135
- hash: commit.hash,
3136
- message: commit.message,
3137
- })));
3138
- setIsCherryPickOpen(true);
3139
- };
3140
-
3141
- const isCherryPickAlreadyInProgressError = (error: unknown) => {
3142
- if (!(error instanceof Error)) return false;
3143
- const message = error.message.toLowerCase();
3144
- return message.includes('cherry-pick') && message.includes('already in progress');
3145
- };
3146
-
3147
- const isCherryPickConflictError = (error: unknown) => {
3148
- if (!(error instanceof Error)) return false;
3149
- const message = error.message.toLowerCase();
3150
- return (
3151
- message.includes('could not apply') ||
3152
- message.includes('conflict') ||
3153
- message.includes('cherry-pick --continue')
3154
- );
3155
- };
3156
-
3157
- const runCherryPickByHashes = useCallback(async (commitHashes: string[]) => {
3158
- if (commitHashes.length === 1) {
3159
- await runGitAction({
3160
- repoPath,
3161
- action: 'cherry-pick',
3162
- data: { commitHash: commitHashes[0] }
3163
- });
3164
- return;
3165
- }
3166
-
3167
- await runGitAction({
3168
- repoPath,
3169
- action: 'cherry-pick-multiple',
3170
- data: { commitHashes }
3171
- });
3172
- }, [repoPath, runGitAction]);
3173
-
3174
- const abortCherryPickAndResetUi = useCallback(async () => {
3175
- try {
3176
- await runGitAction({
3177
- repoPath,
3178
- action: 'cherry-pick-abort',
3179
- });
3180
- } catch (abortError) {
3181
- console.error(abortError);
3182
- } finally {
3183
- setIsCherryPickOpen(false);
3184
- setIsAbortCherryPickOpen(false);
3185
- setCommitsToCherryPick([]);
3186
- }
3187
- }, [repoPath, runGitAction]);
3188
-
3189
- const handleCherryPickCommit = async () => {
3190
- if (commitsToCherryPick.length === 0) return;
3191
- setIsCherryPicking(true);
3192
- try {
3193
- const commitHashes = commitsToCherryPick.map((commit) => commit.hash);
3194
- await runCherryPickByHashes(commitHashes);
3195
- setIsCherryPickOpen(false);
3196
- setCommitsToCherryPick([]);
3197
- } catch (e) {
3198
- if (isCherryPickAlreadyInProgressError(e)) {
3199
- setIsCherryPickOpen(false);
3200
- setIsAbortCherryPickOpen(true);
3201
- } else if (isCherryPickConflictError(e)) {
3202
- await abortCherryPickAndResetUi();
3203
- }
3204
- console.error(e);
3205
- } finally {
3206
- setIsCherryPicking(false);
3207
- }
3208
- };
3209
-
3210
- const handleAbortCherryPick = async () => {
3211
- if (commitsToCherryPick.length === 0) {
3212
- setIsAbortCherryPickOpen(false);
3213
- return;
3214
- }
3215
-
3216
- setIsAbortingCherryPick(true);
3217
- try {
3218
- await runGitAction({
3219
- repoPath,
3220
- action: 'cherry-pick-abort',
3221
- });
3222
-
3223
- const commitHashes = commitsToCherryPick.map((commit) => commit.hash);
3224
- await runCherryPickByHashes(commitHashes);
3225
-
3226
- setIsAbortCherryPickOpen(false);
3227
- setCommitsToCherryPick([]);
3228
- } catch (e) {
3229
- if (isCherryPickAlreadyInProgressError(e)) {
3230
- setIsAbortCherryPickOpen(true);
3231
- } else if (isCherryPickConflictError(e)) {
3232
- await abortCherryPickAndResetUi();
3233
- }
3234
- console.error(e);
3235
- } finally {
3236
- setIsAbortingCherryPick(false);
3237
- }
3238
- };
3239
-
3240
- const handleCreateBranch = async () => {
3241
- if (!newBranchName) return;
3242
- setIsCreating(true);
3243
- try {
3244
- await runGitAction({
3245
- repoPath,
3246
- action: 'branch',
3247
- data: { branch: newBranchName, fromRef: createBranchFromRef || undefined }
3248
- });
3249
- setIsCreateBranchOpen(false);
3250
- setNewBranchName('');
3251
- setCreateBranchFromRef(null);
3252
- } catch (e) {
3253
- console.error(e);
3254
- // alert or toast error
3255
- } finally {
3256
- setIsCreating(false);
3257
- }
3258
- };
3259
-
3260
- const confirmCreateBranch = (sourceBranch?: string) => {
3261
- setCreateBranchFromRef(sourceBranch || null);
3262
- setIsCreateBranchOpen(true);
3263
- };
3264
-
3265
- const confirmCreateTag = (commitHash: string) => {
3266
- setCreateTagCommitHash(commitHash);
3267
- setNewTagName('');
3268
- setPushTagToRemote(false);
3269
- setIsCreateTagOpen(true);
3270
- };
3271
-
3272
- const handleCreateTag = async () => {
3273
- if (!createTagCommitHash || !newTagName.trim()) return;
3274
- setIsCreatingTag(true);
3275
- try {
3276
- await runGitAction({
3277
- repoPath,
3278
- action: 'create-tag',
3279
- data: {
3280
- tagName: newTagName.trim(),
3281
- commitHash: createTagCommitHash,
3282
- pushToRemote: pushTagToRemote,
3283
- }
3284
- });
3285
- setIsCreateTagOpen(false);
3286
- setCreateTagCommitHash(null);
3287
- setNewTagName('');
3288
- setPushTagToRemote(false);
3289
- } catch (e) {
3290
- console.error(e);
3291
- } finally {
3292
- setIsCreatingTag(false);
3293
- }
3294
- };
3295
-
3296
- const currentBranch = branchData?.current;
3297
- const trackingInfoByBranch = branchData?.trackingInfo;
3298
- const remoteNames = useMemo(() => {
3299
- const names = Object.keys(branchData?.remoteUrls ?? {});
3300
- return names.sort((a, b) => {
3301
- if (a === 'origin') return -1;
3302
- if (b === 'origin') return 1;
3303
- return a.localeCompare(b);
3304
- });
3305
- }, [branchData?.remoteUrls]);
3306
- const remoteNameForTagDelete = remoteNames[0] || null;
3307
- const selectedTrackingUpstreams = useMemo(() => {
3308
- const upstreams: string[] = [];
3309
- for (const branchRef of branchesToDelete) {
3310
- if (branchRef.startsWith('remotes/')) continue;
3311
- const upstream = trackingInfoByBranch?.[branchRef]?.upstream;
3312
- if (upstream) upstreams.push(upstream);
3313
- }
3314
- return Array.from(new Set(upstreams));
3315
- }, [branchesToDelete, trackingInfoByBranch]);
3316
- const trackingInfoForRename = useMemo(() => {
3317
- if (!branchToRename || remoteBranchToRename) return null;
3318
- return trackingInfoByBranch?.[branchToRename] ?? null;
3319
- }, [branchToRename, remoteBranchToRename, trackingInfoByBranch]);
3320
- const localChangesCount = statusData?.files?.length;
3321
- const currentBranchName = currentBranch || (isBranchesLoading ? 'Loading branches...' : 'Detached HEAD');
3322
- const currentBranchLabel = currentBranch && typeof localChangesCount === 'number' && localChangesCount > 0
3323
- ? `${currentBranch} (${localChangesCount})`
3324
- : currentBranchName;
3325
- const currentTrackingBranch = useMemo(() => {
3326
- if (!currentBranch) return null;
3327
- const tracking = trackingInfoByBranch?.[currentBranch];
3328
- if (!tracking?.upstream) return null;
3329
- const slashIndex = tracking.upstream.indexOf('/');
3330
- if (slashIndex <= 0) return null;
3331
-
3332
- return {
3333
- upstream: tracking.upstream,
3334
- remote: tracking.upstream.slice(0, slashIndex),
3335
- branch: tracking.upstream.slice(slashIndex + 1),
3336
- };
3337
- }, [currentBranch, trackingInfoByBranch]);
3338
- const pullActionDisabledReason = useMemo(() => {
3339
- if (isBranchesLoading) return 'Loading branches...';
3340
- if (!currentBranch) return 'Not on a local branch';
3341
- if (!currentTrackingBranch) return `Branch "${currentBranch}" has no tracking remote branch`;
3342
- return null;
3343
- }, [currentBranch, currentTrackingBranch, isBranchesLoading]);
3344
- const pushActionDisabledReason = useMemo(() => {
3345
- if (isBranchesLoading) return 'Loading branches...';
3346
- if (!currentBranch) return 'Not on a local branch';
3347
- return null;
3348
- }, [currentBranch, isBranchesLoading]);
3349
-
3350
- const confirmPullCurrentBranch = () => {
3351
- if (!currentBranch || pullActionDisabledReason) return;
3352
- void confirmPullFromRemote(currentBranch);
3353
- };
3354
-
3355
- const confirmPushCurrentBranch = () => {
3356
- if (!currentBranch || pushActionDisabledReason) return;
3357
- void confirmPushToRemote(currentBranch);
3358
- };
3359
-
3360
- const getBranchContextMenuItems = (options: BranchMenuOptions): ContextMenuItem[] => {
3361
- const menuItems = buildBranchContextMenuItems(options, {
3362
- onCheckout: handleCheckout,
3363
- onCheckoutToLocal: confirmCheckoutToLocal,
3364
- onCreateBranch: confirmCreateBranch,
3365
- onDeleteBranch: confirmDeleteBranch,
3366
- onDeleteBranches: confirmDeleteBranches,
3367
- onRenameBranch: confirmRenameBranch,
3368
- onRenameRemoteBranch: confirmRenameRemoteBranch,
3369
- onRebase: confirmRebase,
3370
- onMerge: confirmMerge,
3371
- onPushToRemote: confirmPushToRemote,
3372
- onPullFromRemote: confirmPullFromRemote,
3373
- });
3374
-
3375
- if (customBranchScripts.length > 0) {
3376
- menuItems.push({
3377
- label: 'Custom scripts',
3378
- children: customBranchScripts.map((script) => ({
3379
- label: script.name,
3380
- onClick: () => {
3381
- void handleRunCustomScript(script, options.branchRef);
3382
- },
3383
- })),
3384
- });
3385
- }
3386
-
3387
- return menuItems;
3388
- };
3389
-
3390
- const localBranchSet = useMemo(() => {
3391
- return new Set(branchData?.branches ?? []);
3392
- }, [branchData?.branches]);
3393
-
3394
- const remoteBranchMap = useMemo(() => {
3395
- const map = new Map<string, string>();
3396
- if (!branchData?.remotes) return map;
3397
-
3398
- for (const [remoteName, branches] of Object.entries(branchData.remotes)) {
3399
- for (const branch of branches) {
3400
- map.set(`${remoteName}/${branch}`, `remotes/${remoteName}/${branch}`);
3401
- }
3402
- }
3403
-
3404
- return map;
3405
- }, [branchData?.remotes]);
3406
-
3407
- const getBranchTagContextMenuItems = (displayRef: string): ContextMenuItem[] | null => {
3408
- if (displayRef.startsWith('tag:')) {
3409
- const tagName = displayRef.replace(/^tag:\s*/, '').trim();
3410
- if (!tagName) return null;
3411
- return [
3412
- {
3413
- label: 'Delete Tag',
3414
- onClick: () => confirmDeleteTag(tagName),
3415
- danger: true,
3416
- },
3417
- ];
3418
- }
3419
-
3420
- if (localBranchSet.has(displayRef)) {
3421
- return getBranchContextMenuItems({
3422
- branchRef: displayRef,
3423
- branchLeafName: displayRef.split('/').pop() || displayRef,
3424
- currentBranch,
3425
- isRemote: false,
3426
- });
3427
- }
3428
-
3429
- const remoteBranchRef = remoteBranchMap.get(displayRef);
3430
- if (!remoteBranchRef) return null;
3431
-
3432
- return getBranchContextMenuItems({
3433
- branchRef: remoteBranchRef,
3434
- branchLeafName: remoteBranchRef.split('/').pop() || displayRef,
3435
- currentBranch,
3436
- isRemote: true,
3437
- });
3438
- };
3439
- const localGroupBranchRefs = useMemo(() => {
3440
- if (!localBranchTree) return [];
3441
- return collectAllBranchRefs(localBranchTree).filter((branchRef) => branchRef !== currentBranch);
3442
- }, [localBranchTree, currentBranch]);
3443
-
3444
- const branchTreePopoverContent = (
3445
- <div className="w-[22rem] max-w-[calc(100vw-2rem)] flex flex-col border border-base-300 bg-base-100 rounded-box shadow-xl overflow-hidden">
3446
- <div className="px-4 border-b border-base-300 flex items-center justify-between bg-base-100 h-[57px] shrink-0">
3447
- <h2 className="font-bold text-lg">Branches</h2>
3448
- <div className="flex items-center gap-1">
3449
- {hasVisibilityFilters && (
3450
- <div className="tooltip tooltip-left z-20" data-tip="Clear filters">
3451
- <button
3452
- className="btn btn-ghost btn-xs btn-square"
3453
- onClick={handleClearAllFilters}
3454
- >
3455
- <i className="iconoir-filter text-[16px]" aria-hidden="true" />
3456
- </button>
3457
- </div>
3458
- )}
3459
- <div className="tooltip tooltip-left z-20" data-tip="Create Branch">
3460
- <button className="btn btn-ghost btn-xs btn-square" onClick={() => confirmCreateBranch()}>
3461
- <i className="iconoir-plus-circle text-[16px]" aria-hidden="true" />
3462
- </button>
3463
- </div>
3464
- </div>
3465
- </div>
3466
- <div className="max-h-[70vh] overflow-auto">
3467
- <div className="p-2 space-y-0.5">
3468
- {localBranchTree && (
3469
- <>
3470
- <ContextMenu
3471
- items={[
3472
- {
3473
- label: 'Delete',
3474
- onClick: () => confirmDeleteBranches(localGroupBranchRefs),
3475
- danger: true,
3476
- disabled: localGroupBranchRefs.length === 0,
3477
- },
3478
- ]}
3479
- >
3480
- <GroupHeader
3481
- name="Branches"
3482
- groupPath="__local__"
3483
- icon={<i className="iconoir-git-branch text-[14px]" aria-hidden="true" />}
3484
- isExpanded={localGroupExpanded}
3485
- onToggle={handleToggleLocalGroup}
3486
- visibilityMap={visibilityMap}
3487
- onToggleVisibility={handleToggleVisibility}
3488
- />
3489
- </ContextMenu>
3490
- {localGroupExpanded && (
3491
- <BranchTreeItem
3492
- node={localBranchTree}
3493
- currentBranch={branchData?.current}
3494
- expandedFolders={expandedFolders}
3495
- onToggleFolder={toggleFolder}
3496
- onCheckout={handleCheckout}
3497
- onCheckoutToLocal={confirmCheckoutToLocal}
3498
- onCreateBranch={() => confirmCreateBranch()}
3499
- onDeleteBranch={confirmDeleteBranch}
3500
- onRenameBranch={confirmRenameBranch}
3501
- onRenameRemoteBranch={confirmRenameRemoteBranch}
3502
- onRebase={confirmRebase}
3503
- onMerge={confirmMerge}
3504
- onPushToRemote={confirmPushToRemote}
3505
- onPullFromRemote={confirmPullFromRemote}
3506
- getBranchContextMenuItems={getBranchContextMenuItems}
3507
- onBranchClick={handleBranchClick}
3508
- onBranchContextMenu={handleBranchContextMenu}
3509
- onDeleteBranchGroup={confirmDeleteBranches}
3510
- selectedBranches={selectedBranchSet}
3511
- visibilityMap={visibilityMap}
3512
- onToggleVisibility={handleToggleVisibility}
3513
- depth={1}
3514
- groupPath="__local__"
3515
- trackingInfo={branchData?.trackingInfo}
3516
- />
3517
- )}
3518
- </>
3519
- )}
3520
-
3521
- <>
3522
- <ContextMenu
3523
- items={[
3524
- { label: 'Fetch from all remotes', onClick: handleFetchFromAllRemotes },
3525
- { label: 'Add remote', onClick: confirmAddRemote },
3526
- ]}
3527
- >
3528
- <GroupHeader
3529
- name="Remotes"
3530
- groupPath="__remotes__"
3531
- icon={<i className="iconoir-globe text-[14px]" aria-hidden="true" />}
3532
- isExpanded={remotesGroupExpanded}
3533
- onToggle={handleToggleRemotesGroup}
3534
- visibilityMap={visibilityMap}
3535
- onToggleVisibility={handleToggleVisibility}
3536
- />
3537
- </ContextMenu>
3538
- {remotesGroupExpanded && isBranchesLoading && !remoteBranchTrees && (
3539
- <div className="flex items-center gap-2 px-2 py-2 text-sm opacity-70" style={{ paddingLeft: '20px' }}>
3540
- <span className="loading loading-spinner loading-xs"></span>
3541
- <span>Loading remotes...</span>
3542
- </div>
3543
- )}
3544
- {remotesGroupExpanded && remoteBranchTrees && Array.from(remoteBranchTrees.entries()).map(([remoteName, tree]) => {
3545
- const remoteGroupPath = `__remotes__/${remoteName}`;
3546
- const isRemoteExpanded = expandedFolders.has(remoteGroupPath);
3547
-
3548
- return (
3549
- <div key={remoteName}>
3550
- <ContextMenu
3551
- items={[
3552
- { label: `Fetch from ${remoteName}`, onClick: () => handleFetchFromRemote(remoteName) },
3553
- { label: 'Rename', onClick: () => confirmRenameRemote(remoteName) },
3554
- {
3555
- label: 'Delete',
3556
- onClick: () => confirmDeleteRemote(remoteName),
3557
- danger: true,
3558
- },
3559
- ]}
3560
- >
3561
- <GroupHeader
3562
- name={remoteName}
3563
- groupPath={remoteGroupPath}
3564
- icon={<i className="iconoir-globe text-[14px] opacity-50" aria-hidden="true" />}
3565
- isExpanded={isRemoteExpanded}
3566
- onToggle={() => toggleFolder(remoteGroupPath)}
3567
- visibilityMap={visibilityMap}
3568
- onToggleVisibility={handleToggleVisibility}
3569
- depth={1}
3570
- />
3571
- </ContextMenu>
3572
- {isRemoteExpanded && (
3573
- <BranchTreeItem
3574
- node={tree}
3575
- currentBranch={branchData?.current}
3576
- expandedFolders={expandedFolders}
3577
- onToggleFolder={toggleFolder}
3578
- onCheckout={handleCheckout}
3579
- onCheckoutToLocal={confirmCheckoutToLocal}
3580
- onCreateBranch={() => confirmCreateBranch()}
3581
- onDeleteBranch={confirmDeleteBranch}
3582
- onRenameBranch={confirmRenameBranch}
3583
- onRenameRemoteBranch={confirmRenameRemoteBranch}
3584
- onRebase={confirmRebase}
3585
- onMerge={confirmMerge}
3586
- onPushToRemote={confirmPushToRemote}
3587
- onPullFromRemote={confirmPullFromRemote}
3588
- getBranchContextMenuItems={getBranchContextMenuItems}
3589
- onBranchClick={handleBranchClick}
3590
- onBranchContextMenu={handleBranchContextMenu}
3591
- onDeleteBranchGroup={confirmDeleteBranches}
3592
- selectedBranches={selectedBranchSet}
3593
- visibilityMap={visibilityMap}
3594
- onToggleVisibility={handleToggleVisibility}
3595
- depth={2}
3596
- groupPath={remoteGroupPath}
3597
- isRemote={true}
3598
- trackingInfo={branchData?.trackingInfo}
3599
- />
3600
- )}
3601
- </div>
3602
- );
3603
- })}
3604
- </>
3605
- </div>
3606
- </div>
3607
- </div>
3608
- );
3609
-
3610
- if (isLoading && limit === 100) {
3611
- return <div className="flex items-center justify-center p-8 h-full"><span className="loading loading-spinner text-base-content/50"></span></div>;
3612
- }
3613
-
3614
- if (isError) {
3615
- return (
3616
- <div className="flex items-center justify-center p-8 h-full flex-col gap-4">
3617
- <p className="text-error font-medium">Error Loading History</p>
3618
- <p className="text-sm opacity-70">{(error as Error)?.message || 'An unknown error occurred'}</p>
3619
- <button onClick={() => refetch()} className="btn btn-outline btn-sm">
3620
- <i className="iconoir-refresh-circle text-[16px] mr-1" aria-hidden="true" />
3621
- Try Again
3622
- </button>
3623
- </div>
3624
- );
3625
- }
3626
-
3627
- if (!log) return <div className="flex items-center justify-center p-8 h-full opacity-70">No history data available</div>;
3628
-
3629
- return (
3630
- <div className="flex h-full overflow-hidden">
3631
- {isResetOpen && (
3632
- <dialog className="modal modal-open">
3633
- <div className="modal-box">
3634
- <h3 className="font-bold text-lg">Reset to Commit</h3>
3635
- <p className="py-4 break-words">
3636
- Are you sure you want to hard reset the current branch to commit <span className="font-mono bg-base-200 px-1 rounded">{resetCommitHash?.substring(0, 7)}</span>?
3637
- <br/>
3638
- <span className="text-error font-bold">Warning: This will discard all local changes and commits after this point. This action cannot be undone.</span>
3639
- </p>
3640
- <div className="modal-action">
3641
- <button className="btn" onClick={() => setIsResetOpen(false)} disabled={isResetting}>Cancel</button>
3642
- <button className="btn btn-error" onClick={handleConfirmReset} disabled={isResetting}>
3643
- {isResetting && <span className="loading loading-spinner loading-xs"></span>}
3644
- Reset
3645
- </button>
3646
- </div>
3647
- </div>
3648
- <form method="dialog" className="modal-backdrop">
3649
- <button onClick={() => setIsResetOpen(false)}>close</button>
3650
- </form>
3651
- </dialog>
3652
- )}
3653
-
3654
- {isRevertOpen && (
3655
- <dialog className="modal modal-open">
3656
- <div className="modal-box">
3657
- <h3 className="font-bold text-lg">Revert Commit</h3>
3658
- <p className="py-4 break-words">
3659
- Are you sure you want to revert commit <span className="font-mono bg-base-200 px-1 rounded">{commitToRevert?.hash.substring(0, 7)}</span> on <span className="font-bold break-all">{branchData?.current || 'current'}</span>?
3660
- </p>
3661
- {commitToRevert?.message && (
3662
- <div className="rounded border border-base-300 bg-base-200/40 px-3 py-2 text-xs break-words">
3663
- {commitToRevert.message}
3664
- </div>
3665
- )}
3666
- <div className="modal-action">
3667
- <button
3668
- className="btn"
3669
- onClick={() => {
3670
- setIsRevertOpen(false);
3671
- setCommitToRevert(null);
3672
- }}
3673
- disabled={isReverting}
3674
- >
3675
- Cancel
3676
- </button>
3677
- <button className="btn btn-primary" onClick={handleConfirmRevert} disabled={isReverting}>
3678
- {isReverting && <span className="loading loading-spinner loading-xs"></span>}
3679
- Revert
3680
- </button>
3681
- </div>
3682
- </div>
3683
- <form method="dialog" className="modal-backdrop">
3684
- <button
3685
- onClick={() => {
3686
- setIsRevertOpen(false);
3687
- setCommitToRevert(null);
3688
- }}
3689
- >
3690
- close
3691
- </button>
3692
- </form>
3693
- </dialog>
3694
- )}
3695
-
3696
- {isRewordOpen && (
3697
- <dialog className="modal modal-open">
3698
- <div className="modal-box">
3699
- <h3 className="font-bold text-lg">Reword Commit</h3>
3700
- <p className="py-4 break-words">
3701
- Reword commit <span className="font-mono bg-base-200 px-1 rounded">{commitToReword?.hash.substring(0, 7)}</span> on branch <span className="font-bold">{commitToReword?.branch}</span>.
3702
- </p>
3703
- <input
3704
- type="text"
3705
- className="input input-bordered w-full font-mono text-sm mb-3"
3706
- value={newMessageSubject}
3707
- onChange={e => setNewMessageSubject(e.target.value)}
3708
- autoFocus
3709
- onKeyDown={e => {
3710
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && newMessageSubject.trim() && !isRewording) {
3711
- e.preventDefault();
3712
- handleReword();
3713
- }
3714
- }}
3715
- placeholder="Commit subject"
3716
- disabled={isRewording}
3717
- />
3718
- <textarea
3719
- className="textarea textarea-bordered w-full h-32 font-mono text-sm"
3720
- value={newMessageBody}
3721
- onChange={e => setNewMessageBody(e.target.value)}
3722
- onKeyDown={e => {
3723
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && newMessageSubject.trim() && !isRewording) {
3724
- e.preventDefault();
3725
- handleReword();
3726
- }
3727
- }}
3728
- placeholder="Commit message body (optional)"
3729
- disabled={isRewording}
3730
- />
3731
- {commitToReword?.branch !== branchData?.current && (
3732
- <div className="alert alert-warning text-xs mt-2 py-2">
3733
- <span>This will briefly checkout <b>{commitToReword?.branch}</b> to amend the commit.</span>
3734
- </div>
3735
- )}
3736
- <div className="modal-action">
3737
- <button className="btn" onClick={closeRewordDialog} disabled={isRewording}>Cancel</button>
3738
- <button className="btn btn-primary" onClick={handleReword} disabled={!newMessageSubject.trim() || isRewording}>
3739
- {isRewording && <span className="loading loading-spinner loading-xs"></span>}
3740
- Reword
3741
- </button>
3742
- </div>
3743
- </div>
3744
- <form method="dialog" className="modal-backdrop">
3745
- <button onClick={closeRewordDialog}>close</button>
3746
- </form>
3747
- </dialog>
3748
- )}
3749
-
3750
- {isDeleteOpen && (
3751
- <dialog className="modal modal-open">
3752
- <div className="modal-box">
3753
- <h3 className="font-bold text-lg">{branchesToDelete.length > 1 ? 'Delete Branches' : 'Delete Branch'}</h3>
3754
- {branchesToDelete.length > 1 ? (
3755
- <div className="py-4 space-y-3">
3756
- <p className="break-words">
3757
- Are you sure you want to delete <span className="font-bold">{branchesToDelete.length} selected branches</span>?
3758
- This action cannot be undone.
3759
- </p>
3760
- <div className="max-h-44 overflow-auto rounded border border-base-300 bg-base-200/40 p-2 space-y-1">
3761
- {branchesToDelete.map((branch) => (
3762
- <div key={branch} className="text-xs min-w-0 break-all">
3763
- {branch.startsWith('remotes/') ? branch.slice('remotes/'.length) : branch}
3764
- </div>
3765
- ))}
3766
- </div>
3767
- </div>
3768
- ) : (
3769
- <p className="py-4 break-words">
3770
- Are you sure you want to delete the branch <span className="font-bold break-all">{branchesToDelete[0]?.startsWith('remotes/') ? branchesToDelete[0].slice('remotes/'.length) : branchesToDelete[0]}</span>?
3771
- This action cannot be undone.
3772
- </p>
3773
- )}
3774
- {selectedTrackingUpstreams.length > 0 && (
3775
- <div className="form-control">
3776
- <label className="label cursor-pointer justify-start items-start gap-2 min-w-0">
3777
- <input type="checkbox" className="checkbox checkbox-sm" checked={deleteRemoteBranch} onChange={(e) => setDeleteRemoteBranch(e.target.checked)} disabled={isDeleting} />
3778
- <span className="label-text break-words whitespace-normal">
3779
- {selectedTrackingUpstreams.length === 1 ? (
3780
- <>
3781
- Delete tracking remote branch <span className="font-mono opacity-70 break-all">{selectedTrackingUpstreams[0]}</span>
3782
- </>
3783
- ) : (
3784
- <>Delete {selectedTrackingUpstreams.length} tracking remote branches</>
3785
- )}
3786
- </span>
3787
- </label>
3788
- </div>
3789
- )}
3790
- <div className="modal-action">
3791
- <button className="btn" onClick={() => { setIsDeleteOpen(false); setBranchesToDelete([]); setDeleteRemoteBranch(false); }} disabled={isDeleting}>Cancel</button>
3792
- <button className="btn btn-error" onClick={handleDeleteBranch} disabled={isDeleting}>
3793
- {isDeleting && <span className="loading loading-spinner loading-xs"></span>}
3794
- Delete
3795
- </button>
3796
- </div>
3797
- </div>
3798
- <form method="dialog" className="modal-backdrop">
3799
- <button onClick={() => { setIsDeleteOpen(false); setBranchesToDelete([]); setDeleteRemoteBranch(false); }}>close</button>
3800
- </form>
3801
- </dialog>
3802
- )}
3803
-
3804
- {isDeleteTagOpen && (
3805
- <dialog className="modal modal-open">
3806
- <div className="modal-box">
3807
- <h3 className="font-bold text-lg">Delete Tag</h3>
3808
- <p className="py-4 break-words">
3809
- Are you sure you want to delete the tag <span className="font-bold break-all">{tagToDelete}</span>?
3810
- This action cannot be undone.
3811
- </p>
3812
- <div className="form-control">
3813
- <label className="label cursor-pointer justify-start items-start gap-2 min-w-0">
3814
- <input
3815
- type="checkbox"
3816
- className="checkbox checkbox-sm"
3817
- checked={deleteRemoteTag}
3818
- onChange={(e) => setDeleteRemoteTag(e.target.checked)}
3819
- disabled={isDeletingTag || !remoteNameForTagDelete}
3820
- />
3821
- <span className="label-text break-words whitespace-normal">
3822
- {remoteNameForTagDelete ? (
3823
- <>
3824
- Also delete from remote <span className="font-mono opacity-70">{remoteNameForTagDelete}</span>
3825
- </>
3826
- ) : (
3827
- 'No remote configured'
3828
- )}
3829
- </span>
3830
- </label>
3831
- </div>
3832
- <div className="modal-action">
3833
- <button className="btn" onClick={closeDeleteTagDialog} disabled={isDeletingTag}>Cancel</button>
3834
- <button className="btn btn-error" onClick={handleDeleteTag} disabled={isDeletingTag || !tagToDelete}>
3835
- {isDeletingTag && <span className="loading loading-spinner loading-xs"></span>}
3836
- Delete
3837
- </button>
3838
- </div>
3839
- </div>
3840
- <form method="dialog" className="modal-backdrop">
3841
- <button onClick={closeDeleteTagDialog}>close</button>
3842
- </form>
3843
- </dialog>
3844
- )}
3845
-
3846
- {isCherryPickOpen && (
3847
- <dialog className="modal modal-open">
3848
- <div className="modal-box">
3849
- <h3 className="font-bold text-lg">Cherry Pick</h3>
3850
- <p className="text-sm opacity-70 mt-1">
3851
- {commitsToCherryPick.length > 1
3852
- ? 'Apply selected commits from oldest to newest'
3853
- : 'Apply changes from the selected commit'}
3854
- </p>
3855
- {commitsToCherryPick.length === 1 ? (
3856
- <p className="py-4 break-words">
3857
- Are you sure to apply <span className="font-bold font-mono break-all">{commitsToCherryPick[0]?.hash}</span> <span className="font-bold break-words">{commitsToCherryPick[0]?.message}</span> to <span className="font-bold break-all">{branchData?.current || 'current'}</span> branch?
3858
- </p>
3859
- ) : (
3860
- <div className="py-4 space-y-3">
3861
- <p className="break-words">
3862
- Are you sure to apply <span className="font-bold">{commitsToCherryPick.length} selected commits</span> to <span className="font-bold break-all">{branchData?.current || 'current'}</span> branch?
3863
- </p>
3864
- <div className="max-h-44 overflow-auto rounded border border-base-300 bg-base-200/40 p-2 space-y-1">
3865
- {commitsToCherryPick.map((commit) => (
3866
- <div key={commit.hash} className="text-xs min-w-0">
3867
- <span className="font-mono opacity-70">{commit.hash.slice(0, 7)}</span>{' '}
3868
- <span className="break-words">{commit.message}</span>
3869
- </div>
3870
- ))}
3871
- </div>
3872
- </div>
3873
- )}
3874
- <div className="modal-action">
3875
- <button
3876
- className="btn"
3877
- onClick={() => {
3878
- setIsCherryPickOpen(false);
3879
- setCommitsToCherryPick([]);
3880
- }}
3881
- disabled={isCherryPicking}
3882
- >
3883
- Cancel
3884
- </button>
3885
- <button className="btn btn-primary" onClick={handleCherryPickCommit} disabled={isCherryPicking}>
3886
- {isCherryPicking && <span className="loading loading-spinner loading-xs"></span>}
3887
- Confirm
3888
- </button>
3889
- </div>
3890
- </div>
3891
- <form method="dialog" className="modal-backdrop">
3892
- <button
3893
- onClick={() => {
3894
- setIsCherryPickOpen(false);
3895
- setCommitsToCherryPick([]);
3896
- }}
3897
- >
3898
- close
3899
- </button>
3900
- </form>
3901
- </dialog>
3902
- )}
3903
-
3904
- {isAbortCherryPickOpen && (
3905
- <dialog className="modal modal-open">
3906
- <div className="modal-box">
3907
- <h3 className="font-bold text-lg">Cherry Pick In Progress</h3>
3908
- <p className="text-sm opacity-70 mt-1">Another cherry-pick operation is currently in progress.</p>
3909
- <p className="py-4 break-words">
3910
- Abort the in-progress cherry-pick and continue with {commitsToCherryPick.length > 1 ? 'the selected commits' : 'this commit'}?
3911
- </p>
3912
- <div className="modal-action">
3913
- <button
3914
- className="btn"
3915
- onClick={() => {
3916
- setIsAbortCherryPickOpen(false);
3917
- setCommitsToCherryPick([]);
3918
- }}
3919
- disabled={isAbortingCherryPick}
3920
- >
3921
- Cancel
3922
- </button>
3923
- <button
3924
- className="btn btn-warning"
3925
- onClick={() => void handleAbortCherryPick()}
3926
- disabled={isAbortingCherryPick}
3927
- >
3928
- {isAbortingCherryPick && <span className="loading loading-spinner loading-xs"></span>}
3929
- Abort and Continue
3930
- </button>
3931
- </div>
3932
- </div>
3933
- <form method="dialog" className="modal-backdrop">
3934
- <button
3935
- onClick={() => {
3936
- setIsAbortCherryPickOpen(false);
3937
- setCommitsToCherryPick([]);
3938
- }}
3939
- >
3940
- close
3941
- </button>
3942
- </form>
3943
- </dialog>
3944
- )}
3945
-
3946
- {isRenameOpen && (
3947
- <dialog className="modal modal-open">
3948
- <div className="modal-box">
3949
- <h3 className="font-bold text-lg">{remoteBranchToRename ? 'Rename Remote Branch' : 'Rename Branch'}</h3>
3950
- <p className="py-4 break-words">
3951
- Enter a new name for the branch <span className="font-bold break-all">{branchToRename}</span>. Press <kbd className="kbd kbd-sm">Cmd</kbd>+<kbd className="kbd kbd-sm">Enter</kbd> to confirm.
3952
- </p>
3953
- <input
3954
- type="text"
3955
- className="input input-bordered w-full"
3956
- value={newBranchNameForRename}
3957
- onChange={e => setNewBranchNameForRename(sanitizeBranchName(e.target.value))}
3958
- placeholder="New branch name"
3959
- disabled={isRenaming}
3960
- autoFocus
3961
- onKeyDown={e => {
3962
- const shortcutPressed = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
3963
- const sameName = remoteBranchToRename
3964
- ? newBranchNameForRename === remoteBranchToRename.branch
3965
- : newBranchNameForRename === branchToRename;
3966
- if (shortcutPressed && newBranchNameForRename && !sameName && !isRenaming) {
3967
- handleRenameBranch();
3968
- }
3969
- }}
3970
- />
3971
- {!remoteBranchToRename && trackingInfoForRename?.upstream && (
3972
- <div className="form-control mt-2">
3973
- <label className="label cursor-pointer justify-start items-start gap-2 min-w-0">
3974
- <input
3975
- type="checkbox"
3976
- className="checkbox checkbox-sm"
3977
- checked={renameTrackingRemoteBranch}
3978
- onChange={(e) => setRenameTrackingRemoteBranch(e.target.checked)}
3979
- disabled={isRenaming}
3980
- />
3981
- <span className="label-text break-words whitespace-normal">
3982
- Also rename tracking remote branch <span className="font-mono opacity-70 break-all">{trackingInfoForRename.upstream}</span>
3983
- </span>
3984
- </label>
3985
- </div>
3986
- )}
3987
- <div className="modal-action">
3988
- <button
3989
- className="btn"
3990
- onClick={closeRenameBranchDialog}
3991
- disabled={isRenaming}
3992
- >
3993
- Cancel
3994
- </button>
3995
- <button
3996
- className="btn btn-primary"
3997
- onClick={handleRenameBranch}
3998
- disabled={
3999
- !newBranchNameForRename ||
4000
- (remoteBranchToRename
4001
- ? newBranchNameForRename === remoteBranchToRename.branch
4002
- : newBranchNameForRename === branchToRename) ||
4003
- isRenaming
4004
- }
4005
- >
4006
- {isRenaming && <span className="loading loading-spinner loading-xs"></span>}
4007
- Rename
4008
- </button>
4009
- </div>
4010
- </div>
4011
- <form method="dialog" className="modal-backdrop">
4012
- <button onClick={closeRenameBranchDialog}>
4013
- close
4014
- </button>
4015
- </form>
4016
- </dialog>
4017
- )}
4018
-
4019
- {isRenameRemoteOpen && (
4020
- <dialog className="modal modal-open">
4021
- <div className="modal-box">
4022
- <h3 className="font-bold text-lg">Rename Remote</h3>
4023
- <p className="py-4 break-words">
4024
- Enter a new name for remote <span className="font-bold break-all">{remoteToRename}</span>. Press <kbd className="kbd kbd-sm">Cmd</kbd>+<kbd className="kbd kbd-sm">Enter</kbd> to confirm.
4025
- </p>
4026
- <input
4027
- type="text"
4028
- className="input input-bordered w-full"
4029
- value={newRemoteNameForRename}
4030
- onChange={(e) => setNewRemoteNameForRename(e.target.value)}
4031
- placeholder="New remote name"
4032
- disabled={isRenamingRemote}
4033
- autoFocus
4034
- onKeyDown={(e) => {
4035
- const shortcutPressed = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
4036
- const trimmedNewName = newRemoteNameForRename.trim();
4037
- const sameName = trimmedNewName === (remoteToRename ?? '').trim();
4038
- if (shortcutPressed && trimmedNewName && !sameName && !isRenamingRemote) {
4039
- handleRenameRemote();
4040
- }
4041
- }}
4042
- />
4043
- <div className="modal-action">
4044
- <button
4045
- className="btn"
4046
- onClick={closeRenameRemoteDialog}
4047
- disabled={isRenamingRemote}
4048
- >
4049
- Cancel
4050
- </button>
4051
- <button
4052
- className="btn btn-primary"
4053
- onClick={handleRenameRemote}
4054
- disabled={
4055
- !newRemoteNameForRename.trim() ||
4056
- newRemoteNameForRename.trim() === (remoteToRename ?? '').trim() ||
4057
- isRenamingRemote
4058
- }
4059
- >
4060
- {isRenamingRemote && <span className="loading loading-spinner loading-xs"></span>}
4061
- Rename
4062
- </button>
4063
- </div>
4064
- </div>
4065
- <form method="dialog" className="modal-backdrop">
4066
- <button onClick={closeRenameRemoteDialog}>
4067
- close
4068
- </button>
4069
- </form>
4070
- </dialog>
4071
- )}
4072
-
4073
- {isDeleteRemoteOpen && (
4074
- <dialog className="modal modal-open">
4075
- <div className="modal-box">
4076
- <h3 className="font-bold text-lg">Delete Remote</h3>
4077
- <p className="py-4 break-words">
4078
- Are you sure you want to delete remote <span className="font-bold break-all">{remoteToDelete}</span>?
4079
- This removes all tracking branches for that remote.
4080
- </p>
4081
- <div className="modal-action">
4082
- <button
4083
- className="btn"
4084
- onClick={closeDeleteRemoteDialog}
4085
- disabled={isDeletingRemote}
4086
- >
4087
- Cancel
4088
- </button>
4089
- <button
4090
- className="btn btn-error"
4091
- onClick={handleDeleteRemote}
4092
- disabled={!remoteToDelete || isDeletingRemote}
4093
- >
4094
- {isDeletingRemote && <span className="loading loading-spinner loading-xs"></span>}
4095
- Delete
4096
- </button>
4097
- </div>
4098
- </div>
4099
- <form method="dialog" className="modal-backdrop">
4100
- <button onClick={closeDeleteRemoteDialog}>
4101
- close
4102
- </button>
4103
- </form>
4104
- </dialog>
4105
- )}
4106
-
4107
- {isAddRemoteOpen && (
4108
- <dialog className="modal modal-open">
4109
- <div className="modal-box">
4110
- <h3 className="font-bold text-lg">Add Remote</h3>
4111
- <p className="py-4 break-words">
4112
- Add a new remote by providing a name and URL. Press <kbd className="kbd kbd-sm">Cmd</kbd>+<kbd className="kbd kbd-sm">Enter</kbd> to confirm.
4113
- </p>
4114
- <div className="space-y-3">
4115
- <div>
4116
- <label className="label pt-0">
4117
- <span className="label-text">Remote name</span>
4118
- </label>
4119
- <input
4120
- type="text"
4121
- className="input input-bordered w-full"
4122
- value={newRemoteName}
4123
- onChange={(e) => setNewRemoteName(e.target.value)}
4124
- placeholder="origin"
4125
- disabled={isAddingRemote}
4126
- autoFocus
4127
- />
4128
- </div>
4129
- <div>
4130
- <label className="label pt-0">
4131
- <span className="label-text">Remote URL</span>
4132
- </label>
4133
- <input
4134
- type="text"
4135
- className="input input-bordered w-full"
4136
- value={newRemoteUrl}
4137
- onChange={(e) => setNewRemoteUrl(e.target.value)}
4138
- placeholder="https://github.com/owner/repo.git"
4139
- disabled={isAddingRemote}
4140
- onKeyDown={(e) => {
4141
- const shortcutPressed = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
4142
- if (shortcutPressed && newRemoteName.trim() && newRemoteUrl.trim() && !isAddingRemote) {
4143
- handleAddRemote();
4144
- }
4145
- }}
4146
- />
4147
- </div>
4148
- </div>
4149
- <div className="modal-action">
4150
- <button
4151
- className="btn"
4152
- onClick={closeAddRemoteDialog}
4153
- disabled={isAddingRemote}
4154
- >
4155
- Cancel
4156
- </button>
4157
- <button
4158
- className="btn btn-primary"
4159
- onClick={handleAddRemote}
4160
- disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || isAddingRemote}
4161
- >
4162
- {isAddingRemote && <span className="loading loading-spinner loading-xs"></span>}
4163
- Add
4164
- </button>
4165
- </div>
4166
- </div>
4167
- <form method="dialog" className="modal-backdrop">
4168
- <button onClick={closeAddRemoteDialog}>
4169
- close
4170
- </button>
4171
- </form>
4172
- </dialog>
4173
- )}
4174
-
4175
- {isRebaseOpen && (
4176
- <dialog className="modal modal-open">
4177
- <div className="modal-box">
4178
- <h3 className="font-bold text-lg">Rebase</h3>
4179
- <p className="py-4 break-words">
4180
- Copy commits from one branch to another.<br/>
4181
- Are you sure to rebase <span className="font-bold break-all">{rebaseSourceBranch}</span> onto <span className="font-bold break-all">{rebaseTargetBranch}</span>?
4182
- </p>
4183
- <div className="form-control">
4184
- <label className="label cursor-pointer justify-start gap-2">
4185
- <input type="checkbox" className="checkbox checkbox-sm" checked={rebaseStashChanges} onChange={(e) => setRebaseStashChanges(e.target.checked)} disabled={isRebasing} />
4186
- <span className="label-text">Stash and reapply local changes</span>
4187
- </label>
4188
- </div>
4189
- {!rebaseStashChanges && (
4190
- <p className="text-xs text-warning mt-2 ml-6">
4191
- Warning: All local changes will be discarded.
4192
- </p>
4193
- )}
4194
- {rebaseConflictStatus === 'checking' ? (
4195
- <div className="alert alert-info text-sm mt-4 py-2">
4196
- <span className="loading loading-spinner loading-xs"></span>
4197
- <span>Checking conflicts for rebasing <span className="font-bold break-all">{rebaseSourceBranch}</span> onto <span className="font-bold break-all">{rebaseTargetBranch}</span>...</span>
4198
- </div>
4199
- ) : rebaseConflictStatus === 'no-conflict' ? (
4200
- <div className="alert alert-success text-sm mt-4 py-2">
4201
- <i className="iconoir-check-circle-solid text-[18px]" aria-hidden="true" />
4202
- <span>No conflict: rebasing <span className="font-bold break-all">{rebaseSourceBranch}</span> onto <span className="font-bold break-all">{rebaseTargetBranch}</span> will not cause conflicts.</span>
4203
- </div>
4204
- ) : (
4205
- <div className="alert alert-warning text-sm mt-4 py-2">
4206
- <i className="iconoir-warning-circle-solid text-[18px]" aria-hidden="true" />
4207
- <span>Conflicts detected: rebasing <span className="font-bold break-all">{rebaseSourceBranch}</span> onto <span className="font-bold break-all">{rebaseTargetBranch}</span> will cause conflicts.</span>
4208
- </div>
4209
- )}
4210
- <div className="modal-action">
4211
- <button className="btn" onClick={closeRebaseDialog} disabled={isRebasing}>Cancel</button>
4212
- <button className="btn btn-primary" onClick={handleRebase} disabled={isRebasing}>
4213
- {isRebasing && <span className="loading loading-spinner loading-xs"></span>}
4214
- Confirm
4215
- </button>
4216
- </div>
4217
- </div>
4218
- <form method="dialog" className="modal-backdrop">
4219
- <button onClick={closeRebaseDialog}>close</button>
4220
- </form>
4221
- </dialog>
4222
- )}
4223
-
4224
- {isMergeOpen && (
4225
- <dialog className="modal modal-open">
4226
- <div className="modal-box">
4227
- <h3 className="font-bold text-lg">Merge</h3>
4228
- <p className="py-4 break-words">
4229
- Merge branch into another one.<br/>
4230
- Are you sure to merge <span className="font-bold break-all">{mergeSourceBranch}</span> into <span className="font-bold break-all">{mergeTargetBranch}</span>?
4231
- </p>
4232
- <div className="form-control">
4233
- <label className="label cursor-pointer justify-start gap-2">
4234
- <input type="checkbox" className="checkbox checkbox-sm" checked={mergeRebaseBeforeMerge} onChange={(e) => setMergeRebaseBeforeMerge(e.target.checked)} disabled={isMerging} />
4235
- <span className="label-text">Rebase before merge</span>
4236
- </label>
4237
- </div>
4238
- <div className="form-control">
4239
- <label className="label cursor-pointer justify-start gap-2">
4240
- <input type="checkbox" className="checkbox checkbox-sm" checked={mergeSquash} onChange={(e) => handleMergeSquashToggle(e.target.checked)} disabled={isMerging} />
4241
- <span className="label-text">Squash before merge</span>
4242
- </label>
4243
- </div>
4244
- {mergeSquash && (
4245
- <textarea
4246
- className="textarea textarea-bordered w-full mt-2"
4247
- placeholder="Commit message for squash merge"
4248
- value={mergeSquashMessage}
4249
- onChange={(e) => setMergeSquashMessage(e.target.value)}
4250
- disabled={isMerging}
4251
- autoFocus
4252
- />
4253
- )}
4254
- <div className="form-control">
4255
- <label className="label cursor-pointer justify-start gap-2">
4256
- <input type="checkbox" className="checkbox checkbox-sm" checked={mergeFastForward} onChange={(e) => setMergeFastForward(e.target.checked)} disabled={isMerging} />
4257
- <span className="label-text">Fast forward merge</span>
4258
- </label>
4259
- </div>
4260
- {mergeConflictStatus === 'checking' ? (
4261
- <div className="alert alert-info text-sm mt-4 py-2">
4262
- <span className="loading loading-spinner loading-xs"></span>
4263
- <span>Checking conflicts for merging <span className="font-bold break-all">{mergeSourceBranch}</span> into <span className="font-bold break-all">{mergeTargetBranch}</span>...</span>
4264
- </div>
4265
- ) : mergeConflictStatus === 'no-conflict' ? (
4266
- <div className="alert alert-success text-sm mt-4 py-2">
4267
- <i className="iconoir-check-circle-solid text-[18px]" aria-hidden="true" />
4268
- <span>No conflict: merging <span className="font-bold break-all">{mergeSourceBranch}</span> into <span className="font-bold break-all">{mergeTargetBranch}</span> will not cause conflicts.</span>
4269
- </div>
4270
- ) : (
4271
- <div className="alert alert-warning text-sm mt-4 py-2">
4272
- <i className="iconoir-warning-circle-solid text-[18px]" aria-hidden="true" />
4273
- <span>Conflicts detected: merging <span className="font-bold break-all">{mergeSourceBranch}</span> into <span className="font-bold break-all">{mergeTargetBranch}</span> will cause conflicts.</span>
4274
- </div>
4275
- )}
4276
- <div className="modal-action">
4277
- <button className="btn" onClick={closeMergeDialog} disabled={isMerging}>Cancel</button>
4278
- <button className="btn btn-primary" onClick={handleMerge} disabled={isMerging}>
4279
- {isMerging && <span className="loading loading-spinner loading-xs"></span>}
4280
- Confirm
4281
- </button>
4282
- </div>
4283
- </div>
4284
- <form method="dialog" className="modal-backdrop">
4285
- <button onClick={closeMergeDialog}>close</button>
4286
- </form>
4287
- </dialog>
4288
- )}
4289
-
4290
- {isPushOpen && (
4291
- <dialog className="modal modal-open">
4292
- <div className="modal-box">
4293
- <h3 className="font-bold text-lg">Push to Remote</h3>
4294
- <p className="py-4 break-words">Push <span className="font-bold break-all">{pushBranch}</span> to a remote repository.</p>
4295
-
4296
- {pushError && pushRemotes.length === 0 ? (
4297
- <div className="alert alert-error">
4298
- <span className="text-xl">⚠️</span>
4299
- <span>{pushError}</span>
4300
- </div>
4301
- ) : pushLoadingRemotes ? (
4302
- <div className="flex justify-center py-8">
4303
- <span className="loading loading-spinner loading-lg"></span>
4304
- </div>
4305
- ) : (
4306
- <div className="flex flex-col gap-4">
4307
- <div className="form-control w-full flex flex-row items-center justify-between gap-4">
4308
- <label className="label flex-shrink-0"><span className="label-text">Remote Repository</span></label>
4309
- <select className="select select-bordered w-64" value={pushSelectedRemote} onChange={(e) => handlePushRemoteChange(e.target.value)} disabled={isPushing}>
4310
- {pushRemotes.map((remote) => <option key={remote} value={remote}>{remote}</option>)}
4311
- </select>
4312
- </div>
4313
-
4314
- <div className="form-control w-full flex flex-row items-center justify-between gap-4">
4315
- <label className="label flex-shrink-0"><span className="label-text">Remote Branch</span></label>
4316
- <div className="flex flex-col items-end gap-1 w-64">
4317
- {pushLoadingBranches ? (
4318
- <div className="flex items-center gap-2 p-3 border rounded-lg bg-base-200 opacity-70 w-full">
4319
- <span className="loading loading-spinner loading-xs"></span> Loading branches...
4320
- </div>
4321
- ) : (
4322
- <select className="select select-bordered w-full" value={pushSelectedRemoteBranch} onChange={(e) => setPushSelectedRemoteBranch(e.target.value)} disabled={isPushing}>
4323
- {pushBranch && !pushRemoteBranches.includes(pushBranch) && <option value={pushBranch}>{pushBranch} (new)</option>}
4324
- {pushRemoteBranches.map((branch) => <option key={branch} value={branch}>{branch}{pushTrackingBranch?.remote === pushSelectedRemote && pushTrackingBranch?.branch === branch ? ' (tracking)' : ''}</option>)}
4325
- </select>
4326
- )}
4327
- {pushSelectedRemoteBranch && !pushRemoteBranches.includes(pushSelectedRemoteBranch) && (
4328
- <div className="label"><span className="label-text-alt text-warning">New branch will be created</span></div>
4329
- )}
4330
- </div>
4331
- </div>
4332
-
4333
- <div className="form-control">
4334
- <label className="label cursor-pointer justify-start gap-2">
4335
- <input type="checkbox" className="checkbox checkbox-sm" checked={pushRebaseFirst} onChange={(e) => setPushRebaseFirst(e.target.checked)} disabled={isPushing || pushForcePush} />
4336
- <span className="label-text">Rebase onto remote branch before pushing</span>
4337
- </label>
4338
- </div>
4339
-
4340
- <div className="form-control">
4341
- <label className="label cursor-pointer justify-start gap-2">
4342
- <input type="checkbox" className="checkbox checkbox-sm checkbox-error" checked={pushForcePush} onChange={(e) => setPushForcePush(e.target.checked)} disabled={isPushing} />
4343
- <span className="label-text text-error">Force push</span>
4344
- </label>
4345
- </div>
4346
-
4347
- <div className="form-control">
4348
- <label className="label cursor-pointer justify-start gap-2">
4349
- <input type="checkbox" className="checkbox checkbox-sm" checked={pushLocalOnlyTags} onChange={(e) => setPushLocalOnlyTags(e.target.checked)} disabled={isPushing} />
4350
- <span className="label-text">Push all tags</span>
4351
- </label>
4352
- </div>
4353
-
4354
- <div className="form-control">
4355
- <label className="label cursor-pointer justify-start gap-2">
4356
- <input type="checkbox" className="checkbox checkbox-sm" checked={pushSquash} onChange={(e) => handlePushSquashToggle(e.target.checked)} disabled={isPushing} />
4357
- <span className="label-text">Squash local commits before push</span>
4358
- </label>
4359
- </div>
4360
- {pushSquash && (
4361
- <textarea className="textarea textarea-bordered w-full" placeholder="Commit message for squash" value={pushSquashMessage} onChange={(e) => setPushSquashMessage(e.target.value)} disabled={isPushing} autoFocus />
4362
- )}
4363
-
4364
- {pushError && (
4365
- <div className="alert alert-error text-sm">
4366
- <span>{pushError}</span>
4367
- </div>
4368
- )}
4369
- </div>
4370
- )}
4371
-
4372
- <div className="modal-action">
4373
- <button className="btn" onClick={() => setIsPushOpen(false)} disabled={isPushing}>Cancel</button>
4374
- {pushRemotes.length > 0 && (
4375
- <button className="btn btn-primary" onClick={handlePushToRemote} disabled={isPushing || !pushSelectedRemote || !pushSelectedRemoteBranch}>
4376
- {isPushing && <span className="loading loading-spinner loading-xs"></span>} Push
4377
- </button>
4378
- )}
4379
- </div>
4380
- </div>
4381
- <form method="dialog" className="modal-backdrop">
4382
- <button onClick={() => setIsPushOpen(false)}>close</button>
4383
- </form>
4384
- </dialog>
4385
- )}
4386
-
4387
- {isPullOpen && (
4388
- <dialog className="modal modal-open">
4389
- <div className="modal-box">
4390
- <h3 className="font-bold text-lg">Pull from Remote</h3>
4391
- <p className="py-4 break-words">Pull changes from a remote branch into <span className="font-bold break-all">{pullBranch}</span>.</p>
4392
-
4393
- {pullError && pullRemotes.length === 0 ? (
4394
- <div className="alert alert-error"><span>{pullError}</span></div>
4395
- ) : pullLoadingRemotes ? (
4396
- <div className="flex justify-center py-8"><span className="loading loading-spinner loading-lg"></span></div>
4397
- ) : (
4398
- <div className="flex flex-col gap-4">
4399
- <div className="form-control w-full flex flex-row items-center justify-between gap-4">
4400
- <label className="label flex-shrink-0"><span className="label-text">Remote Repository</span></label>
4401
- <select className="select select-bordered w-64" value={pullSelectedRemote} onChange={(e) => handlePullRemoteChange(e.target.value)} disabled={isPulling}>
4402
- {pullRemotes.map((remote) => <option key={remote} value={remote}>{remote}</option>)}
4403
- </select>
4404
- </div>
4405
-
4406
- <div className="form-control w-full flex flex-row items-center justify-between gap-4">
4407
- <label className="label flex-shrink-0"><span className="label-text">Remote Branch</span></label>
4408
- {pullLoadingBranches ? (
4409
- <div className="flex items-center gap-2 p-3 border rounded-lg bg-base-200 opacity-70 w-64">
4410
- <span className="loading loading-spinner loading-xs"></span> Loading branches...
4411
- </div>
4412
- ) : (
4413
- <select className="select select-bordered w-64" value={pullSelectedRemoteBranch} onChange={(e) => setPullSelectedRemoteBranch(e.target.value)} disabled={isPulling}>
4414
- {pullRemoteBranches.map((branch) => <option key={branch} value={branch}>{branch}{pullTrackingBranch?.remote === pullSelectedRemote && pullTrackingBranch?.branch === branch ? ' (tracking)' : ''}</option>)}
4415
- </select>
4416
- )}
4417
- </div>
4418
-
4419
- <div className="form-control">
4420
- <label className="label cursor-pointer justify-start gap-2">
4421
- <input type="checkbox" className="checkbox checkbox-sm" checked={pullRebase} onChange={(e) => setPullRebase(e.target.checked)} disabled={isPulling} />
4422
- <span className="label-text">Rebase onto remote branch</span>
4423
- </label>
4424
- </div>
4425
-
4426
- {pullError && <div className="alert alert-error text-sm"><span>{pullError}</span></div>}
4427
- </div>
4428
- )}
4429
-
4430
- <div className="modal-action">
4431
- <button className="btn" onClick={() => setIsPullOpen(false)} disabled={isPulling}>Cancel</button>
4432
- {pullRemotes.length > 0 && (
4433
- <button className="btn btn-primary" onClick={handlePullFromRemote} disabled={isPulling || !pullSelectedRemote || !pullSelectedRemoteBranch}>
4434
- {isPulling && <span className="loading loading-spinner loading-xs"></span>} Pull
4435
- </button>
4436
- )}
4437
- </div>
4438
- </div>
4439
- <form method="dialog" className="modal-backdrop">
4440
- <button onClick={() => setIsPullOpen(false)}>close</button>
4441
- </form>
4442
- </dialog>
4443
- )}
4444
-
4445
- {isCreateTagOpen && (
4446
- <dialog className="modal modal-open">
4447
- <div className="modal-box">
4448
- <h3 className="font-bold text-lg">Create Tag</h3>
4449
- <p className="py-4 break-words">
4450
- Create a new tag at commit <span className="font-mono bg-base-200 px-1 rounded">{createTagCommitHash?.substring(0, 7)}</span>.
4451
- </p>
4452
- <input
4453
- type="text"
4454
- className="input input-bordered w-full font-mono"
4455
- value={newTagName}
4456
- onChange={(e) => setNewTagName(e.target.value)}
4457
- placeholder="Tag name"
4458
- disabled={isCreatingTag}
4459
- autoFocus
4460
- onKeyDown={(e) => {
4461
- if (e.key === 'Enter' && newTagName.trim() && !isCreatingTag) {
4462
- handleCreateTag();
4463
- }
4464
- }}
4465
- />
4466
- <div className="form-control mt-3">
4467
- <label className="label cursor-pointer justify-start gap-2">
4468
- <input
4469
- type="checkbox"
4470
- className="checkbox checkbox-sm"
4471
- checked={pushTagToRemote}
4472
- onChange={(e) => setPushTagToRemote(e.target.checked)}
4473
- disabled={isCreatingTag}
4474
- />
4475
- <span className="label-text">Push tag to remote after creation</span>
4476
- </label>
4477
- </div>
4478
- <div className="modal-action">
4479
- <button
4480
- className="btn"
4481
- onClick={() => {
4482
- setIsCreateTagOpen(false);
4483
- setCreateTagCommitHash(null);
4484
- setNewTagName('');
4485
- setPushTagToRemote(false);
4486
- }}
4487
- disabled={isCreatingTag}
4488
- >
4489
- Cancel
4490
- </button>
4491
- <button className="btn btn-primary" onClick={handleCreateTag} disabled={!newTagName.trim() || isCreatingTag}>
4492
- {isCreatingTag && <span className="loading loading-spinner loading-xs"></span>}
4493
- Create
4494
- </button>
4495
- </div>
4496
- </div>
4497
- <form method="dialog" className="modal-backdrop">
4498
- <button
4499
- onClick={() => {
4500
- setIsCreateTagOpen(false);
4501
- setCreateTagCommitHash(null);
4502
- setNewTagName('');
4503
- setPushTagToRemote(false);
4504
- }}
4505
- >
4506
- close
4507
- </button>
4508
- </form>
4509
- </dialog>
4510
- )}
4511
-
4512
- {iscreateBranchOpen && (
4513
- <dialog className="modal modal-open">
4514
- <div className="modal-box">
4515
- <h3 className="font-bold text-lg">Create New Branch</h3>
4516
- <p className="py-4 break-words">
4517
- Create a new branch from{' '}
4518
- <span className="font-bold break-all">
4519
- {createBranchFromRef ? createBranchFromRef.replace(/^remotes\//, '') : 'current HEAD'}
4520
- </span>.
4521
- </p>
4522
- <input
4523
- type="text"
4524
- className="input input-bordered w-full"
4525
- value={newBranchName}
4526
- onChange={e => setNewBranchName(sanitizeBranchName(e.target.value))}
4527
- placeholder="Branch name"
4528
- disabled={isCreating}
4529
- autoFocus
4530
- onKeyDown={e => {
4531
- if (e.key === 'Enter' && newBranchName && !isCreating) {
4532
- handleCreateBranch();
4533
- }
4534
- }}
4535
- />
4536
- <div className="modal-action">
4537
- <button
4538
- className="btn"
4539
- onClick={() => {
4540
- setIsCreateBranchOpen(false);
4541
- setCreateBranchFromRef(null);
4542
- }}
4543
- disabled={isCreating}
4544
- >
4545
- Cancel
4546
- </button>
4547
- <button className="btn btn-primary" onClick={handleCreateBranch} disabled={!newBranchName || isCreating}>
4548
- {isCreating && <span className="loading loading-spinner loading-xs"></span>} Create & Checkout
4549
- </button>
4550
- </div>
4551
- </div>
4552
- <form method="dialog" className="modal-backdrop">
4553
- <button
4554
- onClick={() => {
4555
- setIsCreateBranchOpen(false);
4556
- setCreateBranchFromRef(null);
4557
- }}
4558
- >
4559
- close
4560
- </button>
4561
- </form>
4562
- </dialog>
4563
- )}
4564
-
4565
- {isCheckoutToLocalOpen && (
4566
- <dialog className="modal modal-open">
4567
- <div className="modal-box">
4568
- <h3 className="font-bold text-lg">Checkout to Local Branch</h3>
4569
- <p className="py-4 break-words">Create a local branch from <span className="font-bold break-all">{checkoutRemoteBranch?.replace(/^remotes\//, '')}</span> and set up tracking.</p>
4570
- <div className="form-control w-full">
4571
- <label className="label"><span className="label-text">Local Branch Name</span></label>
4572
- <input
4573
- type="text"
4574
- className="input input-bordered w-full"
4575
- value={checkoutLocalBranchName}
4576
- onChange={e => setCheckoutLocalBranchName(sanitizeBranchName(e.target.value))}
4577
- placeholder="Local branch name"
4578
- disabled={isCheckingOutToLocal}
4579
- autoFocus
4580
- onKeyDown={e => {
4581
- if (e.key === 'Enter' && checkoutLocalBranchName && !isCheckingOutToLocal) {
4582
- handleCheckoutToLocal();
4583
- }
4584
- }}
4585
- />
4586
- </div>
4587
- <div className="modal-action">
4588
- <button className="btn" onClick={() => setIsCheckoutToLocalOpen(false)} disabled={isCheckingOutToLocal}>Cancel</button>
4589
- <button className="btn btn-primary" onClick={handleCheckoutToLocal} disabled={!checkoutLocalBranchName || isCheckingOutToLocal}>
4590
- {isCheckingOutToLocal && <span className="loading loading-spinner loading-xs"></span>} Checkout
4591
- </button>
4592
- </div>
4593
- </div>
4594
- <form method="dialog" className="modal-backdrop">
4595
- <button onClick={() => setIsCheckoutToLocalOpen(false)}>close</button>
4596
- </form>
4597
- </dialog>
4598
- )}
4599
-
4600
- {scriptExecution.isOpen && (
4601
- <dialog className="modal modal-open">
4602
- <div className="modal-box max-w-4xl">
4603
- <div className="flex items-start justify-between gap-4">
4604
- <div className="min-w-0">
4605
- <h3 className="font-bold text-lg truncate">Custom Script: {scriptExecution.scriptName}</h3>
4606
- <p className="text-xs opacity-70 mt-1 break-all">
4607
- Branch: {scriptExecution.branchRef}
4608
- </p>
4609
- </div>
4610
- <span
4611
- className={cn(
4612
- "badge badge-sm shrink-0",
4613
- scriptExecution.status === 'running' || scriptExecution.status === 'starting'
4614
- ? 'badge-info'
4615
- : scriptExecution.status === 'completed'
4616
- ? 'badge-success'
4617
- : scriptExecution.status === 'failed'
4618
- ? 'badge-error'
4619
- : scriptExecution.status === 'canceled'
4620
- ? 'badge-warning'
4621
- : 'badge-ghost'
4622
- )}
4623
- >
4624
- {scriptExecution.status}
4625
- </span>
4626
- </div>
4627
-
4628
- <div className="mt-4 border border-base-300 rounded bg-base-200/40">
4629
- <pre className="p-3 font-mono text-xs overflow-auto max-h-[50vh] whitespace-pre-wrap break-words">{scriptExecution.output || 'Waiting for output...'}</pre>
4630
- </div>
4631
-
4632
- {scriptExecution.error && (
4633
- <div className="alert alert-error py-2 mt-3">
4634
- <span>{scriptExecution.error}</span>
4635
- </div>
4636
- )}
4637
-
4638
- <div className="modal-action">
4639
- <button
4640
- className="btn btn-warning"
4641
- onClick={() => void handleCancelCustomScriptExecution()}
4642
- disabled={!isScriptExecutionRunning || isCancelingScriptExecution}
4643
- >
4644
- {isCancelingScriptExecution && <span className="loading loading-spinner loading-xs"></span>}
4645
- Cancel
4646
- </button>
4647
- <button
4648
- className="btn btn-outline"
4649
- onClick={() => void handleCopyCustomScriptOutput()}
4650
- disabled={isCopyingScriptOutput}
4651
- >
4652
- {isCopyingScriptOutput && <span className="loading loading-spinner loading-xs"></span>}
4653
- {didCopyScriptOutput ? 'Copied' : 'Copy'}
4654
- </button>
4655
- <button
4656
- className="btn btn-primary"
4657
- onClick={() => {
4658
- setScriptExecution(DEFAULT_SCRIPT_EXECUTION);
4659
- setDidCopyScriptOutput(false);
4660
- setIsCancelingScriptExecution(false);
4661
- }}
4662
- disabled={!isScriptExecutionFinished}
4663
- >
4664
- Done
4665
- </button>
4666
- </div>
4667
- </div>
4668
- <form method="dialog" className="modal-backdrop">
4669
- <button
4670
- onClick={(e) => {
4671
- if (!isScriptExecutionFinished) {
4672
- e.preventDefault();
4673
- return;
4674
- }
4675
- setScriptExecution(DEFAULT_SCRIPT_EXECUTION);
4676
- setDidCopyScriptOutput(false);
4677
- setIsCancelingScriptExecution(false);
4678
- }}
4679
- >
4680
- close
4681
- </button>
4682
- </form>
4683
- </dialog>
4684
- )}
4685
-
4686
- {/* Main Content */}
4687
- <div className="flex-1 flex flex-col min-w-0 bg-base-100">
4688
- <div className="h-[57px] flex items-center px-6 border-b border-base-300 shrink-0">
4689
- <div className="flex items-center gap-2 min-w-0">
4690
- <h1 className="font-bold text-lg">History</h1>
4691
- <div className="relative" ref={branchPopoverRef}>
4692
- <button
4693
- className="btn btn-sm gap-2 max-w-[24rem]"
4694
- onClick={() => setIsBranchPopoverOpen(prev => !prev)}
4695
- title={currentBranchLabel}
4696
- >
4697
- <span className="truncate">{currentBranchLabel}</span>
4698
- <i className={cn("iconoir-nav-arrow-down text-[16px] shrink-0 transition-transform", isBranchPopoverOpen && "rotate-180")} aria-hidden="true" />
4699
- </button>
4700
- {isBranchPopoverOpen && (
4701
- <div className="absolute left-0 top-full mt-2 z-50">
4702
- {branchTreePopoverContent}
4703
- </div>
4704
- )}
4705
- </div>
4706
- <div className="flex items-center gap-2 shrink-0">
4707
- <button
4708
- className="btn btn-sm gap-2"
4709
- onClick={() => void handleFetchFromAllRemotes()}
4710
- disabled={isFetchingAllRemotes || isPullOpen || isPushOpen}
4711
- title="Fetch latest changes from all remotes"
4712
- >
4713
- {isFetchingAllRemotes ? (
4714
- <span className="loading loading-spinner loading-xs"></span>
4715
- ) : (
4716
- <i className="iconoir-refresh text-[16px]" aria-hidden="true" />
4717
- )}
4718
- Fetch
4719
- </button>
4720
- <button
4721
- className="btn btn-sm gap-2"
4722
- onClick={confirmPullCurrentBranch}
4723
- disabled={!!pullActionDisabledReason || isPullOpen || isPushOpen}
4724
- title={pullActionDisabledReason || `Pull from ${currentTrackingBranch?.upstream}`}
4725
- >
4726
- {pullLoadingRemotes ? (
4727
- <span className="loading loading-spinner loading-xs"></span>
4728
- ) : (
4729
- <i className="iconoir-arrow-down text-[16px]" aria-hidden="true" />
4730
- )}
4731
- Pull
4732
- </button>
4733
- <button
4734
- className="btn btn-sm gap-2"
4735
- onClick={confirmPushCurrentBranch}
4736
- disabled={!!pushActionDisabledReason || isPullOpen || isPushOpen}
4737
- title={pushActionDisabledReason || (currentTrackingBranch ? `Push to ${currentTrackingBranch.upstream}` : 'Push current branch to remote')}
4738
- >
4739
- {pushLoadingRemotes ? (
4740
- <span className="loading loading-spinner loading-xs"></span>
4741
- ) : (
4742
- <i className="iconoir-arrow-up text-[16px]" aria-hidden="true" />
4743
- )}
4744
- Push
4745
- </button>
4746
- </div>
4747
- </div>
4748
- </div>
4749
-
4750
- <div className="flex-1 overflow-hidden relative">
4751
- {/* Show loading spinner while branches are loading if visibility filters are set */}
4752
- {hasVisibilityFilters && isBranchesLoading ? (
4753
- <div className="flex items-center justify-center h-full">
4754
- <span className="loading loading-spinner loading-lg opacity-50"></span>
4755
- </div>
4756
- ) : (
4757
- <GitGraph
4758
- ref={gitGraphRef}
4759
- commits={filteredCommits}
4760
- selectedHash={selectedHash || undefined}
4761
- selectedHashes={selectedCommitHashSet}
4762
- onSelectCommit={handleSelectCommit}
4763
- onResetToCommit={handleResetToCommit}
4764
- onRevertCommit={confirmRevertCommit}
4765
- onCreateTag={confirmCreateTag}
4766
- onCherryPickCommit={confirmCherryPickCommit}
4767
- onCherryPickSelectedCommits={confirmCherryPickSelectedCommits}
4768
- onRewordCommit={confirmRewordCommit}
4769
- localBranches={branchData?.branches || []}
4770
- trackingInfo={branchData?.trackingInfo}
4771
- onEndReached={() => {
4772
- if (!isFetching && log.all.length >= limit) {
4773
- setLimit(l => l + 50);
4774
- }
4775
- }}
4776
- isLoadingMore={isFetching && limit > 100}
4777
- currentBranch={branchData?.current}
4778
- hiddenBranches={hiddenBranches}
4779
- getBranchTagContextMenuItems={getBranchTagContextMenuItems}
4780
- />
4781
- )}
4782
- </div>
4783
-
4784
- {selectedHash && (
4785
- <div
4786
- className="flex flex-col overflow-hidden border-t border-base-300 bg-base-200/30"
4787
- style={{ height: panelHeight }}
4788
- >
4789
- {/* Resize handle */}
4790
- <div
4791
- className={cn(
4792
- "h-1.5 cursor-ns-resize flex items-center justify-center hover:bg-base-200 transition-colors group shrink-0",
4793
- isResizing && "bg-base-200"
4794
- )}
4795
- onMouseDown={handleResizeStart}
4796
- >
4797
- <div className="w-8 h-1 rounded-full bg-base-300 group-hover:bg-base-content/20 transition-colors" />
4798
- </div>
4799
-
4800
- {/* Header with commit info */}
4801
- <div className="flex flex-row items-center py-2 px-4 border-b border-base-300 bg-base-100 shrink-0 justify-between gap-4">
4802
- <div className="flex items-center gap-4 flex-1 min-w-0">
4803
- <span className="text-sm font-bold truncate">
4804
- {log.all.find(c => c.hash === selectedHash)?.message}
4805
- </span>
4806
- <span className="text-xs font-mono opacity-50 shrink-0">
4807
- {selectedHash.substring(0, 7)}
4808
- </span>
4809
- </div>
4810
- <div className="flex items-center gap-1 shrink-0">
4811
- <button
4812
- className="ml-2 btn btn-ghost btn-xs btn-square"
4813
- onClick={() => selectSingleCommit(null)}
4814
- title="Close"
4815
- >
4816
-
4817
- </button>
4818
- </div>
4819
- </div>
4820
-
4821
- {/* Combined commit message and changes content */}
4822
- <div
4823
- ref={commitDetailsContentRef}
4824
- className="flex-1 overflow-hidden bg-base-100 grid"
4825
- style={{
4826
- gridTemplateRows: `${commitDetailsMessageRatio}fr 6px ${1 - commitDetailsMessageRatio}fr`,
4827
- }}
4828
- >
4829
- <div className="border-b border-base-300 bg-base-100 min-h-0 flex flex-col">
4830
- <div className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-wider font-bold opacity-60">
4831
- Message
4832
- </div>
4833
- <div className="px-4 pb-3 overflow-auto flex-1 min-h-0">
4834
- <div className="text-xs opacity-70 whitespace-pre-wrap font-mono">
4835
- {log.all.find(c => c.hash === selectedHash)?.body || 'No additional message'}
4836
- </div>
4837
- </div>
4838
- </div>
4839
- <div
4840
- className={cn(
4841
- 'cursor-ns-resize flex items-center justify-center hover:bg-base-200/60 transition-colors',
4842
- isCommitDetailsRatioResizing && 'bg-base-200/60'
4843
- )}
4844
- onMouseDown={handleCommitDetailsRatioResizeStart}
4845
- >
4846
- <div className="w-8 h-1 rounded-full bg-base-300" />
4847
- </div>
4848
- <div className="flex-1 overflow-hidden min-h-0 flex flex-col">
4849
- <div className="px-4 pt-2 pb-1 text-[10px] uppercase tracking-wider font-bold opacity-60 border-b border-base-300 bg-base-100 shrink-0">
4850
- Changes
4851
- </div>
4852
- <div className="flex-1 min-h-0">
4853
- <CommitChangesView repoPath={repoPath} commitHash={selectedHash} />
4854
- </div>
4855
- </div>
4856
- </div>
4857
- </div>
4858
- )}
4859
- </div>
4860
- </div>
4861
- );
4862
- }