railmux 0.4.0.dev8__tar.gz → 0.4.0.dev9__tar.gz
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.
- {railmux-0.4.0.dev8/src/railmux.egg-info → railmux-0.4.0.dev9}/PKG-INFO +1 -1
- railmux-0.4.0.dev9/src/railmux/__init__.py +1 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/cli.py +10 -2
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/discovery.py +141 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tmux_binding_manager.py +52 -13
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tmux_ctl.py +145 -43
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/app.py +50 -1
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/windows_install_log.py +28 -8
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/windows_msys2.py +138 -4
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/windows_pacman.py +176 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9/src/railmux.egg-info}/PKG-INFO +1 -1
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_discovery.py +113 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_fullscreen_binding.py +41 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_binding_manager.py +39 -5
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_ctl.py +36 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_integration.py +17 -2
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_install_log.py +34 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_msys2.py +62 -7
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_pacman.py +96 -0
- railmux-0.4.0.dev8/src/railmux/__init__.py +0 -1
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/LICENSE +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/MANIFEST.in +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/README.md +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/pyproject.toml +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/setup.cfg +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/__main__.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/background_index.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/codex_index.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/config.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_history.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fast_display_server.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/favorites.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/launcher.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/local_open.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/models.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/modes.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/provider_paths.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/renames.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/restart_state.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/self_update.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/session_index.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/settings.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tmux_server.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/transcript.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/modals.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux/windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/SOURCES.txt +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_agent_workspace.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_background_index.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_cli.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_codex_delete.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_codex_index.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_codex_lineage.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_config.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_config_cli.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_display_transport.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_fast_full_window.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_focus_highlight.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_help_agent.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_keymap.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_launcher.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_local_open.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_modals.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_modes.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_new_project.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_options.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_path_codec.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_projects_pane.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_provider_paths.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_release_notes.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_remote_config.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_renames.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_restart_state.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_running_pane.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_running_sort.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_self_update.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_session_cache.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_session_index.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_settings.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_sidebar.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_soft_quit.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_status.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_statusbar.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_system_deps.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_server.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_transcript.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_widgets.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tools/release_notes.py +0 -0
- {railmux-0.4.0.dev8 → railmux-0.4.0.dev9}/tools/wheel_smoke.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0.dev9"
|
|
@@ -24,12 +24,15 @@ from railmux.system_deps import ensure_tmux_available
|
|
|
24
24
|
from railmux.ssh_args import AppendSshArgument, ExtendSshArguments
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def _show_startup_message(
|
|
27
|
+
def _show_startup_message(
|
|
28
|
+
detail: str = "Reconnecting sessions and panes…",
|
|
29
|
+
) -> None:
|
|
28
30
|
"""Paint immediate feedback before App performs its initial discovery."""
|
|
29
31
|
if not sys.stdout.isatty():
|
|
30
32
|
return
|
|
31
33
|
size = shutil.get_terminal_size((80, 24))
|
|
32
|
-
sys.stdout.write(render_startup_surface(
|
|
34
|
+
sys.stdout.write(render_startup_surface(
|
|
35
|
+
size.columns, size.lines, detail=detail))
|
|
33
36
|
sys.stdout.flush()
|
|
34
37
|
|
|
35
38
|
|
|
@@ -441,6 +444,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
441
444
|
_show_startup_message()
|
|
442
445
|
# Lazy import so non-TUI invocations (--version etc) don't pull urwid.
|
|
443
446
|
from railmux.ui.app import App
|
|
447
|
+
from railmux.provider_paths import running_in_windows_wrapper
|
|
444
448
|
app = App(
|
|
445
449
|
claude_home=Path(args.claude_home),
|
|
446
450
|
config=config,
|
|
@@ -451,6 +455,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
451
455
|
is_ssh_session() if args.scroll_coalescing is None
|
|
452
456
|
else args.scroll_coalescing
|
|
453
457
|
),
|
|
458
|
+
startup_progress=(
|
|
459
|
+
_show_startup_message
|
|
460
|
+
if running_in_windows_wrapper() else None
|
|
461
|
+
),
|
|
454
462
|
)
|
|
455
463
|
app.run()
|
|
456
464
|
return 0
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Enumerate Claude project directories from ~/.claude/projects/."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
7
|
+
import stat
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
|
|
8
10
|
from railmux.atomic_file import atomic_write_text
|
|
@@ -19,6 +21,134 @@ _cache: dict[Path, tuple[int, list[Project]]] = {}
|
|
|
19
21
|
_session_validity: dict[
|
|
20
22
|
Path, dict[Path, tuple[tuple[int, int, int], bool]]
|
|
21
23
|
] = {}
|
|
24
|
+
_persistent_validity_loaded: set[Path] = set()
|
|
25
|
+
_persistent_validity_exact_only: set[Path] = set()
|
|
26
|
+
_persistent_validity_dirty: set[Path] = set()
|
|
27
|
+
_VALIDITY_CACHE_SCHEMA = 1
|
|
28
|
+
_VALIDITY_CACHE_LIMIT = 16 * 1024 * 1024
|
|
29
|
+
_VALIDITY_RECORD_LIMIT = 100_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _windows_validity_cache_enabled() -> bool:
|
|
33
|
+
return os.environ.get("RAILMUX_WINDOWS_RUNTIME") == "msys2"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validity_cache_file(claude_home: Path) -> Path:
|
|
37
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
38
|
+
identity = hashlib.sha256(
|
|
39
|
+
os.fsencode(os.path.abspath(claude_home))
|
|
40
|
+
).hexdigest()[:16]
|
|
41
|
+
return base / "railmux" / f"claude-validity-{identity}.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_persistent_validity(claude_home: Path) -> None:
|
|
45
|
+
if (
|
|
46
|
+
not _windows_validity_cache_enabled()
|
|
47
|
+
or claude_home in _persistent_validity_loaded
|
|
48
|
+
):
|
|
49
|
+
return
|
|
50
|
+
_persistent_validity_loaded.add(claude_home)
|
|
51
|
+
path = _validity_cache_file(claude_home)
|
|
52
|
+
try:
|
|
53
|
+
info = path.lstat()
|
|
54
|
+
if (
|
|
55
|
+
not stat.S_ISREG(info.st_mode)
|
|
56
|
+
or info.st_uid != os.getuid()
|
|
57
|
+
or info.st_size > _VALIDITY_CACHE_LIMIT
|
|
58
|
+
or info.st_mode & 0o077
|
|
59
|
+
):
|
|
60
|
+
return
|
|
61
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
63
|
+
return
|
|
64
|
+
if not isinstance(raw, dict) or raw.get("schema") != _VALIDITY_CACHE_SCHEMA:
|
|
65
|
+
return
|
|
66
|
+
records = raw.get("records")
|
|
67
|
+
if not isinstance(records, list) or len(records) > _VALIDITY_RECORD_LIMIT:
|
|
68
|
+
return
|
|
69
|
+
projects_dir = claude_home / "projects"
|
|
70
|
+
loaded: list[Path] = []
|
|
71
|
+
try:
|
|
72
|
+
for record in records:
|
|
73
|
+
if not isinstance(record, list) or len(record) != 6:
|
|
74
|
+
raise ValueError("invalid validity record")
|
|
75
|
+
project, filename, inode, mtime_ns, size, valid = record
|
|
76
|
+
if (
|
|
77
|
+
not isinstance(project, str)
|
|
78
|
+
or not is_encoded_project_name(project)
|
|
79
|
+
or not isinstance(filename, str)
|
|
80
|
+
or Path(filename).name != filename
|
|
81
|
+
or not filename.endswith(".jsonl")
|
|
82
|
+
or not _looks_like_uuid(Path(filename).stem)
|
|
83
|
+
or not all(isinstance(value, int) and value >= 0
|
|
84
|
+
for value in (inode, mtime_ns, size))
|
|
85
|
+
or not isinstance(valid, bool)
|
|
86
|
+
):
|
|
87
|
+
raise ValueError("invalid validity record")
|
|
88
|
+
project_dir = projects_dir / project
|
|
89
|
+
session_path = project_dir / filename
|
|
90
|
+
_session_validity.setdefault(project_dir, {})[session_path] = (
|
|
91
|
+
(inode, mtime_ns, size), valid,
|
|
92
|
+
)
|
|
93
|
+
loaded.append(session_path)
|
|
94
|
+
except ValueError:
|
|
95
|
+
for session_path in loaded:
|
|
96
|
+
cached = _session_validity.get(session_path.parent)
|
|
97
|
+
if cached is not None:
|
|
98
|
+
cached.pop(session_path, None)
|
|
99
|
+
if not cached:
|
|
100
|
+
_session_validity.pop(session_path.parent, None)
|
|
101
|
+
return
|
|
102
|
+
_persistent_validity_exact_only.update(loaded)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _mark_persistent_validity_dirty(claude_dir: Path) -> None:
|
|
106
|
+
if _windows_validity_cache_enabled():
|
|
107
|
+
_persistent_validity_dirty.add(claude_dir.parent.parent)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _save_persistent_validity(claude_home: Path) -> None:
|
|
111
|
+
if (
|
|
112
|
+
not _windows_validity_cache_enabled()
|
|
113
|
+
or claude_home not in _persistent_validity_dirty
|
|
114
|
+
):
|
|
115
|
+
return
|
|
116
|
+
projects_dir = claude_home / "projects"
|
|
117
|
+
records: list[list[object]] = []
|
|
118
|
+
for project_dir, values in _session_validity.items():
|
|
119
|
+
if (
|
|
120
|
+
project_dir.parent != projects_dir
|
|
121
|
+
or not is_encoded_project_name(project_dir.name)
|
|
122
|
+
):
|
|
123
|
+
continue
|
|
124
|
+
for session_path, (signature, valid) in values.items():
|
|
125
|
+
if session_path.parent != project_dir:
|
|
126
|
+
continue
|
|
127
|
+
inode, mtime_ns, size = signature
|
|
128
|
+
records.append([
|
|
129
|
+
project_dir.name,
|
|
130
|
+
session_path.name,
|
|
131
|
+
inode,
|
|
132
|
+
mtime_ns,
|
|
133
|
+
size,
|
|
134
|
+
valid,
|
|
135
|
+
])
|
|
136
|
+
records.sort(key=lambda item: int(item[3]), reverse=True)
|
|
137
|
+
records = records[:_VALIDITY_RECORD_LIMIT]
|
|
138
|
+
path = _validity_cache_file(claude_home)
|
|
139
|
+
try:
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
atomic_write_text(
|
|
142
|
+
path,
|
|
143
|
+
json.dumps(
|
|
144
|
+
{"schema": _VALIDITY_CACHE_SCHEMA, "records": records},
|
|
145
|
+
separators=(",", ":"),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
os.chmod(path, 0o600)
|
|
149
|
+
except OSError:
|
|
150
|
+
return
|
|
151
|
+
_persistent_validity_dirty.discard(claude_home)
|
|
22
152
|
|
|
23
153
|
|
|
24
154
|
def invalidate_session(path: Path) -> None:
|
|
@@ -27,6 +157,8 @@ def invalidate_session(path: Path) -> None:
|
|
|
27
157
|
if cached is None:
|
|
28
158
|
return
|
|
29
159
|
cached.pop(path, None)
|
|
160
|
+
_persistent_validity_exact_only.discard(path)
|
|
161
|
+
_mark_persistent_validity_dirty(path.parent)
|
|
30
162
|
if not cached:
|
|
31
163
|
_session_validity.pop(path.parent, None)
|
|
32
164
|
|
|
@@ -64,6 +196,7 @@ def list_projects(claude_home: Path) -> list[Project]:
|
|
|
64
196
|
session counts and recency track files created, changed, or removed below
|
|
65
197
|
the cached top-level directory.
|
|
66
198
|
"""
|
|
199
|
+
_load_persistent_validity(claude_home)
|
|
67
200
|
projects_dir = claude_home / "projects"
|
|
68
201
|
if not projects_dir.is_dir():
|
|
69
202
|
return []
|
|
@@ -79,6 +212,7 @@ def list_projects(claude_home: Path) -> list[Project]:
|
|
|
79
212
|
# without re-decoding every project name.
|
|
80
213
|
refreshed = _refresh_activity(cached[1])
|
|
81
214
|
_cache[claude_home] = (parent_mtime, refreshed)
|
|
215
|
+
_save_persistent_validity(claude_home)
|
|
82
216
|
return refreshed
|
|
83
217
|
|
|
84
218
|
# Full scan needed.
|
|
@@ -153,6 +287,7 @@ def list_projects(claude_home: Path) -> list[Project]:
|
|
|
153
287
|
|
|
154
288
|
results.sort(key=lambda p: p.last_activity_ts, reverse=True)
|
|
155
289
|
_cache[claude_home] = (parent_mtime, results)
|
|
290
|
+
_save_persistent_validity(claude_home)
|
|
156
291
|
return results
|
|
157
292
|
|
|
158
293
|
|
|
@@ -198,6 +333,7 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
|
|
|
198
333
|
if cached is not None and cached[0] == signature:
|
|
199
334
|
valid = cached[1]
|
|
200
335
|
elif (cached is not None and cached[1]
|
|
336
|
+
and path not in _persistent_validity_exact_only
|
|
201
337
|
and cached[0][0] == stat.st_ino
|
|
202
338
|
and stat.st_size > cached[0][2]):
|
|
203
339
|
# Claude session JSONLs are append-only. Once a file is valid,
|
|
@@ -208,9 +344,12 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
|
|
|
208
344
|
# explicitly via ``invalidate_session``).
|
|
209
345
|
valid = True
|
|
210
346
|
validity[path] = (signature, True)
|
|
347
|
+
_mark_persistent_validity_dirty(claude_dir)
|
|
211
348
|
else:
|
|
212
349
|
valid = _scan_session(temp_project, path) is not None
|
|
213
350
|
validity[path] = (signature, valid)
|
|
351
|
+
_persistent_validity_exact_only.discard(path)
|
|
352
|
+
_mark_persistent_validity_dirty(claude_dir)
|
|
214
353
|
if not valid:
|
|
215
354
|
continue
|
|
216
355
|
count += 1
|
|
@@ -219,6 +358,8 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
|
|
|
219
358
|
latest = m
|
|
220
359
|
for stale in set(validity) - current_paths:
|
|
221
360
|
del validity[stale]
|
|
361
|
+
_persistent_validity_exact_only.discard(stale)
|
|
362
|
+
_mark_persistent_validity_dirty(claude_dir)
|
|
222
363
|
if not validity:
|
|
223
364
|
_session_validity.pop(claude_dir, None)
|
|
224
365
|
return count, latest
|
|
@@ -14,8 +14,14 @@ from railmux import restart_state, tmux_ctl
|
|
|
14
14
|
from railmux.atomic_file import atomic_write_text
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
_VERSION =
|
|
17
|
+
_VERSION = 9
|
|
18
18
|
_KEYS = ("F8", "F9")
|
|
19
|
+
_STATUS_CLICK_KEYS = (
|
|
20
|
+
"MouseDown1Status",
|
|
21
|
+
"MouseDown1Control0",
|
|
22
|
+
"MouseDown1Control1",
|
|
23
|
+
"MouseDown1Control2",
|
|
24
|
+
)
|
|
19
25
|
_MAX_STATE_BYTES = 64 * 1024
|
|
20
26
|
|
|
21
27
|
|
|
@@ -114,12 +120,18 @@ class SharedTmuxBindingManager:
|
|
|
114
120
|
and all(value is None or isinstance(value, str)
|
|
115
121
|
for value in right_click_backup.values())
|
|
116
122
|
)
|
|
117
|
-
|
|
123
|
+
legacy_status_click_valid = (
|
|
118
124
|
isinstance(status_click_backup, dict)
|
|
119
125
|
and set(status_click_backup) == {"MouseDown1Status"}
|
|
120
126
|
and all(value is None or isinstance(value, str)
|
|
121
127
|
for value in status_click_backup.values())
|
|
122
128
|
)
|
|
129
|
+
status_click_valid = (
|
|
130
|
+
isinstance(status_click_backup, dict)
|
|
131
|
+
and set(status_click_backup) == set(_STATUS_CLICK_KEYS)
|
|
132
|
+
and all(value is None or isinstance(value, str)
|
|
133
|
+
for value in status_click_backup.values())
|
|
134
|
+
)
|
|
123
135
|
termux_tap_valid = (
|
|
124
136
|
isinstance(termux_tap_backup, dict)
|
|
125
137
|
and set(termux_tap_backup) == {"MouseDown1Pane"}
|
|
@@ -149,7 +161,13 @@ class SharedTmuxBindingManager:
|
|
|
149
161
|
or (not selection_hook_managed
|
|
150
162
|
and selection_hook_index is not None)))
|
|
151
163
|
or (raw["version"] >= 5
|
|
152
|
-
and (
|
|
164
|
+
and ((raw["version"] < 9
|
|
165
|
+
and not (
|
|
166
|
+
legacy_status_click_valid
|
|
167
|
+
or status_click_valid
|
|
168
|
+
))
|
|
169
|
+
or (raw["version"] >= 9
|
|
170
|
+
and not status_click_valid)
|
|
153
171
|
or not isinstance(status_click_managed, bool)))
|
|
154
172
|
or (raw["version"] >= 8
|
|
155
173
|
and (not termux_tap_valid
|
|
@@ -293,6 +311,29 @@ class SharedTmuxBindingManager:
|
|
|
293
311
|
or {"MouseDown1Pane": None}
|
|
294
312
|
)
|
|
295
313
|
upgraded = True
|
|
314
|
+
if state["version"] < 9:
|
|
315
|
+
if state["status_click_managed"]:
|
|
316
|
+
if set(state["status_click_backup"]) != set(
|
|
317
|
+
_STATUS_CLICK_KEYS
|
|
318
|
+
):
|
|
319
|
+
status_click_backup = (
|
|
320
|
+
tmux_ctl.upgrade_root_status_click_binding(
|
|
321
|
+
state["status_click_backup"],
|
|
322
|
+
state["token"],
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
if status_click_backup is None:
|
|
326
|
+
return False
|
|
327
|
+
state["status_click_backup"] = (
|
|
328
|
+
status_click_backup)
|
|
329
|
+
else:
|
|
330
|
+
state["status_click_backup"] = {
|
|
331
|
+
key: None for key in _STATUS_CLICK_KEYS
|
|
332
|
+
}
|
|
333
|
+
# tmux 3.7 routes control ranges through dedicated
|
|
334
|
+
# MouseDown1ControlN keys. Capture all originals before
|
|
335
|
+
# reinstalling the shared status-click lease.
|
|
336
|
+
upgraded = True
|
|
296
337
|
if upgraded:
|
|
297
338
|
state["version"] = _VERSION
|
|
298
339
|
state["phase"] = "installing"
|
|
@@ -341,17 +382,14 @@ class SharedTmuxBindingManager:
|
|
|
341
382
|
)
|
|
342
383
|
if state["status_click_managed"]:
|
|
343
384
|
current_status = (
|
|
344
|
-
tmux_ctl.read_root_status_click_binding()
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
and tmux_ctl.root_status_click_binding_is_original_or_owned(
|
|
350
|
-
current_status,
|
|
351
|
-
state["status_click_backup"].get(
|
|
352
|
-
"MouseDown1Status"),
|
|
385
|
+
tmux_ctl.read_root_status_click_binding())
|
|
386
|
+
safe = safe and all(
|
|
387
|
+
tmux_ctl.root_status_click_binding_is_original_or_owned(
|
|
388
|
+
current_status.get(key),
|
|
389
|
+
state["status_click_backup"].get(key),
|
|
353
390
|
token,
|
|
354
391
|
)
|
|
392
|
+
for key in _STATUS_CLICK_KEYS
|
|
355
393
|
)
|
|
356
394
|
if state["termux_tap_managed"]:
|
|
357
395
|
current_tap = (
|
|
@@ -543,7 +581,8 @@ class SharedTmuxBindingManager:
|
|
|
543
581
|
"selection_hook_index": selection_hook_index,
|
|
544
582
|
"status_click_managed": status_click_backup is not None,
|
|
545
583
|
"status_click_backup": (
|
|
546
|
-
status_click_backup
|
|
584
|
+
status_click_backup
|
|
585
|
+
or {key: None for key in _STATUS_CLICK_KEYS}),
|
|
547
586
|
"termux_tap_managed": termux_tap_backup is not None,
|
|
548
587
|
"termux_tap_backup": (
|
|
549
588
|
termux_tap_backup or {"MouseDown1Pane": None}),
|
|
@@ -1815,6 +1815,15 @@ _ROOT_TERMUX_TAP_MARKER = "railmux-termux-tap-v1"
|
|
|
1815
1815
|
STATUS_ACTION_MODE = "railmux-mode"
|
|
1816
1816
|
STATUS_ACTION_LAYOUT = "railmux-layout"
|
|
1817
1817
|
STATUS_ACTION_COPY = "railmux-copy"
|
|
1818
|
+
_ROOT_STATUS_ACTION_KEYS = {
|
|
1819
|
+
STATUS_ACTION_MODE: "MouseDown1Control0",
|
|
1820
|
+
STATUS_ACTION_LAYOUT: "MouseDown1Control1",
|
|
1821
|
+
STATUS_ACTION_COPY: "MouseDown1Control2",
|
|
1822
|
+
}
|
|
1823
|
+
_ROOT_STATUS_CLICK_KEYS = (
|
|
1824
|
+
_ROOT_STATUS_CLICK_KEY,
|
|
1825
|
+
*_ROOT_STATUS_ACTION_KEYS.values(),
|
|
1826
|
+
)
|
|
1818
1827
|
_STATUS_ACTIONS = frozenset({
|
|
1819
1828
|
STATUS_ACTION_MODE,
|
|
1820
1829
|
STATUS_ACTION_LAYOUT,
|
|
@@ -1897,6 +1906,14 @@ def status_action_range(action: str, content: str) -> str:
|
|
|
1897
1906
|
raise ValueError(f"invalid Railmux status action: {action!r}")
|
|
1898
1907
|
if not status_pane_ranges_supported():
|
|
1899
1908
|
return content
|
|
1909
|
+
# tmux 3.7 gives control ranges distinct root-table mouse keys. Using those
|
|
1910
|
+
# keys avoids routing fixed actions through the generic user-range
|
|
1911
|
+
# dispatcher and provides deterministic bindings on the 3.7b version
|
|
1912
|
+
# bundled by the managed Windows runtime.
|
|
1913
|
+
if tmux_version() >= (3, 7):
|
|
1914
|
+
key = _ROOT_STATUS_ACTION_KEYS[action]
|
|
1915
|
+
index = key.removeprefix("MouseDown1Control")
|
|
1916
|
+
return f"#[range=control|{index}]{content}#[norange]"
|
|
1900
1917
|
return f"#[range=user|{action}]{content}#[norange]"
|
|
1901
1918
|
|
|
1902
1919
|
|
|
@@ -2752,27 +2769,100 @@ def restore_root_termux_tap_binding(
|
|
|
2752
2769
|
|
|
2753
2770
|
|
|
2754
2771
|
def read_root_status_click_binding() -> RootStatusClickBindingBackup:
|
|
2755
|
-
"""Capture
|
|
2772
|
+
"""Capture every root binding used by status ranges and controls."""
|
|
2756
2773
|
return {
|
|
2757
|
-
|
|
2758
|
-
|
|
2774
|
+
key: _read_key_binding("root", key)
|
|
2775
|
+
for key in _ROOT_STATUS_CLICK_KEYS
|
|
2759
2776
|
}
|
|
2760
2777
|
|
|
2761
2778
|
|
|
2779
|
+
def _status_click_backup_replayable(
|
|
2780
|
+
backup: RootStatusClickBindingBackup,
|
|
2781
|
+
) -> bool:
|
|
2782
|
+
if set(backup) != set(_ROOT_STATUS_CLICK_KEYS):
|
|
2783
|
+
return False
|
|
2784
|
+
for binding in backup.values():
|
|
2785
|
+
if binding is None:
|
|
2786
|
+
continue
|
|
2787
|
+
try:
|
|
2788
|
+
_binding_command(binding)
|
|
2789
|
+
except ValueError:
|
|
2790
|
+
return False
|
|
2791
|
+
return True
|
|
2792
|
+
|
|
2793
|
+
|
|
2762
2794
|
def prepare_root_status_click_binding() -> RootStatusClickBindingBackup | None:
|
|
2763
2795
|
"""Return a replayable status-click binding, or decline management."""
|
|
2764
2796
|
if not status_pane_ranges_supported():
|
|
2765
2797
|
return None
|
|
2766
2798
|
backup = read_root_status_click_binding()
|
|
2767
|
-
|
|
2768
|
-
|
|
2799
|
+
if any(
|
|
2800
|
+
binding and _ROOT_STATUS_CLICK_MARKER in binding
|
|
2801
|
+
for binding in backup.values()
|
|
2802
|
+
):
|
|
2769
2803
|
return None
|
|
2770
|
-
if
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2804
|
+
return backup if _status_click_backup_replayable(backup) else None
|
|
2805
|
+
|
|
2806
|
+
|
|
2807
|
+
def upgrade_root_status_click_binding(
|
|
2808
|
+
previous: RootStatusClickBindingBackup,
|
|
2809
|
+
token: str,
|
|
2810
|
+
) -> RootStatusClickBindingBackup | None:
|
|
2811
|
+
"""Expand a pre-control-range lease without losing user bindings.
|
|
2812
|
+
|
|
2813
|
+
Versions 5-8 saved only MouseDown1Status. Before version 9 installs the
|
|
2814
|
+
tmux 3.7 control keys it captures their exact current commands, while also
|
|
2815
|
+
verifying that the old status wrapper is still either original or ours.
|
|
2816
|
+
"""
|
|
2817
|
+
if set(previous) != {_ROOT_STATUS_CLICK_KEY}:
|
|
2818
|
+
return None
|
|
2819
|
+
current = read_root_status_click_binding()
|
|
2820
|
+
if not root_status_click_binding_is_original_or_owned(
|
|
2821
|
+
current.get(_ROOT_STATUS_CLICK_KEY),
|
|
2822
|
+
previous.get(_ROOT_STATUS_CLICK_KEY),
|
|
2823
|
+
token,
|
|
2824
|
+
):
|
|
2825
|
+
return None
|
|
2826
|
+
upgraded = dict(current)
|
|
2827
|
+
upgraded[_ROOT_STATUS_CLICK_KEY] = previous[_ROOT_STATUS_CLICK_KEY]
|
|
2828
|
+
return upgraded if _status_click_backup_replayable(upgraded) else None
|
|
2829
|
+
|
|
2830
|
+
|
|
2831
|
+
def _status_click_fallback(binding: str | None) -> str:
|
|
2832
|
+
return _binding_command(binding) if binding is not None else 'run-shell "true"'
|
|
2833
|
+
|
|
2834
|
+
|
|
2835
|
+
def _set_root_status_control_forwarding(
|
|
2836
|
+
backup: RootStatusClickBindingBackup,
|
|
2837
|
+
token: str,
|
|
2838
|
+
) -> None:
|
|
2839
|
+
if tmux_version() < (3, 7):
|
|
2840
|
+
return
|
|
2841
|
+
marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
|
|
2842
|
+
action_keys = {
|
|
2843
|
+
STATUS_ACTION_MODE: "F5",
|
|
2844
|
+
STATUS_ACTION_LAYOUT: "F7",
|
|
2845
|
+
STATUS_ACTION_COPY: "F6",
|
|
2846
|
+
}
|
|
2847
|
+
condition = (
|
|
2848
|
+
"#{&&:"
|
|
2849
|
+
f"#{{!=:#{{{RAILMUX_CONTROLLER_OPTION}}},}},"
|
|
2850
|
+
f"#{{==:{marker},{marker}}}}}"
|
|
2851
|
+
)
|
|
2852
|
+
for action, key in _ROOT_STATUS_ACTION_KEYS.items():
|
|
2853
|
+
dispatch = (
|
|
2854
|
+
f"run-shell \"{_tmux_shell_executable()} send-keys -t "
|
|
2855
|
+
f"'#{{{RAILMUX_CONTROLLER_OPTION}}}' {action_keys[action]}\""
|
|
2856
|
+
)
|
|
2857
|
+
subprocess.check_call(
|
|
2858
|
+
[
|
|
2859
|
+
"tmux", "bind-key", "-T", "root", key,
|
|
2860
|
+
"if-shell", "-F", condition, dispatch,
|
|
2861
|
+
_status_click_fallback(backup.get(key)),
|
|
2862
|
+
],
|
|
2863
|
+
stdout=subprocess.DEVNULL,
|
|
2864
|
+
stderr=subprocess.DEVNULL,
|
|
2865
|
+
)
|
|
2776
2866
|
|
|
2777
2867
|
|
|
2778
2868
|
def set_root_status_click_forwarding(
|
|
@@ -2781,6 +2871,8 @@ def set_root_status_click_forwarding(
|
|
|
2781
2871
|
"""Dispatch Railmux user ranges and replay all other status clicks."""
|
|
2782
2872
|
if not status_pane_ranges_supported():
|
|
2783
2873
|
return False
|
|
2874
|
+
if not _status_click_backup_replayable(backup):
|
|
2875
|
+
return False
|
|
2784
2876
|
marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
|
|
2785
2877
|
condition = (
|
|
2786
2878
|
"#{&&:"
|
|
@@ -2791,10 +2883,7 @@ def set_root_status_click_forwarding(
|
|
|
2791
2883
|
)
|
|
2792
2884
|
original = backup.get(_ROOT_STATUS_CLICK_KEY)
|
|
2793
2885
|
try:
|
|
2794
|
-
fallback = (
|
|
2795
|
-
_binding_command(original)
|
|
2796
|
-
if original is not None else 'run-shell "true"'
|
|
2797
|
-
)
|
|
2886
|
+
fallback = _status_click_fallback(original)
|
|
2798
2887
|
dispatch = (
|
|
2799
2888
|
'run-shell "case \'#{mouse_status_range}\' in '
|
|
2800
2889
|
f'%*) {_tmux_shell_executable()} select-pane -Z -t '
|
|
@@ -2820,21 +2909,33 @@ def set_root_status_click_forwarding(
|
|
|
2820
2909
|
stdout=subprocess.DEVNULL,
|
|
2821
2910
|
stderr=subprocess.DEVNULL,
|
|
2822
2911
|
)
|
|
2912
|
+
_set_root_status_control_forwarding(backup, token)
|
|
2823
2913
|
return True
|
|
2824
2914
|
except (ValueError, subprocess.CalledProcessError, FileNotFoundError):
|
|
2915
|
+
# A later control-key write can fail after MouseDown1Status was
|
|
2916
|
+
# installed. Restore only bindings bearing this lease's marker.
|
|
2917
|
+
restore_root_status_click_binding(backup, token=token)
|
|
2825
2918
|
return False
|
|
2826
2919
|
|
|
2827
2920
|
|
|
2828
2921
|
def root_status_click_binding_owned_by(token: str) -> bool:
|
|
2829
2922
|
marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
and marker in binding
|
|
2923
|
+
current = read_root_status_click_binding()
|
|
2924
|
+
binding = current.get(_ROOT_STATUS_CLICK_KEY)
|
|
2925
|
+
base_owned = bool(
|
|
2926
|
+
binding and marker in binding
|
|
2834
2927
|
and RAILMUX_CONTROLLER_OPTION in binding
|
|
2835
2928
|
and "mouse_status_range" in binding
|
|
2836
2929
|
and "select-pane -Z -t" in binding
|
|
2837
2930
|
)
|
|
2931
|
+
if not base_owned or tmux_version() < (3, 7):
|
|
2932
|
+
return base_owned
|
|
2933
|
+
return all(
|
|
2934
|
+
(binding := current.get(key)) is not None
|
|
2935
|
+
and marker in binding
|
|
2936
|
+
and RAILMUX_CONTROLLER_OPTION in binding
|
|
2937
|
+
for key in _ROOT_STATUS_ACTION_KEYS.values()
|
|
2938
|
+
)
|
|
2838
2939
|
|
|
2839
2940
|
|
|
2840
2941
|
def root_status_click_binding_is_original_or_owned(
|
|
@@ -2851,35 +2952,36 @@ def root_status_click_binding_is_original_or_owned(
|
|
|
2851
2952
|
def restore_root_status_click_binding(
|
|
2852
2953
|
backup: RootStatusClickBindingBackup, *, token: str,
|
|
2853
2954
|
) -> None:
|
|
2854
|
-
"""Restore
|
|
2855
|
-
live = read_root_status_click_binding()
|
|
2955
|
+
"""Restore status bindings only while each live wrapper is still ours."""
|
|
2956
|
+
live = read_root_status_click_binding()
|
|
2856
2957
|
marker = f"{_ROOT_STATUS_CLICK_MARKER}-{token}"
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
_ROOT_STATUS_CLICK_KEY],
|
|
2865
|
-
stdout=subprocess.DEVNULL,
|
|
2866
|
-
stderr=subprocess.DEVNULL,
|
|
2867
|
-
)
|
|
2868
|
-
else:
|
|
2869
|
-
fd, path = tempfile.mkstemp(
|
|
2870
|
-
prefix="railmux-root-status-click-", suffix=".conf")
|
|
2871
|
-
try:
|
|
2872
|
-
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
2873
|
-
fh.write(original + "\n")
|
|
2958
|
+
for key in _ROOT_STATUS_CLICK_KEYS:
|
|
2959
|
+
current = live.get(key)
|
|
2960
|
+
if current is None or marker not in current:
|
|
2961
|
+
continue
|
|
2962
|
+
original = backup.get(key)
|
|
2963
|
+
try:
|
|
2964
|
+
if original is None:
|
|
2874
2965
|
subprocess.check_call(
|
|
2875
|
-
["tmux", "
|
|
2966
|
+
["tmux", "unbind-key", "-T", "root", key],
|
|
2876
2967
|
stdout=subprocess.DEVNULL,
|
|
2877
2968
|
stderr=subprocess.DEVNULL,
|
|
2878
2969
|
)
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2970
|
+
else:
|
|
2971
|
+
fd, path = tempfile.mkstemp(
|
|
2972
|
+
prefix="railmux-root-status-click-", suffix=".conf")
|
|
2973
|
+
try:
|
|
2974
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
2975
|
+
fh.write(original + "\n")
|
|
2976
|
+
subprocess.check_call(
|
|
2977
|
+
["tmux", "source-file", path],
|
|
2978
|
+
stdout=subprocess.DEVNULL,
|
|
2979
|
+
stderr=subprocess.DEVNULL,
|
|
2980
|
+
)
|
|
2981
|
+
finally:
|
|
2982
|
+
os.unlink(path)
|
|
2983
|
+
except (OSError, subprocess.CalledProcessError, FileNotFoundError):
|
|
2984
|
+
pass
|
|
2883
2985
|
|
|
2884
2986
|
|
|
2885
2987
|
def read_prefix_target_binding() -> PrefixTargetBindingBackup:
|