painapple-code 1.0.0__py3-none-any.whl

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 (436) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +18 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +930 -0
  5. painapple_code/cli/__init__.py +204 -0
  6. painapple_code/cli/deploy/__init__.py +15 -0
  7. painapple_code/cli/deploy/claude_seed.py +41 -0
  8. painapple_code/cli/deploy/config.py +260 -0
  9. painapple_code/cli/deploy/container.py +715 -0
  10. painapple_code/cli/deploy/launch.py +163 -0
  11. painapple_code/cli/deploy/runtime.py +223 -0
  12. painapple_code/cli/lifecycle_cmd.py +443 -0
  13. painapple_code/cli/list_cmd.py +572 -0
  14. painapple_code/cli/manage_cmd.py +670 -0
  15. painapple_code/cli/netinfo.py +125 -0
  16. painapple_code/cli/profiles.py +390 -0
  17. painapple_code/cli/serve_args.py +163 -0
  18. painapple_code/cli/serve_config.py +256 -0
  19. painapple_code/cli/setup_cmd.py +664 -0
  20. painapple_code/cli/ui.py +438 -0
  21. painapple_code/cost_analytics.py +879 -0
  22. painapple_code/data/models.defaults.yaml +18 -0
  23. painapple_code/data/models.yaml +18 -0
  24. painapple_code/data/presets.defaults.json +32 -0
  25. painapple_code/data/strings.yaml +2269 -0
  26. painapple_code/helpers.py +406 -0
  27. painapple_code/paths.py +1514 -0
  28. painapple_code/prompt_explorer.py +984 -0
  29. painapple_code/providers/__init__.py +224 -0
  30. painapple_code/providers/base.py +710 -0
  31. painapple_code/providers/claude/__init__.py +74 -0
  32. painapple_code/providers/claude/capabilities.py +218 -0
  33. painapple_code/providers/claude/errors.py +79 -0
  34. painapple_code/providers/claude/launch.py +50 -0
  35. painapple_code/providers/claude/summary.py +89 -0
  36. painapple_code/providers/claude/translate.py +74 -0
  37. painapple_code/providers/claude_sdk/__init__.py +139 -0
  38. painapple_code/providers/claude_sdk/driver.py +412 -0
  39. painapple_code/providers/codex/__init__.py +87 -0
  40. painapple_code/providers/codex/capabilities.py +321 -0
  41. painapple_code/providers/codex/errors.py +70 -0
  42. painapple_code/providers/codex/launch.py +142 -0
  43. painapple_code/providers/codex/summary.py +196 -0
  44. painapple_code/providers/codex/translate.py +204 -0
  45. painapple_code/providers/codex_app_server/__init__.py +100 -0
  46. painapple_code/providers/codex_app_server/capabilities.py +41 -0
  47. painapple_code/providers/codex_app_server/launch.py +202 -0
  48. painapple_code/providers/codex_app_server/summary.py +291 -0
  49. painapple_code/providers/codex_app_server/translate.py +263 -0
  50. painapple_code/providers/codex_app_server/transport.py +238 -0
  51. painapple_code/routes/__init__.py +0 -0
  52. painapple_code/routes/api_agents.py +441 -0
  53. painapple_code/routes/api_app.py +368 -0
  54. painapple_code/routes/api_app_commit_sections.py +127 -0
  55. painapple_code/routes/api_app_config.py +997 -0
  56. painapple_code/routes/api_app_session_prefs.py +217 -0
  57. painapple_code/routes/api_browser.py +690 -0
  58. painapple_code/routes/api_commands.py +374 -0
  59. painapple_code/routes/api_costs.py +128 -0
  60. painapple_code/routes/api_drafts.py +139 -0
  61. painapple_code/routes/api_exec.py +149 -0
  62. painapple_code/routes/api_files.py +542 -0
  63. painapple_code/routes/api_git.py +657 -0
  64. painapple_code/routes/api_logs.py +649 -0
  65. painapple_code/routes/api_plugins.py +275 -0
  66. painapple_code/routes/api_project_config.py +418 -0
  67. painapple_code/routes/api_prompts.py +231 -0
  68. painapple_code/routes/api_providers.py +53 -0
  69. painapple_code/routes/api_search.py +411 -0
  70. painapple_code/routes/api_session_stash.py +234 -0
  71. painapple_code/routes/api_session_welcome.py +151 -0
  72. painapple_code/routes/api_sessions.py +742 -0
  73. painapple_code/routes/api_shadow.py +323 -0
  74. painapple_code/routes/api_shadow_db.py +512 -0
  75. painapple_code/routes/api_shadow_files.py +332 -0
  76. painapple_code/routes/api_shadow_search.py +476 -0
  77. painapple_code/routes/api_skills.py +610 -0
  78. painapple_code/routes/api_tasks.py +147 -0
  79. painapple_code/routes/api_terminal.py +540 -0
  80. painapple_code/routes/api_upload.py +293 -0
  81. painapple_code/routes/api_viewer.py +229 -0
  82. painapple_code/routes/dependencies.py +109 -0
  83. painapple_code/routes/ws_chat.py +837 -0
  84. painapple_code/server.py +2208 -0
  85. painapple_code/server_logging.py +263 -0
  86. painapple_code/services/__init__.py +0 -0
  87. painapple_code/services/agent_session.py +2738 -0
  88. painapple_code/session_store.py +253 -0
  89. painapple_code/session_store_core.py +879 -0
  90. painapple_code/shadow_db.py +830 -0
  91. painapple_code/shadow_db_plans.py +369 -0
  92. painapple_code/shadow_db_queries.py +595 -0
  93. painapple_code/shadow_db_schema.py +676 -0
  94. painapple_code/shadow_git.py +1296 -0
  95. painapple_code/shadow_git_frontmatter.py +314 -0
  96. painapple_code/shadow_git_sections.py +520 -0
  97. painapple_code/shadow_git_summary.py +462 -0
  98. painapple_code/shadow_parser.py +560 -0
  99. painapple_code/static/css/00-variables.css +276 -0
  100. painapple_code/static/css/01-base.css +245 -0
  101. painapple_code/static/css/02-tooltips.css +36 -0
  102. painapple_code/static/css/09-left-rail.css +448 -0
  103. painapple_code/static/css/10-header.css +777 -0
  104. painapple_code/static/css/11-connection-bar.css +120 -0
  105. painapple_code/static/css/20-messages.css +750 -0
  106. painapple_code/static/css/21-tool-blocks.css +2340 -0
  107. painapple_code/static/css/22-edit-diff.css +619 -0
  108. painapple_code/static/css/23-thinking.css +1584 -0
  109. painapple_code/static/css/24-background-tasks.css +359 -0
  110. painapple_code/static/css/25-context-block.css +252 -0
  111. painapple_code/static/css/26-session-setup.css +144 -0
  112. painapple_code/static/css/30-markdown.css +854 -0
  113. painapple_code/static/css/40-input.css +2418 -0
  114. painapple_code/static/css/41-autocomplete.css +127 -0
  115. painapple_code/static/css/42-activity-strip.css +97 -0
  116. painapple_code/static/css/43-file-autocomplete.css +121 -0
  117. painapple_code/static/css/44-snippets-autocomplete.css +224 -0
  118. painapple_code/static/css/45-selection-bar.css +433 -0
  119. painapple_code/static/css/46-keyboard-bar.css +119 -0
  120. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  121. painapple_code/static/css/47-token-profile.css +106 -0
  122. painapple_code/static/css/50-welcome.css +3095 -0
  123. painapple_code/static/css/51-modals.css +301 -0
  124. painapple_code/static/css/52-utilities.css +77 -0
  125. painapple_code/static/css/53-image-upload.css +456 -0
  126. painapple_code/static/css/54-todo-list.css +102 -0
  127. painapple_code/static/css/55-file-upload.css +113 -0
  128. painapple_code/static/css/56-chat-navigator.css +218 -0
  129. painapple_code/static/css/56-permission-card.css +332 -0
  130. painapple_code/static/css/56-plan-approval.css +204 -0
  131. painapple_code/static/css/56-question-form.css +602 -0
  132. painapple_code/static/css/57-swipe-indicator.css +95 -0
  133. painapple_code/static/css/58-session-families.css +627 -0
  134. painapple_code/static/css/59-about.css +196 -0
  135. painapple_code/static/css/61-log-explorer.css +723 -0
  136. painapple_code/static/css/62-json-tree.css +489 -0
  137. painapple_code/static/css/62-skills-widget.css +650 -0
  138. painapple_code/static/css/63-commands-widget.css +30 -0
  139. painapple_code/static/css/63-plugins-widget.css +174 -0
  140. painapple_code/static/css/63-terminal.css +516 -0
  141. painapple_code/static/css/64-search-files-widget.css +448 -0
  142. painapple_code/static/css/65-snippets-widget.css +426 -0
  143. painapple_code/static/css/66-active-sessions.css +979 -0
  144. painapple_code/static/css/66-agents-widget.css +265 -0
  145. painapple_code/static/css/66-file-explorer-widget.css +855 -0
  146. painapple_code/static/css/66-git-panel.css +791 -0
  147. painapple_code/static/css/66-history-explorer.css +1496 -0
  148. painapple_code/static/css/67-diff-viewer.css +602 -0
  149. painapple_code/static/css/67-editor-view.css +748 -0
  150. painapple_code/static/css/67-file-preview-widget.css +1873 -0
  151. painapple_code/static/css/68-browser-widget.css +337 -0
  152. painapple_code/static/css/68-compare-wizard.css +355 -0
  153. painapple_code/static/css/68-csv-preview.css +106 -0
  154. painapple_code/static/css/68-diff-preview.css +103 -0
  155. painapple_code/static/css/68-helpers-install-widget.css +491 -0
  156. painapple_code/static/css/68-image-annotate.css +346 -0
  157. painapple_code/static/css/68-terminal-view.css +98 -0
  158. painapple_code/static/css/68-uploads-widget.css +229 -0
  159. painapple_code/static/css/69-cost-analytics.css +537 -0
  160. painapple_code/static/css/70-config-panel.css +3736 -0
  161. painapple_code/static/css/70-excalidraw.css +136 -0
  162. painapple_code/static/css/70-prompt-explorer.css +890 -0
  163. painapple_code/static/css/71-chart.css +111 -0
  164. painapple_code/static/css/72-discussion-sidebar.css +678 -0
  165. painapple_code/static/css/74-debug-logs.css +656 -0
  166. painapple_code/static/css/80-open-dialog.css +146 -0
  167. painapple_code/static/css/80-quick-switcher.css +383 -0
  168. painapple_code/static/css/80-widget-system.css +1763 -0
  169. painapple_code/static/css/81-quick-actions.css +1020 -0
  170. painapple_code/static/css/82-context-popover.css +1233 -0
  171. painapple_code/static/css/83-context-menu.css +339 -0
  172. painapple_code/static/css/85-lazy-loading.css +102 -0
  173. painapple_code/static/css/86-zen-mode.css +708 -0
  174. painapple_code/static/css/87-grid-switcher.css +346 -0
  175. painapple_code/static/css/login.css +198 -0
  176. painapple_code/static/dist/app.bundle.js +7177 -0
  177. painapple_code/static/feature-triage.html +446 -0
  178. painapple_code/static/icons/apple-touch-icon.png +0 -0
  179. painapple_code/static/icons/favicon-32.png +0 -0
  180. painapple_code/static/icons/favicon.ico +0 -0
  181. painapple_code/static/icons/icon-128.png +0 -0
  182. painapple_code/static/icons/icon-144.png +0 -0
  183. painapple_code/static/icons/icon-152.png +0 -0
  184. painapple_code/static/icons/icon-180.png +0 -0
  185. painapple_code/static/icons/icon-192.png +0 -0
  186. painapple_code/static/icons/icon-384.png +0 -0
  187. painapple_code/static/icons/icon-512.png +0 -0
  188. painapple_code/static/icons/icon-72.png +0 -0
  189. painapple_code/static/icons/icon-96.png +0 -0
  190. painapple_code/static/js/action-delegate.js +187 -0
  191. painapple_code/static/js/activity-strip.js +193 -0
  192. painapple_code/static/js/app/context-menus.js +619 -0
  193. painapple_code/static/js/app/file-preview.js +163 -0
  194. painapple_code/static/js/app/init-ui.js +574 -0
  195. painapple_code/static/js/app/input-events.js +561 -0
  196. painapple_code/static/js/app/input-resize.js +313 -0
  197. painapple_code/static/js/app/message-actions.js +419 -0
  198. painapple_code/static/js/app/panels.js +210 -0
  199. painapple_code/static/js/app/rendering-delegators.js +246 -0
  200. painapple_code/static/js/app/server-loading.js +365 -0
  201. painapple_code/static/js/app/session-ops.js +287 -0
  202. painapple_code/static/js/app/session-switch.js +443 -0
  203. painapple_code/static/js/app/settings-helpers.js +280 -0
  204. painapple_code/static/js/app/startup-welcome.js +290 -0
  205. painapple_code/static/js/app-context.js +157 -0
  206. painapple_code/static/js/app.js +2134 -0
  207. painapple_code/static/js/auth-fetch.js +46 -0
  208. painapple_code/static/js/background-tasks.js +274 -0
  209. painapple_code/static/js/boot.js +73 -0
  210. painapple_code/static/js/caret-position.js +171 -0
  211. painapple_code/static/js/chat-navigator.js +710 -0
  212. painapple_code/static/js/chat-search.js +323 -0
  213. painapple_code/static/js/command-executor.js +176 -0
  214. painapple_code/static/js/command-store.js +497 -0
  215. painapple_code/static/js/components.js +1414 -0
  216. painapple_code/static/js/config.js +218 -0
  217. painapple_code/static/js/context-menu.js +740 -0
  218. painapple_code/static/js/controllers/chat-controller.js +4252 -0
  219. painapple_code/static/js/controllers/dialog-controller.js +217 -0
  220. painapple_code/static/js/controllers/tab-controller.js +1828 -0
  221. painapple_code/static/js/controllers/thinking-controller.js +915 -0
  222. painapple_code/static/js/diff-utils.js +486 -0
  223. painapple_code/static/js/drafts-pill.js +95 -0
  224. painapple_code/static/js/editor-view.js +815 -0
  225. painapple_code/static/js/effort-settings.js +430 -0
  226. painapple_code/static/js/feature-triage.js +811 -0
  227. painapple_code/static/js/file-autocomplete.js +516 -0
  228. painapple_code/static/js/file-tabs.js +123 -0
  229. painapple_code/static/js/gestures.js +448 -0
  230. painapple_code/static/js/grid-switcher.js +334 -0
  231. painapple_code/static/js/input-handler.js +1472 -0
  232. painapple_code/static/js/keyboard-bar.js +495 -0
  233. painapple_code/static/js/layout-switcher.js +109 -0
  234. painapple_code/static/js/linkify-utils.js +200 -0
  235. painapple_code/static/js/login.js +141 -0
  236. painapple_code/static/js/open-dialog.js +879 -0
  237. painapple_code/static/js/orphan-terminals.js +373 -0
  238. painapple_code/static/js/path-utils.js +179 -0
  239. painapple_code/static/js/perf-marks.js +102 -0
  240. painapple_code/static/js/permission-settings.js +475 -0
  241. painapple_code/static/js/pointer-input.js +59 -0
  242. painapple_code/static/js/preview/json-tree-menu.js +226 -0
  243. painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
  244. painapple_code/static/js/preview/json-tree.js +457 -0
  245. painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
  246. painapple_code/static/js/preview/preview-edit.js +367 -0
  247. painapple_code/static/js/preview/preview-events.js +361 -0
  248. painapple_code/static/js/preview/preview-history.js +567 -0
  249. painapple_code/static/js/preview/preview-inline-edit.js +869 -0
  250. painapple_code/static/js/preview/preview-poll.js +108 -0
  251. painapple_code/static/js/preview/preview-render.js +572 -0
  252. painapple_code/static/js/preview/preview-search.js +421 -0
  253. painapple_code/static/js/preview/preview-state.js +256 -0
  254. painapple_code/static/js/preview/preview-utils.js +251 -0
  255. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  256. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  257. painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
  258. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  259. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  260. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  261. painapple_code/static/js/preview-plugins/index.js +34 -0
  262. painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
  263. painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
  264. painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
  265. painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
  266. painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
  267. painapple_code/static/js/project-color-picker.js +110 -0
  268. painapple_code/static/js/project-colors.js +163 -0
  269. painapple_code/static/js/prompt-favorites.js +187 -0
  270. painapple_code/static/js/quick-actions-menu.js +1736 -0
  271. painapple_code/static/js/quick-actions-registry.js +1239 -0
  272. painapple_code/static/js/quick-switcher/controller.js +294 -0
  273. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  274. painapple_code/static/js/quick-switcher/index.js +30 -0
  275. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  276. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  277. painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
  278. painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
  279. painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
  280. painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
  281. painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
  282. painapple_code/static/js/quick-switcher/registry.js +69 -0
  283. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  284. painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
  285. painapple_code/static/js/recent-opens.js +40 -0
  286. painapple_code/static/js/scroll-manager.js +530 -0
  287. painapple_code/static/js/scroll-state-machine.js +359 -0
  288. painapple_code/static/js/selection/action-bar.js +793 -0
  289. painapple_code/static/js/selection/index.js +15 -0
  290. painapple_code/static/js/selection/selection-handler.js +877 -0
  291. painapple_code/static/js/selection/state.js +181 -0
  292. painapple_code/static/js/session/agent-progress.js +202 -0
  293. painapple_code/static/js/session/handle-agent-message.js +481 -0
  294. painapple_code/static/js/session/handle-message.js +550 -0
  295. painapple_code/static/js/session/interactive.js +587 -0
  296. painapple_code/static/js/session/message-store.js +271 -0
  297. painapple_code/static/js/session/messages.js +253 -0
  298. painapple_code/static/js/session/persistence.js +179 -0
  299. painapple_code/static/js/session/restore.js +136 -0
  300. painapple_code/static/js/session/sync.js +333 -0
  301. painapple_code/static/js/session-container-pool.js +371 -0
  302. painapple_code/static/js/session-setup-panel.js +274 -0
  303. painapple_code/static/js/session.js +1047 -0
  304. painapple_code/static/js/shortcut-hints.js +272 -0
  305. painapple_code/static/js/shortcuts.js +1146 -0
  306. painapple_code/static/js/skills-autocomplete.js +262 -0
  307. painapple_code/static/js/snippets-autocomplete.js +738 -0
  308. painapple_code/static/js/stash-refs-view.js +83 -0
  309. painapple_code/static/js/stash-ui.js +768 -0
  310. painapple_code/static/js/stash.js +785 -0
  311. painapple_code/static/js/status-bar.js +1184 -0
  312. painapple_code/static/js/token-profile.js +274 -0
  313. painapple_code/static/js/tool-renderer-blocks.js +1626 -0
  314. painapple_code/static/js/tool-renderer-thinking.js +813 -0
  315. painapple_code/static/js/tool-renderer.js +1500 -0
  316. painapple_code/static/js/tooltips.js +197 -0
  317. painapple_code/static/js/upload-manager.js +1019 -0
  318. painapple_code/static/js/utils.js +521 -0
  319. painapple_code/static/js/welcome/api.js +363 -0
  320. painapple_code/static/js/welcome/cards.js +626 -0
  321. painapple_code/static/js/welcome/context-menu.js +585 -0
  322. painapple_code/static/js/welcome/families.js +801 -0
  323. painapple_code/static/js/welcome/preview.js +234 -0
  324. painapple_code/static/js/welcome/state.js +129 -0
  325. painapple_code/static/js/welcome.js +1874 -0
  326. painapple_code/static/js/widget-system/base-widget.js +717 -0
  327. painapple_code/static/js/widget-system/device-manager.js +178 -0
  328. painapple_code/static/js/widget-system/event-bus.js +152 -0
  329. painapple_code/static/js/widget-system/icons.js +120 -0
  330. painapple_code/static/js/widget-system/index.js +49 -0
  331. painapple_code/static/js/widget-system/init.js +54 -0
  332. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  333. painapple_code/static/js/widget-system/types/floating.js +915 -0
  334. painapple_code/static/js/widget-system/types/index.js +40 -0
  335. painapple_code/static/js/widget-system/types/modal.js +147 -0
  336. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  337. painapple_code/static/js/widget-system/types/tab.js +146 -0
  338. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  339. painapple_code/static/js/widget-system/widget-manager.js +1187 -0
  340. painapple_code/static/js/widgets/about-widget.js +260 -0
  341. painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
  342. painapple_code/static/js/widgets/agents-widget.js +897 -0
  343. painapple_code/static/js/widgets/browser-widget.js +579 -0
  344. painapple_code/static/js/widgets/commands-widget.js +694 -0
  345. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  346. painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
  347. painapple_code/static/js/widgets/config/gestures.js +161 -0
  348. painapple_code/static/js/widgets/config/models-tab.js +988 -0
  349. painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
  350. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  351. painapple_code/static/js/widgets/config/state.js +517 -0
  352. painapple_code/static/js/widgets/config/system-controls.js +90 -0
  353. painapple_code/static/js/widgets/config-widget.js +1535 -0
  354. painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
  355. painapple_code/static/js/widgets/debug-widget.js +693 -0
  356. painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
  357. painapple_code/static/js/widgets/discussion-widget.js +1107 -0
  358. painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
  359. painapple_code/static/js/widgets/file-preview-widget.js +861 -0
  360. painapple_code/static/js/widgets/git-widget.js +794 -0
  361. painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
  362. painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
  363. painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
  364. painapple_code/static/js/widgets/image-preview-widget.js +692 -0
  365. painapple_code/static/js/widgets/index.js +176 -0
  366. painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
  367. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  368. painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
  369. painapple_code/static/js/widgets/search-files-widget.js +529 -0
  370. painapple_code/static/js/widgets/skills-widget.js +843 -0
  371. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  372. painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
  373. painapple_code/static/js/widgets/tasks-widget.js +379 -0
  374. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  375. painapple_code/static/js/widgets/terminal/gestures.js +357 -0
  376. painapple_code/static/js/widgets/terminal/init.js +642 -0
  377. painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
  378. painapple_code/static/js/widgets/terminal/osc52.js +213 -0
  379. painapple_code/static/js/widgets/terminal/render.js +159 -0
  380. painapple_code/static/js/widgets/terminal/size.js +54 -0
  381. painapple_code/static/js/widgets/terminal/state.js +211 -0
  382. painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
  383. painapple_code/static/js/widgets/terminal-widget.js +793 -0
  384. painapple_code/static/js/widgets/uploads-widget.js +461 -0
  385. painapple_code/static/js/widgets/zen-widget.js +1001 -0
  386. painapple_code/static/login.html +51 -0
  387. painapple_code/static/manifest.json +73 -0
  388. painapple_code/static/sw.js +293 -0
  389. painapple_code/static/vendor/LICENSES.txt +769 -0
  390. painapple_code/static/vendor/README.md +103 -0
  391. painapple_code/static/vendor/codemirror.js +35 -0
  392. painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
  393. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  394. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  395. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  396. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  397. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  398. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  399. painapple_code/static/vendor/highlight.min.js +1213 -0
  400. painapple_code/static/vendor/marked.min.js +6 -0
  401. painapple_code/static/vendor/purify.min.js +3 -0
  402. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  403. painapple_code/static/vendor/xterm.css +209 -0
  404. painapple_code/static/vendor/xterm.js +2 -0
  405. painapple_code/static/web-client.html +635 -0
  406. painapple_code/subprocess_registry.py +535 -0
  407. painapple_code/tls_cert.py +79 -0
  408. painapple_code/tools/agents/shadow-git-helper.md +400 -0
  409. painapple_code/tools/excalidraw-to-svg.js +143 -0
  410. painapple_code/tools/install-helpers.sh +148 -0
  411. painapple_code/tools/shadow-git +320 -0
  412. painapple_code/tools/shadow-query +120 -0
  413. painapple_code/tools/vegalite-to-svg.js +79 -0
  414. painapple_code/turn_query.py +766 -0
  415. painapple_code/turn_tracker.py +221 -0
  416. painapple_code/utils/__init__.py +0 -0
  417. painapple_code/utils/agent_cli.py +306 -0
  418. painapple_code/utils/chart.py +103 -0
  419. painapple_code/utils/excalidraw.py +107 -0
  420. painapple_code/utils/file_lock.py +162 -0
  421. painapple_code/utils/file_paths.py +648 -0
  422. painapple_code/utils/generate_icons.py +251 -0
  423. painapple_code/utils/proc.py +205 -0
  424. painapple_code/utils/pty_backend.py +59 -0
  425. painapple_code/utils/pty_posix.py +160 -0
  426. painapple_code/utils/pty_windows.py +245 -0
  427. painapple_code/utils/token_profiles.py +91 -0
  428. painapple_code/viewer_templates.py +657 -0
  429. painapple_code/welcome_search.py +879 -0
  430. painapple_code-1.0.0.dist-info/METADATA +292 -0
  431. painapple_code-1.0.0.dist-info/RECORD +436 -0
  432. painapple_code-1.0.0.dist-info/WHEEL +5 -0
  433. painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
  434. painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
  435. painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
  436. painapple_code-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,657 @@
