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.
Files changed (189) hide show
  1. {railmux-0.4.0.dev34/src/railmux.egg-info → railmux-0.4.0.dev36}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev36/src/railmux/__init__.py +1 -0
  3. railmux-0.4.0.dev36/src/railmux/project_favorites.py +79 -0
  4. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/provider_paths.py +3 -1
  5. railmux-0.4.0.dev36/src/railmux/release_version.py +111 -0
  6. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/app.py +179 -13
  7. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/projects_pane.py +44 -9
  8. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_msys2.py +32 -17
  9. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_ui_transition.py +4 -3
  10. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36/src/railmux.egg-info}/PKG-INFO +1 -1
  11. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/SOURCES.txt +4 -0
  12. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_focus_highlight.py +183 -2
  13. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_fullscreen_binding.py +19 -0
  14. railmux-0.4.0.dev36/tests/test_project_favorites.py +52 -0
  15. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_projects_pane.py +65 -0
  16. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_release_notes.py +26 -1
  17. railmux-0.4.0.dev36/tests/test_release_version.py +128 -0
  18. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_sidebar.py +17 -0
  19. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_msys2.py +83 -0
  20. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_ui_transition.py +1 -1
  21. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tools/release_notes.py +5 -2
  22. railmux-0.4.0.dev34/src/railmux/__init__.py +0 -1
  23. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/LICENSE +0 -0
  24. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/MANIFEST.in +0 -0
  25. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/README.md +0 -0
  26. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/pyproject.toml +0 -0
  27. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/setup.cfg +0 -0
  28. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/__main__.py +0 -0
  29. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/atomic_file.py +0 -0
  30. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/background_index.py +0 -0
  31. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/cli.py +0 -0
  32. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/codex_index.py +0 -0
  33. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/config.py +0 -0
  34. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/config_cli.py +0 -0
  35. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/diagnostics.py +0 -0
  36. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/discovery.py +0 -0
  37. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/display_transport.py +0 -0
  38. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/entrypoint.py +0 -0
  39. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_client.py +0 -0
  40. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_history.py +0 -0
  41. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_input.py +0 -0
  42. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_protocol.py +0 -0
  43. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fast_display_server.py +0 -0
  44. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/favorites.py +0 -0
  45. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/fuzzy.py +0 -0
  46. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/help_workspace.py +0 -0
  47. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/launcher.py +0 -0
  48. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/legacy_sessions.py +0 -0
  49. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/local_clipboard.py +0 -0
  50. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/local_open.py +0 -0
  51. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/models.py +0 -0
  52. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/modes.py +0 -0
  53. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/mouse_manager.py +0 -0
  54. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/orphan_marker.py +0 -0
  55. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/pane_surface.py +0 -0
  56. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/path_codec.py +0 -0
  57. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/preview_pager.py +0 -0
  58. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/remote_config.py +0 -0
  59. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/renames.py +0 -0
  60. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/restart_state.py +0 -0
  61. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/runtime_config.py +0 -0
  62. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/scroll_agent.py +0 -0
  63. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/scroll_manager.py +0 -0
  64. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/selection_isolation.py +0 -0
  65. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/self_update.py +0 -0
  66. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/session_cache.py +0 -0
  67. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/session_index.py +0 -0
  68. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/session_lease.py +0 -0
  69. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/setting_contracts.py +0 -0
  70. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/settings.py +0 -0
  71. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ssh_args.py +0 -0
  72. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ssh_compat.py +0 -0
  73. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ssh_display_diagnostics.py +0 -0
  74. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ssh_doctor.py +0 -0
  75. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/system_deps.py +0 -0
  76. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/terminal_status.py +0 -0
  77. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tmux_binding_manager.py +0 -0
  78. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tmux_ctl.py +0 -0
  79. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tmux_health.py +0 -0
  80. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tmux_server.py +0 -0
  81. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/tool_panes.py +0 -0
  82. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/transcript.py +0 -0
  83. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/__init__.py +0 -0
  84. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/_widgets.py +0 -0
  85. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/keymap.py +0 -0
  86. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/modals.py +0 -0
  87. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/running_pane.py +0 -0
  88. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/sessions_pane.py +0 -0
  89. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/sidebar.py +0 -0
  90. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/statusbar.py +0 -0
  91. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/ui/workspace.py +0 -0
  92. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_attach_relay.py +0 -0
  93. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_bootstrap.py +0 -0
  94. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_install_log.py +0 -0
  95. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_pacman.py +0 -0
  96. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_paths.py +0 -0
  97. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux/windows_tmux_lifecycle.py +0 -0
  98. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/dependency_links.txt +0 -0
  99. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/entry_points.txt +0 -0
  100. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/requires.txt +0 -0
  101. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/src/railmux.egg-info/top_level.txt +0 -0
  102. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_agent_workspace.py +0 -0
  103. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_atomic_file.py +0 -0
  104. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_background_index.py +0 -0
  105. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_cli.py +0 -0
  106. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_codex_delete.py +0 -0
  107. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_codex_index.py +0 -0
  108. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_codex_lineage.py +0 -0
  109. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_codex_transcript.py +0 -0
  110. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_config.py +0 -0
  111. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_config_cli.py +0 -0
  112. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_denested_transport_benchmark.py +0 -0
  113. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_diagnostics.py +0 -0
  114. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_discovery.py +0 -0
  115. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_display_transport.py +0 -0
  116. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_fast_full_window.py +0 -0
  117. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_fuzzy.py +0 -0
  118. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_help_agent.py +0 -0
  119. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_help_workspace.py +0 -0
  120. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_history_cleanup.py +0 -0
  121. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_keymap.py +0 -0
  122. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_launcher.py +0 -0
  123. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_layout_profile.py +0 -0
  124. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_legacy_sessions.py +0 -0
  125. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_local_clipboard.py +0 -0
  126. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_local_open.py +0 -0
  127. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_modals.py +0 -0
  128. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_mode_selection.py +0 -0
  129. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_modes.py +0 -0
  130. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_mouse_manager.py +0 -0
  131. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_new_project.py +0 -0
  132. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_options.py +0 -0
  133. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_orphan_recovery.py +0 -0
  134. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_pane_surface.py +0 -0
  135. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_path_codec.py +0 -0
  136. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_preview_pager.py +0 -0
  137. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_provider_paths.py +0 -0
  138. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_release_workflow.py +0 -0
  139. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_remote_config.py +0 -0
  140. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_renames.py +0 -0
  141. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_restart_state.py +0 -0
  142. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_running_pane.py +0 -0
  143. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_running_sort.py +0 -0
  144. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_runtime_config.py +0 -0
  145. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_scroll_agent.py +0 -0
  146. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_scroll_manager.py +0 -0
  147. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_selection_isolation.py +0 -0
  148. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_self_update.py +0 -0
  149. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_session_cache.py +0 -0
  150. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_session_index.py +0 -0
  151. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_session_lease.py +0 -0
  152. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_sessions_pane.py +0 -0
  153. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_setting_contracts.py +0 -0
  154. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_settings.py +0 -0
  155. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_soft_quit.py +0 -0
  156. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_args.py +0 -0
  157. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_compat.py +0 -0
  158. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_display_benchmark.py +0 -0
  159. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_display_diagnostics.py +0 -0
  160. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_ssh_doctor.py +0 -0
  161. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_status.py +0 -0
  162. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_statusbar.py +0 -0
  163. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_system_deps.py +0 -0
  164. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_terminal_size.py +0 -0
  165. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_terminal_status.py +0 -0
  166. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_termux_local_touch.py +0 -0
  167. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_binding_manager.py +0 -0
  168. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_ctl.py +0 -0
  169. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_health.py +0 -0
  170. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_integration.py +0 -0
  171. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_server.py +0 -0
  172. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tmux_status.py +0 -0
  173. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_tool_panes.py +0 -0
  174. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_transcript.py +0 -0
  175. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_utf8_state.py +0 -0
  176. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_web_demo_recorder.py +0 -0
  177. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_wheel_smoke.py +0 -0
  178. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_widgets.py +0 -0
  179. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_attach_relay.py +0 -0
  180. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_bootstrap.py +0 -0
  181. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_install_log.py +0 -0
  182. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_pacman.py +0 -0
  183. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_paths.py +0 -0
  184. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_tmux_lifecycle.py +0 -0
  185. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_windows_wrapper_contract.py +0 -0
  186. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tests/test_yolo_prompt.py +0 -0
  187. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tools/denested_transport_benchmark.py +0 -0
  188. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tools/wheel_smoke.py +0 -0
  189. {railmux-0.4.0.dev34 → railmux-0.4.0.dev36}/tools/windows_archive_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev34
3
+ Version: 0.4.0.dev36
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -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
- r"railmux-[0-9]+(?:\.[0-9]+)*(?:\.dev[0-9]+)?\Z"
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
- tmux_ctl.set_pane_user_option(
1252
- pane_id,
1253
- tmux_server.TRANSCRIPT_SOURCE_OPTION,
1254
- marker,
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, tmux_name: str, slot: AgentSlot | None = None,
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
- target_pane = None if active else workspace.target.pane_id
1385
- state = (active, layout, target_pane)
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.pane_id,
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, _target_pane = state
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(agent_tmux_name, slot)
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(opened)
2662
+ self._finish_tmux_bindings(
2663
+ opened,
2664
+ force_target=not target_was_available,
2665
+ )
2561
2666
 
2562
- def _finish_tmux_bindings(self, opened: bool) -> None:
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=True)
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) -> 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
- *, boxed: bool = True) -> None:
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(p, selected=(p.encoded_name == sel),
107
- on_click=lambda p=p: self._on_select(p),
108
- on_double_click=(lambda p=p: self._on_double_click(p))
109
- if self._on_double_click else None)
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
- return [
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