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,1137 @@
1
+ """Show the welcome window, where the user opens a recent repository, a folder or a clone."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass
7
+ from functools import partial
8
+ from itertools import pairwise
9
+ from math import ceil
10
+ from typing import TYPE_CHECKING, Self
11
+
12
+ from PySide6.QtCore import QEvent, QMargins, QModelIndex, QPointF, QRect, QRectF, QSize, Qt, QTimer, Signal
13
+ from PySide6.QtGui import QAction, QColor, QFont, QFontMetrics, QPainter, QPalette, QTextLayout, QTextOption
14
+ from PySide6.QtWidgets import (
15
+ QAbstractItemView,
16
+ QApplication,
17
+ QFileDialog,
18
+ QFrame,
19
+ QGridLayout,
20
+ QHBoxLayout,
21
+ QLabel,
22
+ QLayout,
23
+ QLineEdit,
24
+ QListWidget,
25
+ QListWidgetItem,
26
+ QMessageBox,
27
+ QPushButton,
28
+ QScrollArea,
29
+ QSizePolicy,
30
+ QStyle,
31
+ QStyledItemDelegate,
32
+ QStyleOptionViewItem,
33
+ QVBoxLayout,
34
+ QWidget,
35
+ )
36
+
37
+ from gitpane.config import (
38
+ FIRST_ROW,
39
+ MILLISECONDS_PER_SECOND,
40
+ RECENT_CHECK_UNKNOWN_DELAY_MS,
41
+ RECENT_CHECKING_DISPLAY_DELAY_MS,
42
+ SCREEN_MARGIN,
43
+ SPACING_XS,
44
+ UNKNOWN_DARK_COLOR,
45
+ UNKNOWN_LIGHT_COLOR,
46
+ WELCOME_MAX_SCREEN_RATIO,
47
+ WELCOME_MIN_HEIGHT,
48
+ WELCOME_MIN_WIDTH,
49
+ WELCOME_WIDTH,
50
+ SharedText,
51
+ center_window_at,
52
+ fit_window,
53
+ )
54
+ from gitpane.operation.diagnostic import SensitiveTextFilter
55
+ from gitpane.recent.mount_table import PLATFORM_MOUNT_TABLE_READER
56
+ from gitpane.recent.recent_availability import RecentAvailabilityCheck, folder_is_reachable, ignore_log_line, unanswered_after_wait_log_line
57
+ from gitpane.recent.recent_name import repository_display_name
58
+ from gitpane.recent.recent_search import ranked_recent_dirs
59
+ from gitpane.text.typed_path import session_typed_path, typed_path_value
60
+ from gitpane.text.typed_text import typed_value
61
+ from gitpane.thread.weak_signal import weak_signal_emitter
62
+ from gitpane.ui.look.action_icon import action_icon
63
+ from gitpane.ui.look.theme_color import is_dark_palette
64
+ from gitpane.ui.look.theme_look import follow_theme
65
+ from gitpane.ui.look.windows_control_look import WINDOWS_LIST_BORDER_WIDTH, apply_windows_line_edit_look, fit_windows_field_height, secondary_text_color, windows_list_style_sheet
66
+ from gitpane.ui.transport.clone_dialog import CLONE_FORM_CAPTIONS, CLONE_PLAIN_SIDE_BUTTON_TEXTS
67
+ from gitpane.ui.widget.busy_indicator import FRAME_INTERVAL_MS, next_busy_angle, paint_busy_arc
68
+ from gitpane.ui.widget.dialog_layout import (
69
+ BROWSE_TEXT,
70
+ BUTTON_COLUMN,
71
+ FIELD_COLUMN,
72
+ FORM_BAND_BUTTON_MIN_SIZE,
73
+ FORM_GRID_BODY_MARGINS,
74
+ FORM_GRID_CAPTION_INDENT,
75
+ FORM_GRID_COLUMN_SPACING,
76
+ FORM_GRID_ROW_SPACING,
77
+ FORM_GRID_ROWS_TO_BLOCK_SPACE,
78
+ BandButtonSizing,
79
+ add_form_caption,
80
+ apply_side_button_look,
81
+ fit_caption_to_other_form,
82
+ fit_side_buttons,
83
+ install_banded_layout,
84
+ refresh_style_sheet_fonts,
85
+ )
86
+ from gitpane.ui.widget.long_text import LONG_WORD_WRAP_MODE, WrappingLabel
87
+ from gitpane.ui.widget.result_label import ResultTone, result_color
88
+ from gitpane.ui.window_control.dialog_modality import prepare_message_box
89
+ from gitpane.ui.window_control.item_activation import activate_current_row
90
+ from gitpane.ui.window_control.window_placement import WELCOME_ANCHOR_RANK, centre_on_placement_anchor, is_visible_window, mark_placement_anchor
91
+
92
+ if TYPE_CHECKING:
93
+ from collections.abc import Callable
94
+
95
+ from PySide6.QtGui import QCloseEvent, QHideEvent, QResizeEvent, QScreen, QShowEvent
96
+
97
+ from gitpane.operation.diagnostic import DiagnosticLog
98
+ from gitpane.recent.mount_table import MountEntry
99
+
100
+
101
+ RECENT_COLUMN = 0
102
+ # The Open repository form uses the margins of the shared form grid, as the Clone window does, except at the bottom: the Clone window has a fixed height
103
+ # that provides the room under its content, while this form's height follows its content, so the recent list keeps as
104
+ # much room above the button band as on its left.
105
+ WELCOME_BODY_MARGINS = QMargins(FORM_GRID_BODY_MARGINS.left(), FORM_GRID_BODY_MARGINS.top(), FORM_GRID_BODY_MARGINS.right(), FORM_GRID_BODY_MARGINS.left())
106
+ # The form's grid has a single row: the repository path.
107
+ PATH_ROW = 0
108
+ # The Open repository form adds its clone button in a column right of Browse…; the rest of the grid keeps the columns of
109
+ # the Clone form, so the caption, the field and Browse… stay lined up with that window.
110
+ CLONE_BUTTON_COLUMN = BUTTON_COLUMN + 1
111
+ # Read alone by a screen reader, "or Clone" does not say what the button does.
112
+ CLONE_BUTTON_ACCESSIBLE_NAME = "Clone a repository"
113
+ # Width of the Search field above the recent list, in logical pixels: wide enough for a few words, while the row of
114
+ # Recent repositories:, Search: and the field still fits in the narrowest form.
115
+ RECENT_SEARCH_FIELD_WIDTH = 200
116
+ RECENT_SEARCH_ACCESSIBLE_NAME = "Search recent repositories"
117
+ # The style only wraps an item's text inside a valid rectangle; the height given does not change the measured height.
118
+ MEASURE_RECT_MIN_HEIGHT = 1
119
+ RECENT_MIN_VISIBLE_ROW_COUNT = 1
120
+ """Recent rows the list keeps room for however short the form gets, before the form itself has to scroll."""
121
+ UNWRAPPED_MEASURE_WIDTH = 100000
122
+ """Width wide enough for any recent path to fit on one line, to measure a row as if its text did not wrap."""
123
+ # A little air above and below the lines of a recent repository.
124
+ RECENT_ROW_VERTICAL_PADDING = 3
125
+ # The text of the recents, and of the empty recent box, starts at the same x as the captions' text: the captions' indent
126
+ # minus the list border, which already moves the text right.
127
+ RECENT_ROW_PADDING = QMargins(FORM_GRID_CAPTION_INDENT - WINDOWS_LIST_BORDER_WIDTH, RECENT_ROW_VERTICAL_PADDING,
128
+ FORM_GRID_CAPTION_INDENT - WINDOWS_LIST_BORDER_WIDTH, RECENT_ROW_VERTICAL_PADDING)
129
+
130
+
131
+ def empty_recent_style_sheet(palette: QPalette) -> str:
132
+ """Return the style sheet of the text shown in place of an empty recent list: the list's border and background, grey text.
133
+
134
+ The text sits as far from the border as the text of a recent row, so both start at the same place.
135
+ """
136
+ padding = f"{RECENT_ROW_PADDING.top()}px {RECENT_ROW_PADDING.right()}px {RECENT_ROW_PADDING.bottom()}px {RECENT_ROW_PADDING.left()}px"
137
+ return windows_list_style_sheet(palette, "QLabel", hide_focus_outline=False) + f" QLabel {{ color: {secondary_text_color(palette).name()}; padding: {padding}; }}"
138
+
139
+
140
+ class WelcomeText(enum.Enum):
141
+ """Texts the Open repository window shows, one member per text."""
142
+
143
+ TITLE = "GitPane"
144
+ OPEN_REPOSITORY = "Open repository"
145
+ OPEN = "Open"
146
+ CLONE = "or Clone"
147
+ CANCEL_READING = "Cancel reading"
148
+ REPOSITORY_PATH = "Repository path"
149
+ EMPTY_PATH = "Enter a repository path or use Browse."
150
+ RECENT_SEARCH = "Search"
151
+ RECENT_EMPTY = "No recent repositories"
152
+ RECENT_NO_MATCH = "No matching repositories"
153
+ RECENT_HELP = "Enter a repository path or click Browse to get started."
154
+ OPENING = "Opening…"
155
+ INVALID_PATH_TITLE = "Invalid path"
156
+ INVALID_PATH = "This folder does not exist:\n{path}"
157
+
158
+
159
+ class RecentMark(enum.Enum):
160
+ """What the third line of a recent's row says about the check of its folder: nothing, still checking, Unknown or Unavailable.
161
+
162
+ The value is what a listed row keeps in its item data; the text the line shows is the ``label`` attribute.
163
+ """
164
+
165
+ NONE = ("", "")
166
+ CHECKING = ("checking", "checking")
167
+ UNKNOWN = ("unknown", "Unknown")
168
+ UNAVAILABLE = ("unavailable", "Unavailable")
169
+
170
+ def __new__(cls, value: str, label: str) -> Self:
171
+ """Keep the value its row stores as the member's value, and the text its line shows beside it."""
172
+ member = object.__new__(cls)
173
+ member._value_ = value
174
+ member.label = label
175
+ return member
176
+
177
+
178
+ # Item data role that holds the value of a recent repository's mark; the path itself is in UserRole.
179
+ RECENT_MARK_ROLE = int(Qt.ItemDataRole.UserRole) + 1
180
+
181
+
182
+ def index_recent_mark(index: QModelIndex) -> RecentMark:
183
+ """Return the mark a listed row carries; a row without any mark data has none."""
184
+ mark_value = index.data(RECENT_MARK_ROLE)
185
+ if mark_value is None:
186
+ return RecentMark.NONE
187
+ return RecentMark(mark_value)
188
+
189
+
190
+ def recent_item_text(recent_dir: str, mark: RecentMark) -> str:
191
+ """Return the whole row of a recent as plain text, for screen readers: its name, its path, and its mark's label when it has one.
192
+
193
+ The name is the one the search ranks and the window title shows, the whole path for the root of a drive, which has
194
+ no folder name. The delegate draws the same lines in colors.
195
+ """
196
+ text = f"{repository_display_name(recent_dir)}\n{recent_dir}"
197
+ if mark != RecentMark.NONE:
198
+ text += "\n" + mark.label
199
+ return text
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class RecentRowLine:
204
+ """One line of text of a recent repository, as it is measured and painted.
205
+
206
+ ``indent`` moves the text right, in the measure as in the painting, so the measured height stays the painted one;
207
+ the "checking" line leaves that room for the turning arc drawn at its left when ``shows_busy_arc`` is set.
208
+ """
209
+
210
+ text: str
211
+ font: QFont
212
+ color: QColor
213
+ indent: int = 0
214
+ shows_busy_arc: bool = False
215
+
216
+
217
+ # Space between the turning arc and the "checking" text it stands before.
218
+ RECENT_BUSY_ARC_GAP = SPACING_XS
219
+
220
+
221
+ class RecentRepositoryDelegate(QStyledItemDelegate):
222
+ """Draw a recent repository as its folder name, its full path in the secondary text color, then its mark in italics.
223
+
224
+ The mark is "checking" while the folder's answer is late, in the secondary text color, Unknown when the check got no
225
+ answer, in yellow, or Unavailable in the warning color. The three take the same line, so going from one to another
226
+ never changes the row's height.
227
+
228
+ The measured height is exactly the painted height: one function lays the lines out for both, so the list asks for
229
+ the room its rows really take and the window grows with them. The text is laid out as plain text, never as rich
230
+ text, since folder names come from the disk.
231
+ """
232
+
233
+ def __init__(self, parent: QWidget | None = None) -> None:
234
+ """Start the arc of the "checking" rows at its first angle; the window's spinner timer turns it."""
235
+ super().__init__(parent)
236
+ self.busy_angle = 0
237
+
238
+ def row_lines(self, option: QStyleOptionViewItem, index: QModelIndex) -> list[RecentRowLine]:
239
+ """Return the lines of a listed row: name, path, then its mark in italics when it has one."""
240
+ return self.recent_row_lines(option, index.data(Qt.ItemDataRole.UserRole) or "", index_recent_mark(index))
241
+
242
+ def recent_row_lines(self, option: QStyleOptionViewItem, recent_dir: str, mark: RecentMark) -> list[RecentRowLine]:
243
+ """Return the lines of a recent's row, whether or not the list shows it right now.
244
+
245
+ Unavailable is written in the warning color. "checking" only says the answer is late, so it takes the path's
246
+ secondary color; Unknown says the check gave up, so it is yellow, to be noticed without being read as gone. A
247
+ selected row is written entirely in the selection's text color, of the active or inactive group as the window
248
+ is, so every line stays readable on the selection.
249
+ """
250
+ palette = option.palette
251
+ dark = is_dark_palette(palette)
252
+ name_color = palette.color(QPalette.ColorRole.Text)
253
+ path_color = secondary_text_color(palette)
254
+ warning_color = QColor(result_color(ResultTone.WARNING, dark))
255
+ unknown_color = QColor(UNKNOWN_DARK_COLOR if dark else UNKNOWN_LIGHT_COLOR)
256
+ if option.state & QStyle.StateFlag.State_Selected:
257
+ group = QPalette.ColorGroup.Active if option.state & QStyle.StateFlag.State_Active else QPalette.ColorGroup.Inactive
258
+ selected_color = palette.color(group, QPalette.ColorRole.HighlightedText)
259
+ name_color = path_color = warning_color = unknown_color = selected_color
260
+ font = QFont(option.font)
261
+ lines = [RecentRowLine(repository_display_name(recent_dir), font, name_color), RecentRowLine(recent_dir, font, path_color)]
262
+ if mark == RecentMark.NONE:
263
+ return lines
264
+ italic_font = QFont(font)
265
+ italic_font.setItalic(True)
266
+ if mark == RecentMark.UNAVAILABLE:
267
+ lines.append(RecentRowLine(mark.label, italic_font, warning_color))
268
+ return lines
269
+ if mark == RecentMark.UNKNOWN:
270
+ lines.append(RecentRowLine(mark.label, italic_font, unknown_color))
271
+ return lines
272
+ # The arc sits in a square as tall as the line, left of the text.
273
+ arc_side = QFontMetrics(italic_font).height()
274
+ lines.append(RecentRowLine(mark.label, italic_font, path_color, arc_side + RECENT_BUSY_ARC_GAP, shows_busy_arc=True))
275
+ return lines
276
+
277
+ def _lay_out_lines(self, lines: list[RecentRowLine], width: int) -> tuple[list[tuple[QTextLayout, RecentRowLine, float]], float, float]:
278
+ """Wrap each line at the width less its indent and stack them, returning the layouts, their total height and the widest wrapped line.
279
+
280
+ Each layout comes with its row line and the top of its first text line. A path without spaces still wraps,
281
+ anywhere if needed, as the other long values of the application do.
282
+ """
283
+ text_option = QTextOption()
284
+ text_option.setWrapMode(LONG_WORD_WRAP_MODE)
285
+ layouts: list[tuple[QTextLayout, RecentRowLine, float]] = []
286
+ top = 0.0
287
+ widest = 0.0
288
+ for row_line in lines:
289
+ layout = QTextLayout(row_line.text, row_line.font)
290
+ layout.setTextOption(text_option)
291
+ line_top = top
292
+ layout.beginLayout()
293
+ while True:
294
+ text_line = layout.createLine()
295
+ if not text_line.isValid():
296
+ break
297
+ text_line.setLineWidth(max(1, width - row_line.indent))
298
+ text_line.setPosition(QPointF(row_line.indent, top))
299
+ top += text_line.height()
300
+ widest = max(widest, row_line.indent + text_line.naturalTextWidth())
301
+ layout.endLayout()
302
+ layouts.append((layout, row_line, line_top))
303
+ return layouts, top, widest
304
+
305
+ def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
306
+ """Measure the listed row at the width given in option.rect, which the list sets to its own width, padding included."""
307
+ item_option = QStyleOptionViewItem(option)
308
+ self.initStyleOption(item_option, index)
309
+ return self.measure_recent_row(item_option, index.data(Qt.ItemDataRole.UserRole) or "", index_recent_mark(index))
310
+
311
+ def measure_recent_row(self, option: QStyleOptionViewItem, recent_dir: str, mark: RecentMark) -> QSize:
312
+ """Measure a recent's row at the width given in option.rect, padding included, whether or not the list shows it.
313
+
314
+ Qt also asks with an invalid rectangle, during its own layout and before the list has a width; the text is then
315
+ measured unwrapped, as Qt's own delegate does, rather than wrapped at one pixel into one glyph per line.
316
+ """
317
+ horizontal_padding = RECENT_ROW_PADDING.left() + RECENT_ROW_PADDING.right()
318
+ vertical_padding = RECENT_ROW_PADDING.top() + RECENT_ROW_PADDING.bottom()
319
+ if option.rect.isValid():
320
+ text_width = max(1, option.rect.width() - horizontal_padding)
321
+ else:
322
+ text_width = UNWRAPPED_MEASURE_WIDTH
323
+ _layouts, height, widest = self._lay_out_lines(self.recent_row_lines(option, recent_dir, mark), text_width)
324
+ return QSize(ceil(widest) + horizontal_padding, ceil(height) + vertical_padding)
325
+
326
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
327
+ """Let the style draw the row's background, selection, hover and focus without text, then draw the lines over it.
328
+
329
+ A "checking" line gets the turning arc at its left, at the angle the window's spinner timer last set; painting
330
+ only reads that angle.
331
+ """
332
+ item_option = QStyleOptionViewItem(option)
333
+ self.initStyleOption(item_option, index)
334
+ lines = self.row_lines(item_option, index)
335
+ item_option.text = ""
336
+ widget = option.widget
337
+ style = widget.style() if widget is not None else QApplication.style()
338
+ style.drawControl(QStyle.ControlElement.CE_ItemViewItem, item_option, painter, widget)
339
+ text_rect = option.rect.marginsRemoved(RECENT_ROW_PADDING)
340
+ layouts, _height, _widest = self._lay_out_lines(lines, max(1, text_rect.width()))
341
+ painter.save()
342
+ painter.setClipRect(option.rect)
343
+ origin = QPointF(text_rect.topLeft())
344
+ for layout, row_line, line_top in layouts:
345
+ painter.setPen(row_line.color)
346
+ layout.draw(painter, origin)
347
+ if row_line.shows_busy_arc:
348
+ arc_side = row_line.indent - RECENT_BUSY_ARC_GAP
349
+ paint_busy_arc(painter, QRectF(origin.x(), origin.y() + line_top, arc_side, arc_side), self.busy_angle, row_line.color)
350
+ painter.restore()
351
+
352
+
353
+ class RecentRepositoryList(QListWidget):
354
+ """Recent repositories, as tall as all their rows when the form has room, otherwise scrolling on their own.
355
+
356
+ The list asks for the height of every row, so a short list needs no scrolling at all; when the form reaches its height
357
+ cap the layout shrinks only the list, which then scrolls, so the path field above it stays visible; the buttons sit
358
+ in the band under the form and are always visible.
359
+ The paths wrap, so a row's height depends on the list width: the wanted height is measured again whenever that width
360
+ changes.
361
+ """
362
+
363
+ def __init__(self) -> None:
364
+ """Draw the rows with the recent delegate, wrap long paths instead of eliding them, and scroll by pixel since a wrapped row can be several lines tall."""
365
+ super().__init__()
366
+ self.delegate = RecentRepositoryDelegate(self)
367
+ self.setItemDelegate(self.delegate)
368
+ self.setWordWrap(True)
369
+ self.setTextElideMode(Qt.TextElideMode.ElideNone)
370
+ self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
371
+
372
+ def measure_option(self, width: int) -> QStyleOptionViewItem:
373
+ """Return the list's item option with a rectangle of the given width; without any width yet, wide enough not to wrap."""
374
+ option = QStyleOptionViewItem()
375
+ self.initViewItemOption(option)
376
+ option.rect = QRect(0, 0, width if width > 0 else UNWRAPPED_MEASURE_WIDTH, MEASURE_RECT_MIN_HEIGHT)
377
+ return option
378
+
379
+ def row_height_at_width(self, row: int, width: int) -> int:
380
+ """Return the height the row takes once its text wraps at the given width; without any width yet, its unwrapped height."""
381
+ index = self.model().index(row, RECENT_COLUMN)
382
+ return self.itemDelegateForIndex(index).sizeHint(self.measure_option(width), index).height()
383
+
384
+ def recent_height_at_width(self, recent_dir: str, mark: RecentMark, width: int) -> int:
385
+ """Return the height a recent's row takes at the given width with the given mark, even while the search hides it."""
386
+ return self.delegate.measure_recent_row(self.measure_option(width), recent_dir, mark).height()
387
+
388
+ def sizeHintForRow(self, row: int) -> int:
389
+ """Return the height the row takes once its text wraps at the list's current width.
390
+
391
+ The base implementation measures the text without any width, so a wrapped path would count as a single line.
392
+ """
393
+ return self.row_height_at_width(row, self.viewport().width())
394
+
395
+ def rows_height(self) -> int:
396
+ """Return the height every row takes at the current width, plus the list frame."""
397
+ height = self.frameWidth() * 2
398
+ for row in range(self.count()):
399
+ height += self.sizeHintForRow(row)
400
+ return height
401
+
402
+ def sizeHint(self) -> QSize:
403
+ """Ask for the height of every row, so the form grows to show all the recents without scrolling when it can."""
404
+ return QSize(super().sizeHint().width(), self.rows_height())
405
+
406
+ def minimumSizeHint(self) -> QSize:
407
+ """Let the layout shrink the list down to room for its first rows; below its wanted height the list scrolls on its own.
408
+
409
+ The first rows are measured as if their text did not wrap: a long path that wraps over many lines on a narrow form
410
+ would otherwise raise the minimum past the room left, and the whole form would scroll instead of the list.
411
+ """
412
+ height = self.frameWidth() * 2
413
+ for row in range(min(RECENT_MIN_VISIBLE_ROW_COUNT, self.count())):
414
+ height += self.row_height_at_width(row, UNWRAPPED_MEASURE_WIDTH)
415
+ return QSize(super().minimumSizeHint().width(), height)
416
+
417
+ def resizeEvent(self, event: QResizeEvent) -> None:
418
+ """Measure the wanted height again when the width changes, since the paths then wrap differently.
419
+
420
+ Only a width change triggers it: the scroll bar appearing narrows the rows but not the list, so this cannot loop.
421
+ """
422
+ super().resizeEvent(event)
423
+ if event.size().width() != event.oldSize().width():
424
+ self.updateGeometry()
425
+
426
+
427
+ class WelcomeFormContent(QWidget):
428
+ """Content of the scrolling form, which the scroll area may shrink down to its minimum height rather than its preferred one."""
429
+
430
+ def heightForWidth(self, width: int) -> int:
431
+ """Return the smallest height the form's layout accepts at this width, instead of the height it would like.
432
+
433
+ Workaround: a resizable QScrollArea never makes a content whose height depends on its width (the wrapped labels)
434
+ shorter than that content's heightForWidth, which QWidget answers with the preferred height. The form would then
435
+ always be as tall as every recent row and scroll as a whole. Answering with the minimum height lets the scroll
436
+ area fit the form to the window, so the layout shrinks the recent list, which scrolls on its own; the form only
437
+ scrolls once it is shorter than that minimum.
438
+ """
439
+ layout = self.layout()
440
+ if layout is None or not layout.hasHeightForWidth():
441
+ return super().heightForWidth(width)
442
+ return layout.totalMinimumHeightForWidth(width)
443
+
444
+
445
+ class WelcomeWindow(QWidget):
446
+ """Collect a repository path and display persisted successful openings.
447
+
448
+ Drawn like the Git Extensions windows: a white body holding the path row (field, Browse… and the "or Clone" button
449
+ that opens the Clone window) and the recent repositories, above a grey band carrying Settings on its left and Open
450
+ on its right. A Search field at the top right of the recent list filters it as the user types.
451
+
452
+ Each recent's folder is checked in the background: a recent whose answer is late shows "checking" with a turning
453
+ arc after a short delay, then Unknown when its check fails without answer or waits too long; a missing folder or a
454
+ failed opening shows Unavailable. A real answer, however late, always replaces "checking" and Unknown.
455
+ """
456
+
457
+ open_requested = Signal(str)
458
+ clone_requested = Signal()
459
+ closed = Signal()
460
+ cancel_requested = Signal()
461
+ setting_requested = Signal()
462
+ availability_checked = Signal(str, bool)
463
+ availability_log_line = Signal(str)
464
+ availability_unanswered = Signal(str)
465
+
466
+ def __init__(self, recent_dirs: list[str], anchor: QWidget | None = None, is_reachable: Callable[[str], bool] = folder_is_reachable,
467
+ diagnostic_log: DiagnosticLog | None = None, read_mount_table: Callable[[], list[MountEntry]] | None = PLATFORM_MOUNT_TABLE_READER,
468
+ typed_path: Callable[[str], str] = session_typed_path) -> None:
469
+ """Build the opening form as a body above a button band, with keyboard-accessible actions.
470
+
471
+ ``anchor`` is the repository window the form was opened from; the form opens centred on it, or on the screen
472
+ when there is none (at startup). ``is_reachable`` answers whether a recent folder can be reached, on a
473
+ background thread; tests give their own answers instead of asking the disk. ``diagnostic_log`` is the --log
474
+ file, which learns why a folder check got no answer; without it those reasons are dropped.
475
+ ``read_mount_table`` reads the system's mount table to group the folder checks by disk; tests give their own table.
476
+ ``typed_path`` turns the text of the path field, without its blanks, into the path to open, expanding ``~`` where
477
+ that is the usage; tests give their own system and home folder.
478
+ """
479
+ super().__init__()
480
+ mark_placement_anchor(self, WELCOME_ANCHOR_RANK)
481
+ self.placement_anchor = anchor
482
+ self.typed_path = typed_path
483
+ self.centred = False
484
+ self.setWindowTitle(WelcomeText.TITLE.value)
485
+ # Starts at the height cap so the first layout gives the recent list its full width; the first show then fits the
486
+ # height to the recent repositories. Both are measured on the screen the form opens on, which may not be its own yet.
487
+ self.resize(min(WELCOME_WIDTH, self.placement_screen().availableGeometry().width() - SCREEN_MARGIN * 2), self.recent_height_cap())
488
+ # The recent list does the scrolling when the recents do not fit; this scroll area is only the last resort for a
489
+ # form shorter than its controls plus one recent row (very small screen, or the user shrinking the window). The
490
+ # buttons sit in the band under it and never scroll.
491
+ self.scroll_area = QScrollArea()
492
+ self.scroll_area.setWidgetResizable(True)
493
+ self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
494
+ # Without a frame the scroll area would be an invisible Tab stop; it still scrolls to whichever child takes the focus.
495
+ self.scroll_area.setFocusPolicy(Qt.FocusPolicy.NoFocus)
496
+ content = WelcomeFormContent()
497
+ layout = QVBoxLayout(content)
498
+ self.scroll_area.setWidget(content)
499
+ layout.setSpacing(0)
500
+ layout.setContentsMargins(WELCOME_BODY_MARGINS)
501
+ self.path_edit = QLineEdit()
502
+ self.path_edit.setAccessibleName(WelcomeText.REPOSITORY_PATH.value)
503
+ self.path_edit.returnPressed.connect(self._request_open)
504
+ self.browse_button = QPushButton(BROWSE_TEXT)
505
+ # Enter opens the typed path; it never runs Browse….
506
+ self.browse_button.setAutoDefault(False)
507
+ self.browse_button.setIcon(action_icon("folder", self.devicePixelRatioF()))
508
+ self.browse_button.clicked.connect(self._browse)
509
+ self.clone_button = QPushButton(WelcomeText.CLONE.value)
510
+ # Enter opens the typed path; it never starts a clone.
511
+ self.clone_button.setAutoDefault(False)
512
+ self.clone_button.setAccessibleName(CLONE_BUTTON_ACCESSIBLE_NAME)
513
+ self.clone_button.clicked.connect(self.clone_requested)
514
+ path_grid = QGridLayout()
515
+ path_grid.setContentsMargins(0, 0, 0, 0)
516
+ path_grid.setHorizontalSpacing(FORM_GRID_COLUMN_SPACING)
517
+ path_grid.setVerticalSpacing(FORM_GRID_ROW_SPACING)
518
+ path_grid.setColumnStretch(FIELD_COLUMN, 1)
519
+ self.path_caption_label = add_form_caption(path_grid, PATH_ROW, WelcomeText.REPOSITORY_PATH.value + ":", self.path_edit)
520
+ path_grid.addWidget(self.path_edit, PATH_ROW, FIELD_COLUMN)
521
+ path_grid.addWidget(self.browse_button, PATH_ROW, BUTTON_COLUMN)
522
+ path_grid.addWidget(self.clone_button, PATH_ROW, CLONE_BUTTON_COLUMN)
523
+ layout.addLayout(path_grid)
524
+ # Plain text: the line shows paths and Git causes. A long path wraps instead of widening the window, and the
525
+ # line stays hidden while it has nothing to say.
526
+ self.status_label = WrappingLabel()
527
+ self.status_label.setWordWrap(True)
528
+ status_policy = self.status_label.sizePolicy()
529
+ status_policy.setHorizontalPolicy(QSizePolicy.Policy.Ignored)
530
+ self.status_label.setSizePolicy(status_policy)
531
+ # One row space under the path, its text in line with the captions.
532
+ self.status_label.setContentsMargins(FORM_GRID_CAPTION_INDENT, FORM_GRID_ROW_SPACING, 0, 0)
533
+ self.status_label.hide()
534
+ layout.addWidget(self.status_label)
535
+ # The Clone window leaves this space between its rows and the block under them.
536
+ layout.addSpacing(FORM_GRID_ROWS_TO_BLOCK_SPACE)
537
+ self.recent_list = RecentRepositoryList()
538
+ self.recent_list.setAccessibleName(SharedText.RECENT_REPOSITORY.value)
539
+ self.unavailable_dir_set: set[str] = set()
540
+ self.recent_list.itemClicked.connect(self._select_recent)
541
+ self.recent_list.itemActivated.connect(self._open_recent)
542
+ self.recent_list.installEventFilter(self)
543
+ self.recent_dirs: list[str] = []
544
+ # Answers of the background folder checks, kept for the window's life: a new history only asks about new folders.
545
+ self.reachable_by_dir: dict[str, bool] = {}
546
+ # Folders asked about by this window that have no real answer yet and are not shown as Unknown.
547
+ self.pending_dir_set: set[str] = set()
548
+ # Pending folders that have waited longer than the display delay: their rows show "checking".
549
+ self.checking_dir_set: set[str] = set()
550
+ # Folders whose question failed without an answer, or waited too long: their rows show Unknown until an answer comes.
551
+ self.unknown_dir_set: set[str] = set()
552
+ # Row of each recent the list shows right now, rebuilt with the rows, so a mark change finds its row without a walk.
553
+ self.row_by_recent_dir: dict[str, int] = {}
554
+ # One timer turns the arcs of every "checking" row, and only runs while one is shown.
555
+ self.checking_spinner_timer = QTimer(self)
556
+ self.checking_spinner_timer.setInterval(FRAME_INTERVAL_MS)
557
+ self.checking_spinner_timer.timeout.connect(self._advance_checking_spinner)
558
+ # The answers arrive on worker threads; the queued connections run the row update and the log write on the
559
+ # window's own thread, the only one that writes the log file.
560
+ self.availability_checked.connect(self._show_availability, Qt.ConnectionType.QueuedConnection)
561
+ self.availability_unanswered.connect(self._show_unanswered, Qt.ConnectionType.QueuedConnection)
562
+ self.diagnostic_log = diagnostic_log
563
+ log_line = ignore_log_line
564
+ if diagnostic_log is not None:
565
+ self.availability_log_line.connect(self._write_availability_log_line, Qt.ConnectionType.QueuedConnection)
566
+ log_line = weak_signal_emitter(self, lambda form: form.availability_log_line)
567
+ self.availability_check = RecentAvailabilityCheck(weak_signal_emitter(self, lambda form: form.availability_checked), is_reachable, log_line=log_line,
568
+ read_mount_table=read_mount_table,
569
+ report_unanswered=weak_signal_emitter(self, lambda form: form.availability_unanswered))
570
+ self.search_edit = QLineEdit()
571
+ self.search_edit.setAccessibleName(RECENT_SEARCH_ACCESSIBLE_NAME)
572
+ self.search_edit.setClearButtonEnabled(True)
573
+ self.search_edit.setFixedWidth(RECENT_SEARCH_FIELD_WIDTH)
574
+ self.search_edit.textChanged.connect(self._search_changed)
575
+ self.search_edit.returnPressed.connect(self._open_search_result)
576
+ self.search_edit.installEventFilter(self)
577
+ self.search_label = QLabel(WelcomeText.RECENT_SEARCH.value + ":")
578
+ self.search_label.setBuddy(self.search_edit)
579
+ self.recent_caption_label = QLabel(SharedText.RECENT_REPOSITORY.value + ":")
580
+ self.recent_caption_label.setContentsMargins(FORM_GRID_CAPTION_INDENT, 0, 0, 0)
581
+ self.recent_caption_label.setBuddy(self.recent_list)
582
+ # The caption of the list on the left, the search on the right: the field's right edge falls on the list's.
583
+ recent_caption_row = QHBoxLayout()
584
+ recent_caption_row.setContentsMargins(0, 0, 0, 0)
585
+ recent_caption_row.setSpacing(FORM_GRID_COLUMN_SPACING)
586
+ recent_caption_row.addWidget(self.recent_caption_label, 0, Qt.AlignmentFlag.AlignVCenter)
587
+ recent_caption_row.addStretch(1)
588
+ recent_caption_row.addWidget(self.search_label, 0, Qt.AlignmentFlag.AlignVCenter)
589
+ recent_caption_row.addWidget(self.search_edit, 0, Qt.AlignmentFlag.AlignVCenter)
590
+ layout.addLayout(recent_caption_row)
591
+ layout.addSpacing(SPACING_XS)
592
+ layout.addWidget(self.recent_list, 1)
593
+ # Without recents, this text takes the list's place inside the same border, so the window keeps its layout.
594
+ self.empty_label = QLabel(WelcomeText.RECENT_EMPTY.value + "\n" + WelcomeText.RECENT_HELP.value)
595
+ self.empty_label.setTextFormat(Qt.TextFormat.PlainText)
596
+ self.empty_label.setWordWrap(True)
597
+ self.empty_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
598
+ # A framed QLabel indents its text by half an "x" on its own; the padding alone places the text.
599
+ self.empty_label.setIndent(0)
600
+ layout.addWidget(self.empty_label, 1)
601
+ body = QVBoxLayout()
602
+ body.addWidget(self.scroll_area)
603
+ self.setting_button = QPushButton(SharedText.SETTING_BUTTON.value)
604
+ self.setting_button.clicked.connect(self.setting_requested)
605
+ self.open_button = QPushButton(WelcomeText.OPEN.value)
606
+ self.open_button.clicked.connect(self._request_open)
607
+ # Outside a dialog the default role only draws the default outline; Enter opens through the path field and the
608
+ # recent list, connected above. It is given before the band is built and measured: the default outline makes
609
+ # Open taller, and Qt does not measure a band again when one of its buttons becomes the default, so a band
610
+ # measured earlier would keep the shorter height and be laid out too short.
611
+ self.open_button.setDefault(True)
612
+ # Shown in Open's place while a repository is read: the band skips the hidden Open button.
613
+ self.cancel_button = QPushButton(WelcomeText.CANCEL_READING.value)
614
+ self.cancel_button.clicked.connect(self.cancel_requested)
615
+ self.cancel_button.hide()
616
+ for button in (self.setting_button, self.open_button, self.cancel_button):
617
+ button.setMinimumSize(FORM_BAND_BUTTON_MIN_SIZE)
618
+ self.button_band = install_banded_layout(self, body, [self.open_button, self.cancel_button], body_margins=QMargins(), leading_buttons=[self.setting_button], button_sizing=BandButtonSizing.WINDOW)
619
+ # Height of the button band when the form last laid its theme look or fitted its metrics: a theme switch changes
620
+ # it, and a shown form follows by the difference only.
621
+ self.band_height = self.button_band.sizeHint().height()
622
+ self._apply_theme_look()
623
+ # Tab follows the reading order: the form from top to bottom, then the band from left to right.
624
+ tab_order = [self.path_edit, self.browse_button, self.clone_button, self.search_edit, self.recent_list, self.setting_button, self.open_button,
625
+ self.cancel_button]
626
+ for previous_control, next_control in pairwise(tab_order):
627
+ QWidget.setTabOrder(previous_control, next_control)
628
+ self.show_recent(recent_dirs)
629
+ close_action = QAction("Close", self)
630
+ close_action.setShortcut("Ctrl+W")
631
+ close_action.triggered.connect(self.close)
632
+ self.addAction(close_action)
633
+ # Set once the band helper has created the outer layout: without it, Qt makes that layout's minimum size the
634
+ # window's, and the form could not be shrunk below all its rows.
635
+ self.layout().setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
636
+ self.refresh_metrics(self.placement_screen().availableGeometry())
637
+ self.path_edit.setFocus()
638
+ follow_theme(self, self._apply_theme_look)
639
+
640
+ def _apply_theme_look(self) -> None:
641
+ """Lay down everything of the form's own look that depends on the light or dark theme; the only place that does.
642
+
643
+ The body, the band and the band's buttons are laid by the banded layout, which follows the theme on its own.
644
+ This lays the rest: Browse… and the clone button, the two text fields, the recent list and its empty text. It
645
+ runs once at construction and again at each theme switch, after the band. The colors come from the application
646
+ palette, never from a widget's: a style sheet changes its widget's palette. The recent list keeps its focus
647
+ rectangle for keyboard navigation.
648
+
649
+ The band's buttons are not as tall in both themes, so a shown form changes its height by as much as the band
650
+ did since the last look: the recent list keeps its height and does not start scrolling, exactly as in a form
651
+ opened in the new theme.
652
+ """
653
+ source = QApplication.palette()
654
+ # Browse… and the clone button beside it share the Windows look, the field height and one common width.
655
+ apply_side_button_look([self.browse_button, self.clone_button], CLONE_PLAIN_SIDE_BUTTON_TEXTS)
656
+ apply_windows_line_edit_look(self.path_edit)
657
+ apply_windows_line_edit_look(self.search_edit)
658
+ self.recent_list.setStyleSheet(windows_list_style_sheet(source, "QListWidget", hide_focus_outline=False))
659
+ self.empty_label.setStyleSheet(empty_recent_style_sheet(source))
660
+ band_height = self.button_band.sizeHint().height()
661
+ if self.centred:
662
+ self.resize(self.width(), self.height() + band_height - self.band_height)
663
+ self.band_height = band_height
664
+
665
+ def refresh_metrics(self, available: QRect | None = None) -> None:
666
+ """Follow the screen density and the system text size, and keep the opening form within the current screen.
667
+
668
+ Runs again after every font change: the path field, Browse… and the clone button keep the captures' size at the
669
+ default text size and grow with a larger one, so their text is never cut; the caption follows the font on its own,
670
+ and takes the Clone window's caption column width so the path field stays in line with the Clone window's fields.
671
+ """
672
+ refresh_style_sheet_fonts(self)
673
+ fit_caption_to_other_form(self.path_caption_label, CLONE_FORM_CAPTIONS)
674
+ self.browse_button.setIcon(action_icon("folder", self.devicePixelRatioF()))
675
+ fit_side_buttons([self.browse_button, self.clone_button], CLONE_PLAIN_SIDE_BUTTON_TEXTS)
676
+ fit_windows_field_height(self.path_edit)
677
+ fit_windows_field_height(self.search_edit)
678
+ fit_window(self, WELCOME_MIN_WIDTH, WELCOME_MIN_HEIGHT, available)
679
+ self.band_height = self.button_band.sizeHint().height()
680
+
681
+ def show_recent(self, recent_dirs: list[str]) -> None:
682
+ """Keep the full recent history and show it through the current search at once, then learn in the background which folders are gone.
683
+
684
+ Only the folders this window never asked about are checked, on background threads: a disk spinning up from
685
+ sleep or a disconnected network drive never freezes the window, and a new history does not ask again about
686
+ the folders already answered. A folder already known unavailable is not asked about. Rows are shown without
687
+ a mark until their answer comes back; a folder still without an answer after a short delay shows "checking",
688
+ so a local folder answering at once never makes the list jump, and Unknown after the long wait. Its answer
689
+ still replaces either mark whenever it comes. The editable path text is left alone.
690
+ """
691
+ self.recent_dirs = list(recent_dirs)
692
+ checked_recent_dirs: list[str] = []
693
+ new_pending_dir_set: set[str] = set()
694
+ for recent_dir in recent_dirs:
695
+ if recent_dir in self.unavailable_dir_set:
696
+ continue
697
+ checked_recent_dirs.append(recent_dir)
698
+ if recent_dir not in self.reachable_by_dir and recent_dir not in self.pending_dir_set and recent_dir not in self.unknown_dir_set:
699
+ new_pending_dir_set.add(recent_dir)
700
+ self.pending_dir_set.update(new_pending_dir_set)
701
+ self.availability_check.request(checked_recent_dirs)
702
+ if new_pending_dir_set:
703
+ # The folders asked about together wait from the same instant: one single-shot timer per batch, with the
704
+ # window as its context so it is dropped along with a deleted window.
705
+ batch = frozenset(new_pending_dir_set)
706
+ QTimer.singleShot(RECENT_CHECKING_DISPLAY_DELAY_MS, self, partial(self._show_checking, batch))
707
+ QTimer.singleShot(RECENT_CHECK_UNKNOWN_DELAY_MS, self, partial(self._show_unknown_after_wait, batch))
708
+ self._apply_recent_filter(keep_current=True)
709
+
710
+ def recent_mark(self, recent_dir: str) -> RecentMark:
711
+ """Return the mark the row of a recent carries now, by the one rule the window follows."""
712
+ return self._mark_with_answer(recent_dir, self.reachable_by_dir.get(recent_dir))
713
+
714
+ def _mark_with_answer(self, recent_dir: str, reachable: bool | None) -> RecentMark:
715
+ """Return the mark of a recent whose folder check answered ``reachable``, None for no answer yet.
716
+
717
+ In this order: a failed opening or a folder found missing is Unavailable; a folder found present has no mark;
718
+ then a folder without answer is Unknown once the check gave up, "checking" once it waited past the display
719
+ delay, and unmarked before that. A real answer therefore always wins over Unknown, and only a real "absent" or
720
+ a failed opening ever gives Unavailable.
721
+ """
722
+ if recent_dir in self.unavailable_dir_set or reachable is False:
723
+ return RecentMark.UNAVAILABLE
724
+ if reachable:
725
+ return RecentMark.NONE
726
+ if recent_dir in self.unknown_dir_set:
727
+ return RecentMark.UNKNOWN
728
+ if recent_dir in self.checking_dir_set:
729
+ return RecentMark.CHECKING
730
+ return RecentMark.NONE
731
+
732
+ def is_marked_unavailable(self, recent_dir: str) -> bool:
733
+ """Tell whether the row of a recent carries the Unavailable mark: it failed to open, or its folder was found missing.
734
+
735
+ A folder whose answer has not come back yet is never marked Unavailable: a slow answer is never taken for a missing folder.
736
+ """
737
+ return self.recent_mark(recent_dir) is RecentMark.UNAVAILABLE
738
+
739
+ def _show_checking(self, batch: frozenset[str]) -> None:
740
+ """Show "checking" on the folders of a batch that still have no answer once the display delay has passed.
741
+
742
+ Nothing changes once the check is closed: its folders will never answer this window.
743
+ """
744
+ if self.availability_check.closed:
745
+ return
746
+ late_dir_set: set[str] = set()
747
+ for recent_dir in batch:
748
+ if recent_dir in self.pending_dir_set and recent_dir not in self.checking_dir_set:
749
+ late_dir_set.add(recent_dir)
750
+ if not late_dir_set:
751
+ return
752
+ fitted_before = self.fitted_height() if self.centred else 0
753
+ self.checking_dir_set.update(late_dir_set)
754
+ self._refresh_recent_rows(late_dir_set)
755
+ self._follow_fitted_height(fitted_before)
756
+
757
+ def _show_unknown_after_wait(self, batch: frozenset[str]) -> None:
758
+ """Stop waiting for the folders of a batch still without answer after the long wait: show them as Unknown and say so in --log.
759
+
760
+ A question blocked on a dead network mount cannot be cancelled, so it goes on: its real answer, present or
761
+ absent, still replaces Unknown whenever it comes. Nothing changes once the check is closed.
762
+ """
763
+ if self.availability_check.closed:
764
+ return
765
+ waited_dir_set: set[str] = set()
766
+ for recent_dir in batch:
767
+ if recent_dir in self.pending_dir_set:
768
+ waited_dir_set.add(recent_dir)
769
+ if not waited_dir_set:
770
+ return
771
+ fitted_before = self.fitted_height() if self.centred else 0
772
+ self.pending_dir_set.difference_update(waited_dir_set)
773
+ self.checking_dir_set.difference_update(waited_dir_set)
774
+ self.unknown_dir_set.update(waited_dir_set)
775
+ self._refresh_recent_rows(waited_dir_set)
776
+ self._follow_fitted_height(fitted_before)
777
+ waited_seconds = RECENT_CHECK_UNKNOWN_DELAY_MS / MILLISECONDS_PER_SECOND
778
+ for recent_dir in sorted(waited_dir_set):
779
+ self._write_availability_log_line(unanswered_after_wait_log_line(recent_dir, waited_seconds))
780
+
781
+ def _show_unanswered(self, recent_dir: str) -> None:
782
+ """Show a recent as Unknown when its folder question failed without saying whether the folder exists.
783
+
784
+ Access refused, a network timeout or a drive that is not ready say nothing about the folder: it is not marked
785
+ Unavailable and stays openable. A real answer already known, or a failed opening, keeps its mark.
786
+ """
787
+ if recent_dir in self.reachable_by_dir or recent_dir in self.unavailable_dir_set or recent_dir in self.unknown_dir_set:
788
+ return
789
+ fitted_before = self.fitted_height() if self.centred else 0
790
+ self.pending_dir_set.discard(recent_dir)
791
+ self.checking_dir_set.discard(recent_dir)
792
+ self.unknown_dir_set.add(recent_dir)
793
+ self._refresh_recent_rows({recent_dir})
794
+ self._follow_fitted_height(fitted_before)
795
+
796
+ def _advance_checking_spinner(self) -> None:
797
+ """Turn the arcs one step and repaint the listed "checking" rows alone, not the whole list."""
798
+ delegate = self.recent_list.delegate
799
+ delegate.busy_angle = next_busy_angle(delegate.busy_angle)
800
+ model = self.recent_list.model()
801
+ for recent_dir in self.checking_dir_set:
802
+ row = self.row_by_recent_dir.get(recent_dir)
803
+ if row is not None:
804
+ self.recent_list.update(model.index(row, RECENT_COLUMN))
805
+
806
+ def _follow_checking_spinner(self) -> None:
807
+ """Run the spinner timer only while a row shows "checking" and the form is visible, so it costs nothing otherwise."""
808
+ if self.checking_dir_set and self.isVisible():
809
+ if not self.checking_spinner_timer.isActive():
810
+ self.checking_spinner_timer.start()
811
+ return
812
+ self.checking_spinner_timer.stop()
813
+
814
+ def _show_availability(self, recent_dir: str, reachable: bool) -> None:
815
+ """Remember whether a recent folder can be reached and update its row in place, only when its mark changes.
816
+
817
+ A real answer ends the wait, even one that comes after the folder was shown as Unknown: a late answer always
818
+ wins. The row is changed where it stands, so the search, the chosen row and the scroll position stay as the
819
+ user left them; a recent the search hides right now gets its mark from the remembered answer once it is listed
820
+ again. The answers always come back after the first fit of the form's height, even for a local folder answered
821
+ at once, since they cross threads through the event loop: a shown form changes its height by as much as the row
822
+ did, so the list does not start scrolling while every recent still fits. That holds for a row the search hides
823
+ too: the fitted height counts every recent of the history, so the room is there once the search is emptied.
824
+ """
825
+ mark_changes = self._mark_with_answer(recent_dir, reachable) is not self.recent_mark(recent_dir)
826
+ fitted_before = self.fitted_height() if mark_changes and self.centred else 0
827
+ self.pending_dir_set.discard(recent_dir)
828
+ self.checking_dir_set.discard(recent_dir)
829
+ self.unknown_dir_set.discard(recent_dir)
830
+ self.reachable_by_dir[recent_dir] = reachable
831
+ if mark_changes:
832
+ self._refresh_recent_rows({recent_dir})
833
+ self._follow_fitted_height(fitted_before)
834
+
835
+ def _write_availability_log_line(self, line: str) -> None:
836
+ """Write why a folder check got no answer into the --log file, filtered like every other record of that file."""
837
+ if self.diagnostic_log is None:
838
+ return
839
+ self.diagnostic_log.write_line(SensitiveTextFilter().redact(line))
840
+
841
+ def _refresh_recent_rows(self, changed_dir_set: set[str] | frozenset[str]) -> None:
842
+ """Rewrite the listed rows of recents whose mark changed, in one pass, and let the list measure its rows again.
843
+
844
+ A whole batch goes through one pass, and its caller through one height measure: a hundred recents of a dead
845
+ mount changing their mark at once must not walk the list and measure the form a hundred times. A recent the
846
+ search hides has no row to rewrite; the fitted height counts it with its new mark all the same. Every change of
847
+ the waiting folders comes through here, so the spinner timer follows them here too.
848
+ """
849
+ for recent_dir in changed_dir_set:
850
+ row = self.row_by_recent_dir.get(recent_dir)
851
+ if row is None:
852
+ continue
853
+ mark = self.recent_mark(recent_dir)
854
+ item = self.recent_list.item(row)
855
+ item.setText(recent_item_text(recent_dir, mark))
856
+ item.setData(RECENT_MARK_ROLE, mark.value)
857
+ # A mark line makes a row taller: the list lays its rows out again by itself after the data change, and the form
858
+ # learns the list's new height.
859
+ self.recent_list.updateGeometry()
860
+ self._follow_checking_spinner()
861
+
862
+ def _search_changed(self, _text: str) -> None:
863
+ """List what the new search keeps and let go of the row chosen before it, so Enter in Search opens the best match.
864
+
865
+ A row chosen before the search changed, by a click or by Qt picking the first row when the list took the focus,
866
+ must not win over the match the user now sees at the top; a row is chosen again only in the list itself.
867
+ """
868
+ self._apply_recent_filter(keep_current=False)
869
+
870
+ def _apply_recent_filter(self, *, keep_current: bool) -> None:
871
+ """Show the recents the search keeps, best first, from what show_recent remembered, without looking at the disk.
872
+
873
+ With ``keep_current``, the row chosen in the list stays chosen while it is still listed: a new history or an
874
+ Unavailable mark does not undo the user's choice. When no row is left, a grey text takes the list's place in the
875
+ same border: the usual help without any recent, or a no-match line when the search hides them all. The window is
876
+ never resized here: the list scrolls or leaves room, as for any other number of recents.
877
+ """
878
+ listed_dirs = ranked_recent_dirs(self.search_edit.text(), self.recent_dirs)
879
+ current_item = self.recent_list.currentItem() if keep_current else None
880
+ current_dir = None if current_item is None else current_item.data(Qt.ItemDataRole.UserRole)
881
+ self.recent_list.clear()
882
+ self.row_by_recent_dir = {}
883
+ for recent_dir in listed_dirs:
884
+ mark = self.recent_mark(recent_dir)
885
+ item = QListWidgetItem(recent_item_text(recent_dir, mark))
886
+ item.setData(Qt.ItemDataRole.UserRole, recent_dir)
887
+ item.setData(RECENT_MARK_ROLE, mark.value)
888
+ self.row_by_recent_dir[recent_dir] = self.recent_list.count()
889
+ self.recent_list.addItem(item)
890
+ if recent_dir == current_dir:
891
+ # Only the current row moves: itemClicked and itemActivated come from the user alone, so nothing opens.
892
+ self.recent_list.setCurrentItem(item)
893
+ if self.recent_dirs:
894
+ self.empty_label.setText(WelcomeText.RECENT_NO_MATCH.value)
895
+ else:
896
+ self.empty_label.setText(WelcomeText.RECENT_EMPTY.value + "\n" + WelcomeText.RECENT_HELP.value)
897
+ self.empty_label.setVisible(not listed_dirs)
898
+ self.recent_list.setVisible(bool(listed_dirs))
899
+ self.recent_list.updateGeometry()
900
+
901
+ def placement_screen(self) -> QScreen:
902
+ """Return the screen the form opens on: the one of the repository window it was opened from, else its own.
903
+
904
+ Before its first show the form's own screen is the primary one, which may be taller than the screen it is centred on.
905
+ """
906
+ if is_visible_window(self.placement_anchor):
907
+ return self.placement_anchor.screen()
908
+ return self.screen()
909
+
910
+ def recent_height_cap(self) -> int:
911
+ """Return the tallest the form grows to show its recent repositories: a share of the usable height of the screen it opens on."""
912
+ return round(WELCOME_MAX_SCREEN_RATIO * self.placement_screen().availableGeometry().height())
913
+
914
+ def fitted_height(self) -> int:
915
+ """Return the height that shows every recent repository without scrolling, within the minimum height and the height cap.
916
+
917
+ Measured at the current width, since the recent paths and the status line wrap. The button band sits outside the
918
+ scrolling form, so its height is added to the form's. Every recent of the history counts, with its known mark,
919
+ including those the search hides right now.
920
+ """
921
+ content_layout = self.scroll_area.widget().layout()
922
+ scroll_frame_height = self.scroll_area.frameWidth() * 2
923
+ content_width = self.scroll_area.width() - scroll_frame_height
924
+ if content_layout.hasHeightForWidth():
925
+ content_height = content_layout.totalHeightForWidth(content_width)
926
+ else:
927
+ content_height = content_layout.totalSizeHint().height()
928
+ content_height += self._unlisted_recent_height(content_layout, content_width)
929
+ outer_margins = self.layout().contentsMargins()
930
+ wanted_height = content_height + scroll_frame_height + outer_margins.top() + outer_margins.bottom() + self.button_band.sizeHint().height()
931
+ return min(max(wanted_height, WELCOME_MIN_HEIGHT), self.recent_height_cap())
932
+
933
+ def _unlisted_recent_height(self, content_layout: QLayout, content_width: int) -> int:
934
+ """Return the height the form's laid-out content lacks to hold every recent of the history, the ones the search hides included.
935
+
936
+ The window keeps its height while the user types in Search, so the room for the whole history has to be known
937
+ all along: a mark that arrives on a hidden row then grows the window at once, and every row fits without
938
+ scrolling once the search is emptied. Without a search every recent is listed and nothing is missing.
939
+ """
940
+ if self.recent_list.count() == len(self.recent_dirs):
941
+ return 0
942
+ history_height = self.recent_list.frameWidth() * 2
943
+ row_width = self.recent_list.viewport().width()
944
+ for recent_dir in self.recent_dirs:
945
+ history_height += self.recent_list.recent_height_at_width(recent_dir, self.recent_mark(recent_dir), row_width)
946
+ if not self.recent_list.isHidden():
947
+ return history_height - self.recent_list.sizeHint().height()
948
+ # The search hides every recent: the no-match text holds the list's place in the layout, at the layout's inner width.
949
+ label_item = content_layout.itemAt(content_layout.indexOf(self.empty_label))
950
+ layout_margins = content_layout.contentsMargins()
951
+ label_width = content_width - layout_margins.left() - layout_margins.right()
952
+ if label_item.hasHeightForWidth():
953
+ return history_height - label_item.heightForWidth(label_width)
954
+ return history_height - label_item.sizeHint().height()
955
+
956
+ def fit_recent_height(self) -> None:
957
+ """Make the form tall enough to show every recent repository without scrolling, up to the height cap.
958
+
959
+ Runs at the first show, once the children have their real widths: the recent paths wrap, so their height depends
960
+ on the width. The window never goes below its minimum height; past the cap, the layout shrinks only the recent
961
+ list, which then scrolls while every other control stays visible.
962
+ """
963
+ self.resize(self.width(), self.fitted_height())
964
+
965
+ def mark_unavailable(self, repository_dir: str) -> None:
966
+ """Keep a recent that failed to open visible with its Unavailable marker, without reading the disk again for the others.
967
+
968
+ The mark goes into the remembered history, not into the shown rows, so a recent the search hides right now is
969
+ still marked once the search is emptied; a history received later keeps it marked too, whatever the background
970
+ check of its folder answers, so the folder stops waiting for one. The form keeps its height: the list shows the
971
+ taller row or scrolls.
972
+ """
973
+ self.unavailable_dir_set.add(repository_dir)
974
+ self.pending_dir_set.discard(repository_dir)
975
+ self.checking_dir_set.discard(repository_dir)
976
+ self.unknown_dir_set.discard(repository_dir)
977
+ self._refresh_recent_rows({repository_dir})
978
+
979
+ def set_loading(self, loading: bool) -> None:
980
+ """Keep the window responsive while preventing a duplicate opening request.
981
+
982
+ While a repository is read, Cancel reading takes Open's place in the band and the status line says so. Open is
983
+ hidden and disabled: the path field and the recent list refuse to open while it is disabled. The clone button of
984
+ the path row is disabled too, since a clone cannot start while a repository is read. The keyboard focus follows
985
+ the swap, so a user who pressed Open lands on Cancel reading, and back on Open once the reading ends.
986
+ """
987
+ focus_widget = self.focusWidget()
988
+ self.cancel_button.setVisible(loading)
989
+ self.open_button.setVisible(not loading)
990
+ self.open_button.setEnabled(not loading)
991
+ self.clone_button.setEnabled(not loading)
992
+ self.show_status(WelcomeText.OPENING.value if loading else "")
993
+ if loading and focus_widget is self.open_button:
994
+ self.cancel_button.setFocus()
995
+ return
996
+ if not loading and focus_widget is self.cancel_button:
997
+ self.open_button.setFocus()
998
+
999
+ def show_status(self, text: str) -> None:
1000
+ """Write the line under the repository path, and show it only while it has something to say.
1001
+
1002
+ An empty text hides the line, so no blank space sits between the path and the recent repositories. A shown form
1003
+ changes its height by as much as the line changes its fitted height, so the recent list keeps its room and does
1004
+ not start scrolling while every recent still fits; a height the user set by hand keeps its difference.
1005
+ """
1006
+ fitted_before = self.fitted_height() if self.centred else 0
1007
+ self.status_label.setText(text)
1008
+ self.status_label.setVisible(bool(text))
1009
+ self._follow_fitted_height(fitted_before)
1010
+
1011
+ def _follow_fitted_height(self, fitted_before: int) -> None:
1012
+ """Change a shown form's height by as much as its fitted height changed since ``fitted_before``.
1013
+
1014
+ The one rule for content that grows or shrinks after the first fit (the status line, the mark of a folder check):
1015
+ the recent list keeps its room, a height the user set by hand keeps its difference, and a form at its height cap
1016
+ does not move, its list scrolling instead. Before the first show there is nothing to follow: the first fit
1017
+ measures everything.
1018
+ """
1019
+ if not self.centred:
1020
+ return
1021
+ self.scroll_area.widget().layout().invalidate()
1022
+ difference = self.fitted_height() - fitted_before
1023
+ if difference:
1024
+ self.resize(self.width(), self.height() + difference)
1025
+
1026
+ def showEvent(self, event: QShowEvent) -> None:
1027
+ """Size and centre the form on its first show: on the repository window it was opened from, else on the usable screen.
1028
+
1029
+ The form is itself a placement anchor, so the application-wide placement of new windows skips it and it has to
1030
+ place itself. Later shows keep the form's size and place, including a place the user dragged it to. The arcs of
1031
+ the rows still "checking" turn again.
1032
+ """
1033
+ super().showEvent(event)
1034
+ self._follow_checking_spinner()
1035
+ if self.centred:
1036
+ return
1037
+ self.centred = True
1038
+ self.fit_recent_height()
1039
+ if centre_on_placement_anchor(self, self.placement_anchor):
1040
+ return
1041
+ screen = self.placement_screen()
1042
+ center_window_at(self, screen.availableGeometry().center(), screen)
1043
+
1044
+ def closeEvent(self, event: QCloseEvent) -> None:
1045
+ """Stop listening to the folder checks still running, then notify the owner before discarding a pending opening.
1046
+
1047
+ A question to a sleeping disk cannot be cancelled; closing the check makes sure its late answer is dropped
1048
+ instead of reaching a window Qt may already have deleted; the pending "checking" and Unknown timers then change
1049
+ nothing. The arcs of the "checking" rows stop turning.
1050
+ """
1051
+ self.availability_check.close()
1052
+ self.checking_spinner_timer.stop()
1053
+ self.closed.emit()
1054
+ super().closeEvent(event)
1055
+
1056
+ def hideEvent(self, event: QHideEvent) -> None:
1057
+ """Stop turning the arcs of the "checking" rows while the form is hidden: nobody sees them."""
1058
+ super().hideEvent(event)
1059
+ self._follow_checking_spinner()
1060
+
1061
+ def _browse(self) -> None:
1062
+ """Start the native chooser at the typed path, read as Open reads it, and keep the current text when the chooser is cancelled.
1063
+
1064
+ GitPane does not look at that folder itself: the chooser starts there, or where it can when the folder is missing.
1065
+ """
1066
+ repository_dir = QFileDialog.getExistingDirectory(self, WelcomeText.OPEN_REPOSITORY.value, typed_path_value(self.path_edit.text(), self.typed_path))
1067
+ if repository_dir:
1068
+ self.path_edit.setText(repository_dir)
1069
+ self.path_edit.setFocus()
1070
+
1071
+ def _request_open(self) -> None:
1072
+ """Validate the empty form locally before requesting asynchronous opening.
1073
+
1074
+ The path is used without the blanks around it, so a path pasted with a space opens its repository, and with
1075
+ ``~`` expanded to the home folder where that is the usage; the field keeps the text as typed.
1076
+ """
1077
+ if not self.open_button.isEnabled():
1078
+ return
1079
+ if not typed_value(self.path_edit.text()):
1080
+ self.show_status(WelcomeText.EMPTY_PATH.value)
1081
+ self.path_edit.setFocus()
1082
+ return
1083
+ self.open_requested.emit(typed_path_value(self.path_edit.text(), self.typed_path))
1084
+
1085
+ def invalid_path_message(self, repository_dir: str) -> QMessageBox:
1086
+ """Build, without opening it, the message saying that no folder exists at ``repository_dir``.
1087
+
1088
+ The path is plain text, since a path may hold a ``<`` or a ``&`` that rich text would read as markup, and it can
1089
+ be selected, so the user can copy it. The caller keeps and opens the message.
1090
+ """
1091
+ box = QMessageBox(QMessageBox.Icon.Warning, WelcomeText.INVALID_PATH_TITLE.value, WelcomeText.INVALID_PATH.value.format(path=repository_dir), QMessageBox.StandardButton.Ok, self)
1092
+ box.setTextFormat(Qt.TextFormat.PlainText)
1093
+ box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
1094
+ prepare_message_box(box, WelcomeText.INVALID_PATH_TITLE.value)
1095
+ return box
1096
+
1097
+ def eventFilter(self, watched: QWidget, event: QEvent) -> bool:
1098
+ """Open the current recent on Enter in the recent list, and give the Search field the two keys it leaves unused.
1099
+
1100
+ Qt for macOS does not activate a list row on Enter, so the list's Enter goes through the shared helper that does
1101
+ it on every system. In Search, Down moves to the recent list and Escape empties the search.
1102
+ """
1103
+ if watched is self.recent_list and activate_current_row(self.recent_list, event):
1104
+ return True
1105
+ if watched is not self.search_edit or event.type() != QEvent.Type.KeyPress:
1106
+ return super().eventFilter(watched, event)
1107
+ if event.key() == Qt.Key.Key_Down and self.recent_list.count():
1108
+ self.recent_list.setFocus()
1109
+ if self.recent_list.currentRow() < 0:
1110
+ self.recent_list.setCurrentRow(FIRST_ROW)
1111
+ return True
1112
+ if event.key() == Qt.Key.Key_Escape and self.search_edit.text():
1113
+ self.search_edit.clear()
1114
+ return True
1115
+ return super().eventFilter(watched, event)
1116
+
1117
+ def _open_search_result(self) -> None:
1118
+ """Open the recent chosen in the list since the search last changed on Enter in Search, else the best match, as a launcher does.
1119
+
1120
+ Every change of the search lets go of the chosen row, so a current row here was picked after the last key typed.
1121
+ """
1122
+ item = self.recent_list.currentItem()
1123
+ if item is None:
1124
+ item = self.recent_list.item(FIRST_ROW)
1125
+ if item is None:
1126
+ return
1127
+ self._open_recent(item)
1128
+
1129
+ def _open_recent(self, item: QListWidgetItem) -> None:
1130
+ """Activate the exact saved directory without substituting another location."""
1131
+ if not self.open_button.isEnabled():
1132
+ return
1133
+ self._select_recent(item)
1134
+ self._request_open()
1135
+
1136
+ def _select_recent(self, item: QListWidgetItem) -> None:
1137
+ self.path_edit.setText(item.data(Qt.ItemDataRole.UserRole))