1
+ """
2
+ Git API Routes - Read-only git repository operations
3
+
4
+ These endpoints provide read-only access to git repositories for
5
+ status, diff, log, and commit viewing functionality.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import re
11
+ from datetime import datetime, timezone
12
+ from fastapi import APIRouter, HTTPException
13
+
14
+ from painapple_code.utils.file_paths import resolve_work_dir
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter(prefix="/api/git", tags=["git"])
19
+
20
+
21
+ async def _run_git(cmd: list[str], cwd: str, timeout: float = 30) -> tuple[str, int]:
22
+ """Run a git command asynchronously. Returns (stdout, returncode)."""
23
+ proc = await asyncio.create_subprocess_exec(
24
+ *cmd, cwd=cwd,
25
+ stdout=asyncio.subprocess.PIPE,
26
+ stderr=asyncio.subprocess.PIPE,
27
+ )
28
+ try:
29
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
30
+ except asyncio.TimeoutError:
31
+ proc.kill()
32
+ await proc.communicate()
33
+ raise
34
+ return stdout.decode(), proc.returncode
35
+
36
+
37
+ # ═══════════════════════════════════════════════════════════════════════
38
+ # Git Status
39
+ # ═══════════════════════════════════════════════════════════════════════
40
+
41
+ @router.get("/status")
42
+ async def get_git_status(cwd: str = None):
43
+ """
44
+ Get git status with structured output.
45
+ Returns staged, modified, and untracked files with stats.
46
+ """
47
+ try:
48
+ work_dir = resolve_work_dir(cwd)
49
+ if not work_dir.exists():
50
+ raise HTTPException(status_code=404, detail="Directory not found")
51
+
52
+ # Check if inside a git repo (works for subdirectories of git repos)
53
+ stdout, returncode = await _run_git(
54
+ ["git", "rev-parse", "--is-inside-work-tree"],
55
+ str(work_dir), timeout=5
56
+ )
57
+ if returncode != 0:
58
+ return {"error": "not_a_repo", "message": "Not a git repository"}
59
+
60
+ # Get repo root so clients can resolve relative porcelain paths to absolute
61
+ stdout, returncode = await _run_git(
62
+ ["git", "rev-parse", "--show-toplevel"],
63
+ str(work_dir), timeout=5
64
+ )
65
+ root = stdout.strip() if returncode == 0 else str(work_dir)
66
+
67
+ # Get current branch
68
+ stdout, returncode = await _run_git(
69
+ ["git", "branch", "--show-current"],
70
+ str(work_dir), timeout=10
71
+ )
72
+ branch = stdout.strip() or "HEAD detached"
73
+
74
+ # Get status with porcelain format for easy parsing
75
+ stdout, returncode = await _run_git(
76
+ ["git", "status", "--porcelain=v1", "-uall"],
77
+ str(work_dir), timeout=30
78
+ )
79
+
80
+ staged = []
81
+ modified = []
82
+ untracked = []
83
+
84
+ for line in stdout.split('\n'):
85
+ line = line.rstrip() # Only strip trailing whitespace, preserve leading space
86
+ if not line:
87
+ continue
88
+
89
+ index_status = line[0] # Status in index (staged)
90
+ worktree_status = line[1] # Status in worktree
91
+ file_path = line[3:]
92
+
93
+ # Handle renamed files: "R old -> new"
94
+ if ' -> ' in file_path:
95
+ file_path = file_path.split(' -> ')[1]
96
+
97
+ file_info = {
98
+ "path": file_path,
99
+ "name": file_path.split('/')[-1],
100
+ }
101
+
102
+ # Untracked files
103
+ if index_status == '?' and worktree_status == '?':
104
+ untracked.append(file_info)
105
+ # Staged changes
106
+ elif index_status in 'MADRC':
107
+ file_info["status"] = {
108
+ 'M': 'modified',
109
+ 'A': 'added',
110
+ 'D': 'deleted',
111
+ 'R': 'renamed',
112
+ 'C': 'copied'
113
+ }.get(index_status, 'modified')
114
+ staged.append(file_info)
115
+ # Also modified in worktree?
116
+ if worktree_status == 'M':
117
+ modified.append({**file_info, "status": "modified"})
118
+ # Modified but not staged
119
+ elif worktree_status == 'M':
120
+ file_info["status"] = "modified"
121
+ modified.append(file_info)
122
+ elif worktree_status == 'D':
123
+ file_info["status"] = "deleted"
124
+ modified.append(file_info)
125
+
126
+ # Get ahead/behind info
127
+ stdout, returncode = await _run_git(
128
+ ["git", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
129
+ str(work_dir), timeout=10
130
+ )
131
+ ahead = behind = 0
132
+ if returncode == 0 and stdout.strip():
133
+ parts = stdout.strip().split()
134
+ if len(parts) == 2:
135
+ behind, ahead = int(parts[0]), int(parts[1])
136
+
137
+ # Get diff stats for staged files
138
+ staged_stats = {}
139
+ if staged:
140
+ stdout, returncode = await _run_git(
141
+ ["git", "diff", "--numstat", "--staged"],
142
+ str(work_dir), timeout=30
143
+ )
144
+ for line in stdout.strip().split('\n'):
145
+ if not line:
146
+ continue
147
+ parts = line.split('\t')
148
+ if len(parts) >= 3:
149
+ add, rm, path = parts[0], parts[1], parts[2]
150
+ # Handle binary files (shows '-' instead of numbers)
151
+ staged_stats[path] = {
152
+ 'additions': int(add) if add != '-' else 0,
153
+ 'deletions': int(rm) if rm != '-' else 0
154
+ }
155
+
156
+ # Get diff stats for modified (unstaged) files
157
+ modified_stats = {}
158
+ if modified:
159
+ stdout, returncode = await _run_git(
160
+ ["git", "diff", "--numstat"],
161
+ str(work_dir), timeout=30
162
+ )
163
+ for line in stdout.strip().split('\n'):
164
+ if not line:
165
+ continue
166
+ parts = line.split('\t')
167
+ if len(parts) >= 3:
168
+ add, rm, path = parts[0], parts[1], parts[2]
169
+ modified_stats[path] = {
170
+ 'additions': int(add) if add != '-' else 0,
171
+ 'deletions': int(rm) if rm != '-' else 0
172
+ }
173
+
174
+ # Merge stats into file info
175
+ for f in staged:
176
+ stats = staged_stats.get(f['path'], {})
177
+ f['additions'] = stats.get('additions', 0)
178
+ f['deletions'] = stats.get('deletions', 0)
179
+
180
+ for f in modified:
181
+ stats = modified_stats.get(f['path'], {})
182
+ f['additions'] = stats.get('additions', 0)
183
+ f['deletions'] = stats.get('deletions', 0)
184
+
185
+ return {
186
+ "branch": branch,
187
+ "root": root,
188
+ "staged": staged,
189
+ "modified": modified,
190
+ "untracked": untracked,
191
+ "ahead": ahead,
192
+ "behind": behind,
193
+ "summary": {
194
+ "stagedCount": len(staged),
195
+ "modifiedCount": len(modified),
196
+ "untrackedCount": len(untracked),
197
+ }
198
+ }
199
+
200
+ except asyncio.TimeoutError:
201
+ raise HTTPException(status_code=504, detail="Git command timed out")
202
+ except Exception as e:
203
+ logger.error(f"Git status error: {e}")
204
+ raise HTTPException(status_code=500, detail=str(e))
205
+
206
+
207
+ # ═══════════════════════════════════════════════════════════════════════
208
+ # Git Diff
209
+ # ═══════════════════════════════════════════════════════════════════════
210
+
211
+ @router.get("/diff")
212
+ async def get_git_diff(cwd: str = None, file: str = None, staged: bool = False):
213
+ """
214
+ Get git diff for a file or all changes.
215
+ - file: specific file path (optional)
216
+ - staged: if true, show staged changes (--cached)
217
+ """
218
+ try:
219
+ work_dir = resolve_work_dir(cwd)
220
+
221
+ cmd = ["git", "diff", "--no-color"]
222
+ if staged:
223
+ cmd.append("--cached")
224
+ if file:
225
+ cmd.append("--")
226
+ cmd.append(file)
227
+
228
+ stdout, returncode = await _run_git(cmd, str(work_dir), timeout=30)
229
+
230
+ # Parse diff into structured format
231
+ diff_text = stdout
232
+ files = []
233
+ current_file = None
234
+
235
+ for line in diff_text.split('\n'):
236
+ if line.startswith('diff --git'):
237
+ if current_file:
238
+ files.append(current_file)
239
+ # Extract file path from "diff --git a/path b/path"
240
+ parts = line.split(' b/')
241
+ file_path = parts[-1] if len(parts) > 1 else ''
242
+ current_file = {
243
+ "path": file_path,
244
+ "hunks": [],
245
+ "additions": 0,
246
+ "deletions": 0,
247
+ }
248
+ elif line.startswith('@@') and current_file:
249
+ current_file["hunks"].append({
250
+ "header": line,
251
+ "lines": []
252
+ })
253
+ elif current_file and current_file["hunks"]:
254
+ hunk = current_file["hunks"][-1]
255
+ hunk["lines"].append(line)
256
+ if line.startswith('+') and not line.startswith('+++'):
257
+ current_file["additions"] += 1
258
+ elif line.startswith('-') and not line.startswith('---'):
259
+ current_file["deletions"] += 1
260
+
261
+ if current_file:
262
+ files.append(current_file)
263
+
264
+ return {
265
+ "files": files,
266
+ "raw": diff_text,
267
+ "summary": {
268
+ "filesChanged": len(files),
269
+ "additions": sum(f["additions"] for f in files),
270
+ "deletions": sum(f["deletions"] for f in files),
271
+ }
272
+ }
273
+
274
+ except asyncio.TimeoutError:
275
+ raise HTTPException(status_code=504, detail="Git command timed out")
276
+ except Exception as e:
277
+ logger.error(f"Git diff error: {e}")
278
+ raise HTTPException(status_code=500, detail=str(e))
279
+
280
+
281
+ # ═══════════════════════════════════════════════════════════════════════
282
+ # Git File Content (for side-by-side diff viewer)
283
+ # ═══════════════════════════════════════════════════════════════════════
284
+
285
+ @router.get("/file-content")
286
+ async def get_git_file_content(file: str, cwd: str = None, staged: bool = False, commit: str = None):
287
+ """
288
+ Get old/new versions of a file for side-by-side diff.
289
+ If commit is provided, compares commit~1 vs commit.
290
+ Otherwise compares HEAD vs working/staged.
291
+ """
292
+ try:
293
+ work_dir = resolve_work_dir(cwd)
294
+
295
+ if commit:
296
+ # Validate commit hash
297
+ if not re.match(r'^[0-9a-fA-F]{4,40}$', commit):
298
+ raise HTTPException(status_code=400, detail="Invalid commit hash")
299
+ # Compare parent vs commit
300
+ old_stdout, old_rc = await _run_git(
301
+ ["git", "show", f"{commit}~1:{file}"], str(work_dir), timeout=10
302
+ )
303
+ old_content = old_stdout if old_rc == 0 else ''
304
+ new_stdout, new_rc = await _run_git(
305
+ ["git", "show", f"{commit}:{file}"], str(work_dir), timeout=10
306
+ )
307
+ new_content = new_stdout if new_rc == 0 else ''
308
+ old_label = f"{commit[:8]}~1"
309
+ new_label = commit[:8]
310
+ else:
311
+ # Get HEAD version
312
+ old_stdout, old_rc = await _run_git(
313
+ ["git", "show", f"HEAD:{file}"], str(work_dir), timeout=10
314
+ )
315
+ old_content = old_stdout if old_rc == 0 else ''
316
+
317
+ if staged:
318
+ # Get staged (index) version
319
+ new_stdout, new_rc = await _run_git(
320
+ ["git", "show", f":{file}"], str(work_dir), timeout=10
321
+ )
322
+ new_content = new_stdout if new_rc == 0 else ''
323
+ else:
324
+ # Read working tree version — confine to work_dir (no ../ escape)
325
+ import pathlib
326
+ work_root = pathlib.Path(str(work_dir)).resolve()
327
+ file_path = (work_root / file).resolve()
328
+ if not file_path.is_relative_to(work_root):
329
+ raise HTTPException(status_code=400, detail="File path escapes working directory")
330
+ try:
331
+ new_content = file_path.read_text(encoding="utf-8", errors='replace')
332
+ except FileNotFoundError:
333
+ new_content = ''
334
+ old_label = "HEAD"
335
+ new_label = "Staged" if staged else "Working Tree"
336
+
337
+ return {
338
+ "file": file,
339
+ "old": old_content,
340
+ "new": new_content,
341
+ "oldLabel": old_label,
342
+ "newLabel": new_label
343
+ }
344
+
345
+ except asyncio.TimeoutError:
346
+ raise HTTPException(status_code=504, detail="Git command timed out")
347
+ except HTTPException:
348
+ raise
349
+ except Exception as e:
350
+ logger.error(f"Git file-content error: {e}")
351
+ raise HTTPException(status_code=500, detail=str(e))
352
+
353
+
354
+ # ═══════════════════════════════════════════════════════════════════════
355
+ # Git Log
356
+ # ═══════════════════════════════════════════════════════════════════════
357
+
358
+ @router.get("/log")
359
+ async def get_git_log(cwd: str = None, limit: int = 20, skip: int = 0):
360
+ """
361
+ Get recent commit history.
362
+ """
363
+ try:
364
+ work_dir = resolve_work_dir(cwd)
365
+
366
+ # Get commits with detailed format
367
+ stdout, returncode = await _run_git(
368
+ [
369
+ "git", "log",
370
+ f"--skip={skip}",
371
+ f"-{limit}",
372
+ "--format=%H%n%h%n%an%n%ae%n%at%n%s%n%b%n---COMMIT_END---"
373
+ ],
374
+ str(work_dir), timeout=30
375
+ )
376
+
377
+ commits = []
378
+ entries = stdout.split('---COMMIT_END---')
379
+
380
+ for entry in entries:
381
+ lines = entry.strip().split('\n')
382
+ if len(lines) >= 6:
383
+ hash_full = lines[0]
384
+ hash_short = lines[1]
385
+ author_name = lines[2]
386
+ author_email = lines[3]
387
+ timestamp = int(lines[4]) if lines[4].isdigit() else 0
388
+ subject = lines[5]
389
+ body = '\n'.join(lines[6:]).strip() if len(lines) > 6 else ''
390
+
391
+ commits.append({
392
+ "hash": hash_full,
393
+ "hashShort": hash_short,
394
+ "author": author_name,
395
+ "email": author_email,
396
+ "timestamp": timestamp,
397
+ "date": datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() if timestamp else None,
398
+ "subject": subject,
399
+ "body": body,
400
+ })
401
+
402
+ # Get total commit count
403
+ count_stdout, count_returncode = await _run_git(
404
+ ["git", "rev-list", "--count", "HEAD"],
405
+ str(work_dir), timeout=10
406
+ )
407
+ total = int(count_stdout.strip()) if count_returncode == 0 else len(commits)
408
+
409
+ return {
410
+ "commits": commits,
411
+ "total": total,
412
+ "limit": limit,
413
+ "skip": skip,
414
+ "hasMore": skip + len(commits) < total,
415
+ }
416
+
417
+ except asyncio.TimeoutError:
418
+ raise HTTPException(status_code=504, detail="Git command timed out")
419
+ except Exception as e:
420
+ logger.error(f"Git log error: {e}")
421
+ raise HTTPException(status_code=500, detail=str(e))
422
+
423
+
424
+ # ═══════════════════════════════════════════════════════════════════════
425
+ # Git Show (Commit Details)
426
+ # ═══════════════════════════════════════════════════════════════════════
427
+
428
+ @router.get("/show/{commit_hash}")
429
+ async def get_git_show(commit_hash: str, cwd: str = None):
430
+ """
431
+ Get details and diff for a specific commit.
432
+ """
433
+ try:
434
+ work_dir = resolve_work_dir(cwd)
435
+
436
+ # Validate commit hash (prevent injection)
437
+ if not re.match(r'^[a-fA-F0-9]{7,40}$', commit_hash):
438
+ raise HTTPException(status_code=400, detail="Invalid commit hash")
439
+
440
+ # Get commit details
441
+ stdout, returncode = await _run_git(
442
+ [
443
+ "git", "show",
444
+ "--format=%H%n%h%n%an%n%ae%n%at%n%s%n%b%n---DETAILS_END---",
445
+ "--stat",
446
+ "--no-color",
447
+ commit_hash
448
+ ],
449
+ str(work_dir), timeout=30
450
+ )
451
+
452
+ if returncode != 0:
453
+ raise HTTPException(status_code=404, detail="Commit not found")
454
+
455
+ # Parse output
456
+ parts = stdout.split('---DETAILS_END---')
457
+ details_lines = parts[0].strip().split('\n')
458
+ stats = parts[1].strip() if len(parts) > 1 else ''
459
+
460
+ commit = {
461
+ "hash": details_lines[0] if len(details_lines) > 0 else '',
462
+ "hashShort": details_lines[1] if len(details_lines) > 1 else '',
463
+ "author": details_lines[2] if len(details_lines) > 2 else '',
464
+ "email": details_lines[3] if len(details_lines) > 3 else '',
465
+ "timestamp": int(details_lines[4]) if len(details_lines) > 4 and details_lines[4].isdigit() else 0,
466
+ "subject": details_lines[5] if len(details_lines) > 5 else '',
467
+ "body": '\n'.join(details_lines[6:]).strip() if len(details_lines) > 6 else '',
468
+ "stats": stats,
469
+ }
470
+
471
+ # Get the diff
472
+ diff_stdout, diff_returncode = await _run_git(
473
+ ["git", "show", "--no-color", "--format=", commit_hash],
474
+ str(work_dir), timeout=30
475
+ )
476
+
477
+ # Parse diff
478
+ files = []
479
+ current_file = None
480
+ for line in diff_stdout.split('\n'):
481
+ if line.startswith('diff --git'):
482
+ if current_file:
483
+ files.append(current_file)
484
+ parts = line.split(' b/')
485
+ file_path = parts[-1] if len(parts) > 1 else ''
486
+ current_file = {
487
+ "path": file_path,
488
+ "hunks": [],
489
+ "additions": 0,
490
+ "deletions": 0,
491
+ }
492
+ elif line.startswith('@@') and current_file:
493
+ current_file["hunks"].append({
494
+ "header": line,
495
+ "lines": []
496
+ })
497
+ elif current_file and current_file["hunks"]:
498
+ hunk = current_file["hunks"][-1]
499
+ hunk["lines"].append(line)
500
+ if line.startswith('+') and not line.startswith('+++'):
501
+ current_file["additions"] += 1
502
+ elif line.startswith('-') and not line.startswith('---'):
503
+ current_file["deletions"] += 1
504
+
505
+ if current_file:
506
+ files.append(current_file)
507
+
508
+ return {
509
+ "commit": commit,
510
+ "files": files,
511
+ "diffRaw": diff_stdout,
512
+ }
513
+
514
+ except HTTPException:
515
+ raise
516
+ except asyncio.TimeoutError:
517
+ raise HTTPException(status_code=504, detail="Git command timed out")
518
+ except Exception as e:
519
+ logger.error(f"Git show error: {e}")
520
+ raise HTTPException(status_code=500, detail=str(e))
521
+
522
+
523
+ # ═══════════════════════════════════════════════════════════════════════
524
+ # Git File at Ref (for compare wizard)
525
+ # ═══════════════════════════════════════════════════════════════════════
526
+
527
+ @router.get("/file-at-ref")
528
+ async def get_git_file_at_ref(file: str, ref: str, cwd: str = None):
529
+ """
530
+ Get file content at a specific git ref (commit hash or branch name).
531
+ """
532
+ try:
533
+ work_dir = resolve_work_dir(cwd)
534
+
535
+ # Validate ref: allow hex hashes, branch names, HEAD~N, tags.
536
+ # Forbid a leading '-'/'.' so the ref can't smuggle a git option.
537
+ if not re.match(r'^(?![-.])[a-zA-Z0-9_./-]+(?:~\d+)?(?:\^\d+)?$', ref):
538
+ raise HTTPException(status_code=400, detail="Invalid ref")
539
+
540
+ stdout, rc = await _run_git(
541
+ ["git", "show", f"{ref}:{file}"], str(work_dir), timeout=10
542
+ )
543
+
544
+ if rc != 0:
545
+ raise HTTPException(status_code=404, detail="File not found at ref")
546
+
547
+ return {"content": stdout, "file": file, "ref": ref}
548
+
549
+ except HTTPException:
550
+ raise
551
+ except asyncio.TimeoutError:
552
+ raise HTTPException(status_code=504, detail="Git command timed out")
553
+ except Exception as e:
554
+ logger.error(f"Git file-at-ref error: {e}")
555
+ raise HTTPException(status_code=500, detail=str(e))
556
+
557
+
558
+ # ═══════════════════════════════════════════════════════════════════════
559
+ # Git File Log (commits touching a specific file)
560
+ # ═══════════════════════════════════════════════════════════════════════
561
+
562
+ @router.get("/file-log")
563
+ async def get_git_file_log(file: str, cwd: str = None, limit: int = 30):
564
+ """
565
+ Get commit history for a specific file.
566
+ """
567
+ try:
568
+ work_dir = resolve_work_dir(cwd)
569
+
570
+ stdout, returncode = await _run_git(
571
+ [
572
+ "git", "log",
573
+ f"-{limit}",
574
+ "--format=%H|%h|%an|%at|%s",
575
+ "--follow",
576
+ "--", file
577
+ ],
578
+ str(work_dir), timeout=30
579
+ )
580
+
581
+ if returncode != 0 or not stdout.strip():
582
+ return {"commits": [], "total": 0, "file": file}
583
+
584
+ commits = []
585
+ for line in stdout.strip().split('\n'):
586
+ if not line:
587
+ continue
588
+ parts = line.split('|', 4)
589
+ if len(parts) >= 5:
590
+ commits.append({
591
+ "hash": parts[0],
592
+ "hashShort": parts[1],
593
+ "author": parts[2],
594
+ "timestamp": int(parts[3]) if parts[3].isdigit() else 0,
595
+ "subject": parts[4],
596
+ })
597
+
598
+ return {"commits": commits, "total": len(commits), "file": file}
599
+
600
+ except asyncio.TimeoutError:
601
+ raise HTTPException(status_code=504, detail="Git command timed out")
602
+ except Exception as e:
603
+ logger.error(f"Git file-log error: {e}")
604
+ raise HTTPException(status_code=500, detail=str(e))
605
+
606
+
607
+ # ═══════════════════════════════════════════════════════════════════════
608
+ # Git Branches
609
+ # ═══════════════════════════════════════════════════════════════════════
610
+
611
+ @router.get("/branches")
612
+ async def get_git_branches(cwd: str = None):
613
+ """
614
+ List all local branches with latest commit info.
615
+ """
616
+ try:
617
+ work_dir = resolve_work_dir(cwd)
618
+
619
+ # Get current branch
620
+ cur_stdout, _ = await _run_git(
621
+ ["git", "branch", "--show-current"], str(work_dir), timeout=5
622
+ )
623
+ current_branch = cur_stdout.strip()
624
+
625
+ # Get all branches with commit info
626
+ stdout, returncode = await _run_git(
627
+ [
628
+ "git", "branch",
629
+ "--format=%(refname:short)|%(objectname:short)|%(committerdate:unix)|%(subject)"
630
+ ],
631
+ str(work_dir), timeout=10
632
+ )
633
+
634
+ if returncode != 0:
635
+ return {"branches": [], "current": current_branch}
636
+
637
+ branches = []
638
+ for line in stdout.strip().split('\n'):
639
+ if not line:
640
+ continue
641
+ parts = line.split('|', 3)
642
+ if len(parts) >= 4:
643
+ branches.append({
644
+ "name": parts[0],
645
+ "hashShort": parts[1],
646
+ "timestamp": int(parts[2]) if parts[2].isdigit() else 0,
647
+ "subject": parts[3],
648
+ "isCurrent": parts[0] == current_branch,
649
+ })
650
+
651
+ return {"branches": branches, "current": current_branch}
652
+
653
+ except asyncio.TimeoutError:
654
+ raise HTTPException(status_code=504, detail="Git command timed out")
655
+ except Exception as e:
656
+ logger.error(f"Git branches error: {e}")
657
+ raise HTTPException(status_code=500, detail=str(e))