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,241 @@
|
|
|
1
|
+
"""Fetch optional author photos without exposing identities or trusting image input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
from zlib import crc32
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt, QTimer, QUrl, Signal
|
|
14
|
+
from PySide6.QtGui import QImage, QImageReader
|
|
15
|
+
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
|
16
|
+
|
|
17
|
+
from gitpane.config import (
|
|
18
|
+
AVATAR_ACTIVE_LIMIT,
|
|
19
|
+
AVATAR_CACHE_ENTRY_LIMIT,
|
|
20
|
+
AVATAR_CACHE_LIMIT_BYTES,
|
|
21
|
+
AVATAR_CACHE_TTL_SECONDS,
|
|
22
|
+
AVATAR_DECODED_SIDE_LIMIT,
|
|
23
|
+
AVATAR_PENDING_LIMIT,
|
|
24
|
+
AVATAR_REQUEST_SIDE_LIMIT,
|
|
25
|
+
AVATAR_RESPONSE_LIMIT_BYTES,
|
|
26
|
+
AVATAR_TIMEOUT_MS,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
|
|
32
|
+
GRAVATAR_HOST = "www.gravatar.com"
|
|
33
|
+
HTTP_OK = 200
|
|
34
|
+
RASTER_FORMAT_SET = {b"png", b"jpeg", b"jpg", b"gif", b"webp"}
|
|
35
|
+
IMAGE_CONTENT_TYPE_SET = {"image/png", "image/jpeg", "image/gif", "image/webp"}
|
|
36
|
+
AUTHOR_COLORS = ("#778d13", "#990099", "#267777", "#944913", "#6754a3", "#19699b")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_author_email(author_email: str) -> str:
|
|
40
|
+
"""Use one author identity for Gravatar requests, cache lookups and fallback colors."""
|
|
41
|
+
return author_email.strip().lower()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def author_color(author_name: str, author_email: str) -> str:
|
|
45
|
+
"""Keep the same colored initials in history rows and the selected commit's detail."""
|
|
46
|
+
identity = normalize_author_email(author_email) or author_name
|
|
47
|
+
return AUTHOR_COLORS[crc32(identity.encode("utf-8")) % len(AUTHOR_COLORS)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def author_initials(author_name: str) -> str:
|
|
51
|
+
"""Return the first two word initials, retaining a visible unknown-author fallback."""
|
|
52
|
+
initial_word_count = 2
|
|
53
|
+
first_character_index = 0
|
|
54
|
+
initials = ""
|
|
55
|
+
for word in author_name.split()[:initial_word_count]:
|
|
56
|
+
initials += word[first_character_index].upper()
|
|
57
|
+
return initials or "?"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class PhotoRequest:
|
|
62
|
+
"""Retain coalesced consumers while limiting a single network response."""
|
|
63
|
+
|
|
64
|
+
author_hash: str
|
|
65
|
+
side: int
|
|
66
|
+
ratio_by_identity: dict[object, float] = field(default_factory=dict)
|
|
67
|
+
reply: QNetworkReply | None = None
|
|
68
|
+
timer: QTimer | None = None
|
|
69
|
+
data: bytearray = field(default_factory=bytearray)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AvatarService(QObject):
|
|
73
|
+
"""Share bounded HTTPS downloads and decoded image caching across windows."""
|
|
74
|
+
|
|
75
|
+
image_ready = Signal(object, QImage)
|
|
76
|
+
|
|
77
|
+
def __init__(self, manager: QNetworkAccessManager | None = None, clock: Callable[[], float] = time.monotonic, parent: QObject | None = None) -> None:
|
|
78
|
+
"""Inject the network and clock so timeout, cache and race tests stay offline."""
|
|
79
|
+
super().__init__(parent)
|
|
80
|
+
self.manager = manager if manager is not None else QNetworkAccessManager(self)
|
|
81
|
+
self.clock = clock
|
|
82
|
+
self.request_by_key: dict[tuple[str, int], PhotoRequest] = {}
|
|
83
|
+
self.cache_by_key: OrderedDict[tuple[str, int], tuple[float, QImage]] = OrderedDict()
|
|
84
|
+
self.cache_bytes = 0
|
|
85
|
+
self.closed = False
|
|
86
|
+
|
|
87
|
+
def request(self, author_email: str, logical_size: int, device_pixel_ratio: float, identity: object) -> None:
|
|
88
|
+
"""Coalesce equal images and publish only to consumers still interested in them."""
|
|
89
|
+
if self.closed or not author_email.strip() or logical_size <= 0 or not math.isfinite(device_pixel_ratio) or device_pixel_ratio <= 0:
|
|
90
|
+
return
|
|
91
|
+
side = min(AVATAR_REQUEST_SIDE_LIMIT, math.ceil(logical_size * device_pixel_ratio))
|
|
92
|
+
author_hash = hashlib.sha256(normalize_author_email(author_email).encode("utf-8")).hexdigest()
|
|
93
|
+
key = (author_hash, side)
|
|
94
|
+
cached = self.cache_by_key.get(key)
|
|
95
|
+
if cached is not None:
|
|
96
|
+
expiry, image = cached
|
|
97
|
+
if expiry > self.clock():
|
|
98
|
+
self.cache_by_key.move_to_end(key)
|
|
99
|
+
self._publish(identity, image, device_pixel_ratio)
|
|
100
|
+
return
|
|
101
|
+
self._evict(key)
|
|
102
|
+
pending = self.request_by_key.get(key)
|
|
103
|
+
if pending is None:
|
|
104
|
+
if len(self.request_by_key) >= AVATAR_PENDING_LIMIT:
|
|
105
|
+
return
|
|
106
|
+
pending = PhotoRequest(author_hash, side)
|
|
107
|
+
self.request_by_key[key] = pending
|
|
108
|
+
pending.ratio_by_identity[identity] = device_pixel_ratio
|
|
109
|
+
self._pump()
|
|
110
|
+
|
|
111
|
+
def cancel(self, identity: object) -> None:
|
|
112
|
+
"""Remove a consumer and abort a download once nobody needs its response."""
|
|
113
|
+
for key, pending in tuple(self.request_by_key.items()):
|
|
114
|
+
pending.ratio_by_identity.pop(identity, None)
|
|
115
|
+
if not pending.ratio_by_identity:
|
|
116
|
+
self._release(key, abort=True)
|
|
117
|
+
self._pump()
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
"""Abort outstanding requests before the application's network objects disappear."""
|
|
121
|
+
self.closed = True
|
|
122
|
+
for key in tuple(self.request_by_key):
|
|
123
|
+
self._release(key, abort=True)
|
|
124
|
+
self.cache_by_key.clear()
|
|
125
|
+
self.cache_bytes = 0
|
|
126
|
+
|
|
127
|
+
def _pump(self) -> None:
|
|
128
|
+
"""Start queued downloads only while global capacity remains available."""
|
|
129
|
+
active_count = 0
|
|
130
|
+
for pending in self.request_by_key.values():
|
|
131
|
+
active_count += pending.reply is not None
|
|
132
|
+
for key, pending in tuple(self.request_by_key.items()):
|
|
133
|
+
if active_count >= AVATAR_ACTIVE_LIMIT:
|
|
134
|
+
return
|
|
135
|
+
if pending.reply is not None:
|
|
136
|
+
continue
|
|
137
|
+
request = QNetworkRequest(QUrl(f"https://{GRAVATAR_HOST}/avatar/{pending.author_hash}?d=404&s={pending.side}"))
|
|
138
|
+
# Refuse every redirect: optional photos never justify forwarding an identity to another endpoint.
|
|
139
|
+
request.setAttribute(QNetworkRequest.Attribute.RedirectPolicyAttribute, QNetworkRequest.RedirectPolicy.ManualRedirectPolicy)
|
|
140
|
+
request.setAttribute(QNetworkRequest.Attribute.CookieLoadControlAttribute, QNetworkRequest.LoadControl.Manual)
|
|
141
|
+
request.setAttribute(QNetworkRequest.Attribute.CookieSaveControlAttribute, QNetworkRequest.LoadControl.Manual)
|
|
142
|
+
request.setTransferTimeout(AVATAR_TIMEOUT_MS)
|
|
143
|
+
reply = self.manager.get(request)
|
|
144
|
+
pending.reply = reply
|
|
145
|
+
reply.setReadBufferSize(AVATAR_RESPONSE_LIMIT_BYTES + 1)
|
|
146
|
+
reply.readyRead.connect(lambda key=key: self._receive(key))
|
|
147
|
+
reply.metaDataChanged.connect(lambda key=key: self._check_header(key))
|
|
148
|
+
reply.sslErrors.connect(lambda _errors, key=key: self._finish(key, failed=True))
|
|
149
|
+
reply.finished.connect(lambda key=key: self._finish(key))
|
|
150
|
+
timer = QTimer(self)
|
|
151
|
+
timer.setSingleShot(True)
|
|
152
|
+
timer.timeout.connect(lambda key=key: self._finish(key, failed=True))
|
|
153
|
+
pending.timer = timer
|
|
154
|
+
timer.start(AVATAR_TIMEOUT_MS)
|
|
155
|
+
active_count += 1
|
|
156
|
+
|
|
157
|
+
def _check_header(self, key: tuple[str, int]) -> bool:
|
|
158
|
+
"""Reject oversized or redirected content before reading its body."""
|
|
159
|
+
pending = self.request_by_key.get(key)
|
|
160
|
+
if pending is None or pending.reply is None:
|
|
161
|
+
return False
|
|
162
|
+
reply = pending.reply
|
|
163
|
+
content_length = reply.header(QNetworkRequest.KnownHeaders.ContentLengthHeader)
|
|
164
|
+
redirect = reply.attribute(QNetworkRequest.Attribute.RedirectionTargetAttribute)
|
|
165
|
+
if redirect is not None or (content_length is not None and int(content_length) > AVATAR_RESPONSE_LIMIT_BYTES):
|
|
166
|
+
self._finish(key, failed=True)
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def _receive(self, key: tuple[str, int]) -> None:
|
|
171
|
+
"""Read at most one byte beyond the budget to detect streamed overflow."""
|
|
172
|
+
if not self._check_header(key):
|
|
173
|
+
return
|
|
174
|
+
pending = self.request_by_key[key]
|
|
175
|
+
pending.data.extend(bytes(pending.reply.read(AVATAR_RESPONSE_LIMIT_BYTES + 1 - len(pending.data))))
|
|
176
|
+
if len(pending.data) > AVATAR_RESPONSE_LIMIT_BYTES:
|
|
177
|
+
self._finish(key, failed=True)
|
|
178
|
+
|
|
179
|
+
def _finish(self, key: tuple[str, int], failed: bool = False) -> None:
|
|
180
|
+
"""Validate trusted origin and bounded raster data, then release before notifying UI."""
|
|
181
|
+
pending = self.request_by_key.get(key)
|
|
182
|
+
if pending is None:
|
|
183
|
+
return
|
|
184
|
+
image = QImage()
|
|
185
|
+
reply = pending.reply
|
|
186
|
+
if not failed and reply is not None:
|
|
187
|
+
self._receive(key)
|
|
188
|
+
if key not in self.request_by_key:
|
|
189
|
+
return
|
|
190
|
+
url = reply.url()
|
|
191
|
+
content_type = str(reply.header(QNetworkRequest.KnownHeaders.ContentTypeHeader) or "").split(";", maxsplit=1)[0].lower()
|
|
192
|
+
if (reply.error() == QNetworkReply.NetworkError.NoError and reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) == HTTP_OK
|
|
193
|
+
and url.scheme() == "https" and url.host() == GRAVATAR_HOST and content_type in IMAGE_CONTENT_TYPE_SET):
|
|
194
|
+
image = self._decode(pending)
|
|
195
|
+
self._release(key, abort=failed)
|
|
196
|
+
self.cache_by_key[key] = (self.clock() + AVATAR_CACHE_TTL_SECONDS, image)
|
|
197
|
+
self.cache_bytes += image.sizeInBytes()
|
|
198
|
+
while len(self.cache_by_key) > AVATAR_CACHE_ENTRY_LIMIT or self.cache_bytes > AVATAR_CACHE_LIMIT_BYTES:
|
|
199
|
+
self._evict(next(iter(self.cache_by_key)))
|
|
200
|
+
for identity, ratio in pending.ratio_by_identity.items():
|
|
201
|
+
self._publish(identity, image, ratio)
|
|
202
|
+
self._pump()
|
|
203
|
+
|
|
204
|
+
def _decode(self, pending: PhotoRequest) -> QImage:
|
|
205
|
+
"""Inspect raster dimensions before allocation and scale to the requested density."""
|
|
206
|
+
buffer = QBuffer()
|
|
207
|
+
buffer.setData(QByteArray(pending.data))
|
|
208
|
+
buffer.open(QIODevice.OpenModeFlag.ReadOnly)
|
|
209
|
+
reader = QImageReader(buffer)
|
|
210
|
+
size = reader.size()
|
|
211
|
+
if bytes(reader.format()).lower() not in RASTER_FORMAT_SET or not size.isValid() or size.isEmpty() or max(size.width(), size.height()) > AVATAR_DECODED_SIDE_LIMIT:
|
|
212
|
+
return QImage()
|
|
213
|
+
image = reader.read()
|
|
214
|
+
if image.isNull() or max(image.width(), image.height()) > AVATAR_DECODED_SIDE_LIMIT:
|
|
215
|
+
return QImage()
|
|
216
|
+
return image.scaled(pending.side, pending.side, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
217
|
+
|
|
218
|
+
def _publish(self, identity: object, image: QImage, ratio: float) -> None:
|
|
219
|
+
if image.isNull():
|
|
220
|
+
return
|
|
221
|
+
rendered = QImage(image)
|
|
222
|
+
rendered.setDevicePixelRatio(ratio)
|
|
223
|
+
self.image_ready.emit(identity, rendered)
|
|
224
|
+
|
|
225
|
+
def _evict(self, key: tuple[str, int]) -> None:
|
|
226
|
+
_expiry, image = self.cache_by_key.pop(key)
|
|
227
|
+
self.cache_bytes -= image.sizeInBytes()
|
|
228
|
+
|
|
229
|
+
def _release(self, key: tuple[str, int], abort: bool) -> None:
|
|
230
|
+
"""Disconnect callbacks before abort/deleteLater so late signals cannot touch released work."""
|
|
231
|
+
pending = self.request_by_key.pop(key)
|
|
232
|
+
if pending.timer is not None:
|
|
233
|
+
pending.timer.stop()
|
|
234
|
+
pending.timer.timeout.disconnect()
|
|
235
|
+
pending.timer.deleteLater()
|
|
236
|
+
if pending.reply is not None:
|
|
237
|
+
for signal in (pending.reply.readyRead, pending.reply.metaDataChanged, pending.reply.sslErrors, pending.reply.finished):
|
|
238
|
+
signal.disconnect()
|
|
239
|
+
if abort:
|
|
240
|
+
pending.reply.abort()
|
|
241
|
+
pending.reply.deleteLater()
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Follow historical file identities without changing repository navigation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict, deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from time import monotonic
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
12
|
+
|
|
13
|
+
from gitpane.config import FILE_HISTORY_ACTIVE_COMPARISON_LIMIT, FILE_HISTORY_BATCH_SIZE, FILE_HISTORY_CACHE_ENTRY_LIMIT, FILE_HISTORY_COMPARISON_LIMIT, FILE_HISTORY_TURN_BUDGET_MS
|
|
14
|
+
from gitpane.history.repository_value import FilePanel, FileReadIdentity
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gitpane.history.repository import GitRepository
|
|
18
|
+
from gitpane.history.repository_value import Commit, ComparisonGroup, HistorySnapshot, RepositoryFailure, RequestIdentity, RevisionEntry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WindowCapability(Enum):
|
|
22
|
+
"""Name actions whose availability differs between reading and management windows."""
|
|
23
|
+
|
|
24
|
+
CONSULT = "consult"
|
|
25
|
+
MANAGE = "manage"
|
|
26
|
+
FILE_MENU = "file_menu"
|
|
27
|
+
RESTORE = "restore"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
WINDOW_CAPABILITY_BY_MODE = {
|
|
31
|
+
"repository": frozenset(WindowCapability),
|
|
32
|
+
"file_history": frozenset({WindowCapability.CONSULT}),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class FileHistoryAnchor:
|
|
38
|
+
"""Identify an existing side of a historical entry, including its exact object."""
|
|
39
|
+
|
|
40
|
+
commit_oid: str
|
|
41
|
+
path: bytes
|
|
42
|
+
blob_oid: str
|
|
43
|
+
side: str = "after"
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_entry(cls, entry: RevisionEntry) -> FileHistoryAnchor:
|
|
47
|
+
"""Anchor deletions in their parent so the removed identity remains reachable."""
|
|
48
|
+
if entry.new_path is not None and entry.new_blob_oid is not None:
|
|
49
|
+
return cls(entry.commit_oid, entry.new_path, entry.new_blob_oid)
|
|
50
|
+
if entry.parent_oid is not None and entry.old_path is not None and entry.old_blob_oid is not None:
|
|
51
|
+
return cls(entry.parent_oid, entry.old_path, entry.old_blob_oid, "before")
|
|
52
|
+
raise ValueError("The selected entry has no historical file")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class FileHistoryResult:
|
|
57
|
+
"""Keep file membership, historical names and drawing ancestry independent."""
|
|
58
|
+
|
|
59
|
+
touching_oid_set: set[str]
|
|
60
|
+
path_by_oid: dict[str, tuple[bytes, ...]]
|
|
61
|
+
visible_parent_oids_by_oid: dict[str, list[str]]
|
|
62
|
+
complete: bool
|
|
63
|
+
comparison_count: int = 0
|
|
64
|
+
error: str | None = None
|
|
65
|
+
|
|
66
|
+
def select_entry(self, commit: Commit, groups: list[ComparisonGroup]) -> RevisionEntry | None:
|
|
67
|
+
"""Find the tracked revision in true parent order, using the old side for deletions."""
|
|
68
|
+
paths = self.path_by_oid.get(commit.oid, ())
|
|
69
|
+
for parent_oid in commit.parent_oids or [None]:
|
|
70
|
+
for group in groups:
|
|
71
|
+
if group.parent_oid != parent_oid:
|
|
72
|
+
continue
|
|
73
|
+
for entry in group.entries:
|
|
74
|
+
path = entry.new_path if entry.new_path is not None else entry.old_path
|
|
75
|
+
if path in paths:
|
|
76
|
+
return entry
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def visible_parent_map(snapshot: HistorySnapshot, visible_oid_set: set[str]) -> dict[str, list[str]]:
|
|
81
|
+
"""Connect the first visible ancestors on each real parent branch, preserving order."""
|
|
82
|
+
nearest_oids_by_oid: dict[str, list[str]] = {}
|
|
83
|
+
result_by_oid = {}
|
|
84
|
+
for oid in snapshot.ordered_oids:
|
|
85
|
+
if oid not in visible_oid_set:
|
|
86
|
+
continue
|
|
87
|
+
pending = [(oid, False)]
|
|
88
|
+
while pending:
|
|
89
|
+
current_oid, expanded = pending.pop()
|
|
90
|
+
if current_oid in nearest_oids_by_oid:
|
|
91
|
+
continue
|
|
92
|
+
parents = snapshot.parent_oids_by_oid.get(current_oid, [])
|
|
93
|
+
if not expanded:
|
|
94
|
+
pending.append((current_oid, True))
|
|
95
|
+
for parent_oid in reversed(parents):
|
|
96
|
+
if parent_oid not in visible_oid_set and parent_oid not in nearest_oids_by_oid:
|
|
97
|
+
pending.append((parent_oid, False))
|
|
98
|
+
continue
|
|
99
|
+
ancestor_oids = []
|
|
100
|
+
ancestor_oid_set: set[str] = set()
|
|
101
|
+
for parent_oid in parents:
|
|
102
|
+
candidates = [parent_oid] if parent_oid in visible_oid_set else nearest_oids_by_oid.get(parent_oid, [])
|
|
103
|
+
for candidate in candidates:
|
|
104
|
+
if candidate not in ancestor_oid_set:
|
|
105
|
+
ancestor_oid_set.add(candidate)
|
|
106
|
+
ancestor_oids.append(candidate)
|
|
107
|
+
nearest_oids_by_oid[current_oid] = ancestor_oids
|
|
108
|
+
result_by_oid[oid] = nearest_oids_by_oid[oid]
|
|
109
|
+
return result_by_oid
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class FileHistoryLimits:
|
|
114
|
+
"""Bound comparison concurrency, retained edges and work between event-loop turns."""
|
|
115
|
+
|
|
116
|
+
active_comparison_limit: int = FILE_HISTORY_ACTIVE_COMPARISON_LIMIT
|
|
117
|
+
cache_entry_limit: int = FILE_HISTORY_CACHE_ENTRY_LIMIT
|
|
118
|
+
comparison_limit: int = FILE_HISTORY_COMPARISON_LIMIT
|
|
119
|
+
turn_budget_ms: int = FILE_HISTORY_TURN_BUDGET_MS
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
"""Reject unusable limits before starting a calculation."""
|
|
123
|
+
if min(self.active_comparison_limit, self.cache_entry_limit, self.comparison_limit, self.turn_budget_ms) < 1:
|
|
124
|
+
raise ValueError("File history limits must be positive")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class FileHistoryEngine(QObject):
|
|
128
|
+
"""Read immutable comparisons asynchronously and follow identities along both directions."""
|
|
129
|
+
|
|
130
|
+
finished = Signal(object)
|
|
131
|
+
progressed = Signal(int, int)
|
|
132
|
+
|
|
133
|
+
def __init__(self, repository: GitRepository, limits: FileHistoryLimits | None = None) -> None:
|
|
134
|
+
"""Use the repository's bounded process runner for all historical reads."""
|
|
135
|
+
super().__init__()
|
|
136
|
+
self.repository = repository
|
|
137
|
+
self.limits = limits or FileHistoryLimits()
|
|
138
|
+
self.generation = 0
|
|
139
|
+
self.identity: RequestIdentity | None = None
|
|
140
|
+
self.snapshot: HistorySnapshot | None = None
|
|
141
|
+
self.entry_by_cache_key: OrderedDict[tuple[str, int, int, bytes, str | None, str], list[RevisionEntry]] = OrderedDict()
|
|
142
|
+
self.cache_entry_count = 0
|
|
143
|
+
self.comparison_count = 0
|
|
144
|
+
self.active_comparison_count = 0
|
|
145
|
+
self.round_comparison_count = 0
|
|
146
|
+
self.batch_size_limit = FILE_HISTORY_BATCH_SIZE
|
|
147
|
+
self.active_batch_size = 0
|
|
148
|
+
self.states: deque[tuple[str, bytes, str]] = deque()
|
|
149
|
+
self.edges: deque[tuple[str, bytes, str, str | None, str, bool]] = deque()
|
|
150
|
+
self.seen_state_set: set[tuple[str, bytes, str]] = set()
|
|
151
|
+
self.path_set_by_oid: dict[str, set[bytes]] = {}
|
|
152
|
+
self.touching_oid_set: set[str] = set()
|
|
153
|
+
self.child_oids_by_oid: dict[str, list[str]] = {}
|
|
154
|
+
self.error: str | None = None
|
|
155
|
+
self.paused = False
|
|
156
|
+
self.closed = False
|
|
157
|
+
self.timer = QTimer(self)
|
|
158
|
+
self.timer.setSingleShot(True)
|
|
159
|
+
self.timer.timeout.connect(self._advance)
|
|
160
|
+
self.repository.file_failed.connect(self._failed)
|
|
161
|
+
|
|
162
|
+
def cancel(self) -> None:
|
|
163
|
+
"""Invalidate callbacks before cancelling this engine's file comparisons."""
|
|
164
|
+
self.generation += 1
|
|
165
|
+
self.timer.stop()
|
|
166
|
+
self.active_comparison_count = 0
|
|
167
|
+
self.active_batch_size = 0
|
|
168
|
+
self.paused = False
|
|
169
|
+
if self.identity is not None:
|
|
170
|
+
self.repository.cancel_file(self.identity.session_id, FilePanel.FILE_HISTORY)
|
|
171
|
+
|
|
172
|
+
def calculate(self, snapshot: HistorySnapshot, anchor: FileHistoryAnchor, identity: RequestIdentity) -> None:
|
|
173
|
+
"""Walk every captured branch, including descendants newer than the opening commit."""
|
|
174
|
+
if self.closed:
|
|
175
|
+
return
|
|
176
|
+
self.cancel()
|
|
177
|
+
self.identity = identity
|
|
178
|
+
self.snapshot = snapshot
|
|
179
|
+
self.comparison_count = 0
|
|
180
|
+
self.round_comparison_count = 0
|
|
181
|
+
self.batch_size_limit = FILE_HISTORY_BATCH_SIZE
|
|
182
|
+
self.states = deque([(anchor.commit_oid, anchor.path, anchor.blob_oid)])
|
|
183
|
+
self.edges.clear()
|
|
184
|
+
self.seen_state_set.clear()
|
|
185
|
+
self.path_set_by_oid.clear()
|
|
186
|
+
self.touching_oid_set.clear()
|
|
187
|
+
self.child_oids_by_oid.clear()
|
|
188
|
+
for child_oid, parent_oids in snapshot.parent_oids_by_oid.items():
|
|
189
|
+
for parent_oid in parent_oids:
|
|
190
|
+
self.child_oids_by_oid.setdefault(parent_oid, []).append(child_oid)
|
|
191
|
+
self.error = snapshot.error
|
|
192
|
+
self.paused = False
|
|
193
|
+
if anchor.commit_oid not in snapshot.parent_oids_by_oid:
|
|
194
|
+
self.error = "The opening revision is outside the captured history"
|
|
195
|
+
self.timer.start(0)
|
|
196
|
+
|
|
197
|
+
def resume(self) -> None:
|
|
198
|
+
"""Continue the same captured identity after a comparison budget or read failure."""
|
|
199
|
+
if self.closed or not self.paused or self.snapshot is None or self.snapshot.error:
|
|
200
|
+
return
|
|
201
|
+
self.error = None
|
|
202
|
+
self.paused = False
|
|
203
|
+
self.round_comparison_count = 0
|
|
204
|
+
self.timer.start(0)
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
"""Disconnect the shared service so closing a window releases its calculation safely."""
|
|
208
|
+
if self.closed:
|
|
209
|
+
return
|
|
210
|
+
self.closed = True
|
|
211
|
+
self.cancel()
|
|
212
|
+
self.repository.file_failed.disconnect(self._failed)
|
|
213
|
+
self.timer.timeout.disconnect(self._advance)
|
|
214
|
+
self.identity = None
|
|
215
|
+
self.states.clear()
|
|
216
|
+
self.edges.clear()
|
|
217
|
+
self.entry_by_cache_key.clear()
|
|
218
|
+
self.cache_entry_count = 0
|
|
219
|
+
|
|
220
|
+
def _advance(self) -> None:
|
|
221
|
+
"""Follow cached identity edges cooperatively and prefetch missing neighborhoods in batches."""
|
|
222
|
+
if self.identity is None or self.snapshot is None or self.paused or self.active_comparison_count:
|
|
223
|
+
return
|
|
224
|
+
deadline = monotonic() + self.limits.turn_budget_ms / 1000
|
|
225
|
+
while monotonic() < deadline:
|
|
226
|
+
if self.error:
|
|
227
|
+
self._publish(False)
|
|
228
|
+
return
|
|
229
|
+
if self.edges:
|
|
230
|
+
edge = next(iter(self.edges))
|
|
231
|
+
_oid, path, _blob, parent_oid, child_oid, _forward = edge
|
|
232
|
+
repository_dir = self.repository.context_by_session[self.identity.session_id].repository_dir
|
|
233
|
+
key = (repository_dir, id(self.snapshot), self.snapshot.generation, path, parent_oid, child_oid)
|
|
234
|
+
entries = self.entry_by_cache_key.get(key)
|
|
235
|
+
if entries is None:
|
|
236
|
+
if self.round_comparison_count >= self.limits.comparison_limit:
|
|
237
|
+
self.error = "File history is incomplete. Continue reading to resume the captured history."
|
|
238
|
+
self._publish(False)
|
|
239
|
+
return
|
|
240
|
+
self._read_edge(key, parent_oid, child_oid)
|
|
241
|
+
return
|
|
242
|
+
self.entry_by_cache_key.move_to_end(key)
|
|
243
|
+
self.edges.popleft()
|
|
244
|
+
self._follow_edge(edge, entries)
|
|
245
|
+
continue
|
|
246
|
+
if not self.states:
|
|
247
|
+
self._publish(self.snapshot.complete and not self.snapshot.shallow)
|
|
248
|
+
return
|
|
249
|
+
oid, path, blob = self.states.popleft()
|
|
250
|
+
if (oid, path, blob) in self.seen_state_set:
|
|
251
|
+
continue
|
|
252
|
+
self.seen_state_set.add((oid, path, blob))
|
|
253
|
+
self.path_set_by_oid.setdefault(oid, set()).add(path)
|
|
254
|
+
for parent_oid in self.snapshot.parent_oids_by_oid.get(oid, []) or [None]:
|
|
255
|
+
self.edges.append((oid, path, blob, parent_oid, oid, False))
|
|
256
|
+
for child_oid in self.child_oids_by_oid.get(oid, []):
|
|
257
|
+
self.edges.append((oid, path, blob, oid, child_oid, True))
|
|
258
|
+
self.timer.start(0)
|
|
259
|
+
|
|
260
|
+
def _read_edge(self, key: tuple[str, int, int, bytes, str | None, str], parent_oid: str | None, child_oid: str) -> None:
|
|
261
|
+
"""Batch neighboring captured edges while bounding read work and retained cache entries."""
|
|
262
|
+
if self.active_comparison_count >= self.limits.active_comparison_limit:
|
|
263
|
+
return
|
|
264
|
+
generation = self.generation
|
|
265
|
+
repository_dir, snapshot_id, snapshot_generation, path, _parent_oid, _child_oid = key
|
|
266
|
+
prefix = (repository_dir, snapshot_id, snapshot_generation, path)
|
|
267
|
+
batch_limit = min(self.batch_size_limit, self.limits.cache_entry_limit, self.limits.comparison_limit - self.round_comparison_count)
|
|
268
|
+
comparisons = [(parent_oid, child_oid)]
|
|
269
|
+
comparison_set = {(parent_oid, child_oid)}
|
|
270
|
+
pending_oids = deque([child_oid])
|
|
271
|
+
if parent_oid is not None:
|
|
272
|
+
pending_oids.append(parent_oid)
|
|
273
|
+
visited_oid_set: set[str] = set()
|
|
274
|
+
deadline = monotonic() + self.limits.turn_budget_ms / 1000
|
|
275
|
+
while pending_oids and len(comparisons) < batch_limit and monotonic() < deadline:
|
|
276
|
+
oid = pending_oids.popleft()
|
|
277
|
+
if oid in visited_oid_set or oid not in self.snapshot.parent_oids_by_oid:
|
|
278
|
+
continue
|
|
279
|
+
visited_oid_set.add(oid)
|
|
280
|
+
neighbors = []
|
|
281
|
+
for parent in self.snapshot.parent_oids_by_oid[oid] or [None]:
|
|
282
|
+
neighbors.append((parent, oid))
|
|
283
|
+
if parent is not None:
|
|
284
|
+
pending_oids.append(parent)
|
|
285
|
+
for child in self.child_oids_by_oid.get(oid, []):
|
|
286
|
+
neighbors.append((oid, child))
|
|
287
|
+
pending_oids.append(child)
|
|
288
|
+
for comparison in neighbors:
|
|
289
|
+
if comparison in comparison_set or (*prefix, *comparison) in self.entry_by_cache_key:
|
|
290
|
+
continue
|
|
291
|
+
comparison_set.add(comparison)
|
|
292
|
+
comparisons.append(comparison)
|
|
293
|
+
if len(comparisons) == batch_limit:
|
|
294
|
+
break
|
|
295
|
+
self.active_comparison_count += 1
|
|
296
|
+
self.active_batch_size = len(comparisons)
|
|
297
|
+
self.comparison_count += len(comparisons)
|
|
298
|
+
self.round_comparison_count += len(comparisons)
|
|
299
|
+
|
|
300
|
+
def receive(entry_by_comparison: dict[tuple[str | None, str], list[RevisionEntry]]) -> None:
|
|
301
|
+
"""Cache only scoped raw metadata, with no parent descriptions or file patches."""
|
|
302
|
+
if generation != self.generation:
|
|
303
|
+
return
|
|
304
|
+
self.active_comparison_count -= 1
|
|
305
|
+
self.active_batch_size = 0
|
|
306
|
+
for comparison, entries in entry_by_comparison.items():
|
|
307
|
+
self.entry_by_cache_key[(*prefix, *comparison)] = entries
|
|
308
|
+
while len(self.entry_by_cache_key) > self.limits.cache_entry_limit:
|
|
309
|
+
self.entry_by_cache_key.popitem(last=False)
|
|
310
|
+
self.cache_entry_count = len(self.entry_by_cache_key)
|
|
311
|
+
self.progressed.emit(len(self.path_set_by_oid), len(self.snapshot.ordered_oids))
|
|
312
|
+
if generation == self.generation:
|
|
313
|
+
self.timer.start(0)
|
|
314
|
+
|
|
315
|
+
self.repository.read_history_comparisons(comparisons, path, FileReadIdentity(self.identity, FilePanel.FILE_HISTORY, generation), receive)
|
|
316
|
+
|
|
317
|
+
def _follow_edge(self, edge: tuple[str, bytes, str, str | None, str, bool], entries: list[RevisionEntry]) -> None:
|
|
318
|
+
"""Carry the actual blob across renames, stopping on an addition or deletion."""
|
|
319
|
+
_oid, path, blob, parent_oid, child_oid, forward = edge
|
|
320
|
+
opposite_oid = child_oid if forward else parent_oid
|
|
321
|
+
opposite_path, opposite_blob = path, blob
|
|
322
|
+
for entry in entries:
|
|
323
|
+
own_path = entry.old_path if forward else entry.new_path
|
|
324
|
+
if own_path != path:
|
|
325
|
+
continue
|
|
326
|
+
own_blob = entry.old_blob_oid if forward else entry.new_blob_oid
|
|
327
|
+
if own_blob != blob:
|
|
328
|
+
self.error = "The historical file identity does not match the captured Git object"
|
|
329
|
+
return
|
|
330
|
+
self.touching_oid_set.add(child_oid)
|
|
331
|
+
opposite_path = entry.new_path if forward else entry.old_path
|
|
332
|
+
opposite_blob = entry.new_blob_oid if forward else entry.old_blob_oid
|
|
333
|
+
if opposite_path is None:
|
|
334
|
+
self.path_set_by_oid.setdefault(child_oid, set()).add(path)
|
|
335
|
+
break
|
|
336
|
+
if opposite_oid in self.snapshot.parent_oids_by_oid and opposite_path is not None and opposite_blob is not None:
|
|
337
|
+
self.states.append((opposite_oid, opposite_path, opposite_blob))
|
|
338
|
+
|
|
339
|
+
def _publish(self, complete: bool) -> None:
|
|
340
|
+
"""Report completion separately from partial results so callers can offer a retry."""
|
|
341
|
+
self.paused = not complete
|
|
342
|
+
if not complete and self.error is None:
|
|
343
|
+
self.error = "File history is incomplete at the shallow repository boundary"
|
|
344
|
+
path_by_oid = {}
|
|
345
|
+
for oid, path_set in self.path_set_by_oid.items():
|
|
346
|
+
path_by_oid[oid] = tuple(sorted(path_set))
|
|
347
|
+
result = FileHistoryResult(set(self.touching_oid_set), path_by_oid, visible_parent_map(self.snapshot, self.touching_oid_set), complete, self.comparison_count, self.error)
|
|
348
|
+
self.finished.emit(result)
|
|
349
|
+
|
|
350
|
+
def _failed(self, identity: FileReadIdentity, failure: RepositoryFailure) -> None:
|
|
351
|
+
"""Retain failed work and shrink the next batch so output or timeout limits can recover."""
|
|
352
|
+
if identity.panel_kind != FilePanel.FILE_HISTORY or identity.request_identity != self.identity or identity.panel_generation != self.generation:
|
|
353
|
+
return
|
|
354
|
+
retry_divisor = 2
|
|
355
|
+
self.batch_size_limit = max(1, self.active_batch_size // retry_divisor)
|
|
356
|
+
self.active_batch_size = 0
|
|
357
|
+
self.active_comparison_count = 0
|
|
358
|
+
self.error = f"File history is incomplete: {failure.cause}"
|
|
359
|
+
self._publish(False)
|