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
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Name the per-user INI file that holds GitPane's preferences.
|
|
2
|
+
|
|
3
|
+
It is the only location, so a pip or pipx install, whose package folder does not belong to the user, keeps the same preferences.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from gitpane.config import APPLICATION_NAME, WINDOWS_PLATFORM
|
|
12
|
+
from gitpane.system.xdg_session import xdg_config_home
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
PREFERENCE_BASENAME = "gitpane.ini"
|
|
18
|
+
APPDATA_ENVIRONMENT_NAME = "APPDATA"
|
|
19
|
+
# Where APPDATA points by default under the user's home folder, used when the variable is missing.
|
|
20
|
+
WINDOWS_ROAMING_DIR_PARTS = ("AppData", "Roaming")
|
|
21
|
+
PREFERENCE_FILE_LOG_TEXT = "Preferences file: {filename}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def user_preference_dir(platform: str, environment_by_name: Mapping[str, str], home_dir: str) -> str:
|
|
25
|
+
"""Name the per-user folder that holds the preferences file.
|
|
26
|
+
|
|
27
|
+
A relative XDG_CONFIG_HOME is ignored, as the XDG specification asks, so the file never lands in the current folder.
|
|
28
|
+
"""
|
|
29
|
+
if platform == WINDOWS_PLATFORM:
|
|
30
|
+
roaming_dir = environment_by_name.get(APPDATA_ENVIRONMENT_NAME, "") or os.path.join(home_dir, *WINDOWS_ROAMING_DIR_PARTS)
|
|
31
|
+
return os.path.join(roaming_dir, APPLICATION_NAME)
|
|
32
|
+
return os.path.join(xdg_config_home(environment_by_name, home_dir), APPLICATION_NAME)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def user_preference_filename(platform: str, environment_by_name: Mapping[str, str], home_dir: str) -> str:
|
|
36
|
+
"""Name the only preferences file GitPane uses when no file is given to it.
|
|
37
|
+
|
|
38
|
+
The system, the environment and the home folder are parameters, so every system's rule is testable from any machine.
|
|
39
|
+
"""
|
|
40
|
+
return os.path.join(user_preference_dir(platform, environment_by_name, home_dir), PREFERENCE_BASENAME)
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Persist only changed preferences so separate launches can share storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ntpath
|
|
6
|
+
import os
|
|
7
|
+
import posixpath
|
|
8
|
+
import sys
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import TYPE_CHECKING, Self
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QObject, QSettings, Signal
|
|
13
|
+
|
|
14
|
+
from gitpane.config import (
|
|
15
|
+
ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY,
|
|
16
|
+
AUTHENTICATION_PREFERENCE_KEY,
|
|
17
|
+
CLONE_DESTINATION_LIMIT,
|
|
18
|
+
EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY,
|
|
19
|
+
LINUX_PLATFORM,
|
|
20
|
+
MACOS_PLATFORM,
|
|
21
|
+
OPERATION_KEEP_OPEN_PREFERENCE_KEY,
|
|
22
|
+
PUSH_TRACKING_PREFERENCE_KEY,
|
|
23
|
+
RECENT_LIMIT,
|
|
24
|
+
STAGING_PROPORTION_BY_KEY,
|
|
25
|
+
WINDOWS_PLATFORM,
|
|
26
|
+
)
|
|
27
|
+
from gitpane.recent.mount_table import case_insensitive_path_key
|
|
28
|
+
from gitpane.reference.stash import STASH_DEFAULT_BY_KEY
|
|
29
|
+
from gitpane.system.path_identity import on_disk_path
|
|
30
|
+
from gitpane.tool.editor_recipe import (
|
|
31
|
+
EDITOR_ARGUMENT_PREFERENCE_KEY,
|
|
32
|
+
EDITOR_DEFAULT_BY_KEY,
|
|
33
|
+
EDITOR_FILENAME_PREFERENCE_KEY,
|
|
34
|
+
EDITOR_INITIAL_PATH,
|
|
35
|
+
EDITOR_PLATFORM,
|
|
36
|
+
EDITOR_PRESET_PREFERENCE_KEY,
|
|
37
|
+
EditorRecipe,
|
|
38
|
+
build_editor_arguments,
|
|
39
|
+
detect_editor_recipe,
|
|
40
|
+
)
|
|
41
|
+
from gitpane.tool.kdiff3 import detect_kdiff3_filename
|
|
42
|
+
from gitpane.tool.mergetool_recipe import MERGETOOL_COMMAND_PREFERENCE_KEY, MERGETOOL_DEFAULT_BY_KEY, MERGETOOL_FILENAME_PREFERENCE_KEY, MergetoolRecipe, parse_mergetool_command
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Mapping
|
|
46
|
+
from types import ModuleType
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
FETCH_DEFAULT_PREFERENCE_KEY = "fetch_default"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FetchMode(Enum):
|
|
53
|
+
"""What a Fetch does: fetch the one remote of the current branch, or fetch every remote and prune what they deleted.
|
|
54
|
+
|
|
55
|
+
The value is the word saved in the preferences file, which every system and older GitPane versions read; the label
|
|
56
|
+
is the text of the Fetch menus, which list the modes in this order.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
FETCH = ("fetch", "Fetch")
|
|
60
|
+
PRUNE_ALL = ("prune-all", "Fetch and prune all")
|
|
61
|
+
|
|
62
|
+
def __new__(cls, value: str, label: str) -> Self:
|
|
63
|
+
"""Keep the saved word as the value and the menu text beside it."""
|
|
64
|
+
member = object.__new__(cls)
|
|
65
|
+
member._value_ = value
|
|
66
|
+
member.label = label
|
|
67
|
+
return member
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
FETCH_INITIAL_MODE = FetchMode.PRUNE_ALL
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fetch_mode_preference(value: object) -> FetchMode:
|
|
74
|
+
"""Read a saved default Fetch, falling back to the initial mode for a word this version does not know, an empty value or another type.
|
|
75
|
+
|
|
76
|
+
The file may have been edited by hand or written by a later GitPane: the Fetch button must still have a mode.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
return FetchMode(value)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return FETCH_INITIAL_MODE
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Parent folders of the last clones that really started, the most recent first, offered by the next clone form.
|
|
85
|
+
CLONE_DESTINATION_DIRS_PREFERENCE_KEY = "clone_destination_dirs"
|
|
86
|
+
CONFLICT_PROMPT_PREFERENCE_KEY = "conflict_prompt_suppressed"
|
|
87
|
+
REBASE_PROMPT_PREFERENCE_KEY = "rebase_prompt_suppressed"
|
|
88
|
+
# Shown by Open repository and Settings, and raised by every refused save: they name the file and say what to do, nothing else.
|
|
89
|
+
PREFERENCE_UNREADABLE_TEXT = "The preferences file cannot be read: {filename}. Fix or delete it, then restart GitPane. Nothing is saved until then."
|
|
90
|
+
PREFERENCE_UNWRITABLE_TEXT = "The preferences file cannot be saved: {filename}. Check that the file is not read-only and that its folder accepts new files."
|
|
91
|
+
|
|
92
|
+
PREFERENCE_DEFAULT_BY_KEY: dict[str, object] = {
|
|
93
|
+
"git_filename": "", "terminal_id": "", "terminal_label": "", "terminal_filename": "", "recent_dirs": [],
|
|
94
|
+
"difftool_filename": "", "difftool_command": '"$LOCAL" "$REMOTE"',
|
|
95
|
+
"ssh_filename": "", "git_utility_dir": "",
|
|
96
|
+
OPERATION_KEEP_OPEN_PREFERENCE_KEY: True,
|
|
97
|
+
FETCH_DEFAULT_PREFERENCE_KEY: FETCH_INITIAL_MODE.value,
|
|
98
|
+
CLONE_DESTINATION_DIRS_PREFERENCE_KEY: [],
|
|
99
|
+
CONFLICT_PROMPT_PREFERENCE_KEY: False,
|
|
100
|
+
REBASE_PROMPT_PREFERENCE_KEY: False,
|
|
101
|
+
"diff_splitter_state": "", "tree_splitter_state": "", "file_history_splitter_state": "", "diff_show_entire_file": False,
|
|
102
|
+
EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY: "",
|
|
103
|
+
AUTHENTICATION_PREFERENCE_KEY: '{"accounts": [], "global": {}, "repository": {}}',
|
|
104
|
+
ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY: "{}",
|
|
105
|
+
PUSH_TRACKING_PREFERENCE_KEY: "{}",
|
|
106
|
+
**STAGING_PROPORTION_BY_KEY,
|
|
107
|
+
**STASH_DEFAULT_BY_KEY,
|
|
108
|
+
**MERGETOOL_DEFAULT_BY_KEY,
|
|
109
|
+
**EDITOR_DEFAULT_BY_KEY,
|
|
110
|
+
}
|
|
111
|
+
# Folder lists and the number of folders each keeps, the most recent first.
|
|
112
|
+
DIR_LIST_LIMIT_BY_KEY = {"recent_dirs": RECENT_LIMIT, CLONE_DESTINATION_DIRS_PREFERENCE_KEY: CLONE_DESTINATION_LIMIT}
|
|
113
|
+
# How each system writes, folds and recognizes absolute paths: Windows from a drive letter or a network share with
|
|
114
|
+
# backslashes, Linux and macOS from the root with forward slashes. A system missing here is refused, never guessed.
|
|
115
|
+
PATH_MODULE_BY_PLATFORM = {WINDOWS_PLATFORM: ntpath, LINUX_PLATFORM: posixpath, MACOS_PLATFORM: posixpath}
|
|
116
|
+
# Diagnostic log line for a folder of a saved list that another system wrote: the preferences file may be shared between
|
|
117
|
+
# systems, so the folder stays in the file for that system and is only left out of the lists shown here.
|
|
118
|
+
FOREIGN_DIR_LOG_TEXT = "Folder written by another system kept in {key} and not used here: {folder}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _path_module(platform: str) -> ModuleType:
|
|
122
|
+
"""Return the path rules of ``platform``, raising OSError for a system GitPane does not know rather than guessing its rules."""
|
|
123
|
+
path_module = PATH_MODULE_BY_PLATFORM.get(platform)
|
|
124
|
+
if path_module is None:
|
|
125
|
+
raise OSError("No path rules are known for " + platform)
|
|
126
|
+
return path_module
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def saved_recent_identity(recent_dir: str, platform: str = sys.platform) -> str:
|
|
130
|
+
"""Return the key that tells whether two entries of the recent history name the same repository, without touching the disk.
|
|
131
|
+
|
|
132
|
+
The history is saved with its paths already resolved, so its entries compare as normalized text: a path written
|
|
133
|
+
differently (trailing separator, ``.`` segment, other case on Windows) still matches. On macOS another letter case
|
|
134
|
+
or Unicode form (composed or decomposed accents) is the same entry too, as on its disks by default; Linux compares
|
|
135
|
+
the paths as written. The key stays text: resolving each entry again would open its folder, and a disconnected
|
|
136
|
+
network drive in the history would freeze the window while the system waits. A system without known path rules
|
|
137
|
+
raises OSError.
|
|
138
|
+
"""
|
|
139
|
+
path_module = _path_module(platform)
|
|
140
|
+
absolute_dir = path_module.abspath(recent_dir)
|
|
141
|
+
if platform == MACOS_PLATFORM:
|
|
142
|
+
return case_insensitive_path_key(absolute_dir)
|
|
143
|
+
return path_module.normcase(absolute_dir)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def split_dir_entries(value: list[str], platform: str = sys.platform) -> tuple[list[str], list[str]]:
|
|
147
|
+
"""Split saved folders into those of ``platform``, absolute by its own path rules, and those another system wrote.
|
|
148
|
+
|
|
149
|
+
A preferences file can be shared between Windows, Linux and macOS: a Windows folder such as ``E:\\work`` is not an
|
|
150
|
+
absolute path on macOS and must never be read as a folder below the current one, and a POSIX folder is not absolute
|
|
151
|
+
on Windows. A relative entry is absolute on no system and goes with the other system's entries. Both parts keep the
|
|
152
|
+
saved order; nothing is read on disk. A system without known path rules raises OSError.
|
|
153
|
+
"""
|
|
154
|
+
path_module = _path_module(platform)
|
|
155
|
+
own_dirs = []
|
|
156
|
+
foreign_dirs = []
|
|
157
|
+
for folder in value:
|
|
158
|
+
if path_module.isabs(folder):
|
|
159
|
+
own_dirs.append(folder)
|
|
160
|
+
else:
|
|
161
|
+
foreign_dirs.append(folder)
|
|
162
|
+
return own_dirs, foreign_dirs
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def saved_dir_entries(value: object) -> list[str]:
|
|
166
|
+
"""Return a saved folder list as a list of folder texts, raising TypeError or ValueError when it cannot be one.
|
|
167
|
+
|
|
168
|
+
The INI file gives an empty list back as None and a one-folder list as plain text, both taken back as lists here.
|
|
169
|
+
"""
|
|
170
|
+
if value is None:
|
|
171
|
+
return []
|
|
172
|
+
if isinstance(value, str):
|
|
173
|
+
return [value] if value else []
|
|
174
|
+
if not isinstance(value, list):
|
|
175
|
+
raise TypeError("Unreadable folder list")
|
|
176
|
+
for folder in value:
|
|
177
|
+
if not isinstance(folder, str) or not folder:
|
|
178
|
+
raise ValueError("Unreadable folder path")
|
|
179
|
+
return list(value)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def foreign_saved_dirs(value: object, platform: str = sys.platform) -> list[str]:
|
|
183
|
+
"""Return the folders of a saved folder list that another system wrote, in saved order, or none when the list is unreadable.
|
|
184
|
+
|
|
185
|
+
An unreadable list has its own warning, so it gives no foreign folder; a system without known path rules raises OSError.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
entries = saved_dir_entries(value)
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
return []
|
|
191
|
+
return split_dir_entries(entries, platform)[1]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def normalized_dir_list(value: object, limit: int, platform: str = sys.platform) -> list[str]:
|
|
195
|
+
"""Check a saved folder list and return the folders of this system, normalized, without repeats, cut to ``limit``, without touching the disk.
|
|
196
|
+
|
|
197
|
+
Folders another system wrote are left out, never normalized: making them absolute would join them to the current
|
|
198
|
+
folder. Folders compare by the key of saved_recent_identity, and the first spelling of a folder is kept with its
|
|
199
|
+
place: the list is saved most recent first, so two spellings of one folder merge into the most recent one.
|
|
200
|
+
"""
|
|
201
|
+
own_dirs, _foreign_dirs = split_dir_entries(saved_dir_entries(value), platform)
|
|
202
|
+
path_module = _path_module(platform)
|
|
203
|
+
normalized_dirs = []
|
|
204
|
+
identity_set: set[str] = set()
|
|
205
|
+
for folder in own_dirs:
|
|
206
|
+
normalized_dir = path_module.abspath(folder)
|
|
207
|
+
identity = saved_recent_identity(normalized_dir, platform)
|
|
208
|
+
if identity not in identity_set:
|
|
209
|
+
normalized_dirs.append(normalized_dir)
|
|
210
|
+
identity_set.add(identity)
|
|
211
|
+
return normalized_dirs[:limit]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def saved_recent_dir(repository_dir: str) -> str:
|
|
215
|
+
"""Return the form in which a repository is saved in the recent history: absolute, spelled as the disk spells it.
|
|
216
|
+
|
|
217
|
+
On macOS that is the letter case and Unicode form the disk keeps, whatever was typed, so the history and the
|
|
218
|
+
window title name the folder the same way; elsewhere it is the path with its links resolved, as before. Paired with
|
|
219
|
+
saved_recent_identity, it gives a repository that is not read from the history, such as the one a window shows,
|
|
220
|
+
the same key as its saved entry, so the switch menu lists it once. It touches the disk, so it is only for a path
|
|
221
|
+
known to be reachable, never for each entry of the history.
|
|
222
|
+
"""
|
|
223
|
+
return on_disk_path(os.path.abspath(repository_dir))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class PreferenceStore(QObject):
|
|
227
|
+
"""Read fresh settings and synchronize individual changes to local storage."""
|
|
228
|
+
|
|
229
|
+
changed = Signal(object)
|
|
230
|
+
warning_raised = Signal(str)
|
|
231
|
+
|
|
232
|
+
def __init__(self, setting: QSettings) -> None:
|
|
233
|
+
"""Use injected storage to keep application preferences independently testable."""
|
|
234
|
+
super().__init__()
|
|
235
|
+
self.setting = setting
|
|
236
|
+
self.value_by_key = dict(PREFERENCE_DEFAULT_BY_KEY)
|
|
237
|
+
self.warning = ""
|
|
238
|
+
# One diagnostic line per saved folder another system wrote, refreshed with the values: the launch writes them
|
|
239
|
+
# to the --log file, never to a window, since the folder is only kept for that other system.
|
|
240
|
+
self.foreign_dir_lines: list[str] = []
|
|
241
|
+
# Whether the file was found read-only, checked only at launch and after a refused save, never on each read: Qt
|
|
242
|
+
# tests it by opening the file for writing outside its own lock, which can make another GitPane's save fail.
|
|
243
|
+
self.unwritable = False
|
|
244
|
+
self.refresh()
|
|
245
|
+
self._check_writable()
|
|
246
|
+
|
|
247
|
+
def refresh(self) -> None:
|
|
248
|
+
"""Merge external changes without discarding valid keys when another is unreadable.
|
|
249
|
+
|
|
250
|
+
Folders another system wrote in the folder lists are left out of the values and listed in ``foreign_dir_lines``;
|
|
251
|
+
reading never rewrites the file, so they stay in it.
|
|
252
|
+
"""
|
|
253
|
+
self.setting.sync()
|
|
254
|
+
try:
|
|
255
|
+
self._check_status()
|
|
256
|
+
except OSError as error:
|
|
257
|
+
self._warn(str(error))
|
|
258
|
+
return
|
|
259
|
+
changed_value_by_key = {}
|
|
260
|
+
self.warning = ""
|
|
261
|
+
if self.unwritable:
|
|
262
|
+
self._warn(self._unwritable_text())
|
|
263
|
+
foreign_dir_lines = []
|
|
264
|
+
for key, default in PREFERENCE_DEFAULT_BY_KEY.items():
|
|
265
|
+
value = self.setting.value(key, default)
|
|
266
|
+
if key in DIR_LIST_LIMIT_BY_KEY:
|
|
267
|
+
for foreign_dir in foreign_saved_dirs(value):
|
|
268
|
+
foreign_dir_lines.append(FOREIGN_DIR_LOG_TEXT.format(key=key, folder=foreign_dir))
|
|
269
|
+
try:
|
|
270
|
+
value = self._validate(key, value)
|
|
271
|
+
except (TypeError, ValueError):
|
|
272
|
+
self._warn(f"Unreadable preference ignored : {key}.")
|
|
273
|
+
continue
|
|
274
|
+
if self.value_by_key[key] != value:
|
|
275
|
+
self.value_by_key[key] = value
|
|
276
|
+
changed_value_by_key[key] = value
|
|
277
|
+
self.foreign_dir_lines = foreign_dir_lines
|
|
278
|
+
if changed_value_by_key:
|
|
279
|
+
self.changed.emit(changed_value_by_key)
|
|
280
|
+
|
|
281
|
+
def read(self, key: str, default: object = "") -> object:
|
|
282
|
+
"""Import changes made by other processes before returning a preference."""
|
|
283
|
+
self.refresh()
|
|
284
|
+
value = self.value_by_key.get(key, default)
|
|
285
|
+
return list(value) if isinstance(value, list) else value
|
|
286
|
+
|
|
287
|
+
def read_saved(self, key: str) -> object:
|
|
288
|
+
"""Return the value stored right now for this key, read and checked again, never this store's copy in memory.
|
|
289
|
+
|
|
290
|
+
``read`` keeps showing the last good copy when the stored value cannot be read (written by another GitPane version,
|
|
291
|
+
say) or the storage cannot be synchronized. That is fine to show, but a value rewritten from that copy would undo
|
|
292
|
+
what another window saved meanwhile, so this raises OSError or ValueError instead.
|
|
293
|
+
"""
|
|
294
|
+
self.setting.sync()
|
|
295
|
+
self._check_status()
|
|
296
|
+
value = self.setting.value(key, PREFERENCE_DEFAULT_BY_KEY.get(key))
|
|
297
|
+
try:
|
|
298
|
+
value = self._validate(key, value)
|
|
299
|
+
except TypeError as error:
|
|
300
|
+
raise ValueError(str(error)) from error
|
|
301
|
+
return list(value) if isinstance(value, list) else value
|
|
302
|
+
|
|
303
|
+
def contains_saved(self, key: str) -> bool:
|
|
304
|
+
"""Distinguish absent keys from saved empty values without hiding synchronization errors."""
|
|
305
|
+
self.setting.sync()
|
|
306
|
+
try:
|
|
307
|
+
self._check_status()
|
|
308
|
+
saved = self.setting.contains(key)
|
|
309
|
+
self._check_status()
|
|
310
|
+
except OSError as error:
|
|
311
|
+
self._warn(str(error))
|
|
312
|
+
raise
|
|
313
|
+
return saved
|
|
314
|
+
|
|
315
|
+
def initialize_difftool(self, *, save: bool = True) -> str:
|
|
316
|
+
"""Return the diff tool executable a comparison starts: the saved one, or else the KDiff3 detected on this system.
|
|
317
|
+
|
|
318
|
+
Settings shows that same detected KDiff3 while no diff tool is saved, so what it shows is what a comparison uses.
|
|
319
|
+
The detected executable is saved, with the arguments in effect, only when ``save`` is set, which the comparison
|
|
320
|
+
itself does: opening Settings writes nothing. A refused write is reported and the detected KDiff3 is still used.
|
|
321
|
+
"""
|
|
322
|
+
self.refresh()
|
|
323
|
+
saved_filename = str(self.value_by_key["difftool_filename"])
|
|
324
|
+
if saved_filename:
|
|
325
|
+
return saved_filename
|
|
326
|
+
detected_filename = detect_kdiff3_filename(sys.platform)
|
|
327
|
+
if not detected_filename or not save:
|
|
328
|
+
return detected_filename
|
|
329
|
+
try:
|
|
330
|
+
self.update({"difftool_filename": detected_filename, "difftool_command": str(self.value_by_key["difftool_command"])})
|
|
331
|
+
except OSError as error:
|
|
332
|
+
self._warn(f"Cannot save the detected external diff tool: {error}")
|
|
333
|
+
return detected_filename
|
|
334
|
+
|
|
335
|
+
def initialize_mergetool(self, *, save_detected: bool = True) -> str:
|
|
336
|
+
"""Return the merge tool executable conflict resolution uses, choosing a KDiff3 on first use.
|
|
337
|
+
|
|
338
|
+
A saved merge path, even an emptied one, or saved arguments alone are kept as they are. Otherwise a KDiff3 saved as
|
|
339
|
+
the diff tool is saved as the merge tool too, and failing that the KDiff3 detected on this system is used. That
|
|
340
|
+
detected one is saved, with the default arguments, only when ``save_detected`` is set, which the conflict action
|
|
341
|
+
does: starting GitPane or opening Settings writes nothing for it.
|
|
342
|
+
"""
|
|
343
|
+
self.refresh()
|
|
344
|
+
filename = str(self.value_by_key[MERGETOOL_FILENAME_PREFERENCE_KEY])
|
|
345
|
+
try:
|
|
346
|
+
if self.contains_saved(MERGETOOL_FILENAME_PREFERENCE_KEY) or self.contains_saved(MERGETOOL_COMMAND_PREFERENCE_KEY):
|
|
347
|
+
return filename
|
|
348
|
+
candidate_filename = str(self.value_by_key["difftool_filename"])
|
|
349
|
+
if os.path.basename(candidate_filename).casefold() not in ("kdiff3", "kdiff3.exe"):
|
|
350
|
+
detected_filename = detect_kdiff3_filename(sys.platform)
|
|
351
|
+
if not detected_filename:
|
|
352
|
+
return filename
|
|
353
|
+
if not save_detected:
|
|
354
|
+
return detected_filename
|
|
355
|
+
candidate_filename = detected_filename
|
|
356
|
+
filename = candidate_filename
|
|
357
|
+
recipe = MergetoolRecipe(filename)
|
|
358
|
+
try:
|
|
359
|
+
recipe.validate()
|
|
360
|
+
except ValueError:
|
|
361
|
+
return filename
|
|
362
|
+
self.update({MERGETOOL_FILENAME_PREFERENCE_KEY: filename, MERGETOOL_COMMAND_PREFERENCE_KEY: recipe.command})
|
|
363
|
+
except OSError as error:
|
|
364
|
+
self._warn(f"Cannot initialize the external merge tool: {error}")
|
|
365
|
+
return filename
|
|
366
|
+
|
|
367
|
+
def editor_recipe(self) -> EditorRecipe:
|
|
368
|
+
"""Read the common editor choice as one recipe after refreshing persisted values."""
|
|
369
|
+
self.refresh()
|
|
370
|
+
return EditorRecipe(str(self.value_by_key[EDITOR_PRESET_PREFERENCE_KEY]), str(self.value_by_key[EDITOR_FILENAME_PREFERENCE_KEY]), str(self.value_by_key[EDITOR_ARGUMENT_PREFERENCE_KEY]))
|
|
371
|
+
|
|
372
|
+
def editor_choice_saved(self) -> bool:
|
|
373
|
+
"""Tell whether Settings holds an editor choice, which then comes before any detected editor.
|
|
374
|
+
|
|
375
|
+
One saved editor key is enough, even an empty one: the user chose it. A settings file that cannot be read counts
|
|
376
|
+
as saved, so what it holds is kept rather than replaced by a detection; the problem is already reported.
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
for key in EDITOR_DEFAULT_BY_KEY:
|
|
380
|
+
if self.contains_saved(key):
|
|
381
|
+
return True
|
|
382
|
+
except OSError:
|
|
383
|
+
return True
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
def initialize_editor(self, *, platform: str = EDITOR_PLATFORM, search_path: str = EDITOR_INITIAL_PATH, repository_dirs: tuple[str, ...] = (),
|
|
387
|
+
flatpak_dirs: tuple[str, ...] | None = None, system_editor_id: str = "") -> EditorRecipe:
|
|
388
|
+
"""Answer the saved editor choice, or else the editor detected on this system, which is used as it is and never saved.
|
|
389
|
+
|
|
390
|
+
Settings shows the detected editor and Show in text editor opens it without a Save; only the user's Save writes
|
|
391
|
+
an editor choice. ``system_editor_id`` is the editor the system names for plain text, read elsewhere and off the
|
|
392
|
+
window's thread; it only steers the detection and is never saved here either. Without ``flatpak_dirs``, the
|
|
393
|
+
detection looks in the session's Flatpak installations.
|
|
394
|
+
"""
|
|
395
|
+
previous = self.editor_recipe()
|
|
396
|
+
try:
|
|
397
|
+
if self.editor_choice_saved():
|
|
398
|
+
return previous
|
|
399
|
+
return detect_editor_recipe(platform=platform, search_path=search_path, repository_dirs=repository_dirs, flatpak_dirs=flatpak_dirs, system_editor_id=system_editor_id)
|
|
400
|
+
except OSError as error:
|
|
401
|
+
self._warn(f"Cannot initialize the external editor: {error}")
|
|
402
|
+
return previous
|
|
403
|
+
|
|
404
|
+
def update(self, changed_value_by_key: Mapping[str, object]) -> None:
|
|
405
|
+
"""Write only the supplied keys and report storage failures immediately.
|
|
406
|
+
|
|
407
|
+
A folder list is written as this system's folders followed by the folders another system wrote, read again from
|
|
408
|
+
the file right before the write and each part cut to the list limit: the file may be shared with that system, so
|
|
409
|
+
none of its folders is ever dropped here. The values kept in memory and emitted hold this system's folders only.
|
|
410
|
+
"""
|
|
411
|
+
self.refresh()
|
|
412
|
+
self._check_status()
|
|
413
|
+
accepted_value_by_key = {}
|
|
414
|
+
written_value_by_key = {}
|
|
415
|
+
previous_value_by_key = {}
|
|
416
|
+
for key, value in changed_value_by_key.items():
|
|
417
|
+
accepted_value = self._validate(key, value)
|
|
418
|
+
previous_value = self.setting.value(key)
|
|
419
|
+
accepted_value_by_key[key] = accepted_value
|
|
420
|
+
previous_value_by_key[key] = previous_value
|
|
421
|
+
written_value_by_key[key] = accepted_value
|
|
422
|
+
if key in DIR_LIST_LIMIT_BY_KEY:
|
|
423
|
+
written_value_by_key[key] = [*accepted_value, *foreign_saved_dirs(previous_value)[:DIR_LIST_LIMIT_BY_KEY[key]]]
|
|
424
|
+
for key, value in written_value_by_key.items():
|
|
425
|
+
self.setting.setValue(key, value)
|
|
426
|
+
self.setting.sync()
|
|
427
|
+
try:
|
|
428
|
+
self._check_status()
|
|
429
|
+
except OSError:
|
|
430
|
+
# QSettings buffers failed writes; restore them so a later sync cannot publish a rejected draft.
|
|
431
|
+
for key, previous_value in previous_value_by_key.items():
|
|
432
|
+
if previous_value is None:
|
|
433
|
+
self.setting.remove(key)
|
|
434
|
+
else:
|
|
435
|
+
self.setting.setValue(key, previous_value)
|
|
436
|
+
self.setting.sync()
|
|
437
|
+
self._check_writable()
|
|
438
|
+
raise
|
|
439
|
+
# A save that went through proves the file is writable again, so the read-only warning goes.
|
|
440
|
+
if self.unwritable:
|
|
441
|
+
self.unwritable = False
|
|
442
|
+
if self.warning == self._unwritable_text():
|
|
443
|
+
self.warning = ""
|
|
444
|
+
actual_value_by_key = {}
|
|
445
|
+
for key, value in accepted_value_by_key.items():
|
|
446
|
+
if self.value_by_key[key] != value:
|
|
447
|
+
self.value_by_key[key] = value
|
|
448
|
+
actual_value_by_key[key] = value
|
|
449
|
+
if actual_value_by_key:
|
|
450
|
+
self.changed.emit(actual_value_by_key)
|
|
451
|
+
|
|
452
|
+
def read_recent(self) -> list[str]:
|
|
453
|
+
"""Return successful repository paths in persisted recency order."""
|
|
454
|
+
return self.read("recent_dirs", [])
|
|
455
|
+
|
|
456
|
+
def remember(self, repository_dir: str) -> None:
|
|
457
|
+
"""Move a successful repository to the front without overwriting other keys; repositories another system saved stay in the file."""
|
|
458
|
+
recent_dirs = [saved_recent_dir(repository_dir), *self.read_recent()]
|
|
459
|
+
self.update({"recent_dirs": recent_dirs})
|
|
460
|
+
|
|
461
|
+
def read_clone_destinations(self) -> list[str]:
|
|
462
|
+
"""Return the parent folders of the last started clones, the most recent first, as saved: none is looked at on disk."""
|
|
463
|
+
return self.read(CLONE_DESTINATION_DIRS_PREFERENCE_KEY, [])
|
|
464
|
+
|
|
465
|
+
def remember_clone_destination(self, parent_dir: str) -> None:
|
|
466
|
+
"""Put the parent folder of a clone that really started at the front of the destination list, without other keys changing.
|
|
467
|
+
|
|
468
|
+
The same folder written differently moves to the front instead of being listed twice, and the oldest folder
|
|
469
|
+
beyond the limit is dropped. The folder is saved as typed, made absolute, never resolved on disk. Folders another
|
|
470
|
+
system saved stay in the file.
|
|
471
|
+
"""
|
|
472
|
+
destination_dirs = [os.path.abspath(parent_dir), *self.read_clone_destinations()]
|
|
473
|
+
self.update({CLONE_DESTINATION_DIRS_PREFERENCE_KEY: destination_dirs})
|
|
474
|
+
|
|
475
|
+
def forget(self, repository_dirs: list[str]) -> None:
|
|
476
|
+
"""Remove these repositories from the recent history, for example folders that no longer exist.
|
|
477
|
+
|
|
478
|
+
Only the saved list changes, in one write: the folders themselves are never touched. The paths are compared as
|
|
479
|
+
saved, never resolved on disk, since they usually come from the history itself and name folders that may be
|
|
480
|
+
unreachable. Nothing is emitted when none of them is in the history, and a storage refusal raises OSError with
|
|
481
|
+
the previous list kept. Repositories another system saved are never removed from the file.
|
|
482
|
+
"""
|
|
483
|
+
forgotten_identity_set: set[str] = set()
|
|
484
|
+
for repository_dir in repository_dirs:
|
|
485
|
+
forgotten_identity_set.add(saved_recent_identity(repository_dir))
|
|
486
|
+
kept_dirs = []
|
|
487
|
+
for recent_dir in self.read_recent():
|
|
488
|
+
if saved_recent_identity(recent_dir) not in forgotten_identity_set:
|
|
489
|
+
kept_dirs.append(recent_dir)
|
|
490
|
+
self.update({"recent_dirs": kept_dirs})
|
|
491
|
+
|
|
492
|
+
def _validate(self, key: str, value: object) -> object:
|
|
493
|
+
"""Reject malformed values per key and normalize only folder lists.
|
|
494
|
+
|
|
495
|
+
The recent history and the clone destinations are normalized as text, never resolved on disk: every read of any
|
|
496
|
+
preference validates them, and an unreachable network folder in them would freeze the window each time.
|
|
497
|
+
``remember`` resolves the one path it saves, so the saved history entries are already resolved.
|
|
498
|
+
"""
|
|
499
|
+
if key not in PREFERENCE_DEFAULT_BY_KEY:
|
|
500
|
+
raise ValueError(f"Unknown preference : {key}")
|
|
501
|
+
if key == AUTHENTICATION_PREFERENCE_KEY:
|
|
502
|
+
from gitpane.account.authentication import validate_selection_json
|
|
503
|
+
return validate_selection_json(value)
|
|
504
|
+
if key == ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY:
|
|
505
|
+
from gitpane.account.authentication import validate_sign_in_method_json
|
|
506
|
+
return validate_sign_in_method_json(value)
|
|
507
|
+
if key == PUSH_TRACKING_PREFERENCE_KEY:
|
|
508
|
+
from gitpane.transport.push_tracking import validate_push_tracking_json
|
|
509
|
+
return validate_push_tracking_json(value)
|
|
510
|
+
if key == FETCH_DEFAULT_PREFERENCE_KEY:
|
|
511
|
+
# Only the saved word is accepted, never a member: Qt would write an object to the file in a form no other
|
|
512
|
+
# system or GitPane version can read.
|
|
513
|
+
if isinstance(value, str):
|
|
514
|
+
try:
|
|
515
|
+
FetchMode(value)
|
|
516
|
+
return value
|
|
517
|
+
except ValueError:
|
|
518
|
+
pass
|
|
519
|
+
raise ValueError("Choose Fetch or Fetch and prune all as the default.")
|
|
520
|
+
if key in STAGING_PROPORTION_BY_KEY:
|
|
521
|
+
if not isinstance(value, (list, tuple)) or len(value) != len(STAGING_PROPORTION_BY_KEY[key]):
|
|
522
|
+
raise TypeError(f"Unreadable preference : {key}")
|
|
523
|
+
sizes: list[int] = []
|
|
524
|
+
for size in value:
|
|
525
|
+
# INI storage reads each list item back as text after a fresh process opens the file.
|
|
526
|
+
if isinstance(size, str) and size.isdigit():
|
|
527
|
+
size = int(size)
|
|
528
|
+
if type(size) is not int or size <= 0:
|
|
529
|
+
raise ValueError(f"Unreadable preference : {key}")
|
|
530
|
+
sizes.append(size)
|
|
531
|
+
return sizes
|
|
532
|
+
if key in {"diff_show_entire_file", OPERATION_KEEP_OPEN_PREFERENCE_KEY, CONFLICT_PROMPT_PREFERENCE_KEY, REBASE_PROMPT_PREFERENCE_KEY} or key in STASH_DEFAULT_BY_KEY:
|
|
533
|
+
if type(value) is bool:
|
|
534
|
+
return value
|
|
535
|
+
# INI storage reads booleans as strings after a fresh process opens the file.
|
|
536
|
+
if value in ("true", "false"):
|
|
537
|
+
return value == "true"
|
|
538
|
+
raise TypeError(f"Unreadable preference : {key}")
|
|
539
|
+
if key in DIR_LIST_LIMIT_BY_KEY:
|
|
540
|
+
try:
|
|
541
|
+
return normalized_dir_list(value, DIR_LIST_LIMIT_BY_KEY[key])
|
|
542
|
+
except TypeError as error:
|
|
543
|
+
raise TypeError(f"Unreadable preference : {key}") from error
|
|
544
|
+
if not isinstance(value, str):
|
|
545
|
+
raise TypeError(f"Unreadable preference : {key}")
|
|
546
|
+
if key == MERGETOOL_COMMAND_PREFERENCE_KEY:
|
|
547
|
+
parse_mergetool_command(value, "base", "local", "remote", "merged")
|
|
548
|
+
if key in EDITOR_DEFAULT_BY_KEY and "\0" in value:
|
|
549
|
+
raise ValueError("External editor settings cannot contain a null character.")
|
|
550
|
+
if key == EDITOR_ARGUMENT_PREFERENCE_KEY:
|
|
551
|
+
build_editor_arguments(value, os.path.abspath("editor-file"))
|
|
552
|
+
return value
|
|
553
|
+
|
|
554
|
+
def _check_writable(self) -> None:
|
|
555
|
+
"""Look once whether the settings file can be saved, and warn when it cannot, so a read-only file is named before a save fails.
|
|
556
|
+
|
|
557
|
+
Only called at launch and after a refused save: a readable status is required first, since a file Qt could not
|
|
558
|
+
read already has its own warning.
|
|
559
|
+
"""
|
|
560
|
+
if self.setting.status() != QSettings.Status.NoError:
|
|
561
|
+
return
|
|
562
|
+
self.unwritable = not self.setting.isWritable()
|
|
563
|
+
if self.unwritable:
|
|
564
|
+
self._warn(self._unwritable_text())
|
|
565
|
+
|
|
566
|
+
def _unwritable_text(self) -> str:
|
|
567
|
+
"""Say that the settings file cannot be saved, naming it."""
|
|
568
|
+
return PREFERENCE_UNWRITABLE_TEXT.format(filename=self._display_filename())
|
|
569
|
+
|
|
570
|
+
def _warn(self, warning: str) -> None:
|
|
571
|
+
"""Keep the latest problem for the windows that show it when they open, and announce it to live listeners."""
|
|
572
|
+
self.warning = warning
|
|
573
|
+
self.warning_raised.emit(warning)
|
|
574
|
+
|
|
575
|
+
def _check_status(self) -> None:
|
|
576
|
+
"""Raise OSError naming the settings file when Qt could not parse it or could not save it.
|
|
577
|
+
|
|
578
|
+
A file Qt could not parse stays in that state for the whole session, so every later save is refused: writing
|
|
579
|
+
would replace the file with the few keys Qt understood and lose what the user edited by hand.
|
|
580
|
+
"""
|
|
581
|
+
status = self.setting.status()
|
|
582
|
+
if status == QSettings.Status.NoError:
|
|
583
|
+
return
|
|
584
|
+
if status == QSettings.Status.FormatError:
|
|
585
|
+
raise OSError(PREFERENCE_UNREADABLE_TEXT.format(filename=self._display_filename()))
|
|
586
|
+
raise OSError(self._unwritable_text())
|
|
587
|
+
|
|
588
|
+
def _display_filename(self) -> str:
|
|
589
|
+
"""Return the settings file path the way the system writes it, since Qt reports it with forward slashes."""
|
|
590
|
+
return os.path.abspath(self.setting.fileName())
|
|
File without changes
|