gitpane 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. gitpane-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,414 @@
1
+ """Find out in the background whether the recent repository folders can still be reached, without ever freezing a window.
2
+
3
+ Asking Windows whether a folder exists can block for a long time: a hard disk that went to sleep takes up to a minute
4
+ to spin up before answering yes, and a disconnected network drive or an unreachable server can take as long to answer
5
+ no. So the question is never asked on the thread that draws the windows, and a slow answer is never taken for a no:
6
+ only a real answer marks a folder unavailable, however late it comes. No question is ever abandoned or given a time
7
+ limit here: an owner that stops waiting shows the folder as unknown and still takes its answer when it comes.
8
+
9
+ Folders are grouped by what their answer waits on, each group asked in order on its own thread, so a sleeping disk or a
10
+ dead server only delays the folders that live on it. On Windows a group is a drive letter or a network server. On Linux
11
+ and macOS it is a mount point, a disk or a network server, read from the system's mount table on a sorting thread of
12
+ its own, never on the thread that asks for the checks; a table that cannot be read keeps every folder in one group, as
13
+ before. The links on a recent's path are followed on the thread of the group whose disk holds them, and a folder whose
14
+ path leads into another mount is handed to that mount's group without reading anything there: a link to a dead share
15
+ only holds its own folder. The threads are daemon threads: an answer still pending when GitPane quits never holds the
16
+ exit, since a blocked question to the system cannot be cancelled.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ import stat
24
+ import threading
25
+ from collections import deque
26
+ from dataclasses import dataclass
27
+ from functools import partial
28
+ from typing import TYPE_CHECKING
29
+
30
+ from gitpane.recent.mount_table import PLATFORM_MOUNT_TABLE_READER, PLATFORM_PATH_COMPARE_KEY, follow_folder_links, mount_group_key, without_double_slash_root
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable
34
+
35
+ from gitpane.recent.mount_table import FolderWalk, MountEntry
36
+
37
+ WORKER_THREAD_NAME = "gitpane-recent-availability"
38
+ # Start of a network path, \\server\share\...; os.path.normpath writes it with backslashes on Windows.
39
+ NETWORK_PATH_PREFIX = "\\\\"
40
+ # Start of a long network path, \\?\UNC\server\share\..., the same server as \\server\share\...; compared without case.
41
+ EXTENDED_NETWORK_PATH_PREFIX = "\\\\?\\UNC\\"
42
+ # Starts of long local paths, \\?\C:\... and \\.\C:\..., or of a volume path, \\?\Volume{...}\...: the next part is the drive.
43
+ EXTENDED_PATH_PREFIXES = ("\\\\?\\", "\\\\.\\")
44
+ # Windows codes Python raises as FileNotFoundError although they only say the path could not be read as written, not
45
+ # that the folder is gone: ERROR_BAD_PATHNAME and ERROR_FILENAME_EXCED_RANGE, a path too long for the system settings.
46
+ ERROR_BAD_PATHNAME = 161
47
+ ERROR_FILENAME_EXCED_RANGE = 206
48
+ UNREADABLE_PATH_WINDOWS_ERROR_SET = frozenset({ERROR_BAD_PATHNAME, ERROR_FILENAME_EXCED_RANGE})
49
+ # Windows code Python raises as a plain OSError although it is a definite "absent": ERROR_NO_SUCH_DEVICE, given for a
50
+ # removed USB drive whose letter Windows still lists, with no device behind it.
51
+ ERROR_NO_SUCH_DEVICE = 433
52
+ GONE_DEVICE_WINDOWS_ERROR_SET = frozenset({ERROR_NO_SUCH_DEVICE})
53
+ # Start of every line the check writes to the --log file, so a reader can find them among the other records.
54
+ UNANSWERED_LOG_PREFIX = "Recent folder check: "
55
+ # Reserved key of the thread that reads the mount table and sorts the folders into their groups. It never meets a group
56
+ # key: mount table keys carry a prefix ending in a colon, Windows keys are a drive letter or \\server. Kept among the
57
+ # groups, the sorting thread starts, releases and is waited for exactly as a group worker is.
58
+ SORTING_GROUP = "sorting"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class PendingFolder:
63
+ """A recent folder waiting in its group's queue.
64
+
65
+ ``folder_dir`` is the recent as saved, which the question and the answer are about; ``walked_dir`` is its absolute
66
+ path with the links followed so far replaced by their targets, so a group handed the folder goes on from there.
67
+ """
68
+
69
+ folder_dir: str
70
+ walked_dir: str
71
+ followed_link_count: int
72
+
73
+
74
+ def start_daemon_thread(target: Callable[[], None]) -> None:
75
+ """Run the target on a new daemon thread, which never holds the application's exit while blocked on a disk."""
76
+ threading.Thread(target=target, name=WORKER_THREAD_NAME, daemon=True).start()
77
+
78
+
79
+ def ignore_log_line(_line: str) -> None:
80
+ """Drop a diagnostic line, for a check whose owner writes no log."""
81
+
82
+
83
+ def ignore_unanswered(_folder_dir: str) -> None:
84
+ """Drop the report of a folder whose question got no answer, for a check whose owner does not show such folders."""
85
+
86
+
87
+ def unanswered_after_wait_log_line(folder_dir: str, waited_seconds: float) -> str:
88
+ """Return the --log line the window writes when it stops waiting for a folder's answer and shows it as Unknown.
89
+
90
+ The question goes on: its real answer, present or absent, still replaces Unknown whenever it comes.
91
+ """
92
+ return (f"{UNANSWERED_LOG_PREFIX}no answer about {folder_dir} after {waited_seconds:g} s; it is shown as Unknown, not marked Unavailable, "
93
+ "and its answer is still taken when it comes.")
94
+
95
+
96
+ def unanswered_log_line(folder_dir: str, error: Exception) -> str:
97
+ """Return the --log line that tells why a recent folder got no answer, so a reader knows why its row is not marked.
98
+
99
+ Only the folder, the error class, its system code and the system's own message are written: enough to tell a drive
100
+ that is not ready from a refused access or a network timeout.
101
+ """
102
+ if isinstance(error, OSError):
103
+ windows_code = getattr(error, "winerror", None)
104
+ code_text = f"Windows error {windows_code}" if windows_code is not None else f"errno {error.errno}"
105
+ cause = f"{type(error).__name__}, {code_text}: {error.strerror or error}"
106
+ else:
107
+ cause = f"{type(error).__name__}: {error}"
108
+ return f"{UNANSWERED_LOG_PREFIX}cannot tell whether {folder_dir} can be reached ({cause}); it is not marked Unavailable."
109
+
110
+
111
+ def folder_is_reachable(folder_dir: str, stat_folder: Callable[[str], os.stat_result] = os.stat) -> bool:
112
+ """Tell whether the folder exists now; raise OSError when the system gives no definite answer. May block for long.
113
+
114
+ Only a definite "absent" is a no: the path is not found (``FileNotFoundError``, which Windows also gives for a network
115
+ server that is switched off or unreachable, a share that does not exist, and a drive letter that is gone, such as an
116
+ unplugged USB drive), the drive letter is still listed but no device is behind it (a removed USB drive Windows has
117
+ not released yet), a part of it is a file (``NotADirectoryError``), or it exists but is not a folder. Any other
118
+ failure, such as a drive that is not ready yet, a network timeout, a refused access, or a path the system refuses to
119
+ read as written (too long, or invalid) even though Python names it "not found", says nothing about the folder and is
120
+ raised for the caller to ignore. ``os.path.isdir`` cannot be used: it turns every one of those failures into False.
121
+ ``stat_folder`` lets tests answer without a disk.
122
+ """
123
+ try:
124
+ folder_status = stat_folder(folder_dir)
125
+ except (FileNotFoundError, NotADirectoryError) as error:
126
+ if getattr(error, "winerror", None) in UNREADABLE_PATH_WINDOWS_ERROR_SET:
127
+ raise
128
+ return False
129
+ except OSError as error:
130
+ if getattr(error, "winerror", None) in GONE_DEVICE_WINDOWS_ERROR_SET:
131
+ return False
132
+ raise
133
+ return stat.S_ISDIR(folder_status.st_mode)
134
+
135
+
136
+ def folder_group(folder_dir: str) -> str:
137
+ """Return what the folder's answer waits on, compared without case as Windows does: its drive letter or its network server.
138
+
139
+ ``c:`` for a local or mapped drive, ``\\\\server`` for a network path: every share of one server is in the same group,
140
+ since a server that is off blocks all of them alike. Two drive letters mapped to one server stay two groups: telling
141
+ them apart would mean asking the network, the very thing that may block. Empty on systems without drives, where
142
+ every folder is in one group.
143
+
144
+ A long path written with the ``\\\\?\\`` or ``\\\\.\\`` prefix, which a recent may keep when Windows cannot shorten it,
145
+ is grouped as its short form: ``\\\\?\\C:\\...`` with ``c:``, ``\\\\?\\UNC\\server\\...`` with ``\\\\server``. Grouped under
146
+ the prefix alone, a local folder would wait behind a dead server.
147
+ """
148
+ normalized_dir = os.path.normpath(folder_dir)
149
+ if normalized_dir[:len(EXTENDED_NETWORK_PATH_PREFIX)].upper() == EXTENDED_NETWORK_PATH_PREFIX:
150
+ normalized_dir = NETWORK_PATH_PREFIX + normalized_dir[len(EXTENDED_NETWORK_PATH_PREFIX):]
151
+ else:
152
+ for prefix in EXTENDED_PATH_PREFIXES:
153
+ if normalized_dir.startswith(prefix):
154
+ device, _separator, _rest = normalized_dir.removeprefix(prefix).partition(os.sep)
155
+ return device.casefold()
156
+ drive, _rest = os.path.splitdrive(normalized_dir)
157
+ if not drive.startswith(NETWORK_PATH_PREFIX):
158
+ return drive.casefold()
159
+ server, _separator, _share = drive.removeprefix(NETWORK_PATH_PREFIX).partition(os.sep)
160
+ return (NETWORK_PATH_PREFIX + server).casefold()
161
+
162
+
163
+ class RecentAvailabilityCheck:
164
+ """Ask on background threads whether each recent folder can be reached, once per folder, and report every real answer.
165
+
166
+ ``report`` is called on a worker thread with the folder and its answer; it only ever gets real answers, and is never
167
+ called once ``close`` has returned, so the owner can stop listening for good by closing the check. A folder whose
168
+ question fails without a real answer is neither confirmed nor marked unavailable: ``log_line`` first gets the line
169
+ that says why, then ``report_unanswered`` gets the folder, both on a worker thread and never after ``close``, so the
170
+ owner can show it as unknown. The owner hands both to its own thread before acting on them.
171
+
172
+ Every group with folders waiting gets its own worker at once, never a place in a queue behind other groups: a question
173
+ blocked on a sleeping disk or a dead server cannot be interrupted, so any limit on the workers would let a few
174
+ blocked groups hold back a local drive's answer. The groups are few, one per drive letter and per network server
175
+ among the recents, so the threads are too.
176
+
177
+ Where a mount table reader is given (Linux and macOS), the folders are first sorted into their groups on a sorting
178
+ thread, since reading the table may block (the ``mount`` command of macOS asks every mount point). Without one
179
+ (Windows), each folder is grouped at once by its drive letter or server. Before asking about a folder, its group's
180
+ thread follows the links of its path on that group's own disk; when they lead into a mount of another group, the
181
+ folder is handed over to that group without anything read there, so a link to a slow mount only holds its own folder.
182
+ """
183
+
184
+ def __init__(self, report: Callable[[str, bool], None], is_reachable: Callable[[str], bool] = folder_is_reachable,
185
+ start_thread: Callable[[Callable[[], None]], None] = start_daemon_thread, log_line: Callable[[str], None] = ignore_log_line,
186
+ read_mount_table: Callable[[], list[MountEntry]] | None = PLATFORM_MOUNT_TABLE_READER, read_link: Callable[[str], str] = os.readlink,
187
+ report_unanswered: Callable[[str], None] = ignore_unanswered, path_compare_key: Callable[[str], str] = PLATFORM_PATH_COMPARE_KEY) -> None:
188
+ """Keep the receivers, the injected folder question, the thread starter, the mount table reader, the link reader and the path comparison.
189
+
190
+ Tests inject them to answer without a disk, fail a start, hold and fail the mount table read, give links without
191
+ a disk, or compare paths without case as macOS does. A mount table reader of None groups the folders by drive
192
+ letter and server, as on Windows, and no link is ever read then.
193
+ """
194
+ self.report = report
195
+ self.is_reachable = is_reachable
196
+ self.start_thread = start_thread
197
+ self.log_line = log_line
198
+ self.read_mount_table = read_mount_table
199
+ self.read_link = read_link
200
+ self.report_unanswered = report_unanswered
201
+ self.path_compare_key = path_compare_key
202
+ self.requested_dir_set: set[str] = set()
203
+ # Folders requested and not sorted into a group yet; only used with a mount table reader.
204
+ self.sorting_dirs: deque[str] = deque()
205
+ # The mount table read last by the sorting thread.
206
+ self.mount_entries: list[MountEntry] = []
207
+ self.waiting_folders_by_group: dict[str, deque[PendingFolder]] = {}
208
+ # Groups a worker is asking about right now: each group has at most one, so its folders are asked in order.
209
+ self.active_group_set: set[str] = set()
210
+ self.closed = False
211
+ # Guards every field above. It is also held while reporting, so close() waits for an answer being delivered; it
212
+ # is notified when a worker leaves, for whoever waits until the check is idle.
213
+ self.condition = threading.Condition()
214
+
215
+ def request(self, folder_dirs: list[str]) -> None:
216
+ """Queue the folders never asked about before, and start a worker for each group with folders waiting and no worker.
217
+
218
+ With a mount table reader, the new folders wait for the sorting thread instead, started here when it does not run:
219
+ the table is never read on the calling thread, the one that draws the windows.
220
+ """
221
+ started_groups: list[str] = []
222
+ with self.condition:
223
+ if self.closed:
224
+ return
225
+ for folder_dir in folder_dirs:
226
+ if folder_dir in self.requested_dir_set:
227
+ continue
228
+ self.requested_dir_set.add(folder_dir)
229
+ if self.read_mount_table is None:
230
+ self.waiting_folders_by_group.setdefault(folder_group(folder_dir), deque()).append(PendingFolder(folder_dir, folder_dir, 0))
231
+ else:
232
+ self.sorting_dirs.append(folder_dir)
233
+ if self.sorting_dirs and SORTING_GROUP not in self.active_group_set:
234
+ started_groups.append(SORTING_GROUP)
235
+ for group, waiting_folders in self.waiting_folders_by_group.items():
236
+ if waiting_folders and group not in self.active_group_set:
237
+ started_groups.append(group)
238
+ for group in started_groups:
239
+ self.active_group_set.add(group)
240
+ for group in started_groups:
241
+ self._start_worker(group)
242
+
243
+ def close(self) -> None:
244
+ """Drop the folders not asked about yet and stop every later report; a question already asked ends on its own."""
245
+ with self.condition:
246
+ self.closed = True
247
+ self.sorting_dirs.clear()
248
+ self.waiting_folders_by_group.clear()
249
+
250
+ def wait_until_idle(self, timeout_seconds: float) -> bool:
251
+ """Wait until no worker runs any more; False when one still does after the timeout. Blocks: never call it from a window."""
252
+ with self.condition:
253
+ return self.condition.wait_for(lambda: not self.active_group_set, timeout_seconds)
254
+
255
+ def _start_worker(self, group: str) -> None:
256
+ """Start the worker of a group already marked active, or the sorting thread, and release the group if the thread cannot start.
257
+
258
+ Its folders stay queued, or waiting to be sorted, and the next request tries again.
259
+ """
260
+ work = self._sort if group == SORTING_GROUP else self._work
261
+ try:
262
+ self.start_thread(partial(work, group))
263
+ except Exception:
264
+ logging.getLogger(__name__).exception("Cannot start a thread to check the recent folders")
265
+ self._release(group)
266
+
267
+ def _release(self, group: str) -> None:
268
+ """Mark a group as without a worker and wake whoever waits until the check is idle."""
269
+ with self.condition:
270
+ self.active_group_set.discard(group)
271
+ self.condition.notify_all()
272
+
273
+ def _sort(self, group: str) -> None:
274
+ """Read the mount table and sort the requested folders into their groups, on its own thread, while folders keep coming.
275
+
276
+ The table is read and the groups worked out without holding the condition, so a slow read never holds a request.
277
+ The thread leaves only while holding the condition, in the same step that finds nothing left to sort, exactly as
278
+ a group worker does, and releases its key on the way out should it end on an error.
279
+ """
280
+ left = False
281
+ try:
282
+ while True:
283
+ with self.condition:
284
+ if self.closed or not self.sorting_dirs:
285
+ self.active_group_set.discard(group)
286
+ self.condition.notify_all()
287
+ left = True
288
+ return
289
+ sorting_dirs = list(self.sorting_dirs)
290
+ self.sorting_dirs.clear()
291
+ entries = self._read_mount_table()
292
+ sorted_folders: list[tuple[str, PendingFolder]] = []
293
+ for folder_dir in sorting_dirs:
294
+ walked_dir = without_double_slash_root(os.path.normpath(os.path.abspath(folder_dir)))
295
+ sorted_folders.append((self._folder_group(walked_dir, entries), PendingFolder(folder_dir, walked_dir, 0)))
296
+ started_groups: list[str] = []
297
+ with self.condition:
298
+ if self.closed:
299
+ continue
300
+ self.mount_entries = entries
301
+ for folder_group_key, pending in sorted_folders:
302
+ self.waiting_folders_by_group.setdefault(folder_group_key, deque()).append(pending)
303
+ for waiting_group, waiting_folders in self.waiting_folders_by_group.items():
304
+ if waiting_folders and waiting_group not in self.active_group_set:
305
+ started_groups.append(waiting_group)
306
+ for started_group in started_groups:
307
+ self.active_group_set.add(started_group)
308
+ for started_group in started_groups:
309
+ self._start_worker(started_group)
310
+ finally:
311
+ if not left:
312
+ self._release(group)
313
+
314
+ def _read_mount_table(self) -> list[MountEntry]:
315
+ """Read the mount table with the injected reader; an unexpected failure is logged and gives an empty table, so one group."""
316
+ try:
317
+ return self.read_mount_table()
318
+ except Exception:
319
+ logging.getLogger(__name__).exception("Cannot read the mount table to group the recent folder checks")
320
+ return []
321
+
322
+ def _folder_group(self, walked_dir: str, entries: list[MountEntry]) -> str:
323
+ """Return the group of a folder from the mount table, or the single group of these systems when no mount holds it.
324
+
325
+ The path was made absolute and normalized without touching the disk, so it compares with the mount points, in the
326
+ way the platform compares names.
327
+ """
328
+ group = mount_group_key(walked_dir, entries, self.path_compare_key)
329
+ if group is None:
330
+ return folder_group(walked_dir)
331
+ return group
332
+
333
+ def _work(self, group: str) -> None:
334
+ """Ask about the folders of one group after the other, until none is waiting.
335
+
336
+ A worker leaves only while holding the condition, in the same step that finds nothing to do, so a folder requested at that
337
+ moment is either seen by this worker or gets a new one. A failing question or receiver only loses its own folder;
338
+ should the worker still end on an error, it releases its group on the way out, so the group's next request gets
339
+ a new worker instead of waiting on a thread that no longer runs.
340
+ """
341
+ left = False
342
+ try:
343
+ while True:
344
+ with self.condition:
345
+ waiting_folders = self.waiting_folders_by_group.get(group)
346
+ if self.closed or not waiting_folders:
347
+ self.waiting_folders_by_group.pop(group, None)
348
+ self.active_group_set.discard(group)
349
+ self.condition.notify_all()
350
+ left = True
351
+ return
352
+ pending = waiting_folders.popleft()
353
+ entries = self.mount_entries
354
+ if entries:
355
+ walk = follow_folder_links(pending.walked_dir, group, pending.followed_link_count, entries, self.read_link, self.path_compare_key)
356
+ if walk.group != group:
357
+ self._hand_over(pending.folder_dir, walk)
358
+ continue
359
+ reachable = self._ask(pending.folder_dir)
360
+ if reachable is None:
361
+ self._deliver(pending.folder_dir, partial(self.report_unanswered, pending.folder_dir))
362
+ else:
363
+ self._report(pending.folder_dir, reachable)
364
+ finally:
365
+ if not left:
366
+ self._release(group)
367
+
368
+ def _hand_over(self, folder_dir: str, walk: FolderWalk) -> None:
369
+ """Pass a folder to the group of the mount its path leads to, without reading anything on that mount.
370
+
371
+ The folder joins that group's queue, after the ones already there, and the group gets a worker if it has none.
372
+ """
373
+ with self.condition:
374
+ if self.closed:
375
+ return
376
+ self.waiting_folders_by_group.setdefault(walk.group, deque()).append(PendingFolder(folder_dir, walk.walked_dir, walk.followed_link_count))
377
+ starts_worker = walk.group not in self.active_group_set
378
+ self.active_group_set.add(walk.group)
379
+ if starts_worker:
380
+ self._start_worker(walk.group)
381
+
382
+ def _ask(self, folder_dir: str) -> bool | None:
383
+ """Ask the injected question; a question that fails gives no answer, so the folder is neither confirmed nor marked.
384
+
385
+ The question itself decides what a definite "absent" is and answers False for it; anything it raises means the
386
+ system did not say, which must never become an Unavailable mark. The reason goes to the log line receiver; an
387
+ unexpected error is also logged in full, since it points at a bug rather than at the disk.
388
+ """
389
+ try:
390
+ return self.is_reachable(folder_dir)
391
+ except (OSError, ValueError) as error:
392
+ self._deliver(folder_dir, partial(self.log_line, unanswered_log_line(folder_dir, error)))
393
+ return None
394
+ except Exception as error:
395
+ logging.getLogger(__name__).exception("The check of the recent folder %s failed", folder_dir)
396
+ self._deliver(folder_dir, partial(self.log_line, unanswered_log_line(folder_dir, error)))
397
+ return None
398
+
399
+ def _report(self, folder_dir: str, reachable: bool) -> None:
400
+ """Hand one answer to the receiver unless the check is closed."""
401
+ self._deliver(folder_dir, partial(self.report, folder_dir, reachable))
402
+
403
+ def _deliver(self, folder_dir: str, deliver: Callable[[], None]) -> None:
404
+ """Run one delivery to the owner unless the check is closed; a receiver that fails loses that delivery, not the next ones.
405
+
406
+ The receiver can fail when the window it forwards to was deleted without being closed first.
407
+ """
408
+ with self.condition:
409
+ if self.closed:
410
+ return
411
+ try:
412
+ deliver()
413
+ except Exception:
414
+ logging.getLogger(__name__).exception("Cannot hand over the check of the recent folder %s", folder_dir)
@@ -0,0 +1,11 @@
1
+ """Name a recent repository the way the recent lists show it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ def repository_display_name(repository_dir: str) -> str:
9
+ """Return the folder name of a repository, or the whole path when it has none, as for the root of a drive."""
10
+ name = os.path.basename(os.path.normpath(repository_dir))
11
+ return name or repository_dir
@@ -0,0 +1,114 @@
1
+ """Rank the recent repositories against a typed search, without ever looking at the disk.
2
+
3
+ The search is fuzzy: the typed letters must appear in the folder name or the path in the same order, whatever their
4
+ case, with gaps allowed. Matches are scored so that the most likely repository comes first.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from gitpane.recent.recent_name import repository_display_name
10
+
11
+ # Every typed letter found in the text counts this much, so a match always scores above nothing.
12
+ MATCH_SCORE = 1
13
+ # A letter right after the previous one found counts more: "pane" in "pane" beats "pane" in "plane".
14
+ CONSECUTIVE_BONUS = 4
15
+ # A letter at the start of the text or of a word counts more: typing the initials of a name finds it.
16
+ WORD_START_BONUS = 6
17
+ # A match in the folder name beats the same match somewhere in the path: the name is what the user remembers.
18
+ NAME_BONUS = 10
19
+ # Characters after which a new word starts, in folder names and in Windows and Linux paths.
20
+ WORD_SEPARATOR_SET = frozenset(" -_.\\/")
21
+
22
+
23
+ def search_key(query: str) -> str:
24
+ """Return the typed search without any whitespace, folded for a case-insensitive comparison.
25
+
26
+ Removing a space only widens the results, and the rule that the letters appear in order still holds: "git pane"
27
+ finds "GitPane-Module".
28
+ """
29
+ return "".join(query.split()).casefold()
30
+
31
+
32
+ def _letter_starts_word(folded_text: str, position: int) -> bool:
33
+ """Tell whether the letter at this position starts the text or follows a word separator."""
34
+ if position == 0:
35
+ return True
36
+ return folded_text[position - 1] in WORD_SEPARATOR_SET
37
+
38
+
39
+ def _score_from_start(folded_query: str, folded_text: str, start: int) -> int | None:
40
+ """Score the query found in order from a given position of its first letter; None when a later letter is missing.
41
+
42
+ Each following letter is taken at its next occurrence.
43
+ """
44
+ score = MATCH_SCORE
45
+ if _letter_starts_word(folded_text, start):
46
+ score += WORD_START_BONUS
47
+ previous_position = start
48
+ for letter in folded_query[1:]:
49
+ position = folded_text.find(letter, previous_position + 1)
50
+ if position < 0:
51
+ return None
52
+ score += MATCH_SCORE
53
+ if position == previous_position + 1:
54
+ score += CONSECUTIVE_BONUS
55
+ if _letter_starts_word(folded_text, position):
56
+ score += WORD_START_BONUS
57
+ previous_position = position
58
+ return score
59
+
60
+
61
+ def subsequence_score(query: str, text: str) -> int | None:
62
+ """Score how well the query letters appear in order in the text, ignoring case; None when they are not all there.
63
+
64
+ Every position of the first letter is tried as a start and the best score kept. The query is never turned into a
65
+ regular expression: whatever the user types is only letters to find, so no input can be slow or invalid.
66
+ """
67
+ folded_query = query.casefold()
68
+ if not folded_query:
69
+ return 0
70
+ folded_text = text.casefold()
71
+ best_score: int | None = None
72
+ start = folded_text.find(folded_query[0])
73
+ while start >= 0:
74
+ score = _score_from_start(folded_query, folded_text, start)
75
+ if score is None:
76
+ # A later start only leaves fewer letters to search in: if this one misses a letter, every later one does too.
77
+ break
78
+ if best_score is None or score > best_score:
79
+ best_score = score
80
+ start = folded_text.find(folded_query[0], start + 1)
81
+ return best_score
82
+
83
+
84
+ def _recent_score(key: str, recent_dir: str) -> int | None:
85
+ """Return the best score of a recent: in its folder name, with the name bonus, or in its whole path; None if neither matches."""
86
+ best_score = None
87
+ name_score = subsequence_score(key, repository_display_name(recent_dir))
88
+ if name_score is not None:
89
+ best_score = name_score + NAME_BONUS
90
+ path_score = subsequence_score(key, recent_dir)
91
+ if path_score is not None and (best_score is None or path_score > best_score):
92
+ best_score = path_score
93
+ return best_score
94
+
95
+
96
+ def ranked_recent_dirs(query: str, recent_dirs: list[str]) -> list[str]:
97
+ """Return the recents that match the search, best first; equal scores keep the history order.
98
+
99
+ An empty search, or one made of spaces, returns every recent in history order, as a new list.
100
+ """
101
+ key = search_key(query)
102
+ if not key:
103
+ return list(recent_dirs)
104
+ scored_recents: list[tuple[int, int, str]] = []
105
+ for index, recent_dir in enumerate(recent_dirs):
106
+ score = _recent_score(key, recent_dir)
107
+ if score is None:
108
+ continue
109
+ scored_recents.append((-score, index, recent_dir))
110
+ scored_recents.sort()
111
+ ranked_dirs: list[str] = []
112
+ for _negative_score, _index, recent_dir in scored_recents:
113
+ ranked_dirs.append(recent_dir)
114
+ return ranked_dirs
File without changes