meltygui 0.1.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 (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1351 @@
1
+ import difflib
2
+ from meltygui.core.diagnostics.notifications import lag_traced
3
+
4
+ import re
5
+ import types
6
+ from collections import defaultdict
7
+ from typing import Any
8
+
9
+ import meltygui_imgui as imgui
10
+
11
+ from meltygui.core.rendering.render_funcs import RenderFuncs
12
+ from meltygui.core.windowing.glfw_utils import print_stack_trace
13
+ from meltygui.core.core_render import render_func
14
+ from meltygui.core.rendering.window_decoration import window
15
+ from meltygui.core.rendering.core_decoration import defaults
16
+
17
+
18
+ _HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
19
+
20
+
21
+ def _diff_lines_with_numbers(diff, base):
22
+ """Walk a unified diff and return (content_lines, numbers): the +/- and
23
+ context lines with the --- / +++ / @@ / "\\ No newline" scaffolding removed,
24
+ plus the true file line number for each. @@ hunk numbers are 1-based and
25
+ snippet-relative; `base` (the address's 0-based start line) shifts them onto
26
+ the file. Added/context lines take the new-side number, deleted lines the
27
+ old-side number — a replacement shows the same number on both."""
28
+ content_lines, numbers = [], []
29
+ old_ln = new_ln = 0
30
+ for line in diff:
31
+ if line.startswith(("---", "+++")):
32
+ continue
33
+ m = _HUNK_RE.match(line)
34
+ if m:
35
+ old_ln, new_ln = int(m.group(1)), int(m.group(2))
36
+ continue
37
+ if line.startswith("\\"): # "\"
38
+ continue
39
+ if line.startswith("+"):
40
+ content_lines.append(line); numbers.append(base + new_ln); new_ln += 1
41
+ elif line.startswith("-"):
42
+ content_lines.append(line); numbers.append(base + old_ln); old_ln += 1
43
+ else: # context line
44
+ content_lines.append(line); numbers.append(base + new_ln)
45
+ old_ln += 1; new_ln += 1
46
+ return content_lines, numbers
47
+
48
+
49
+ # draw_pending_saves' per-entry rendered-diff cache: address →
50
+ # (id(original), id(pending), (pending text, blocks), diff_str, numbers).
51
+ # Keyed on object identities (content-free) - the diff/render recomputes
52
+ # once per actual edit: typing edges splice incrementally off the previous
53
+ # blocks instead of re-matching the whole entry.
54
+ _pending_diff_memo = {}
55
+
56
+
57
+ def _render_diff_blocks(old_l, new_l, blocks, base, n=3):
58
+ """(content_lines, numbers) for draw_text(is_diff=True) straight from
59
+ change blocks — unified-diff-shaped output without re-running the
60
+ matcher: blocks whose ±n context windows touch group into one hunk;
61
+ each block renders its '-' old lines then '+' new lines, with ' '
62
+ context between and around. Numbers are true file lines (base = the
63
+ address's 0-based start): '+'/context take the new side, '-' the old —
64
+ the same convention as _diff_lines_with_numbers."""
65
+ content, numbers = [], []
66
+ if not blocks:
67
+ return content, numbers
68
+ groups, cur = [], [blocks[0]]
69
+ for blk in blocks[1:]:
70
+ if blk[0] - cur[-1][1] <= 2 * n:
71
+ cur.append(blk)
72
+ else:
73
+ groups.append(cur)
74
+ cur = [blk]
75
+ groups.append(cur)
76
+ for group in groups:
77
+ for k in range(max(0, group[0][0] - n), group[0][0]):
78
+ content.append(" " + new_l[k] + "\n")
79
+ numbers.append(base + k + 1)
80
+ for gi, (n0, n1, b0, b1, _t) in enumerate(group):
81
+ for k in range(b0, b1):
82
+ content.append("-" + old_l[k] + "\n")
83
+ numbers.append(base + k + 1)
84
+ for k in range(n0, n1):
85
+ content.append("+" + new_l[k] + "\n")
86
+ numbers.append(base + k + 1)
87
+ stop = (group[gi + 1][0] if gi + 1 < len(group)
88
+ else min(len(new_l), n1 + n))
89
+ for k in range(n1, stop):
90
+ content.append(" " + new_l[k] + "\n")
91
+ numbers.append(base + k + 1)
92
+ return content, numbers
93
+
94
+
95
+ def three_way_merge(base, mine, theirs):
96
+ """Line-level 3-way merge. Returns the merged text, or None when the two
97
+ sides' edits overlap — a direct conflict that needs a human.
98
+
99
+ Both sides diff against `base` (SequenceMatcher, no autojunk); an edit is
100
+ a replaced base-line range plus its replacement lines. An edit both sides
101
+ made identically collapses into one. Overlap is checked on
102
+ insertion-expanded ranges (a pure insert claims the line it lands before),
103
+ so an insert INSIDE the other side's edit conflicts, while edits that
104
+ merely touch end-to-start still splice cleanly. Within one side opcodes
105
+ are separated by at least one equal line, so expansion never makes a side
106
+ self-overlap."""
107
+ base_l = base.splitlines(keepends=True)
108
+ edits = []
109
+ for side, text in ((0, mine), (1, theirs)):
110
+ other_l = text.splitlines(keepends=True)
111
+ sm = difflib.SequenceMatcher(None, base_l, other_l, autojunk=False)
112
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
113
+ if tag != "equal":
114
+ edits.append((i1, i2, tuple(other_l[j1:j2]), side))
115
+ deduped, seen = [], set()
116
+ for i1, i2, repl, side in edits:
117
+ if (i1, i2, repl) in seen:
118
+ continue # both sides made this exact change
119
+ seen.add((i1, i2, repl))
120
+ deduped.append((i1, i2, repl, side))
121
+ # Sweep sorted-by-start edit spans; an overlap with an earlier
122
+ # opposite-side span shows as start < that side's running max end.
123
+ max_end = {0: -1, 1: -1}
124
+ for s, e, side in sorted((i1, max(i2, i1 + 1), side)
125
+ for i1, i2, _, side in deduped):
126
+ if s < max_end[1 - side]:
127
+ return None
128
+ max_end[side] = max(max_end[side], e)
129
+ merged = list(base_l)
130
+ for i1, i2, repl, _ in sorted(deduped, reverse=True):
131
+ merged[i1:i2] = repl
132
+ return "".join(merged)
133
+
134
+
135
+ @window(view_func=RenderFuncs.draw_type, disable_scroll=False)
136
+ class PendingSave:
137
+ pending_saves = defaultdict(Any)
138
+ originals = defaultdict(Any)
139
+ # Result lines from manual merge actions (MERGED / ADOPTED / KEPT OURS /
140
+ # TOOK THEIRS / CONFLICT per file - resolve_external via the merge
141
+ # window's / commit banner's buttons). Shown by draw_pending_saves until
142
+ # dismissed - a merge rewrites the queue, so its outcome must stay
143
+ # inspectable.
144
+ merge_results = []
145
+ # Monotonic per-file edit counter, bumped on every queue_save. A cheap,
146
+ # content-free cache-invalidation signal (see CLAUDE.md - never hash files):
147
+ # readers of current_file_text key on this instead of hashing the text.
148
+ _pending_gen = defaultdict(int)
149
+ # Tint-relevant twin of _pending_gen: bumps only when a queued edit could
150
+ # change definition-tint washes in editors viewing OTHER files - its line
151
+ # count moved (shifting every def line below the span) or a tint-carrying
152
+ # line changed. A #[...] param drag rewriting `#[speed=3.1]` per frame
153
+ # bumps _pending_gen every time but leaves this unchanged, so the
154
+ # _def_tints memos across the app stop recomputing (and mid-edit dropping
155
+ # propagated washes) after each drag frame.
156
+ _tint_gen = defaultdict(int)
157
+
158
+ @classmethod
159
+ def mark_load(cls, address, data, **kwargs):
160
+ # A load answered from the pending overlay (codec.load returns the
161
+ # pending edit, not disk) must NOT re-baseline: basing the result as
162
+ # the "original" turns a real pending edit into a no-op (data ==
163
+ # original) — drop_noop_entries_for would then discard it on the next
164
+ # external write, and the merge base would be wrong.
165
+ if isinstance(data, str) and cls.pending_text_for(address) == data:
166
+ return
167
+ cls.originals[address] = data
168
+
169
+ @classmethod
170
+ def entry_for(cls, address):
171
+ """The queued entry whose span is `address`: exact (path, start, end)
172
+ value match first (Address hashes by location), else by the address's
173
+ `source` — an external write shifts the file, so a freshly resolved
174
+ address no longer matches the coordinates the edit was queued under,
175
+ but both still point at the same live object / call site. Returns
176
+ (queued_address, codec, kwargs) or None."""
177
+ hit = cls.pending_saves.get(address)
178
+ if hit is not None:
179
+ return address, hit[0], hit[1]
180
+ src = getattr(address, "source", None)
181
+ if src is None:
182
+ return None
183
+ for addr, (codec, kwargs) in list(cls.pending_saves.items()):
184
+ try:
185
+ if addr.path == address.path and getattr(addr, "source", None) == src:
186
+ return addr, codec, kwargs
187
+ except Exception:
188
+ continue
189
+ return None
190
+
191
+ @classmethod
192
+ def original_for(cls, address):
193
+ """(matched_address, load-time original text) for `address`, matched
194
+ like entry_for — the 3-way-merge base. None when this span was never
195
+ loaded through load_file."""
196
+ hit = cls.originals.get(address)
197
+ if hit is not None:
198
+ return address, hit
199
+ src = getattr(address, "source", None)
200
+ if src is None:
201
+ return None
202
+ for addr, data in list(cls.originals.items()):
203
+ try:
204
+ if addr.path == address.path and getattr(addr, "source", None) == src:
205
+ return addr, data
206
+ except Exception:
207
+ continue
208
+ return None
209
+
210
+ @classmethod
211
+ def rebase_entry(cls, old_address, new_address, codec, data, original, **kwargs):
212
+ """Move a queued edit onto a freshly resolved span: drop the
213
+ stale-coordinate entry and its load-time original, re-baseline the
214
+ original to `original` (the CURRENT disk span), and queue `data` under
215
+ the new address. The manual merge path calls this after splicing an
216
+ external change into a pending edit, so apply_all_saves later splices
217
+ at coordinates that match the rewritten file."""
218
+ if old_address != new_address:
219
+ cls.pending_saves.pop(old_address, None)
220
+ cls.originals.pop(old_address, None)
221
+ cls.originals[new_address] = original
222
+ cls.queue_save(new_address, codec, data=data, **kwargs)
223
+
224
+ @classmethod
225
+ def discard_entry_for(cls, address):
226
+ """Drop the queued edit (and its baseline) matching `address` — the
227
+ user chose "Load theirs" on an unmergeable conflict; without this the
228
+ pending overlay would keep answering loads with the discarded edit."""
229
+ hit = cls.entry_for(address)
230
+ if hit is not None:
231
+ cls.pending_saves.pop(hit[0], None)
232
+ cls.originals.pop(hit[0], None)
233
+ cls.originals.pop(address, None)
234
+
235
+ @classmethod
236
+ def pending_gen_for(cls, path):
237
+ """Edit generation for `path` — bumps on every queue_save for it. Keyed by
238
+ the same `address.path` value queue_save writes (matches pending_text_for's
239
+ no-resolve convention)."""
240
+ return cls._pending_gen.get(path, 0)
241
+
242
+ @staticmethod
243
+ def _tint_relevant_change(prev_data, data):
244
+ """Could replacing `prev_data` with `data` change def-tint washes in
245
+ another file's editor? True when the line count moved or any line
246
+ mentioning 'tint' differs; unknown shapes (non-str, first sight with
247
+ no baseline) bump conservatively. The split runs only when the span
248
+ mentions tint at all — a plain param edit stays on two C-level
249
+ checks, and the data is already in memory (this is not a file hash)."""
250
+ if prev_data is data:
251
+ return False
252
+ if not isinstance(prev_data, str) or not isinstance(data, str):
253
+ return True
254
+ if prev_data.count("\n") != data.count("\n"):
255
+ return True
256
+ p_has, n_has = "tint" in prev_data, "tint" in data
257
+ if p_has != n_has:
258
+ return True
259
+ if not p_has:
260
+ return False
261
+ return ([l for l in prev_data.split("\n") if "tint" in l]
262
+ != [l for l in data.split("\n") if "tint" in l])
263
+
264
+ @classmethod
265
+ @lag_traced("queue_save", 30)
266
+ def queue_save(cls, address, codec, wake=True, **kwargs):
267
+ prev = cls.pending_saves.get(address)
268
+ cls.pending_saves[address] = codec, kwargs
269
+ cls._pending_gen[address.path] += 1
270
+ # Compare against what this span last queued (or its load-time
271
+ # original on first queue) for the delta this edit actually introduces.
272
+ prev_data = (prev[1].get("data") if prev is not None
273
+ else cls.originals.get(address))
274
+ if cls._tint_relevant_change(prev_data, kwargs.get("data")):
275
+ cls._tint_gen[address.path] += 1
276
+ # Debug timeline: who bumped this file's pending generation (a bump is
277
+ # what invalidates the symbol-usage cache sig → forces a recompute).
278
+ try:
279
+ from meltygui.core.diagnostics.perf_trace import trace as _ptrace
280
+ import sys as _sys
281
+ trail = []
282
+ f = _sys._getframe(1)
283
+ for _ in range(4):
284
+ if f is None:
285
+ break
286
+ trail.append(f"{f.f_code.co_filename.rsplit('/', 1)[-1]}:{f.f_lineno} {f.f_code.co_name}")
287
+ f = f.f_back
288
+ _ptrace(f"queue_save gen={cls._pending_gen[address.path]} <- {' <- '.join(trail)}",
289
+ file=getattr(address.path, 'name', address.path))
290
+ except Exception:
291
+ pass
292
+ # Deferred saves never write disk, so the file watcher never needs to update
293
+ # SIBLING views of this file (a structured/cst/dict view, another editor).
294
+ # When the queued text content changes, wake them so they re-render and
295
+ # pull the new edit from this cache (code_file_io's cross-view-sync branch).
296
+ # Reuses FileWatch's per-path watcher set + dispatch; the editing view that
297
+ # produced the entry is guarded there (its own buffer already matches).
298
+ # `wake=False` is the focused-editor exception: the typing editor's
299
+ # per-keystroke auto-save should not re-render every sibling view of the
300
+ # file on each keystroke (save_file passes it if the text focus sits
301
+ # inside the saving view's own subtree). Programmatic edits - param
302
+ # panels, lenses, live preview comment edits - keep the wake, which is
303
+ # what lands their edit in the visible editor promptly.
304
+ if wake and (prev is None or prev[1].get("data") != kwargs.get("data")):
305
+ cls._wake_file_watchers(address.path)
306
+
307
+ # The merge/conflict window watches this edge too: a fresh pending edit
308
+ # may now have tracked external drift. wake() no-ops while that
309
+ # window is closed, so this hot path (queue_save can fire per edit
310
+ # frame) pays one attr fetch. Lazy import - merge_files imports us.
311
+ try:
312
+ from meltygui.core.runtime.extensions import call
313
+ call('conflicts_changed')
314
+ except Exception:
315
+ pass
316
+
317
+ # Re-lint the file's code-host: what the missing-import checker reports
318
+ # depends on the file's PENDING text (code_checks._module_level_binds),
319
+ # so any queued edit - an import added orremoved in some other view,
320
+ # a revert - may change the right answer for every span of this file
321
+ # without touching their addresses. Cheap flag + rate-limited consumer
322
+ # wake per host (see _kick_relint).
323
+ try:
324
+ from meltygui.code.new_converters import _kick_relint
325
+ _kick_relint(address.path)
326
+ except Exception:
327
+ pass
328
+
329
+ @classmethod
330
+ def _wake_file_watchers(cls, path):
331
+ if path is None:
332
+ return
333
+ from meltygui.core.melty import FileWatch
334
+ from meltygui.core.melty import Melty
335
+ from meltygui.core.cache.invalidation_tracker import Note
336
+ try:
337
+ resolved = str(path.resolve())
338
+ except OSError:
339
+ return
340
+ watchers = list(FileWatch.path_to_draw_states.get(resolved, ()))
341
+ try:
342
+ from meltygui.core.diagnostics.perf_trace import trace as _ptrace
343
+ _ptrace(f"wake_file_watchers n={len(watchers)} "
344
+ f"[{', '.join(getattr(d, 'name', '?') or '?' for d in watchers)}]",
345
+ file=resolved.rsplit('/', 1)[-1])
346
+ except Exception:
347
+ pass
348
+ for ds in watchers:
349
+ FileWatch.dispatch_event_for(ds)
350
+ # dispatch only flags ds._external_change - that bypasses the VALUE
351
+ # cache, but only once the body runs, and a blit-cached editor tile
352
+ # replays its texture without even running the body. Dirty the tile
353
+ # too (force + depth, same shape as the host's notify) so
354
+ # code_file_io actually re-executes and pulls the queued edit.
355
+ tid = getattr(ds, "_tile_id", None)
356
+ if tid is not None:
357
+ Melty.cache.invalidate_up(
358
+ tid, force=True, max_depth=8,
359
+ note=Note(name="queue_save wake", tint=(1, 0.8, 0.2),
360
+ draw_state=ds))
361
+
362
+ @classmethod
363
+ def current_file_text(cls, path):
364
+ """Disk text of `path` with every queued (unsaved) span edit spliced in —
365
+ the file as it WOULD be on disk if the deferred saves flushed right now.
366
+
367
+ Readers that re-read disk see pre-edit content (saves defer to shutdown);
368
+ this is the in-memory truth for whole-file consumers like the symbol-usage
369
+ index, which otherwise resolve references against the stale on-disk file.
370
+ Splices bottom-up (highest start first), matching apply_all_saves /
371
+ codec.save, so an applied span never shifts a not-yet-applied span above
372
+ it. Newline-normalized to '\\n' (callers here only need line/col, which is
373
+ newline-agnostic). A queued whole-file edit (start is None) IS the text.
374
+ Returns None if the file can't be read."""
375
+ from meltygui.core.melty import Melty
376
+ from pathlib import Path as _P
377
+ disk = Melty.read_code(path)
378
+ if disk is None:
379
+ return None
380
+ try:
381
+ rp = _P(path).resolve()
382
+ except OSError:
383
+ return disk
384
+ edits = []
385
+ for addr, (codec, kwargs) in list(cls.pending_saves.items()):
386
+ data = kwargs.get("data")
387
+ if not isinstance(data, str):
388
+ continue
389
+ if data == cls.originals.get(addr):
390
+ continue # no-op entry - must not splice stale text over
391
+ # an externally-changed disk (see pending_text_for)
392
+ try:
393
+ if _P(addr.path).resolve() != rp:
394
+ continue
395
+ except Exception:
396
+ continue
397
+ edits.append((addr.start, addr.end, data))
398
+ if not edits:
399
+ return disk
400
+ whole = [d for (s, e, d) in edits if s is None]
401
+ if whole:
402
+ return whole[-1].replace("\r\n", "\n").replace("\r", "\n")
403
+ lines = disk.split("\n")
404
+ for start, end, data in sorted((e for e in edits if e[0] is not None),
405
+ key=lambda e: -e[0]):
406
+ d = data.replace("\r\n", "\n").replace("\r", "\n")
407
+ if d.endswith("\n"):
408
+ d = d[:-1]
409
+ lines[start:end] = d.split("\n")
410
+ return "\n".join(lines)
411
+
412
+ @classmethod
413
+ def pending_text_for(cls, address):
414
+ """The unsaved in-memory span text queued for `address`, or None.
415
+
416
+ Saves are buffered here and only flushed to disk at shutdown
417
+ (apply_all_saves), so during a session the file on disk is stale. A load
418
+ that re-read disk — a sibling editor, a code-host, the structured view, a
419
+ staleness reload — would resurrect the pre-edit content, and a recompile
420
+ off it would compile the stale version. Matched by (path, start, end),
421
+ NOT identity: every consumer resolves its OWN Address from the (stable,
422
+ since unwritten) disk to the same coords, so the value match lets a
423
+ sibling see the editor's live edit. Cross-SHAPE serving works BOTH
424
+ ways: a span consumer reads its slice of a whole-file entry, and a
425
+ whole-file consumer reads the sync-frame base with the file's span
426
+ entries spliced in (a context-menu lens edit queues per-def spans —
427
+ this is how they reach the editor buffer and the code hosts). None
428
+ when nothing is queued there."""
429
+ for addr, (codec, kwargs) in list(cls.pending_saves.items()):
430
+ if (addr.path == address.path and addr.start == address.start
431
+ and addr.end == address.end):
432
+ data = kwargs.get("data")
433
+ # A no-op entry (data == its load-time original) answers with
434
+ # text identical to what disk held at load - worthless as an
435
+ # overlay, and actively wrong the moment an EXTERNAL write
436
+ # changes the file: it would shadow the new disk content on
437
+ # every reload. drop_noop_entries_for skips such entries on
438
+ # the external-event path, but the drop is a posted render
439
+ # task and a load triggered by the same event can run FIRST - so
440
+ # the skip must live here, at the consumption point.
441
+ if data == cls.originals.get(addr):
442
+ return None
443
+ return data
444
+ # A whole-file pending entry (a manual merge queues one per merged
445
+ # external change) holds span edits that are NOT on disk - a span
446
+ # consumer reloading from disk would lose them. Serve the slice from
447
+ # the merged text. The coords are valid against it: resolve_external
448
+ # resyncs live linenos to the merged source (the same coords the
449
+ # consumer resolved from).
450
+ if address.start is not None:
451
+ for addr, (codec, kwargs) in list(cls.pending_saves.items()):
452
+ if addr.start is not None or addr.path != address.path:
453
+ continue
454
+ data = kwargs.get("data")
455
+ if not isinstance(data, str) or data == cls.originals.get(addr):
456
+ return None
457
+ lines = data.split("\n")
458
+ if address.end is not None and address.end > len(lines):
459
+ return None # bad coords - never serve a short slice
460
+ return "\n".join(lines[address.start:address.end])
461
+ # The mirror direction: a WHOLE-FILE consumer (the code editor's
462
+ # buffer and every code host - auto_load_edits) must also see SPAN
463
+ # edits. A context-menu / lens edit queues a per-def span with no
464
+ # host anywhere; without this branch the editor's cross-view sync
465
+ # asked with (path, None, None), got None, and the edit showed up
466
+ # in draw_pending_saves but in no editor (and, through the hosts,
467
+ # not in the merge window). Compose sync-frame base with spans
468
+ # (studio_text_for - span coords match sync-frame) and memoize on
469
+ # the content-free generation + base identity: this runs per frame
470
+ # in the sync branch, the O(file) splice must cache.
471
+ if address.start is None:
472
+ has_real_span = any(
473
+ addr.path == address.path and addr.start is not None
474
+ and isinstance(kwargs.get("data"), str)
475
+ and kwargs.get("data") != cls.originals.get(addr)
476
+ for addr, (codec, kwargs) in list(cls.pending_saves.items()))
477
+ if not has_real_span:
478
+ return None
479
+ from meltygui.core.melty import Melty
480
+ from meltygui.editor.external_changes import ExternalChanges
481
+ key_path = str(address.path)
482
+ base = ExternalChanges.synced.get(
483
+ key_path, ExternalChanges.originals.get(key_path))
484
+ if base is None:
485
+ base = Melty.read_code(key_path)
486
+ memo_key = (cls._pending_gen.get(address.path, 0), id(base))
487
+ memo = cls._wholefile_overlay_memo.get(key_path)
488
+ if memo is not None and memo[0] == memo_key:
489
+ return memo[1]
490
+ overlay = cls.studio_text_for(key_path)
491
+ if not isinstance(overlay, str):
492
+ overlay = None
493
+ cls._wholefile_overlay_memo[key_path] = (memo_key, overlay)
494
+ return overlay
495
+ return None
496
+
497
+
498
+ @classmethod
499
+ def drop_noop_entries_for(cls, path):
500
+ """Remove queued entries for `path` whose data still equals their
501
+ load-time original — no-op entries (a value toggled and toggled back,
502
+ or a Revert). Called when an EXTERNAL write lands on the file: a no-op
503
+ entry has nothing left to preserve, but left queued it SHADOWS the new
504
+ disk content — pending_text_for keeps answering with the old span
505
+ text, so every reload of that span (a code host, a sibling editor, the
506
+ symbol index's current_file_text splice) resurrects the pre-edit file
507
+ — and at shutdown apply_all_saves would write the stale span back over
508
+ the external edit. Real pending edits (data != original) stay queued
509
+ and surface as the editor's changed-on-disk conflict, as before.
510
+ Render-thread only (callers hop via Melty.post_to_render): the queue
511
+ is iterated by frame code."""
512
+ from pathlib import Path as _P
513
+ try:
514
+ rp = _P(path).resolve()
515
+ except OSError:
516
+ return
517
+ for addr in [a for a, (codec, kw) in cls.pending_saves.items()
518
+ if a.path == rp and kw.get("data") == cls.originals.get(a)]:
519
+ del cls.pending_saves[addr]
520
+
521
+ @classmethod
522
+ @lag_traced("apply_all_saves", 50)
523
+ def apply_all_saves(cls):
524
+ from meltygui.code.new_codecs import SaveConflict
525
+ from meltygui.core.diagnostics.notifications import notify
526
+
527
+ # No merge-on-save: external drift is resolved MANUALLY (merge window /
528
+ # editor banner) before the flush - the main-window close guard
529
+ # (needs_merge?) blocks a shutdown that would land here with pending
530
+ # edits on a changed file. If a changed span does reach codec.save, its
531
+ # _span_fp fingerprint refuses the write (SaveConflict) and the entry
532
+ # defers below instead of splicing at stale offsets.
533
+
534
+ # Apply same-file saves bottom-up (highest start line first). A splice
535
+ # only shifts the lines BELOW its span, so saving the lowest span last
536
+ # means an applied edit never invalidates a still-lying span above it.
537
+ # This matters because the addresses are snapshots taken at resolve time
538
+ # and NOTHING re-resolves them between these batched saves - codec.save
539
+ # shifts live co_firstlineno's, but the Address objects sitting here keep
540
+ # their initial .start/.end. (start=None is a whole-file save; sort it last
541
+ # so it can't clobber span coords mid-batch.)
542
+ def _order(item):
543
+ addr = item[0]
544
+ start = addr.start
545
+ return (str(addr.path), -start if start is not None else float("-inf"))
546
+
547
+ # Keep refused saves PENDING instead of dropping them: a SaveConflict
548
+ # means the on-disk span changed under a stale address (e.g. an earlier
549
+ # save in this batch grew the file above it). The owning editor re-resolves
550
+ # its address on the next mtime-driven render, so a later apply lands it
551
+ # correctly. A plain False is success OR a hard refusal (library/binary/
552
+ # read-only) - neither is retryable, so don't re-queue it.
553
+ survivors = {}
554
+ for address, (codec, kwargs) in sorted(cls.pending_saves.items(), key=_order):
555
+ result = codec.save(address=address, **kwargs)
556
+ if isinstance(result, SaveConflict):
557
+ survivors[address] = (codec, kwargs)
558
+ notify(f"Save deferred: {address.path.name} changed under it",
559
+ tint=(1.0, 0.8, 0.3))
560
+
561
+ cls.pending_saves.clear()
562
+ cls.pending_saves.update(survivors)
563
+
564
+ @classmethod
565
+ def studio_text_for(cls, path):
566
+ """The studio's current view of `path`: the sync-frame text (the last
567
+ disk state the pending queue was rebased to — ExternalChanges.synced,
568
+ falling back to the drift baseline, falling back to disk) with this
569
+ file's real pending span edits spliced in bottom-up. This is the BASE
570
+ side of the manual merge diff: pending is treated as current, and the
571
+ external disk change is the incoming side diffed against it.
572
+
573
+ NOT current_file_text — that splices pending into the CURRENT disk
574
+ text, which already contains the external edits, so diffing it against
575
+ disk would hide the incoming side. Newline-normalized to '\\n'.
576
+ Returns None when no text is available at all."""
577
+ from pathlib import Path as _P
578
+ from meltygui.core.melty import Melty
579
+ from meltygui.editor.external_changes import ExternalChanges
580
+ _norm = cls._norm_text
581
+ key = str(path)
582
+ base = ExternalChanges.synced.get(key,
583
+ ExternalChanges.originals.get(key))
584
+ if base is None:
585
+ base = Melty.read_code(path)
586
+ if base is None:
587
+ return None
588
+ base_n = _norm(base)
589
+ try:
590
+ rp = _P(path).resolve()
591
+ except OSError:
592
+ return base_n
593
+ edits = []
594
+ for addr, (codec, kwargs) in list(cls.pending_saves.items()):
595
+ data = kwargs.get("data")
596
+ if not isinstance(data, str):
597
+ continue
598
+ # This no-op skip is for SPAN entries only (data == load-time
599
+ # original - nothing at stake). A WHOLE-FILE entry compares
600
+ # against a different reference point (its original is the
601
+ # sync frame at QUEUE time, the studio base here is CURRENT
602
+ # sync frame): after a resolve advanced the base, a "no-op"
603
+ # whole-file is still the flushable pending truth - skipping
604
+ # it reported DISK's text as the studio's.
605
+ if addr.start is not None and data == cls.originals.get(addr):
606
+ continue
607
+ try:
608
+ if _P(addr.path).resolve() != rp:
609
+ continue
610
+ except Exception:
611
+ continue
612
+ edits.append((addr.start, addr.end, data))
613
+ if not edits:
614
+ return base_n
615
+ whole = [d for (s, e, d) in edits if s is None]
616
+ if whole:
617
+ return _norm(whole[-1])
618
+ lines = base_n.split("\n")
619
+ for start, end, data in sorted((e for e in edits if e[0] is not None),
620
+ key=lambda e: -e[0]):
621
+ d = _norm(data)
622
+ if d.endswith("\n"):
623
+ d = d[:-1]
624
+ lines[start:end] = d.split("\n")
625
+ return "\n".join(lines)
626
+
627
+ # path str → ((pending_gen, id(base)), overlay text) - the whole-file
628
+ # consumers' pending-composition memo (pending_text_for's mirror image).
629
+ _wholefile_overlay_memo = {}
630
+
631
+ # path → ((id(sync), id(disk)), unmerged: bool). Identity-keyed memo for
632
+ # unmerged_drift_paths: both texts are held objects (synced/originals are
633
+ # setdefault/assign-once per event; the watcher pops code_cache on disk
634
+ # write), so the O(file) normalize+compare runs once per actual change,
635
+ # not per render/frame (editor banner and merge window call this hot).
636
+ _drift_memo = {}
637
+
638
+ @classmethod
639
+ def unmerged_drift_paths(cls):
640
+ """Paths (resolved strs, ExternalChanges keys) whose tracked external
641
+ drift has NOT been merged into pending yet: normalized sync-frame text
642
+ differs from current disk. Comparison is memoized by object identity —
643
+ never a per-call content pass over unchanged texts."""
644
+ from meltygui.core.melty import Melty
645
+ from meltygui.editor.external_changes import ExternalChanges
646
+ _norm = cls._norm_text
647
+ out = []
648
+ for path, baseline in list(ExternalChanges.originals.items()):
649
+ disk = Melty.read_code(path)
650
+ if disk is None:
651
+ continue
652
+ sync = ExternalChanges.synced.get(path, baseline)
653
+ key = (id(sync), id(disk))
654
+ hit = cls._drift_memo.get(path)
655
+ if hit is None or hit[0] != key:
656
+ hit = (key, _norm(sync) != _norm(disk))
657
+ cls._drift_memo[path] = hit
658
+ if hit[1]:
659
+ out.append(path)
660
+ return out
661
+
662
+ @classmethod
663
+ def needs_merge(cls):
664
+ """True iff some file has BOTH a real pending edit (data differs from
665
+ its load-time original) and unmerged external drift — the state where
666
+ exiting would lose or clobber pending. Drives the main-window close
667
+ guard. Pure read over existing state."""
668
+ from pathlib import Path as _P
669
+ drifted = set()
670
+ for p in cls.unmerged_drift_paths():
671
+ try:
672
+ drifted.add(_P(p).resolve())
673
+ except OSError:
674
+ continue
675
+ if not drifted:
676
+ return False
677
+ for addr, (codec, kwargs) in list(cls.pending_saves.items()):
678
+ data = kwargs.get("data")
679
+ if not isinstance(data, str) or data == cls.originals.get(addr):
680
+ continue
681
+ try:
682
+ if _P(addr.path).resolve() in drifted:
683
+ return True
684
+ except Exception:
685
+ continue
686
+ return False
687
+
688
+ @staticmethod
689
+ def _norm_text(t):
690
+ return str(t).replace("\r\n", "\n").replace("\r", "\n")
691
+
692
+ @classmethod
693
+ def resolve_external(cls, path, prefer=None, allow_merge=None,
694
+ whole_text=None):
695
+ """Resolve ONE tracked external change into per-span pending entries
696
+ and return its result line (None when there's nothing to do —
697
+ untracked path, or drift that healed back to the baseline).
698
+
699
+ whole_text: the caller's WHOLE-FILE pending buffer (the merge
700
+ window's pending pane — the studio's current text of the file with
701
+ every accepted change spliced in). When given, the file resolves on
702
+ the whole-file path with that text as "mine" (prefer='mine' queues
703
+ it verbatim as the result; 'theirs' takes disk; None + allow_merge
704
+ 3-way merges it against the sync frame), and the per-span entries
705
+ it already contains are dropped for the one whole-file entry.
706
+
707
+ allow_merge gates the per-span 3-way merge for OVERLAPPING spans:
708
+ only an explicit merge action (the Merge buttons) passes True;
709
+ the default (None/False) forces overlaps to CONFLICT. Rebase/adopt
710
+ of non-overlapping work never depends on it.
711
+
712
+ The disk drift (sync frame → current disk, see ExternalChanges.synced)
713
+ is decomposed into the SAME shape as in-studio edits — span pending
714
+ entries anchored on live objects — and merged with the queue:
715
+
716
+ * pending span entries untouched by the drift are REBASED to the new
717
+ disk coordinates (their span shifted through the drift's hunks);
718
+ * entries the drift overlaps are 3-way merged PER SPAN (base = the
719
+ sync-frame slice, mine = the pending data, theirs = the disk slice);
720
+ * drift hunks inside a live top-level def/class with no pending entry
721
+ are ADOPTED as new pending entries (data = the disk span, original =
722
+ the sync-frame span, so recompile_all hotswaps them; a successful
723
+ hotswap re-baselines them into no-ops since disk already holds them);
724
+ * brand-new defs/classes and changed import lines are exec'd into the
725
+ live module; other module-level changes are reported as
726
+ restart-needed.
727
+
728
+ Live co_firstlineno's are shifted from the sync frame to the disk
729
+ frame (resync_file_linenos) so the disk-coordinate invariant holds —
730
+ the whole-module hotswap from non-disk text this used to do is what
731
+ moved live code into coordinates no file had, corrupting every span
732
+ resolution afterwards.
733
+
734
+ prefer=None runs the auto merge; an overlap that cannot merge returns
735
+ the CONFLICT line and mutates NOTHING. The Merge window's accept
736
+ buttons force a side per overlapping entry instead: prefer='mine'
737
+ keeps the pending data (drift under it is overwritten on
738
+ recompile/shutdown-save); prefer='theirs' drops the overlapping
739
+ entries and takes disk. Non-overlapping entries and drift are always
740
+ rebased/adopted regardless of prefer.
741
+
742
+ The ExternalChanges entry is marked absorbed — NOT popped: the
743
+ external window keeps showing accumulated drift until the user
744
+ dismisses it. ExternalChanges.synced records the disk this absorb was
745
+ computed against, so the next drift diffs from HERE, not from the
746
+ display baseline."""
747
+ from pathlib import Path as _P
748
+ from meltygui.core.melty import Melty
749
+ from meltygui.editor.external_changes import ExternalChanges
750
+ from meltygui.core.automation.mcp_hotswap import _resolve_module
751
+
752
+ if allow_merge is None:
753
+ allow_merge = False # merging only allowed on explicit request
754
+
755
+ _norm = cls._norm_text
756
+ baseline = ExternalChanges.originals.get(path)
757
+ if baseline is None:
758
+ return None
759
+ name = _P(path).name
760
+ disk = Melty.read_code(path)
761
+ if disk is None:
762
+ return f"SKIPPED {name}: deleted or unreadable"
763
+ if prefer is None and ExternalChanges.is_absorbed(path, disk):
764
+ return None # this exact drift is already in the queue
765
+ base_n, disk_n = _norm(baseline), _norm(disk)
766
+ sync_n = _norm(ExternalChanges.synced.get(path, baseline))
767
+ if sync_n == disk_n:
768
+ # Nothing new since the last absorb. Fully healed (disk also back
769
+ # at the display baseline) → drop tracking; else just arm the
770
+ # absorbed marker.
771
+ if base_n == disk_n:
772
+ ExternalChanges.untrack(path)
773
+ else:
774
+ ExternalChanges.mark_absorbed(path, disk)
775
+ return None
776
+ # NOTE: disk == display baseline is NOT "nothing to do" - after an
777
+ # absorb, a disk revert back to the baseline is REAL sync→disk drift
778
+ # (live code holds the absorbed state) and must absorb like any other
779
+ # change, or live and disk silently diverge (found the hard way:
780
+ # reverting a hotswapped smoke edit left the old text live).
781
+ try:
782
+ rp = _P(path).resolve()
783
+ except OSError:
784
+ return f"SKIPPED {name}: unresolvable path"
785
+
786
+ # The file's real pending span entries (data differs from load-time
787
+ # original), and any legacy whole-file entry.
788
+ entries, whole = [], []
789
+ for addr, (codec, kwargs) in list(cls.pending_saves.items()):
790
+ data = kwargs.get("data")
791
+ if not isinstance(data, str):
792
+ continue
793
+ if data == cls.originals.get(addr):
794
+ continue # no-op entry - nothing at stake
795
+ try:
796
+ if _P(addr.path).resolve() != rp:
797
+ continue
798
+ except Exception:
799
+ continue
800
+ (whole if addr.start is None else entries).append(
801
+ (addr, codec, kwargs, data))
802
+
803
+ module = _resolve_module(path)
804
+ if module is None or whole or whole_text is not None:
805
+ # Plain files (no live module → no span hotswap, whole-file is
806
+ # available), whole-file entries (the code editor's buffer, the merge
807
+ # window's pane) and an explicit whole_text take the whole-file
808
+ # merge. The base is the SYNC frame - the text pending derived
809
+ # from - so a second merge after an absorb doesn't re-apply
810
+ # already-absorbed hunks.
811
+ return cls._resolve_external_wholefile(
812
+ rp, name, path, module, sync_n, disk_n, disk,
813
+ entries + whole, prefer, allow_merge, mine=whole_text)
814
+
815
+ return cls._resolve_external_spans(
816
+ rp, name, path, module, sync_n, disk_n, disk, entries, prefer,
817
+ allow_merge)
818
+
819
+ @classmethod
820
+ def _resolve_external_wholefile(cls, rp, name, path, module, base_n,
821
+ disk_n, disk, absorbed, prefer,
822
+ allow_merge, mine=None):
823
+ """The whole-file merge: plain (non-module) files, whole-file entries
824
+ (the code editor's buffer) and the merge window's pane (`mine`, the
825
+ caller's whole-file text). base/mine/theirs are whole texts; the
826
+ result is ONE whole-file pending entry fingerprinted against the
827
+ disk it merged with."""
828
+ from meltygui.editor.external_changes import ExternalChanges
829
+ from meltygui.code.new_codecs import ModuleCodec
830
+ from meltygui.code.new_codecs import TextFileCodec
831
+ from meltygui.code.new_codecs import _span_fingerprint
832
+ from meltygui.code.fileref import Address
833
+
834
+ _norm = cls._norm_text
835
+ whole = [d for a, c, k, d in absorbed if a.start is None]
836
+ if mine is not None:
837
+ mine = _norm(mine)
838
+ elif whole:
839
+ mine = _norm(whole[-1])
840
+ elif absorbed:
841
+ # Splice bottom-up (highest start first) so an applied span
842
+ # never overlap a not-yet-applied span above it - same order as
843
+ # current_file_text / apply_all_saves.
844
+ lines = base_n.split("\n")
845
+ for addr, codec, kwargs, data in sorted(
846
+ (e for e in absorbed if e[0].start is not None),
847
+ key=lambda e: -e[0].start):
848
+ d = _norm(data)
849
+ if d.endswith("\n"):
850
+ d = d[:-1]
851
+ lines[addr.start:addr.end] = d.split("\n")
852
+ mine = "\n".join(lines)
853
+ else:
854
+ mine = base_n
855
+
856
+ if prefer == "mine":
857
+ merged = mine
858
+ elif prefer == "theirs":
859
+ merged = disk_n
860
+ else:
861
+ # 3-way merge gated by allow_merge (True only on an explicit Merge
862
+ # action); off → overlapping pending edits report as a conflict
863
+ # for the manual merge window instead of merging silently.
864
+ if mine == base_n:
865
+ merged = disk_n
866
+ elif allow_merge:
867
+ merged = three_way_merge(base_n, mine, disk_n)
868
+ else:
869
+ merged = None
870
+ if merged is None:
871
+ return (f"CONFLICT {name}: {len(absorbed)} pending edit(s) "
872
+ f"overlap the external change — see Merge window")
873
+
874
+ address = Address(rp, source=module if module is not None else str(rp))
875
+ merge_codec = ModuleCodec if module is not None else TextFileCodec
876
+ if module is None:
877
+ address._allow_write = True # plain-file gate, see codec.save
878
+ for addr, codec, kwargs, data in absorbed:
879
+ cls.pending_saves.pop(addr, None)
880
+ cls.originals.pop(addr, None)
881
+ cls.originals[address] = base_n
882
+ # Fingerprint the DISK this merge was computed against, arming
883
+ # codec.save's changed-on-disk refusal (SaveConflict); like every
884
+ # codec.load-stamped entry; drifts after this merge defers
885
+ # the flush instead of being silently overwritten.
886
+ address._span_fp = _span_fingerprint(disk_n.split("\n"))
887
+ cls.queue_save(address, merge_codec, data=merged)
888
+ ExternalChanges.synced[path] = disk
889
+ ExternalChanges.mark_absorbed(path, disk)
890
+ if prefer == "mine":
891
+ return (f"KEPT OURS {name}: pending version queued — the external "
892
+ f"change will be overwritten")
893
+ if prefer == "theirs":
894
+ return (f"TOOK THEIRS {name}: disk version queued, dropped "
895
+ f"{len(absorbed)} pending edit(s)")
896
+ if absorbed:
897
+ return f"MERGED {name}: external change + {len(absorbed)} pending edit(s)"
898
+ return f"ADOPTED {name}: external change is now a pending edit"
899
+
900
+ @classmethod
901
+ def _resolve_external_spans(cls, rp, name, path, module, sync_n, disk_n,
902
+ disk, entries, prefer, allow_merge):
903
+ """Span-level absorption for a live Python module (see
904
+ resolve_external). Two-phase: PLAN everything against the sync→disk
905
+ diff first — any unresolvable overlap returns the CONFLICT line with
906
+ NOTHING mutated — then commit: shift live linenos, rebase/merge/adopt
907
+ entries, exec new imports/defs, advance the sync frame."""
908
+ import ast
909
+ from meltygui.core.melty import Melty
910
+ from meltygui.editor.external_changes import ExternalChanges
911
+ from meltygui.code.fileref import Address
912
+ from meltygui.code.fileref import _evict_linecache
913
+ from meltygui.code.new_codecs import TypeCodec
914
+ from meltygui.code.new_codecs import FunctionCodec
915
+ from meltygui.code.new_codecs import _span_fingerprint
916
+ from meltygui.code.new_codecs import resync_file_linenos
917
+
918
+ sync_lines = sync_n.split("\n")
919
+ disk_lines = disk_n.split("\n")
920
+ ops = [(i1, i2, j1, j2) for tag, i1, i2, j1, j2 in
921
+ difflib.SequenceMatcher(None, sync_lines, disk_lines,
922
+ autojunk=False).get_opcodes()
923
+ if tag != "equal"]
924
+ if not ops:
925
+ ExternalChanges.synced[path] = disk
926
+ ExternalChanges.mark_absorbed(path, disk)
927
+ return None
928
+
929
+ def _exp(i1, i2):
930
+ # Insertion-expanded old-side range: a pure insert claims the line
931
+ # it lands before (same convention as three_way_merge /
932
+ # merge_files._changed_old_ranges), so an insert INSIDE a span
933
+ # overlaps it while an insert AT its end belongs below.
934
+ return i1, max(i2, i1 + 1)
935
+
936
+ _norm = cls._norm_text
937
+ rebases, merges, drops = [], [], []
938
+ merged_ct = kept_ct = took_ct = 0
939
+ # Sync-frame spans whose entry SURVIVES an overlap (merged / kept):
940
+ # their hunks are settled by the entry. A dropped entry ('theirs')
941
+ # leaves its hunks unclaimed so the disk version adopts + hotswaps;
942
+ # a non-overlapped span contains no hunks, so claiming is moot.
943
+ claimed = []
944
+ for addr, codec, kwargs, data in entries:
945
+ s = addr.start
946
+ orig = cls.originals.get(addr)
947
+ if addr.end is not None:
948
+ e = addr.end
949
+ elif isinstance(orig, str):
950
+ e = s + len(_norm(orig).split("\n"))
951
+ else:
952
+ e = s + 1
953
+ d_above = d_inside = 0
954
+ overlap = straddle = False
955
+ for (i1, i2, j1, j2) in ops:
956
+ e1, e2 = _exp(i1, i2)
957
+ if e2 <= s:
958
+ d_above += (j2 - j1) - (i2 - i1)
959
+ elif e1 < e and e2 > s:
960
+ overlap = True
961
+ if i1 < s or i2 > e:
962
+ straddle = True
963
+ else:
964
+ d_inside += (j2 - j1) - (i2 - i1)
965
+ ns = s + d_above
966
+ if not overlap:
967
+ rebases.append((addr, codec, kwargs, _norm(data),
968
+ ns, e + d_above, orig))
969
+ continue
970
+ if straddle and prefer != "theirs":
971
+ # The external change crosses this span's boundary - no clean
972
+ # theirs-slice exists, and splicing "mine" over part of it
973
+ # would tear the hunk. Human call either way.
974
+ return (f"CONFLICT {name}: an external change crosses a "
975
+ f"pending span boundary — see Merge window")
976
+ ne = e + d_above + d_inside
977
+ theirs_txt = "\n".join(disk_lines[ns:ne])
978
+ if prefer == "mine":
979
+ kept_ct += 1
980
+ claimed.append((s, e))
981
+ merges.append((addr, codec, kwargs, _norm(data),
982
+ ns, ne, theirs_txt))
983
+ elif prefer == "theirs":
984
+ took_ct += 1
985
+ drops.append(addr)
986
+ else:
987
+ # base = the sync-frame slice: the common ancestor both the
988
+ # pending edit and the disk drift derived from. Gated by
989
+ # allow_merge (allow only on an explicit Merge action).
990
+ if not allow_merge:
991
+ return (f"CONFLICT {name}: external change overlaps a "
992
+ f"pending edit — see Merge window")
993
+ base_txt = "\n".join(sync_lines[s:e])
994
+ m = three_way_merge(base_txt, _norm(data), theirs_txt)
995
+ if m is None:
996
+ return (f"CONFLICT {name}: external change overlaps a "
997
+ f"pending edit — see Merge window")
998
+ merged_ct += 1
999
+ claimed.append((s, e))
1000
+ merges.append((addr, codec, kwargs, m, ns, ne, theirs_txt))
1001
+
1002
+ # Drift hunks no pending entry claims → adopt / exec / restart-note.
1003
+ leftover = [op for op in ops
1004
+ if not any(_exp(op[0], op[1])[0] < e
1005
+ and _exp(op[0], op[1])[1] > s
1006
+ for s, e in claimed)]
1007
+ adopts, new_defs, exec_fails = [], [], []
1008
+ restart_needed = 0
1009
+ if leftover:
1010
+ try:
1011
+ sync_tree = ast.parse(sync_n)
1012
+ disk_tree = ast.parse(disk_n)
1013
+ except SyntaxError as ex:
1014
+ return (f"SKIPPED {name}: does not parse "
1015
+ f"({ex.msg}, line {ex.lineno}) — fix and recompile again")
1016
+
1017
+ def _spans(tree):
1018
+ out = {}
1019
+ for node in tree.body:
1020
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef,
1021
+ ast.AsyncFunctionDef)):
1022
+ first = node.lineno
1023
+ if node.decorator_list:
1024
+ first = min(first, node.decorator_list[0].lineno)
1025
+ out[node.name] = (first - 1, node.end_lineno)
1026
+ return out
1027
+
1028
+ sspans, dspans = _spans(sync_tree), _spans(disk_tree)
1029
+ adopt_names, newdef_names = set(), set()
1030
+ for (i1, i2, j1, j2) in leftover:
1031
+ e1, e2 = _exp(i1, i2)
1032
+ owner = next((nm for nm, (s0, e0) in sspans.items()
1033
+ if s0 <= e1 and e2 <= e0), None)
1034
+ if owner is not None:
1035
+ obj = module.__dict__.get(owner)
1036
+ if (isinstance(obj, (type, types.FunctionType))
1037
+ and owner in dspans):
1038
+ adopt_names.add(owner)
1039
+ else:
1040
+ restart_needed += 1
1041
+ continue
1042
+ # Not inside any sync-frame object - brand-new disk-frame
1043
+ # functions/classes exec live (intersection, not containment: the
1044
+ # hunk usually drags the blank lines around a new def along).
1045
+ # Leftover import lines are handled by _exec_file_imports
1046
+ # below; anything else non-blank needs a restart.
1047
+ de1, de2 = j1, max(j2, j1 + 1)
1048
+ hits = [nm for nm, (s0, e0) in dspans.items()
1049
+ if nm not in sspans and s0 < de2 and de1 < e0]
1050
+ newdef_names.update(hits)
1051
+ covered = [dspans[nm] for nm in hits]
1052
+ for idx in range(j1, min(j2, len(disk_lines))):
1053
+ t = disk_lines[idx].strip()
1054
+ if (t and not t.startswith(("import ", "from ", "#"))
1055
+ and not any(s0 <= idx < e0 for s0, e0 in covered)):
1056
+ restart_needed += 1
1057
+ break
1058
+ for nm in sorted(adopt_names):
1059
+ s0, e0 = sspans[nm]
1060
+ ds0, de0 = dspans[nm]
1061
+ adopts.append((nm, module.__dict__[nm], ds0, de0,
1062
+ "\n".join(disk_lines[ds0:de0]),
1063
+ "\n".join(sync_lines[s0:e0])))
1064
+ new_defs = [(nm,) + dspans[nm] for nm in sorted(newdef_names)]
1065
+
1066
+ # ── Apply ───────────────────────────────────────────────────────────
1067
+ # Live co_firstlineno's move from the sync frame to the disk frame
1068
+ # FIRST: recompile_all's per-span hotswaps uses each function's
1069
+ # live lineno, so it must already have the disk one (disk-coordinate
1070
+ # invariant), and every later span resolution hits disk.
1071
+ resync_file_linenos(rp, sync_lines, disk_lines)
1072
+ _evict_linecache(str(rp))
1073
+
1074
+ for addr in drops:
1075
+ cls.pending_saves.pop(addr, None)
1076
+ cls.originals.pop(addr, None)
1077
+
1078
+ # New imports/defs exec BEFORE the adopted spans queue: an adopted
1079
+ # function that calls a new helper must find it live when it hotswaps.
1080
+ if leftover:
1081
+ try:
1082
+ from meltygui.code.file_converters import _exec_file_imports
1083
+ _exec_file_imports(str(rp), module.__dict__)
1084
+ except Exception as ex:
1085
+ exec_fails.append(f"imports: {type(ex).__name__}: {ex}")
1086
+ for nm, ds0, de0 in new_defs:
1087
+ # Pad so the new code object lands with the true disk lineno.
1088
+ src = "\n" * ds0 + "\n".join(disk_lines[ds0:de0])
1089
+ try:
1090
+ with Melty.annotation_scope():
1091
+ exec(compile(src, str(rp), "exec"), module.__dict__)
1092
+ except Exception as ex:
1093
+ exec_fails.append(f"new def {nm}: {type(ex).__name__}: {ex}")
1094
+
1095
+ # Adopted entries queue before the rebases: both may touch the same
1096
+ # object (a sub-span entry like Decorations inside an adopted class),
1097
+ # and recompile_all runs in queue order - the whole-object adoption
1098
+ # must hotswap first so the narrower pending edit re-applies on top.
1099
+ for nm, obj, ds0, de0, dtxt, otxt in adopts:
1100
+ na = Address(rp, ds0, de0, source=obj)
1101
+ na._span_fp = _span_fingerprint(disk_lines[ds0:de0])
1102
+ # Marks the entry for recompile_all: once the hotswap lands, the
1103
+ # entry re-baselines to a no-op (its text is already on disk).
1104
+ na._ext_adopt = True
1105
+ cls.originals[na] = otxt
1106
+ cls.queue_save(na, FunctionCodec if isinstance(obj, types.FunctionType)
1107
+ else TypeCodec, data=dtxt)
1108
+
1109
+ for addr, codec, kwargs, data, ns, ne, orig in rebases:
1110
+ na = Address(rp, ns, ne, source=addr.source,
1111
+ watcher_ds=addr._watcher_ds)
1112
+ for attr in ("_allow_write", "_shift_source"):
1113
+ if hasattr(addr, attr):
1114
+ setattr(na, attr, getattr(addr, attr))
1115
+ na._span_fp = _span_fingerprint(disk_lines[ns:ne])
1116
+ cls.rebase_entry(addr, na, codec, data=data,
1117
+ original=(orig if isinstance(orig, str)
1118
+ else "\n".join(disk_lines[ns:ne])),
1119
+ **{k: v for k, v in kwargs.items() if k != "data"})
1120
+ for addr, codec, kwargs, data, ns, ne, theirs_txt in merges:
1121
+ na = Address(rp, ns, ne, source=addr.source,
1122
+ watcher_ds=addr._watcher_ds)
1123
+ for attr in ("_allow_write", "_shift_source"):
1124
+ if hasattr(addr, attr):
1125
+ setattr(na, attr, getattr(addr, attr))
1126
+ na._span_fp = _span_fingerprint(disk_lines[ns:ne])
1127
+ # original = the disk slice: the entry stays "real" (data differs)
1128
+ # so it recompiles and flushes at shutdown, and the next drift
1129
+ # 3-way merges against the frame it actually diverged from.
1130
+ cls.rebase_entry(addr, na, codec, data=data, original=theirs_txt,
1131
+ **{k: v for k, v in kwargs.items() if k != "data"})
1132
+
1133
+ ExternalChanges.synced[path] = disk
1134
+ ExternalChanges.mark_absorbed(path, disk)
1135
+
1136
+ parts = []
1137
+ if merged_ct:
1138
+ parts.append(f"merged {merged_ct} overlapping pending edit(s)")
1139
+ if kept_ct:
1140
+ parts.append(f"kept {kept_ct} pending edit(s) over the external change")
1141
+ if took_ct:
1142
+ parts.append(f"dropped {took_ct} pending edit(s) for disk")
1143
+ if adopts:
1144
+ parts.append(f"adopted {len(adopts)} changed def(s)")
1145
+ if new_defs:
1146
+ parts.append(f"exec'd {len(new_defs)} new def(s)")
1147
+ if rebases:
1148
+ parts.append(f"rebased {len(rebases)} pending edit(s)")
1149
+ if restart_needed:
1150
+ parts.append(f"{restart_needed} module-level change(s) apply on restart")
1151
+ parts.extend(f"FAILED {f}" for f in exec_fails)
1152
+ tag = ("KEPT OURS" if prefer == "mine"
1153
+ else "TOOK THEIRS" if prefer == "theirs"
1154
+ else "MERGED" if merged_ct else "ADOPTED")
1155
+ return f"{tag} {name}: " + "; ".join(parts)
1156
+
1157
+ @classmethod
1158
+ def _wake_windows(cls):
1159
+ """Worker-thread wake after absorb rewrites the queue: force both
1160
+ windows' subtrees to re-capture (the pending window shows new
1161
+ entries + merge results; the external window gained absorbed markers —
1162
+ its _external_change flag is the established cache bypass)."""
1163
+ try:
1164
+ from meltygui.core.melty import Melty
1165
+ from meltygui.core.windowing.glfw_utils import request_render
1166
+ from meltygui.editor.external_changes import ExternalChanges
1167
+ win = Melty.find_window("draw_pending_saves")
1168
+ if win is not None:
1169
+ Melty.cache.invalidate_up(win._tile_id, force=True, max_depth=8)
1170
+ ds = ExternalChanges._window_ds
1171
+ if ds is not None:
1172
+ ds._external_change = True
1173
+ request_render()
1174
+ except Exception:
1175
+ pass
1176
+
1177
+ @classmethod
1178
+ def recompile_all(cls):
1179
+ """Hotswap every changed pending edit into the running process — no
1180
+ disk write; the queue stays intact for apply_all_saves at shutdown.
1181
+ External disk drift is IGNORED here: pending is the current state,
1182
+ and drift is merged in manually (merge window / editor banner), never
1183
+ as a recompile side effect.
1184
+
1185
+ Rides the editor Run button's worker (recompile_source): each queued
1186
+ span recompiles its OWN live object in place (class / function /
1187
+ module / decorator block / call site), so line-number conventions
1188
+ match a per-editor Run exactly and the hotswap guard arms as usual.
1189
+ Entries whose text still equals their load-time original are skipped
1190
+ — the same no-op filter the diff view uses."""
1191
+ from meltygui.code.new_converters import recompile_source
1192
+ from meltygui.code.new_codecs import CallSite
1193
+ from meltygui.code.new_codecs import Decorations
1194
+ from meltygui.code.chain_converters import record_compile
1195
+ from meltygui.code.file_converters import module_for_path
1196
+
1197
+ # Pick up project files/dirs created since startup (idempotent, only
1198
+ # uncached files are read) so their NEXT pending edit is tracked -
1199
+ # this runs on the recompile worker, never the render thread.
1200
+ try:
1201
+ from meltygui.core.melty import FileWatch
1202
+ FileWatch.watch_project_files()
1203
+ except Exception:
1204
+ pass
1205
+
1206
+ compiled, failures = [], []
1207
+ # Snapshot: this runs in a worker thread (draw_function run_in_thread)
1208
+ # while the render thread may still queue edits mid-iteration.
1209
+ for address, (codec, kwargs) in list(cls.pending_saves.items()):
1210
+ data = kwargs.get("data")
1211
+ if not isinstance(data, str):
1212
+ continue
1213
+ if data == cls.originals.get(address): # .get - the factory raises
1214
+ continue
1215
+ label = address.path.name if address.path is not None else "?"
1216
+ if address.start is not None:
1217
+ label += f"({address.start}:{address.end})"
1218
+ source = getattr(address, "source", None)
1219
+ if not isinstance(source, (type, types.FunctionType,
1220
+ types.ModuleType, CallSite, Decorations)):
1221
+ # Whole-file text entries (TextFileCodec) carry the PATH as
1222
+ # source - a spanless .py entry with a live module still
1223
+ # hotswaps (recompile_source resolves the module). Anything
1224
+ # else is plain text / no live object - nothing to hotswap;
1225
+ # the entry simply flushes to disk at save. Not worth reporting.
1226
+ if not (address.start is None and address.path is not None
1227
+ and address.path.suffix.lower() == ".py"
1228
+ and module_for_path(address.path) is not None):
1229
+ continue
1230
+ # Whole-file module entries hotswap from their PENDING text - the
1231
+ # same whole-module path the per-editor Run button uses on the
1232
+ # live buffer (_recompile_module). The old refusal of non-disk
1233
+ # text guarded the absorb-era merged entries, which matched
1234
+ # neither the editors nor disk; a whole-file entry now IS the
1235
+ # editor's buffer truth, and span consumers read that same text
1236
+ # through the pending overlay (pending_text_for's whole-file
1237
+ # filtering), so live linenos and the served source are coherent.
1238
+ try:
1239
+ err = recompile_source(source, data, address.path, address=address)
1240
+ except Exception as e:
1241
+ err = e
1242
+ if err is None:
1243
+ record_compile(address)
1244
+ compiled.append(label)
1245
+ if getattr(address, "_ext_adopt", False):
1246
+ # An adopted external span is live now and already on
1247
+ # disk - re-baseline it into a no-op so it drops from the
1248
+ # diff view and never re-merges as a "pending edit".
1249
+ cls.originals[address] = data
1250
+ else:
1251
+ failures.append(f"{label}: {type(err).__name__}: {err}")
1252
+
1253
+ if not (compiled or failures):
1254
+ return "Nothing to recompile — no changed pending edits."
1255
+ lines = []
1256
+ if compiled:
1257
+ lines.append(f"Recompiled {len(compiled)}: {', '.join(compiled)}")
1258
+ for failure in failures:
1259
+ lines.append(f"FAILED {failure}")
1260
+ return "\n".join(lines)
1261
+
1262
+ @classmethod
1263
+ def recompile_all_ui(cls):
1264
+ """Run recompile_all exactly as a CLICK on the Pending Saves window's
1265
+ recompile button does: same runner draw_state, same lifecycle — the
1266
+ _run_busy spinner while running, then result + _result_frame stamped
1267
+ for the fading check mark + summary (draw_function's run_in_thread
1268
+ worker protocol, including _run_error on an exception). The recompile
1269
+ itself runs on the CALLING thread (MCP handler / hotkey worker — the
1270
+ established off-render path) while the render thread paints the busy
1271
+ state. Callers: the MCP recompile tool and draw_main's Ctrl+Enter.
1272
+
1273
+ Reveals the window first (posted to the render thread) so the summary
1274
+ is actually seen; if the window has never rendered, the runner ds
1275
+ appears on that reveal frame and is picked up by a short retry —
1276
+ worst case the summary is only the returned string. Single-flight via
1277
+ the button's own _run_busy latch."""
1278
+ import time
1279
+ from meltygui.core.melty import Melty
1280
+ from meltygui.core.windowing.glfw_utils import request_render
1281
+ from meltygui.core.rendering.render_dispatch import is_run_busy
1282
+ from meltygui.core.rendering.render_dispatch import run_busy_begin
1283
+ from meltygui.core.rendering.render_dispatch import run_busy_end
1284
+
1285
+ def _find_runner():
1286
+ try:
1287
+ win = Melty.find_window("draw_pending_saves")
1288
+ if win is None:
1289
+ return None
1290
+ for d in win.descendants(max_depth=8):
1291
+ if str(getattr(d, 'name', '')).startswith("recompile_all"):
1292
+ return d
1293
+ except Exception:
1294
+ return None
1295
+ return None
1296
+
1297
+ def _reveal():
1298
+ try:
1299
+ from meltygui.core.rendering.render_dispatch import Core
1300
+ Core.melty.open_window("draw_pending_saves")
1301
+ except Exception:
1302
+ pass
1303
+
1304
+ try:
1305
+ Melty.post_to_render(_reveal)
1306
+ except Exception:
1307
+ pass
1308
+ request_render()
1309
+ ds = _find_runner()
1310
+ if ds is None: # never-rendered window: the reveal
1311
+ for _ in range(10): # frame creates the runner ds
1312
+ time.sleep(0.05)
1313
+ ds = _find_runner()
1314
+ if ds is not None:
1315
+ break
1316
+ # The busy latch is draw_function's long-lifetime _RUN_BUSY (not
1317
+ # serialized - the misc flag once persisted into ds.pkl and reloaded
1318
+ # every session as "True"). Armed INSIDE the try so nothing between
1319
+ # arming and the finally (the invalidate below once sat outside it)
1320
+ # can leave the runner busy for good.
1321
+ if ds is not None and is_run_busy(ds):
1322
+ return "recompile already running — try again shortly"
1323
+ try:
1324
+ if ds is not None:
1325
+ run_busy_begin(ds)
1326
+ Melty.cache.invalidate_up(ds._tile_id, force=True, max_depth=6)
1327
+ request_render()
1328
+ try:
1329
+ summary = cls.recompile_all()
1330
+ except Exception as e:
1331
+ summary = f"recompile FAILED: {type(e).__name__}: {e}"
1332
+ if ds is not None:
1333
+ ds.misc["_run_error"] = summary
1334
+ else:
1335
+ if ds is not None:
1336
+ ds.result = summary
1337
+ ds.misc["_result_frame"] = Melty.frame_count
1338
+ ds.misc.pop("_run_error", None)
1339
+ finally:
1340
+ if ds is not None:
1341
+ run_busy_end(ds)
1342
+ try:
1343
+ Melty.cache.invalidate_up(ds._tile_id, force=True, max_depth=6)
1344
+ except Exception as e:
1345
+ print(f"recompile_all_ui: invalidate after run failed: {e!r}")
1346
+ request_render()
1347
+ return summary
1348
+
1349
+
1350
+ from meltygui.view.file_view import draw_pending_saves
1351
+ draw_pending_saves = window(disable_scroll=False, z_offset=0, tint=(0.27, 0.27, 0.31))(draw_pending_saves)