railmux 0.4.0.dev34__tar.gz → 0.4.0.dev36__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.dev34/src/railmux.egg-info → railmux-0.4.0.dev36}/PKG-INFO +1 -1
- railmux-0.4.0.dev36/src/railmux/__init__.py +1 -0
- railmux-0.4.0.dev36/src/railmux/project_favorites.py +79 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/provider_paths.py +3 -1
- railmux-0.4.0.dev36/src/railmux/release_version.py +111 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/app.py +179 -13
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/projects_pane.py +44 -9
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_msys2.py +32 -17
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_ui_transition.py +4 -3
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36/src/railmux.egg-info}/PKG-INFO +1 -1
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/SOURCES.txt +4 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_focus_highlight.py +183 -2
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_fullscreen_binding.py +19 -0
- railmux-0.4.0.dev36/tests/test_project_favorites.py +52 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_projects_pane.py +65 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_release_notes.py +26 -1
- railmux-0.4.0.dev36/tests/test_release_version.py +128 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_sidebar.py +17 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_msys2.py +83 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_ui_transition.py +1 -1
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tools/release_notes.py +5 -2
- railmux-0.4.0.dev34/src/railmux/__init__.py +0 -1
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/LICENSE +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/MANIFEST.in +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/README.md +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/pyproject.toml +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/setup.cfg +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/__main__.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/atomic_file.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/background_index.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/cli.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/codex_index.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/config.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/config_cli.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/diagnostics.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/discovery.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/display_transport.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/entrypoint.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_history.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_server.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/favorites.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fuzzy.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/help_workspace.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/launcher.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/local_open.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/models.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/modes.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/pane_surface.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/path_codec.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/preview_pager.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/remote_config.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/renames.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/restart_state.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/runtime_config.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/self_update.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/session_cache.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/session_index.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/session_lease.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/settings.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ssh_args.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/system_deps.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/terminal_status.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tmux_ctl.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tmux_health.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tmux_server.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tool_panes.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/transcript.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/modals.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/running_pane.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/sessions_pane.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_attach_relay.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_install_log.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_pacman.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_paths.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_agent_workspace.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_atomic_file.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_background_index.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_cli.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_codex_delete.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_codex_index.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_codex_lineage.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_codex_transcript.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_config.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_config_cli.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_diagnostics.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_discovery.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_display_transport.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_fast_full_window.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_fuzzy.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_help_agent.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_help_workspace.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_history_cleanup.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_keymap.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_launcher.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_layout_profile.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_local_clipboard.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_local_open.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_modals.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_mode_selection.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_modes.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_mouse_manager.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_new_project.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_options.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_orphan_recovery.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_pane_surface.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_path_codec.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_preview_pager.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_provider_paths.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_release_workflow.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_remote_config.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_renames.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_restart_state.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_running_pane.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_running_sort.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_runtime_config.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_scroll_agent.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_scroll_manager.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_selection_isolation.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_self_update.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_session_cache.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_session_index.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_session_lease.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_sessions_pane.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_setting_contracts.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_settings.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_soft_quit.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_args.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_compat.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_status.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_statusbar.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_system_deps.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_terminal_size.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_terminal_status.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_binding_manager.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_ctl.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_health.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_integration.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_server.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_status.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tool_panes.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_transcript.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_utf8_state.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_widgets.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_attach_relay.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_bootstrap.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_install_log.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_pacman.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_paths.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_tmux_lifecycle.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_wrapper_contract.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tools/wheel_smoke.py +0 -0
- {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tools/windows_archive_smoke.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0.dev36"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Persistent favorite project tracking.
|
|
2
|
+
|
|
3
|
+
Project favorites are deliberately separate from session favorites. A
|
|
4
|
+
project is identified by the absolute path Railmux already discovered, while
|
|
5
|
+
session favorites are provider UUIDs and retain their released JSON schema.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from railmux.atomic_file import atomic_write_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_MAX_FAVORITES = 10_000
|
|
16
|
+
_MAX_PATH_LENGTH = 4096
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _project_favorites_path() -> Path:
|
|
20
|
+
return Path.home() / ".config" / "railmux" / "project-favorites.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _path_key(path: Path) -> str:
|
|
24
|
+
"""Return the stable absolute spelling used by project discovery."""
|
|
25
|
+
return str(path if path.is_absolute() else path.absolute())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProjectFavorites:
|
|
29
|
+
"""In-memory set of favorite absolute project paths, backed by JSON."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._paths: set[str] = set()
|
|
33
|
+
self._path = _project_favorites_path()
|
|
34
|
+
self._load()
|
|
35
|
+
|
|
36
|
+
def _load(self) -> None:
|
|
37
|
+
if not self._path.is_file():
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
41
|
+
if (
|
|
42
|
+
not isinstance(data, list)
|
|
43
|
+
or len(data) > _MAX_FAVORITES
|
|
44
|
+
or any(
|
|
45
|
+
not isinstance(value, str)
|
|
46
|
+
or not value
|
|
47
|
+
or "\x00" in value
|
|
48
|
+
or len(value) > _MAX_PATH_LENGTH
|
|
49
|
+
or not Path(value).is_absolute()
|
|
50
|
+
for value in data
|
|
51
|
+
)
|
|
52
|
+
):
|
|
53
|
+
return
|
|
54
|
+
self._paths = set(data)
|
|
55
|
+
except (json.JSONDecodeError, OSError, UnicodeError):
|
|
56
|
+
self._paths = set()
|
|
57
|
+
|
|
58
|
+
def _save(self) -> None:
|
|
59
|
+
try:
|
|
60
|
+
atomic_write_text(
|
|
61
|
+
self._path,
|
|
62
|
+
json.dumps(sorted(self._paths), ensure_ascii=False, indent=2),
|
|
63
|
+
)
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def is_favorite(self, path: Path) -> bool:
|
|
68
|
+
return _path_key(path) in self._paths
|
|
69
|
+
|
|
70
|
+
def set(self, path: Path, favorite: bool) -> None:
|
|
71
|
+
key = _path_key(path)
|
|
72
|
+
updated = self._paths | {key} if favorite else self._paths - {key}
|
|
73
|
+
if updated == self._paths:
|
|
74
|
+
return
|
|
75
|
+
self._paths = updated
|
|
76
|
+
self._save()
|
|
77
|
+
|
|
78
|
+
def get_paths(self) -> set[Path]:
|
|
79
|
+
return {Path(value) for value in self._paths}
|
|
@@ -9,13 +9,15 @@ import sys
|
|
|
9
9
|
from collections.abc import Mapping
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from railmux.release_version import PROJECT_VERSION_PATTERN
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
_DRIVE_PATH = re.compile(r"^(?:\\\\\?\\)?([A-Za-z]):[\\/](.*)\Z")
|
|
14
16
|
_MANAGED_BASE_MARKER = Path("/railmux-base.json")
|
|
15
17
|
_MANAGED_BASE_CONTENT_MARKER = Path("/railmux-base-content-v1.json")
|
|
16
18
|
_MANAGED_APP_ROOT = Path("/opt/railmux/apps")
|
|
17
19
|
_MANAGED_APP_ID = re.compile(
|
|
18
|
-
|
|
20
|
+
rf"railmux-{PROJECT_VERSION_PATTERN}\Z"
|
|
19
21
|
)
|
|
20
22
|
|
|
21
23
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Narrow version grammar shared by managed Windows app-layer identities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from functools import total_ordering
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidVersion(ValueError):
|
|
10
|
+
"""A version is outside Railmux's canonical persisted grammar."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@total_ordering
|
|
14
|
+
class ProjectVersion:
|
|
15
|
+
"""Small standard-library ordering type for native bootstrap identities."""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("_key", "_value")
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
value: str,
|
|
22
|
+
release: tuple[int, ...],
|
|
23
|
+
stage: int,
|
|
24
|
+
stage_number: int,
|
|
25
|
+
) -> None:
|
|
26
|
+
normalized = release
|
|
27
|
+
while len(normalized) > 1 and normalized[-1] == 0:
|
|
28
|
+
normalized = normalized[:-1]
|
|
29
|
+
self._value = value
|
|
30
|
+
self._key = (normalized, stage, stage_number)
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
return self._value
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return f"ProjectVersion({self._value!r})"
|
|
37
|
+
|
|
38
|
+
def __hash__(self) -> int:
|
|
39
|
+
return hash(self._key)
|
|
40
|
+
|
|
41
|
+
def __eq__(self, other: Any) -> bool:
|
|
42
|
+
if not isinstance(other, ProjectVersion):
|
|
43
|
+
return NotImplemented
|
|
44
|
+
return self._key == other._key
|
|
45
|
+
|
|
46
|
+
def __lt__(self, other: Any) -> bool:
|
|
47
|
+
if not isinstance(other, ProjectVersion):
|
|
48
|
+
return NotImplemented
|
|
49
|
+
return self._key < other._key
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Railmux app directories are security-sensitive persisted identities. Accept
|
|
53
|
+
# only the spellings that this project publishes: a final, an RC, or a
|
|
54
|
+
# development release. PEP 440 aliases, epochs, post releases, and local
|
|
55
|
+
# versions are deliberately excluded so equal versions have one path spelling.
|
|
56
|
+
_CANONICAL_INTEGER_PATTERN = r"(?:0|[1-9][0-9]*)"
|
|
57
|
+
PROJECT_VERSION_PATTERN = (
|
|
58
|
+
rf"{_CANONICAL_INTEGER_PATTERN}"
|
|
59
|
+
rf"(?:\.{_CANONICAL_INTEGER_PATTERN})*"
|
|
60
|
+
rf"(?:rc{_CANONICAL_INTEGER_PATTERN}|\.dev{_CANONICAL_INTEGER_PATTERN})?"
|
|
61
|
+
)
|
|
62
|
+
PROJECT_VERSION_RE = re.compile(PROJECT_VERSION_PATTERN + r"\Z")
|
|
63
|
+
_PROJECT_VERSION_PARTS_RE = re.compile(
|
|
64
|
+
rf"(?P<release>{_CANONICAL_INTEGER_PATTERN}"
|
|
65
|
+
rf"(?:\.{_CANONICAL_INTEGER_PATTERN})*)"
|
|
66
|
+
rf"(?:(?:rc(?P<rc>{_CANONICAL_INTEGER_PATTERN}))|"
|
|
67
|
+
rf"(?:\.dev(?P<dev>{_CANONICAL_INTEGER_PATTERN})))?\Z"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_project_version(value: str) -> ProjectVersion:
|
|
72
|
+
"""Return the canonical supported Railmux version or raise InvalidVersion."""
|
|
73
|
+
match = _PROJECT_VERSION_PARTS_RE.fullmatch(value)
|
|
74
|
+
if match is None:
|
|
75
|
+
raise InvalidVersion(value)
|
|
76
|
+
release_parts = match.group("release").split(".")
|
|
77
|
+
stage_text = match.group("rc") or match.group("dev")
|
|
78
|
+
numeric_parts = release_parts + ([stage_text] if stage_text else [])
|
|
79
|
+
if any(
|
|
80
|
+
len(part) > 1 and part.startswith("0")
|
|
81
|
+
for part in numeric_parts
|
|
82
|
+
):
|
|
83
|
+
raise InvalidVersion(
|
|
84
|
+
f"non-canonical Railmux version spelling: {value!r}"
|
|
85
|
+
)
|
|
86
|
+
if match.group("dev") is not None:
|
|
87
|
+
stage = 0
|
|
88
|
+
stage_number = int(match.group("dev"))
|
|
89
|
+
elif match.group("rc") is not None:
|
|
90
|
+
stage = 1
|
|
91
|
+
stage_number = int(match.group("rc"))
|
|
92
|
+
else:
|
|
93
|
+
stage = 2
|
|
94
|
+
stage_number = 0
|
|
95
|
+
return ProjectVersion(
|
|
96
|
+
value,
|
|
97
|
+
tuple(int(part) for part in release_parts),
|
|
98
|
+
stage,
|
|
99
|
+
stage_number,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_project_version(value: object) -> bool:
|
|
104
|
+
"""Return whether *value* is one canonical published-version spelling."""
|
|
105
|
+
if not isinstance(value, str):
|
|
106
|
+
return False
|
|
107
|
+
try:
|
|
108
|
+
parse_project_version(value)
|
|
109
|
+
except InvalidVersion:
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
@@ -37,6 +37,7 @@ from railmux.display_transport import (
|
|
|
37
37
|
)
|
|
38
38
|
from railmux.discovery import invalidate_session, list_projects
|
|
39
39
|
from railmux.favorites import Favorites
|
|
40
|
+
from railmux.project_favorites import ProjectFavorites
|
|
40
41
|
from railmux.fast_display_input import is_termux_environment
|
|
41
42
|
from railmux.help_workspace import (
|
|
42
43
|
is_help_workspace,
|
|
@@ -813,6 +814,7 @@ class App:
|
|
|
813
814
|
self._renames = Renames()
|
|
814
815
|
self._session_cache = SessionCache(self._renames)
|
|
815
816
|
self._favorites = Favorites()
|
|
817
|
+
self._project_favorites = ProjectFavorites()
|
|
816
818
|
self._settings = Settings()
|
|
817
819
|
self._layout_profile = self._settings.layout_profile
|
|
818
820
|
self._active_sidebar_permille = (
|
|
@@ -1002,7 +1004,9 @@ class App:
|
|
|
1002
1004
|
projects,
|
|
1003
1005
|
on_select=self._on_project_select,
|
|
1004
1006
|
on_double_click=self._on_project_double_click,
|
|
1007
|
+
on_context=self._open_project_context_menu,
|
|
1005
1008
|
provider_label=initial_mode.label,
|
|
1009
|
+
favorite_paths=self._project_favorites.get_paths(),
|
|
1006
1010
|
boxed=False,
|
|
1007
1011
|
)
|
|
1008
1012
|
self._sessions_pane = SessionsPane(
|
|
@@ -1218,6 +1222,19 @@ class App:
|
|
|
1218
1222
|
pane_id = slot.pane_id
|
|
1219
1223
|
if pane_id is None or not pane_id.startswith("%"):
|
|
1220
1224
|
return
|
|
1225
|
+
marker = self._transcript_source_marker(session_id, tmux_name)
|
|
1226
|
+
tmux_ctl.set_pane_user_option(
|
|
1227
|
+
pane_id,
|
|
1228
|
+
tmux_server.TRANSCRIPT_SOURCE_OPTION,
|
|
1229
|
+
marker,
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
def _transcript_source_marker(
|
|
1233
|
+
self,
|
|
1234
|
+
session_id: str | None,
|
|
1235
|
+
tmux_name: str | None,
|
|
1236
|
+
) -> str | None:
|
|
1237
|
+
"""Build the bounded read-only Preview locator for one live target."""
|
|
1221
1238
|
marker = None
|
|
1222
1239
|
running = self._by_tmux(tmux_name) if tmux_name else None
|
|
1223
1240
|
logical_id = (
|
|
@@ -1248,11 +1265,78 @@ class App:
|
|
|
1248
1265
|
representative.session_id,
|
|
1249
1266
|
representative.jsonl_path,
|
|
1250
1267
|
)
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1268
|
+
return marker
|
|
1269
|
+
|
|
1270
|
+
def _sync_attached_slot_projection(
|
|
1271
|
+
self,
|
|
1272
|
+
slot: AgentSlot,
|
|
1273
|
+
tmux_name: str,
|
|
1274
|
+
) -> None:
|
|
1275
|
+
"""Publish non-authoritative pane projections with one tmux client.
|
|
1276
|
+
|
|
1277
|
+
The display transport has already verified and committed the pane
|
|
1278
|
+
ownership transaction. These options advertise the explicit Preview
|
|
1279
|
+
source and the prefix-Tab Target; neither grants authority to move,
|
|
1280
|
+
resume, or kill a provider. Batching them removes one deterministic
|
|
1281
|
+
MSYS2 process startup while retaining the established helpers as a
|
|
1282
|
+
fail-safe fallback.
|
|
1283
|
+
"""
|
|
1284
|
+
pane_id = slot.pane_id
|
|
1285
|
+
if (
|
|
1286
|
+
pane_id is None
|
|
1287
|
+
or not pane_id.startswith("%")
|
|
1288
|
+
or tmux_ctl.tmux_version() < (3, 0)
|
|
1289
|
+
):
|
|
1290
|
+
self._sync_slot_transcript_source(
|
|
1291
|
+
slot,
|
|
1292
|
+
self._session_id_for_tmux_target(tmux_name),
|
|
1293
|
+
tmux_name,
|
|
1294
|
+
)
|
|
1295
|
+
return
|
|
1296
|
+
|
|
1297
|
+
session_id = self._session_id_for_tmux_target(tmux_name)
|
|
1298
|
+
marker = self._transcript_source_marker(session_id, tmux_name)
|
|
1299
|
+
if marker is None:
|
|
1300
|
+
transcript_command = (
|
|
1301
|
+
"set-option", "-p", "-t", pane_id,
|
|
1302
|
+
"-u", tmux_server.TRANSCRIPT_SOURCE_OPTION,
|
|
1303
|
+
)
|
|
1304
|
+
else:
|
|
1305
|
+
transcript_command = (
|
|
1306
|
+
"set-option", "-p", "-t", pane_id,
|
|
1307
|
+
tmux_server.TRANSCRIPT_SOURCE_OPTION, marker,
|
|
1308
|
+
)
|
|
1309
|
+
commands: list[tuple[str, ...]] = [transcript_command]
|
|
1310
|
+
|
|
1311
|
+
manager = getattr(self, "_tmux_binding_manager", None)
|
|
1312
|
+
owner = getattr(self, "_railmux_pane_id", None)
|
|
1313
|
+
desired_target: str | None = None
|
|
1314
|
+
if (
|
|
1315
|
+
slot.key == self._agent_workspace().target_slot_key
|
|
1316
|
+
and owner is not None
|
|
1317
|
+
and getattr(manager, "target_toggle_available", False)
|
|
1318
|
+
):
|
|
1319
|
+
desired_target = pane_id
|
|
1320
|
+
commands.append((
|
|
1321
|
+
"set-window-option", "-t", owner,
|
|
1322
|
+
tmux_ctl.RAILMUX_TARGET_OPTION, desired_target,
|
|
1323
|
+
))
|
|
1324
|
+
|
|
1325
|
+
if tmux_ctl.run_command_queue(tuple(commands)):
|
|
1326
|
+
if desired_target is not None:
|
|
1327
|
+
self._projected_target_pane_id = desired_target
|
|
1328
|
+
return
|
|
1329
|
+
|
|
1330
|
+
# A presentation-only batch failure must not turn a verified attach
|
|
1331
|
+
# into a provider/session failure. Retry each released helper so one
|
|
1332
|
+
# unsupported option cannot suppress the other projection.
|
|
1333
|
+
self._sync_slot_transcript_source(
|
|
1334
|
+
slot,
|
|
1335
|
+
session_id,
|
|
1336
|
+
tmux_name,
|
|
1255
1337
|
)
|
|
1338
|
+
if desired_target is not None:
|
|
1339
|
+
self._sync_target_pane_option()
|
|
1256
1340
|
|
|
1257
1341
|
def _paint_slot_active_target(
|
|
1258
1342
|
self,
|
|
@@ -1312,13 +1396,18 @@ class App:
|
|
|
1312
1396
|
)
|
|
1313
1397
|
|
|
1314
1398
|
def _set_active_tmux_target(
|
|
1315
|
-
self,
|
|
1399
|
+
self,
|
|
1400
|
+
tmux_name: str,
|
|
1401
|
+
slot: AgentSlot | None = None,
|
|
1402
|
+
*,
|
|
1403
|
+
sync_transcript_source: bool = True,
|
|
1316
1404
|
) -> None:
|
|
1317
1405
|
slot = slot or self._primary_slot
|
|
1318
1406
|
self._set_slot_active_target(
|
|
1319
1407
|
slot,
|
|
1320
1408
|
self._session_id_for_tmux_target(tmux_name),
|
|
1321
1409
|
tmux_name,
|
|
1410
|
+
sync_transcript_source=sync_transcript_source,
|
|
1322
1411
|
)
|
|
1323
1412
|
|
|
1324
1413
|
def _sync_border_indicators(self, arrows: bool) -> bool:
|
|
@@ -1381,8 +1470,13 @@ class App:
|
|
|
1381
1470
|
"""
|
|
1382
1471
|
workspace = self._agent_workspace()
|
|
1383
1472
|
layout = workspace.layout
|
|
1384
|
-
|
|
1385
|
-
|
|
1473
|
+
# Border formats depend on focus, layout, and the logical Target slot,
|
|
1474
|
+
# not on the physical pane ID currently swapped into that slot. Using
|
|
1475
|
+
# the pane ID here rewrote identical gray borders after every
|
|
1476
|
+
# live-session switch, which is especially visible on MSYS2 where each
|
|
1477
|
+
# tmux client startup is comparatively expensive.
|
|
1478
|
+
target_slot = None if active else workspace.target.key
|
|
1479
|
+
state = (active, layout, target_slot)
|
|
1386
1480
|
if not force and self._divider_active == state:
|
|
1387
1481
|
return
|
|
1388
1482
|
gray = "fg=colour240"
|
|
@@ -1416,7 +1510,7 @@ class App:
|
|
|
1416
1510
|
desired_state = (
|
|
1417
1511
|
active,
|
|
1418
1512
|
workspace.layout,
|
|
1419
|
-
None if active else workspace.target.
|
|
1513
|
+
None if active else workspace.target.key,
|
|
1420
1514
|
)
|
|
1421
1515
|
state = getattr(self, "_divider_active", None)
|
|
1422
1516
|
if state != desired_state:
|
|
@@ -1437,7 +1531,7 @@ class App:
|
|
|
1437
1531
|
if now - getattr(self, "_last_border_verify_at", 0.0) < 2.0:
|
|
1438
1532
|
return
|
|
1439
1533
|
self._last_border_verify_at = now
|
|
1440
|
-
active, layout,
|
|
1534
|
+
active, layout, _target_slot = state
|
|
1441
1535
|
gray = "fg=colour240"
|
|
1442
1536
|
green = f"fg={_GRASS_GREEN}"
|
|
1443
1537
|
if not active:
|
|
@@ -2477,7 +2571,13 @@ class App:
|
|
|
2477
2571
|
slot.agent_tmux_name = agent_tmux_name
|
|
2478
2572
|
mode = self._modes().for_tmux_name(agent_tmux_name)
|
|
2479
2573
|
slot.mode_key = mode.key if mode is not None else None
|
|
2480
|
-
self._set_active_tmux_target(
|
|
2574
|
+
self._set_active_tmux_target(
|
|
2575
|
+
agent_tmux_name,
|
|
2576
|
+
slot,
|
|
2577
|
+
sync_transcript_source=not outcome.display_stable,
|
|
2578
|
+
)
|
|
2579
|
+
if outcome.display_stable and not outcome.target_unchanged:
|
|
2580
|
+
self._sync_attached_slot_projection(slot, agent_tmux_name)
|
|
2481
2581
|
self._set_railmux_focus(
|
|
2482
2582
|
not steal_focus and not self._double_focus_visual_pending,
|
|
2483
2583
|
force_border=not outcome.display_stable,
|
|
@@ -2556,10 +2656,20 @@ class App:
|
|
|
2556
2656
|
if worker is not None and worker.is_alive():
|
|
2557
2657
|
return
|
|
2558
2658
|
manager = getattr(self, "_tmux_binding_manager", None)
|
|
2659
|
+
target_was_available = bool(
|
|
2660
|
+
manager is not None and manager.target_toggle_available)
|
|
2559
2661
|
opened = manager is not None and manager.open()
|
|
2560
|
-
self._finish_tmux_bindings(
|
|
2662
|
+
self._finish_tmux_bindings(
|
|
2663
|
+
opened,
|
|
2664
|
+
force_target=not target_was_available,
|
|
2665
|
+
)
|
|
2561
2666
|
|
|
2562
|
-
def _finish_tmux_bindings(
|
|
2667
|
+
def _finish_tmux_bindings(
|
|
2668
|
+
self,
|
|
2669
|
+
opened: bool,
|
|
2670
|
+
*,
|
|
2671
|
+
force_target: bool = True,
|
|
2672
|
+
) -> None:
|
|
2563
2673
|
"""Project pane-local state after the shared lease is prepared."""
|
|
2564
2674
|
manager = getattr(self, "_tmux_binding_manager", None)
|
|
2565
2675
|
selection = getattr(self, "_selection_isolation_manager", None)
|
|
@@ -2571,7 +2681,7 @@ class App:
|
|
|
2571
2681
|
)
|
|
2572
2682
|
if opened:
|
|
2573
2683
|
if manager.target_toggle_available:
|
|
2574
|
-
self._sync_target_pane_option(force=
|
|
2684
|
+
self._sync_target_pane_option(force=force_target)
|
|
2575
2685
|
elif not getattr(self, "_target_toggle_warning_shown", False):
|
|
2576
2686
|
self._target_toggle_warning_shown = True
|
|
2577
2687
|
self._set_status(
|
|
@@ -7887,6 +7997,7 @@ class App:
|
|
|
7887
7997
|
def _close_modal(self) -> None:
|
|
7888
7998
|
if self._loop is not None:
|
|
7889
7999
|
self._loop.widget = self._frame
|
|
8000
|
+
self._projects_pane.set_context_selected(None)
|
|
7890
8001
|
self._sessions_pane.set_selected_session(None)
|
|
7891
8002
|
self._running_pane.set_selected(None)
|
|
7892
8003
|
|
|
@@ -10458,6 +10569,61 @@ class App:
|
|
|
10458
10569
|
|
|
10459
10570
|
# --- context menu (right-click) ---
|
|
10460
10571
|
|
|
10572
|
+
def _open_project_context_menu(self, project: Project) -> None:
|
|
10573
|
+
"""Open actions for the exact Projects row that was right-clicked."""
|
|
10574
|
+
if self._railmux_pane_id:
|
|
10575
|
+
tmux_ctl.select_pane(self._railmux_pane_id)
|
|
10576
|
+
self._projects_pane.set_context_selected(project.encoded_name)
|
|
10577
|
+
is_starred = self._project_favorites.is_favorite(project.real_path)
|
|
10578
|
+
items: list[tuple[str, Callable[[], None]]] = [
|
|
10579
|
+
(_context_menu_label("Copy path", "c"),
|
|
10580
|
+
lambda p=project: self._copy_project_path(p)),
|
|
10581
|
+
(_context_menu_label("Unstar" if is_starred else "Star", "s"),
|
|
10582
|
+
lambda p=project: self._do_context_project_star(p)),
|
|
10583
|
+
(_context_menu_label("Info", "i"),
|
|
10584
|
+
lambda p=project: self._do_context_project_info(p)),
|
|
10585
|
+
(_context_menu_label("Term", "t"),
|
|
10586
|
+
lambda p=project: self._open_terminal_for_project(p)),
|
|
10587
|
+
]
|
|
10588
|
+
menu = ContextMenu(items, on_close=self._close_modal)
|
|
10589
|
+
self._show_overlay(
|
|
10590
|
+
menu,
|
|
10591
|
+
width=36,
|
|
10592
|
+
height=10,
|
|
10593
|
+
click_outside_to_close=True,
|
|
10594
|
+
fixed_width=True,
|
|
10595
|
+
fixed_height=True,
|
|
10596
|
+
)
|
|
10597
|
+
|
|
10598
|
+
def _copy_project_path(self, project: Project) -> None:
|
|
10599
|
+
path = project.real_path
|
|
10600
|
+
absolute = path if path.is_absolute() else path.absolute()
|
|
10601
|
+
text = str(absolute)
|
|
10602
|
+
if tmux_ctl.copy_to_clipboard(text):
|
|
10603
|
+
self._set_status(f"Copied path: {text}", "success")
|
|
10604
|
+
else:
|
|
10605
|
+
self._set_status(
|
|
10606
|
+
"Could not copy path; terminal clipboard is unavailable",
|
|
10607
|
+
"warn",
|
|
10608
|
+
)
|
|
10609
|
+
|
|
10610
|
+
def _do_context_project_star(self, project: Project) -> None:
|
|
10611
|
+
now_star = not self._project_favorites.is_favorite(project.real_path)
|
|
10612
|
+
self._project_favorites.set(project.real_path, now_star)
|
|
10613
|
+
self._projects_pane.set_favorite_paths(
|
|
10614
|
+
self._project_favorites.get_paths())
|
|
10615
|
+
label = "★" if now_star else "unstarred"
|
|
10616
|
+
self._set_status(f"{label} {project.display_name}")
|
|
10617
|
+
|
|
10618
|
+
def _do_context_project_info(self, project: Project) -> None:
|
|
10619
|
+
modal = ProjectInfoModal(project=project, on_close=self._close_modal)
|
|
10620
|
+
self._show_overlay(
|
|
10621
|
+
modal,
|
|
10622
|
+
width=60,
|
|
10623
|
+
height=40,
|
|
10624
|
+
click_outside_to_close=True,
|
|
10625
|
+
)
|
|
10626
|
+
|
|
10461
10627
|
def _on_running_context_menu(self, entry: RunningEntry) -> None:
|
|
10462
10628
|
r = self._by_tmux(entry.tmux_name)
|
|
10463
10629
|
if r is None:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from railmux.fuzzy import fuzzy_match
|
|
7
8
|
|
|
@@ -18,21 +19,24 @@ from railmux.ui._widgets import (
|
|
|
18
19
|
|
|
19
20
|
class _ProjectRow(ClickableRow):
|
|
20
21
|
def __init__(self, project: Project, selected: bool = False,
|
|
22
|
+
is_favorite: bool = False,
|
|
21
23
|
on_click: "Callable[[], None] | None" = None,
|
|
22
|
-
on_double_click: "Callable[[], None] | None" = None
|
|
24
|
+
on_double_click: "Callable[[], None] | None" = None,
|
|
25
|
+
on_right_click: "Callable[[], None] | None" = None) -> None:
|
|
23
26
|
self.project = project
|
|
24
27
|
# Keep the count in a fixed right-hand column. An inline ``[12]``
|
|
25
28
|
# makes project names jitter as counts change and becomes difficult to
|
|
26
29
|
# scan once names have different lengths.
|
|
27
30
|
count = str(project.session_count)
|
|
31
|
+
marker = "★ " if is_favorite else ""
|
|
28
32
|
label = urwid.Columns([
|
|
29
|
-
urwid.Text(f" {project.display_name}", wrap="ellipsis"),
|
|
33
|
+
urwid.Text(f" {marker}{project.display_name}", wrap="ellipsis"),
|
|
30
34
|
("fixed", max(3, len(count) + 1),
|
|
31
35
|
urwid.Text(f"{count} ", align="right", wrap="clip")),
|
|
32
36
|
], dividechars=0)
|
|
33
37
|
attr = "selected" if selected else None
|
|
34
38
|
super().__init__(urwid.AttrMap(label, attr, focus_map="focus"),
|
|
35
|
-
on_click, on_double_click,
|
|
39
|
+
on_click, on_double_click, on_right_click,
|
|
36
40
|
click_key=project.encoded_name,
|
|
37
41
|
immediate_click=True)
|
|
38
42
|
|
|
@@ -65,15 +69,20 @@ class ProjectsPane(ScrollableSidebarPane, urwid.WidgetWrap):
|
|
|
65
69
|
on_select: Callable[[Project | None], None],
|
|
66
70
|
on_double_click: Callable[[Project | None], None] | None = None,
|
|
67
71
|
provider_label: str = "Agent",
|
|
68
|
-
*,
|
|
72
|
+
*, on_context: Callable[[Project], None] | None = None,
|
|
73
|
+
favorite_paths: set[Path] | None = None,
|
|
74
|
+
boxed: bool = True) -> None:
|
|
69
75
|
self._all_projects = projects
|
|
70
76
|
self._on_select = on_select
|
|
71
77
|
self._on_double_click = on_double_click
|
|
78
|
+
self._on_context = on_context
|
|
72
79
|
self._provider_label = provider_label
|
|
73
80
|
self._boxed = boxed
|
|
74
81
|
self._filter = ""
|
|
75
82
|
self._loading = False
|
|
76
83
|
self._selected_encoded_name: str | None = None
|
|
84
|
+
self._context_encoded_name: str | None = None
|
|
85
|
+
self._favorite_paths = set(favorite_paths or ())
|
|
77
86
|
|
|
78
87
|
self._new_row = _NewProjectRow(on_click=lambda: self._on_select(None))
|
|
79
88
|
self._walker = urwid.SimpleFocusListWalker(self._build_rows(projects))
|
|
@@ -102,11 +111,18 @@ class ProjectsPane(ScrollableSidebarPane, urwid.WidgetWrap):
|
|
|
102
111
|
|
|
103
112
|
def _build_rows(self, projects: list[Project]) -> list:
|
|
104
113
|
sel = self._selected_encoded_name
|
|
114
|
+
context = self._context_encoded_name
|
|
105
115
|
rows = [
|
|
106
|
-
_ProjectRow(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
_ProjectRow(
|
|
117
|
+
p,
|
|
118
|
+
selected=(p.encoded_name in {sel, context}),
|
|
119
|
+
is_favorite=p.real_path in self._favorite_paths,
|
|
120
|
+
on_click=lambda p=p: self._on_select(p),
|
|
121
|
+
on_double_click=(lambda p=p: self._on_double_click(p))
|
|
122
|
+
if self._on_double_click else None,
|
|
123
|
+
on_right_click=(lambda p=p: self._on_context(p))
|
|
124
|
+
if self._on_context else None,
|
|
125
|
+
)
|
|
110
126
|
for p in self._visible_projects(projects)
|
|
111
127
|
]
|
|
112
128
|
if not rows:
|
|
@@ -127,12 +143,19 @@ class ProjectsPane(ScrollableSidebarPane, urwid.WidgetWrap):
|
|
|
127
143
|
self, projects: list[Project] | None = None,
|
|
128
144
|
) -> list[Project]:
|
|
129
145
|
needle = self._filter.lower()
|
|
130
|
-
|
|
146
|
+
visible = [
|
|
131
147
|
project for project in (
|
|
132
148
|
self._all_projects if projects is None else projects
|
|
133
149
|
)
|
|
134
150
|
if fuzzy_match(needle, str(project.real_path))
|
|
135
151
|
]
|
|
152
|
+
# Discovery already orders projects by recent activity. A stable sort
|
|
153
|
+
# moves favorites to the front without disturbing that order within
|
|
154
|
+
# either group.
|
|
155
|
+
return sorted(
|
|
156
|
+
visible,
|
|
157
|
+
key=lambda project: project.real_path not in self._favorite_paths,
|
|
158
|
+
)
|
|
136
159
|
|
|
137
160
|
def set_provider_label(self, label: str) -> None:
|
|
138
161
|
"""Update provider-specific empty text, including empty-to-empty switches."""
|
|
@@ -160,6 +183,18 @@ class ProjectsPane(ScrollableSidebarPane, urwid.WidgetWrap):
|
|
|
160
183
|
self._selected_encoded_name = encoded_name
|
|
161
184
|
self._refresh_rows()
|
|
162
185
|
|
|
186
|
+
def set_context_selected(self, encoded_name: str | None) -> None:
|
|
187
|
+
if self._context_encoded_name == encoded_name:
|
|
188
|
+
return
|
|
189
|
+
self._context_encoded_name = encoded_name
|
|
190
|
+
self._refresh_rows()
|
|
191
|
+
|
|
192
|
+
def set_favorite_paths(self, paths: set[Path]) -> None:
|
|
193
|
+
if self._favorite_paths == paths:
|
|
194
|
+
return
|
|
195
|
+
self._favorite_paths = set(paths)
|
|
196
|
+
self._refresh_rows()
|
|
197
|
+
|
|
163
198
|
def set_filter(self, needle: str) -> None:
|
|
164
199
|
if self._filter == needle:
|
|
165
200
|
return
|