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,869 @@
1
+ /**
2
+ * Inline edit for rendered markdown
3
+ *
4
+ * Click on any rendered block (paragraph, heading, list item, blockquote)
5
+ * to edit its raw markdown source inline. Press Enter to save, Escape to cancel.
6
+ */
7
+
8
+ import { state, fns, activateStateInstance } from './preview-state.js';
9
+ import { showToast } from './preview-utils.js';
10
+ import { appConfirm } from '../utils.js';
11
+ import { CONFIG } from '../config.js';
12
+ import S from '../strings.js';
13
+
14
+ let currentEdit = null;
15
+ let discardConfirmOpen = false;
16
+
17
+ // ─── Per-instance state resolution ──────────────────────────────────────
18
+ // The preview has one PreviewState per widget tab / per session (see
19
+ // preview-state.js), but this module used to keep the edit-mode flag in a
20
+ // module-level boolean — so a floating preview and a tab preview shared one
21
+ // flag, and whichever instance toggled last won. Symptoms: pencil not
22
+ // highlighting, Esc reporting "edit mode off" while the visible pane stayed
23
+ // active, `e` acting on a hidden instance. The flag now lives on PreviewState
24
+ // (state.inlineEdit); each rendered instance stamps its state onto its
25
+ // `.file-preview-widget` host so callers holding only a DOM node (Esc handler,
26
+ // keyboard shortcut) can resolve the right instance.
27
+
28
+ /** Normalize any preview-related element to its `.file-preview-widget` host. */
29
+ function hostOf(el) {
30
+ if (!el) return null;
31
+ if (el.classList?.contains('file-preview-widget')) return el;
32
+ return el.querySelector?.('.file-preview-widget')
33
+ || el.closest?.('.file-preview-widget')
34
+ || el;
35
+ }
36
+
37
+ /** Resolve the PreviewState instance owning `el`; falls back to the active state. */
38
+ function stateFor(el) {
39
+ return hostOf(el)?._previewState || state;
40
+ }
41
+
42
+ const TRASH_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
43
+
44
+ const RESTORE_SVG = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
45
+
46
+ /**
47
+ * POST new file content, throwing the server's own reason on failure.
48
+ * ("Path not allowed" / "Permission denied" / "HTTP 500" beats a bare
49
+ * "Save failed" that hides why the write was rejected.)
50
+ */
51
+ async function writeSource(content) {
52
+ const resp = await fetch(`${CONFIG.API_BASE}/api/file/write`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ path: state.currentPath, content })
56
+ });
57
+ if (!resp.ok) {
58
+ const data = await resp.json().catch(() => ({}));
59
+ throw new Error(data.detail || `HTTP ${resp.status}`);
60
+ }
61
+ return await resp.json().catch(() => ({}));
62
+ }
63
+
64
+ /** "Save failed — Path not allowed" */
65
+ function saveFailedMsg(err, base) {
66
+ const label = base || S.preview?.inline_edit_save_failed || 'Save failed';
67
+ const reason = err?.message && err.message !== 'Save failed' ? err.message : '';
68
+ return reason ? `${label} — ${reason}` : label;
69
+ }
70
+
71
+ // Optional `el`: resolve the instance owning that element (event handlers);
72
+ // without it, reports the ACTIVE instance (render-time callers, where the
73
+ // module state pointer is guaranteed correct).
74
+ export function isInlineEditActive(el) { return !!stateFor(el).inlineEdit; }
75
+
76
+ // True while a block's textarea is open — used by the file-change poll to avoid
77
+ // a rerenderContent() that would wipe the in-progress edit (and its keystrokes).
78
+ export function isInlineEditInProgress() { return !!currentEdit; }
79
+
80
+ export function toggleInlineEdit(container) {
81
+ const host = hostOf(container);
82
+ const s = host?._previewState || state;
83
+ // The user is engaging with THIS instance — make it the active one, so the
84
+ // downstream machinery keyed on the module state pointer (rerenderContent,
85
+ // save paths) targets the pane the user is actually looking at.
86
+ activateStateInstance(s);
87
+ s.inlineEdit = !s.inlineEdit;
88
+ const active = s.inlineEdit;
89
+ const scope = host || container;
90
+ const rendered = scope?.querySelector('.preview-rendered');
91
+ if (rendered) {
92
+ rendered.classList.toggle('inline-edit-mode', active);
93
+ }
94
+ const btn = scope?.querySelector('.inline-edit-toggle');
95
+ if (btn) {
96
+ btn.classList.toggle('active', active);
97
+ btn.setAttribute('data-tooltip', active
98
+ ? (S.preview?.inline_edit_disable || 'Disable inline editing')
99
+ : (S.preview?.inline_edit_enable || 'Click to edit'));
100
+ }
101
+ // Cancel any in-progress edit when toggling THIS instance off
102
+ if (!active && currentEdit && (!scope || scope.contains(currentEdit.element))) {
103
+ cancelEdit();
104
+ }
105
+ return active;
106
+ }
107
+
108
+ export function setInlineEdit(val) {
109
+ state.inlineEdit = val;
110
+ }
111
+
112
+ /**
113
+ * Setup inline edit click handlers on rendered markdown container
114
+ */
115
+ export function setupInlineEdit(container) {
116
+ // Setup runs during render, when the module state pointer IS this
117
+ // instance's state (render() calls activateState first). Capture it and
118
+ // stamp it on the host so later interactions resolve the right instance.
119
+ const s = state;
120
+ const host = hostOf(container);
121
+ if (host) host._previewState = s;
122
+
123
+ const rendered = container.querySelector('.preview-rendered');
124
+ if (!rendered) return;
125
+
126
+ // Restore mode class if re-rendered while this instance is active
127
+ if (s.inlineEdit) {
128
+ rendered.classList.add('inline-edit-mode');
129
+ }
130
+
131
+ // Attach trash button to each selectable (hidden via CSS; shown on hover in edit mode)
132
+ addTrashButtons(rendered);
133
+
134
+ rendered.addEventListener('click', (e) => {
135
+ if (!s.inlineEdit) return;
136
+ // Clicked into this instance — the pointer-keyed machinery follows.
137
+ activateStateInstance(s);
138
+
139
+ // Trash click → delete block directly (no edit step)
140
+ const trashBtn = e.target.closest('.inline-edit-trash-btn');
141
+ if (trashBtn) {
142
+ e.preventDefault();
143
+ e.stopPropagation();
144
+ const block = trashBtn.closest('[data-selectable]');
145
+ if (!block) return;
146
+ // If editing another block, ignore. If editing this block, cancel first.
147
+ if (currentEdit && currentEdit.element !== block) return;
148
+ if (currentEdit && currentEdit.element === block) cancelEdit();
149
+ deleteBlockByElement(block);
150
+ return;
151
+ }
152
+
153
+ if (currentEdit) return;
154
+
155
+ // Find the closest selectable element. Skip nested ones — outer wins,
156
+ // so there are no edits-inside-edits.
157
+ const target = e.target.closest('[data-selectable]:not(.inline-edit-nested)');
158
+ if (!target) return;
159
+ if (target.classList.contains('inline-edit-placeholder')) return;
160
+ if (target.querySelector('.inline-edit-textarea')) return;
161
+
162
+ e.preventDefault();
163
+ e.stopPropagation();
164
+
165
+ const visualOffset = getClickTextOffset(target, e.clientX, e.clientY);
166
+ startEdit(target, rendered, visualOffset);
167
+ });
168
+
169
+ // Toggle button
170
+ const toggleBtn = container.querySelector('.inline-edit-toggle');
171
+ if (toggleBtn) {
172
+ toggleBtn.addEventListener('click', (e) => {
173
+ e.preventDefault();
174
+ toggleInlineEdit(container);
175
+ });
176
+ }
177
+ }
178
+
179
+ function addTrashButtons(rendered) {
180
+ const selectables = rendered.querySelectorAll('[data-selectable]');
181
+ selectables.forEach(el => {
182
+ if (el.classList.contains('inline-edit-placeholder')) return;
183
+ // Suppress nested selectables: if any ancestor inside `rendered` also has
184
+ // [data-selectable], this one is inert (no trash, no hover outline). The
185
+ // outer block is the edit target.
186
+ if (hasSelectableAncestor(el, rendered)) {
187
+ el.classList.add('inline-edit-nested');
188
+ return;
189
+ }
190
+ el.classList.remove('inline-edit-nested');
191
+ if (el.querySelector(':scope > .inline-edit-trash-btn')) return;
192
+ const btn = document.createElement('button');
193
+ btn.type = 'button';
194
+ btn.className = 'inline-edit-trash-btn';
195
+ btn.setAttribute('data-tooltip', S.preview?.inline_edit_delete_tooltip || 'Delete this block');
196
+ btn.setAttribute('contenteditable', 'false');
197
+ btn.innerHTML = TRASH_SVG;
198
+ // mousedown preventDefault keeps textarea focus intact while editing
199
+ btn.addEventListener('mousedown', (e) => e.preventDefault());
200
+ el.appendChild(btn);
201
+ });
202
+ }
203
+
204
+ function hasSelectableAncestor(el, root) {
205
+ let cur = el.parentElement;
206
+ while (cur && cur !== root) {
207
+ if (cur.hasAttribute('data-selectable')) return true;
208
+ cur = cur.parentElement;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ // ─── Edit lifecycle ────────────────────────────────────────────────────
214
+
215
+ function startEdit(element, _rendered, visualOffset = null) {
216
+ const renderedText = element.textContent.trim();
217
+ if (!renderedText) return;
218
+
219
+ // DOM source indexes refer to the content at render time. If deletes are
220
+ // pending (no re-render since), use the saved base. Otherwise state.content.
221
+ const base = state._deleteBase || state.content;
222
+
223
+ // Prefer source map attributes injected during rendering
224
+ let sourceInfo = null;
225
+ const srcStart = element.getAttribute('data-source-start');
226
+ const srcEnd = element.getAttribute('data-source-end');
227
+ if (srcStart != null && srcEnd != null && base) {
228
+ const startIdx = parseInt(srcStart, 10);
229
+ const endIdx = parseInt(srcEnd, 10);
230
+ const raw = base.substring(startIdx, endIdx);
231
+ // Trim trailing newline from raw token (marked includes it) for cleaner editing
232
+ const trimmed = raw.replace(/\n+$/, '');
233
+ sourceInfo = { raw: trimmed, startIdx, endIdx: startIdx + trimmed.length };
234
+ }
235
+
236
+ // Fallback to fuzzy text matching when source map is unavailable
237
+ if (!sourceInfo) {
238
+ sourceInfo = findSourceMarkdown(base, renderedText, element);
239
+ }
240
+
241
+ if (!sourceInfo) {
242
+ showToast(S.preview?.inline_edit_not_found || 'Could not locate in source');
243
+ return;
244
+ }
245
+
246
+ const { raw, startIdx, endIdx } = sourceInfo;
247
+
248
+ // Store original state
249
+ const originalHtml = element.innerHTML;
250
+ element.classList.add('inline-editing');
251
+
252
+ // Create textarea with raw markdown
253
+ const textarea = document.createElement('textarea');
254
+ textarea.className = 'inline-edit-textarea';
255
+ // rows defaults to 2, and autoResize measures scrollHeight after setting
256
+ // height:auto — i.e. against the intrinsic 2-row box — so without this every
257
+ // single-line block grew by a full line on entering edit (a heading by its own
258
+ // larger line-height). rows=1 makes the floor one line; scrollHeight still
259
+ // reports the true height for anything taller.
260
+ textarea.rows = 1;
261
+ // Block only the keys the textarea handles itself (Enter/Escape/Tab) —
262
+ // global shortcuts like Ctrl+/ (focus chat input) must still fire.
263
+ textarea.dataset.shortcutsDisabled = 'enter,escape,tab';
264
+ textarea.value = raw;
265
+ textarea.spellcheck = true;
266
+
267
+ // Preserve the hover trash button (added by addTrashButtons) across content swap
268
+ const savedTrash = element.querySelector(':scope > .inline-edit-trash-btn');
269
+
270
+ // Replace content
271
+ element.textContent = '';
272
+ if (savedTrash) element.appendChild(savedTrash);
273
+ element.appendChild(textarea);
274
+
275
+ // Auto-size
276
+ autoResize(textarea);
277
+ textarea.focus();
278
+
279
+ // Place cursor where the user clicked (approximate — skips markdown syntax
280
+ // like `**` and leading `# `/`- `). Falls back to end of text.
281
+ const cursorPos = visualOffset != null
282
+ ? mapVisualToSource(textarea.value, visualOffset)
283
+ : textarea.value.length;
284
+ textarea.selectionStart = textarea.selectionEnd = cursorPos;
285
+
286
+ currentEdit = { element, textarea, originalHtml, raw, startIdx, endIdx };
287
+
288
+ textarea.addEventListener('input', () => autoResize(textarea));
289
+
290
+ // Use capture phase to intercept before the global shortcut system
291
+ // (shortcuts.js registers on document with capture: true)
292
+ textarea.addEventListener('keydown', (e) => {
293
+ if (e.key === 'Enter' && !e.shiftKey) {
294
+ e.preventDefault();
295
+ e.stopPropagation();
296
+ commitEdit();
297
+ } else if (e.key === 'Escape') {
298
+ e.preventDefault();
299
+ e.stopPropagation();
300
+ // Don't silently drop the user's work — only auto-close when clean.
301
+ if (textarea.value !== currentEdit.raw) {
302
+ const msg = S.preview?.inline_edit_discard_confirm || 'Discard unsaved changes?';
303
+ discardConfirmOpen = true;
304
+ appConfirm(msg, { confirmLabel: 'Discard', danger: true }).then(ok => {
305
+ discardConfirmOpen = false;
306
+ if (ok) cancelEdit();
307
+ else textarea.focus();
308
+ });
309
+ return;
310
+ }
311
+ cancelEdit();
312
+ } else if (e.key === 'Tab' && !e.shiftKey) {
313
+ e.preventDefault();
314
+ e.stopPropagation();
315
+ const { selectionStart: start, selectionEnd: end, value } = textarea;
316
+ textarea.value = value.slice(0, start) + '\t' + value.slice(end);
317
+ textarea.selectionStart = textarea.selectionEnd = start + 1;
318
+ autoResize(textarea);
319
+ }
320
+ }, true);
321
+
322
+ // iOS/WebKit can fire a spurious blur immediately after a programmatic
323
+ // focus() made inside a click handler. Left unguarded, that blur runs the
324
+ // commit/cancel path below and — since nothing's been typed yet — instantly
325
+ // cancelEdit()s, so the block flashes into a textarea and snaps straight
326
+ // back to rendered ("jumps for a second and back to normal"). Ignore blurs
327
+ // until the focus has settled; if one slips through early, reclaim focus
328
+ // instead of tearing the edit down.
329
+ let blurArmed = false;
330
+ setTimeout(() => { blurArmed = true; }, 250);
331
+
332
+ // Clicking outside: commit if user made changes, cancel if untouched.
333
+ // Delay lets Enter/click-handlers run first; commit-on-blur avoids losing work.
334
+ textarea.addEventListener('blur', () => {
335
+ if (!blurArmed) {
336
+ // Focus bounce right after open — keep the just-opened edit alive.
337
+ if (currentEdit?.textarea === textarea && !discardConfirmOpen) {
338
+ textarea.focus();
339
+ }
340
+ return;
341
+ }
342
+ setTimeout(() => {
343
+ // Focus moved to the discard-confirm dialog, not away from the edit
344
+ if (discardConfirmOpen) return;
345
+ if (currentEdit?.textarea !== textarea) return;
346
+ if (textarea.value !== currentEdit.raw) {
347
+ commitEdit();
348
+ } else {
349
+ cancelEdit();
350
+ }
351
+ }, 200);
352
+ });
353
+ }
354
+
355
+ function autoResize(textarea) {
356
+ textarea.style.height = 'auto';
357
+ textarea.style.height = textarea.scrollHeight + 'px';
358
+ }
359
+
360
+ function cancelEdit() {
361
+ if (!currentEdit) return;
362
+ const { element, originalHtml } = currentEdit;
363
+ element.innerHTML = originalHtml;
364
+ element.classList.remove('inline-editing');
365
+ currentEdit = null;
366
+ }
367
+
368
+ async function commitEdit() {
369
+ if (!currentEdit) return;
370
+ const { textarea, raw, startIdx, endIdx } = currentEdit;
371
+ const newRaw = textarea.value;
372
+
373
+ if (newRaw === raw) {
374
+ cancelEdit();
375
+ return;
376
+ }
377
+
378
+ // startIdx/endIdx are into the base (pre-delete content, same indexes as DOM attrs).
379
+ // When deletes are pending, we splice into the base then reapply pending deletes to get
380
+ // the save content.
381
+ const base = state._deleteBase || state.content;
382
+ if (!base) {
383
+ cancelEdit();
384
+ return;
385
+ }
386
+
387
+ const newBase = base.substring(0, startIdx) + newRaw + base.substring(endIdx);
388
+ const deletes = state._deletes || [];
389
+ const newSaveContent = deletes.length ? buildContentWithDeletes(newBase, deletes) : newBase;
390
+
391
+ try {
392
+ await writeSource(newSaveContent);
393
+
394
+ state.content = newSaveContent;
395
+ if (state._deleteBase) state._deleteBase = newBase;
396
+ currentEdit = null;
397
+
398
+ // Re-render wipes pending-delete placeholders (by design: a save action finalizes them)
399
+ fns.rerenderContent();
400
+ showToast(S.toast?.file_saved || 'Saved');
401
+ } catch (err) {
402
+ console.error('[InlineEdit] Save failed:', err);
403
+ showToast(saveFailedMsg(err));
404
+ cancelEdit();
405
+ }
406
+ }
407
+
408
+ // ─── Delete + restore (in-place placeholder) ────────────────────────────
409
+
410
+ async function deleteBlockByElement(element) {
411
+ const srcStart = element.getAttribute('data-source-start');
412
+ const srcEnd = element.getAttribute('data-source-end');
413
+ if (srcStart == null || srcEnd == null) {
414
+ showToast(S.preview?.inline_edit_not_found || 'Could not locate in source');
415
+ return;
416
+ }
417
+
418
+ // On first delete of this session, snapshot state.content as the base.
419
+ // All data-source-start/end refer to this base.
420
+ if (!state._deleteBase) {
421
+ state._deleteBase = state.content;
422
+ state._deletes = [];
423
+ }
424
+ const base = state._deleteBase;
425
+
426
+ const origStart = parseInt(srcStart, 10);
427
+ let origEnd = parseInt(srcEnd, 10);
428
+ if (base[origEnd] === '\n') origEnd++;
429
+
430
+ // Build placeholder matching the original tag so we stay valid in its container
431
+ const placeholder = document.createElement(element.tagName);
432
+ placeholder.className = 'inline-edit-placeholder';
433
+ const labelText = S.preview?.inline_edit_deleted || 'Block deleted';
434
+ const restoreLabel = S.preview?.inline_edit_restore || 'Restore';
435
+ placeholder.innerHTML =
436
+ '<span class="inline-edit-placeholder-label">' + labelText + '</span>' +
437
+ '<button type="button" class="inline-edit-restore-btn">' +
438
+ RESTORE_SVG +
439
+ '<span>' + restoreLabel + '</span>' +
440
+ '</button>';
441
+
442
+ const record = { origStart, origEnd, placeholder, originalEl: element };
443
+
444
+ placeholder.querySelector('.inline-edit-restore-btn').addEventListener('click', (e) => {
445
+ e.preventDefault();
446
+ e.stopPropagation();
447
+ restoreDeletedBlock(record);
448
+ });
449
+
450
+ state._deletes.push(record);
451
+ element.parentNode.replaceChild(placeholder, element);
452
+
453
+ const newContent = buildContentWithDeletes(base, state._deletes);
454
+
455
+ try {
456
+ const result = await writeSource(newContent);
457
+ state.content = newContent;
458
+ // Update mtime so preview-poll's silentReload doesn't trigger and wipe placeholders
459
+ if (result.mtime) state.mtime = result.mtime;
460
+ } catch (err) {
461
+ // Revert: swap original element back, drop the record
462
+ if (placeholder.parentNode) {
463
+ placeholder.parentNode.replaceChild(element, placeholder);
464
+ }
465
+ state._deletes = state._deletes.filter(d => d !== record);
466
+ if (state._deletes.length === 0) {
467
+ delete state._deleteBase;
468
+ state._deletes = null;
469
+ }
470
+ console.error('[InlineEdit] Delete failed:', err);
471
+ showToast(saveFailedMsg(err, S.preview?.inline_edit_delete_failed || 'Delete failed'));
472
+ }
473
+ }
474
+
475
+ async function restoreDeletedBlock(record) {
476
+ const base = state._deleteBase;
477
+ if (!base) return;
478
+
479
+ const remaining = (state._deletes || []).filter(d => d !== record);
480
+ const newContent = buildContentWithDeletes(base, remaining);
481
+
482
+ try {
483
+ const result = await writeSource(newContent);
484
+
485
+ state.content = newContent;
486
+ state._deletes = remaining;
487
+ if (result.mtime) state.mtime = result.mtime;
488
+
489
+ // Swap placeholder back with the original element
490
+ if (record.placeholder.parentNode) {
491
+ record.placeholder.parentNode.replaceChild(record.originalEl, record.placeholder);
492
+ }
493
+
494
+ if (remaining.length === 0) {
495
+ delete state._deleteBase;
496
+ state._deletes = null;
497
+ }
498
+
499
+ showToast(S.preview?.inline_edit_restored || 'Block restored');
500
+ } catch (err) {
501
+ console.error('[InlineEdit] Restore failed:', err);
502
+ showToast(saveFailedMsg(err, S.preview?.inline_edit_restore_failed || 'Restore failed'));
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Rebuild content by removing all pending-delete ranges from the base.
508
+ * Splices from highest origStart to lowest so earlier indexes stay valid.
509
+ */
510
+ function buildContentWithDeletes(base, deletes) {
511
+ const sorted = [...deletes].sort((a, b) => b.origStart - a.origStart);
512
+ let content = base;
513
+ for (const d of sorted) {
514
+ content = content.substring(0, d.origStart) + content.substring(d.origEnd);
515
+ }
516
+ return content;
517
+ }
518
+
519
+ /**
520
+ * Called from rerenderContent to discard pending-delete state — placeholders
521
+ * are gone from the DOM after a re-render, so the tracker is no longer useful.
522
+ */
523
+ export function resetDeleteSession() {
524
+ if (state._deleteBase) delete state._deleteBase;
525
+ if (state._deletes) state._deletes = null;
526
+ }
527
+
528
+ // ─── Source mapping ────────────────────────────────────────────────────
529
+
530
+ /**
531
+ * Find the raw markdown source that corresponds to the rendered text.
532
+ * Returns { raw, startIdx, endIdx } where raw is the source markdown
533
+ * and startIdx/endIdx are byte offsets into the full source string.
534
+ */
535
+ function findSourceMarkdown(source, renderedText, element) {
536
+ if (!source || !renderedText) return null;
537
+
538
+ const tag = element.tagName?.toLowerCase();
539
+ const selType = element.getAttribute('data-selectable');
540
+
541
+ // Strategy 1: Heading — find `#{1,6} text` line
542
+ if (selType === 'heading' || /^h[1-6]$/.test(tag)) {
543
+ const headingMatch = findHeadingSource(source, renderedText);
544
+ if (headingMatch) return headingMatch;
545
+ }
546
+
547
+ // Strategy 2: List item — find `- text` or `* text` or `1. text` line
548
+ if (selType === 'bullet' || tag === 'li') {
549
+ const listMatch = findListItemSource(source, renderedText);
550
+ if (listMatch) return listMatch;
551
+ }
552
+
553
+ // Strategy 3: Blockquote — find `> text` line
554
+ if (selType === 'quote' || tag === 'blockquote') {
555
+ const quoteMatch = findBlockquoteSource(source, renderedText);
556
+ if (quoteMatch) return quoteMatch;
557
+ }
558
+
559
+ // Strategy 4: Direct text match (paragraphs, sentences)
560
+ const directMatch = findDirectTextMatch(source, renderedText);
561
+ if (directMatch) return directMatch;
562
+
563
+ // Strategy 5: Fuzzy line match — strip markdown and compare
564
+ const fuzzyMatch = findFuzzyLineMatch(source, renderedText);
565
+ if (fuzzyMatch) return fuzzyMatch;
566
+
567
+ return null;
568
+ }
569
+
570
+ function findHeadingSource(source, text) {
571
+ // Match heading lines: # Title, ## Title, etc.
572
+ const regex = new RegExp(`^(#{1,6}\\s+)${escapeRegex(text)}\\s*$`, 'm');
573
+ const match = source.match(regex);
574
+ if (match) {
575
+ const startIdx = match.index;
576
+ const endIdx = startIdx + match[0].length;
577
+ return { raw: match[0], startIdx, endIdx };
578
+ }
579
+ return null;
580
+ }
581
+
582
+ function findListItemSource(source, text) {
583
+ // Match list items: - text, * text, + text, 1. text
584
+ const regex = new RegExp(`^(\\s*(?:[-*+]|\\d+\\.)\\s+)${escapeRegex(text)}\\s*$`, 'm');
585
+ const match = source.match(regex);
586
+ if (match) {
587
+ const startIdx = match.index;
588
+ const endIdx = startIdx + match[0].length;
589
+ return { raw: match[0], startIdx, endIdx };
590
+ }
591
+
592
+ // Fallback: try matching just the text content within a list item line
593
+ const lines = source.split('\n');
594
+ for (let i = 0; i < lines.length; i++) {
595
+ const line = lines[i];
596
+ const listMatch = line.match(/^(\s*(?:[-*+]|\d+\.)\s+)/);
597
+ if (listMatch && stripInlineMarkdown(line.slice(listMatch[1].length)).trim() === text) {
598
+ const startIdx = getLineOffset(source, i);
599
+ const endIdx = startIdx + line.length;
600
+ return { raw: line, startIdx, endIdx };
601
+ }
602
+ }
603
+ return null;
604
+ }
605
+
606
+ function findBlockquoteSource(source, text) {
607
+ // Blockquotes can span multiple lines, each starting with >
608
+ const lines = source.split('\n');
609
+ for (let i = 0; i < lines.length; i++) {
610
+ if (!lines[i].match(/^>\s*/)) continue;
611
+
612
+ // Collect contiguous blockquote lines
613
+ let end = i;
614
+ while (end < lines.length && lines[end].match(/^>\s*/)) end++;
615
+
616
+ const quoteLines = lines.slice(i, end);
617
+ const quoteText = quoteLines.map(l => l.replace(/^>\s*/, '')).join(' ').trim();
618
+
619
+ if (quoteText === text || stripInlineMarkdown(quoteText) === text) {
620
+ const block = quoteLines.join('\n');
621
+ const startIdx = getLineOffset(source, i);
622
+ const endIdx = startIdx + block.length;
623
+ return { raw: block, startIdx, endIdx };
624
+ }
625
+ }
626
+ return null;
627
+ }
628
+
629
+ function findDirectTextMatch(source, text) {
630
+ // Try to find the text directly in the source
631
+ const idx = source.indexOf(text);
632
+ if (idx !== -1) {
633
+ // Expand to full line(s) — don't cut mid-line
634
+ const before = source.lastIndexOf('\n', idx - 1);
635
+ const after = source.indexOf('\n', idx + text.length);
636
+ const startIdx = before === -1 ? 0 : before + 1;
637
+ const endIdx = after === -1 ? source.length : after;
638
+ const raw = source.substring(startIdx, endIdx);
639
+ return { raw, startIdx, endIdx };
640
+ }
641
+ return null;
642
+ }
643
+
644
+ function findFuzzyLineMatch(source, text) {
645
+ const lines = source.split('\n');
646
+
647
+ // Single line match: strip markdown from line and compare
648
+ for (let i = 0; i < lines.length; i++) {
649
+ const stripped = stripInlineMarkdown(
650
+ lines[i].replace(/^#{1,6}\s+/, '')
651
+ .replace(/^\s*(?:[-*+]|\d+\.)\s+/, '')
652
+ .replace(/^>\s*/, '')
653
+ ).trim();
654
+
655
+ if (stripped === text) {
656
+ const startIdx = getLineOffset(source, i);
657
+ const endIdx = startIdx + lines[i].length;
658
+ return { raw: lines[i], startIdx, endIdx };
659
+ }
660
+ }
661
+
662
+ // Multi-line paragraph: consecutive non-empty, non-special lines
663
+ for (let i = 0; i < lines.length; i++) {
664
+ if (isSpecialLine(lines[i]) || !lines[i].trim()) continue;
665
+
666
+ let end = i;
667
+ while (end < lines.length && lines[end].trim() && !isSpecialLine(lines[end])) end++;
668
+
669
+ const paraLines = lines.slice(i, end);
670
+ const paraText = paraLines.map(l => stripInlineMarkdown(l).trim()).join(' ');
671
+
672
+ if (paraText === text) {
673
+ const block = paraLines.join('\n');
674
+ const startIdx = getLineOffset(source, i);
675
+ const endIdx = startIdx + block.length;
676
+ return { raw: block, startIdx, endIdx };
677
+ }
678
+ }
679
+
680
+ return null;
681
+ }
682
+
683
+ // ─── Helpers ───────────────────────────────────────────────────────────
684
+
685
+ /**
686
+ * Get the text offset within `element` at the given viewport coordinates.
687
+ * Returns the number of characters from the start of the element's text to
688
+ * the click point, or null if it can't be determined.
689
+ */
690
+ function getClickTextOffset(element, x, y) {
691
+ let range = null;
692
+ try {
693
+ if (document.caretRangeFromPoint) {
694
+ range = document.caretRangeFromPoint(x, y); // Chrome/Safari/iOS
695
+ } else if (document.caretPositionFromPoint) {
696
+ const pos = document.caretPositionFromPoint(x, y); // Firefox
697
+ if (pos) {
698
+ range = document.createRange();
699
+ range.setStart(pos.offsetNode, pos.offset);
700
+ range.collapse(true);
701
+ }
702
+ }
703
+ } catch (_) {
704
+ return null;
705
+ }
706
+ if (!range || !element.contains(range.startContainer)) return null;
707
+ const preRange = document.createRange();
708
+ preRange.selectNodeContents(element);
709
+ preRange.setEnd(range.startContainer, range.startOffset);
710
+ return preRange.toString().length;
711
+ }
712
+
713
+ /**
714
+ * Map a visual text offset (in the rendered DOM) to an offset in the raw
715
+ * markdown source. Heuristic — skips inline emphasis markers and the leading
716
+ * line marker on the first line. Good enough for paragraphs, headings, and
717
+ * simple list items; degrades gracefully for complex inline markdown.
718
+ */
719
+ function mapVisualToSource(source, visualOffset) {
720
+ let srcIdx = 0;
721
+ const leadMatch = source.match(/^(\s*(?:#{1,6}\s+|[-*+]\s+|\d+\.\s+|>\s+))/);
722
+ if (leadMatch) srcIdx = leadMatch[0].length;
723
+ if (visualOffset <= 0) return srcIdx;
724
+
725
+ let visIdx = 0;
726
+ while (srcIdx < source.length && visIdx < visualOffset) {
727
+ const c = source[srcIdx];
728
+ if (c === '*' || c === '_' || c === '`' || c === '~') {
729
+ srcIdx++;
730
+ continue;
731
+ }
732
+ if (c === '[') {
733
+ srcIdx++;
734
+ continue;
735
+ }
736
+ if (c === ']' && source[srcIdx + 1] === '(') {
737
+ const close = source.indexOf(')', srcIdx + 2);
738
+ if (close !== -1) {
739
+ srcIdx = close + 1;
740
+ continue;
741
+ }
742
+ }
743
+ srcIdx++;
744
+ visIdx++;
745
+ }
746
+ return srcIdx;
747
+ }
748
+
749
+ function getLineOffset(source, lineIndex) {
750
+ let offset = 0;
751
+ const lines = source.split('\n');
752
+ for (let i = 0; i < lineIndex && i < lines.length; i++) {
753
+ offset += lines[i].length + 1; // +1 for \n
754
+ }
755
+ return offset;
756
+ }
757
+
758
+ function isSpecialLine(line) {
759
+ return /^(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```|---|\*\*\*|___|\|)/.test(line.trim());
760
+ }
761
+
762
+ function stripInlineMarkdown(text) {
763
+ return text
764
+ .replace(/\*\*\*(.*?)\*\*\*/g, '$1') // bold+italic
765
+ .replace(/\*\*(.*?)\*\*/g, '$1') // bold
766
+ .replace(/\*(.*?)\*/g, '$1') // italic
767
+ .replace(/~~(.*?)~~/g, '$1') // strikethrough
768
+ .replace(/`(.*?)`/g, '$1') // inline code
769
+ .replace(/\[(.*?)\]\(.*?\)/g, '$1'); // links
770
+ }
771
+
772
+ function escapeRegex(str) {
773
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
774
+ }
775
+
776
+ // ─── Clickable checkboxes ──────────────────────────────────────────────
777
+
778
+ /**
779
+ * Setup clickable checkboxes in rendered markdown.
780
+ * marked.js renders `- [ ]` / `- [x]` as <input type="checkbox" disabled>.
781
+ * We enable them and toggle the source on click.
782
+ */
783
+ export function setupCheckboxes(container) {
784
+ const rendered = container.querySelector('.preview-rendered');
785
+ if (!rendered) return;
786
+
787
+ // Enable all task list checkboxes (marked renders them disabled)
788
+ rendered.querySelectorAll('input[type="checkbox"][disabled]').forEach(cb => {
789
+ cb.disabled = false;
790
+ cb.classList.add('md-task-checkbox');
791
+ });
792
+
793
+ // Delegate click handling. Re-point the active state to this instance
794
+ // first — toggleCheckboxInSource reads state.content/currentPath.
795
+ const s = state;
796
+ rendered.addEventListener('change', (e) => {
797
+ const cb = e.target;
798
+ if (!cb.classList.contains('md-task-checkbox')) return;
799
+ activateStateInstance(s);
800
+ toggleCheckboxInSource(cb);
801
+ });
802
+ }
803
+
804
+ async function toggleCheckboxInSource(checkbox) {
805
+ const source = state.content;
806
+ if (!source || !state.currentPath) return;
807
+
808
+ const li = checkbox.closest('li');
809
+ if (!li) return;
810
+
811
+ const isNowChecked = checkbox.checked;
812
+ const oldMark = isNowChecked ? '[ ]' : '[x]';
813
+ const newMark = isNowChecked ? '[x]' : '[ ]';
814
+
815
+ let newSource;
816
+
817
+ // Strategy 1: Use source map attributes (precise byte range)
818
+ const srcStart = li.getAttribute('data-source-start');
819
+ const srcEnd = li.getAttribute('data-source-end');
820
+ if (srcStart != null && srcEnd != null) {
821
+ const start = parseInt(srcStart, 10);
822
+ const end = parseInt(srcEnd, 10);
823
+ const liRaw = source.substring(start, end);
824
+ const replaced = liRaw.replace(oldMark, newMark);
825
+ if (replaced === liRaw) {
826
+ checkbox.checked = !isNowChecked;
827
+ return;
828
+ }
829
+ newSource = source.substring(0, start) + replaced + source.substring(end);
830
+ } else {
831
+ // Strategy 2: Fuzzy text matching fallback
832
+ const taskText = li.textContent.trim();
833
+ const lines = source.split('\n');
834
+ let matchIdx = -1;
835
+ for (let i = 0; i < lines.length; i++) {
836
+ const line = lines[i];
837
+ const taskMatch = line.match(/^(\s*(?:[-*+]|\d+\.)\s+)\[([ xX])\]\s*/);
838
+ if (!taskMatch) continue;
839
+ const lineText = line.slice(taskMatch.index + taskMatch[0].length).trim();
840
+ const strippedLineText = stripInlineMarkdown(lineText);
841
+ if (taskText === lineText || taskText === strippedLineText ||
842
+ taskText.startsWith(lineText) || taskText.startsWith(strippedLineText)) {
843
+ const currentMark = taskMatch[2];
844
+ const wasChecked = currentMark === 'x' || currentMark === 'X';
845
+ if (wasChecked !== isNowChecked) {
846
+ matchIdx = i;
847
+ break;
848
+ }
849
+ }
850
+ }
851
+ if (matchIdx === -1) {
852
+ checkbox.checked = !isNowChecked;
853
+ return;
854
+ }
855
+ lines[matchIdx] = lines[matchIdx].replace(oldMark, newMark);
856
+ newSource = lines.join('\n');
857
+ }
858
+
859
+ // Save
860
+ try {
861
+ await writeSource(newSource);
862
+ state.content = newSource;
863
+ } catch (err) {
864
+ console.error('[InlineEdit] Checkbox save failed:', err);
865
+ // Revert on failure
866
+ checkbox.checked = !isNowChecked;
867
+ showToast(saveFailedMsg(err));
868
+ }
869
+ }