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,481 @@
|
|
|
1
|
+
"""Keep the external text editor recipe, literal file arguments included, separate from Git preferences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QProcess
|
|
11
|
+
|
|
12
|
+
from gitpane.config import LINUX_PLATFORM, MACOS_OPEN_FILENAME, MACOS_PLATFORM, WINDOWS_PLATFORM
|
|
13
|
+
from gitpane.system.path_identity import canonical_path
|
|
14
|
+
from gitpane.system.xdg_session import xdg_data_home
|
|
15
|
+
from gitpane.tool.app_bundle import (
|
|
16
|
+
APPLICATION_SEARCH_DIRS,
|
|
17
|
+
BUNDLE_SUFFIX,
|
|
18
|
+
CONTENTS_DIR_BASENAME,
|
|
19
|
+
MACOS_DIR_BASENAME,
|
|
20
|
+
TEXTEDIT_BUNDLE_IDENTIFIER,
|
|
21
|
+
chosen_application_program,
|
|
22
|
+
enclosing_bundle_dir,
|
|
23
|
+
is_textedit,
|
|
24
|
+
read_bundle,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable, Mapping
|
|
29
|
+
|
|
30
|
+
EDITOR_PRESET_PREFERENCE_KEY = "editor_preset"
|
|
31
|
+
EDITOR_FILENAME_PREFERENCE_KEY = "editor_filename"
|
|
32
|
+
EDITOR_ARGUMENT_PREFERENCE_KEY = "editor_arguments"
|
|
33
|
+
EDITOR_DEFAULT_BY_KEY = {EDITOR_PRESET_PREFERENCE_KEY: "custom", EDITOR_FILENAME_PREFERENCE_KEY: "", EDITOR_ARGUMENT_PREFERENCE_KEY: ""}
|
|
34
|
+
EDITOR_INITIAL_PATH = os.environ.get("PATH", "")
|
|
35
|
+
EDITOR_INITIAL_DIR = os.path.realpath(os.getcwd())
|
|
36
|
+
EDITOR_WINDOWS_LAUNCHER_EXTENSION_SET = {".cmd", ".bat"}
|
|
37
|
+
# The argument an editor command names the file with; a command without it gets the file at its end.
|
|
38
|
+
EDITOR_FILE_PARAMETER = "{file}"
|
|
39
|
+
FLATPAK_PROGRAM_NAME = "flatpak"
|
|
40
|
+
# Flatpak's two default installations: the system's, and the user's under the user's data folder.
|
|
41
|
+
FLATPAK_SYSTEM_INSTALLATION_DIR = "/var/lib/flatpak"
|
|
42
|
+
FLATPAK_USER_INSTALLATION_BASENAME = "flatpak"
|
|
43
|
+
FLATPAK_EXPORTS_DIR_BASENAME = "exports"
|
|
44
|
+
# Where an installation exports a program for each application it deploys, named after the application's identifier.
|
|
45
|
+
FLATPAK_EXPORTED_PROGRAM_PARTS = (FLATPAK_EXPORTS_DIR_BASENAME, "bin")
|
|
46
|
+
# Where an installation exports the desktop file of each application it deploys.
|
|
47
|
+
FLATPAK_EXPORTED_APPLICATION_PARTS = (FLATPAK_EXPORTS_DIR_BASENAME, "share", "applications")
|
|
48
|
+
# The command and the marks Flatpak itself writes in the desktop file of an exported application, so that a file the
|
|
49
|
+
# application's sandbox cannot see is handed to it through the document portal.
|
|
50
|
+
FLATPAK_RUN_ARGUMENTS = "run --file-forwarding"
|
|
51
|
+
FLATPAK_FILE_FORWARDING_MARK = "@@"
|
|
52
|
+
# The platform of a preset offered on every system.
|
|
53
|
+
ALL_PLATFORM = "all"
|
|
54
|
+
# Systems where Settings lists only the editors found on the machine: on these systems an editor is a program or an
|
|
55
|
+
# application GitPane can look for on disk, and a list of editors the user does not have would only be noise. Windows
|
|
56
|
+
# keeps its whole list.
|
|
57
|
+
INSTALLED_ONLY_PRESET_PLATFORM_SET = frozenset({MACOS_PLATFORM, LINUX_PLATFORM})
|
|
58
|
+
UNAVAILABLE_EDITOR_TEXT = "The configured editor is unavailable. Open Settings to check its executable and arguments."
|
|
59
|
+
TEXTEDIT_PRESET_ID = "textedit-macos"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def editor_platform(platform: str) -> str:
|
|
63
|
+
"""Name the presets ``platform`` uses, in the platform vocabulary of the configuration.
|
|
64
|
+
|
|
65
|
+
Windows and macOS have their own presets; Linux, whatever its version suffix, and any other Unix system use the
|
|
66
|
+
Linux presets, as they always did.
|
|
67
|
+
"""
|
|
68
|
+
if platform in (WINDOWS_PLATFORM, MACOS_PLATFORM):
|
|
69
|
+
return platform
|
|
70
|
+
return LINUX_PLATFORM
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
EDITOR_PLATFORM = editor_platform(sys.platform)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class EditorPreset:
|
|
78
|
+
"""Describe a selectable editor: the programs it can be found as and the arguments its command passes before the file."""
|
|
79
|
+
|
|
80
|
+
preset_id: str
|
|
81
|
+
label: str
|
|
82
|
+
platform: str
|
|
83
|
+
candidate_programs: tuple[str, ...]
|
|
84
|
+
arguments: str
|
|
85
|
+
# Places of the program relative to an applications folder, application folder name first; tried before the
|
|
86
|
+
# names searched in PATH.
|
|
87
|
+
bundle_programs: tuple[tuple[str, ...], ...] = ()
|
|
88
|
+
# Options of macOS's open command that show a file in this editor, for a preset whose own arguments open a new window
|
|
89
|
+
# or a separate copy of the editor and wait for it; empty when the preset's own command already fits a plain look at a
|
|
90
|
+
# file.
|
|
91
|
+
file_open_options: tuple[str, ...] = ()
|
|
92
|
+
# Names of the desktop files under which a freedesktop desktop knows this editor, whether it came as a distribution
|
|
93
|
+
# package, a snap or a Flatpak.
|
|
94
|
+
desktop_file_ids: tuple[str, ...] = ()
|
|
95
|
+
# Identifier of the editor's Flatpak application; empty when the editor has none on Flathub.
|
|
96
|
+
flatpak_app_id: str = ""
|
|
97
|
+
# Identifiers the editor's macOS application declares, under which LaunchServices names it as the default editor.
|
|
98
|
+
bundle_identifiers: tuple[str, ...] = ()
|
|
99
|
+
|
|
100
|
+
def carries_default_editor_id(self, default_editor_id: str) -> bool:
|
|
101
|
+
"""Tell whether the identifier a system names its default text editor by stands for this preset.
|
|
102
|
+
|
|
103
|
+
A desktop file name is compared as written, as freedesktop desktops write it; an application identifier is
|
|
104
|
+
compared without case, since LaunchServices writes the ones it keeps in lower case.
|
|
105
|
+
"""
|
|
106
|
+
if default_editor_id in self.desktop_file_ids:
|
|
107
|
+
return True
|
|
108
|
+
folded_id = default_editor_id.casefold()
|
|
109
|
+
for bundle_identifier in self.bundle_identifiers:
|
|
110
|
+
if bundle_identifier.casefold() == folded_id:
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def bundle_program_places(bundle_basenames: tuple[str, ...], program_parts: tuple[str, ...]) -> tuple[tuple[str, ...], ...]:
|
|
116
|
+
"""Build the places of one program inside each of the application folder names it ships under, in that order."""
|
|
117
|
+
places = []
|
|
118
|
+
for bundle_basename in bundle_basenames:
|
|
119
|
+
places.append((bundle_basename, *program_parts))
|
|
120
|
+
return tuple(places)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# TextEdit returns at once when started directly. The preset's command asks macOS's open command for a separate copy of
|
|
124
|
+
# it (-n) for the file and waits until that copy quits (-W), without touching a TextEdit already open with other
|
|
125
|
+
# documents. Named by its identifier, so a renamed application is still found.
|
|
126
|
+
TEXTEDIT_ARGUMENTS = "-W -n -b " + TEXTEDIT_BUNDLE_IDENTIFIER
|
|
127
|
+
# Show in text editor waits for nothing, so the file simply opens in the user's own TextEdit.
|
|
128
|
+
TEXTEDIT_FILE_OPEN_OPTIONS = ("-b", TEXTEDIT_BUNDLE_IDENTIFIER)
|
|
129
|
+
# The other macOS presets open a new window and wait (--new-window --wait, or --wait for Zed), which the rebase needs to
|
|
130
|
+
# tell when its file was closed. Show in text editor hands the file to the editor through open instead, so it goes to
|
|
131
|
+
# the window already open and nothing is left waiting. VS Code and Zed are named by their one identifier.
|
|
132
|
+
VSCODE_BUNDLE_IDENTIFIER = "com.microsoft.VSCode"
|
|
133
|
+
ZED_BUNDLE_IDENTIFIER = "dev.zed.Zed"
|
|
134
|
+
# Sublime Text 4 and Sublime Text 3 ship under the same application name with their own identifier, and a recipe does
|
|
135
|
+
# not record which one is installed: open names it by that name, which finds whichever version LaunchServices knows,
|
|
136
|
+
# where one identifier would miss the other version.
|
|
137
|
+
SUBLIME_TEXT_APPLICATION_NAME = "Sublime Text"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
EDITOR_PRESETS = (
|
|
141
|
+
EditorPreset("custom", "Custom", ALL_PLATFORM, (), ""),
|
|
142
|
+
EditorPreset("notepad", "Notepad", WINDOWS_PLATFORM, ("notepad.exe", os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "notepad.exe")), ""),
|
|
143
|
+
EditorPreset("notepad++", "Notepad++", WINDOWS_PLATFORM, ("notepad++.exe", os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "notepad++", "notepad++.exe")), "-multiInst -nosession"),
|
|
144
|
+
EditorPreset("sublime-windows", "Sublime Text", WINDOWS_PLATFORM, ("sublime_text.exe",), "--new-window --wait"),
|
|
145
|
+
# The Linux order is the one detection falls back on when the desktop names no known editor: the desktops' own
|
|
146
|
+
# editors first, gedit after the GNOME editor that replaced it, the developers' editors last. Their arguments are
|
|
147
|
+
# empty: the external editor only shows a file, the way the desktop's file manager opens it, so the file goes alone
|
|
148
|
+
# to the editor's window already open. The identifiers are written in the users' preferences and are never renamed.
|
|
149
|
+
EditorPreset("gnome-text-editor", "GNOME Text Editor", LINUX_PLATFORM, ("gnome-text-editor",), "",
|
|
150
|
+
desktop_file_ids=("org.gnome.TextEditor.desktop",), flatpak_app_id="org.gnome.TextEditor"),
|
|
151
|
+
EditorPreset("kate", "Kate", LINUX_PLATFORM, ("kate",), "", desktop_file_ids=("org.kde.kate.desktop",), flatpak_app_id="org.kde.kate"),
|
|
152
|
+
EditorPreset("kwrite", "KWrite", LINUX_PLATFORM, ("kwrite",), "", desktop_file_ids=("org.kde.kwrite.desktop",), flatpak_app_id="org.kde.kwrite"),
|
|
153
|
+
EditorPreset("xed", "Xed", LINUX_PLATFORM, ("xed",), "", desktop_file_ids=("org.x.editor.desktop", "xed.desktop")),
|
|
154
|
+
EditorPreset("mousepad", "Mousepad", LINUX_PLATFORM, ("mousepad",), "", desktop_file_ids=("org.xfce.mousepad.desktop", "mousepad.desktop"), flatpak_app_id="org.xfce.mousepad"),
|
|
155
|
+
EditorPreset("pluma", "Pluma", LINUX_PLATFORM, ("pluma",), "", desktop_file_ids=("pluma.desktop",)),
|
|
156
|
+
EditorPreset("featherpad", "FeatherPad", LINUX_PLATFORM, ("featherpad",), "", desktop_file_ids=("featherpad.desktop",)),
|
|
157
|
+
EditorPreset("cosmic-edit", "COSMIC Text Editor", LINUX_PLATFORM, ("cosmic-edit",), "", desktop_file_ids=("com.system76.CosmicEdit.desktop",)),
|
|
158
|
+
EditorPreset("elementary-code", "Code (elementary)", LINUX_PLATFORM, ("io.elementary.code",), "",
|
|
159
|
+
desktop_file_ids=("io.elementary.code.desktop",), flatpak_app_id="io.elementary.code"),
|
|
160
|
+
EditorPreset("gedit", "Gedit", LINUX_PLATFORM, ("gedit",), "", desktop_file_ids=("org.gnome.gedit.desktop", "gedit.desktop"), flatpak_app_id="org.gnome.gedit"),
|
|
161
|
+
EditorPreset("code", "VS Code", LINUX_PLATFORM, ("code",), "",
|
|
162
|
+
desktop_file_ids=("code.desktop", "code_code.desktop", "com.visualstudio.code.desktop"), flatpak_app_id="com.visualstudio.code"),
|
|
163
|
+
EditorPreset("sublime-linux", "Sublime Text", LINUX_PLATFORM, ("subl",), "",
|
|
164
|
+
desktop_file_ids=("sublime_text.desktop", "sublime-text_subl.desktop", "com.sublimetext.three.desktop"), flatpak_app_id="com.sublimetext.three"),
|
|
165
|
+
EditorPreset(TEXTEDIT_PRESET_ID, "TextEdit", MACOS_PLATFORM, (MACOS_OPEN_FILENAME,), TEXTEDIT_ARGUMENTS, file_open_options=TEXTEDIT_FILE_OPEN_OPTIONS,
|
|
166
|
+
bundle_identifiers=(TEXTEDIT_BUNDLE_IDENTIFIER,)),
|
|
167
|
+
EditorPreset("code-macos", "VS Code", MACOS_PLATFORM, ("code",), "--new-window --wait", file_open_options=("-b", VSCODE_BUNDLE_IDENTIFIER),
|
|
168
|
+
bundle_programs=bundle_program_places(("Visual Studio Code.app",), (CONTENTS_DIR_BASENAME, "Resources", "app", "bin", "code")), bundle_identifiers=(VSCODE_BUNDLE_IDENTIFIER,)),
|
|
169
|
+
EditorPreset("sublime-macos", "Sublime Text", MACOS_PLATFORM, ("subl",), "--new-window --wait", file_open_options=("-a", SUBLIME_TEXT_APPLICATION_NAME),
|
|
170
|
+
bundle_programs=bundle_program_places((SUBLIME_TEXT_APPLICATION_NAME + BUNDLE_SUFFIX,), (CONTENTS_DIR_BASENAME, "SharedSupport", "bin", "subl")),
|
|
171
|
+
bundle_identifiers=("com.sublimetext.4", "com.sublimetext.3")),
|
|
172
|
+
EditorPreset("zed-macos", "Zed", MACOS_PLATFORM, ("zed",), "--wait", file_open_options=("-b", ZED_BUNDLE_IDENTIFIER),
|
|
173
|
+
bundle_programs=bundle_program_places(("Zed.app",), (CONTENTS_DIR_BASENAME, MACOS_DIR_BASENAME, "cli")), bundle_identifiers=(ZED_BUNDLE_IDENTIFIER,)),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def editor_preset(preset_id: str) -> EditorPreset | None:
|
|
178
|
+
"""Return the preset saved under ``preset_id``, or None for a preset GitPane no longer offers or one written by hand."""
|
|
179
|
+
for preset in EDITOR_PRESETS:
|
|
180
|
+
if preset.preset_id == preset_id:
|
|
181
|
+
return preset
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def file_open_options(recipe: EditorRecipe) -> tuple[str, ...]:
|
|
186
|
+
"""Return the options of macOS's open command that show a file in the recipe's editor for a plain look at it, or nothing.
|
|
187
|
+
|
|
188
|
+
Only a preset recipe whose arguments are still the preset's own gets them: arguments the user changed are the user's
|
|
189
|
+
command and are started as written.
|
|
190
|
+
"""
|
|
191
|
+
preset = editor_preset(recipe.preset_id)
|
|
192
|
+
if preset is None or recipe.argument_template != preset.arguments:
|
|
193
|
+
return ()
|
|
194
|
+
return preset.file_open_options
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_repository_program(program_filename: str, repository_dirs: tuple[str, ...]) -> bool:
|
|
198
|
+
"""Exclude executable search results inside known repositories or a discoverable Git checkout."""
|
|
199
|
+
canonical_filename = canonical_path(program_filename)
|
|
200
|
+
for repository_dir in repository_dirs:
|
|
201
|
+
canonical_dir = canonical_path(repository_dir)
|
|
202
|
+
try:
|
|
203
|
+
if os.path.commonpath((canonical_filename, canonical_dir)) == canonical_dir:
|
|
204
|
+
return True
|
|
205
|
+
except ValueError:
|
|
206
|
+
continue
|
|
207
|
+
for current_dir in (EDITOR_INITIAL_DIR, os.getcwd()):
|
|
208
|
+
if os.path.dirname(canonical_filename) == canonical_path(current_dir):
|
|
209
|
+
return True
|
|
210
|
+
current_dir = os.path.dirname(canonical_filename)
|
|
211
|
+
while current_dir:
|
|
212
|
+
if os.path.lexists(os.path.join(current_dir, ".git")):
|
|
213
|
+
return True
|
|
214
|
+
parent_dir = os.path.dirname(current_dir)
|
|
215
|
+
if parent_dir == current_dir:
|
|
216
|
+
break
|
|
217
|
+
current_dir = parent_dir
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def resolve_editor_program(program: str, *, repository_dirs: tuple[str, ...] = (), platform: str = EDITOR_PLATFORM, search_path: str = EDITOR_INITIAL_PATH) -> str:
|
|
222
|
+
"""Find an installed executable in the initial search path, without implicit current-directory lookup, and return it as found.
|
|
223
|
+
|
|
224
|
+
The path is the PATH folder joined to the name, or the absolute path given, never the target of a link: a snap
|
|
225
|
+
command such as /snap/bin/code is a link to the snap program, which picks the application by the name it is
|
|
226
|
+
started under, so started by its target it would only print snap's help. The repository check judges the link's
|
|
227
|
+
target, so a link cannot bring in a program that lives in a checkout.
|
|
228
|
+
"""
|
|
229
|
+
if not program or "\0" in program:
|
|
230
|
+
return ""
|
|
231
|
+
candidate_filenames = []
|
|
232
|
+
if os.path.isabs(program):
|
|
233
|
+
candidate_filenames.append(program)
|
|
234
|
+
elif os.path.basename(program) == program:
|
|
235
|
+
for program_dir in search_path.split(os.pathsep):
|
|
236
|
+
if not program_dir or not os.path.isabs(program_dir):
|
|
237
|
+
continue
|
|
238
|
+
candidate_filenames.append(os.path.join(program_dir, program))
|
|
239
|
+
_program_root, extension = os.path.splitext(program)
|
|
240
|
+
if platform == WINDOWS_PLATFORM and not extension:
|
|
241
|
+
candidate_filenames.append(os.path.join(program_dir, program + ".exe"))
|
|
242
|
+
for candidate_filename in candidate_filenames:
|
|
243
|
+
if _is_repository_program(candidate_filename, repository_dirs):
|
|
244
|
+
continue
|
|
245
|
+
_program_root, extension = os.path.splitext(candidate_filename)
|
|
246
|
+
if platform == WINDOWS_PLATFORM and extension.casefold() in EDITOR_WINDOWS_LAUNCHER_EXTENSION_SET:
|
|
247
|
+
continue
|
|
248
|
+
if os.path.isfile(candidate_filename) and os.access(candidate_filename, os.X_OK):
|
|
249
|
+
return candidate_filename
|
|
250
|
+
return ""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def flatpak_installation_dirs(environment_by_name: Mapping[str, str], home_dir: str) -> tuple[str, ...]:
|
|
254
|
+
"""Name Flatpak's two default installations, the system's first, then the user's under the user's data folder."""
|
|
255
|
+
return (FLATPAK_SYSTEM_INSTALLATION_DIR, os.path.join(xdg_data_home(environment_by_name, home_dir), FLATPAK_USER_INSTALLATION_BASENAME))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
EDITOR_FLATPAK_DIRS = flatpak_installation_dirs(os.environ, os.path.expanduser("~"))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def editor_flatpak_dirs(flatpak_dirs: tuple[str, ...] | None) -> tuple[str, ...]:
|
|
262
|
+
"""Return the Flatpak installations to look in: ``flatpak_dirs`` when the caller gives them, otherwise the session's.
|
|
263
|
+
|
|
264
|
+
The session's installations are read from this module at the call rather than bound as a default value, so the
|
|
265
|
+
test configuration replaces them in one place.
|
|
266
|
+
"""
|
|
267
|
+
if flatpak_dirs is not None:
|
|
268
|
+
return flatpak_dirs
|
|
269
|
+
return EDITOR_FLATPAK_DIRS
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def flatpak_argument_template(app_id: str) -> str:
|
|
273
|
+
"""Build the arguments of ``flatpak`` that open the file in the Flatpak application ``app_id``."""
|
|
274
|
+
return " ".join((FLATPAK_RUN_ARGUMENTS, app_id, FLATPAK_FILE_FORWARDING_MARK, EDITOR_FILE_PARAMETER, FLATPAK_FILE_FORWARDING_MARK))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def flatpak_recipe(preset: EditorPreset, *, platform: str, search_path: str, repository_dirs: tuple[str, ...], flatpak_dirs: tuple[str, ...]) -> EditorRecipe | None:
|
|
278
|
+
"""Return the recipe that opens the file in the preset's Flatpak application, or None when that application cannot be used.
|
|
279
|
+
|
|
280
|
+
The application counts as installed when one of ``flatpak_dirs`` exports its program; a link to a removed
|
|
281
|
+
deployment counts as absent, since the file check follows it. It is started through ``flatpak run
|
|
282
|
+
--file-forwarding``, as Flatpak's own desktop file does, never through the exported program: that one has no file
|
|
283
|
+
forwarding, so a file the sandbox cannot see would not open. The ``flatpak`` program must be in PATH. The
|
|
284
|
+
application identifier only ever comes from the preset table. Only files are checked: no process is started.
|
|
285
|
+
"""
|
|
286
|
+
if not preset.flatpak_app_id:
|
|
287
|
+
return None
|
|
288
|
+
for flatpak_dir in flatpak_dirs:
|
|
289
|
+
if not os.path.isfile(os.path.join(flatpak_dir, *FLATPAK_EXPORTED_PROGRAM_PARTS, preset.flatpak_app_id)):
|
|
290
|
+
continue
|
|
291
|
+
flatpak_filename = resolve_editor_program(FLATPAK_PROGRAM_NAME, platform=platform, search_path=search_path, repository_dirs=repository_dirs)
|
|
292
|
+
if not flatpak_filename:
|
|
293
|
+
return None
|
|
294
|
+
return EditorRecipe(preset.preset_id, flatpak_filename, flatpak_argument_template(preset.flatpak_app_id))
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def recipe_for_preset(preset: EditorPreset, *, platform: str = EDITOR_PLATFORM, search_path: str = EDITOR_INITIAL_PATH, repository_dirs: tuple[str, ...] = (),
|
|
299
|
+
application_dirs: tuple[str, ...] = APPLICATION_SEARCH_DIRS, flatpak_dirs: tuple[str, ...] | None = None) -> EditorRecipe:
|
|
300
|
+
"""Prefill a preset using the actual installed program, leaving missing programs explicitly empty.
|
|
301
|
+
|
|
302
|
+
A program inside an application is looked for in each applications folder in turn, every known place in one folder
|
|
303
|
+
before the next folder, then the names searched in PATH, then the preset's Flatpak application in ``flatpak_dirs``:
|
|
304
|
+
a native editor wins over its Flatpak. The order comes from the table and the folders given, never from the order
|
|
305
|
+
the disk lists entries in, so an application installed twice gives the first folder's program. Only files are
|
|
306
|
+
checked: no process is started. Without ``flatpak_dirs``, the session's Flatpak installations are looked in.
|
|
307
|
+
"""
|
|
308
|
+
flatpak_dirs = editor_flatpak_dirs(flatpak_dirs)
|
|
309
|
+
for application_dir in application_dirs:
|
|
310
|
+
for bundle_program in preset.bundle_programs:
|
|
311
|
+
filename = resolve_editor_program(os.path.join(application_dir, *bundle_program), platform=platform, search_path=search_path, repository_dirs=repository_dirs)
|
|
312
|
+
if filename:
|
|
313
|
+
return EditorRecipe(preset.preset_id, filename, preset.arguments)
|
|
314
|
+
for program in preset.candidate_programs:
|
|
315
|
+
filename = resolve_editor_program(program, platform=platform, search_path=search_path, repository_dirs=repository_dirs)
|
|
316
|
+
if filename:
|
|
317
|
+
return EditorRecipe(preset.preset_id, filename, preset.arguments)
|
|
318
|
+
flatpak_form = flatpak_recipe(preset, platform=platform, search_path=search_path, repository_dirs=repository_dirs, flatpak_dirs=flatpak_dirs)
|
|
319
|
+
if flatpak_form is not None:
|
|
320
|
+
return flatpak_form
|
|
321
|
+
return EditorRecipe(preset.preset_id, "", preset.arguments)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def offered_editor_presets(platform: str, installed: Callable[[EditorPreset], bool]) -> list[EditorPreset]:
|
|
325
|
+
"""List the presets Settings offers on ``platform``, in the table's order, Custom first.
|
|
326
|
+
|
|
327
|
+
On macOS and Linux only the presets ``installed`` reports present are offered; Windows offers its whole list, so
|
|
328
|
+
``installed`` is not called there.
|
|
329
|
+
"""
|
|
330
|
+
offered_presets = []
|
|
331
|
+
for preset in EDITOR_PRESETS:
|
|
332
|
+
if preset.platform == ALL_PLATFORM:
|
|
333
|
+
offered_presets.append(preset)
|
|
334
|
+
continue
|
|
335
|
+
if preset.platform != platform:
|
|
336
|
+
continue
|
|
337
|
+
if platform in INSTALLED_ONLY_PRESET_PLATFORM_SET and not installed(preset):
|
|
338
|
+
continue
|
|
339
|
+
offered_presets.append(preset)
|
|
340
|
+
return offered_presets
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _default_editor_preset_recipe(default_editor_id: str, *, platform: str, search_path: str, repository_dirs: tuple[str, ...], application_dirs: tuple[str, ...],
|
|
344
|
+
flatpak_dirs: tuple[str, ...]) -> EditorRecipe | None:
|
|
345
|
+
"""Answer the recipe of the preset of ``platform`` that carries the system's default editor identifier, when that preset is installed, or None.
|
|
346
|
+
|
|
347
|
+
Only Linux and macOS presets carry such identifiers, so Windows always answers None.
|
|
348
|
+
"""
|
|
349
|
+
if not default_editor_id:
|
|
350
|
+
return None
|
|
351
|
+
for preset in EDITOR_PRESETS:
|
|
352
|
+
if preset.platform != platform or not preset.carries_default_editor_id(default_editor_id):
|
|
353
|
+
continue
|
|
354
|
+
recipe = recipe_for_preset(preset, platform=platform, search_path=search_path, repository_dirs=repository_dirs, application_dirs=application_dirs, flatpak_dirs=flatpak_dirs)
|
|
355
|
+
if recipe.program_filename:
|
|
356
|
+
return recipe
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def detect_editor_recipe(*, platform: str = EDITOR_PLATFORM, search_path: str = EDITOR_INITIAL_PATH, repository_dirs: tuple[str, ...] = (),
|
|
361
|
+
application_dirs: tuple[str, ...] = APPLICATION_SEARCH_DIRS, flatpak_dirs: tuple[str, ...] | None = None, system_editor_id: str = "") -> EditorRecipe:
|
|
362
|
+
"""Use Notepad on Windows; on Linux and macOS, the preset the system opens plain text with; else, on Linux only, the first installed preset in the table's order.
|
|
363
|
+
|
|
364
|
+
``system_editor_id`` is the identifier the system names its default text editor by, empty when it names nothing or
|
|
365
|
+
has not been read yet: on Linux the desktop file the desktop names for plain text, on macOS the identifier of the
|
|
366
|
+
application LaunchServices names for it. The preset that carries it is taken when it is installed. Otherwise, on
|
|
367
|
+
Linux the table's order decides, and an empty Custom choice is left only when no Linux preset is installed at all;
|
|
368
|
+
on macOS nothing is chosen, so Show in text editor opens macOS's default text editor until the user picks one in
|
|
369
|
+
Settings. The identifier only picks a preset of the table: an editor the table does not know is never taken from
|
|
370
|
+
it. A Linux preset counts as installed when its program is in PATH or its Flatpak application is in
|
|
371
|
+
``flatpak_dirs``, the session's Flatpak installations when none are given. Windows ignores ``system_editor_id``.
|
|
372
|
+
"""
|
|
373
|
+
flatpak_dirs = editor_flatpak_dirs(flatpak_dirs)
|
|
374
|
+
default_recipe = _default_editor_preset_recipe(system_editor_id, platform=platform, search_path=search_path, repository_dirs=repository_dirs, application_dirs=application_dirs,
|
|
375
|
+
flatpak_dirs=flatpak_dirs)
|
|
376
|
+
if default_recipe is not None:
|
|
377
|
+
return default_recipe
|
|
378
|
+
if platform == MACOS_PLATFORM:
|
|
379
|
+
return EditorRecipe()
|
|
380
|
+
for preset in EDITOR_PRESETS:
|
|
381
|
+
if preset.platform != platform:
|
|
382
|
+
continue
|
|
383
|
+
recipe = recipe_for_preset(preset, platform=platform, search_path=search_path, repository_dirs=repository_dirs, application_dirs=application_dirs, flatpak_dirs=flatpak_dirs)
|
|
384
|
+
if platform == WINDOWS_PLATFORM or recipe.program_filename:
|
|
385
|
+
return recipe
|
|
386
|
+
return EditorRecipe()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass(frozen=True)
|
|
390
|
+
class EditorRecipe:
|
|
391
|
+
"""Store an editor selection without interpreting its executable or arguments as shell code."""
|
|
392
|
+
|
|
393
|
+
preset_id: str = "custom"
|
|
394
|
+
program_filename: str = ""
|
|
395
|
+
argument_template: str = ""
|
|
396
|
+
|
|
397
|
+
def validate(self, *, platform: str = EDITOR_PLATFORM) -> None:
|
|
398
|
+
"""Validate a draft; an empty program is a valid choice, for which Show in text editor applies the rule of the platform.
|
|
399
|
+
|
|
400
|
+
The messages are shown as they are where Settings or Show in text editor refuses the choice, so each one says what to do.
|
|
401
|
+
"""
|
|
402
|
+
build_editor_arguments(self.argument_template, os.path.abspath("editor-file"))
|
|
403
|
+
if "\0" in self.program_filename or "\0" in self.preset_id:
|
|
404
|
+
raise ValueError("External editor settings cannot contain a null character.")
|
|
405
|
+
if not self.program_filename:
|
|
406
|
+
return
|
|
407
|
+
if not os.path.isabs(self.program_filename) or not os.path.isfile(self.program_filename) or not os.access(self.program_filename, os.X_OK):
|
|
408
|
+
raise ValueError(UNAVAILABLE_EDITOR_TEXT)
|
|
409
|
+
_program_root, extension = os.path.splitext(self.program_filename)
|
|
410
|
+
if platform == WINDOWS_PLATFORM and extension.casefold() in EDITOR_WINDOWS_LAUNCHER_EXTENSION_SET:
|
|
411
|
+
raise ValueError("This Windows editor launcher is not qualified. Choose an appropriate executable in Open Settings.")
|
|
412
|
+
|
|
413
|
+
def preference_value(self) -> dict[str, object]:
|
|
414
|
+
"""Serialize the three editor fields together for an atomic preference update."""
|
|
415
|
+
return {EDITOR_PRESET_PREFERENCE_KEY: self.preset_id, EDITOR_FILENAME_PREFERENCE_KEY: self.program_filename, EDITOR_ARGUMENT_PREFERENCE_KEY: self.argument_template}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def bundle_preset_program(bundle_dir: str, platform: str) -> tuple[str, str]:
|
|
419
|
+
"""Return the waiting command-line tool inside an editor's application and the id of its preset, or two empty strings.
|
|
420
|
+
|
|
421
|
+
The application is recognised by the folder name the preset of ``platform`` looks for, and only when its tool is
|
|
422
|
+
really there and can be run, so an unrelated application with the same name gives nothing.
|
|
423
|
+
"""
|
|
424
|
+
bundle_basename = os.path.basename(bundle_dir).casefold()
|
|
425
|
+
for preset in EDITOR_PRESETS:
|
|
426
|
+
if preset.platform != platform:
|
|
427
|
+
continue
|
|
428
|
+
for preset_bundle_basename, *program_parts in preset.bundle_programs:
|
|
429
|
+
if preset_bundle_basename.casefold() != bundle_basename:
|
|
430
|
+
continue
|
|
431
|
+
program_filename = resolve_editor_program(os.path.join(bundle_dir, *program_parts), platform=platform)
|
|
432
|
+
if program_filename:
|
|
433
|
+
return program_filename, preset.preset_id
|
|
434
|
+
return "", ""
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def chosen_program(filename: str, platform: str = EDITOR_PLATFORM) -> tuple[str, str]:
|
|
438
|
+
"""Decide what a file picked in Settings' editor chooser stands for: a program for the Executable field, a preset, or both.
|
|
439
|
+
|
|
440
|
+
Returns the program and the id of the preset the pick stands for. TextEdit, picked as its application or as the
|
|
441
|
+
program inside it and recognised by the identifier it declares, stands for the TextEdit preset alone, with no
|
|
442
|
+
program: TextEdit is opened through macOS's open command, as the preset does, never by starting the program inside
|
|
443
|
+
its application. VS Code, Sublime Text or Zed, picked the same way on a system whose presets know them, stand for
|
|
444
|
+
their preset together with the waiting command-line tool inside the picked application: the program their
|
|
445
|
+
application declares also hands the file to the editor and returns at once. Another readable application gives the program it declares, with no
|
|
446
|
+
preset; anything else is kept as picked. This only judges a pick from the chooser: a command the user types by hand
|
|
447
|
+
is the user's own choice and is never refused or warned about.
|
|
448
|
+
"""
|
|
449
|
+
bundle_dir = enclosing_bundle_dir(filename.rstrip(os.sep) or filename)
|
|
450
|
+
if bundle_dir:
|
|
451
|
+
bundle = read_bundle(bundle_dir)
|
|
452
|
+
if bundle is not None and is_textedit(bundle):
|
|
453
|
+
return "", TEXTEDIT_PRESET_ID
|
|
454
|
+
program_filename, preset_id = bundle_preset_program(bundle_dir, editor_platform(platform))
|
|
455
|
+
if preset_id:
|
|
456
|
+
return program_filename, preset_id
|
|
457
|
+
return chosen_application_program(filename), ""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def build_editor_arguments(argument_template: str, file_filename: str) -> list[str]:
|
|
461
|
+
"""Insert one absolute filename as one literal argument, appending it when the template omits it."""
|
|
462
|
+
if "\0" in argument_template or "\0" in file_filename:
|
|
463
|
+
raise ValueError("External editor arguments cannot contain a null character.")
|
|
464
|
+
if argument_template.replace('"""', '').count('"') % 2:
|
|
465
|
+
raise ValueError("External editor arguments contain an unclosed double quote.")
|
|
466
|
+
arguments = QProcess.splitCommand(argument_template)
|
|
467
|
+
file_count = 0
|
|
468
|
+
results = []
|
|
469
|
+
for argument in arguments:
|
|
470
|
+
if argument == EDITOR_FILE_PARAMETER:
|
|
471
|
+
file_count += 1
|
|
472
|
+
results.append(os.path.abspath(file_filename))
|
|
473
|
+
elif any(character in argument for character in ("{", "}", "$")):
|
|
474
|
+
raise ValueError("External editor arguments accept only a standalone {file} parameter.")
|
|
475
|
+
else:
|
|
476
|
+
results.append(argument)
|
|
477
|
+
if file_count > 1:
|
|
478
|
+
raise ValueError("External editor arguments must reference {file} only once.")
|
|
479
|
+
if not file_count:
|
|
480
|
+
results.append(os.path.abspath(file_filename))
|
|
481
|
+
return results
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Select local files through the desktop's shared file manager interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
from urllib.parse import quote_from_bytes, urlunsplit
|
|
12
|
+
|
|
13
|
+
from gitpane.config import LINUX_PLATFORM, MACOS_OPEN_FILENAME, MACOS_PLATFORM, MILLISECONDS_PER_SECOND, TOOL_START_TIMEOUT_MS, WINDOWS_PLATFORM
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
# The program that asks the desktop's file manager, over the session bus, to select a file on Linux.
|
|
19
|
+
DBUS_SEND_PROGRAM_NAME = "dbus-send"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def reveal_file(filename: str, startup_id: str = "", platform: str = sys.platform,
|
|
23
|
+
run: Callable[..., subprocess.CompletedProcess[bytes]] = subprocess.run) -> bool:
|
|
24
|
+
"""Request selection on a worker thread, returning false when folder fallback is needed.
|
|
25
|
+
|
|
26
|
+
On Linux the startup identifier goes to the file manager, which uses the user's action time it carries to come to
|
|
27
|
+
the front; an empty one leaves the window manager to decide. Windows brings Explorer forward by itself. On macOS
|
|
28
|
+
open -R asks the Finder to show the file selected in its folder's window, which needs no permission. The helper
|
|
29
|
+
programs are started through run, bounded by the tool startup timeout; a timeout is raised to the caller.
|
|
30
|
+
"""
|
|
31
|
+
if platform == WINDOWS_PLATFORM:
|
|
32
|
+
return _reveal_windows_file(filename)
|
|
33
|
+
if platform == MACOS_PLATFORM:
|
|
34
|
+
return _reveal_macos_file(filename, run)
|
|
35
|
+
if platform != LINUX_PLATFORM:
|
|
36
|
+
return False
|
|
37
|
+
program_filename = shutil.which(DBUS_SEND_PROGRAM_NAME)
|
|
38
|
+
if program_filename is None:
|
|
39
|
+
return False
|
|
40
|
+
# Escape commas too: dbus-send treats them as array separators, even without a shell.
|
|
41
|
+
uri = urlunsplit(("file", "", quote_from_bytes(os.fsencode(filename), safe="/"), "", ""))
|
|
42
|
+
arguments = [program_filename, "--session", "--print-reply", f"--reply-timeout={TOOL_START_TIMEOUT_MS}",
|
|
43
|
+
"--dest=org.freedesktop.FileManager1", "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems", f"array:string:{uri}", f"string:{startup_id}"]
|
|
44
|
+
result = run(arguments, shell=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND, check=False)
|
|
45
|
+
return result.returncode == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def selection_helper_available(platform: str = sys.platform) -> bool:
|
|
49
|
+
"""Tell whether reveal_file has a helper to ask for the selection: on Linux it needs dbus-send, which a system may lack."""
|
|
50
|
+
return platform != LINUX_PLATFORM or shutil.which(DBUS_SEND_PROGRAM_NAME) is not None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _reveal_macos_file(filename: str, run: Callable[..., subprocess.CompletedProcess[bytes]]) -> bool:
|
|
54
|
+
"""Ask the Finder, through open -R, to select the exact file; the name is one argument and no shell reads it.
|
|
55
|
+
|
|
56
|
+
A refusal (a file gone since the click, for instance) answers false, so the caller opens the folder instead.
|
|
57
|
+
"""
|
|
58
|
+
result = run([MACOS_OPEN_FILENAME, "-R", filename], shell=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
59
|
+
timeout=TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND, check=False)
|
|
60
|
+
return result.returncode == 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _reveal_windows_file(filename: str) -> bool:
|
|
64
|
+
"""Use the Shell API to select an exact Unicode path without command-line parsing."""
|
|
65
|
+
shell = ctypes.WinDLL("shell32")
|
|
66
|
+
ole = ctypes.WinDLL("ole32")
|
|
67
|
+
ole.CoInitializeEx.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
|
68
|
+
ole.CoInitializeEx.restype = ctypes.c_int32
|
|
69
|
+
ole.CoUninitialize.argtypes = []
|
|
70
|
+
ole.CoUninitialize.restype = None
|
|
71
|
+
ole.CoTaskMemFree.argtypes = [ctypes.c_void_p]
|
|
72
|
+
ole.CoTaskMemFree.restype = None
|
|
73
|
+
shell.SHParseDisplayName.argtypes = [ctypes.c_wchar_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32)]
|
|
74
|
+
shell.SHParseDisplayName.restype = ctypes.c_int32
|
|
75
|
+
shell.SHOpenFolderAndSelectItems.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p), ctypes.c_uint32]
|
|
76
|
+
shell.SHOpenFolderAndSelectItems.restype = ctypes.c_int32
|
|
77
|
+
apartment_threaded = 0x2
|
|
78
|
+
disable_legacy_dde = 0x4
|
|
79
|
+
if ole.CoInitializeEx(None, apartment_threaded | disable_legacy_dde) < 0:
|
|
80
|
+
raise OSError("The system could not initialize file selection.")
|
|
81
|
+
item_id = ctypes.c_void_p()
|
|
82
|
+
try:
|
|
83
|
+
no_attributes = 0
|
|
84
|
+
if shell.SHParseDisplayName(filename, None, ctypes.byref(item_id), no_attributes, None) < 0:
|
|
85
|
+
raise OSError("The system could not locate the selected file.")
|
|
86
|
+
single_absolute_item = 0
|
|
87
|
+
no_flags = 0
|
|
88
|
+
if shell.SHOpenFolderAndSelectItems(item_id, single_absolute_item, None, no_flags) < 0:
|
|
89
|
+
raise OSError("The system refused to select the file in the file explorer.")
|
|
90
|
+
return True
|
|
91
|
+
finally:
|
|
92
|
+
if item_id.value is not None:
|
|
93
|
+
ole.CoTaskMemFree(item_id)
|
|
94
|
+
ole.CoUninitialize()
|