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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- 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)
|