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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- 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
|