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,816 @@
|
|
|
1
|
+
"""Start independent tools (terminal, text editor, file manager) in explicit directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING, Protocol
|
|
15
|
+
|
|
16
|
+
from PySide6.QtCore import QObject, QTimer, QUrl, Signal
|
|
17
|
+
from PySide6.QtGui import QDesktopServices
|
|
18
|
+
|
|
19
|
+
from gitpane.config import (
|
|
20
|
+
EDITOR_HIDDEN_ENVIRONMENT_PREFIXES,
|
|
21
|
+
FILE_EXPORT_ACTIVE_LIMIT,
|
|
22
|
+
FILE_EXPORT_BUFFER_CHUNKS,
|
|
23
|
+
GIT_READ_TIMEOUT_MS,
|
|
24
|
+
MACOS_OPEN_FILENAME,
|
|
25
|
+
MACOS_PLATFORM,
|
|
26
|
+
MILLISECONDS_PER_SECOND,
|
|
27
|
+
PROCESS_CHUNK_BYTES,
|
|
28
|
+
TOOL_RESULT_POLL_MS,
|
|
29
|
+
TOOL_START_ACTIVE_LIMIT,
|
|
30
|
+
TOOL_START_TIMEOUT_MS,
|
|
31
|
+
WINDOWS_PLATFORM,
|
|
32
|
+
)
|
|
33
|
+
from gitpane.system.path_identity import canonical_path
|
|
34
|
+
from gitpane.system.startup_activation import NO_STARTUP_ACTIVATION, StartupActivation, no_startup_activation
|
|
35
|
+
from gitpane.tool.editor_recipe import EditorRecipe, build_editor_arguments, file_open_options
|
|
36
|
+
from gitpane.tool.file_reveal import reveal_file, selection_helper_available
|
|
37
|
+
from gitpane.tool.terminal_recipe import (
|
|
38
|
+
RECIPE_BY_ID,
|
|
39
|
+
SH_EXECUTABLE_BASENAME,
|
|
40
|
+
TerminalLaunch,
|
|
41
|
+
bundle_recipe_program,
|
|
42
|
+
executable_available,
|
|
43
|
+
git_bash_candidate_filenames,
|
|
44
|
+
inside_system_root,
|
|
45
|
+
terminal_arguments,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import Callable, Mapping
|
|
50
|
+
|
|
51
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
52
|
+
from gitpane.tool.terminal_recipe import TerminalDescriptor, TerminalRecipe
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class OperationResult:
|
|
57
|
+
"""Report a completed file operation without including historical content."""
|
|
58
|
+
|
|
59
|
+
request_id: object
|
|
60
|
+
destination_filename: str
|
|
61
|
+
cause: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _contained(filename: str, directory: str) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
return os.path.commonpath((filename, directory)) == directory
|
|
67
|
+
except ValueError:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _protect_git_path(filename: str, context: RepositoryContext) -> str:
|
|
72
|
+
"""Reject Git metadata even through a linked worktree or a directory symlink."""
|
|
73
|
+
resolved_filename = canonical_path(filename)
|
|
74
|
+
for directory in (context.git_dir, context.common_git_dir):
|
|
75
|
+
if _contained(resolved_filename, canonical_path(directory)):
|
|
76
|
+
raise ValueError("Git internal files cannot be used for this operation.")
|
|
77
|
+
for component in resolved_filename.replace("\\", "/").split("/"):
|
|
78
|
+
if component.casefold() == ".git":
|
|
79
|
+
raise ValueError("Git internal files cannot be used for this operation.")
|
|
80
|
+
return os.path.realpath(filename)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def worktree_filename(context: RepositoryContext, path: bytes) -> str:
|
|
84
|
+
"""Build the absolute worktree filename even for a historical file that no longer exists."""
|
|
85
|
+
if context.is_bare or context.worktree_dir is None:
|
|
86
|
+
raise ValueError("This bare repository has no working tree folder.")
|
|
87
|
+
parts = path.split(b"/")
|
|
88
|
+
for part in parts:
|
|
89
|
+
if not part or part in (b".", b"..") or b"\0" in part or (os.name == "nt" and (b"\\" in part or b":" in part)):
|
|
90
|
+
raise ValueError("The selected Git path is not a safe local file path.")
|
|
91
|
+
return os.path.join(os.path.abspath(context.worktree_dir), *map(os.fsdecode, parts))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def file_location(context: RepositoryContext, path: bytes, *, allow_missing: bool = False) -> str:
|
|
95
|
+
"""Resolve a file's folder, optionally finding an existing ancestor for a deleted working path."""
|
|
96
|
+
filename = worktree_filename(context, path)
|
|
97
|
+
worktree_dir = os.path.realpath(context.worktree_dir)
|
|
98
|
+
resolved_filename = _protect_git_path(filename, context)
|
|
99
|
+
if not _contained(os.path.normcase(resolved_filename), os.path.normcase(worktree_dir)):
|
|
100
|
+
raise ValueError("The selected file resolves outside this working tree.")
|
|
101
|
+
if not allow_missing and not os.path.exists(filename):
|
|
102
|
+
raise FileNotFoundError("This historical file is absent from the working tree. No file was restored.")
|
|
103
|
+
directory = os.path.dirname(resolved_filename)
|
|
104
|
+
while not os.path.isdir(directory):
|
|
105
|
+
if os.path.lexists(directory) or os.path.normcase(directory) == os.path.normcase(worktree_dir):
|
|
106
|
+
raise FileNotFoundError("The working tree folder is unavailable.")
|
|
107
|
+
directory = os.path.dirname(directory)
|
|
108
|
+
return directory
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class FileExport:
|
|
113
|
+
"""Share bounded chunks and cancellation with a disk worker that never touches Qt."""
|
|
114
|
+
|
|
115
|
+
request_id: object
|
|
116
|
+
context: RepositoryContext
|
|
117
|
+
destination_filename: str
|
|
118
|
+
overwrite_confirmed: bool
|
|
119
|
+
cancel_read: Callable[[], None]
|
|
120
|
+
chunks: queue.Queue[bytes | int] = field(default_factory=lambda: queue.Queue(FILE_EXPORT_BUFFER_CHUNKS))
|
|
121
|
+
cancelled: threading.Event = field(default_factory=threading.Event)
|
|
122
|
+
publish_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
123
|
+
cause: str = ""
|
|
124
|
+
deadline: float = field(default_factory=lambda: time.monotonic() + GIT_READ_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _write_export(operation: FileExport, completion_queue: queue.SimpleQueue[OperationResult]) -> None:
|
|
128
|
+
"""Publish complete bytes atomically and remove only this operation's temporary file."""
|
|
129
|
+
temporary_filename = ""
|
|
130
|
+
cause = ""
|
|
131
|
+
try:
|
|
132
|
+
destination_filename = os.path.abspath(operation.destination_filename)
|
|
133
|
+
resolved_filename = _protect_git_path(destination_filename, operation.context)
|
|
134
|
+
if os.path.islink(destination_filename):
|
|
135
|
+
raise ValueError("Choose a regular destination file instead of a symbolic link.")
|
|
136
|
+
original_stat = os.stat(destination_filename) if os.path.exists(destination_filename) else None
|
|
137
|
+
if original_stat is not None and not operation.overwrite_confirmed:
|
|
138
|
+
raise FileExistsError("Replacing this file requires explicit overwrite confirmation.")
|
|
139
|
+
if operation.cancelled.is_set():
|
|
140
|
+
raise OSError(operation.cause)
|
|
141
|
+
descriptor, temporary_filename = tempfile.mkstemp(prefix=".gitpane-export-", dir=os.path.dirname(resolved_filename))
|
|
142
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
143
|
+
written_size = 0
|
|
144
|
+
while True:
|
|
145
|
+
if operation.cancelled.is_set():
|
|
146
|
+
raise OSError(operation.cause)
|
|
147
|
+
if time.monotonic() >= operation.deadline:
|
|
148
|
+
raise TimeoutError("Saving the historical file timed out.")
|
|
149
|
+
try:
|
|
150
|
+
chunk = operation.chunks.get(timeout=TOOL_RESULT_POLL_MS / MILLISECONDS_PER_SECOND)
|
|
151
|
+
except queue.Empty:
|
|
152
|
+
continue
|
|
153
|
+
if isinstance(chunk, int):
|
|
154
|
+
if written_size != chunk:
|
|
155
|
+
raise OSError("The historical file stream ended with an incorrect byte count.")
|
|
156
|
+
break
|
|
157
|
+
written_size += stream.write(chunk)
|
|
158
|
+
stream.flush()
|
|
159
|
+
os.fsync(stream.fileno())
|
|
160
|
+
with operation.publish_lock:
|
|
161
|
+
if operation.cancelled.is_set():
|
|
162
|
+
raise OSError(operation.cause)
|
|
163
|
+
if _protect_git_path(destination_filename, operation.context) != resolved_filename or os.path.islink(destination_filename):
|
|
164
|
+
raise OSError("The destination changed while the file was being saved.")
|
|
165
|
+
current_stat = os.stat(destination_filename) if os.path.exists(destination_filename) else None
|
|
166
|
+
if current_stat != original_stat:
|
|
167
|
+
raise OSError("The destination changed while the file was being saved. Choose it again.")
|
|
168
|
+
if original_stat is None:
|
|
169
|
+
# Linking publishes a complete file without overwriting a destination created after confirmation.
|
|
170
|
+
os.link(temporary_filename, resolved_filename)
|
|
171
|
+
os.unlink(temporary_filename)
|
|
172
|
+
else:
|
|
173
|
+
os.replace(temporary_filename, resolved_filename)
|
|
174
|
+
temporary_filename = ""
|
|
175
|
+
except (OSError, ValueError) as error:
|
|
176
|
+
cause = str(error)
|
|
177
|
+
finally:
|
|
178
|
+
if temporary_filename:
|
|
179
|
+
try:
|
|
180
|
+
os.unlink(temporary_filename)
|
|
181
|
+
except OSError as error:
|
|
182
|
+
cause = f"{cause}; temporary file cleanup failed: {error}"
|
|
183
|
+
completion_queue.put(OperationResult(operation.request_id, operation.destination_filename, cause))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class ToolResult:
|
|
188
|
+
"""Report startup acceptance separately from the external application's lifetime."""
|
|
189
|
+
|
|
190
|
+
request_id: object
|
|
191
|
+
label: str
|
|
192
|
+
repository_dir: str
|
|
193
|
+
program_filename: str
|
|
194
|
+
arguments: tuple[str, ...]
|
|
195
|
+
cause: str = ""
|
|
196
|
+
timed_out: bool = False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _run_until_answer(command_arguments: list[str], working_dir: str, run: Callable[..., subprocess.CompletedProcess[bytes]], **option_by_name: object) -> None:
|
|
200
|
+
"""Run a command that answers as soon as it has handed its work over, and raise its refusal with what it wrote.
|
|
201
|
+
|
|
202
|
+
It is waited for, bounded by the tool startup timeout; the service calls this on its worker thread, never on the
|
|
203
|
+
window's.
|
|
204
|
+
"""
|
|
205
|
+
completed = run(command_arguments, stdin=subprocess.DEVNULL, capture_output=True,
|
|
206
|
+
timeout=TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND, check=False, cwd=working_dir, **option_by_name)
|
|
207
|
+
if completed.returncode != 0:
|
|
208
|
+
message = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
209
|
+
raise OSError(message or f"{os.path.basename(command_arguments[0])} exited with code {completed.returncode}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _run_open_command(arguments: list[str], working_dir: str, run: Callable[..., subprocess.CompletedProcess[bytes]]) -> None:
|
|
213
|
+
"""Ask LaunchServices, through macOS's open command, to open something, and raise its refusal with what open wrote.
|
|
214
|
+
|
|
215
|
+
open answers as soon as the application has been asked, so waiting for it never waits for the application.
|
|
216
|
+
"""
|
|
217
|
+
_run_until_answer([MACOS_OPEN_FILENAME, *arguments], working_dir, run)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _start_detached_terminal(program_filename: str, arguments: list[str], recipe: TerminalRecipe, repository_dir: str, platform: str,
|
|
221
|
+
popen: Callable[..., subprocess.Popen], activation: StartupActivation) -> None:
|
|
222
|
+
"""Start a terminal program that outlives GitPane, in the repository folder, without waiting for it.
|
|
223
|
+
|
|
224
|
+
Its environment is GitPane's without any inherited startup value, plus the one read when the user clicked: Qt
|
|
225
|
+
removes its own activation token from the C environment only, and never an inherited X11 startup identifier.
|
|
226
|
+
"""
|
|
227
|
+
option_by_name: dict[str, object] = {"cwd": repository_dir, "shell": False, "close_fds": True, "env": activation.environment(os.environ)}
|
|
228
|
+
if platform == WINDOWS_PLATFORM:
|
|
229
|
+
# Only the shell requested by the user receives a visible console; no PowerShell helper is launched.
|
|
230
|
+
option_by_name["creationflags"] = subprocess.CREATE_NEW_CONSOLE if recipe.new_console else subprocess.DETACHED_PROCESS
|
|
231
|
+
else:
|
|
232
|
+
option_by_name.update(start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
233
|
+
process = popen([program_filename, *arguments], **option_by_name)
|
|
234
|
+
# Popen never waits for terminal exit. Its own nonblocking cleanup reaps exited children on later starts.
|
|
235
|
+
process.poll()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def launch_terminal(descriptor: TerminalDescriptor, repository_dir: str, platform: str = sys.platform,
|
|
239
|
+
popen: Callable[..., subprocess.Popen] = subprocess.Popen,
|
|
240
|
+
run: Callable[..., subprocess.CompletedProcess[bytes]] = subprocess.run, *, activation: StartupActivation = NO_STARTUP_ACTIVATION) -> None:
|
|
241
|
+
"""Start an independent terminal with captured cwd and no constructed shell command.
|
|
242
|
+
|
|
243
|
+
The recipe's launch form decides how: its own program, or a program inside a macOS application, started detached;
|
|
244
|
+
or macOS's open command (run), whose refusal is raised with its message. A terminal whose program or application
|
|
245
|
+
has gone is refused before anything starts, instead of selecting another one. A terminal started directly receives
|
|
246
|
+
the startup value read when the user clicked, so its window may come to the front; macOS's open is asked as before.
|
|
247
|
+
"""
|
|
248
|
+
if not os.path.isdir(repository_dir):
|
|
249
|
+
raise FileNotFoundError(f"Folder unavailable: {repository_dir}")
|
|
250
|
+
arguments = terminal_arguments(descriptor, repository_dir)
|
|
251
|
+
recipe = RECIPE_BY_ID[descriptor.recipe_id]
|
|
252
|
+
program_filename = available_terminal_program(descriptor)
|
|
253
|
+
if not program_filename:
|
|
254
|
+
raise FileNotFoundError(f"Terminal unavailable : {descriptor.label} — {descriptor.program_filename}")
|
|
255
|
+
if recipe.launch == TerminalLaunch.OPEN:
|
|
256
|
+
_run_open_command(arguments, repository_dir, run)
|
|
257
|
+
return
|
|
258
|
+
_start_detached_terminal(program_filename, arguments, recipe, repository_dir, platform, popen, activation)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def available_terminal_program(descriptor: TerminalDescriptor) -> str:
|
|
262
|
+
"""Return what a terminal starts, its program or the macOS application folder open is asked for, or an empty text when it has gone.
|
|
263
|
+
|
|
264
|
+
These are the cheap checks that refuse a terminal before anything starts, so the service also runs them on the
|
|
265
|
+
window's thread before it asks for the click's startup value.
|
|
266
|
+
"""
|
|
267
|
+
if not descriptor.available:
|
|
268
|
+
return ""
|
|
269
|
+
recipe = RECIPE_BY_ID[descriptor.recipe_id]
|
|
270
|
+
if recipe.launch == TerminalLaunch.OPEN:
|
|
271
|
+
return descriptor.program_filename if os.path.isdir(descriptor.program_filename) else ""
|
|
272
|
+
program_filename = descriptor.program_filename
|
|
273
|
+
if recipe.launch == TerminalLaunch.BUNDLE_PROGRAM:
|
|
274
|
+
program_filename = bundle_recipe_program(recipe, descriptor.program_filename)
|
|
275
|
+
return program_filename if executable_available(program_filename) else ""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TextEditorCommand(Protocol):
|
|
279
|
+
"""Build the full start command of the editor that opens a working copy file.
|
|
280
|
+
|
|
281
|
+
Two real forms exist: an editor recipe from Settings or detection, started without a shell, and the editor line
|
|
282
|
+
Git itself would use, started the way Git starts it. Both answer the same question, so the launcher and the
|
|
283
|
+
unavailable reason never need to tell them apart.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def arguments(self, filename: str) -> list[str]:
|
|
287
|
+
"""Return the program followed by its arguments, the file as a single argument; raise ValueError with a readable cause when the editor cannot start."""
|
|
288
|
+
...
|
|
289
|
+
|
|
290
|
+
def answers_at_once(self) -> bool:
|
|
291
|
+
"""Tell whether the command hands the file over and ends at once with the system's answer, so its refusal can be reported.
|
|
292
|
+
|
|
293
|
+
Any other command is the editor itself, or a command that may wait for it, and is started detached without waiting.
|
|
294
|
+
"""
|
|
295
|
+
...
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass(frozen=True)
|
|
299
|
+
class RecipeTextEditor:
|
|
300
|
+
"""Start an editor recipe from Settings, or a detected one, as a plain argument list without any shell."""
|
|
301
|
+
|
|
302
|
+
recipe: EditorRecipe
|
|
303
|
+
|
|
304
|
+
def arguments(self, filename: str) -> list[str]:
|
|
305
|
+
"""Check the recipe still names a usable program, then insert the absolute filename as one literal argument."""
|
|
306
|
+
self.recipe.validate()
|
|
307
|
+
return [self.recipe.program_filename, *build_editor_arguments(self.recipe.argument_template, filename)]
|
|
308
|
+
|
|
309
|
+
def answers_at_once(self) -> bool:
|
|
310
|
+
"""Answer no: a recipe names the editor, which may wait until the file is closed."""
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# The open option that picks the default text editor of macOS, the one LaunchServices chooses (TextEdit unless the user changed it).
|
|
315
|
+
MACOS_DEFAULT_TEXT_EDITOR_OPEN_OPTIONS = ("-t",)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True)
|
|
319
|
+
class MacOpenTextEditor:
|
|
320
|
+
"""Open a file through macOS's open command, in the default text editor or in the application its options name.
|
|
321
|
+
|
|
322
|
+
open hands the file to that editor and returns at once, so nothing waits for the editor; no permission is asked.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
open_options: tuple[str, ...] = MACOS_DEFAULT_TEXT_EDITOR_OPEN_OPTIONS
|
|
326
|
+
|
|
327
|
+
def arguments(self, filename: str) -> list[str]:
|
|
328
|
+
"""Return open and its options, followed by the file name as one argument, exactly as given."""
|
|
329
|
+
return [MACOS_OPEN_FILENAME, *self.open_options, filename]
|
|
330
|
+
|
|
331
|
+
def answers_at_once(self) -> bool:
|
|
332
|
+
"""Answer yes: open ends as soon as LaunchServices has handed the file over, or refused it."""
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# The editor a platform opens when neither Settings nor detection gives one; a platform missing here greys the entry.
|
|
337
|
+
DEFAULT_TEXT_EDITOR_BY_PLATFORM: dict[str, TextEditorCommand] = {MACOS_PLATFORM: MacOpenTextEditor()}
|
|
338
|
+
# The shell through which Git itself starts an editor line on POSIX systems.
|
|
339
|
+
POSIX_SHELL_FILENAME = "/bin/sh"
|
|
340
|
+
GIT_EDITOR_ENVIRONMENT_NAME = "GIT_EDITOR"
|
|
341
|
+
VISUAL_ENVIRONMENT_NAME = "VISUAL"
|
|
342
|
+
EDITOR_ENVIRONMENT_NAME = "EDITOR"
|
|
343
|
+
TERM_ENVIRONMENT_NAME = "TERM"
|
|
344
|
+
DUMB_TERMINAL_NAME = "dumb"
|
|
345
|
+
# The editor value Git reads as "no editor at all".
|
|
346
|
+
GIT_NO_EDITOR_VALUE = ":"
|
|
347
|
+
FILE_NOT_IN_WORKTREE_REASON = "The file does not exist in the working tree."
|
|
348
|
+
NOT_A_FILE_REASON = "A folder cannot be opened in a text editor."
|
|
349
|
+
NO_TEXT_EDITOR_REASON = "No text editor set in Settings."
|
|
350
|
+
GIT_SHELL_NOT_FOUND_REASON = "The shell of Git was not found, so the editor set for Git cannot be started."
|
|
351
|
+
GIT_EDITOR_NULL_CHARACTER_REASON = "The editor set for Git or the file name contains a null character."
|
|
352
|
+
TEXT_EDITOR_LABEL = "text editor"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@dataclass(frozen=True)
|
|
356
|
+
class GitTextEditor:
|
|
357
|
+
"""Start the editor line set for Git (GIT_EDITOR, core.editor, VISUAL or EDITOR) the way Git starts it.
|
|
358
|
+
|
|
359
|
+
That value is a shell line the user wrote for Git, which Git runs through a shell, so it may hold options, quotes
|
|
360
|
+
or a variable. It is started the same way here: the line goes into the shell script, the file name only arrives
|
|
361
|
+
as a positional parameter, so a file name coming from a commit is never read as shell code. A Settings recipe is
|
|
362
|
+
still started without any shell.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
command_line: str
|
|
366
|
+
shell_filename: str
|
|
367
|
+
|
|
368
|
+
def arguments(self, filename: str) -> list[str]:
|
|
369
|
+
"""Return the shell, the script `<line> "$@"`, the line as the script name, then the absolute file name."""
|
|
370
|
+
if not self.shell_filename:
|
|
371
|
+
raise ValueError(GIT_SHELL_NOT_FOUND_REASON)
|
|
372
|
+
if "\0" in self.command_line or "\0" in filename:
|
|
373
|
+
raise ValueError(GIT_EDITOR_NULL_CHARACTER_REASON)
|
|
374
|
+
return [self.shell_filename, "-c", self.command_line + ' "$@"', self.command_line, os.path.abspath(filename)]
|
|
375
|
+
|
|
376
|
+
def answers_at_once(self) -> bool:
|
|
377
|
+
"""Answer no: the line set for Git starts the editor itself, which may wait until the file is closed."""
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def git_editor_line(environment_by_name: Mapping[str, str], core_editor: str) -> str:
|
|
382
|
+
"""Return the editor line Git would start, in Git's order, without Git's compiled-in default.
|
|
383
|
+
|
|
384
|
+
That default is not a choice of the user and usually names a terminal editor, which shows nothing without a
|
|
385
|
+
terminal, so nothing set gives an empty line. VISUAL only counts with a terminal that is not dumb, as in Git. The
|
|
386
|
+
first value that is not blank decides: the colon Git reads as "no editor" ends the search with no editor, as in
|
|
387
|
+
Git; an empty or blank value is skipped, since Git would fail to start it. No process is started: asking `git var`
|
|
388
|
+
would answer the compiled-in default too.
|
|
389
|
+
"""
|
|
390
|
+
terminal_name = environment_by_name.get(TERM_ENVIRONMENT_NAME, "")
|
|
391
|
+
candidates = [environment_by_name.get(GIT_EDITOR_ENVIRONMENT_NAME, ""), core_editor]
|
|
392
|
+
if terminal_name and terminal_name != DUMB_TERMINAL_NAME:
|
|
393
|
+
candidates.append(environment_by_name.get(VISUAL_ENVIRONMENT_NAME, ""))
|
|
394
|
+
candidates.append(environment_by_name.get(EDITOR_ENVIRONMENT_NAME, ""))
|
|
395
|
+
for candidate in candidates:
|
|
396
|
+
line = candidate.strip()
|
|
397
|
+
if not line:
|
|
398
|
+
continue
|
|
399
|
+
if line == GIT_NO_EDITOR_VALUE:
|
|
400
|
+
return ""
|
|
401
|
+
return line
|
|
402
|
+
return ""
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def uses_git_editor(platform: str = sys.platform) -> bool:
|
|
406
|
+
"""Tell whether Show in text editor may start the editor set for Git on this platform.
|
|
407
|
+
|
|
408
|
+
On Windows that editor is a graphical program chosen in the Git for Windows installer, so it opens a window. Elsewhere
|
|
409
|
+
it is often a terminal editor (vi, nano) which, started without a terminal, shows nothing and reports no error.
|
|
410
|
+
"""
|
|
411
|
+
return platform == WINDOWS_PLATFORM
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def git_shell_filename(platform: str = sys.platform, git_filename: str = "", git_utility_dir: str = "", which: Callable[[str], str | None] = shutil.which,
|
|
415
|
+
available: Callable[[str], bool] = executable_available, environment_by_name: Mapping[str, str] | None = None) -> str:
|
|
416
|
+
"""Name the shell that starts the editor line set for Git: /bin/sh on POSIX, the sh.exe of Git for Windows otherwise.
|
|
417
|
+
|
|
418
|
+
On Windows the sh.exe is searched like Git Bash, starting from the Git GitPane runs; nothing found gives an empty
|
|
419
|
+
name, which greys the entry with its reason. Only files are looked at.
|
|
420
|
+
"""
|
|
421
|
+
if platform != WINDOWS_PLATFORM:
|
|
422
|
+
return POSIX_SHELL_FILENAME
|
|
423
|
+
environment_by_name = os.environ if environment_by_name is None else environment_by_name
|
|
424
|
+
for candidate_filename in git_bash_candidate_filenames(SH_EXECUTABLE_BASENAME, git_filename, git_utility_dir, which, environment_by_name):
|
|
425
|
+
if inside_system_root(candidate_filename, environment_by_name):
|
|
426
|
+
continue
|
|
427
|
+
if available(candidate_filename):
|
|
428
|
+
return os.path.abspath(candidate_filename)
|
|
429
|
+
return ""
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def text_editor_command(editor_recipe: EditorRecipe, git_editor: str, shell_filename: Callable[[], str], *, saved: bool, platform: str = sys.platform) -> TextEditorCommand | None:
|
|
433
|
+
"""Choose the editor that opens a working copy file, with one rule per platform.
|
|
434
|
+
|
|
435
|
+
The recipe is the one Settings shows: the saved choice (``saved``), even when its program has gone, so its own reason
|
|
436
|
+
is shown instead of silently opening another editor; otherwise the detected editor, which is not saved.
|
|
437
|
+
|
|
438
|
+
On Windows the saved editor comes first, then the editor line set for Git, started by the sh.exe of Git for Windows,
|
|
439
|
+
then the detected editor (Notepad): the editor the user chose for Git in its installer keeps opening. Without that
|
|
440
|
+
sh.exe the detected editor opens; with nothing detected either, the Git line is kept so the entry is greyed with the
|
|
441
|
+
reason that the shell is missing. Searching the shell only looks at files.
|
|
442
|
+
|
|
443
|
+
Elsewhere an editor set for Git is often a terminal editor (vi, nano) which, started without a terminal, shows nothing
|
|
444
|
+
and reports no error. So only the saved or detected editor opens there; the Git line and the shell are never looked
|
|
445
|
+
at. With neither, macOS opens its default text editor (open -t), so the entry is never greyed for lack of an editor,
|
|
446
|
+
while Linux greys the entry with its reason. A macOS preset whose command only waits for the rebase, such as
|
|
447
|
+
TextEdit's separate waiting copy or the new waiting window of VS Code, Sublime Text and Zed, opens the file with
|
|
448
|
+
its plain open options instead, so it goes to the editor's window already open.
|
|
449
|
+
"""
|
|
450
|
+
if not uses_git_editor(platform):
|
|
451
|
+
if editor_recipe.program_filename:
|
|
452
|
+
open_options = file_open_options(editor_recipe)
|
|
453
|
+
if open_options:
|
|
454
|
+
return MacOpenTextEditor(open_options)
|
|
455
|
+
return RecipeTextEditor(editor_recipe)
|
|
456
|
+
return DEFAULT_TEXT_EDITOR_BY_PLATFORM.get(platform)
|
|
457
|
+
if saved and editor_recipe.program_filename:
|
|
458
|
+
return RecipeTextEditor(editor_recipe)
|
|
459
|
+
if git_editor:
|
|
460
|
+
git_shell = shell_filename()
|
|
461
|
+
if git_shell:
|
|
462
|
+
return GitTextEditor(git_editor, git_shell)
|
|
463
|
+
if editor_recipe.program_filename:
|
|
464
|
+
return RecipeTextEditor(editor_recipe)
|
|
465
|
+
return GitTextEditor(git_editor, "")
|
|
466
|
+
if editor_recipe.program_filename:
|
|
467
|
+
return RecipeTextEditor(editor_recipe)
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def text_editor_unavailable_reason(context: RepositoryContext, path: bytes, command: TextEditorCommand | None) -> str:
|
|
472
|
+
"""Tell why the working copy of a Git path cannot be opened in a text editor, or answer an empty reason.
|
|
473
|
+
|
|
474
|
+
The reason greys the menu entry, so it only looks at the disk and the chosen command and never starts a process.
|
|
475
|
+
"""
|
|
476
|
+
try:
|
|
477
|
+
file_location(context, path, allow_missing=True)
|
|
478
|
+
filename = worktree_filename(context, path)
|
|
479
|
+
except (OSError, ValueError) as error:
|
|
480
|
+
return str(error)
|
|
481
|
+
if os.path.isdir(filename):
|
|
482
|
+
return NOT_A_FILE_REASON
|
|
483
|
+
if not os.path.isfile(filename):
|
|
484
|
+
return FILE_NOT_IN_WORKTREE_REASON
|
|
485
|
+
if command is None:
|
|
486
|
+
return NO_TEXT_EDITOR_REASON
|
|
487
|
+
try:
|
|
488
|
+
command.arguments(filename)
|
|
489
|
+
except ValueError as error:
|
|
490
|
+
return str(error)
|
|
491
|
+
return ""
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def editor_environment() -> dict[str, str]:
|
|
495
|
+
"""Copy the environment without GitPane's own and trace variables, which an editor started by GitPane must not inherit."""
|
|
496
|
+
environment_by_name = {}
|
|
497
|
+
for name, value in os.environ.items():
|
|
498
|
+
if name.upper().startswith(EDITOR_HIDDEN_ENVIRONMENT_PREFIXES):
|
|
499
|
+
continue
|
|
500
|
+
environment_by_name[name] = value
|
|
501
|
+
return environment_by_name
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def launch_text_editor(command: TextEditorCommand, filename: str, platform: str = sys.platform,
|
|
505
|
+
popen: Callable[..., subprocess.Popen] = subprocess.Popen,
|
|
506
|
+
run: Callable[..., subprocess.CompletedProcess[bytes]] = subprocess.run, *, activation: StartupActivation = NO_STARTUP_ACTIVATION) -> None:
|
|
507
|
+
"""Start the chosen editor on a working copy file without ever waiting for it.
|
|
508
|
+
|
|
509
|
+
An editor set to wait until its window closes (`--wait`) would otherwise keep GitPane blocked for as long as the
|
|
510
|
+
file stays open. A command that only hands the file over, such as macOS's open, ends at once with the system's
|
|
511
|
+
answer: it is run until that answer (run), so a refusal is reported with its message, as for a terminal. The
|
|
512
|
+
editor receives the startup value read when the user clicked (an X11 startup identifier or a Wayland activation
|
|
513
|
+
token), so its window may come to the front, and never one GitPane inherited.
|
|
514
|
+
"""
|
|
515
|
+
arguments = command.arguments(filename)
|
|
516
|
+
working_dir = os.path.dirname(os.path.abspath(filename))
|
|
517
|
+
if command.answers_at_once():
|
|
518
|
+
_run_until_answer(arguments, working_dir, run, env=activation.environment(editor_environment()))
|
|
519
|
+
return
|
|
520
|
+
option_by_name: dict[str, object] = {"cwd": working_dir, "shell": False, "close_fds": True, "env": activation.environment(editor_environment())}
|
|
521
|
+
if platform == WINDOWS_PLATFORM:
|
|
522
|
+
option_by_name["creationflags"] = subprocess.DETACHED_PROCESS
|
|
523
|
+
else:
|
|
524
|
+
option_by_name.update(start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
525
|
+
process = popen(arguments, **option_by_name)
|
|
526
|
+
# Popen never waits for the editor to exit. Its own nonblocking cleanup reaps exited children on later starts.
|
|
527
|
+
process.poll()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class TerminalStart(Protocol):
|
|
531
|
+
"""Start a terminal in a folder, handing it the startup value read when the user clicked."""
|
|
532
|
+
|
|
533
|
+
def __call__(self, descriptor: TerminalDescriptor, repository_dir: str, *, activation: StartupActivation) -> None:
|
|
534
|
+
"""Start the terminal without waiting for it, or raise why it could not start."""
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class TextEditorStart(Protocol):
|
|
538
|
+
"""Start an editor on a working copy file, handing it the startup value read when the user clicked."""
|
|
539
|
+
|
|
540
|
+
def __call__(self, command: TextEditorCommand, filename: str, *, activation: StartupActivation) -> None:
|
|
541
|
+
"""Start the editor without waiting for it, or raise why it could not start."""
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def open_explorer(repository_dir: str, open_url: Callable[[QUrl], bool] = QDesktopServices.openUrl) -> None:
|
|
545
|
+
"""Ask the desktop to open the exact local directory and report startup refusal."""
|
|
546
|
+
if not os.path.isdir(repository_dir):
|
|
547
|
+
raise FileNotFoundError(f"Folder unavailable: {repository_dir}")
|
|
548
|
+
if not open_url(QUrl.fromLocalFile(repository_dir)):
|
|
549
|
+
raise OSError("The system refused to open the folder in the file explorer.")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@dataclass
|
|
553
|
+
class PendingLaunch:
|
|
554
|
+
"""Retain capacity after timeout until an uninterruptible OS startup actually returns."""
|
|
555
|
+
|
|
556
|
+
result: ToolResult
|
|
557
|
+
deadline: float
|
|
558
|
+
reported: bool = False
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _launch_worker(start: Callable[[], None], result: ToolResult, completion_queue: queue.SimpleQueue[ToolResult]) -> None:
|
|
562
|
+
"""Send plain data back from a daemon worker without touching any Qt object."""
|
|
563
|
+
cause = ""
|
|
564
|
+
try:
|
|
565
|
+
start()
|
|
566
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
567
|
+
cause = str(error)
|
|
568
|
+
completion_queue.put(ToolResult(result.request_id, result.label, result.repository_dir, result.program_filename, result.arguments, cause))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _reveal_worker(reveal: Callable[[str, str], bool], filename: str, startup_id: str, result: ToolResult, completion_queue: queue.SimpleQueue[tuple[ToolResult, bool]]) -> None:
|
|
572
|
+
"""Request native selection off the GUI thread and leave folder fallback to Qt.
|
|
573
|
+
|
|
574
|
+
A selection helper that does not answer in time counts as no selection, so the folder still opens; any other
|
|
575
|
+
failure is reported with its cause.
|
|
576
|
+
"""
|
|
577
|
+
cause = ""
|
|
578
|
+
selected = False
|
|
579
|
+
try:
|
|
580
|
+
selected = reveal(filename, startup_id)
|
|
581
|
+
except subprocess.TimeoutExpired:
|
|
582
|
+
selected = False
|
|
583
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
584
|
+
cause = str(error)
|
|
585
|
+
completion_queue.put((ToolResult(result.request_id, result.label, result.repository_dir, result.program_filename, result.arguments, cause), selected))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class SystemToolService(QObject):
|
|
589
|
+
"""Bound startup work while keeping external terminals independent of application shutdown."""
|
|
590
|
+
|
|
591
|
+
started = Signal(object, str)
|
|
592
|
+
finished = Signal(object)
|
|
593
|
+
export_finished = Signal(object)
|
|
594
|
+
export_idle = Signal()
|
|
595
|
+
|
|
596
|
+
def __init__(self, launch: TerminalStart = launch_terminal,
|
|
597
|
+
explorer: Callable[[str], None] = open_explorer, parent: QObject | None = None,
|
|
598
|
+
export_writer: Callable[[FileExport, queue.SimpleQueue[OperationResult]], None] = _write_export,
|
|
599
|
+
reveal: Callable[[str, str], bool] = reveal_file,
|
|
600
|
+
text_editor_launch: TextEditorStart = launch_text_editor,
|
|
601
|
+
activation: Callable[[], StartupActivation] = no_startup_activation,
|
|
602
|
+
reveal_available: Callable[[], bool] = selection_helper_available) -> None:
|
|
603
|
+
"""Inject OS calls and return worker results through a GUI-owned polling timer.
|
|
604
|
+
|
|
605
|
+
The activation source tells the desktop that the user asked for the program GitPane starts (an X11 startup
|
|
606
|
+
identifier or a Wayland activation token), so its window may come to the front; it is read on the GUI thread
|
|
607
|
+
when the user clicks, before the worker starts, and never otherwise. The application injects the desktop's
|
|
608
|
+
source, which lives in the interface layer; without it, no value is sent and the desktop decides alone. It is
|
|
609
|
+
not read for a launch the cheap checks already know will not use it (a terminal that has gone, no selection
|
|
610
|
+
helper for Show in folder): under Wayland a token nobody uses shows a busy pointer for up to 15 s.
|
|
611
|
+
"""
|
|
612
|
+
super().__init__(parent)
|
|
613
|
+
self.launch = launch
|
|
614
|
+
self.text_editor_launch = text_editor_launch
|
|
615
|
+
self.explorer = explorer
|
|
616
|
+
self.reveal = reveal
|
|
617
|
+
self.reveal_available = reveal_available
|
|
618
|
+
self.activation = activation
|
|
619
|
+
self.export_writer = export_writer
|
|
620
|
+
self.export_by_id: dict[object, FileExport] = {}
|
|
621
|
+
self.export_completion_queue: queue.SimpleQueue[OperationResult] = queue.SimpleQueue()
|
|
622
|
+
self.pending_by_id: dict[object, PendingLaunch] = {}
|
|
623
|
+
self.completion_queue: queue.SimpleQueue[ToolResult] = queue.SimpleQueue()
|
|
624
|
+
self.reveal_completion_queue: queue.SimpleQueue[tuple[ToolResult, bool]] = queue.SimpleQueue()
|
|
625
|
+
self.closed = False
|
|
626
|
+
self.timer = QTimer(self)
|
|
627
|
+
self.timer.setInterval(TOOL_RESULT_POLL_MS)
|
|
628
|
+
self.timer.timeout.connect(self._poll)
|
|
629
|
+
|
|
630
|
+
def show_file_location(self, request_id: object, context: RepositoryContext, path: bytes, *, allow_missing: bool = False) -> None:
|
|
631
|
+
"""Select a contained working file, falling back to its folder when selection is unavailable."""
|
|
632
|
+
if self.closed or request_id in self.pending_by_id:
|
|
633
|
+
return
|
|
634
|
+
try:
|
|
635
|
+
directory = file_location(context, path, allow_missing=allow_missing)
|
|
636
|
+
filename = worktree_filename(context, path)
|
|
637
|
+
except (OSError, ValueError) as error:
|
|
638
|
+
self.finished.emit(ToolResult(request_id, "file location", context.repository_dir, "", (), str(error)))
|
|
639
|
+
return
|
|
640
|
+
if not os.path.lexists(filename):
|
|
641
|
+
self.open_explorer(request_id, directory)
|
|
642
|
+
return
|
|
643
|
+
result = ToolResult(request_id, "file explorer", directory, "", ())
|
|
644
|
+
if len(self.pending_by_id) >= TOOL_START_ACTIVE_LIMIT:
|
|
645
|
+
self.finished.emit(ToolResult(request_id, result.label, directory, "", (), "Too many tools are starting. Try again after they finish."))
|
|
646
|
+
return
|
|
647
|
+
self.pending_by_id[request_id] = PendingLaunch(result, time.monotonic() + TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
|
|
648
|
+
self.started.emit(request_id, result.label)
|
|
649
|
+
# Read now, on the GUI thread, so the value is the one of this request and Qt windows are only touched here.
|
|
650
|
+
activation = self.activation() if self.reveal_available() else NO_STARTUP_ACTIVATION
|
|
651
|
+
threading.Thread(target=_reveal_worker, args=(self.reveal, filename, activation.value, result, self.reveal_completion_queue), daemon=True).start()
|
|
652
|
+
self.timer.start()
|
|
653
|
+
|
|
654
|
+
def save_historical_file(self, request_id: object, context: RepositoryContext, destination_filename: str,
|
|
655
|
+
read: Callable[[Callable[[bytes], None], Callable[[int], None]], None], cancel_read: Callable[[], None], overwrite_confirmed: bool = False) -> None:
|
|
656
|
+
"""Stream an immutable Git read into a bounded, cancellable atomic disk writer."""
|
|
657
|
+
if self.closed or len(self.export_by_id) >= FILE_EXPORT_ACTIVE_LIMIT:
|
|
658
|
+
self.export_finished.emit(OperationResult(request_id, destination_filename, "Another file export is still running. Try again after it finishes."))
|
|
659
|
+
return
|
|
660
|
+
operation = FileExport(request_id, context, destination_filename, overwrite_confirmed, cancel_read)
|
|
661
|
+
self.export_by_id[request_id] = operation
|
|
662
|
+
threading.Thread(target=self.export_writer, args=(operation, self.export_completion_queue), daemon=True).start()
|
|
663
|
+
self.timer.start()
|
|
664
|
+
|
|
665
|
+
def consume(chunk: bytes) -> None:
|
|
666
|
+
"""Keep UI delivery nonblocking even when the destination disk stalls."""
|
|
667
|
+
if operation.cancelled.is_set():
|
|
668
|
+
return
|
|
669
|
+
try:
|
|
670
|
+
for offset in range(0, len(chunk), PROCESS_CHUNK_BYTES):
|
|
671
|
+
operation.chunks.put_nowait(chunk[offset:offset + PROCESS_CHUNK_BYTES])
|
|
672
|
+
except queue.Full:
|
|
673
|
+
self.fail_export(request_id, "The destination cannot keep up with the file stream. Saving was cancelled.")
|
|
674
|
+
|
|
675
|
+
def completed(size: int) -> None:
|
|
676
|
+
"""Allow publication only after Git confirms the complete object framing."""
|
|
677
|
+
try:
|
|
678
|
+
operation.chunks.put_nowait(size)
|
|
679
|
+
except queue.Full:
|
|
680
|
+
self.fail_export(request_id, "The destination cannot keep up with the file stream. Saving was cancelled.")
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
read(consume, completed)
|
|
684
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
685
|
+
self.fail_export(request_id, str(error))
|
|
686
|
+
|
|
687
|
+
def fail_export(self, request_id: object, cause: str) -> None:
|
|
688
|
+
"""Stop a failed Git read and let the disk worker remove its temporary file."""
|
|
689
|
+
operation = self.export_by_id.get(request_id)
|
|
690
|
+
if operation is None:
|
|
691
|
+
return
|
|
692
|
+
with operation.publish_lock:
|
|
693
|
+
operation.cause = cause
|
|
694
|
+
operation.cancelled.set()
|
|
695
|
+
operation.cancel_read()
|
|
696
|
+
|
|
697
|
+
def cancel_export(self, request_id: object) -> None:
|
|
698
|
+
"""Cancel an unfinished export without touching an existing destination."""
|
|
699
|
+
self.fail_export(request_id, "Saving was cancelled.")
|
|
700
|
+
|
|
701
|
+
def launch_terminal(self, request_id: object, descriptor: TerminalDescriptor, repository_dir: str) -> bool:
|
|
702
|
+
"""Capture a request once and schedule startup without blocking the Qt event loop."""
|
|
703
|
+
if self.closed or request_id in self.pending_by_id:
|
|
704
|
+
return False
|
|
705
|
+
result = ToolResult(request_id, descriptor.label, repository_dir, descriptor.program_filename, tuple(terminal_arguments(descriptor, repository_dir)))
|
|
706
|
+
if len(self.pending_by_id) >= TOOL_START_ACTIVE_LIMIT:
|
|
707
|
+
self.finished.emit(ToolResult(request_id, result.label, repository_dir, result.program_filename, result.arguments, "Too many tools are starting. Try again after they finish."))
|
|
708
|
+
return False
|
|
709
|
+
self.pending_by_id[request_id] = PendingLaunch(result, time.monotonic() + TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
|
|
710
|
+
self.started.emit(request_id, descriptor.label)
|
|
711
|
+
# Read now, on the GUI thread, so the value is the one of this request and Qt windows are only touched here.
|
|
712
|
+
activation = self.activation() if os.path.isdir(repository_dir) and available_terminal_program(descriptor) else NO_STARTUP_ACTIVATION
|
|
713
|
+
threading.Thread(target=_launch_worker, args=(lambda: self.launch(descriptor, repository_dir, activation=activation), result, self.completion_queue), daemon=True).start()
|
|
714
|
+
self.timer.start()
|
|
715
|
+
return True
|
|
716
|
+
|
|
717
|
+
def launch_text_editor(self, request_id: object, command: TextEditorCommand, filename: str) -> bool:
|
|
718
|
+
"""Start the editor on a working copy file off the Qt thread, with the same startup limit and timeout as a terminal.
|
|
719
|
+
|
|
720
|
+
The result names the editor program and its arguments, so a failure tells which editor did not start.
|
|
721
|
+
"""
|
|
722
|
+
if self.closed or request_id in self.pending_by_id:
|
|
723
|
+
return False
|
|
724
|
+
directory = os.path.dirname(filename)
|
|
725
|
+
try:
|
|
726
|
+
program_filename, *argument_list = command.arguments(filename)
|
|
727
|
+
except ValueError as error:
|
|
728
|
+
self.finished.emit(ToolResult(request_id, TEXT_EDITOR_LABEL, directory, "", (), str(error)))
|
|
729
|
+
return False
|
|
730
|
+
result = ToolResult(request_id, TEXT_EDITOR_LABEL, directory, program_filename, tuple(argument_list))
|
|
731
|
+
if len(self.pending_by_id) >= TOOL_START_ACTIVE_LIMIT:
|
|
732
|
+
self.finished.emit(ToolResult(request_id, result.label, directory, result.program_filename, result.arguments, "Too many tools are starting. Try again after they finish."))
|
|
733
|
+
return False
|
|
734
|
+
self.pending_by_id[request_id] = PendingLaunch(result, time.monotonic() + TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
|
|
735
|
+
self.started.emit(request_id, result.label)
|
|
736
|
+
# Read now, on the GUI thread, so the value is the one of this request and Qt windows are only touched here.
|
|
737
|
+
activation = self.activation()
|
|
738
|
+
threading.Thread(target=_launch_worker, args=(lambda: self.text_editor_launch(command, filename, activation=activation), result, self.completion_queue), daemon=True).start()
|
|
739
|
+
self.timer.start()
|
|
740
|
+
return True
|
|
741
|
+
|
|
742
|
+
def open_explorer(self, request_id: object, repository_dir: str) -> None:
|
|
743
|
+
"""Invoke Qt's desktop integration on the GUI thread after displaying progress."""
|
|
744
|
+
if self.closed or request_id in self.pending_by_id:
|
|
745
|
+
return
|
|
746
|
+
result = ToolResult(request_id, "file explorer", repository_dir, "", ())
|
|
747
|
+
self.pending_by_id[request_id] = PendingLaunch(result, time.monotonic() + TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
|
|
748
|
+
self.started.emit(request_id, result.label)
|
|
749
|
+
QTimer.singleShot(0, self, lambda: self._open_explorer(result))
|
|
750
|
+
|
|
751
|
+
def _open_explorer(self, result: ToolResult) -> None:
|
|
752
|
+
"""Open a validated folder through Qt and report failures to the requesting window."""
|
|
753
|
+
if self.closed:
|
|
754
|
+
return
|
|
755
|
+
cause = ""
|
|
756
|
+
try:
|
|
757
|
+
self.explorer(result.repository_dir)
|
|
758
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
759
|
+
cause = str(error)
|
|
760
|
+
self.pending_by_id.pop(result.request_id, None)
|
|
761
|
+
self.finished.emit(ToolResult(result.request_id, result.label, result.repository_dir, "", (), cause))
|
|
762
|
+
|
|
763
|
+
def _poll(self) -> None:
|
|
764
|
+
"""Report a bounded startup wait without pretending a timed-out OS call was cancelled."""
|
|
765
|
+
while True:
|
|
766
|
+
try:
|
|
767
|
+
result = self.export_completion_queue.get_nowait()
|
|
768
|
+
except queue.Empty:
|
|
769
|
+
break
|
|
770
|
+
operation = self.export_by_id.pop(result.request_id, None)
|
|
771
|
+
if operation is not None:
|
|
772
|
+
operation.cancel_read()
|
|
773
|
+
self.export_finished.emit(result)
|
|
774
|
+
if not self.export_by_id:
|
|
775
|
+
self.export_idle.emit()
|
|
776
|
+
while True:
|
|
777
|
+
try:
|
|
778
|
+
result, selected = self.reveal_completion_queue.get_nowait()
|
|
779
|
+
except queue.Empty:
|
|
780
|
+
break
|
|
781
|
+
pending = self.pending_by_id.get(result.request_id)
|
|
782
|
+
if pending is None:
|
|
783
|
+
continue
|
|
784
|
+
if pending.reported:
|
|
785
|
+
self.pending_by_id.pop(result.request_id)
|
|
786
|
+
continue
|
|
787
|
+
if not selected and not result.cause:
|
|
788
|
+
self._open_explorer(result)
|
|
789
|
+
continue
|
|
790
|
+
self.pending_by_id.pop(result.request_id)
|
|
791
|
+
self.finished.emit(result)
|
|
792
|
+
while True:
|
|
793
|
+
try:
|
|
794
|
+
result = self.completion_queue.get_nowait()
|
|
795
|
+
except queue.Empty:
|
|
796
|
+
break
|
|
797
|
+
pending = self.pending_by_id.pop(result.request_id, None)
|
|
798
|
+
if pending is not None and not pending.reported:
|
|
799
|
+
self.finished.emit(result)
|
|
800
|
+
for pending in tuple(self.pending_by_id.values()):
|
|
801
|
+
if not pending.reported and time.monotonic() >= pending.deadline:
|
|
802
|
+
pending.reported = True
|
|
803
|
+
result = pending.result
|
|
804
|
+
self.finished.emit(ToolResult(result.request_id, result.label, result.repository_dir, result.program_filename, result.arguments,
|
|
805
|
+
"Startup timed out. The system may still open this tool; another launch will wait for its response.", True))
|
|
806
|
+
if not self.pending_by_id and not self.export_by_id:
|
|
807
|
+
self.timer.stop()
|
|
808
|
+
|
|
809
|
+
def close(self) -> None:
|
|
810
|
+
"""Stop receiving startup reports without terminating already launched tools."""
|
|
811
|
+
self.closed = True
|
|
812
|
+
for request_id in tuple(self.export_by_id):
|
|
813
|
+
self.cancel_export(request_id)
|
|
814
|
+
if not self.export_by_id:
|
|
815
|
+
self.timer.stop()
|
|
816
|
+
self.pending_by_id.clear()
|