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,1418 @@
|
|
|
1
|
+
"""Coordinate one shared preparation session, its draft included, per worktree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QObject, QSignalBlocker, QTimer, Signal
|
|
10
|
+
from PySide6.QtWidgets import QApplication
|
|
11
|
+
from shiboken6 import isValid
|
|
12
|
+
|
|
13
|
+
from gitpane.commit.commit_write import CommitOutcome, CommitRequest, CommitService
|
|
14
|
+
from gitpane.config import WORKTREE_REFRESH_INTERVAL_MS, ProcessPriority
|
|
15
|
+
from gitpane.history.file_history import FileHistoryAnchor
|
|
16
|
+
from gitpane.history.history_parse import parse_tree_entries
|
|
17
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
18
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
19
|
+
from gitpane.operation.repository_operation import OperationAction, OperationIntent, PauseProbe, native_rebase_capture, read_pause_probe_filenames
|
|
20
|
+
from gitpane.sequencer.merge_state import MergeBannerState, merge_message_text, read_merge_banner_state
|
|
21
|
+
from gitpane.submodule.submodule_discovery import SubmoduleService
|
|
22
|
+
from gitpane.tool.system_tool import file_location, worktree_filename
|
|
23
|
+
from gitpane.worktree.command_sequence import FileTask, GitCommand
|
|
24
|
+
from gitpane.worktree.file_menu_action import READ_ONLY_FILE_ACTION_SET, FileAction
|
|
25
|
+
from gitpane.worktree.ignore_rule import IgnoreDestination, IgnoreOutcome, IgnoreRuleService
|
|
26
|
+
from gitpane.worktree.mutation import RECOVER_CHANGES_OPERATION
|
|
27
|
+
from gitpane.worktree.partial_patch import RawLineSelection, RawPatch, read_partial
|
|
28
|
+
from gitpane.worktree.trash import TrashOutcome, TrashRequest, TrashService
|
|
29
|
+
from gitpane.worktree.worktree_snapshot import StagingSide, WorktreeKey, local_history_path, passive_observation_source
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Callable, Generator
|
|
33
|
+
|
|
34
|
+
from PySide6.QtWidgets import QWidget
|
|
35
|
+
|
|
36
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
37
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
38
|
+
from gitpane.sequencer.merge_state import MergeInProgress
|
|
39
|
+
from gitpane.sequencer.rebase import RebaseService
|
|
40
|
+
from gitpane.sequencer.rebase_state import NativeMetadata, RebaseSnapshot
|
|
41
|
+
from gitpane.submodule.submodule_discovery import SubmoduleSummary
|
|
42
|
+
from gitpane.worktree.mutation import MutationOutcome, MutationService, PreparedLineReset
|
|
43
|
+
from gitpane.worktree.staged_reset import ResetCandidate
|
|
44
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
45
|
+
from gitpane.worktree.worktree_snapshot import LocalEntry, PassiveObservationSource, WorktreeProbe, WorktreeSnapshot
|
|
46
|
+
|
|
47
|
+
# The staging view explains these actions together; the intention it captures for them is never reserved.
|
|
48
|
+
STAGING_AVAILABILITY_OPERATION_ID = "staging-availability"
|
|
49
|
+
STAGING_AVAILABILITY_ACTIONS: tuple[OperationAction, ...] = (OperationAction.COMMIT, OperationAction.AMEND, OperationAction.COMMIT_PUSH,
|
|
50
|
+
OperationAction.STAGE, OperationAction.UNSTAGE)
|
|
51
|
+
# Diagnostics that describe the present state of the worktree rather than the result of an action: they stay shown until
|
|
52
|
+
# a successful read proves them over, whereas a result is forgotten once the user closes the window that showed it.
|
|
53
|
+
READ_CHANGES_OPERATION = "Read changes"
|
|
54
|
+
STATE_DIAGNOSTIC_OPERATION_SET = frozenset({READ_CHANGES_OPERATION, RECOVER_CHANGES_OPERATION})
|
|
55
|
+
# Said when the changes shown moved while a file's history anchor was read and that anchor no longer matches them.
|
|
56
|
+
HISTORY_STATE_CHANGED_CAUSE = "Changes were updated while resolving file history; use File history again"
|
|
57
|
+
# Side accepted by default for a file action: only Unstaged entries may be staged, stop being tracked or show their history.
|
|
58
|
+
UNSTAGED_SIDE_SET = frozenset((StagingSide.UNSTAGED,))
|
|
59
|
+
# Both lists of Prepare changes.
|
|
60
|
+
LISTED_SIDE_SET = frozenset((StagingSide.UNSTAGED, StagingSide.STAGED))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def head_moved_between(previous: WorktreeSnapshot | None, current: WorktreeSnapshot | None) -> bool:
|
|
64
|
+
"""Tell whether two successive worktree reads name another HEAD or another branch; a first read, or one without status facts, tells nothing."""
|
|
65
|
+
if previous is None or current is None or previous.probe is None or current.probe is None:
|
|
66
|
+
return False
|
|
67
|
+
return previous.probe.head_oid != current.probe.head_oid or previous.probe.branch_head != current.probe.branch_head
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StagingSession(QObject):
|
|
71
|
+
"""Keep writes alive independently of windows while coalescing background refresh."""
|
|
72
|
+
|
|
73
|
+
changed = Signal()
|
|
74
|
+
# The draft has a signal of its own: the subscribers of changed read the worktree state again and start Git
|
|
75
|
+
# observations, which a keystroke never justifies. The text itself never travels in the signal.
|
|
76
|
+
draft_changed = Signal()
|
|
77
|
+
pause_observation_requested = Signal()
|
|
78
|
+
# A worktree read found HEAD, or the branch it is on, other than the read before it. Only a read says so: a state an
|
|
79
|
+
# operation of GitPane publishes after writing never does, since that operation refreshes its windows itself.
|
|
80
|
+
head_moved = Signal()
|
|
81
|
+
# A click on Commit or on Commit & push has just captured this CommitRequest, before the commit service saw it:
|
|
82
|
+
# whoever times the commit starts its clock here. Commit & push may still drop the request while it reads where to
|
|
83
|
+
# push, before submitting it; then the commit service never sees it.
|
|
84
|
+
commit_submitted = Signal(object)
|
|
85
|
+
commit_finished = Signal(object)
|
|
86
|
+
history_requested = Signal(object)
|
|
87
|
+
folder_requested = Signal(object)
|
|
88
|
+
text_editor_requested = Signal(object)
|
|
89
|
+
window_opened = Signal(object)
|
|
90
|
+
commit_push_requested = Signal(object, object)
|
|
91
|
+
|
|
92
|
+
def __init__(self, context: RepositoryContext, git_filename: str, worktree_service: WorktreeService, mutation: MutationService, parent: QObject | None = None, *,
|
|
93
|
+
rebase_service: RebaseService | None = None, window_factory: Callable[[RepositoryContext], QWidget]) -> None:
|
|
94
|
+
"""Associate the shared draft and last result with exactly one real worktree.
|
|
95
|
+
|
|
96
|
+
``rebase_service`` is the application's one rebase observer; an Amend of a paused rebase reads the pause through
|
|
97
|
+
it, and is refused without it. ``window_factory`` builds the Prepare changes window for this worktree the first
|
|
98
|
+
time the session opens; the window class belongs to the interface, which the domain does not import.
|
|
99
|
+
"""
|
|
100
|
+
super().__init__(parent)
|
|
101
|
+
self.context = context
|
|
102
|
+
self.rebase_service = rebase_service
|
|
103
|
+
self.window_factory = window_factory
|
|
104
|
+
self.key = WorktreeKey.from_context(context)
|
|
105
|
+
self.git_filename = git_filename
|
|
106
|
+
self.worktree_service = worktree_service
|
|
107
|
+
self.submodule_service = SubmoduleService(worktree_service)
|
|
108
|
+
self.mutation = mutation
|
|
109
|
+
self.commit_service = CommitService(worktree_service.runner, worktree_service, mutation, self)
|
|
110
|
+
self.commit_service.finished.connect(self._commit_finished)
|
|
111
|
+
self.commit_service.waiting.connect(self._commit_waiting)
|
|
112
|
+
self.last_commit_outcome: CommitOutcome | None = None
|
|
113
|
+
self.active_commit_request: CommitRequest | None = None
|
|
114
|
+
self.active_commit_action: OperationAction | None = None
|
|
115
|
+
self.commit_push_session_id: str | None = None
|
|
116
|
+
self.pending_commit_push_request: CommitRequest | None = None
|
|
117
|
+
self.trash_service = TrashService(worktree_service, mutation, parent=self)
|
|
118
|
+
self.trash_service.finished.connect(self._trash_finished)
|
|
119
|
+
self.trash_service.waiting.connect(self._trash_waiting)
|
|
120
|
+
self.last_trash_outcome: TrashOutcome | None = None
|
|
121
|
+
self.ignore_service = IgnoreRuleService(worktree_service, mutation, self)
|
|
122
|
+
self.ignore_service.finished.connect(self._ignore_finished)
|
|
123
|
+
self.last_ignore_outcome: IgnoreOutcome | None = None
|
|
124
|
+
self.snapshot: WorktreeSnapshot | None = None
|
|
125
|
+
# Annotated as the QWidget it is: its class, the Prepare changes window, lives in the interface, which the domain
|
|
126
|
+
# does not import even for a type; the session only uses what that window shows and emits.
|
|
127
|
+
self.window: QWidget | None = None
|
|
128
|
+
self.draft = ""
|
|
129
|
+
self.normal_draft = ""
|
|
130
|
+
self.amend_draft = ""
|
|
131
|
+
self.amend_head_oid: str | None = None
|
|
132
|
+
self.amend = False
|
|
133
|
+
self.message_generation = 0
|
|
134
|
+
self.amend_generation = 0
|
|
135
|
+
self.amend_loading = False
|
|
136
|
+
self.pending_rebase_amend: RebaseSnapshot | None = None
|
|
137
|
+
self.pending_amend_generation = 0
|
|
138
|
+
self.pending_message_generation = 0
|
|
139
|
+
self.amend_rebase_capture: tuple[NativeMetadata | None, ...] | None = None
|
|
140
|
+
# The merge waiting for its commit, as the last read after a worktree read found it; None when there is none or
|
|
141
|
+
# it could not be read, in which case the Commit itself explains why.
|
|
142
|
+
self.merge_in_progress: MergeInProgress | None = None
|
|
143
|
+
# The merge state of the repository window's banner, read with every worktree read whether Prepare changes is
|
|
144
|
+
# open or not, and known before that read is published.
|
|
145
|
+
self.merge_banner_state = MergeBannerState()
|
|
146
|
+
# The parents of the merge whose prepared message was already offered, so it is offered once per merge: a user
|
|
147
|
+
# who empties it keeps an empty message.
|
|
148
|
+
self.merge_message_parent_oids: tuple[str, ...] = ()
|
|
149
|
+
# The text offered for the merge in progress, to take it back when the merge goes away without the user having
|
|
150
|
+
# changed it: a later ordinary Commit must not carry the message of a merge that no longer exists.
|
|
151
|
+
self.merge_message_offered = ""
|
|
152
|
+
self.generation = 0
|
|
153
|
+
self.configuration_generation = 0
|
|
154
|
+
self.selection_generation = 0
|
|
155
|
+
self.refresh_active = False
|
|
156
|
+
self.refresh_queued = False
|
|
157
|
+
# The queue level of the follow-up read asked while another read or a write was running: the most urgent of the
|
|
158
|
+
# requests it merges, and nothing when no follow-up is waiting.
|
|
159
|
+
self.queued_refresh_priority: ProcessPriority | None = None
|
|
160
|
+
self.explicit_refresh = False
|
|
161
|
+
# A window that has just started showing this worktree has no count yet. A read that finds the state this
|
|
162
|
+
# session already had tells nobody, so the next read that publishes must announce its result anyway.
|
|
163
|
+
self.announce_requested = False
|
|
164
|
+
self.probe_active = False
|
|
165
|
+
self.last_outcome: MutationOutcome | None = None
|
|
166
|
+
self.diagnostic = None
|
|
167
|
+
# The diagnostic of a Commit whose result needs attention, kept by identity: the status bar draws it as a
|
|
168
|
+
# warning only while it is the diagnostic shown, so any other diagnostic written later gets the ordinary look
|
|
169
|
+
# without every place that writes one having to say so.
|
|
170
|
+
self.warning_diagnostic: PreparedDiagnostic | None = None
|
|
171
|
+
# The diagnostics of the kept results (last file, commit, trash and ignore outcomes) the user has already read
|
|
172
|
+
# by closing the window that showed them, kept by identity: a newer result is a new object, so it counts as unread.
|
|
173
|
+
self.read_result_diagnostics: tuple[PreparedDiagnostic, ...] = ()
|
|
174
|
+
self.busy = False
|
|
175
|
+
self.line_model: RawPatch | None = None
|
|
176
|
+
# The comparison on screen, as the capture it was read from and the row it describes. Both are set only once an
|
|
177
|
+
# answer was accepted for the still-selected row, so a newer capture can keep showing it without reading it
|
|
178
|
+
# again for as long as the data it came from is unchanged.
|
|
179
|
+
self.preview_snapshot: WorktreeSnapshot | None = None
|
|
180
|
+
self.preview_entry: LocalEntry | None = None
|
|
181
|
+
# The automatic refresh only serves a window somebody is looking at, and the window activation filter is the
|
|
182
|
+
# only place that knows which window that is: it starts and stops this timer through set_window_active.
|
|
183
|
+
self.window_active: bool = False
|
|
184
|
+
# Says whether the pause state deserves a look through Git; the rebase and replay controllers mark each observation they start.
|
|
185
|
+
self.pause_probe = PauseProbe()
|
|
186
|
+
self.timer = QTimer(self)
|
|
187
|
+
self.timer.setInterval(WORKTREE_REFRESH_INTERVAL_MS)
|
|
188
|
+
self.timer.timeout.connect(self._tick)
|
|
189
|
+
mutation.finished.connect(self._finished)
|
|
190
|
+
mutation.repository_operation.context_by_key[self.key] = context
|
|
191
|
+
mutation.repository_operation.changed.connect(self._show)
|
|
192
|
+
|
|
193
|
+
def action_diagnostic(self, action: OperationAction) -> str:
|
|
194
|
+
"""Ask the shared business policy without giving the window reservation authority."""
|
|
195
|
+
intent = OperationIntent.capture(STAGING_AVAILABILITY_OPERATION_ID, self.context, self.git_filename, action=action)
|
|
196
|
+
return self.mutation.repository_operation.availability_diagnostic(intent)
|
|
197
|
+
|
|
198
|
+
def open(self) -> QWidget | None:
|
|
199
|
+
"""Reuse the live staging window and reject contexts without a working tree."""
|
|
200
|
+
if self.context.is_bare or self.context.worktree_dir is None:
|
|
201
|
+
return None
|
|
202
|
+
self._window_destroyed()
|
|
203
|
+
if self.window is None:
|
|
204
|
+
self.window = self.window_factory(self.context)
|
|
205
|
+
self.window.stage_requested.connect(self.stage)
|
|
206
|
+
self.window.commit_requested.connect(self.commit)
|
|
207
|
+
self.window.commit_push_requested.connect(self.commit_push)
|
|
208
|
+
self.window.amend_checkbox.setChecked(self.amend)
|
|
209
|
+
self.window.amend_changed.connect(self.set_amend)
|
|
210
|
+
self.window.unstage_requested.connect(self.unstage)
|
|
211
|
+
self.window.refresh_requested.connect(lambda: self.refresh(explicit=True))
|
|
212
|
+
self.window.entry_selected.connect(self.select)
|
|
213
|
+
self.window.selection_cleared.connect(self.clear_selection)
|
|
214
|
+
self.window.line_menu.stage_requested.connect(self.stage_lines)
|
|
215
|
+
self.window.line_menu.reset_requested.connect(self.reset_lines)
|
|
216
|
+
self.window.file_menu.requested.connect(self.reset_file)
|
|
217
|
+
self.window.file_menu.delete_requested.connect(self.delete_file)
|
|
218
|
+
self.window.file_menu.ignore_requested.connect(self.add_ignore)
|
|
219
|
+
self.window.file_menu.action_requested.connect(self.consult)
|
|
220
|
+
self.window.load_message(self.draft)
|
|
221
|
+
self.window.message_edit.textChanged.connect(self._draft_changed)
|
|
222
|
+
self.window.closed.connect(self._closed)
|
|
223
|
+
self.window.destroyed.connect(self._window_destroyed)
|
|
224
|
+
self.window_opened.emit(self.window)
|
|
225
|
+
self.window.show()
|
|
226
|
+
self.window.raise_()
|
|
227
|
+
self.window.activateWindow()
|
|
228
|
+
self._show()
|
|
229
|
+
self.refresh()
|
|
230
|
+
return self.window
|
|
231
|
+
|
|
232
|
+
def _draft_changed(self) -> None:
|
|
233
|
+
"""Retain exact editor text in the worktree's application-lifetime session."""
|
|
234
|
+
if self.window is not None:
|
|
235
|
+
self.draft = self.window.message_edit.toPlainText()
|
|
236
|
+
self.message_generation += 1
|
|
237
|
+
if self.amend:
|
|
238
|
+
self.amend_draft = self.draft
|
|
239
|
+
else:
|
|
240
|
+
self.normal_draft = self.draft
|
|
241
|
+
self.draft_changed.emit()
|
|
242
|
+
|
|
243
|
+
def request_rebase_amend(self, state: RebaseSnapshot) -> None:
|
|
244
|
+
"""Wait for a new worktree read before selecting the captured edit commit."""
|
|
245
|
+
if self.busy or state.key != self.key or not state.fresh or state.state != "edit":
|
|
246
|
+
return
|
|
247
|
+
self.pending_rebase_amend = state
|
|
248
|
+
self.pending_amend_generation = self.generation + 1
|
|
249
|
+
self.pending_message_generation = self.message_generation
|
|
250
|
+
self.amend_generation += 1
|
|
251
|
+
self.amend_head_oid = None
|
|
252
|
+
self.amend_loading = True
|
|
253
|
+
try:
|
|
254
|
+
self.amend_rebase_capture = native_rebase_capture(self.context)
|
|
255
|
+
except (OSError, ValueError) as error:
|
|
256
|
+
self._refuse_rebase_amend(str(error))
|
|
257
|
+
return
|
|
258
|
+
self.refresh(explicit=True)
|
|
259
|
+
|
|
260
|
+
def _refuse_rebase_amend(self, reason: str) -> None:
|
|
261
|
+
"""Invalidate only the pending amend authority while preserving the user's text."""
|
|
262
|
+
self.pending_rebase_amend = None
|
|
263
|
+
self.amend_loading = False
|
|
264
|
+
self.amend_head_oid = None
|
|
265
|
+
self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], reason).prepare()
|
|
266
|
+
self._show()
|
|
267
|
+
|
|
268
|
+
def _load_pending_amend(self, generation: int) -> None:
|
|
269
|
+
"""Reject a replaced pause and load only the requested fresh HEAD message.
|
|
270
|
+
|
|
271
|
+
The pause is read through the application's rebase service, the same observer every window uses: a read already
|
|
272
|
+
running can answer only if it started after this request, otherwise a newer read is made.
|
|
273
|
+
"""
|
|
274
|
+
previous = self.pending_rebase_amend
|
|
275
|
+
snapshot = self.snapshot
|
|
276
|
+
if previous is None or generation < self.pending_amend_generation or self.refresh_queued:
|
|
277
|
+
return
|
|
278
|
+
self.pending_rebase_amend = None
|
|
279
|
+
if self.rebase_service is None:
|
|
280
|
+
self._refuse_rebase_amend("The rebase state cannot be read here. Refresh the repository before amending.")
|
|
281
|
+
return
|
|
282
|
+
amend_generation = self.amend_generation
|
|
283
|
+
message_generation = self.pending_message_generation
|
|
284
|
+
git_filename = self.git_filename
|
|
285
|
+
|
|
286
|
+
def current() -> bool:
|
|
287
|
+
"""Answer only the Amend request still waiting in this open session, for the same Git."""
|
|
288
|
+
return isValid(self) and amend_generation == self.amend_generation and git_filename == self.git_filename
|
|
289
|
+
|
|
290
|
+
def complete(state: RebaseSnapshot) -> None:
|
|
291
|
+
"""Bind the message request to the same native edit instruction and HEAD."""
|
|
292
|
+
if not current():
|
|
293
|
+
return
|
|
294
|
+
if message_generation != self.message_generation:
|
|
295
|
+
self._refuse_rebase_amend("The message changed while refreshing. Your text is preserved; select Amend commit again to reload.")
|
|
296
|
+
return
|
|
297
|
+
if not state.fresh:
|
|
298
|
+
self._refuse_rebase_amend("The rebase state could not be read. Refresh the rebase before amending.")
|
|
299
|
+
return
|
|
300
|
+
if (state.state != "edit" or state.identity != previous.identity or state.current_step != previous.current_step or state.head_oid != previous.head_oid
|
|
301
|
+
or snapshot is None or not snapshot.fresh or snapshot.head_oid != previous.head_oid
|
|
302
|
+
or self.snapshot is None or not self.snapshot.fresh or self.snapshot.head_oid != previous.head_oid):
|
|
303
|
+
self._refuse_rebase_amend("The edit commit changed. Refresh the rebase before amending.")
|
|
304
|
+
return
|
|
305
|
+
self.set_amend(True, reload=True)
|
|
306
|
+
|
|
307
|
+
self.rebase_service.observe(self.context, git_filename, is_current=current, callback=complete)
|
|
308
|
+
|
|
309
|
+
def set_amend(self, enabled: bool, *, reload: bool = False) -> None:
|
|
310
|
+
"""Load HEAD on every new Amend selection without overwriting typing during the read."""
|
|
311
|
+
if self.busy or enabled == self.amend and not reload:
|
|
312
|
+
return
|
|
313
|
+
reason = self.action_diagnostic(OperationAction.AMEND) if enabled else ""
|
|
314
|
+
if reason:
|
|
315
|
+
if reload:
|
|
316
|
+
self.amend_loading = False
|
|
317
|
+
self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], reason).prepare()
|
|
318
|
+
if self.window is not None:
|
|
319
|
+
with QSignalBlocker(self.window.amend_checkbox):
|
|
320
|
+
self.window.amend_checkbox.setChecked(self.amend)
|
|
321
|
+
self._show()
|
|
322
|
+
return
|
|
323
|
+
self.amend = enabled
|
|
324
|
+
if not reload:
|
|
325
|
+
self.pending_rebase_amend = None
|
|
326
|
+
self.amend_rebase_capture = None
|
|
327
|
+
self.amend_generation += 1
|
|
328
|
+
generation = self.amend_generation
|
|
329
|
+
self.amend_head_oid = None
|
|
330
|
+
self.amend_loading = enabled
|
|
331
|
+
if not enabled:
|
|
332
|
+
self._set_message(self.normal_draft)
|
|
333
|
+
self._show()
|
|
334
|
+
return
|
|
335
|
+
snapshot = self.snapshot
|
|
336
|
+
if snapshot is None or not snapshot.fresh or snapshot.head_oid is None:
|
|
337
|
+
self.amend_loading = False
|
|
338
|
+
self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], "There is no current HEAD message; Refresh first").prepare()
|
|
339
|
+
self._show()
|
|
340
|
+
return
|
|
341
|
+
message_generation = self.message_generation
|
|
342
|
+
|
|
343
|
+
def complete(value: tuple[str, str] | None, error: Exception | None) -> None:
|
|
344
|
+
"""Apply only the message belonging to the still-selected mode, HEAD and editor generation."""
|
|
345
|
+
if not self.amend or generation != self.amend_generation:
|
|
346
|
+
return
|
|
347
|
+
self.amend_loading = False
|
|
348
|
+
if message_generation != self.message_generation:
|
|
349
|
+
error = ValueError("The message was edited while loading HEAD. Your text is preserved; uncheck and check Amend to reload")
|
|
350
|
+
elif self.snapshot is None or not self.snapshot.fresh or self.snapshot.head_oid != snapshot.head_oid:
|
|
351
|
+
error = ValueError("HEAD changed; Refresh and load Amend again")
|
|
352
|
+
if self.amend_rebase_capture is not None:
|
|
353
|
+
try:
|
|
354
|
+
if native_rebase_capture(self.context) != self.amend_rebase_capture:
|
|
355
|
+
error = ValueError("The native rebase changed while loading Amend. Refresh before amending.")
|
|
356
|
+
except (OSError, ValueError) as failure:
|
|
357
|
+
error = failure
|
|
358
|
+
if error is not None:
|
|
359
|
+
self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
|
|
360
|
+
self._show()
|
|
361
|
+
return
|
|
362
|
+
self.amend_head_oid, message = value
|
|
363
|
+
self.amend_draft = message
|
|
364
|
+
cause_cleared = self.diagnostic is not None
|
|
365
|
+
self.diagnostic = None
|
|
366
|
+
self._set_message(message)
|
|
367
|
+
self._show()
|
|
368
|
+
if cause_cleared:
|
|
369
|
+
# The repository window shows the cause of the session's diagnostic on its Commit button and reads it
|
|
370
|
+
# again only when the worktree state is published.
|
|
371
|
+
self.changed.emit()
|
|
372
|
+
|
|
373
|
+
self._show()
|
|
374
|
+
self.worktree_service.run(self.context, self.git_filename, self.commit_service.read_amend_message(snapshot), complete)
|
|
375
|
+
|
|
376
|
+
def _set_message(self, message: str) -> None:
|
|
377
|
+
"""Replace displayed text without turning a programmatic load into a user edit.
|
|
378
|
+
|
|
379
|
+
Publishes the replaced draft, not the worktree state.
|
|
380
|
+
"""
|
|
381
|
+
self.draft = message
|
|
382
|
+
if self.window is not None:
|
|
383
|
+
with QSignalBlocker(self.window.message_edit):
|
|
384
|
+
self.window.load_message(message)
|
|
385
|
+
self.draft_changed.emit()
|
|
386
|
+
|
|
387
|
+
def _closed(self) -> None:
|
|
388
|
+
"""Detach the closed view while preserving its draft in the shared session, and forget the result it showed.
|
|
389
|
+
|
|
390
|
+
The comparison and its line model belonged to the view, so no line action can outlive it. Closing the window
|
|
391
|
+
counts as having read the result of the last action (Commit, Amend, Move to Trash, ignore rules, staging, Reset,
|
|
392
|
+
file actions): reopened, the window shows the present state, so an old result is never taken for a new
|
|
393
|
+
problem; the result stays in the --log file. A diagnostic of the worktree state (a failed read, a recovery)
|
|
394
|
+
is still true and stays until a successful read, and nothing is forgotten while a write runs. The repository
|
|
395
|
+
window shows the cause of the session's diagnostic on its Commit button, so the state is published once when
|
|
396
|
+
a result was forgotten, and not at all otherwise.
|
|
397
|
+
"""
|
|
398
|
+
self.selection_generation += 1
|
|
399
|
+
self._forget_preview()
|
|
400
|
+
if self.amend_loading or self.pending_rebase_amend is not None:
|
|
401
|
+
self.amend_generation += 1
|
|
402
|
+
self.pending_rebase_amend = None
|
|
403
|
+
self.amend_loading = False
|
|
404
|
+
self.amend_head_oid = None
|
|
405
|
+
self.window = None
|
|
406
|
+
if self.busy:
|
|
407
|
+
return
|
|
408
|
+
self.read_result_diagnostics = tuple(self._kept_result_diagnostics())
|
|
409
|
+
if self.diagnostic is None or self.diagnostic.operation in STATE_DIAGNOSTIC_OPERATION_SET:
|
|
410
|
+
return
|
|
411
|
+
self.diagnostic = None
|
|
412
|
+
self.warning_diagnostic = None
|
|
413
|
+
self.changed.emit()
|
|
414
|
+
|
|
415
|
+
def _kept_result_diagnostics(self) -> list[PreparedDiagnostic]:
|
|
416
|
+
"""Answer the diagnostics of the results the session keeps beyond its window: the last file, commit, trash and ignore outcomes."""
|
|
417
|
+
diagnostics: list[PreparedDiagnostic] = []
|
|
418
|
+
for outcome in (self.last_outcome, self.last_commit_outcome, self.last_trash_outcome, self.last_ignore_outcome):
|
|
419
|
+
if outcome is not None and outcome.diagnostic is not None:
|
|
420
|
+
diagnostics.append(outcome.diagnostic)
|
|
421
|
+
return diagnostics
|
|
422
|
+
|
|
423
|
+
def _unread_result_kept(self) -> bool:
|
|
424
|
+
"""Tell whether a kept result has a diagnostic the user has not read yet by closing the window."""
|
|
425
|
+
for diagnostic in self._kept_result_diagnostics():
|
|
426
|
+
if not any(diagnostic is read_diagnostic for read_diagnostic in self.read_result_diagnostics):
|
|
427
|
+
return True
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
def _window_destroyed(self, *arguments: object) -> None:
|
|
431
|
+
"""Drop a native-deleted widget without discarding its session-owned draft or result."""
|
|
432
|
+
if self.window is not None and not isValid(self.window):
|
|
433
|
+
self._closed()
|
|
434
|
+
|
|
435
|
+
def commit(self) -> None:
|
|
436
|
+
"""Capture one explicit commit with the still-current HEAD loaded for Amend.
|
|
437
|
+
|
|
438
|
+
commit_submitted tells that this click sent the captured request, just before the commit service receives it,
|
|
439
|
+
so a refusal the service reports at once still comes after it.
|
|
440
|
+
"""
|
|
441
|
+
if self.busy or self.amend_loading or self.pending_rebase_amend is not None or self.snapshot is None or not self.snapshot.fresh:
|
|
442
|
+
return
|
|
443
|
+
if self.amend and (self.amend_head_oid is None or self.amend_head_oid != self.snapshot.head_oid):
|
|
444
|
+
self.diagnostic = Diagnostic("Amend", self.context.repository_dir, self.git_filename, [], "HEAD is not the loaded Amend commit; Refresh and load Amend again").prepare()
|
|
445
|
+
self._show()
|
|
446
|
+
return
|
|
447
|
+
request = CommitRequest.capture(self.snapshot, self.draft, self.amend)
|
|
448
|
+
if self.amend and self.amend_rebase_capture is not None:
|
|
449
|
+
request = replace(request, rebase_capture=self.amend_rebase_capture)
|
|
450
|
+
self.active_commit_request = request
|
|
451
|
+
self.active_commit_action = OperationAction.AMEND if request.amend else OperationAction.COMMIT
|
|
452
|
+
self.busy = True
|
|
453
|
+
self.last_commit_outcome = None
|
|
454
|
+
self.diagnostic = None
|
|
455
|
+
self.warning_diagnostic = None
|
|
456
|
+
self._show()
|
|
457
|
+
self.commit_submitted.emit(request)
|
|
458
|
+
self.commit_service.submit(request)
|
|
459
|
+
|
|
460
|
+
def commit_push(self) -> None:
|
|
461
|
+
"""Reserve the UI immediately and capture one request before asynchronous routing reads.
|
|
462
|
+
|
|
463
|
+
commit_submitted tells that this click captured the request, as for Commit, just before the routing reads start.
|
|
464
|
+
"""
|
|
465
|
+
if self.busy or self.window is None or not self.window.commit_push_button.isEnabled() or self.snapshot is None:
|
|
466
|
+
return
|
|
467
|
+
if self.amend and (self.amend_head_oid is None or self.amend_head_oid != self.snapshot.head_oid):
|
|
468
|
+
return
|
|
469
|
+
request = CommitRequest.capture(self.snapshot, self.draft, self.amend)
|
|
470
|
+
self.pending_commit_push_request = request
|
|
471
|
+
self.active_commit_request = request
|
|
472
|
+
self.active_commit_action = OperationAction.AMEND if request.amend else OperationAction.COMMIT
|
|
473
|
+
self.busy = True
|
|
474
|
+
self.last_commit_outcome = None
|
|
475
|
+
self.diagnostic = None
|
|
476
|
+
self.warning_diagnostic = None
|
|
477
|
+
self._show()
|
|
478
|
+
self.commit_submitted.emit(request)
|
|
479
|
+
self.commit_push_requested.emit(self, request)
|
|
480
|
+
|
|
481
|
+
def finish_commit_action(self, operation_id: str) -> None:
|
|
482
|
+
"""Clear local progress only when the completed request still owns its presentation."""
|
|
483
|
+
if self.active_commit_request is None or self.active_commit_request.operation_id != operation_id:
|
|
484
|
+
return
|
|
485
|
+
self.active_commit_request = None
|
|
486
|
+
self.active_commit_action = None
|
|
487
|
+
|
|
488
|
+
def _commit_finished(self, outcome: CommitOutcome) -> None:
|
|
489
|
+
"""Empty the draft once a commit exists, keep it otherwise, and close only after Git's actual result is verified.
|
|
490
|
+
|
|
491
|
+
The draft, Amend and a waiting merge are cleared as soon as the read after the write proves a new commit, even
|
|
492
|
+
when the result is a warning: committing the same message again would add a second commit. The window closes
|
|
493
|
+
on success only; a warning or a failure keeps it open, reopened if needed. When no commit was proven, the
|
|
494
|
+
status unknown included, the draft stays for another try.
|
|
495
|
+
|
|
496
|
+
A successful commit shows the complete read the commit service made after writing and does not read the
|
|
497
|
+
worktree again for it. A failure, a missing or stale read, or a refresh requested meanwhile still reads again.
|
|
498
|
+
"""
|
|
499
|
+
if self.active_commit_request is not None and self.active_commit_request.operation_id != outcome.operation_id:
|
|
500
|
+
return
|
|
501
|
+
self.finish_commit_action(outcome.operation_id)
|
|
502
|
+
self.last_commit_outcome = outcome
|
|
503
|
+
self.busy = False
|
|
504
|
+
self.diagnostic = outcome.diagnostic
|
|
505
|
+
self.warning_diagnostic = outcome.diagnostic if outcome.status == OperationStatus.PARTIAL else None
|
|
506
|
+
if outcome.after is not None:
|
|
507
|
+
self.snapshot = outcome.after.snapshot
|
|
508
|
+
if self.snapshot.git_filename != self.git_filename:
|
|
509
|
+
self.snapshot = replace(self.snapshot, fresh=False)
|
|
510
|
+
elif self.snapshot is not None:
|
|
511
|
+
self.snapshot = replace(self.snapshot, fresh=False)
|
|
512
|
+
if outcome.commit_created or outcome.status == OperationStatus.SUCCESS:
|
|
513
|
+
self.normal_draft = ""
|
|
514
|
+
self.amend_draft = ""
|
|
515
|
+
self.amend = False
|
|
516
|
+
self.amend_head_oid = None
|
|
517
|
+
# A new commit concludes any merge that was waiting; the next read says whether another one exists.
|
|
518
|
+
self.merge_in_progress = None
|
|
519
|
+
self.merge_banner_state = MergeBannerState()
|
|
520
|
+
self.merge_message_parent_oids = ()
|
|
521
|
+
self.merge_message_offered = ""
|
|
522
|
+
self._set_message("")
|
|
523
|
+
if outcome.status == OperationStatus.SUCCESS:
|
|
524
|
+
self._show()
|
|
525
|
+
if self.window is not None:
|
|
526
|
+
self.window.close()
|
|
527
|
+
self._show()
|
|
528
|
+
if outcome.status != OperationStatus.SUCCESS and self.window is None:
|
|
529
|
+
self.open()
|
|
530
|
+
self.changed.emit()
|
|
531
|
+
self.commit_finished.emit(outcome)
|
|
532
|
+
if outcome.status != OperationStatus.SUCCESS or self.snapshot is None or not self.snapshot.fresh or self.explicit_refresh or self.refresh_active:
|
|
533
|
+
self.refresh(priority=ProcessPriority.CONTINUATION)
|
|
534
|
+
else:
|
|
535
|
+
# The snapshot shown above is the complete read made after the commit: a timer or window request queued
|
|
536
|
+
# while the commit ran would read exactly the same state again.
|
|
537
|
+
self.refresh_queued = False
|
|
538
|
+
self.queued_refresh_priority = None
|
|
539
|
+
|
|
540
|
+
def _commit_waiting(self, diagnostic: object) -> None:
|
|
541
|
+
"""Keep delayed native writes visible while their reservation and draft remain owned."""
|
|
542
|
+
self.diagnostic = diagnostic
|
|
543
|
+
self._show()
|
|
544
|
+
|
|
545
|
+
def set_git_filename(self, git_filename: str) -> None:
|
|
546
|
+
"""Change future operations while preserving captured active sequences and session drafts."""
|
|
547
|
+
if self.git_filename == git_filename:
|
|
548
|
+
return
|
|
549
|
+
self.git_filename = git_filename
|
|
550
|
+
self.configuration_generation += 1
|
|
551
|
+
self.amend_generation += 1
|
|
552
|
+
self.pending_rebase_amend = None
|
|
553
|
+
self.amend_rebase_capture = None
|
|
554
|
+
self.amend_loading = False
|
|
555
|
+
self.amend_head_oid = None
|
|
556
|
+
self.invalidate_conversion()
|
|
557
|
+
|
|
558
|
+
def invalidate_conversion(self) -> None:
|
|
559
|
+
"""Discard line candidates and reread real Git state after conversion settings change."""
|
|
560
|
+
self._forget_preview()
|
|
561
|
+
self.selection_generation += 1
|
|
562
|
+
if self.snapshot is not None:
|
|
563
|
+
self.snapshot = replace(self.snapshot, fresh=False)
|
|
564
|
+
self._show()
|
|
565
|
+
self.refresh()
|
|
566
|
+
|
|
567
|
+
def set_window_active(self, active: bool) -> None:
|
|
568
|
+
"""Start or stop the automatic refresh when a window of this worktree gains or loses activation, and read once on return.
|
|
569
|
+
|
|
570
|
+
Coming back to the window is the moment the displayed state may be stale, so it is also the moment worth one read.
|
|
571
|
+
"""
|
|
572
|
+
if self.window_active == active:
|
|
573
|
+
return
|
|
574
|
+
self.window_active = active
|
|
575
|
+
if not active:
|
|
576
|
+
self.timer.stop()
|
|
577
|
+
return
|
|
578
|
+
if self.worktree_service.runner.closed:
|
|
579
|
+
return
|
|
580
|
+
self.timer.start()
|
|
581
|
+
if self.mutation.repository_operation.owns_worktree(self.key):
|
|
582
|
+
# The operation checks the repository itself and refreshes when it ends; a read taken in the middle of a
|
|
583
|
+
# write would only show a passing state.
|
|
584
|
+
return
|
|
585
|
+
if self.refresh_active:
|
|
586
|
+
# The read already in flight will publish the state; a change made while it runs shows at the next period,
|
|
587
|
+
# and the period has just been restarted.
|
|
588
|
+
return
|
|
589
|
+
self.refresh()
|
|
590
|
+
|
|
591
|
+
def _tick(self) -> None:
|
|
592
|
+
"""Read the worktree periodically for an active window, through the probe when a complete snapshot can serve as the comparison."""
|
|
593
|
+
if not self.window_active:
|
|
594
|
+
# This attribute can be written directly, so the timer must never outlive the activation it serves.
|
|
595
|
+
self.timer.stop()
|
|
596
|
+
return
|
|
597
|
+
if self.mutation.repository_operation.owns_worktree(self.key):
|
|
598
|
+
# The operation checks the repository itself and refreshes when it ends; a read taken in the middle of a
|
|
599
|
+
# write would only show a passing state.
|
|
600
|
+
return
|
|
601
|
+
if self.refresh_active or self.probe_active:
|
|
602
|
+
# The read in flight is this period's read; asking for another one would only queue a duplicate.
|
|
603
|
+
return
|
|
604
|
+
snapshot = self.snapshot
|
|
605
|
+
if (snapshot is None or not snapshot.fresh or snapshot.probe is None or snapshot.git_filename != self.git_filename or self.busy
|
|
606
|
+
or self.refresh_queued or self.explicit_refresh or self.context.is_bare or self.worktree_service.runner.closed):
|
|
607
|
+
self.refresh(priority=ProcessPriority.BACKGROUND)
|
|
608
|
+
return
|
|
609
|
+
self.probe_active = True
|
|
610
|
+
generation = self.generation
|
|
611
|
+
self.worktree_service.run(self.context, self.git_filename, self.worktree_service.probe(snapshot),
|
|
612
|
+
lambda probe, error: self._probe_finished(snapshot, generation, probe, error), priority=ProcessPriority.BACKGROUND)
|
|
613
|
+
|
|
614
|
+
def _probe_finished(self, snapshot: WorktreeSnapshot, generation: int, probe: WorktreeProbe | None, error: Exception | None) -> None:
|
|
615
|
+
"""Publish nothing while the displayed state still holds, and read everything again in every other case."""
|
|
616
|
+
if not isValid(self):
|
|
617
|
+
return
|
|
618
|
+
if not self.probe_active:
|
|
619
|
+
return
|
|
620
|
+
self.probe_active = False
|
|
621
|
+
if (generation != self.generation or self.snapshot is not snapshot or self.refresh_active or self.busy or not self.window_active
|
|
622
|
+
or self.mutation.repository_operation.owns_worktree(self.key)):
|
|
623
|
+
# A complete read has started or will start on its own: the end of an operation, or the return to the window.
|
|
624
|
+
return
|
|
625
|
+
# The probe never publishes a state itself; it only says whether one must be read again.
|
|
626
|
+
if error is not None or probe != snapshot.probe:
|
|
627
|
+
self.refresh(priority=ProcessPriority.BACKGROUND)
|
|
628
|
+
return
|
|
629
|
+
if self._pause_observation_due():
|
|
630
|
+
self.pause_observation_requested.emit()
|
|
631
|
+
self._read_merge_banner_state(generation)
|
|
632
|
+
|
|
633
|
+
def _pause_observation_due(self) -> bool:
|
|
634
|
+
"""Tell whether Git's pause state deserves a look although the worktree read has not changed.
|
|
635
|
+
|
|
636
|
+
It does when a pause file or a fact of the last status differs from what the last observation that really
|
|
637
|
+
started covered. Nothing is recorded here.
|
|
638
|
+
"""
|
|
639
|
+
return self.pause_probe.observation_due(self._displayed_worktree_probe())
|
|
640
|
+
|
|
641
|
+
def _displayed_worktree_probe(self) -> WorktreeProbe | None:
|
|
642
|
+
"""Answer the status facts of the snapshot on screen, even when an explicit read has just marked it stale."""
|
|
643
|
+
if self.snapshot is None:
|
|
644
|
+
return None
|
|
645
|
+
return self.snapshot.probe
|
|
646
|
+
|
|
647
|
+
def passive_observation_source(self) -> PassiveObservationSource | None:
|
|
648
|
+
"""Answer what the automatic refresh has just read, for the observation that makes the banner follow the pause.
|
|
649
|
+
|
|
650
|
+
Nothing is answered unless the state on screen is fresh and at rest: the observation then reads everything through
|
|
651
|
+
Git. An operation never reads this.
|
|
652
|
+
"""
|
|
653
|
+
snapshot = self.snapshot
|
|
654
|
+
if snapshot is None or not snapshot.fresh or snapshot.probe is None:
|
|
655
|
+
return None
|
|
656
|
+
if snapshot.head_oid != snapshot.probe.head_oid or snapshot.git_filename != self.git_filename:
|
|
657
|
+
return None
|
|
658
|
+
if self.refresh_active or self.refresh_queued or self.explicit_refresh or self.busy:
|
|
659
|
+
return None
|
|
660
|
+
filename_by_git_path = self.pause_probe.filename_by_git_path()
|
|
661
|
+
if filename_by_git_path is None:
|
|
662
|
+
return None
|
|
663
|
+
try:
|
|
664
|
+
return passive_observation_source(snapshot.probe, filename_by_git_path)
|
|
665
|
+
except ValueError:
|
|
666
|
+
# A status record that cannot be taken apart is not trusted: the observation reads it through Git instead.
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
def mark_pause_observed(self, observation: str) -> None:
|
|
670
|
+
"""Note that an observation of the pause state starts now.
|
|
671
|
+
|
|
672
|
+
The signature is taken before the observation reads anything, so whatever moves afterwards is looked at again.
|
|
673
|
+
"""
|
|
674
|
+
# An explicit read has just marked the snapshot stale, but its facts are still the ones the screen shows.
|
|
675
|
+
self.pause_probe.mark_observed(observation, self._displayed_worktree_probe())
|
|
676
|
+
|
|
677
|
+
def refresh(self, *, explicit: bool = False, priority: ProcessPriority = ProcessPriority.USER, announce: bool = False) -> None:
|
|
678
|
+
"""Allow one active read and at most one follow-up, without racing an index write.
|
|
679
|
+
|
|
680
|
+
The first read of the session starts by asking Git where the pause state files are. The priority is where the
|
|
681
|
+
read waits in the process queue: the user's own request by default, and always for an explicit Refresh. A
|
|
682
|
+
follow-up merging several requests waits at the most urgent of their levels, so a later background request
|
|
683
|
+
never lowers a user's.
|
|
684
|
+
|
|
685
|
+
announce is for a window that starts showing this worktree with an unknown count: the next read that publishes
|
|
686
|
+
tells the subscribers of changed about its result even when it matches the state already held. The session
|
|
687
|
+
outlives its windows, so a worktree shown again usually reads exactly what it had, and would otherwise stay
|
|
688
|
+
unknown in that window until something in it changes.
|
|
689
|
+
"""
|
|
690
|
+
if self.worktree_service.runner.closed:
|
|
691
|
+
self.timer.stop()
|
|
692
|
+
self.refresh_queued = False
|
|
693
|
+
self.queued_refresh_priority = None
|
|
694
|
+
return
|
|
695
|
+
if announce:
|
|
696
|
+
self.announce_requested = True
|
|
697
|
+
if explicit:
|
|
698
|
+
priority = ProcessPriority.USER
|
|
699
|
+
self.explicit_refresh = True
|
|
700
|
+
if self.snapshot is not None:
|
|
701
|
+
self.snapshot = replace(self.snapshot, fresh=False)
|
|
702
|
+
self._show()
|
|
703
|
+
self.changed.emit()
|
|
704
|
+
if self.busy or self.refresh_active:
|
|
705
|
+
self._queue_refresh(priority)
|
|
706
|
+
return
|
|
707
|
+
if self.context.is_bare:
|
|
708
|
+
return
|
|
709
|
+
if self.queued_refresh_priority is not None:
|
|
710
|
+
priority = min(priority, self.queued_refresh_priority)
|
|
711
|
+
# This new read already includes requests received before it started.
|
|
712
|
+
# Keep the queue only for requests that arrive while this read is active.
|
|
713
|
+
self.refresh_queued = False
|
|
714
|
+
self.queued_refresh_priority = None
|
|
715
|
+
self.generation += 1
|
|
716
|
+
generation = self.generation
|
|
717
|
+
configuration_generation = self.configuration_generation
|
|
718
|
+
self.refresh_active = True
|
|
719
|
+
|
|
720
|
+
def complete(snapshot: WorktreeSnapshot | None, error: Exception | None) -> None:
|
|
721
|
+
"""Publish only the latest requested generation and preserve failures explicitly."""
|
|
722
|
+
if generation != self.generation:
|
|
723
|
+
return
|
|
724
|
+
self.refresh_active = False
|
|
725
|
+
if configuration_generation != self.configuration_generation:
|
|
726
|
+
self._queue_refresh(priority)
|
|
727
|
+
self._start_queued_refresh()
|
|
728
|
+
return
|
|
729
|
+
if self.busy:
|
|
730
|
+
self._queue_refresh(priority)
|
|
731
|
+
return
|
|
732
|
+
explicit_finished = self.explicit_refresh and not self.refresh_queued
|
|
733
|
+
if not self.refresh_queued:
|
|
734
|
+
self.explicit_refresh = False
|
|
735
|
+
previous = self.snapshot
|
|
736
|
+
previous_cause = self.diagnostic.cause if self.diagnostic is not None else ""
|
|
737
|
+
merge_changed = False
|
|
738
|
+
if error is not None:
|
|
739
|
+
self.diagnostic = Diagnostic(READ_CHANGES_OPERATION, self.context.repository_dir, self.git_filename, [], str(error)).prepare()
|
|
740
|
+
if self.snapshot is not None:
|
|
741
|
+
self.snapshot = replace(self.snapshot, fresh=False, diagnostic=self.diagnostic)
|
|
742
|
+
else:
|
|
743
|
+
if snapshot.merge_banner_state is not None:
|
|
744
|
+
merge_changed = snapshot.merge_banner_state != self.merge_banner_state
|
|
745
|
+
self._apply_merge_banner_state(snapshot.merge_banner_state)
|
|
746
|
+
self.snapshot = snapshot
|
|
747
|
+
# Recovery can publish an outcome synchronously; queue its reread behind this callback.
|
|
748
|
+
self.refresh_active = True
|
|
749
|
+
try:
|
|
750
|
+
recovery = self.mutation.recover(self.context, snapshot.index_filename)
|
|
751
|
+
finally:
|
|
752
|
+
self.refresh_active = False
|
|
753
|
+
if recovery:
|
|
754
|
+
self.diagnostic = Diagnostic(RECOVER_CHANGES_OPERATION, self.context.repository_dir, self.git_filename, [], recovery).prepare()
|
|
755
|
+
elif (self.diagnostic is None or self.diagnostic.operation in STATE_DIAGNOSTIC_OPERATION_SET) and not self._unread_result_kept():
|
|
756
|
+
self.diagnostic = None
|
|
757
|
+
if self.explicit_refresh and self.snapshot is not None:
|
|
758
|
+
self.snapshot = replace(self.snapshot, fresh=False)
|
|
759
|
+
# Background reads must not invalidate open menus or pending history lookups when only the read number changed.
|
|
760
|
+
if previous is not None and self.snapshot is not None and replace(self.snapshot, generation=previous.generation) == previous:
|
|
761
|
+
self.snapshot = previous
|
|
762
|
+
content_changed = previous is None or self.snapshot is not previous
|
|
763
|
+
cause_changed = (self.diagnostic.cause if self.diagnostic is not None else "") != previous_cause
|
|
764
|
+
observation_due = self._pause_observation_due()
|
|
765
|
+
if content_changed:
|
|
766
|
+
self.selection_generation += 1
|
|
767
|
+
self._show()
|
|
768
|
+
if content_changed:
|
|
769
|
+
self._reconcile_selection()
|
|
770
|
+
# Subscribers read the snapshot, the diagnostic cause and the end of a read the user asked for (a Rescan
|
|
771
|
+
# waits for this signal to finish). An identical background read tells them nothing, and every signal
|
|
772
|
+
# starts Git observations again; only a window that has just started showing this worktree, with no count
|
|
773
|
+
# yet, needs to hear it.
|
|
774
|
+
announced = self.announce_requested
|
|
775
|
+
self.announce_requested = False
|
|
776
|
+
if content_changed and head_moved_between(previous, self.snapshot):
|
|
777
|
+
# Told first: the graph refresh this starts gives the windows a new generation, and a pause observation
|
|
778
|
+
# started before it by the signals below would then be dropped as out of date.
|
|
779
|
+
self.head_moved.emit()
|
|
780
|
+
if content_changed or cause_changed or merge_changed or explicit_finished or announced:
|
|
781
|
+
self.changed.emit()
|
|
782
|
+
elif observation_due:
|
|
783
|
+
self.pause_observation_requested.emit()
|
|
784
|
+
if self.refresh_queued:
|
|
785
|
+
self._start_queued_refresh()
|
|
786
|
+
else:
|
|
787
|
+
self._load_pending_amend(generation)
|
|
788
|
+
|
|
789
|
+
def start_read() -> None:
|
|
790
|
+
"""Start the worktree read of this refresh, compared with the snapshot on screen; it also reads the merge state."""
|
|
791
|
+
self.worktree_service.read(self.context, self.git_filename, generation, complete, previous=self.snapshot, priority=priority)
|
|
792
|
+
|
|
793
|
+
if self.pause_probe.filenames is not None:
|
|
794
|
+
start_read()
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
# Git locates the pause state files once per session, before the first read, so the first observation marked
|
|
798
|
+
# as started already has a complete signature to be compared with.
|
|
799
|
+
def paths_read(filenames: tuple[str, ...] | None, error: Exception | None) -> None:
|
|
800
|
+
"""Keep the located paths and go on with the read; a failure only makes every period ask for an observation."""
|
|
801
|
+
if not isValid(self):
|
|
802
|
+
return
|
|
803
|
+
if self.worktree_service.runner.closed or generation != self.generation:
|
|
804
|
+
self.refresh_active = False
|
|
805
|
+
return
|
|
806
|
+
if error is None:
|
|
807
|
+
self.pause_probe.filenames = filenames
|
|
808
|
+
start_read()
|
|
809
|
+
|
|
810
|
+
self.worktree_service.run(self.context, self.git_filename, read_pause_probe_filenames(self.context), paths_read, priority=priority)
|
|
811
|
+
|
|
812
|
+
def _apply_merge_banner_state(self, state: MergeBannerState) -> None:
|
|
813
|
+
"""Keep the merge state a read found, and for an open Prepare changes allow the merge's Commit and offer Git's message.
|
|
814
|
+
|
|
815
|
+
The message is offered once per merge, in an empty normal draft: a message the user wrote, an Amend message and
|
|
816
|
+
a write or Amend load in progress are never replaced. A merge that could not be read offers nothing: the Commit
|
|
817
|
+
then refuses it with the cause.
|
|
818
|
+
|
|
819
|
+
When the merge is gone (aborted, or concluded elsewhere), the offered message is taken back if the user did not
|
|
820
|
+
change it, whether Prepare changes is open or not, so a later ordinary Commit does not carry it. The merge already
|
|
821
|
+
offered is forgotten at the same time, Prepare changes open or not, so the same merge done again later, whose
|
|
822
|
+
merged commit has not moved, offers Git's message again.
|
|
823
|
+
"""
|
|
824
|
+
self.merge_banner_state = state
|
|
825
|
+
if state.merge is None and not state.cause:
|
|
826
|
+
self.merge_message_parent_oids = ()
|
|
827
|
+
if self.merge_message_offered and not self.busy:
|
|
828
|
+
if self.normal_draft == self.merge_message_offered:
|
|
829
|
+
self.normal_draft = ""
|
|
830
|
+
if not self.amend and not self.amend_loading:
|
|
831
|
+
self._set_message("")
|
|
832
|
+
self.merge_message_offered = ""
|
|
833
|
+
if self.window is None:
|
|
834
|
+
return
|
|
835
|
+
self.merge_in_progress = state.merge
|
|
836
|
+
if self.merge_in_progress is None:
|
|
837
|
+
self.merge_message_parent_oids = ()
|
|
838
|
+
return
|
|
839
|
+
parent_oids = self.merge_in_progress.parent_oids
|
|
840
|
+
if parent_oids == self.merge_message_parent_oids or self.amend or self.amend_loading or self.busy or self.draft:
|
|
841
|
+
return
|
|
842
|
+
self.merge_message_parent_oids = parent_oids
|
|
843
|
+
text = merge_message_text(self.merge_in_progress.message)
|
|
844
|
+
if text:
|
|
845
|
+
self.normal_draft = text
|
|
846
|
+
self.merge_message_offered = text
|
|
847
|
+
self._set_message(text)
|
|
848
|
+
|
|
849
|
+
def _read_merge_banner_state(self, generation: int) -> None:
|
|
850
|
+
"""Read the merge state again on the file worker, for a pause file that moved while the worktree read did not change.
|
|
851
|
+
|
|
852
|
+
A changed state is published as a change of the session; an unchanged one, or an answer that a newer read has
|
|
853
|
+
overtaken, publishes nothing.
|
|
854
|
+
"""
|
|
855
|
+
if self.context.is_bare or self.worktree_service.runner.closed:
|
|
856
|
+
return
|
|
857
|
+
git_dir = self.context.git_dir
|
|
858
|
+
|
|
859
|
+
def sequence() -> Generator:
|
|
860
|
+
"""Read the merge files in the worker that runs the session's file reads."""
|
|
861
|
+
return (yield FileTask(lambda: read_merge_banner_state(git_dir)))
|
|
862
|
+
|
|
863
|
+
def merge_read(state: MergeBannerState | None, error: Exception | None) -> None:
|
|
864
|
+
"""Publish the merge state when it changed and no newer read has started."""
|
|
865
|
+
if not isValid(self) or error is not None or generation != self.generation or self.refresh_active or state == self.merge_banner_state:
|
|
866
|
+
return
|
|
867
|
+
self._apply_merge_banner_state(state)
|
|
868
|
+
self._show()
|
|
869
|
+
self.changed.emit()
|
|
870
|
+
|
|
871
|
+
self.worktree_service.run(self.context, self.git_filename, sequence(), merge_read, priority=ProcessPriority.BACKGROUND)
|
|
872
|
+
|
|
873
|
+
def _queue_refresh(self, priority: ProcessPriority) -> None:
|
|
874
|
+
"""Keep one follow-up read at the most urgent level of the requests it merges."""
|
|
875
|
+
self.refresh_queued = True
|
|
876
|
+
self.queued_refresh_priority = priority if self.queued_refresh_priority is None else min(self.queued_refresh_priority, priority)
|
|
877
|
+
|
|
878
|
+
def _start_queued_refresh(self) -> None:
|
|
879
|
+
"""Start the waiting follow-up read on the next turn of the event loop.
|
|
880
|
+
|
|
881
|
+
The follow-up stays queued until that read really starts: in the turn between the two reads the session is not at
|
|
882
|
+
rest, so nothing that waits for a settled state acts on the result about to be replaced.
|
|
883
|
+
"""
|
|
884
|
+
QTimer.singleShot(0, self, self._run_queued_refresh)
|
|
885
|
+
|
|
886
|
+
def _run_queued_refresh(self) -> None:
|
|
887
|
+
"""Start the follow-up read still queued, at the most urgent level of the requests it merges.
|
|
888
|
+
|
|
889
|
+
Nothing starts when the queue was emptied meanwhile: a read started since then already covers it, or the result
|
|
890
|
+
shown made it useless, or the session was closed.
|
|
891
|
+
"""
|
|
892
|
+
if not self.refresh_queued:
|
|
893
|
+
return
|
|
894
|
+
priority = self.queued_refresh_priority if self.queued_refresh_priority is not None else ProcessPriority.USER
|
|
895
|
+
self.refresh(priority=priority)
|
|
896
|
+
|
|
897
|
+
def _forget_preview(self) -> None:
|
|
898
|
+
"""Drop the identity of the comparison on screen and its line model, so nothing can keep or act on them."""
|
|
899
|
+
self.preview_snapshot = None
|
|
900
|
+
self.preview_entry = None
|
|
901
|
+
self.line_model = None
|
|
902
|
+
|
|
903
|
+
def _preview_still_valid(self, entry: LocalEntry) -> bool:
|
|
904
|
+
"""Tell whether the finished comparison on screen still describes the selected row of the snapshot now shown.
|
|
905
|
+
|
|
906
|
+
The row must be the very one that was read (side, paths, status, modes and object IDs), in a fresh capture of
|
|
907
|
+
the same worktree and Git, with the same conversion rules. The unstaged side also needs the same working bytes,
|
|
908
|
+
the staged side the same HEAD. Equal labels prove nothing: a file edited again keeps its status line. A
|
|
909
|
+
submodule is always read again, because its checkout moves without changing the parent's row.
|
|
910
|
+
"""
|
|
911
|
+
read_from, current = self.preview_snapshot, self.snapshot
|
|
912
|
+
if read_from is None or current is None or not current.fresh or self.preview_entry != entry or entry.is_gitlink:
|
|
913
|
+
return False
|
|
914
|
+
if self.line_model is not None and (self.line_model.snapshot is not read_from or self.line_model.entry != entry):
|
|
915
|
+
return False
|
|
916
|
+
if current.key != read_from.key or current.git_filename != read_from.git_filename or current.git_filename != self.git_filename:
|
|
917
|
+
return False
|
|
918
|
+
if current.conversion != read_from.conversion:
|
|
919
|
+
return False
|
|
920
|
+
if entry.side == StagingSide.STAGED:
|
|
921
|
+
return current.head_oid == read_from.head_oid
|
|
922
|
+
working = current.fingerprint_by_path.get(entry.path)
|
|
923
|
+
return working is not None and working == read_from.fingerprint_by_path.get(entry.path)
|
|
924
|
+
|
|
925
|
+
def _reconcile_selection(self) -> None:
|
|
926
|
+
"""Keep, rebind or read again the comparison of the row still selected once a new state is shown.
|
|
927
|
+
|
|
928
|
+
A finished comparison whose data is unchanged stays on screen with its text, cursor and scroll, and its line
|
|
929
|
+
model is bound to the new snapshot; line intentions captured before keep their old read number and stay
|
|
930
|
+
refused. Any other selected row is read again once. No selected row clears the comparison and its line model.
|
|
931
|
+
"""
|
|
932
|
+
if self.window is None:
|
|
933
|
+
self._forget_preview()
|
|
934
|
+
return
|
|
935
|
+
entries = self.window.selected_entries(self.window.unstaged_list) or self.window.selected_entries(self.window.staged_list)
|
|
936
|
+
if not entries:
|
|
937
|
+
self._forget_preview()
|
|
938
|
+
self.window.clear_summary()
|
|
939
|
+
self.window.diff_view.clear()
|
|
940
|
+
self.window.line_menu.bind(None)
|
|
941
|
+
return
|
|
942
|
+
entry = next(iter(entries))
|
|
943
|
+
if not self._preview_still_valid(entry):
|
|
944
|
+
self.select(entry)
|
|
945
|
+
return
|
|
946
|
+
self.preview_snapshot = self.snapshot
|
|
947
|
+
if self.line_model is not None:
|
|
948
|
+
self.line_model = replace(self.line_model, snapshot=self.snapshot, entry=entry)
|
|
949
|
+
self.window.line_menu.bind(self.line_model)
|
|
950
|
+
|
|
951
|
+
def _publish_operation_snapshot(self, snapshot: WorktreeSnapshot | None) -> tuple[bool, bool]:
|
|
952
|
+
"""Show the complete read an operation made after writing; answer whether it was shown and whether it spares another read.
|
|
953
|
+
|
|
954
|
+
A read of another worktree never becomes this session's state. A read made with another Git is shown as not up
|
|
955
|
+
to date. A missing or stale read, a refresh the user asked for, or a read already running all leave the next
|
|
956
|
+
state to a new worktree read. A shown read is followed by the selection reconciliation, which keeps, rebinds or
|
|
957
|
+
drops the comparison on screen and its line model.
|
|
958
|
+
"""
|
|
959
|
+
if snapshot is None or snapshot.key != self.key:
|
|
960
|
+
if self.snapshot is not None:
|
|
961
|
+
self.snapshot = replace(self.snapshot, fresh=False)
|
|
962
|
+
return False, False
|
|
963
|
+
shown = snapshot if snapshot.git_filename == self.git_filename else replace(snapshot, fresh=False)
|
|
964
|
+
changed = shown is not self.snapshot
|
|
965
|
+
self.snapshot = shown
|
|
966
|
+
if changed:
|
|
967
|
+
# Rows and pending previews of the previous state no longer describe the files.
|
|
968
|
+
self.selection_generation += 1
|
|
969
|
+
return changed, shown.fresh and not self.explicit_refresh and not self.refresh_active
|
|
970
|
+
|
|
971
|
+
def _show(self) -> None:
|
|
972
|
+
"""Publish current preparation and message availability to the live view."""
|
|
973
|
+
self._window_destroyed()
|
|
974
|
+
if self.window is not None:
|
|
975
|
+
with QSignalBlocker(self.window.amend_checkbox):
|
|
976
|
+
self.window.amend_checkbox.setChecked(self.amend)
|
|
977
|
+
# The capture reads the native rebase and replay files on the thread of the interface and does not depend on the action.
|
|
978
|
+
intent = OperationIntent.capture(STAGING_AVAILABILITY_OPERATION_ID, self.context, self.git_filename)
|
|
979
|
+
for action in STAGING_AVAILABILITY_ACTIONS:
|
|
980
|
+
availability = self.mutation.repository_operation.availability(replace(intent, action=action))
|
|
981
|
+
own_commit = self.active_commit_request is not None and availability.owner_operation_id == self.active_commit_request.operation_id
|
|
982
|
+
self.window.reason_by_action[action] = "" if own_commit else availability.reason
|
|
983
|
+
self.window.merge_in_progress = self.merge_in_progress is not None
|
|
984
|
+
self.window.amend_ready = not self.amend_loading and self.amend_head_oid is not None and self.snapshot is not None and self.amend_head_oid == self.snapshot.head_oid
|
|
985
|
+
self.window.show_snapshot(self.snapshot, self.busy, self.diagnostic, warning=self.diagnostic is not None and self.diagnostic is self.warning_diagnostic,
|
|
986
|
+
commit_action=self.active_commit_action)
|
|
987
|
+
if self.explicit_refresh and not self.busy:
|
|
988
|
+
self.window.status_label.setText("Reading changes…")
|
|
989
|
+
|
|
990
|
+
def select(self, entry: LocalEntry) -> None:
|
|
991
|
+
"""Read a selected comparison without allowing a late response to replace a newer one.
|
|
992
|
+
|
|
993
|
+
The comparison becomes the one on screen, with its capture and row, only when its answer is accepted.
|
|
994
|
+
"""
|
|
995
|
+
self.selection_generation += 1
|
|
996
|
+
generation = self.selection_generation
|
|
997
|
+
snapshot = self.snapshot
|
|
998
|
+
self._forget_preview()
|
|
999
|
+
if snapshot is None or entry not in snapshot.entries or self.window is None:
|
|
1000
|
+
return
|
|
1001
|
+
self.window.diff_view.clear()
|
|
1002
|
+
self.window.clear_summary()
|
|
1003
|
+
self.window.line_menu.bind(None)
|
|
1004
|
+
|
|
1005
|
+
if entry.is_gitlink:
|
|
1006
|
+
window, context = self.window, self.context
|
|
1007
|
+
self.window.show_summary_status("Reading submodule changes…")
|
|
1008
|
+
|
|
1009
|
+
def summary_complete(summary: SubmoduleSummary | None, error: Exception | None) -> None:
|
|
1010
|
+
"""Publish only to the same live repository, snapshot and selected side."""
|
|
1011
|
+
if (generation != self.selection_generation or self.window is not window or window.close_requested
|
|
1012
|
+
or self.context is not context or self.snapshot is not snapshot):
|
|
1013
|
+
return
|
|
1014
|
+
if error is not None or summary is None:
|
|
1015
|
+
window.show_summary_status("Differences could not be read. Review the details and refresh the repository.\n\n" + str(error))
|
|
1016
|
+
return
|
|
1017
|
+
if summary.side != entry.side or summary.reference.path != entry.path or summary.reference.parent_dir != context.repository_dir:
|
|
1018
|
+
return
|
|
1019
|
+
expected_source = entry.old_blob_oid if entry.old_blob_oid and entry.old_blob_oid.strip("0") else None
|
|
1020
|
+
expected_target = entry.new_blob_oid if entry.new_blob_oid and entry.new_blob_oid.strip("0") else None
|
|
1021
|
+
_fingerprint, _separator, expected_checkout = snapshot.fingerprint_by_path.get(entry.path, "").rpartition(":")
|
|
1022
|
+
if (summary.reference.parent_head_oid != snapshot.head_oid or summary.source.oid != expected_source
|
|
1023
|
+
or (entry.side == StagingSide.STAGED and summary.target.oid != expected_target)
|
|
1024
|
+
or (expected_checkout and expected_checkout != "uninitialized" and summary.reference.checkout_oid != expected_checkout)):
|
|
1025
|
+
window.show_summary_status("The submodule changed since it was read. Refresh the repository and review it before staging again.")
|
|
1026
|
+
return
|
|
1027
|
+
window.show_summary(summary, entry)
|
|
1028
|
+
|
|
1029
|
+
self.submodule_service.compare(context, snapshot.git_filename, entry, summary_complete)
|
|
1030
|
+
return
|
|
1031
|
+
|
|
1032
|
+
def complete(patch: bytes | RawPatch | None, error: Exception | None) -> None:
|
|
1033
|
+
"""Route a complete preview only to the selection that requested it."""
|
|
1034
|
+
if generation != self.selection_generation or self.window is None:
|
|
1035
|
+
return
|
|
1036
|
+
if error is not None:
|
|
1037
|
+
self.window.reader_toolbar.status_label.setText("Preview incomplete: " + str(error) + ". Whole-file staging remains available.")
|
|
1038
|
+
if entry.side == StagingSide.UNSTAGED:
|
|
1039
|
+
self.worktree_service.run(self.context, snapshot.git_filename, self.worktree_service.diff(snapshot, entry), fallback)
|
|
1040
|
+
return
|
|
1041
|
+
self.preview_snapshot = snapshot
|
|
1042
|
+
self.preview_entry = entry
|
|
1043
|
+
if isinstance(patch, RawPatch):
|
|
1044
|
+
self.line_model = patch
|
|
1045
|
+
self.window.show_comparison(patch.patch, entry)
|
|
1046
|
+
self.window.line_menu.bind(patch)
|
|
1047
|
+
else:
|
|
1048
|
+
self.window.show_comparison(patch, entry)
|
|
1049
|
+
|
|
1050
|
+
def fallback(patch: bytes | None, error: Exception | None) -> None:
|
|
1051
|
+
"""Keep whole-file previews readable when partial transformations are unavailable."""
|
|
1052
|
+
if generation == self.selection_generation and self.window is not None and error is None:
|
|
1053
|
+
self.window.show_comparison(patch, entry, complete=False)
|
|
1054
|
+
|
|
1055
|
+
sequence = read_partial(snapshot, entry) if entry.side == StagingSide.UNSTAGED and not entry.is_gitlink else self.worktree_service.diff(snapshot, entry)
|
|
1056
|
+
self.worktree_service.run(self.context, snapshot.git_filename, sequence, complete)
|
|
1057
|
+
|
|
1058
|
+
def clear_selection(self) -> None:
|
|
1059
|
+
"""Invalidate delayed previews and line actions when no visible file remains selected."""
|
|
1060
|
+
self.selection_generation += 1
|
|
1061
|
+
self._forget_preview()
|
|
1062
|
+
|
|
1063
|
+
def stage_lines(self, selection: RawLineSelection | None) -> None:
|
|
1064
|
+
"""Recheck direct calls against the active complete model before reserving a write."""
|
|
1065
|
+
if self.busy or self.snapshot is None or not self.snapshot.fresh or self.line_model is None or selection is None:
|
|
1066
|
+
return
|
|
1067
|
+
try:
|
|
1068
|
+
self.line_model.selected(selection)
|
|
1069
|
+
self.mutation._validate(self.line_model.snapshot, self.snapshot, (selection.path,))
|
|
1070
|
+
except ValueError as error:
|
|
1071
|
+
self.diagnostic = Diagnostic("Stage selected lines", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
|
|
1072
|
+
self.refresh()
|
|
1073
|
+
return
|
|
1074
|
+
self.busy = True
|
|
1075
|
+
self.last_outcome = None
|
|
1076
|
+
self._show()
|
|
1077
|
+
self.mutation.stage_lines(self.line_model, selection)
|
|
1078
|
+
|
|
1079
|
+
def reset_lines(self, selection: RawLineSelection | None) -> None:
|
|
1080
|
+
"""Apply the selected line reset directly while rejecting stale content or a closed source view."""
|
|
1081
|
+
if self.busy or self.snapshot is None or not self.snapshot.fresh or self.line_model is None or selection is None or self.window is None:
|
|
1082
|
+
return
|
|
1083
|
+
try:
|
|
1084
|
+
self.line_model.selected(selection)
|
|
1085
|
+
self.mutation._validate(self.line_model.snapshot, self.snapshot, (selection.path,))
|
|
1086
|
+
except ValueError as error:
|
|
1087
|
+
self.diagnostic = Diagnostic("Reset selected lines", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
|
|
1088
|
+
self.refresh()
|
|
1089
|
+
return
|
|
1090
|
+
window = self.window
|
|
1091
|
+
generation = self.selection_generation
|
|
1092
|
+
self.busy = True
|
|
1093
|
+
self.last_outcome = None
|
|
1094
|
+
self._show()
|
|
1095
|
+
|
|
1096
|
+
def complete(prepared: PreparedLineReset | None, error: Exception | None) -> None:
|
|
1097
|
+
"""Publish the computed candidate only while its original selection remains current."""
|
|
1098
|
+
if error is not None:
|
|
1099
|
+
self.busy = False
|
|
1100
|
+
self.diagnostic = Diagnostic("Reset selected lines", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
|
|
1101
|
+
self._show()
|
|
1102
|
+
self.refresh()
|
|
1103
|
+
return
|
|
1104
|
+
if self._confirmation_is_current(window, generation):
|
|
1105
|
+
self.mutation.reset_lines(prepared)
|
|
1106
|
+
return
|
|
1107
|
+
try:
|
|
1108
|
+
self.mutation.cancel_reset_lines(prepared)
|
|
1109
|
+
except (OSError, ValueError) as failure:
|
|
1110
|
+
self.diagnostic = Diagnostic("Cancel reset", self.context.repository_dir, self.git_filename, [], str(failure)).prepare()
|
|
1111
|
+
self._finish_cancelled_confirmation(window)
|
|
1112
|
+
|
|
1113
|
+
self.mutation.prepare_reset_lines(self.line_model, selection, complete)
|
|
1114
|
+
|
|
1115
|
+
def _confirmation_is_current(self, window: QWidget, generation: int) -> bool:
|
|
1116
|
+
"""Reject late confirmation answers after selection changes or a requested closure."""
|
|
1117
|
+
return self.window is window and generation == self.selection_generation and not window.close_requested
|
|
1118
|
+
|
|
1119
|
+
def _finish_cancelled_confirmation(self, window: QWidget) -> None:
|
|
1120
|
+
"""Release the busy UI after cancellation and honor a closure before any write starts."""
|
|
1121
|
+
self.busy = False
|
|
1122
|
+
self._show()
|
|
1123
|
+
if self.window is window and window.close_requested:
|
|
1124
|
+
window.close()
|
|
1125
|
+
self.changed.emit()
|
|
1126
|
+
self.refresh()
|
|
1127
|
+
|
|
1128
|
+
def stage(self, entries: tuple[LocalEntry, ...]) -> None:
|
|
1129
|
+
"""Validate session capability and selection before submitting an irreversible index result."""
|
|
1130
|
+
if self.busy or self.snapshot is None or not self.snapshot.fresh or self.context.is_bare:
|
|
1131
|
+
return
|
|
1132
|
+
self.busy = True
|
|
1133
|
+
self.last_outcome = None
|
|
1134
|
+
self._show()
|
|
1135
|
+
self.mutation.stage_file(self.snapshot, entries)
|
|
1136
|
+
|
|
1137
|
+
def reset_file(self, entries: tuple[LocalEntry, ...]) -> None:
|
|
1138
|
+
"""Reset captured files in their original live view, confirming only new additions."""
|
|
1139
|
+
if self.busy or self.snapshot is None or not self.snapshot.fresh or self.window is None or not entries:
|
|
1140
|
+
return
|
|
1141
|
+
if any(entry not in self.snapshot.entries for entry in entries):
|
|
1142
|
+
self.refresh()
|
|
1143
|
+
return
|
|
1144
|
+
window = self.window
|
|
1145
|
+
generation = self.selection_generation
|
|
1146
|
+
self.busy = True
|
|
1147
|
+
self.last_outcome = None
|
|
1148
|
+
self._show()
|
|
1149
|
+
|
|
1150
|
+
def complete(prepared: ResetCandidate | None, error: Exception | None) -> None:
|
|
1151
|
+
"""Recheck the source view before publication and after any new-file confirmation."""
|
|
1152
|
+
if error is not None:
|
|
1153
|
+
self.busy = False
|
|
1154
|
+
self.diagnostic = Diagnostic("Reset", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
|
|
1155
|
+
self._show()
|
|
1156
|
+
self.refresh()
|
|
1157
|
+
return
|
|
1158
|
+
needs_confirmation = prepared.snapshot.requires_file_confirmation(tuple(change.path for change in prepared.changes if change.before.exists))
|
|
1159
|
+
accepted = self._confirmation_is_current(window, generation) and (not needs_confirmation or window.file_menu.confirm(prepared))
|
|
1160
|
+
if accepted and self._confirmation_is_current(window, generation):
|
|
1161
|
+
self.mutation.reset_file(prepared)
|
|
1162
|
+
return
|
|
1163
|
+
try:
|
|
1164
|
+
self.mutation.cancel_reset_file(prepared)
|
|
1165
|
+
except (OSError, ValueError) as failure:
|
|
1166
|
+
self.diagnostic = Diagnostic("Cancel Reset", self.context.repository_dir, self.git_filename, [], str(failure)).prepare()
|
|
1167
|
+
self._finish_cancelled_confirmation(window)
|
|
1168
|
+
|
|
1169
|
+
self.mutation.prepare_reset_file(self.snapshot, entries, complete)
|
|
1170
|
+
|
|
1171
|
+
def delete_file(self, entries: tuple[LocalEntry, ...], selected_snapshot: WorktreeSnapshot | None = None) -> None:
|
|
1172
|
+
"""Move captured files to Trash directly, confirming batches that include a new addition."""
|
|
1173
|
+
if self.busy or self.snapshot is None or not self.snapshot.fresh or self.window is None or not entries:
|
|
1174
|
+
return
|
|
1175
|
+
window = self.window
|
|
1176
|
+
generation = self.selection_generation
|
|
1177
|
+
self.busy = True
|
|
1178
|
+
self.last_trash_outcome = None
|
|
1179
|
+
self._show()
|
|
1180
|
+
self.changed.emit()
|
|
1181
|
+
|
|
1182
|
+
def complete(request: TrashRequest | None, error: Exception | None) -> None:
|
|
1183
|
+
"""Require the original live view before moving files or confirming new additions."""
|
|
1184
|
+
if error is not None:
|
|
1185
|
+
self.busy = False
|
|
1186
|
+
self.diagnostic = Diagnostic("Move to Trash", self.context.repository_dir, self.git_filename, [], str(error)).prepare()
|
|
1187
|
+
self._show()
|
|
1188
|
+
self.changed.emit()
|
|
1189
|
+
return
|
|
1190
|
+
needs_confirmation = request.snapshot.requires_file_confirmation(tuple(target.path for target in request.targets))
|
|
1191
|
+
accepted = self._confirmation_is_current(window, generation) and (not needs_confirmation or window.file_menu.confirm_trash(request))
|
|
1192
|
+
if accepted and self._confirmation_is_current(window, generation):
|
|
1193
|
+
self.trash_service.submit(request)
|
|
1194
|
+
return
|
|
1195
|
+
self.trash_service.cancel(request)
|
|
1196
|
+
self._finish_cancelled_confirmation(window)
|
|
1197
|
+
|
|
1198
|
+
self.trash_service.prepare(selected_snapshot or self.snapshot, entries, complete)
|
|
1199
|
+
|
|
1200
|
+
def _trash_finished(self, outcome: TrashOutcome) -> None:
|
|
1201
|
+
"""Retain OS results beyond window closure and show the complete read the operation made after moving.
|
|
1202
|
+
|
|
1203
|
+
That read already shows the non-staged Git deletions, after a partial or refused batch too, so the worktree is
|
|
1204
|
+
read again only when the read cannot be used.
|
|
1205
|
+
"""
|
|
1206
|
+
self.last_trash_outcome = outcome
|
|
1207
|
+
self.busy = False
|
|
1208
|
+
self.diagnostic = outcome.diagnostic
|
|
1209
|
+
shown, reusable = self._publish_operation_snapshot(outcome.snapshot)
|
|
1210
|
+
if reusable:
|
|
1211
|
+
# A timer or window request queued while the files moved would read exactly the state now shown.
|
|
1212
|
+
self.refresh_queued = False
|
|
1213
|
+
self.queued_refresh_priority = None
|
|
1214
|
+
self._show()
|
|
1215
|
+
if shown:
|
|
1216
|
+
self._reconcile_selection()
|
|
1217
|
+
if outcome.status == OperationStatus.SUCCESS and self.window is not None and self.window.close_requested:
|
|
1218
|
+
self.window.close()
|
|
1219
|
+
reopened = False
|
|
1220
|
+
if outcome.status != OperationStatus.SUCCESS and self.window is None:
|
|
1221
|
+
# Opening the window starts its own worktree read.
|
|
1222
|
+
reopened = self.open() is not None
|
|
1223
|
+
self.changed.emit()
|
|
1224
|
+
if not reusable and not reopened:
|
|
1225
|
+
self.refresh(priority=ProcessPriority.CONTINUATION)
|
|
1226
|
+
|
|
1227
|
+
def add_ignore(self, entries: tuple[LocalEntry, ...], selected_snapshot: WorktreeSnapshot, destination: IgnoreDestination) -> None:
|
|
1228
|
+
"""Apply only a captured current Unstaged intention to the named rule destination."""
|
|
1229
|
+
if self.busy or self.window is None or self.window.close_requested or self.snapshot is None or not self.snapshot.fresh:
|
|
1230
|
+
return
|
|
1231
|
+
if selected_snapshot is not self.snapshot or not entries or any(entry not in self.snapshot.entries or entry.side != StagingSide.UNSTAGED for entry in entries):
|
|
1232
|
+
self.refresh()
|
|
1233
|
+
return
|
|
1234
|
+
self.busy = True
|
|
1235
|
+
self.last_ignore_outcome = None
|
|
1236
|
+
self._show()
|
|
1237
|
+
self.ignore_service.submit(selected_snapshot, entries, destination)
|
|
1238
|
+
|
|
1239
|
+
def _local_intention_is_current(self, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot, side_set: frozenset[StagingSide] = UNSTAGED_SIDE_SET) -> bool:
|
|
1240
|
+
"""Validate direct handlers as well as menu dispatch against the live worktree read.
|
|
1241
|
+
|
|
1242
|
+
By default only Unstaged entries are accepted; a read-only action passes ``LISTED_SIDE_SET`` so that an entry
|
|
1243
|
+
of either list is accepted.
|
|
1244
|
+
"""
|
|
1245
|
+
return (not self.busy and self.window is not None and not self.window.close_requested and self.snapshot is snapshot and snapshot.fresh
|
|
1246
|
+
and not self.context.is_bare and bool(entries) and all(entry in snapshot.entries and entry.side in side_set for entry in entries))
|
|
1247
|
+
|
|
1248
|
+
def stop_tracking(self, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot) -> None:
|
|
1249
|
+
"""Confirm cached removal while retaining the exact snapshot and window lifetime."""
|
|
1250
|
+
if not self._local_intention_is_current(entries, snapshot):
|
|
1251
|
+
self.refresh()
|
|
1252
|
+
return
|
|
1253
|
+
if any(entry.old_mode not in ("100644", "100755", "120000") or entry.skip_worktree or entry.conflict_stages for entry in entries):
|
|
1254
|
+
self._consultation_failed("Select tracked ordinary files to stop tracking")
|
|
1255
|
+
return
|
|
1256
|
+
window = self.window
|
|
1257
|
+
generation = self.selection_generation
|
|
1258
|
+
self.busy = True
|
|
1259
|
+
self.last_outcome = None
|
|
1260
|
+
self._show()
|
|
1261
|
+
accepted = window.file_menu.confirm_stop_tracking(entries)
|
|
1262
|
+
if not accepted or not self._confirmation_is_current(window, generation):
|
|
1263
|
+
self._finish_cancelled_confirmation(window)
|
|
1264
|
+
return
|
|
1265
|
+
self.mutation.stop_tracking(snapshot, entries)
|
|
1266
|
+
|
|
1267
|
+
def consult(self, action: FileAction, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot) -> None:
|
|
1268
|
+
"""Dispatch captured local-file commands without inventing historical object identities.
|
|
1269
|
+
|
|
1270
|
+
Show in folder and Show in text editor come from both lists, since they only open the working copy or its
|
|
1271
|
+
folder; every other action comes only from the Unstaged list.
|
|
1272
|
+
"""
|
|
1273
|
+
side_set = LISTED_SIDE_SET if action in READ_ONLY_FILE_ACTION_SET else UNSTAGED_SIDE_SET
|
|
1274
|
+
if not self._local_intention_is_current(entries, snapshot, side_set=side_set):
|
|
1275
|
+
return
|
|
1276
|
+
if action == FileAction.STAGE:
|
|
1277
|
+
self.stage(entries)
|
|
1278
|
+
return
|
|
1279
|
+
if action == FileAction.STOP_TRACKING:
|
|
1280
|
+
self.stop_tracking(entries, snapshot)
|
|
1281
|
+
return
|
|
1282
|
+
if action in (FileAction.COPY_BASENAME, FileAction.COPY_ABSOLUTE):
|
|
1283
|
+
labels: list[str] = []
|
|
1284
|
+
try:
|
|
1285
|
+
for entry in entries:
|
|
1286
|
+
filename = worktree_filename(self.context, entry.path)
|
|
1287
|
+
labels.append(os.path.basename(filename) if action == FileAction.COPY_BASENAME else filename)
|
|
1288
|
+
except ValueError as error:
|
|
1289
|
+
self._consultation_failed(str(error))
|
|
1290
|
+
return
|
|
1291
|
+
QApplication.clipboard().setText("\n".join(labels))
|
|
1292
|
+
if any("\n" in label or "\r" in label for label in labels):
|
|
1293
|
+
self._consultation_failed("Paths copied literally. A filename contains a line break, so displayed lines do not identify separate files.")
|
|
1294
|
+
return
|
|
1295
|
+
if action not in READ_ONLY_FILE_ACTION_SET and action != FileAction.HISTORY:
|
|
1296
|
+
return
|
|
1297
|
+
if len(entries) != 1:
|
|
1298
|
+
self._consultation_failed("Select one file for this action")
|
|
1299
|
+
return
|
|
1300
|
+
entry = next(iter(entries))
|
|
1301
|
+
if action in READ_ONLY_FILE_ACTION_SET:
|
|
1302
|
+
try:
|
|
1303
|
+
file_location(self.context, entry.path, allow_missing=True)
|
|
1304
|
+
except (OSError, ValueError) as error:
|
|
1305
|
+
self._consultation_failed(str(error))
|
|
1306
|
+
return
|
|
1307
|
+
signal = self.folder_requested if action == FileAction.FOLDER else self.text_editor_requested
|
|
1308
|
+
signal.emit(entry.path)
|
|
1309
|
+
return
|
|
1310
|
+
path = local_history_path(snapshot, entry)
|
|
1311
|
+
if path is None:
|
|
1312
|
+
self._consultation_failed("No file history exists at HEAD for this file")
|
|
1313
|
+
return
|
|
1314
|
+
window = self.window
|
|
1315
|
+
|
|
1316
|
+
def read_anchor() -> Generator[GitCommand, bytes, FileHistoryAnchor]:
|
|
1317
|
+
"""Resolve the tree entry of the snapshot's HEAD commit, then check once that HEAD still names that commit.
|
|
1318
|
+
|
|
1319
|
+
The tree is asked for by the commit ID, which never changes, so HEAD moving before the read cannot change
|
|
1320
|
+
what is read: the single check at the end refuses the anchor instead.
|
|
1321
|
+
"""
|
|
1322
|
+
output = yield GitCommand(["ls-tree", "-z", snapshot.head_oid, "--", os.fsdecode(path)])
|
|
1323
|
+
tree_entries = parse_tree_entries(output)
|
|
1324
|
+
historical = next((candidate for candidate in tree_entries if candidate.path == path and candidate.object_type == "blob"), None)
|
|
1325
|
+
if historical is None:
|
|
1326
|
+
raise ValueError("No file history exists at HEAD for this file")
|
|
1327
|
+
after = yield GitCommand(["rev-parse", "--verify", "--quiet", "HEAD"], accepted_codes=(0, 1))
|
|
1328
|
+
if after.strip().decode("ascii", "replace") != snapshot.head_oid:
|
|
1329
|
+
raise ValueError("HEAD changed while resolving file history; Refresh first")
|
|
1330
|
+
return FileHistoryAnchor(snapshot.head_oid, path, historical.oid)
|
|
1331
|
+
|
|
1332
|
+
def complete(anchor: FileHistoryAnchor | None, error: Exception | None) -> None:
|
|
1333
|
+
"""Open the history for the still-open requesting view when the anchor still matches the changes shown, and say why otherwise.
|
|
1334
|
+
|
|
1335
|
+
A worktree read published meanwhile replaces the snapshot without changing what the anchor names, so the
|
|
1336
|
+
anchor is compared with the changes now shown rather than with the snapshot it was read from. Only a closed
|
|
1337
|
+
view means nobody waits for the answer any more.
|
|
1338
|
+
"""
|
|
1339
|
+
if self.window is not window or window.close_requested:
|
|
1340
|
+
return
|
|
1341
|
+
if error is not None:
|
|
1342
|
+
self._consultation_failed(str(error))
|
|
1343
|
+
return
|
|
1344
|
+
if not self._history_anchor_is_current(anchor, entry.path):
|
|
1345
|
+
self._consultation_failed(HISTORY_STATE_CHANGED_CAUSE)
|
|
1346
|
+
return
|
|
1347
|
+
self.history_requested.emit(anchor)
|
|
1348
|
+
|
|
1349
|
+
self.worktree_service.run(self.context, self.git_filename, read_anchor(), complete)
|
|
1350
|
+
|
|
1351
|
+
def _history_anchor_is_current(self, anchor: FileHistoryAnchor, path: bytes) -> bool:
|
|
1352
|
+
"""Tell whether the snapshot now shown would give the same history anchor for the unstaged file at ``path``.
|
|
1353
|
+
|
|
1354
|
+
An anchor names a commit and the file's name in it, and Git never changes either object. A newer read that still
|
|
1355
|
+
has HEAD on that commit and still lists the file as unstaged under the same name at HEAD gives exactly the same
|
|
1356
|
+
anchor, whatever else it changed; the window must also still accept a request on that read.
|
|
1357
|
+
"""
|
|
1358
|
+
current = self.snapshot
|
|
1359
|
+
if current is None:
|
|
1360
|
+
return False
|
|
1361
|
+
entry = next((candidate for candidate in current.entries if candidate.side == StagingSide.UNSTAGED and candidate.path == path), None)
|
|
1362
|
+
if entry is None or not self._local_intention_is_current((entry,), current):
|
|
1363
|
+
return False
|
|
1364
|
+
return current.head_oid == anchor.commit_oid and local_history_path(current, entry) == anchor.path
|
|
1365
|
+
|
|
1366
|
+
def _consultation_failed(self, cause: str, diagnostic: PreparedDiagnostic | None = None) -> None:
|
|
1367
|
+
"""Explain an unavailable action in the existing staging status area.
|
|
1368
|
+
|
|
1369
|
+
A tool started outside Git, such as the text editor, hands over its own diagnostic so the details name that
|
|
1370
|
+
program instead of Git.
|
|
1371
|
+
"""
|
|
1372
|
+
if diagnostic is None:
|
|
1373
|
+
diagnostic = Diagnostic("Local file action", self.context.repository_dir, self.git_filename, [], cause).prepare()
|
|
1374
|
+
self.diagnostic = diagnostic
|
|
1375
|
+
self._show()
|
|
1376
|
+
|
|
1377
|
+
def _ignore_finished(self, outcome: IgnoreOutcome) -> None:
|
|
1378
|
+
"""Retain rule outcomes and reread without staging either rules or selected files."""
|
|
1379
|
+
self.last_ignore_outcome = outcome
|
|
1380
|
+
self.busy = False
|
|
1381
|
+
self.diagnostic = outcome.diagnostic
|
|
1382
|
+
self._show()
|
|
1383
|
+
if self.window is not None and self.window.close_requested:
|
|
1384
|
+
self.window.close()
|
|
1385
|
+
self.changed.emit()
|
|
1386
|
+
self.refresh(priority=ProcessPriority.CONTINUATION)
|
|
1387
|
+
|
|
1388
|
+
def _trash_waiting(self, outcome: TrashOutcome) -> None:
|
|
1389
|
+
"""Keep an outstanding native call visible without ending its mutation reservation."""
|
|
1390
|
+
self.last_trash_outcome = outcome
|
|
1391
|
+
self.diagnostic = outcome.diagnostic
|
|
1392
|
+
self._show()
|
|
1393
|
+
self.changed.emit()
|
|
1394
|
+
|
|
1395
|
+
def _finished(self, outcome: MutationOutcome) -> None:
|
|
1396
|
+
"""Publish file-operation outcomes without unlocking an unrelated active native commit."""
|
|
1397
|
+
if outcome.key != self.key:
|
|
1398
|
+
return
|
|
1399
|
+
if self.commit_service.active_by_id or self.trash_service.active_by_id or self.ignore_service.active or self.key in self.mutation.active_key_set:
|
|
1400
|
+
return
|
|
1401
|
+
self.last_outcome = outcome
|
|
1402
|
+
self.busy = False
|
|
1403
|
+
self.diagnostic = outcome.diagnostic
|
|
1404
|
+
if self.snapshot is not None:
|
|
1405
|
+
self.snapshot = replace(self.snapshot, fresh=False)
|
|
1406
|
+
self._show()
|
|
1407
|
+
if outcome.status == "success" and self.window is not None and self.window.close_requested:
|
|
1408
|
+
self.window.close()
|
|
1409
|
+
self.refresh(priority=ProcessPriority.CONTINUATION)
|
|
1410
|
+
|
|
1411
|
+
def unstage(self, entries: tuple[LocalEntry, ...]) -> None:
|
|
1412
|
+
"""Remove whole selected staged changes while retaining the session's working content."""
|
|
1413
|
+
if self.busy or self.snapshot is None or not self.snapshot.fresh or self.context.is_bare:
|
|
1414
|
+
return
|
|
1415
|
+
self.busy = True
|
|
1416
|
+
self.last_outcome = None
|
|
1417
|
+
self._show()
|
|
1418
|
+
self.mutation.unstage_file(self.snapshot, entries)
|