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,38 @@
|
|
|
1
|
+
"""Keep the operation consoles of GitPane so the ones that reported a pause can close once it is answered."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shiboken6 import isValid
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from gitpane.ui.console.operation_dialog import OperationDialog
|
|
11
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsoleRegistry:
|
|
15
|
+
"""The consoles of the reference, rebase and transport operations by operation, and the ones of each worktree that wait for the answer to a pause."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
"""Start with no console; the wiring builds the registry after the window activation filter, and the controllers that open consoles receive it."""
|
|
19
|
+
self.rebase_output_by_id: dict[str, OperationDialog] = {}
|
|
20
|
+
# Launch, Continue and Abort consoles of a rebase, cherry-pick or revert that published a pause, per worktree, so they can be settled once
|
|
21
|
+
# the user answered the pause or the operation ended instead of lingering after the pause they reported is gone.
|
|
22
|
+
self.paused_console_ids_by_key: dict[WorktreeKey, list[str]] = {}
|
|
23
|
+
self.reference_output_by_id: dict[str, OperationDialog] = {}
|
|
24
|
+
self.transport_dialog_by_id: dict[str, OperationDialog] = {}
|
|
25
|
+
|
|
26
|
+
def settle_paused_consoles(self, key: WorktreeKey) -> None:
|
|
27
|
+
"""Let the consoles that reported a pause of this worktree close by Keep dialog open now that the operation ended."""
|
|
28
|
+
for operation_id in self.paused_console_ids_by_key.pop(key, []):
|
|
29
|
+
self.settle_answered_console(operation_id)
|
|
30
|
+
|
|
31
|
+
def settle_answered_console(self, operation_id: str) -> None:
|
|
32
|
+
"""Let one console that waited for the user's answer (a paused replay, a refused push) close by Keep dialog open.
|
|
33
|
+
|
|
34
|
+
A console already dismissed by the user is simply gone.
|
|
35
|
+
"""
|
|
36
|
+
output = self.reference_output_by_id.get(operation_id) or self.rebase_output_by_id.get(operation_id) or self.transport_dialog_by_id.get(operation_id)
|
|
37
|
+
if output is not None and isValid(output):
|
|
38
|
+
output.settle_pause()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Open the emoji keyboard of GitPane where its user left it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shiboken6 import isValid
|
|
8
|
+
|
|
9
|
+
from gitpane.config import EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY
|
|
10
|
+
from gitpane.history.file_history import WindowCapability
|
|
11
|
+
from gitpane.operation.diagnostic import Diagnostic
|
|
12
|
+
from gitpane.ui.emoji.emoji_font import load_emoji_font
|
|
13
|
+
from gitpane.ui.emoji.emoji_window import EmojiWindow
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from PySide6.QtCore import QRect
|
|
17
|
+
from PySide6.QtWidgets import QApplication
|
|
18
|
+
|
|
19
|
+
from gitpane.controller.operation_policy import OperationPolicy
|
|
20
|
+
from gitpane.controller.session_registry import SessionRegistry
|
|
21
|
+
from gitpane.operation.diagnostic import DiagnosticLog
|
|
22
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EmojiWindowController:
|
|
26
|
+
"""The single floating emoji keyboard shared by every repository window, with its saved place and the one-time report of its font."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, application: QApplication, preference: PreferenceStore, diagnostic_log: DiagnosticLog | None) -> None:
|
|
29
|
+
"""Receive the application, the preferences and the lower controllers the keyboard works with; the wiring builds it after the welcome controller, and the repository windows ask it for the keyboard."""
|
|
30
|
+
self.session_registry_controller = session_registry_controller
|
|
31
|
+
self.operation_policy_controller = operation_policy_controller
|
|
32
|
+
self.application = application
|
|
33
|
+
self.preference = preference
|
|
34
|
+
self.diagnostic_log = diagnostic_log
|
|
35
|
+
self.emoji_window: EmojiWindow | None = None
|
|
36
|
+
self.emoji_font_reported = False
|
|
37
|
+
|
|
38
|
+
def open_emoji_window(self, session_id: str) -> EmojiWindow | None:
|
|
39
|
+
"""Open the single floating emoji keyboard, shared by every repository window, or bring it back.
|
|
40
|
+
|
|
41
|
+
It has no Qt parent so that it survives a repository switch in the window that asked for it, and a second
|
|
42
|
+
request, from any window, brings the same window back instead of opening another one. Without a saved geometry,
|
|
43
|
+
its first size is computed on the screen of the window that asked, where it opens.
|
|
44
|
+
"""
|
|
45
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
46
|
+
return None
|
|
47
|
+
if self.emoji_window is not None and isValid(self.emoji_window):
|
|
48
|
+
self.emoji_window.showNormal()
|
|
49
|
+
self.emoji_window.raise_()
|
|
50
|
+
self.emoji_window.activateWindow()
|
|
51
|
+
return self.emoji_window
|
|
52
|
+
window = EmojiWindow(self.application.clipboard())
|
|
53
|
+
self.emoji_window = window
|
|
54
|
+
window.geometry_saved.connect(self._save_emoji_window_geometry)
|
|
55
|
+
if not window.restore_saved_geometry(str(self.preference.read(EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY))):
|
|
56
|
+
# The keyboard is shared by every window and has no Qt parent; it opens over the window that asked for it.
|
|
57
|
+
window.placement_anchor = self.session_registry_controller.window_by_session.get(session_id)
|
|
58
|
+
window.resize_to_whole_grid(self._emoji_window_available_area(session_id))
|
|
59
|
+
window.showNormal()
|
|
60
|
+
self.report_emoji_font()
|
|
61
|
+
return window
|
|
62
|
+
|
|
63
|
+
def _emoji_window_available_area(self, session_id: str) -> QRect:
|
|
64
|
+
"""Return the usable area of the screen the keyboard opens on: that of the window that asked, else the primary screen.
|
|
65
|
+
|
|
66
|
+
The keyboard has no parent and is not shown yet, so its own screen is still the primary one whatever window asked.
|
|
67
|
+
"""
|
|
68
|
+
anchor = self.session_registry_controller.window_by_session.get(session_id)
|
|
69
|
+
screen = anchor.screen() if anchor is not None else self.application.primaryScreen()
|
|
70
|
+
return screen.availableGeometry()
|
|
71
|
+
|
|
72
|
+
def report_emoji_font(self) -> None:
|
|
73
|
+
"""Write once per process, to the --log file, why the bundled colour emoji font is not used.
|
|
74
|
+
|
|
75
|
+
The application keeps working with the system fonts, so nothing is shown on screen; only the font warning is
|
|
76
|
+
written, never a symbol or the message draft. The first symbol area that needs the font triggers the report.
|
|
77
|
+
"""
|
|
78
|
+
if self.emoji_font_reported:
|
|
79
|
+
return
|
|
80
|
+
self.emoji_font_reported = True
|
|
81
|
+
status = load_emoji_font()
|
|
82
|
+
if status.warning and self.diagnostic_log is not None:
|
|
83
|
+
self.diagnostic_log.write_line(status.warning)
|
|
84
|
+
|
|
85
|
+
def _save_emoji_window_geometry(self, value: str) -> None:
|
|
86
|
+
"""Keep the floating keyboard's place and size for its next opening, in this session or a later one.
|
|
87
|
+
|
|
88
|
+
A refused write only costs the remembered place, so it goes to the --log file and never interrupts the user.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
self.preference.update({EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY: value})
|
|
92
|
+
except (OSError, ValueError, TypeError) as error:
|
|
93
|
+
diagnostic = Diagnostic("Save emoji keyboard position", "", "", [], str(error)).prepare()
|
|
94
|
+
if self.diagnostic_log is not None:
|
|
95
|
+
self.diagnostic_log.write(diagnostic)
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Act on a file a repository window or Prepare changes shows, outside Git's history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QStandardPaths
|
|
11
|
+
from PySide6.QtWidgets import QFileDialog, QMessageBox, QPushButton
|
|
12
|
+
from shiboken6 import isValid
|
|
13
|
+
|
|
14
|
+
from gitpane.config import ProcessPriority, SharedText
|
|
15
|
+
from gitpane.history.file_history import FileHistoryAnchor, WindowCapability
|
|
16
|
+
from gitpane.history.repository_value import FilePanel, FileReadIdentity, RequestIdentity, TreeEntry, historical_selection
|
|
17
|
+
from gitpane.operation.diagnostic import Diagnostic, SensitiveTextFilter
|
|
18
|
+
from gitpane.sequencer.conflict import conflict_path_label
|
|
19
|
+
from gitpane.system.qt_folder import writable_folder
|
|
20
|
+
from gitpane.tool.difftool import DifftoolRecipe
|
|
21
|
+
from gitpane.tool.git_executable import terminal_git_filename
|
|
22
|
+
from gitpane.tool.kdiff3_install import status_bar_refusal_text
|
|
23
|
+
from gitpane.tool.system_tool import git_editor_line, git_shell_filename, text_editor_command, text_editor_unavailable_reason, uses_git_editor, worktree_filename
|
|
24
|
+
from gitpane.ui.console.error_dialog import ErrorDialog
|
|
25
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
26
|
+
from gitpane.worktree.command_sequence import GitCommand, GitCommandError
|
|
27
|
+
from gitpane.worktree.historical_restore import RESTORE_SELECTED_VERSION_TITLE
|
|
28
|
+
from gitpane.worktree.worktree_snapshot import WorktreeKey
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Generator
|
|
32
|
+
|
|
33
|
+
from gitpane.controller.history_read_controller import HistoryReadController
|
|
34
|
+
from gitpane.controller.opening_controller import OpeningController
|
|
35
|
+
from gitpane.controller.operation_policy import OperationPolicy
|
|
36
|
+
from gitpane.controller.refresh_controller import FullRefreshRead, RefreshController
|
|
37
|
+
from gitpane.controller.session_registry import SessionRegistry
|
|
38
|
+
from gitpane.controller.staging_session_registry import StagingSessionRegistry
|
|
39
|
+
from gitpane.history.repository import GitRepository
|
|
40
|
+
from gitpane.history.repository_value import RepositoryContext, RevisionEntry
|
|
41
|
+
from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
|
|
42
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
43
|
+
from gitpane.tool.difftool import DifftoolLaunch, DifftoolService
|
|
44
|
+
from gitpane.tool.system_editor_default import SystemEditorDefault
|
|
45
|
+
from gitpane.tool.system_tool import OperationResult, SystemToolService, TextEditorCommand, ToolResult
|
|
46
|
+
from gitpane.tool.terminal_recipe import TerminalDescriptor
|
|
47
|
+
from gitpane.ui.repository.repository_window import RepositoryWindow
|
|
48
|
+
from gitpane.worktree.file_action_target import FileActionSelection, FileRestoreTarget
|
|
49
|
+
from gitpane.worktree.historical_restore import HistoricalRestoreRequest, HistoricalRestoreService
|
|
50
|
+
from gitpane.worktree.mutation import MutationOutcome
|
|
51
|
+
from gitpane.worktree.worktree_service import WorktreeService
|
|
52
|
+
|
|
53
|
+
# Names of the file tools in their start requests: those of Prepare changes are told apart from those of a repository window.
|
|
54
|
+
STAGING_FOLDER_TOOL = "staging-folder"
|
|
55
|
+
STAGING_TEXT_EDITOR_TOOL = "staging-text-editor"
|
|
56
|
+
TEXT_EDITOR_TOOL = "text-editor"
|
|
57
|
+
# Tools started from Prepare changes: their request carries the Git folder, and their failures go to its status area.
|
|
58
|
+
STAGING_TOOL_SET = frozenset((STAGING_FOLDER_TOOL, STAGING_TEXT_EDITOR_TOOL))
|
|
59
|
+
RESTORE_UNCHANGED_TEXT = "'{path}' already matches the selected version. Nothing was restored."
|
|
60
|
+
RESTORE_UNCHANGED_STATUS = "The selected version is already present."
|
|
61
|
+
RESTORED_STATUS = "Selected version restored. The index and HEAD are unchanged."
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FileActionController:
|
|
65
|
+
"""The actions on one file of a repository window or of Prepare changes: external tools, file history, restoring a selected version, external comparison and Save as, each checked against the authority of the window that asks."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, refresh_controller: RefreshController,
|
|
68
|
+
history_read_controller: HistoryReadController, opening_controller: OpeningController, preference: PreferenceStore, repository: GitRepository, difftool: DifftoolService, worktree_service: WorktreeService,
|
|
69
|
+
historical_restore: HistoricalRestoreService, diagnostic_log: DiagnosticLog | None, system_tool: SystemToolService, system_editor_default: SystemEditorDefault) -> None:
|
|
70
|
+
"""Receive the services and the lower controllers the file actions work with; the wiring builds it after the emoji keyboard controller, and the repository windows and Prepare changes reach it through the wiring.
|
|
71
|
+
|
|
72
|
+
``system_editor_default`` keeps the text editor the system names, read in the background, which the editor
|
|
73
|
+
detection follows while no editor choice is saved.
|
|
74
|
+
"""
|
|
75
|
+
self.session_registry_controller = session_registry_controller
|
|
76
|
+
self.operation_policy_controller = operation_policy_controller
|
|
77
|
+
self.staging_session_registry_controller = staging_session_registry_controller
|
|
78
|
+
self.preference = preference
|
|
79
|
+
self.repository = repository
|
|
80
|
+
self.worktree_service = worktree_service
|
|
81
|
+
self.diagnostic_log = diagnostic_log
|
|
82
|
+
self.system_tool = system_tool
|
|
83
|
+
self.system_editor_default = system_editor_default
|
|
84
|
+
self.refresh_controller = refresh_controller
|
|
85
|
+
self.history_read_controller = history_read_controller
|
|
86
|
+
self.opening_controller = opening_controller
|
|
87
|
+
self.difftool = difftool
|
|
88
|
+
self.historical_restore = historical_restore
|
|
89
|
+
# The core.editor value of each repository, read once per repository window in the background, because no
|
|
90
|
+
# process may start while a file menu is being built.
|
|
91
|
+
self.git_editor_setting_by_repository_dir: dict[str, str] = {}
|
|
92
|
+
# The window that asked for each restoration of a selected version, until its result is shown.
|
|
93
|
+
self.restore_session_by_operation_id: dict[str, str] = {}
|
|
94
|
+
|
|
95
|
+
def open_explorer(self, session_id: str) -> bool:
|
|
96
|
+
"""Open the worktree only from a window allowed to invoke external tools."""
|
|
97
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
98
|
+
return False
|
|
99
|
+
self.system_tool.open_explorer((session_id, "explorer"), self.session_registry_controller.session_by_id[session_id].repository_context.repository_dir)
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def launch_terminal(self, session_id: str, descriptor: TerminalDescriptor | None) -> bool:
|
|
103
|
+
"""Reject terminal intents from limited windows even when emitted directly."""
|
|
104
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or descriptor is None:
|
|
105
|
+
return False
|
|
106
|
+
self.system_tool.launch_terminal((session_id, "terminal"), descriptor, self.session_registry_controller.session_by_id[session_id].repository_context.repository_dir)
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def _file_action_allowed(self, session_id: str, selection: FileActionSelection) -> bool:
|
|
110
|
+
"""Validate direct intentions as well as menu clicks against the live owning window."""
|
|
111
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
112
|
+
return (self.operation_policy_controller.allows(session_id, WindowCapability.FILE_MENU) and window is not None and isValid(window)
|
|
113
|
+
and window.file_action_is_current(selection))
|
|
114
|
+
|
|
115
|
+
def show_file_folder(self, session_id: str, selection: FileActionSelection) -> None:
|
|
116
|
+
"""Locate the selected historical name in the real working tree without restoring it."""
|
|
117
|
+
if not self._file_action_allowed(session_id, selection):
|
|
118
|
+
return
|
|
119
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
120
|
+
identity = FileReadIdentity(RequestIdentity(session_id, session.generation, oid=selection.commit_oid))
|
|
121
|
+
chosen = historical_selection(selection.entry, identity)
|
|
122
|
+
self.system_tool.show_file_location((session_id, "file-folder"), session.repository_context, chosen.path)
|
|
123
|
+
|
|
124
|
+
def show_file_in_text_editor(self, session_id: str, selection: FileActionSelection) -> None:
|
|
125
|
+
"""Open the working copy of the selected historical name in the editor, never a version of the commit."""
|
|
126
|
+
if not self._file_action_allowed(session_id, selection):
|
|
127
|
+
return
|
|
128
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
129
|
+
identity = FileReadIdentity(RequestIdentity(session_id, session.generation, oid=selection.commit_oid))
|
|
130
|
+
chosen = historical_selection(selection.entry, identity)
|
|
131
|
+
self.launch_text_editor((session_id, TEXT_EDITOR_TOOL), session.repository_context, chosen.path)
|
|
132
|
+
|
|
133
|
+
def launch_text_editor(self, request_id: tuple[str, str], context: RepositoryContext, path: bytes) -> None:
|
|
134
|
+
"""Start the chosen editor on the working copy file of a Git path, checking again that it can open.
|
|
135
|
+
|
|
136
|
+
The file may have gone, or the editor changed, between the menu and the click; the reason is then shown
|
|
137
|
+
and nothing starts.
|
|
138
|
+
"""
|
|
139
|
+
command = self.text_editor_command(context)
|
|
140
|
+
reason = text_editor_unavailable_reason(context, path, command)
|
|
141
|
+
if reason or command is None:
|
|
142
|
+
self._show_text_editor_refusal(request_id, reason)
|
|
143
|
+
return
|
|
144
|
+
self.system_tool.launch_text_editor(request_id, command, worktree_filename(context, path))
|
|
145
|
+
|
|
146
|
+
def _staging_consultation_failed(self, git_dir: str, cause: str, diagnostic: PreparedDiagnostic | None = None) -> None:
|
|
147
|
+
"""Show why a file action of Prepare changes did not happen, in the status area of that Prepare changes.
|
|
148
|
+
|
|
149
|
+
A tool that failed to start passes its prepared diagnostic, so the details name the program that failed.
|
|
150
|
+
"""
|
|
151
|
+
for key, staging in self.staging_session_registry_controller.staging_by_key.items():
|
|
152
|
+
if key.git_dir == git_dir:
|
|
153
|
+
staging._consultation_failed(cause, diagnostic)
|
|
154
|
+
|
|
155
|
+
def _show_text_editor_refusal(self, request_id: tuple[str, str], reason: str) -> None:
|
|
156
|
+
"""Show in the requesting window why the editor was not started."""
|
|
157
|
+
session_id, tool = request_id
|
|
158
|
+
if tool in STAGING_TOOL_SET:
|
|
159
|
+
self._staging_consultation_failed(session_id, reason)
|
|
160
|
+
return
|
|
161
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
162
|
+
if window is not None and isValid(window) and not window.is_closed:
|
|
163
|
+
window.statusBar().showMessage(reason)
|
|
164
|
+
|
|
165
|
+
def text_editor_command(self, context: RepositoryContext) -> TextEditorCommand | None:
|
|
166
|
+
"""Choose the editor for a working copy file with the rule of this platform.
|
|
167
|
+
|
|
168
|
+
On Windows: Settings, then what Git would use, then the detected editor. Elsewhere: Settings or the detected
|
|
169
|
+
editor only, since an editor set for Git is often a terminal editor that shows nothing without a terminal. The
|
|
170
|
+
detected editor is the one Settings shows while nothing is saved, and it is not saved here either. No process
|
|
171
|
+
starts: core.editor, only kept on Windows, comes from the value read when the repository window opened, and a
|
|
172
|
+
read not finished yet counts as unset; the click asks again with the value known by then. The editor the system
|
|
173
|
+
names for plain text is read in the background the same way: a read not finished yet counts as the system
|
|
174
|
+
naming nothing.
|
|
175
|
+
"""
|
|
176
|
+
editor_recipe = self.preference.initialize_editor(repository_dirs=(context.repository_dir,), system_editor_id=self.system_editor_default.system_editor_id)
|
|
177
|
+
git_editor = git_editor_line(os.environ, self.git_editor_setting_by_repository_dir.get(context.repository_dir, ""))
|
|
178
|
+
git_utility_dir = str(self.preference.value_by_key["git_utility_dir"])
|
|
179
|
+
return text_editor_command(editor_recipe, git_editor, lambda: git_shell_filename(git_filename=terminal_git_filename(self.repository.git_filename), git_utility_dir=git_utility_dir),
|
|
180
|
+
saved=self.preference.editor_choice_saved())
|
|
181
|
+
|
|
182
|
+
def text_editor_reason(self, session_id: str, selection: FileActionSelection) -> str:
|
|
183
|
+
"""Tell the file menu why the working copy of its file cannot be opened in a text editor, or answer an empty reason."""
|
|
184
|
+
session = self.session_registry_controller.session_by_id.get(session_id)
|
|
185
|
+
if session is None:
|
|
186
|
+
return ""
|
|
187
|
+
identity = FileReadIdentity(RequestIdentity(session_id, session.generation, oid=selection.commit_oid))
|
|
188
|
+
chosen = historical_selection(selection.entry, identity)
|
|
189
|
+
context = session.repository_context
|
|
190
|
+
return text_editor_unavailable_reason(context, chosen.path, self.text_editor_command(context))
|
|
191
|
+
|
|
192
|
+
def read_git_editor_setting(self, context: RepositoryContext) -> None:
|
|
193
|
+
"""Read core.editor once per repository window, in the background, because no process may start while a menu is built.
|
|
194
|
+
|
|
195
|
+
Git reads it in the repository itself, so its includes and its ownership check of the repository apply as
|
|
196
|
+
when Git starts the editor. A failed read keeps the value unset and goes to the --log file. Outside Windows the
|
|
197
|
+
editor set for Git is never started, so no process is started to read it.
|
|
198
|
+
"""
|
|
199
|
+
if not uses_git_editor():
|
|
200
|
+
return
|
|
201
|
+
if context.is_bare or context.worktree_dir is None:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
def read() -> Generator[GitCommand, bytes, bytes]:
|
|
205
|
+
"""Ask Git for the value; exit code 1 means it is not set."""
|
|
206
|
+
output = yield GitCommand(["config", "--get", "core.editor"], accepted_codes=(0, 1))
|
|
207
|
+
return output
|
|
208
|
+
|
|
209
|
+
def complete(value: object, error: Exception | None) -> None:
|
|
210
|
+
"""Keep the value for this repository, or log why it could not be read."""
|
|
211
|
+
if error is not None:
|
|
212
|
+
if self.diagnostic_log is not None:
|
|
213
|
+
self.diagnostic_log.write_line("Cannot read core.editor of " + context.repository_dir + ": " + str(error))
|
|
214
|
+
return
|
|
215
|
+
self.git_editor_setting_by_repository_dir[context.repository_dir] = bytes(value).decode("utf-8", "surrogateescape").strip()
|
|
216
|
+
|
|
217
|
+
self.worktree_service.run(context, self.repository.git_filename, read(), complete, priority=ProcessPriority.BACKGROUND)
|
|
218
|
+
|
|
219
|
+
def tool_started(self, request_id: tuple[str, str], label: str) -> None:
|
|
220
|
+
"""Mark only the requesting window's tool action as starting."""
|
|
221
|
+
session_id, tool = request_id
|
|
222
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
223
|
+
if window is not None and isValid(window) and not window.is_closed:
|
|
224
|
+
window.set_tool_busy(tool, True)
|
|
225
|
+
window.statusBar().showMessage(f"Opening {label}…")
|
|
226
|
+
|
|
227
|
+
def _tool_failure_diagnostic(self, result: ToolResult) -> PreparedDiagnostic:
|
|
228
|
+
"""Prepare the diagnostic of a tool that did not start, naming its program and arguments, and write it to the --log file.
|
|
229
|
+
|
|
230
|
+
Prepare changes and the repository window show the same prepared text, so the screen and the log agree.
|
|
231
|
+
"""
|
|
232
|
+
diagnostic = Diagnostic(f"Open {result.label}", result.repository_dir, result.program_filename, list(result.arguments), result.cause).prepare()
|
|
233
|
+
if self.diagnostic_log is not None:
|
|
234
|
+
self.diagnostic_log.write(diagnostic)
|
|
235
|
+
return diagnostic
|
|
236
|
+
|
|
237
|
+
def tool_finished(self, result: ToolResult) -> None:
|
|
238
|
+
"""Release startup controls and show actual system failures in the owning window."""
|
|
239
|
+
session_id, tool = result.request_id
|
|
240
|
+
if tool in STAGING_TOOL_SET:
|
|
241
|
+
if result.cause:
|
|
242
|
+
self._staging_consultation_failed(session_id, result.cause, self._tool_failure_diagnostic(result))
|
|
243
|
+
return
|
|
244
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
245
|
+
if window is None or not isValid(window) or window.is_closed:
|
|
246
|
+
return
|
|
247
|
+
window.set_tool_busy(tool, False)
|
|
248
|
+
window.statusBar().clearMessage()
|
|
249
|
+
if not result.cause:
|
|
250
|
+
return
|
|
251
|
+
diagnostic = self._tool_failure_diagnostic(result)
|
|
252
|
+
dialog = ErrorDialog(diagnostic, window)
|
|
253
|
+
if tool == "terminal":
|
|
254
|
+
choose_button = QPushButton(SharedText.TERMINAL_CHOOSE.value, dialog)
|
|
255
|
+
dialog.layout().addWidget(choose_button)
|
|
256
|
+
choose_button.clicked.connect(dialog.accept)
|
|
257
|
+
choose_button.clicked.connect(lambda: window.terminal_menu.popup(window.terminal_button.mapToGlobal(window.terminal_button.rect().bottomLeft())))
|
|
258
|
+
self.session_registry_controller.dialogs.append(dialog)
|
|
259
|
+
dialog.finished.connect(lambda: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
|
|
260
|
+
dialog.finished.connect(dialog.deleteLater)
|
|
261
|
+
open_window_modal(dialog)
|
|
262
|
+
|
|
263
|
+
def open_file_history(self, session_id: str, entry: RevisionEntry | TreeEntry) -> RepositoryWindow | None:
|
|
264
|
+
"""Create a separately captured read-only session for the chosen historical side."""
|
|
265
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.FILE_MENU):
|
|
266
|
+
return None
|
|
267
|
+
source = self.session_registry_controller.session_by_id[session_id]
|
|
268
|
+
if isinstance(entry, TreeEntry):
|
|
269
|
+
if entry not in source.tree_entries or source.selected_oid is None:
|
|
270
|
+
return None
|
|
271
|
+
anchor = FileHistoryAnchor(source.selected_oid, entry.path, entry.oid)
|
|
272
|
+
else:
|
|
273
|
+
if not any(entry in group.entries for group in source.diff_groups):
|
|
274
|
+
return None
|
|
275
|
+
anchor = FileHistoryAnchor.from_entry(entry)
|
|
276
|
+
identity = RequestIdentity(uuid.uuid4().hex, 1)
|
|
277
|
+
self.session_registry_controller.identity_by_session[identity.session_id] = identity
|
|
278
|
+
self.repository.context_by_session[identity.session_id] = source.repository_context
|
|
279
|
+
self.opening_controller.opened(identity, source.repository_context, None, anchor, self.session_registry_controller.window_by_session.get(session_id))
|
|
280
|
+
return self.session_registry_controller.window_by_session[identity.session_id]
|
|
281
|
+
|
|
282
|
+
def restore_historical_file(self, session_id: str, selection: FileActionSelection | None, target: FileRestoreTarget | None = None) -> None:
|
|
283
|
+
"""Restore the viewed commit or its chosen parent, confirming only over a new local addition."""
|
|
284
|
+
if selection is None or not self.operation_policy_controller.allows(session_id, WindowCapability.RESTORE) or not self._file_action_allowed(session_id, selection):
|
|
285
|
+
return
|
|
286
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
287
|
+
context = session.repository_context
|
|
288
|
+
if context.is_bare or context.worktree_dir is None:
|
|
289
|
+
return
|
|
290
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
291
|
+
targets = window.file_restore_targets(selection) or []
|
|
292
|
+
if target is None:
|
|
293
|
+
target = next(iter(targets), None)
|
|
294
|
+
if target is None or target.unavailable or target not in targets:
|
|
295
|
+
return
|
|
296
|
+
message = target.message
|
|
297
|
+
|
|
298
|
+
def validate() -> bool:
|
|
299
|
+
"""Prevent a closed or reoriented window from authorizing a previous selection."""
|
|
300
|
+
return self.operation_policy_controller.allows(session_id, WindowCapability.RESTORE) and self._file_action_allowed(session_id, selection)
|
|
301
|
+
|
|
302
|
+
def prepared(request: HistoricalRestoreRequest | None, error: Exception | None) -> None:
|
|
303
|
+
"""Publish the prepared source, retaining confirmation when a new local file would be replaced."""
|
|
304
|
+
if error is not None:
|
|
305
|
+
if validate():
|
|
306
|
+
diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(RESTORE_SELECTED_VERSION_TITLE, context.repository_dir,
|
|
307
|
+
self.repository.git_filename, [], str(error)).prepare()
|
|
308
|
+
if self.diagnostic_log is not None:
|
|
309
|
+
self.diagnostic_log.write(diagnostic)
|
|
310
|
+
dialog = ErrorDialog(diagnostic, window)
|
|
311
|
+
self.session_registry_controller.dialogs.append(dialog)
|
|
312
|
+
dialog.finished.connect(lambda: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
|
|
313
|
+
dialog.finished.connect(dialog.deleteLater)
|
|
314
|
+
open_window_modal(dialog)
|
|
315
|
+
return
|
|
316
|
+
if not validate():
|
|
317
|
+
self.historical_restore.cancel(request)
|
|
318
|
+
return
|
|
319
|
+
needs_confirmation = request.before.exists and request.snapshot.requires_file_confirmation((request.path,))
|
|
320
|
+
accepted = not needs_confirmation or window.confirm_historical_restore(request)
|
|
321
|
+
if not accepted or not validate():
|
|
322
|
+
self.historical_restore.cancel(request)
|
|
323
|
+
return
|
|
324
|
+
self.restore_session_by_operation_id[request.operation_id] = session_id
|
|
325
|
+
self.historical_restore.restore(request)
|
|
326
|
+
|
|
327
|
+
self.historical_restore.prepare(context, self.repository.git_filename, selection, message, session.mode, validate, prepared, source_oid=target.commit_oid)
|
|
328
|
+
|
|
329
|
+
def historical_restore_finished(self, outcome: MutationOutcome) -> None:
|
|
330
|
+
"""Refresh affected file views and report the actual retained restoration result.
|
|
331
|
+
|
|
332
|
+
A restoration that found the file already at the selected version wrote nothing, which a status bar text alone
|
|
333
|
+
hides too easily: the window where the user asked also opens a message saying so. The other windows of the
|
|
334
|
+
worktree only read again.
|
|
335
|
+
"""
|
|
336
|
+
requesting_session_id = self.restore_session_by_operation_id.pop(outcome.operation_id, None)
|
|
337
|
+
if outcome.operation_id not in self.historical_restore.outcome_by_id:
|
|
338
|
+
return
|
|
339
|
+
shared_by_key: dict[WorktreeKey, FullRefreshRead] = {}
|
|
340
|
+
for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
|
|
341
|
+
if session.is_closed or WorktreeKey.from_context(session.repository_context) != outcome.key:
|
|
342
|
+
continue
|
|
343
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
344
|
+
if window is None or not isValid(window) or window.is_closed:
|
|
345
|
+
continue
|
|
346
|
+
if outcome.diagnostic is not None:
|
|
347
|
+
dialog = ErrorDialog(outcome.diagnostic, window)
|
|
348
|
+
self.session_registry_controller.dialogs.append(dialog)
|
|
349
|
+
dialog.finished.connect(lambda dialog=dialog: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
|
|
350
|
+
dialog.finished.connect(dialog.deleteLater)
|
|
351
|
+
open_window_modal(dialog)
|
|
352
|
+
else:
|
|
353
|
+
window.statusBar().showMessage(RESTORE_UNCHANGED_STATUS if outcome.status == "unchanged" else RESTORED_STATUS)
|
|
354
|
+
if outcome.status == "unchanged" and session_id == requesting_session_id:
|
|
355
|
+
self._show_unchanged_restore(window, outcome)
|
|
356
|
+
self.refresh_controller.refresh(session_id, full_refresh_read=self.refresh_controller.full_refresh_read_for(session, shared_by_key))
|
|
357
|
+
self.refresh_controller.observe_full_refresh_pauses(shared_by_key)
|
|
358
|
+
|
|
359
|
+
def _show_unchanged_restore(self, window: RepositoryWindow, outcome: MutationOutcome) -> None:
|
|
360
|
+
"""Tell the user, over the window that asked, that the file already had the selected version and nothing was written."""
|
|
361
|
+
path_label = ", ".join(conflict_path_label(path) for path in outcome.completed_paths)
|
|
362
|
+
message = QMessageBox(QMessageBox.Icon.Information, RESTORE_SELECTED_VERSION_TITLE, RESTORE_UNCHANGED_TEXT.format(path=path_label),
|
|
363
|
+
QMessageBox.StandardButton.Ok, window)
|
|
364
|
+
prepare_message_box(message, RESTORE_SELECTED_VERSION_TITLE)
|
|
365
|
+
self.session_registry_controller.dialogs.append(message)
|
|
366
|
+
message.finished.connect(lambda _result, message=message: self.session_registry_controller.dialogs.remove(message) if message in self.session_registry_controller.dialogs else None)
|
|
367
|
+
message.finished.connect(message.deleteLater)
|
|
368
|
+
open_window_modal(message)
|
|
369
|
+
|
|
370
|
+
def compare_external(self, session_id: str) -> None:
|
|
371
|
+
"""Capture a main-window comparison and preferences only after explicit activation.
|
|
372
|
+
|
|
373
|
+
With no diff tool saved, the KDiff3 detected on this system is used and saved at this first use, as Settings shows it.
|
|
374
|
+
"""
|
|
375
|
+
if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
|
|
376
|
+
return
|
|
377
|
+
session = self.session_registry_controller.session_by_id.get(session_id)
|
|
378
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
379
|
+
if session is None or window is None or session.selected_entry is None:
|
|
380
|
+
return
|
|
381
|
+
identity = session.file_identity_by_kind.get(FilePanel.DIFF)
|
|
382
|
+
entry = session.selected_entry
|
|
383
|
+
if identity is None or window.diff_panel.selected_entry != entry or not self.history_read_controller.file_is_current(identity):
|
|
384
|
+
return
|
|
385
|
+
difftool_filename = self.preference.initialize_difftool()
|
|
386
|
+
recipe = DifftoolRecipe(difftool_filename, str(self.preference.value_by_key["difftool_command"]))
|
|
387
|
+
self.difftool.compare(entry, identity, recipe, lambda: self.history_read_controller.file_is_current(identity))
|
|
388
|
+
|
|
389
|
+
def difftool_failed(self, launch: DifftoolLaunch, cause: str) -> None:
|
|
390
|
+
"""Report comparison errors without logging templates or historical contents, bold red in the source window's status bar.
|
|
391
|
+
|
|
392
|
+
--log keeps the whole cause; the status bar, which shows a single line, gets several install commands as a
|
|
393
|
+
pointer to Settings › Tools instead.
|
|
394
|
+
|
|
395
|
+
A failure reported after the tool exited, such as a refused cleanup of its copies, names the tool and says in
|
|
396
|
+
its cause the code it closed with, which is not judged: --log neither blames the tool for it nor says it did not start.
|
|
397
|
+
"""
|
|
398
|
+
if launch.return_code is None:
|
|
399
|
+
diagnostic = Diagnostic("External comparison", "", "", [], cause).prepare()
|
|
400
|
+
else:
|
|
401
|
+
closed_cause = f"{cause} The comparison tool had closed with exit code {launch.return_code}."
|
|
402
|
+
diagnostic = Diagnostic("External comparison", "", launch.recipe.program_filename, [], closed_cause, ended_before_failure=True).prepare()
|
|
403
|
+
if self.diagnostic_log is not None:
|
|
404
|
+
self.diagnostic_log.write(diagnostic)
|
|
405
|
+
window = self.session_registry_controller.window_by_session.get(launch.selection.selection_identity.request_identity.session_id)
|
|
406
|
+
if window is not None:
|
|
407
|
+
window.show_status_failure(status_bar_refusal_text(cause))
|
|
408
|
+
|
|
409
|
+
def difftool_closed(self, launch: DifftoolLaunch) -> None:
|
|
410
|
+
"""Tell the still-live source window that the user closed the comparison, and write to --log how the tool ended.
|
|
411
|
+
|
|
412
|
+
The exit code is not judged, so the window only says the comparison closed; the --log line keeps the tool, its
|
|
413
|
+
exit status and how long it ran, which explain a tool that closed at once without showing anything. The line
|
|
414
|
+
carries neither the arguments nor the names of the historical copies.
|
|
415
|
+
"""
|
|
416
|
+
if self.diagnostic_log is not None:
|
|
417
|
+
closed_line = f"External comparison closed: {launch.recipe.program_filename}, exit status {launch.return_code}, ran {launch.run_duration_ms} ms"
|
|
418
|
+
self.diagnostic_log.write_line(SensitiveTextFilter().redact(closed_line))
|
|
419
|
+
window = self.session_registry_controller.window_by_session.get(launch.selection.selection_identity.request_identity.session_id)
|
|
420
|
+
if window is not None:
|
|
421
|
+
window.statusBar().showMessage("External comparison closed.")
|
|
422
|
+
|
|
423
|
+
def difftool_started(self, launch: DifftoolLaunch) -> None:
|
|
424
|
+
"""Confirm startup only to the still-live source window."""
|
|
425
|
+
window = self.session_registry_controller.window_by_session.get(launch.selection.selection_identity.request_identity.session_id)
|
|
426
|
+
if window is not None:
|
|
427
|
+
window.statusBar().showMessage("External comparison opened. Historical copies remain available until it closes.")
|
|
428
|
+
|
|
429
|
+
def save_file_as(self, session_id: str, selection: FileActionSelection) -> None:
|
|
430
|
+
"""Confirm the destination while selection is valid, then lock export to its exact blob."""
|
|
431
|
+
if not self._file_action_allowed(session_id, selection):
|
|
432
|
+
return
|
|
433
|
+
session = self.session_registry_controller.session_by_id[session_id]
|
|
434
|
+
identity = FileReadIdentity(RequestIdentity(uuid.uuid4().hex, 1, oid=selection.commit_oid), FilePanel.EXPORT)
|
|
435
|
+
chosen = historical_selection(selection.entry, identity)
|
|
436
|
+
if chosen.mode == "160000":
|
|
437
|
+
return
|
|
438
|
+
window = self.session_registry_controller.window_by_session[session_id]
|
|
439
|
+
suggested_filename = os.path.join(writable_folder(QStandardPaths.StandardLocation.DocumentsLocation, sys.platform), selection.basename())
|
|
440
|
+
destination_filename, _selected_filter = QFileDialog.getSaveFileName(window, "Save selected as", suggested_filename)
|
|
441
|
+
if not destination_filename:
|
|
442
|
+
return
|
|
443
|
+
if not self._file_action_allowed(session_id, selection):
|
|
444
|
+
if isValid(window) and not window.is_closed:
|
|
445
|
+
window.statusBar().showMessage("Saving was cancelled because the file selection changed.")
|
|
446
|
+
return
|
|
447
|
+
# A separate read session preserves the accepted blob across subsequent commit changes and Refresh.
|
|
448
|
+
export_session_id = identity.request_identity.session_id
|
|
449
|
+
self.repository.context_by_session[export_session_id] = session.repository_context
|
|
450
|
+
self.repository.identity_by_session[export_session_id] = identity.request_identity
|
|
451
|
+
self.repository.executable_by_session[export_session_id] = self.repository.executable_by_session[session_id]
|
|
452
|
+
self.session_registry_controller.export_owner_by_identity[identity] = session_id
|
|
453
|
+
window.statusBar().showMessage("Saving historical file…")
|
|
454
|
+
self.system_tool.save_historical_file(identity, session.repository_context, destination_filename,
|
|
455
|
+
lambda consume, completed: self.repository.stream_blob(chosen.blob_oid, identity, consume, completed),
|
|
456
|
+
lambda: self.repository.cancel(export_session_id), overwrite_confirmed=os.path.exists(destination_filename))
|
|
457
|
+
|
|
458
|
+
def export_finished(self, result: OperationResult) -> None:
|
|
459
|
+
"""Release export resources and present errors only to a still-live source window."""
|
|
460
|
+
session_id = self.session_registry_controller.export_owner_by_identity.pop(result.request_id, None)
|
|
461
|
+
if session_id is None:
|
|
462
|
+
return
|
|
463
|
+
export_session_id = result.request_id.request_identity.session_id
|
|
464
|
+
self.repository.cancel(export_session_id)
|
|
465
|
+
self.repository.context_by_session.pop(export_session_id, None)
|
|
466
|
+
window = self.session_registry_controller.window_by_session.get(session_id)
|
|
467
|
+
if window is None or not isValid(window) or window.is_closed:
|
|
468
|
+
return
|
|
469
|
+
if not result.cause:
|
|
470
|
+
window.statusBar().showMessage("Historical file saved")
|
|
471
|
+
return
|
|
472
|
+
diagnostic = Diagnostic("Save selected as", window.context.repository_dir, "", [], result.cause).prepare()
|
|
473
|
+
if self.diagnostic_log is not None:
|
|
474
|
+
self.diagnostic_log.write(diagnostic)
|
|
475
|
+
dialog = ErrorDialog(diagnostic, window)
|
|
476
|
+
self.session_registry_controller.dialogs.append(dialog)
|
|
477
|
+
dialog.finished.connect(lambda: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
|
|
478
|
+
dialog.finished.connect(dialog.deleteLater)
|
|
479
|
+
open_window_modal(dialog)
|