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
gitpane/tool/difftool.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Prepare literal arguments for an explicitly configured historical comparison."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import QObject, QProcess, Qt, QTimer, Signal
|
|
20
|
+
|
|
21
|
+
from gitpane.config import DIFFTOOL_ACTIVE_LIMIT, DIFFTOOL_START_TIMEOUT_MS, MILLISECONDS_PER_SECOND, SHELL_SCRIPT_EXTENSION_SET, TOOL_RESULT_POLL_MS
|
|
22
|
+
from gitpane.history.repository_value import FilePanel, FileReadIdentity, RequestIdentity, historical_selection
|
|
23
|
+
from gitpane.system.linux_distribution import DistributionFamily, DistributionRelease
|
|
24
|
+
from gitpane.system.startup_activation import NO_STARTUP_ACTIVATION, StartupActivation, no_startup_activation
|
|
25
|
+
from gitpane.tool.kdiff3 import tool_program_usable
|
|
26
|
+
from gitpane.tool.kdiff3_install import with_kdiff3_install_line
|
|
27
|
+
from gitpane.tool.system_tool import SystemToolService
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
|
|
32
|
+
from gitpane.history.repository import GitRepository
|
|
33
|
+
from gitpane.history.repository_value import HistoricalFileSelection, RepositoryContext, RepositoryFailure, RevisionEntry
|
|
34
|
+
from gitpane.tool.system_tool import OperationResult
|
|
35
|
+
|
|
36
|
+
# The only launch states _poll has to look at over time: a reading launch is cancelled once its file selection is no longer current,
|
|
37
|
+
# and a starting launch reports its startup deadline. No worker event announces either change, so only these states keep the timer running.
|
|
38
|
+
DIFFTOOL_WATCHED_STATE_SET = frozenset(("reading", "starting"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class DifftoolRecipe:
|
|
43
|
+
"""Capture the executable separately from its Qt-quoted argument template."""
|
|
44
|
+
|
|
45
|
+
program_filename: str
|
|
46
|
+
command: str
|
|
47
|
+
|
|
48
|
+
def validate(self, *, platform: str = sys.platform, family: DistributionFamily = DistributionFamily.UNKNOWN,
|
|
49
|
+
release: DistributionRelease = DistributionRelease.OTHER) -> None:
|
|
50
|
+
"""Reject missing executables and malformed templates before publishing a draft.
|
|
51
|
+
|
|
52
|
+
The refusal of a missing tool says how to install KDiff3 on ``platform``, and under Linux on the distribution
|
|
53
|
+
``family`` and ``release``, all received so each system's message can be checked from any of them.
|
|
54
|
+
"""
|
|
55
|
+
parse_difftool_command(self.command, "before", "after")
|
|
56
|
+
if not self.program_filename:
|
|
57
|
+
return
|
|
58
|
+
if not tool_program_usable(self.program_filename):
|
|
59
|
+
raise ValueError(with_kdiff3_install_line("External diff tool executable is missing or not executable. Choose its full path.", platform, family, release))
|
|
60
|
+
if os.path.splitext(self.program_filename)[1].casefold() in SHELL_SCRIPT_EXTENSION_SET:
|
|
61
|
+
raise ValueError("Choose the real external diff executable instead of a shell script launcher.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_difftool_command(command: str, before_filename: str, after_filename: str) -> list[str]:
|
|
65
|
+
"""Split Qt double-quoted arguments before replacing only the two historical filenames."""
|
|
66
|
+
if "\0" in command:
|
|
67
|
+
raise ValueError("External diff arguments cannot contain a null character.")
|
|
68
|
+
# Qt represents a literal quote with three quotes; backslashes do not escape quotes.
|
|
69
|
+
if command.replace('"""', '').count('"') % 2:
|
|
70
|
+
raise ValueError("External diff arguments contain an unclosed double quote.")
|
|
71
|
+
arguments = QProcess.splitCommand(command)
|
|
72
|
+
replacement_by_token = {"$LOCAL": before_filename, "$REMOTE": after_filename}
|
|
73
|
+
token_set: set[str] = set()
|
|
74
|
+
results = []
|
|
75
|
+
for argument in arguments:
|
|
76
|
+
tokens = re.findall(r"\$[A-Za-z_][A-Za-z_0-9]*|\$\{[^}]*\}|\$", argument)
|
|
77
|
+
for token in tokens:
|
|
78
|
+
if token not in replacement_by_token:
|
|
79
|
+
raise ValueError("External diff arguments contain an unknown parameter. Use $LOCAL and $REMOTE only.")
|
|
80
|
+
token_set.add(token)
|
|
81
|
+
results.append(re.sub(r"\$LOCAL|\$REMOTE", lambda match: replacement_by_token[match.group()], argument))
|
|
82
|
+
if token_set != set(replacement_by_token):
|
|
83
|
+
raise ValueError("External diff arguments must include both $LOCAL and $REMOTE.")
|
|
84
|
+
return results
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class DifftoolLaunch:
|
|
89
|
+
"""Keep each comparison's historical versions and private filenames immutable.
|
|
90
|
+
|
|
91
|
+
``activation`` is the startup value read when the user clicked, which lets the tool's window come to the front.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
request_id: str
|
|
95
|
+
selection: HistoricalFileSelection
|
|
96
|
+
entry: RevisionEntry
|
|
97
|
+
recipe: DifftoolRecipe
|
|
98
|
+
temporary_dir: str = ""
|
|
99
|
+
before_filename: str = ""
|
|
100
|
+
after_filename: str = ""
|
|
101
|
+
state: str = "preparing"
|
|
102
|
+
# Set once the tool has exited: the --log line of the close names them, and a failure reported afterwards, such as a
|
|
103
|
+
# refused cleanup, says the tool had already closed with that code.
|
|
104
|
+
return_code: int | None = None
|
|
105
|
+
run_duration_ms: int | None = None
|
|
106
|
+
activation: StartupActivation = NO_STARTUP_ACTIVATION
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class PendingComparison:
|
|
111
|
+
"""Track extraction and OS startup separately from the immutable comparison values."""
|
|
112
|
+
|
|
113
|
+
launch: DifftoolLaunch
|
|
114
|
+
context: RepositoryContext
|
|
115
|
+
git_filename: str
|
|
116
|
+
is_current: Callable[[], bool]
|
|
117
|
+
read_identity: FileReadIdentity | None = None
|
|
118
|
+
remaining_sides: list[str] = field(default_factory=lambda: ["before", "after"])
|
|
119
|
+
deadline: float | None = None
|
|
120
|
+
reported: bool = False
|
|
121
|
+
cancelled: bool = False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TemporaryComparisonStore:
|
|
125
|
+
"""Create independent before and after folders for historical comparisons."""
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
"""Record ownership separately from public launch values to reject altered paths."""
|
|
129
|
+
self.directory_by_request: dict[str, tuple[str, int, int]] = {}
|
|
130
|
+
self.lock = threading.Lock()
|
|
131
|
+
|
|
132
|
+
def create(self, launch: DifftoolLaunch) -> DifftoolLaunch:
|
|
133
|
+
"""Preserve usable basenames while keeping raw Git paths out of local directory traversal."""
|
|
134
|
+
temporary_dir = os.path.realpath(tempfile.mkdtemp(prefix="gitpane-diff-"))
|
|
135
|
+
filenames = []
|
|
136
|
+
try:
|
|
137
|
+
for side, path in (("before", launch.entry.old_path), ("after", launch.entry.new_path)):
|
|
138
|
+
directory = os.path.join(temporary_dir, side)
|
|
139
|
+
os.mkdir(directory)
|
|
140
|
+
basename_index = -1
|
|
141
|
+
basename = os.fsdecode((path or launch.selection.path).split(b"/")[basename_index])
|
|
142
|
+
if basename in ("", ".", "..") or any(character in basename for character in ('\\', '/', '\0')):
|
|
143
|
+
raise ValueError("The historical filename cannot be represented safely on this system.")
|
|
144
|
+
if os.name == "nt" and (os.path.isreserved(basename) or any(character in basename for character in '<>:"|?*') or basename.endswith((".", " "))):
|
|
145
|
+
raise ValueError("The historical filename cannot be represented safely on this system.")
|
|
146
|
+
filenames.append(os.path.join(directory, basename))
|
|
147
|
+
before_filename, after_filename = filenames
|
|
148
|
+
directory_stat = os.stat(temporary_dir)
|
|
149
|
+
with self.lock:
|
|
150
|
+
self.directory_by_request[launch.request_id] = (temporary_dir, directory_stat.st_dev, directory_stat.st_ino)
|
|
151
|
+
return replace(launch, temporary_dir=temporary_dir, before_filename=before_filename, after_filename=after_filename, state="reading")
|
|
152
|
+
except (OSError, ValueError):
|
|
153
|
+
shutil.rmtree(temporary_dir)
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
def cleanup(self, launch: DifftoolLaunch) -> None:
|
|
157
|
+
"""Remove only the recorded directory, refusing replacements and linked descendants."""
|
|
158
|
+
with self.lock:
|
|
159
|
+
owned = self.directory_by_request.get(launch.request_id)
|
|
160
|
+
if owned is None:
|
|
161
|
+
if launch.temporary_dir and os.path.lexists(launch.temporary_dir):
|
|
162
|
+
raise ValueError("The comparison directory is not owned by this service.")
|
|
163
|
+
return
|
|
164
|
+
directory, device, inode = owned
|
|
165
|
+
if launch.temporary_dir != directory or os.path.realpath(launch.temporary_dir) != directory:
|
|
166
|
+
raise ValueError("The comparison directory is not the owned directory.")
|
|
167
|
+
if not os.path.lexists(directory):
|
|
168
|
+
self.directory_by_request.pop(launch.request_id)
|
|
169
|
+
return
|
|
170
|
+
directory_stat = os.lstat(directory)
|
|
171
|
+
if (directory_stat.st_dev, directory_stat.st_ino) != (device, inode):
|
|
172
|
+
raise ValueError("The owned comparison directory was replaced.")
|
|
173
|
+
self._check_directory(directory, directory)
|
|
174
|
+
shutil.rmtree(directory)
|
|
175
|
+
self.directory_by_request.pop(launch.request_id)
|
|
176
|
+
|
|
177
|
+
def _check_directory(self, directory: str, owned_dir: str) -> None:
|
|
178
|
+
"""Refuse symlinks and Windows reparse points before recursive deletion."""
|
|
179
|
+
directory_stat = os.lstat(directory)
|
|
180
|
+
if stat.S_ISLNK(directory_stat.st_mode) or getattr(directory_stat, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0):
|
|
181
|
+
raise ValueError("The owned comparison directory contains a link or reparse point.")
|
|
182
|
+
if os.path.commonpath((os.path.realpath(directory), owned_dir)) != owned_dir:
|
|
183
|
+
raise ValueError("The comparison path resolves outside its owned directory.")
|
|
184
|
+
with os.scandir(directory) as entries:
|
|
185
|
+
for entry in entries:
|
|
186
|
+
entry_stat = entry.stat(follow_symlinks=False)
|
|
187
|
+
if entry.is_symlink() or getattr(entry_stat, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0):
|
|
188
|
+
raise ValueError("The owned comparison directory contains a link or reparse point.")
|
|
189
|
+
if entry.is_dir(follow_symlinks=False):
|
|
190
|
+
self._check_directory(entry.path, owned_dir)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class DifftoolService(QObject):
|
|
194
|
+
"""Own exact historical copies and tracked external processes independently of windows."""
|
|
195
|
+
|
|
196
|
+
started = Signal(object)
|
|
197
|
+
# The tool exited by itself, whatever its exit code: the user closed the comparison.
|
|
198
|
+
tool_exited = Signal(object)
|
|
199
|
+
finished = Signal(object)
|
|
200
|
+
failed = Signal(object, str)
|
|
201
|
+
idle = Signal()
|
|
202
|
+
event_ready = Signal()
|
|
203
|
+
|
|
204
|
+
def __init__(self, repository: GitRepository, process_factory: Callable[..., subprocess.Popen] = subprocess.Popen,
|
|
205
|
+
temporary_store: TemporaryComparisonStore | None = None, parent: QObject | None = None, *, platform: str = sys.platform,
|
|
206
|
+
activation: Callable[[], StartupActivation] = no_startup_activation, distribution_family: DistributionFamily = DistributionFamily.UNKNOWN,
|
|
207
|
+
distribution_release: DistributionRelease = DistributionRelease.OTHER) -> None:
|
|
208
|
+
"""Inject OS process creation, copy storage and the system whose install line refusals give, keeping Qt delivery on its owner thread.
|
|
209
|
+
|
|
210
|
+
Under Linux, the install line also follows the distribution ``distribution_family`` and ``distribution_release``
|
|
211
|
+
that the wiring read from the session once; the defaults say GitPane does not know how to install KDiff3 there.
|
|
212
|
+
|
|
213
|
+
The activation source tells the desktop that the user asked for the comparison (an X11 startup identifier or a
|
|
214
|
+
Wayland activation token), so the tool's window may come to the front; it is read on the window's thread when
|
|
215
|
+
a comparison is accepted, never for a refused one. Without a desktop, as in the tests, there is none.
|
|
216
|
+
"""
|
|
217
|
+
super().__init__(parent)
|
|
218
|
+
self.repository = repository
|
|
219
|
+
self.platform = platform
|
|
220
|
+
self.distribution_family = distribution_family
|
|
221
|
+
self.distribution_release = distribution_release
|
|
222
|
+
self.activation = activation
|
|
223
|
+
self.process_factory = process_factory
|
|
224
|
+
self.temporary_store = temporary_store if temporary_store is not None else TemporaryComparisonStore()
|
|
225
|
+
self.export = SystemToolService(parent=self)
|
|
226
|
+
self.export.export_finished.connect(self._export_finished)
|
|
227
|
+
self.repository.file_failed.connect(self._read_failed)
|
|
228
|
+
self.active_by_id: dict[str, PendingComparison] = {}
|
|
229
|
+
self.event_queue: queue.SimpleQueue[tuple[str, str, object]] = queue.SimpleQueue()
|
|
230
|
+
self.closed = False
|
|
231
|
+
self.timer = QTimer(self)
|
|
232
|
+
self.timer.setInterval(TOOL_RESULT_POLL_MS)
|
|
233
|
+
self.timer.timeout.connect(self._poll)
|
|
234
|
+
self.event_ready.connect(self._poll, Qt.ConnectionType.QueuedConnection)
|
|
235
|
+
|
|
236
|
+
def compare(self, entry: RevisionEntry, identity: FileReadIdentity, recipe: DifftoolRecipe, is_current: Callable[[], bool]) -> bool:
|
|
237
|
+
"""Reserve one launch and capture exact sides only while the source selection is valid.
|
|
238
|
+
|
|
239
|
+
Called by the user's click: once every refusal has passed, the click's startup value is read here, on the
|
|
240
|
+
window's thread, and travels with the launch to the tool's environment.
|
|
241
|
+
"""
|
|
242
|
+
if self.closed or not is_current():
|
|
243
|
+
return False
|
|
244
|
+
owner = identity.request_identity.session_id
|
|
245
|
+
for pending in self.active_by_id.values():
|
|
246
|
+
if pending.launch.selection.selection_identity.request_identity.session_id == owner and pending.launch.state != "running":
|
|
247
|
+
self.failed.emit(pending.launch, "External comparison is still starting or finishing. Wait before launching again.")
|
|
248
|
+
return False
|
|
249
|
+
launch = DifftoolLaunch(uuid.uuid4().hex, historical_selection(entry, identity), entry, recipe)
|
|
250
|
+
try:
|
|
251
|
+
parse_difftool_command(recipe.command, "before", "after")
|
|
252
|
+
if not recipe.program_filename:
|
|
253
|
+
raise ValueError(with_kdiff3_install_line("Choose an external diff tool executable in Settings first.", self.platform, self.distribution_family, self.distribution_release))
|
|
254
|
+
# A cheap file check, made here so a saved tool that disappeared is refused with its own cause and install line.
|
|
255
|
+
recipe.validate(platform=self.platform, family=self.distribution_family, release=self.distribution_release)
|
|
256
|
+
if "160000" in (entry.old_mode, entry.new_mode):
|
|
257
|
+
raise ValueError("Submodules contain commit references, not file versions to compare.")
|
|
258
|
+
if len(self.active_by_id) >= DIFFTOOL_ACTIVE_LIMIT:
|
|
259
|
+
raise ValueError("Too many external comparisons are open. Close one before launching another.")
|
|
260
|
+
except ValueError as error:
|
|
261
|
+
self.failed.emit(launch, str(error))
|
|
262
|
+
return False
|
|
263
|
+
launch = replace(launch, activation=self.activation())
|
|
264
|
+
pending = PendingComparison(launch, self.repository.context_by_session[owner], self.repository.executable_by_session[owner], is_current)
|
|
265
|
+
self.active_by_id[launch.request_id] = pending
|
|
266
|
+
threading.Thread(target=self._prepare, args=(launch,), daemon=True).start()
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
def _queue_event(self, event: tuple[str, str, object]) -> None:
|
|
270
|
+
"""Let a worker thread drop one event and wake the Qt thread, the only one that touches the launches."""
|
|
271
|
+
self.event_queue.put(event)
|
|
272
|
+
self.event_ready.emit()
|
|
273
|
+
|
|
274
|
+
def _update_poll_timer(self) -> None:
|
|
275
|
+
"""Run the timer only while a launch waits in a state that needs checking over time, never while a tool window is open."""
|
|
276
|
+
watched = any(pending.launch.state in DIFFTOOL_WATCHED_STATE_SET for pending in self.active_by_id.values())
|
|
277
|
+
if watched and not self.timer.isActive():
|
|
278
|
+
self.timer.start()
|
|
279
|
+
elif not watched and self.timer.isActive():
|
|
280
|
+
self.timer.stop()
|
|
281
|
+
|
|
282
|
+
def _prepare(self, launch: DifftoolLaunch) -> None:
|
|
283
|
+
"""Create OS folders off the GUI thread before scheduling the Git streams; the recipe was already checked by ``compare``."""
|
|
284
|
+
try:
|
|
285
|
+
self._queue_event((launch.request_id, "prepared", self.temporary_store.create(launch)))
|
|
286
|
+
except (OSError, ValueError) as error:
|
|
287
|
+
self._queue_event((launch.request_id, "error", f"Preparing external comparison failed ({type(error).__name__})."))
|
|
288
|
+
|
|
289
|
+
def _read_next(self, pending: PendingComparison) -> None:
|
|
290
|
+
"""Reuse bounded atomic export for each side, including a real empty file for absent blobs."""
|
|
291
|
+
if pending.cancelled or not pending.is_current():
|
|
292
|
+
self._discard(pending, "External comparison cancelled because the file selection changed.")
|
|
293
|
+
return
|
|
294
|
+
if not pending.remaining_sides:
|
|
295
|
+
pending.launch = replace(pending.launch, state="starting")
|
|
296
|
+
pending.deadline = time.monotonic() + DIFFTOOL_START_TIMEOUT_MS / 1000
|
|
297
|
+
threading.Thread(target=self._run, args=(pending.launch,), daemon=True).start()
|
|
298
|
+
self._update_poll_timer()
|
|
299
|
+
return
|
|
300
|
+
first_side_index = 0
|
|
301
|
+
side = pending.remaining_sides.pop(first_side_index)
|
|
302
|
+
launch = pending.launch
|
|
303
|
+
entry = launch.entry
|
|
304
|
+
blob_oid = entry.old_blob_oid if side == "before" else entry.new_blob_oid
|
|
305
|
+
filename = launch.before_filename if side == "before" else launch.after_filename
|
|
306
|
+
identity = FileReadIdentity(RequestIdentity(launch.request_id, 1, oid=entry.commit_oid), FilePanel.DIFFTOOL)
|
|
307
|
+
pending.read_identity = identity
|
|
308
|
+
self.repository.context_by_session[launch.request_id] = pending.context
|
|
309
|
+
self.repository.identity_by_session[launch.request_id] = identity.request_identity
|
|
310
|
+
self.repository.executable_by_session[launch.request_id] = pending.git_filename
|
|
311
|
+
|
|
312
|
+
def read(consume: Callable[[bytes], None], completed: Callable[[int], None]) -> None:
|
|
313
|
+
"""Stream the immutable object or complete an absent side with zero bytes."""
|
|
314
|
+
if blob_oid is None:
|
|
315
|
+
completed(0)
|
|
316
|
+
else:
|
|
317
|
+
self.repository.stream_blob(blob_oid, identity, consume, completed)
|
|
318
|
+
|
|
319
|
+
self.export.save_historical_file(identity, pending.context, filename, read, lambda: self.repository.cancel(launch.request_id))
|
|
320
|
+
|
|
321
|
+
def _export_finished(self, result: OperationResult) -> None:
|
|
322
|
+
"""Start the second side or the process only after complete bytes reach disk."""
|
|
323
|
+
request_id = result.request_id.request_identity.session_id
|
|
324
|
+
pending = self.active_by_id.get(request_id)
|
|
325
|
+
if pending is None:
|
|
326
|
+
return
|
|
327
|
+
pending.read_identity = None
|
|
328
|
+
if result.cause:
|
|
329
|
+
self._discard(pending, "Reading external comparison files failed: " + result.cause)
|
|
330
|
+
return
|
|
331
|
+
self._read_next(pending)
|
|
332
|
+
|
|
333
|
+
def _read_failed(self, identity: FileReadIdentity, failure: RepositoryFailure) -> None:
|
|
334
|
+
"""Route Git failures to the owned writer so no partial side reaches the tool."""
|
|
335
|
+
if identity.request_identity.session_id in self.active_by_id:
|
|
336
|
+
self.export.fail_export(identity, failure.cause)
|
|
337
|
+
|
|
338
|
+
def _run(self, launch: DifftoolLaunch) -> None:
|
|
339
|
+
"""Launch directly without a shell and wait off-thread without an inactivity deadline.
|
|
340
|
+
|
|
341
|
+
The tool gets GitPane's environment without any inherited startup value, plus the one read at the click.
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
arguments = parse_difftool_command(launch.recipe.command, launch.before_filename, launch.after_filename)
|
|
345
|
+
process = self.process_factory([launch.recipe.program_filename, *arguments], cwd=launch.temporary_dir, shell=False,
|
|
346
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
347
|
+
env=launch.activation.environment(os.environ),
|
|
348
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
349
|
+
started_at = time.monotonic()
|
|
350
|
+
self._queue_event((launch.request_id, "started", None))
|
|
351
|
+
return_code = process.wait()
|
|
352
|
+
run_duration_ms = round((time.monotonic() - started_at) * MILLISECONDS_PER_SECOND)
|
|
353
|
+
self._queue_event((launch.request_id, "exited", (return_code, run_duration_ms)))
|
|
354
|
+
except (OSError, ValueError) as error:
|
|
355
|
+
self._queue_event((launch.request_id, "error", f"Starting external comparison failed ({type(error).__name__})."))
|
|
356
|
+
|
|
357
|
+
def _discard(self, pending: PendingComparison, cause: str = "") -> None:
|
|
358
|
+
"""Clean copies only after their last disk or process worker is finished."""
|
|
359
|
+
pending.launch = replace(pending.launch, state="cleaning")
|
|
360
|
+
if cause and not pending.reported:
|
|
361
|
+
pending.reported = True
|
|
362
|
+
self.failed.emit(pending.launch, cause)
|
|
363
|
+
self.repository.cancel(pending.launch.request_id)
|
|
364
|
+
self.repository.context_by_session.pop(pending.launch.request_id, None)
|
|
365
|
+
threading.Thread(target=self._cleanup, args=(pending.launch,), daemon=True).start()
|
|
366
|
+
|
|
367
|
+
def _cleanup(self, launch: DifftoolLaunch) -> None:
|
|
368
|
+
"""Keep filesystem deletion outside the event loop and report a safe cause on refusal."""
|
|
369
|
+
cause = ""
|
|
370
|
+
try:
|
|
371
|
+
self.temporary_store.cleanup(launch)
|
|
372
|
+
except (OSError, ValueError) as error:
|
|
373
|
+
cause = f"External comparison cleanup failed ({type(error).__name__})."
|
|
374
|
+
self._queue_event((launch.request_id, "cleaned", cause))
|
|
375
|
+
|
|
376
|
+
def _poll(self) -> None:
|
|
377
|
+
"""Deliver worker progress and keep timed-out OS creations reserved until they return.
|
|
378
|
+
|
|
379
|
+
Worker events wake it through event_ready; the timer only adds the checks over time of the watched states.
|
|
380
|
+
"""
|
|
381
|
+
while True:
|
|
382
|
+
try:
|
|
383
|
+
request_id, kind, value = self.event_queue.get_nowait()
|
|
384
|
+
except queue.Empty:
|
|
385
|
+
break
|
|
386
|
+
pending = self.active_by_id.get(request_id)
|
|
387
|
+
if pending is None:
|
|
388
|
+
continue
|
|
389
|
+
if kind == "prepared":
|
|
390
|
+
pending.launch = value
|
|
391
|
+
self._read_next(pending)
|
|
392
|
+
elif kind == "started":
|
|
393
|
+
pending.launch = replace(pending.launch, state="running")
|
|
394
|
+
self.started.emit(pending.launch)
|
|
395
|
+
elif kind == "error":
|
|
396
|
+
self._discard(pending, str(value))
|
|
397
|
+
elif kind == "exited":
|
|
398
|
+
# Like git difftool, the exit code of a comparison is not judged: there is no result to save, and KDiff3
|
|
399
|
+
# returns 1 whenever it is closed without saving a merge result.
|
|
400
|
+
return_code, run_duration_ms = value
|
|
401
|
+
pending.launch = replace(pending.launch, return_code=return_code, run_duration_ms=run_duration_ms)
|
|
402
|
+
self.tool_exited.emit(pending.launch)
|
|
403
|
+
self._discard(pending)
|
|
404
|
+
elif kind == "cleaned":
|
|
405
|
+
self.active_by_id.pop(request_id)
|
|
406
|
+
if value:
|
|
407
|
+
self.failed.emit(pending.launch, str(value))
|
|
408
|
+
self.finished.emit(pending.launch)
|
|
409
|
+
for pending in tuple(self.active_by_id.values()):
|
|
410
|
+
if pending.launch.state == "reading" and not pending.is_current() and pending.read_identity is not None:
|
|
411
|
+
pending.cancelled = True
|
|
412
|
+
self.export.cancel_export(pending.read_identity)
|
|
413
|
+
if pending.launch.state == "starting" and not pending.reported and pending.deadline is not None and time.monotonic() >= pending.deadline:
|
|
414
|
+
pending.reported = True
|
|
415
|
+
self.failed.emit(pending.launch, "External comparison startup timed out. The system may still open it; wait before launching again.")
|
|
416
|
+
self._update_poll_timer()
|
|
417
|
+
if not self.active_by_id:
|
|
418
|
+
self.idle.emit()
|
|
419
|
+
|
|
420
|
+
def close(self) -> None:
|
|
421
|
+
"""Cancel unlaunched reads while preserving supervision of active or starting tools."""
|
|
422
|
+
self.closed = True
|
|
423
|
+
for pending in tuple(self.active_by_id.values()):
|
|
424
|
+
if pending.launch.state in ("preparing", "reading"):
|
|
425
|
+
pending.cancelled = True
|
|
426
|
+
if pending.read_identity is not None:
|
|
427
|
+
self.export.cancel_export(pending.read_identity)
|
|
428
|
+
if not self.active_by_id:
|
|
429
|
+
self.idle.emit()
|