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.
Files changed (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. gitpane-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,375 @@
1
+ """Open a repository window once Git has read its repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from functools import partial
8
+ from typing import TYPE_CHECKING
9
+
10
+ from PySide6.QtCore import QObject, Qt, Signal
11
+ from shiboken6 import isValid
12
+
13
+ from gitpane.controller.session_registry import RepositorySession
14
+ from gitpane.controller.setting_controller import WaitingOpening
15
+ from gitpane.history.file_history import WindowCapability
16
+ from gitpane.history.repository_value import RepositoryFailure, RequestIdentity
17
+ from gitpane.operation.diagnostic import Diagnostic
18
+ from gitpane.recent.recent_availability import folder_is_reachable
19
+ from gitpane.system.path_identity import canonical_path
20
+ from gitpane.thread.weak_signal import weak_signal_emitter
21
+ from gitpane.ui.console.error_dialog import ErrorDialog
22
+ from gitpane.ui.repository.repository_window import RepositoryWindow
23
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
24
+ from gitpane.ui.window_control.window_placement import is_visible_window
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable
28
+
29
+ from PySide6.QtWidgets import QWidget
30
+
31
+ from gitpane.account.authentication import AuthenticationService
32
+ from gitpane.controller.clone_controller import CloneController
33
+ from gitpane.controller.history_read_controller import HistoryReadController
34
+ from gitpane.controller.operation_policy import OperationPolicy
35
+ from gitpane.controller.reference_read_controller import ReferenceReadController
36
+ from gitpane.controller.refresh_controller import RefreshController
37
+ from gitpane.controller.session_registry import SessionRegistry
38
+ from gitpane.controller.setting_controller import SettingController
39
+ from gitpane.controller.staging_session_registry import StagingSessionRegistry
40
+ from gitpane.controller.window_activation import WindowActivationFilter
41
+ from gitpane.history.file_history import FileHistoryAnchor
42
+ from gitpane.history.repository import GitRepository
43
+ from gitpane.history.repository_value import Commit, RepositoryContext
44
+ from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
45
+ from gitpane.preference.preference_store import PreferenceStore
46
+ from gitpane.transport.clone import CloneService
47
+
48
+ OPENING_FOLDER_THREAD_NAME = "gitpane-opening-folder-check"
49
+ MISSING_FOLDER_LOG_TEXT = "Open repository: no folder exists at {path}; Git was not started."
50
+
51
+
52
+ def answer_folder_question(is_folder_present: Callable[[str], bool], folder_dir: str, hand_over: Callable[[bool | None], None]) -> None:
53
+ """Ask on the worker thread whether the folder exists, and hand the answer over to the controller's thread.
54
+
55
+ The answer is None when the question gave no definite answer (a refused access, a disk not ready, a path the system
56
+ cannot read as written): the opening then goes on to Git as it would without the question.
57
+ """
58
+ try:
59
+ present: bool | None = is_folder_present(folder_dir)
60
+ except (OSError, ValueError):
61
+ present = None
62
+ # The controller may have been deleted while the question ran: the answer then has nobody left to take it.
63
+ try:
64
+ hand_over(present)
65
+ except RuntimeError:
66
+ pass
67
+
68
+
69
+ class OpeningController(QObject):
70
+ """The openings asked from the welcome window, the clone form and the file histories: the checks before a window opens, then the actions that follow its wiring."""
71
+
72
+ # Session of a repository window just opened, whose worktree may be paused by a rebase started before it opened.
73
+ pause_observation_requested = Signal(str)
74
+ # Answer of the folder question of a welcome opening: its identity, the folder, and True, False or None when unknown.
75
+ folder_answered = Signal(object, str, object)
76
+
77
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, window_activation_controller: WindowActivationFilter,
78
+ reference_read_controller: ReferenceReadController, refresh_controller: RefreshController, history_read_controller: HistoryReadController, setting_controller: SettingController, clone_controller: CloneController,
79
+ preference: PreferenceStore, repository: GitRepository, authentication: AuthenticationService, clone: CloneService, diagnostic_log: DiagnosticLog | None,
80
+ wire_repository_window: Callable[[RequestIdentity, RepositorySession, RepositoryWindow, RepositoryContext, FileHistoryAnchor | None], None],
81
+ is_folder_present: Callable[[str], bool] = folder_is_reachable, parent: QObject | None = None) -> None:
82
+ """Receive the services, the lower controllers and the wiring function a new repository window is connected with; the wiring builds it after the clone controller, and the rebase learns from its signal which opened window to observe.
83
+
84
+ ``is_folder_present`` tells whether the folder of a welcome opening exists, raising when it cannot tell. It may
85
+ block for long on a sleeping disk or a dead network share, so it is only ever asked on a thread of its own;
86
+ tests answer without a disk.
87
+ """
88
+ super().__init__(parent)
89
+ self.session_registry_controller = session_registry_controller
90
+ self.operation_policy_controller = operation_policy_controller
91
+ self.staging_session_registry_controller = staging_session_registry_controller
92
+ self.window_activation_controller = window_activation_controller
93
+ self.reference_read_controller = reference_read_controller
94
+ self.refresh_controller = refresh_controller
95
+ self.history_read_controller = history_read_controller
96
+ self.setting_controller = setting_controller
97
+ self.clone_controller = clone_controller
98
+ self.preference = preference
99
+ self.repository = repository
100
+ self.authentication = authentication
101
+ self.clone = clone
102
+ self.diagnostic_log = diagnostic_log
103
+ self.wire_repository_window = wire_repository_window
104
+ self.is_folder_present = is_folder_present
105
+ self.folder_answered.connect(self._folder_answered, Qt.ConnectionType.QueuedConnection)
106
+
107
+ def open(self, session_id: str, repository_dir: str) -> None:
108
+ """Assign a never-reused generation and reject repeated active opening intents."""
109
+ identity = self._begin_opening(session_id, repository_dir)
110
+ if identity is None:
111
+ return
112
+ if self._wait_for_startup_check(session_id, identity, repository_dir, checks_folder=False):
113
+ return
114
+ self.repository.open_repository(repository_dir, identity)
115
+
116
+ def open_from_welcome(self, session_id: str, repository_dir: str) -> None:
117
+ """Open the folder the welcome window asked for, once a question off the window's thread found that it exists.
118
+
119
+ A folder that does not exist is never handed to Git: the welcome window says "Invalid path" instead of Git's
120
+ failure to start in it. The question may block for long on a sleeping disk or a dead network share, so it is
121
+ never asked on the window's thread; meanwhile the window shows "Opening…" and Cancel reading, as during a read.
122
+ """
123
+ identity = self._begin_opening(session_id, repository_dir)
124
+ if identity is None:
125
+ return
126
+ if self._wait_for_startup_check(session_id, identity, repository_dir, checks_folder=True):
127
+ return
128
+ self._ask_folder(identity, repository_dir)
129
+
130
+ def resume_waiting_opening(self, waiting: WaitingOpening) -> None:
131
+ """Go on with an opening the startup check of Git released: ask about its folder first when it asks one, else read Git now.
132
+
133
+ The order stays the same whatever answers first: the startup check of Git, then the folder question, then Git.
134
+ """
135
+ if waiting.checks_folder:
136
+ self._ask_folder(waiting.identity, waiting.repository_dir)
137
+ return
138
+ self.repository.open_repository(waiting.repository_dir, waiting.identity)
139
+
140
+ def _begin_opening(self, session_id: str, repository_dir: str) -> RequestIdentity | None:
141
+ """Give an opening its never-reused identity and show the chooser busy, or return None for an opening refused here.
142
+
143
+ An opening is refused while another one of the session runs, once its chooser is gone, and during a clone for any
144
+ folder but the clone's destination.
145
+ """
146
+ if session_id in self.session_registry_controller.identity_by_session or session_id not in self.session_registry_controller.welcome_by_session:
147
+ return None
148
+ clone_dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
149
+ if clone_dialog is not None and clone_dialog.operation_id is not None:
150
+ run = self.clone.run_by_id[clone_dialog.operation_id]
151
+ if run.open_status != "running" or run.destination is None or canonical_path(repository_dir) != canonical_path(run.destination.destination_dir):
152
+ return None
153
+ generation = self.session_registry_controller.generation_by_session.get(session_id, 0) + 1
154
+ self.session_registry_controller.generation_by_session[session_id] = generation
155
+ identity = RequestIdentity(session_id, generation)
156
+ self.session_registry_controller.identity_by_session[session_id] = identity
157
+ self.session_registry_controller.opening_dir_by_session[session_id] = repository_dir
158
+ self.session_registry_controller.welcome_by_session[session_id].set_loading(True)
159
+ return identity
160
+
161
+ def _wait_for_startup_check(self, session_id: str, identity: RequestIdentity, repository_dir: str, *, checks_folder: bool) -> bool:
162
+ """Leave the opening to the startup check of Git while that check runs, returning whether it now waits for it.
163
+
164
+ ``checks_folder`` makes the released opening ask about its folder before Git reads it.
165
+ """
166
+ if self.setting_controller.startup_identity is None:
167
+ return False
168
+ self.setting_controller.waiting_opening_by_session[session_id] = WaitingOpening(identity, repository_dir, self.repository.git_filename, checks_folder)
169
+ return True
170
+
171
+ def _ask_folder(self, identity: RequestIdentity, repository_dir: str) -> None:
172
+ """Ask on a thread of its own whether the opening's folder exists; the answer comes back through ``folder_answered``."""
173
+ # The thread holds the controller only weakly: a question blocked on a dead share keeps nothing alive.
174
+ hand_over = partial(weak_signal_emitter(self, lambda controller: controller.folder_answered), identity, repository_dir)
175
+ thread = threading.Thread(target=answer_folder_question, args=(self.is_folder_present, repository_dir, hand_over), name=OPENING_FOLDER_THREAD_NAME, daemon=True)
176
+ thread.start()
177
+
178
+ def _folder_answered(self, identity: RequestIdentity, repository_dir: str, present: bool | None) -> None:
179
+ """Go on with an opening whose folder question answered, unless Cancel reading, a closed chooser or a newer opening replaced it.
180
+
181
+ Only a definite "absent" refuses the folder; a question without a definite answer leaves the answer to Git.
182
+ """
183
+ if self.session_registry_controller.identity_by_session.get(identity.session_id) != identity:
184
+ return
185
+ if identity.session_id not in self.session_registry_controller.welcome_by_session:
186
+ return
187
+ if present is False:
188
+ self._refuse_missing_folder(identity, repository_dir)
189
+ return
190
+ # The folder question is already answered: an opening released later by a startup check reads Git at once.
191
+ if self._wait_for_startup_check(identity.session_id, identity, repository_dir, checks_folder=False):
192
+ return
193
+ self.repository.open_repository(repository_dir, identity)
194
+
195
+ def _refuse_missing_folder(self, identity: RequestIdentity, repository_dir: str) -> None:
196
+ """End an opening whose folder does not exist, give the chooser back to the user, and explain it in a message over it.
197
+
198
+ A recent of that folder is marked Unavailable, as after a failed opening.
199
+ """
200
+ self.session_registry_controller.identity_by_session.pop(identity.session_id, None)
201
+ welcome = self.session_registry_controller.welcome_by_session[identity.session_id]
202
+ welcome.set_loading(False)
203
+ welcome.mark_unavailable(repository_dir)
204
+ if self.diagnostic_log is not None:
205
+ self.diagnostic_log.write_line(MISSING_FOLDER_LOG_TEXT.format(path=repository_dir))
206
+ box = welcome.invalid_path_message(repository_dir)
207
+ self.session_registry_controller.dialogs.append(box)
208
+ box.finished.connect(lambda: self.session_registry_controller.dialogs.remove(box) if box in self.session_registry_controller.dialogs else None)
209
+ box.finished.connect(box.deleteLater)
210
+ open_window_modal(box)
211
+
212
+ def opened(self, identity: RequestIdentity, context: RepositoryContext, commit: Commit | None, file_history_anchor: FileHistoryAnchor | None = None,
213
+ requesting_window: QWidget | None = None) -> None:
214
+ """Publish a successful read only to its still-current requesting window.
215
+
216
+ ``requesting_window`` is the window a file history was asked from; a new window opens centred on it, or on the
217
+ Open or Clone repository window of its session. A read a navigation owns is left to the navigation's own slot,
218
+ connected before this one. A new window is connected to the controllers before anything selects, registers,
219
+ reads or shows it.
220
+ """
221
+ if identity.session_id in self.session_registry_controller.navigation_owner_by_id:
222
+ return
223
+ if self.session_registry_controller.identity_by_session.get(identity.session_id) != identity:
224
+ return
225
+ clone_dialog = self.session_registry_controller.clone_dialog_by_session.get(identity.session_id)
226
+ if clone_dialog is not None and clone_dialog.operation_id is not None:
227
+ run = self.clone.run_by_id[clone_dialog.operation_id]
228
+ try:
229
+ if run.cancelled or not self.operation_policy_controller.clone_is_current(run.intent):
230
+ return
231
+ run.destination.verify()
232
+ if canonical_path(context.repository_dir) != canonical_path(run.destination.destination_dir):
233
+ raise ValueError("The opened repository does not match the clone destination.")
234
+ if run.intent.selection is not None and not run.selection_saved:
235
+ self.authentication.save_selection(run.intent.selection.origin, run.intent.selection, context)
236
+ run.selection_saved = True
237
+ except (OSError, ValueError, TypeError) as error:
238
+ self.history_read_controller.failed(identity, RepositoryFailure("Open cloned repository", context.repository_dir, self.repository.git_filename, [], str(error)))
239
+ return
240
+ existing_window = self.session_registry_controller.window_by_session.get(identity.session_id)
241
+ if existing_window is not None:
242
+ existing_window.show_commit(commit)
243
+ self.refresh_controller.recovery_request_set.discard(identity)
244
+ return
245
+ if file_history_anchor is None:
246
+ showing_window = self.window_showing_folder(context.repository_dir, other_than=identity.session_id)
247
+ if showing_window is not None:
248
+ # Opening a folder already shown is still a successful opening: it goes first in the recent list.
249
+ preference_diagnostic = self.remember_repository(context.repository_dir)
250
+ self.bring_forward(showing_window)
251
+ welcome = self.session_registry_controller.welcome_by_session.get(identity.session_id)
252
+ if welcome is not None:
253
+ # The chooser closes as after any opening; its close ends this unused opening.
254
+ welcome.close()
255
+ if preference_diagnostic is not None:
256
+ self.show_preference_failure(preference_diagnostic, showing_window)
257
+ return
258
+ preference_diagnostic = None
259
+ if file_history_anchor is None:
260
+ preference_diagnostic = self.remember_repository(context.repository_dir)
261
+ session = RepositorySession(identity.session_id, context, self.repository, file_history_anchor)
262
+ session.generation = identity.generation
263
+ self.session_registry_controller.session_by_id[identity.session_id] = session
264
+ window = RepositoryWindow(context, commit, mode=session.mode, placement_anchor=self._new_window_anchor(identity.session_id, requesting_window))
265
+ if file_history_anchor is not None:
266
+ _directory, _separator, basename = os.fsdecode(file_history_anchor.path).rpartition("/")
267
+ window.setWindowTitle(f"{basename} — File history — {context.repository_dir} — GitPane")
268
+ self.wire_repository_window(identity, session, window, context, file_history_anchor)
269
+ if commit is not None:
270
+ session.select_commit(commit.oid)
271
+ self.session_registry_controller.window_by_session[identity.session_id] = window
272
+ # Walking from a parent repository into a child inside an already active window delivers no activation event.
273
+ self.window_activation_controller.schedule_activation_update()
274
+ if WindowCapability.MANAGE in session.capabilities and not context.is_bare:
275
+ # The window starts with an unknown count, and this worktree may already have been read for another window.
276
+ self.staging_session_registry_controller.staging_session(context).refresh(announce=True)
277
+ window.show()
278
+ self.preference.refresh()
279
+ window.show_recent_repositories(self.preference.value_by_key["recent_dirs"])
280
+ window.apply_reading_preference(self.preference.value_by_key)
281
+ window.apply_fetch_preference(self.preference.value_by_key)
282
+ welcome = self.session_registry_controller.welcome_by_session.pop(identity.session_id, None)
283
+ clone_dialog = self.session_registry_controller.clone_dialog_by_session.pop(identity.session_id, None)
284
+ if clone_dialog is not None and clone_dialog.operation_id is not None:
285
+ self.clone_controller.keep_clone_console(clone_dialog.operation_id, window, show=False)
286
+ self.clone.open_result(clone_dialog.operation_id, True)
287
+ clone_dialog.accept()
288
+ # The replaced windows are handed to Qt for deletion while these local variables still hold them, so their end
289
+ # never depends on where their last Python reference goes away: destroyed at the wrong moment, in the middle of
290
+ # Qt switching the active window to the repository window, they made GitPane crash.
291
+ if clone_dialog is not None:
292
+ clone_dialog.deleteLater()
293
+ if welcome is not None:
294
+ welcome.close()
295
+ welcome.deleteLater()
296
+ window.begin_history()
297
+ self.reference_read_controller.read_history_with_references(identity.session_id, identity)
298
+ self.refresh_controller.read_submodules(identity.session_id)
299
+ self.pause_observation_requested.emit(identity.session_id)
300
+ if preference_diagnostic is not None:
301
+ self.show_preference_failure(preference_diagnostic, window)
302
+
303
+ def window_showing_folder(self, repository_dir: str, *, other_than: str) -> RepositoryWindow | None:
304
+ """Return the repository window, other than the ``other_than`` session's, that already shows this working folder.
305
+
306
+ GitPane keeps one window per working folder, so opening the folder again brings that window forward instead.
307
+ A file history window is not a repository window and is left out; another worktree of the same repository is
308
+ another folder, with its own window.
309
+ """
310
+ # Left as is on purpose: this check runs when an opening or a navigation is asked for, not again when it
311
+ # arrives. So two windows can still show one folder in a rare race: a second request for a folder while a first
312
+ # navigation to that same folder is still reading it, or a switch to a sub-folder of a repository, compared as
313
+ # given with the repository roots the windows show. Nothing is at risk then: every write reserves its
314
+ # repository first, so the two windows can never write to it at the same time.
315
+ target_dir = canonical_path(repository_dir)
316
+ for session_id, session in self.session_registry_controller.session_by_id.items():
317
+ if session_id == other_than or session.is_closed or session.file_history_anchor is not None:
318
+ continue
319
+ window = self.session_registry_controller.window_by_session.get(session_id)
320
+ if window is None or not isValid(window):
321
+ continue
322
+ if canonical_path(session.repository_context.repository_dir) == target_dir:
323
+ return window
324
+ return None
325
+
326
+ def bring_forward(self, window: QWidget) -> None:
327
+ """Show a window again in front of the others, restored if it was minimized."""
328
+ if window.isMinimized():
329
+ window.showNormal()
330
+ window.raise_()
331
+ window.activateWindow()
332
+
333
+ def _new_window_anchor(self, session_id: str, requesting_window: QWidget | None) -> QWidget | None:
334
+ """Return the window a new repository or file history window opens centred on.
335
+
336
+ The Clone repository window comes first while it is on screen, since the user is looking at it; then the Open
337
+ repository window of the session; a file history session has neither and uses the window that asked for it.
338
+ """
339
+ clone_dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
340
+ if is_visible_window(clone_dialog):
341
+ return clone_dialog
342
+ welcome = self.session_registry_controller.welcome_by_session.get(session_id)
343
+ if is_visible_window(welcome):
344
+ return welcome
345
+ if is_visible_window(requesting_window):
346
+ return requesting_window
347
+ return None
348
+
349
+ def remember_repository(self, repository_dir: str) -> PreparedDiagnostic | None:
350
+ """Put a successfully opened repository first in the history, returning the diagnostic of a failed save.
351
+
352
+ A failed save never undoes the opening: the caller shows the diagnostic over the repository that is open.
353
+ """
354
+ try:
355
+ self.preference.remember(repository_dir)
356
+ except OSError as error:
357
+ return Diagnostic("Save preferences", repository_dir, self.repository.git_filename, [], str(error)).prepare()
358
+ return None
359
+
360
+ def show_preference_failure(self, diagnostic: PreparedDiagnostic, window: RepositoryWindow) -> None:
361
+ """Log a failed preference save and explain it in a dialog over the window that stays open."""
362
+ if self.diagnostic_log is not None:
363
+ self.diagnostic_log.write(diagnostic)
364
+ dialog = ErrorDialog(diagnostic, window, self.diagnostic_log.log_filename if self.diagnostic_log is not None else None)
365
+ self.session_registry_controller.dialogs.append(dialog)
366
+ dialog.finished.connect(lambda: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
367
+ dialog.finished.connect(dialog.deleteLater)
368
+ open_window_modal(dialog)
369
+
370
+ def cancel_open(self, session_id: str) -> None:
371
+ """Abandon only the current chooser's read while keeping its form available."""
372
+ self.setting_controller.waiting_opening_by_session.pop(session_id, None)
373
+ self.session_registry_controller.identity_by_session.pop(session_id, None)
374
+ self.repository.cancel(session_id)
375
+ self.session_registry_controller.welcome_by_session[session_id].set_loading(False)
@@ -0,0 +1,76 @@
1
+ """Decide whether a window may still send a captured write to Git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ from gitpane.history.file_history import WindowCapability
9
+ from gitpane.operation.repository_operation import NO_LONGER_AUTHORIZED_TEXT, REPOSITORY_CHANGED_SINCE_OPENED_TEXT, REPOSITORY_CHANGED_WHILE_PREPARING_TEXT, rebase_action_diagnostic
10
+
11
+ if TYPE_CHECKING:
12
+ from gitpane.controller.session_registry import SessionRegistry
13
+ from gitpane.history.repository import GitRepository
14
+ from gitpane.operation.repository_operation import OperationIntent, RepositoryOperationService
15
+ from gitpane.sequencer.rebase import RebaseService
16
+ from gitpane.transport.clone import CloneIntent
17
+
18
+
19
+ class OperationPolicy:
20
+ """The authority a window keeps over the writes it captured."""
21
+
22
+ def __init__(self, *, session_registry_controller: SessionRegistry, repository: GitRepository, repository_operation: RepositoryOperationService, rebase: RebaseService) -> None:
23
+ """Receive the sessions and the services the decision reads; the wiring builds it right after the rebase service, which it reads."""
24
+ self.session_registry_controller = session_registry_controller
25
+ self.repository = repository
26
+ self.repository_operation = repository_operation
27
+ self.rebase = rebase
28
+
29
+ def clone_is_current(self, intent: CloneIntent) -> bool:
30
+ """Prevent a closed or superseded welcome from publishing clone results."""
31
+ return (not self.session_registry_controller.shutdown_requested and intent.session_id in self.session_registry_controller.welcome_by_session and intent.session_id in self.session_registry_controller.clone_dialog_by_session
32
+ and self.session_registry_controller.clone_generation_by_session.get(intent.session_id) == intent.generation)
33
+
34
+ def allows(self, session_id: str, capability: WindowCapability) -> bool:
35
+ """Enforce the same action policy at service boundaries and widget entry points."""
36
+ session = self.session_registry_controller.session_by_id.get(session_id)
37
+ return session is not None and not session.is_closed and capability in session.capabilities
38
+
39
+ def operation_allowed(self, intent: OperationIntent) -> bool:
40
+ """Validate captured window authority and repository identity at submission time."""
41
+ return not self.operation_refusal(intent)
42
+
43
+ def operation_refusal(self, intent: OperationIntent) -> str:
44
+ """Say why a captured write may no longer be sent, or "" when it may.
45
+
46
+ A window refreshed after its form was opened (an outside change the automatic refresh saw) has a newer
47
+ generation than the form's capture: the form cannot write any more and has to be reopened, which is what the
48
+ refusal says, rather than blaming another operation. A write captured at the click, with no form keeping it
49
+ (a stash, a rebase start, the push of a Commit & push), only has to be clicked again.
50
+ """
51
+ reason = self.repository_operation.policy_diagnostic(intent)
52
+ if reason:
53
+ return reason
54
+ state = self.rebase.state_by_key.get(intent.key)
55
+ if state is not None:
56
+ reason = rebase_action_diagnostic(intent.action, state)
57
+ if reason:
58
+ return reason
59
+ if intent.session_id is None:
60
+ return ""
61
+ if not self.allows(intent.session_id, intent.capability):
62
+ return NO_LONGER_AUTHORIZED_TEXT
63
+ session = self.session_registry_controller.session_by_id[intent.session_id]
64
+ if session.repository_context != intent.context or self.session_registry_controller.generation_by_session.get(intent.session_id) != intent.generation:
65
+ return REPOSITORY_CHANGED_SINCE_OPENED_TEXT if intent.from_form else REPOSITORY_CHANGED_WHILE_PREPARING_TEXT
66
+ return ""
67
+
68
+ def commit_push_operation(self, captured: OperationIntent) -> OperationIntent | None:
69
+ """Retain the original repository and permission through the normal history refresh."""
70
+ session_id = captured.session_id
71
+ if captured.capability != WindowCapability.MANAGE or not self.allows(session_id, WindowCapability.MANAGE):
72
+ return None
73
+ session = self.session_registry_controller.session_by_id[session_id]
74
+ if session.repository_context != captured.context or self.repository.git_filename != captured.git_filename:
75
+ return None
76
+ return replace(captured, generation=self.session_registry_controller.generation_by_session[session_id])