je-editor 0.0.104__py3-none-any.whl → 0.0.228__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 (151) hide show
  1. je_editor/__init__.py +26 -21
  2. je_editor/__main__.py +1 -1
  3. je_editor/code_scan/ruff_thread.py +58 -0
  4. je_editor/code_scan/watchdog_implement.py +56 -0
  5. je_editor/code_scan/watchdog_thread.py +78 -0
  6. je_editor/git_client/commit_graph.py +77 -0
  7. je_editor/git_client/git_action.py +175 -0
  8. je_editor/git_client/git_cli.py +66 -0
  9. je_editor/pyside_ui/browser/browser_download_window.py +75 -0
  10. je_editor/pyside_ui/browser/browser_serach_lineedit.py +51 -0
  11. je_editor/pyside_ui/browser/browser_view.py +87 -0
  12. je_editor/pyside_ui/browser/browser_widget.py +103 -0
  13. je_editor/pyside_ui/browser/main_browser_widget.py +85 -0
  14. je_editor/pyside_ui/code/auto_save/auto_save_manager.py +60 -0
  15. je_editor/pyside_ui/code/auto_save/auto_save_thread.py +59 -0
  16. je_editor/pyside_ui/code/code_format/pep8_format.py +130 -0
  17. je_editor/pyside_ui/code/code_process/code_exec.py +267 -0
  18. je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +412 -0
  19. je_editor/pyside_ui/code/running_process_manager.py +48 -0
  20. je_editor/pyside_ui/code/shell_process/shell_exec.py +236 -0
  21. je_editor/pyside_ui/code/syntax/python_syntax.py +99 -0
  22. je_editor/pyside_ui/code/syntax/syntax_setting.py +95 -0
  23. je_editor/pyside_ui/code/textedit_code_result/code_record.py +75 -0
  24. je_editor/pyside_ui/code/variable_inspector/inspector_gui.py +172 -0
  25. je_editor/pyside_ui/dialog/ai_dialog/set_ai_dialog.py +71 -0
  26. je_editor/pyside_ui/dialog/file_dialog/create_file_dialog.py +68 -0
  27. je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py +111 -0
  28. je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py +67 -0
  29. je_editor/pyside_ui/dialog/search_ui/search_error_box.py +49 -0
  30. je_editor/pyside_ui/dialog/search_ui/search_text_box.py +49 -0
  31. je_editor/pyside_ui/git_ui/code_diff_compare/code_diff_viewer_widget.py +90 -0
  32. je_editor/pyside_ui/git_ui/code_diff_compare/line_number_code_viewer.py +141 -0
  33. je_editor/pyside_ui/git_ui/code_diff_compare/multi_file_diff_viewer.py +88 -0
  34. je_editor/pyside_ui/git_ui/code_diff_compare/side_by_side_diff_widget.py +284 -0
  35. je_editor/pyside_ui/git_ui/git_client/commit_table.py +65 -0
  36. je_editor/pyside_ui/git_ui/git_client/git_branch_tree_widget.py +156 -0
  37. je_editor/pyside_ui/git_ui/git_client/git_client_gui.py +799 -0
  38. je_editor/pyside_ui/git_ui/git_client/graph_view.py +218 -0
  39. je_editor/pyside_ui/main_ui/ai_widget/ai_config.py +34 -0
  40. je_editor/pyside_ui/main_ui/ai_widget/ask_thread.py +36 -0
  41. je_editor/pyside_ui/main_ui/ai_widget/chat_ui.py +147 -0
  42. je_editor/pyside_ui/main_ui/ai_widget/langchain_interface.py +84 -0
  43. je_editor/pyside_ui/main_ui/console_widget/console_gui.py +162 -0
  44. je_editor/pyside_ui/main_ui/console_widget/qprocess_adapter.py +84 -0
  45. je_editor/pyside_ui/main_ui/dock/__init__.py +0 -0
  46. je_editor/pyside_ui/main_ui/dock/destroy_dock.py +50 -0
  47. je_editor/pyside_ui/main_ui/editor/__init__.py +0 -0
  48. je_editor/pyside_ui/main_ui/editor/editor_widget.py +301 -0
  49. je_editor/pyside_ui/main_ui/editor/editor_widget_dock.py +70 -0
  50. je_editor/pyside_ui/main_ui/editor/process_input.py +101 -0
  51. je_editor/pyside_ui/main_ui/ipython_widget/__init__.py +0 -0
  52. je_editor/pyside_ui/main_ui/ipython_widget/rich_jupyter.py +78 -0
  53. je_editor/pyside_ui/main_ui/main_editor.py +369 -0
  54. je_editor/pyside_ui/main_ui/menu/__init__.py +0 -0
  55. je_editor/pyside_ui/main_ui/menu/check_style_menu/__init__.py +0 -0
  56. je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py +104 -0
  57. je_editor/pyside_ui/main_ui/menu/dock_menu/__init__.py +0 -0
  58. je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py +208 -0
  59. je_editor/pyside_ui/main_ui/menu/file_menu/__init__.py +0 -0
  60. je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py +186 -0
  61. je_editor/pyside_ui/main_ui/menu/help_menu/__init__.py +0 -0
  62. je_editor/pyside_ui/main_ui/menu/help_menu/build_help_menu.py +100 -0
  63. je_editor/pyside_ui/main_ui/menu/language_menu/__init__.py +0 -0
  64. je_editor/pyside_ui/main_ui/menu/language_menu/build_language_server.py +89 -0
  65. je_editor/pyside_ui/main_ui/menu/python_env_menu/__init__.py +0 -0
  66. je_editor/pyside_ui/main_ui/menu/python_env_menu/build_venv_menu.py +238 -0
  67. je_editor/pyside_ui/main_ui/menu/run_menu/__init__.py +0 -0
  68. je_editor/pyside_ui/main_ui/menu/run_menu/build_run_menu.py +160 -0
  69. je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/__init__.py +0 -0
  70. je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py +109 -0
  71. je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_program_menu.py +101 -0
  72. je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_shell_menu.py +98 -0
  73. je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/utils.py +41 -0
  74. je_editor/pyside_ui/main_ui/menu/set_menu_bar.py +63 -0
  75. je_editor/pyside_ui/main_ui/menu/style_menu/__init__.py +0 -0
  76. je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py +73 -0
  77. je_editor/pyside_ui/main_ui/menu/tab_menu/__init__.py +0 -0
  78. je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +275 -0
  79. je_editor/pyside_ui/main_ui/menu/text_menu/__init__.py +0 -0
  80. je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py +135 -0
  81. je_editor/pyside_ui/main_ui/save_settings/__init__.py +0 -0
  82. je_editor/pyside_ui/main_ui/save_settings/setting_utils.py +33 -0
  83. je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +103 -0
  84. je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py +58 -0
  85. je_editor/pyside_ui/main_ui/system_tray/__init__.py +0 -0
  86. je_editor/pyside_ui/main_ui/system_tray/extend_system_tray.py +90 -0
  87. je_editor/start_editor.py +32 -8
  88. je_editor/utils/encodings/python_encodings.py +100 -97
  89. je_editor/utils/exception/exception_tags.py +11 -11
  90. je_editor/utils/file/open/open_file.py +38 -22
  91. je_editor/utils/file/save/save_file.py +40 -16
  92. je_editor/utils/json/json_file.py +36 -15
  93. je_editor/utils/json_format/json_process.py +38 -2
  94. je_editor/utils/logging/__init__.py +0 -0
  95. je_editor/utils/logging/loggin_instance.py +57 -0
  96. je_editor/utils/multi_language/__init__.py +0 -0
  97. je_editor/utils/multi_language/english.py +221 -0
  98. je_editor/utils/multi_language/multi_language_wrapper.py +54 -0
  99. je_editor/utils/multi_language/traditional_chinese.py +214 -0
  100. je_editor/utils/redirect_manager/redirect_manager_class.py +67 -25
  101. je_editor/utils/venv_check/__init__.py +0 -0
  102. je_editor/utils/venv_check/check_venv.py +51 -0
  103. je_editor-0.0.228.dist-info/METADATA +99 -0
  104. je_editor-0.0.228.dist-info/RECORD +140 -0
  105. {je_editor-0.0.104.dist-info → je_editor-0.0.228.dist-info}/WHEEL +1 -1
  106. {je_editor-0.0.104.dist-info → je_editor-0.0.228.dist-info/licenses}/LICENSE +1 -1
  107. je_editor/pyside_ui/auto_save/auto_save_thread.py +0 -34
  108. je_editor/pyside_ui/code_editor/code_edit_plaintext.py +0 -143
  109. je_editor/pyside_ui/code_process/code_exec.py +0 -190
  110. je_editor/pyside_ui/code_result/code_record.py +0 -39
  111. je_editor/pyside_ui/colors/global_color.py +0 -4
  112. je_editor/pyside_ui/file_dialog/open_file_dialog.py +0 -27
  113. je_editor/pyside_ui/file_dialog/save_file_dialog.py +0 -24
  114. je_editor/pyside_ui/main_ui/editor_main_ui/main_editor.py +0 -183
  115. je_editor/pyside_ui/main_ui_setting/ui_setting.py +0 -36
  116. je_editor/pyside_ui/menu/menu_bar/check_style_menu/build_check_style_menu.py +0 -44
  117. je_editor/pyside_ui/menu/menu_bar/file_menu/build_file_menu.py +0 -30
  118. je_editor/pyside_ui/menu/menu_bar/help_menu/build_help_menu.py +0 -39
  119. je_editor/pyside_ui/menu/menu_bar/run_menu/build_run_menu.py +0 -102
  120. je_editor/pyside_ui/menu/menu_bar/set_menu_bar.py +0 -24
  121. je_editor/pyside_ui/menu/menu_bar/venv_menu/build_venv_menu.py +0 -74
  122. je_editor/pyside_ui/search_ui/search_error_box.py +0 -20
  123. je_editor/pyside_ui/search_ui/search_text_box.py +0 -20
  124. je_editor/pyside_ui/shell_process/shell_exec.py +0 -157
  125. je_editor/pyside_ui/syntax/python_syntax.py +0 -99
  126. je_editor/pyside_ui/treeview/project_treeview/set_project_treeview.py +0 -47
  127. je_editor/pyside_ui/user_setting/user_setting_file.py +0 -23
  128. je_editor-0.0.104.dist-info/METADATA +0 -84
  129. je_editor-0.0.104.dist-info/RECORD +0 -69
  130. /je_editor/{pyside_ui/auto_save → code_scan}/__init__.py +0 -0
  131. /je_editor/{pyside_ui/code_editor → git_client}/__init__.py +0 -0
  132. /je_editor/pyside_ui/{code_process → browser}/__init__.py +0 -0
  133. /je_editor/pyside_ui/{code_result → code}/__init__.py +0 -0
  134. /je_editor/pyside_ui/{colors → code/auto_save}/__init__.py +0 -0
  135. /je_editor/pyside_ui/{file_dialog → code/code_format}/__init__.py +0 -0
  136. /je_editor/pyside_ui/{main_ui/editor_main_ui → code/code_process}/__init__.py +0 -0
  137. /je_editor/pyside_ui/{main_ui_setting → code/plaintext_code_edit}/__init__.py +0 -0
  138. /je_editor/pyside_ui/{menu → code/shell_process}/__init__.py +0 -0
  139. /je_editor/pyside_ui/{menu/menu_bar → code/syntax}/__init__.py +0 -0
  140. /je_editor/pyside_ui/{menu/menu_bar/check_style_menu → code/textedit_code_result}/__init__.py +0 -0
  141. /je_editor/pyside_ui/{menu/menu_bar/file_menu → code/variable_inspector}/__init__.py +0 -0
  142. /je_editor/pyside_ui/{menu/menu_bar/help_menu → dialog}/__init__.py +0 -0
  143. /je_editor/pyside_ui/{menu/menu_bar/run_menu → dialog/ai_dialog}/__init__.py +0 -0
  144. /je_editor/pyside_ui/{menu/menu_bar/venv_menu → dialog/file_dialog}/__init__.py +0 -0
  145. /je_editor/pyside_ui/{search_ui → dialog/search_ui}/__init__.py +0 -0
  146. /je_editor/pyside_ui/{shell_process → git_ui}/__init__.py +0 -0
  147. /je_editor/pyside_ui/{syntax → git_ui/code_diff_compare}/__init__.py +0 -0
  148. /je_editor/pyside_ui/{treeview → git_ui/git_client}/__init__.py +0 -0
  149. /je_editor/pyside_ui/{treeview/project_treeview → main_ui/ai_widget}/__init__.py +0 -0
  150. /je_editor/pyside_ui/{user_setting → main_ui/console_widget}/__init__.py +0 -0
  151. {je_editor-0.0.104.dist-info → je_editor-0.0.228.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,799 @@
1
+ from pathlib import Path
2
+
3
+ from PySide6.QtCore import Qt, QTimer
4
+ from PySide6.QtGui import QTextOption, QTextCharFormat, QColor, QFont, QSyntaxHighlighter, QAction
5
+ from PySide6.QtWidgets import (
6
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
7
+ QPushButton, QComboBox, QListWidget, QListWidgetItem, QPlainTextEdit,
8
+ QLineEdit, QSizePolicy, QSplitter, QFileDialog, QMessageBox, QInputDialog, QMenuBar
9
+ )
10
+ from git import Repo, InvalidGitRepositoryError, NoSuchPathError, GitCommandError
11
+
12
+
13
+ class GitChangeItem:
14
+ """Simple data holder for a file change entry.
15
+ 簡單的資料結構,用來存放檔案變更資訊。
16
+ """
17
+
18
+ def __init__(self, path: str, status: str):
19
+ self.path = path # repo 相對路徑 / repo-relative path
20
+ self.status = status # 狀態,例如 'untracked', 'modified', 'deleted', 'renamed', 'staged'
21
+
22
+
23
+ class GitGui(QWidget):
24
+ def __init__(self):
25
+ super().__init__()
26
+ self.current_repo: Repo | None = None
27
+ self.last_opened_repo_path = None
28
+ self._init_ui() # 初始化 UI
29
+ self._restore_last_opened_repository() # 嘗試還原上次開啟的 repo
30
+
31
+ def _init_ui(self):
32
+ # === Top controls / 上方控制區 ===
33
+ self.repo_path_label = QLabel("Repository: (none)")
34
+ self.open_repo_button = QPushButton("Open Repo")
35
+ self.branch_selector = QComboBox()
36
+ self.checkout_button = QPushButton("Checkout")
37
+ self.clone_repo_button = QPushButton("Clone Repo")
38
+ self.repo_status_label = QLabel("Status: -")
39
+ self.commit_status_label = QLabel("Unpushed commits: ...")
40
+
41
+ top = QHBoxLayout()
42
+ top.addWidget(self.repo_path_label, 1)
43
+ top.addWidget(QLabel("Branch:"))
44
+ top.addWidget(self.branch_selector, 1)
45
+ top.addWidget(self.checkout_button)
46
+ top.addWidget(self.open_repo_button)
47
+ top.addWidget(self.clone_repo_button)
48
+
49
+ # === Left: changes list / 左側:變更清單 ===
50
+ self.changes_list_widget = QListWidget()
51
+ self.changes_list_widget.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
52
+
53
+ # === Right: diff / info viewer / 右側:差異或資訊檢視器 ===
54
+ self.diff_viewer = QPlainTextEdit()
55
+ self.diff_viewer.setReadOnly(True)
56
+ self.diff_viewer.setWordWrapMode(QTextOption.WrapMode.NoWrap)
57
+ self.diff_viewer.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
58
+ self.diff_viewer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
59
+
60
+ # 設定等寬字型 / Use monospaced font
61
+ mono = self.font()
62
+ mono.setFamily("Consolas")
63
+ mono.setPointSize(10)
64
+ self.diff_viewer.setFont(mono)
65
+
66
+ # 差異高亮器 / Diff syntax highlighter
67
+ self.highlighter = GitDiffHighlighter(self.diff_viewer.document())
68
+ self.highlighter.configure_theme_colors()
69
+ self.highlighter.rehighlight()
70
+
71
+ # 左右分割器 / Splitter for changes list and diff viewer
72
+ splitter = QSplitter()
73
+ splitter.addWidget(self.changes_list_widget)
74
+ splitter.addWidget(self.diff_viewer)
75
+ splitter.setStretchFactor(0, 0)
76
+ splitter.setStretchFactor(1, 1)
77
+
78
+ # === Bottom: staging and commit controls / 下方:stage 與 commit 控制 ===
79
+ self.commit_message_input = QLineEdit()
80
+ self.commit_message_input.setPlaceholderText("Commit message...")
81
+ self.stage_selected_button = QPushButton("Stage Selected")
82
+ self.unstage_selected_button = QPushButton("Unstage Selected")
83
+ self.stage_all_button = QPushButton("Stage All")
84
+ self.commit_button = QPushButton("Commit")
85
+ self.unstage_all_button = QPushButton("Unstage All")
86
+ self.track_all_untracked_button = QPushButton("Track All Untracked")
87
+ self.git_push_button = QPushButton("Push")
88
+
89
+ bottom = QHBoxLayout()
90
+ bottom.addWidget(QLabel("Message:"))
91
+ bottom.addWidget(self.commit_message_input, 1)
92
+ bottom.addWidget(self.stage_selected_button)
93
+ bottom.addWidget(self.unstage_selected_button)
94
+ bottom.addWidget(self.unstage_all_button)
95
+ bottom.addWidget(self.stage_all_button)
96
+ bottom.addWidget(self.commit_button)
97
+ bottom.addWidget(self.track_all_untracked_button)
98
+ bottom.addWidget(self.git_push_button)
99
+
100
+ # === Main layout / 主版面配置 ===
101
+ center_layout = QVBoxLayout()
102
+ center_layout.addLayout(top)
103
+ center_layout.addWidget(self.repo_status_label)
104
+ center_layout.addWidget(self.commit_status_label)
105
+ center_layout.addWidget(splitter, 1)
106
+ center_layout.addLayout(bottom)
107
+ self.setLayout(center_layout)
108
+
109
+ # === Events / 事件綁定 ===
110
+ self.open_repo_button.clicked.connect(self.on_open_repository_requested)
111
+ self.clone_repo_button.clicked.connect(self.on_clone_repository_requested)
112
+ self.branch_selector.currentTextChanged.connect(self.on_branch_selection_changed)
113
+ self.checkout_button.clicked.connect(self.on_checkout_branch_requested)
114
+ self.changes_list_widget.itemSelectionChanged.connect(self.on_change_selection_changed)
115
+ self.stage_selected_button.clicked.connect(self.on_stage_selected_changes)
116
+ self.unstage_selected_button.clicked.connect(self.on_unstage_selected_changes)
117
+ self.stage_all_button.clicked.connect(self.on_stage_all_changes)
118
+ self.commit_button.clicked.connect(self.on_commit_staged_changes)
119
+ self.unstage_all_button.clicked.connect(self.on_unstage_all_changes)
120
+ self.track_all_untracked_button.clicked.connect(self.on_track_all_untracked_files)
121
+ self.git_push_button.clicked.connect(self.on_push_to_github)
122
+
123
+ self._update_ui_controls(enabled=False)
124
+
125
+ # === MenuBar / 選單列 ===
126
+ menubar = QMenuBar(self)
127
+ theme_menu = menubar.addMenu("Theme")
128
+
129
+ light_action = QAction("Light", self)
130
+ dark_action = QAction("Dark", self)
131
+
132
+ theme_menu.addAction(light_action)
133
+ theme_menu.addAction(dark_action)
134
+
135
+ light_action.triggered.connect(self.apply_light_theme)
136
+ dark_action.triggered.connect(self.apply_dark_theme)
137
+
138
+ center_layout.setMenuBar(menubar)
139
+
140
+ # === Timer ===
141
+ self.update_commit_status_timer = QTimer()
142
+ self.update_commit_status_timer.setInterval(1000)
143
+ self.update_commit_status_timer.timeout.connect(self.update_commit_status)
144
+ self.update_commit_status_timer.start()
145
+
146
+ # ---------- Repo operations ----------
147
+ # ---------- 儲存庫操作 ----------
148
+
149
+ def _restore_last_opened_repository(self):
150
+ """
151
+ Restore the last opened repository if available.
152
+ 如果有記錄上次開啟的儲存庫,則嘗試重新載入。
153
+ """
154
+ if self.last_opened_repo_path:
155
+ self._load_repository_from_path(Path(self.last_opened_repo_path))
156
+
157
+ def on_open_repository_requested(self):
158
+ """
159
+ Open a Git repository via file dialog.
160
+ 透過檔案選擇對話框開啟 Git 儲存庫。
161
+ """
162
+ repo_directory = QFileDialog.getExistingDirectory(self, "Open Git Repository")
163
+ if not repo_directory:
164
+ return
165
+ self._load_repository_from_path(Path(repo_directory))
166
+ self.last_opened_repo_path = str(repo_directory)
167
+
168
+ def _load_repository_from_path(self, selected_directory_path: Path):
169
+ """
170
+ Load a Git repository from a given folder.
171
+ 從指定資料夾載入 Git 儲存庫。
172
+ """
173
+ try:
174
+ self.current_repo = Repo(selected_directory_path)
175
+ if self.current_repo.bare:
176
+ # 不支援 bare repo
177
+ raise InvalidGitRepositoryError(f"Bare repository not supported: {selected_directory_path}")
178
+ except (InvalidGitRepositoryError, NoSuchPathError) as e:
179
+ # 如果不是合法的 Git repo,顯示錯誤訊息並重置 UI
180
+ QMessageBox.critical(self, "Error", f"Not a valid git repository:\n{e}")
181
+ self.current_repo = None
182
+ self._update_ui_controls(False)
183
+ self.repo_path_label.setText("Repository: (none)")
184
+ self.branch_selector.clear()
185
+ self.changes_list_widget.clear()
186
+ self.diff_viewer.setPlainText("")
187
+ self.repo_status_label.setText("Status: -")
188
+ return
189
+
190
+ # 成功載入 repo,更新 UI
191
+ self.repo_path_label.setText(f"Repository: {selected_directory_path}")
192
+ self._refresh_branch_list()
193
+ self._refresh_change_list()
194
+ self._update_ui_controls(True)
195
+
196
+ def _refresh_branch_list(self):
197
+ """
198
+ Refresh branch list in the combo box.
199
+ 更新分支清單。
200
+ """
201
+ if not self.current_repo:
202
+ self.branch_selector.clear()
203
+ return
204
+
205
+ heads = [h.name for h in self.current_repo.heads]
206
+ self.branch_selector.clear()
207
+ self.branch_selector.addItems(heads)
208
+
209
+ # 設定目前分支
210
+ try:
211
+ current = self.current_repo.active_branch.name
212
+ idx = self.branch_selector.findText(current)
213
+ if idx >= 0:
214
+ self.branch_selector.setCurrentIndex(idx)
215
+ except TypeError:
216
+ # Detached HEAD 狀態
217
+ self.branch_selector.setEditable(True)
218
+ self.branch_selector.setEditText(self.current_repo.head.commit.hexsha[:8])
219
+
220
+ def on_branch_selection_changed(self):
221
+ """
222
+ Triggered when branch selection changes.
223
+ 分支選擇改變時觸發,目前不做動作,需按下 Checkout 才會生效。
224
+ """
225
+ pass
226
+
227
+ def on_checkout_branch_requested(self):
228
+ """
229
+ Checkout the selected branch.
230
+ 切換到選取的分支。
231
+ """
232
+ if not self.current_repo:
233
+ return
234
+ target = self.branch_selector.currentText().strip()
235
+ if not target:
236
+ QMessageBox.warning(self, "Checkout", "Branch name is empty.")
237
+ return
238
+ try:
239
+ self.current_repo.git.checkout(target)
240
+ self._refresh_branch_list()
241
+ self._refresh_change_list()
242
+ except GitCommandError as e:
243
+ QMessageBox.critical(self, "Checkout Error", str(e))
244
+
245
+ # ---------- Changes & staging ----------
246
+ # ---------- 變更與暫存 ----------
247
+
248
+ def _is_binary_path(self, abs_path: Path, sniff_bytes: int = 2048) -> bool:
249
+ """簡單嗅探檔案是否為二進位:若包含 NUL bytes,視為二進位。"""
250
+ try:
251
+ with open(abs_path, 'rb') as f:
252
+ chunk = f.read(sniff_bytes)
253
+ return b'\x00' in chunk
254
+ except Exception:
255
+ return False
256
+
257
+ def _safe_set_diff_text(self, text: str):
258
+ # 套用高亮顏色
259
+ self.diff_viewer.setPlainText(text if text else "(no content)")
260
+ if hasattr(self, "highlighter"):
261
+ self.highlighter.rehighlight()
262
+
263
+ def _show_diff_for_change(self, change: GitChangeItem):
264
+ current_repo = self.current_repo
265
+ if not current_repo:
266
+ self._safe_set_diff_text("Error: repository not loaded.")
267
+ return
268
+
269
+ # Normalize paths (for rename "a -> b" 取目的與來源)
270
+ src, dst = None, None
271
+ if "->" in change.path and change.status in ("renamed", "staged"):
272
+ parts = [p.strip() for p in change.path.split("->")]
273
+ if len(parts) == 2:
274
+ src, dst = parts
275
+ rel = dst or change.path
276
+ abs_path = Path(current_repo.working_tree_dir) / Path(rel)
277
+
278
+ try:
279
+ # Untracked: 顯示新增檔案的內容(模擬 unified diff)
280
+ if change.status == "untracked":
281
+ if not abs_path.exists():
282
+ self._safe_set_diff_text(f"(untracked file missing: {rel})")
283
+ return
284
+ if self._is_binary_path(abs_path):
285
+ self._safe_set_diff_text(f"(binary file, no textual diff: {rel})")
286
+ return
287
+ with open(abs_path, "r", encoding="utf-8", errors="ignore") as f:
288
+ content = f.read()
289
+ header = f"--- /dev/null\n+++ b/{rel}\n"
290
+ body = "\n".join(f"+{line}" for line in content.splitlines())
291
+ self._safe_set_diff_text(header + body)
292
+ return
293
+
294
+ # Deleted: 顯示與 HEAD/Index 的差異(若工作樹檔案不存在也能呈現)
295
+ if change.status == "deleted":
296
+ # working tree vs index(未暫存刪除)
297
+ if rel and not current_repo.index.diff(None, paths=[rel]):
298
+ # 若 diff(None) 空,試試 index vs HEAD
299
+ diff_text = current_repo.git.diff("--cached", rel)
300
+ else:
301
+ diff_text = current_repo.git.diff(rel)
302
+ if not diff_text.strip():
303
+ self._safe_set_diff_text(f"(deleted file; no textual diff or already committed: {rel})")
304
+ else:
305
+ self._safe_set_diff_text(diff_text)
306
+ return
307
+
308
+ # Renamed: 顯示 rename 變更;若僅 rename 無內容改動,diff 可能為空
309
+ if change.status == "renamed":
310
+ # 優先顯示 staged rename
311
+ diff_text = ""
312
+ try:
313
+ if dst:
314
+ diff_text = current_repo.git.diff("--cached", dst)
315
+ except GitCommandError:
316
+ pass
317
+ if not diff_text.strip():
318
+ # fallback: working tree
319
+ try:
320
+ diff_text = current_repo.git.diff(dst or rel)
321
+ except GitCommandError:
322
+ diff_text = ""
323
+ if not diff_text.strip():
324
+ # 顯示 rename meta
325
+ self._safe_set_diff_text(
326
+ f"diff --git a/{src} b/{dst}\nrename from {src}\nrename to {dst}\n(no line changes)")
327
+ else:
328
+ self._safe_set_diff_text(diff_text)
329
+ return
330
+
331
+ # Staged vs HEAD
332
+ if change.status == "staged":
333
+ diff_text = current_repo.git.diff("--cached", rel)
334
+ if not diff_text.strip():
335
+ self._safe_set_diff_text("(no staged changes vs HEAD)")
336
+ else:
337
+ self._safe_set_diff_text(diff_text)
338
+ return
339
+
340
+ # Modified / general unstaged
341
+ if change.status in ("modified",):
342
+ # working tree vs index
343
+ diff_text = current_repo.git.diff(rel)
344
+ if not diff_text.strip():
345
+ # 若空,嘗試 index vs HEAD(可能已被暫存)
346
+ try:
347
+ diff_text = current_repo.git.diff("--cached", rel)
348
+ except GitCommandError:
349
+ diff_text = ""
350
+ if not diff_text.strip():
351
+ self._safe_set_diff_text("(no unstaged changes; file may be already staged)")
352
+ else:
353
+ self._safe_set_diff_text(diff_text)
354
+ return
355
+
356
+ # Fallback
357
+ self._safe_set_diff_text(f"(no diff handler for status: {change.status})")
358
+
359
+ except GitCommandError as e:
360
+ self._safe_set_diff_text(f"Git error while generating diff:\n{e}")
361
+ except FileNotFoundError:
362
+ self._safe_set_diff_text(f"(file missing in working tree: {rel})")
363
+ except Exception as e:
364
+ self._safe_set_diff_text(f"Unexpected error while generating diff:\n{e}")
365
+
366
+ def _refresh_change_list(self):
367
+ """
368
+ Collect changes from working tree and index, then render list.
369
+ 收集工作目錄與索引的變更,並更新清單。
370
+ """
371
+ if not self.current_repo:
372
+ self.changes_list_widget.clear()
373
+ self.diff_viewer.setPlainText("")
374
+ self.repo_status_label.setText("Status: -")
375
+ return
376
+
377
+ repository = self.current_repo
378
+
379
+ # === Untracked files / 未追蹤檔案 ===
380
+ untracked_files = list(repository.untracked_files)
381
+
382
+ # === Unstaged changes (working tree vs index) / 未暫存變更 ===
383
+ working_tree_vs_index_diff = repository.index.diff(None) # None 表示與工作目錄比較
384
+ unstaged_changes = []
385
+ for d in working_tree_vs_index_diff:
386
+ path = d.a_path if d.a_path else d.b_path
387
+ status = "modified"
388
+ if d.change_type == "D":
389
+ status = "deleted"
390
+ elif d.change_type == "R":
391
+ status = "renamed"
392
+ path = f"{d.a_path} -> {d.b_path}"
393
+ unstaged_changes.append(GitChangeItem(path, status))
394
+
395
+ # === Staged changes (index vs HEAD) / 已暫存變更 ===
396
+ index_vs_head_diff = repository.index.diff("HEAD")
397
+ staged_changes = []
398
+ for d in index_vs_head_diff:
399
+ path = d.a_path if d.a_path else d.b_path
400
+ status = "staged"
401
+ staged_changes.append(GitChangeItem(path, status))
402
+
403
+ # === Render list / 渲染清單 ===
404
+ self.changes_list_widget.clear()
405
+
406
+ def add_item(txt: str, bold=False, disabled=False):
407
+ """
408
+ Add a section header item to the list.
409
+ 新增一個區段標題項目到清單。
410
+ """
411
+ list_item = QListWidgetItem(txt)
412
+ if bold:
413
+ font = list_item.font()
414
+ font.setBold(True)
415
+ list_item.setFont(font)
416
+ if disabled:
417
+ list_item.setFlags(list_item.flags() & ~Qt.ItemFlag.ItemIsSelectable & ~Qt.ItemFlag.ItemIsEnabled)
418
+ self.changes_list_widget.addItem(list_item)
419
+
420
+ # 區段標題與檔案項目
421
+ add_item("— Untracked —", bold=True, disabled=True)
422
+ for path in untracked_files:
423
+ self._add_change_item(GitChangeItem(path, "untracked_files"))
424
+
425
+ add_item("— Unstaged —", bold=True, disabled=True)
426
+ for item in unstaged_changes:
427
+ self._add_change_item(item)
428
+
429
+ add_item("— Staged —", bold=True, disabled=True)
430
+ for item in staged_changes:
431
+ self._add_change_item(item)
432
+
433
+ # === Status summary / 狀態摘要 ===
434
+ summary = (
435
+ f"Branch: {getattr(repository.head, 'reference', None) and repository.active_branch.name if not repository.head.is_detached else '(detached)'}\n"
436
+ f"Untracked: {len(untracked_files)} | Unstaged: {len(unstaged_changes)} | Staged: {len(staged_changes)}"
437
+ )
438
+ self.repo_status_label.setText(f"Status: {summary}")
439
+ self.diff_viewer.setPlainText("Select files to stage/unstage.")
440
+
441
+ def _add_change_item(self, change: GitChangeItem):
442
+ """
443
+ Add a file change entry to the list.
444
+ 將檔案變更項目加入清單。
445
+ """
446
+ item_text = f"[{change.status}] {change.path}"
447
+ list_item = QListWidgetItem(item_text)
448
+ list_item.setData(Qt.ItemDataRole.UserRole, change) # 存放 GitChangeItem 物件
449
+ list_item.setCheckState(Qt.CheckState.Unchecked) # 預設未勾選
450
+ self.changes_list_widget.addItem(list_item)
451
+
452
+ def _get_selected_changes(self):
453
+ """
454
+ Collect all checked selected_items from the list.
455
+ 收集所有被勾選的檔案項目。
456
+ """
457
+ selected_items = []
458
+ for i in range(self.changes_list_widget.count()):
459
+ list_item = self.changes_list_widget.item(i)
460
+ if list_item.checkState() == Qt.CheckState.Checked:
461
+ change = list_item.data(Qt.ItemDataRole.UserRole)
462
+ if isinstance(change, GitChangeItem):
463
+ selected_items.append(change)
464
+ return selected_items
465
+
466
+ def on_change_selection_changed(self):
467
+ """
468
+ Show diff for the selected file.
469
+ 顯示選取檔案的差異。
470
+ """
471
+ if not self.current_repo:
472
+ return
473
+ selected_items = self.changes_list_widget.selectedItems()
474
+ if not selected_items:
475
+ self.diff_viewer.clear()
476
+ return
477
+
478
+ # 取最後一個被選中的項目 / Take the last selected item
479
+ list_item = selected_items[-1]
480
+ change = list_item.data(Qt.ItemDataRole.UserRole)
481
+ if isinstance(change, GitChangeItem):
482
+ self._show_diff_for_change(change)
483
+
484
+ def on_stage_selected_changes(self):
485
+ """
486
+ Stage selected_changes files.
487
+ 將選取的檔案加入暫存區。
488
+ """
489
+ if not self.current_repo:
490
+ return
491
+ selected_changes = self._get_selected_changes()
492
+ if not selected_changes:
493
+ QMessageBox.information(self, "Stage", "No files selected_changes.")
494
+ return
495
+ try:
496
+ # 處理重新命名的檔案 "a -> b",需要同時 stage 移除 a 與新增 b
497
+ file_paths = []
498
+ for change_entry in selected_changes:
499
+ if "->" in change_entry.path and change_entry.status in ("renamed", "modified", "staged"):
500
+ parts = change_entry.path.split("->")
501
+ source_path = parts[0].strip()
502
+ destination_path = parts[1].strip()
503
+ file_paths.extend([source_path, destination_path])
504
+ else:
505
+ file_paths.append(change_entry.path)
506
+ self.current_repo.index.add(file_paths)
507
+ self.current_repo.index.write()
508
+ self._refresh_change_list()
509
+ except GitCommandError as e:
510
+ QMessageBox.critical(self, "Stage Error", str(e))
511
+
512
+ def on_unstage_selected_changes(self):
513
+ """
514
+ Unstage selected_changes files.
515
+ 將選取的檔案從暫存區移除。
516
+ """
517
+ if not self.current_repo:
518
+ return
519
+ selected_changes = self._get_selected_changes()
520
+ if not selected_changes:
521
+ QMessageBox.information(self, "Unstage", "No files selected_changes.")
522
+ return
523
+ try:
524
+ file_paths = []
525
+ for change_entry in selected_changes:
526
+ print(change_entry.path)
527
+ if "->" in change_entry.path and change_entry.status in ("renamed", "staged"):
528
+ parts = change_entry.path.split("->")
529
+ source_path = parts[0].strip()
530
+ destination_path = parts[1].strip()
531
+ file_paths.extend([source_path, destination_path])
532
+ else:
533
+ file_paths.append(change_entry.path)
534
+
535
+ if self.current_repo.head.is_valid():
536
+ # 有 HEAD 的情況:reset index
537
+ self.current_repo.git.reset("HEAD", "--", *file_paths)
538
+ else:
539
+ # 初始 commit(沒有 HEAD):直接從 index 移除
540
+ for file_path in file_paths:
541
+ try:
542
+ self.current_repo.index.remove([file_path], working_tree=True)
543
+ except Exception:
544
+ pass
545
+ self._refresh_change_list()
546
+ except GitCommandError as e:
547
+ QMessageBox.critical(self, "Unstage Error", str(e))
548
+
549
+ def on_stage_all_changes(self):
550
+ """
551
+ Stage all changes (equivalent to git add -A).
552
+ 將所有變更加入暫存區(等同於 git add -A)。
553
+ """
554
+ if not self.current_repo:
555
+ return
556
+ try:
557
+ self.current_repo.git.add("-A")
558
+ self._refresh_change_list()
559
+ except GitCommandError as e:
560
+ QMessageBox.critical(self, "Stage All Error", str(e))
561
+
562
+ def on_commit_staged_changes(self):
563
+ """
564
+ Commit staged changes with a message.
565
+ 提交已暫存的變更。
566
+ """
567
+ if not self.current_repo:
568
+ return
569
+ commit_message = self.commit_message_input.text().strip()
570
+ if not commit_message:
571
+ QMessageBox.warning(self, "Commit", "Commit message is empty.")
572
+ return
573
+ # 確認是否有 staged 變更
574
+ staged_changes = list(self.current_repo.index.diff("HEAD"))
575
+ if not staged_changes and self.current_repo.head.is_valid():
576
+ QMessageBox.information(self, "Commit", "No staged changes to commit.")
577
+ return
578
+ try:
579
+ # 支援初始 commit(沒有 HEAD)
580
+ self.current_repo.index.commit(commit_message)
581
+ self.commit_message_input.clear()
582
+ self._refresh_change_list()
583
+ QMessageBox.information(self, "Commit", "Commit successful.")
584
+ except GitCommandError as e:
585
+ QMessageBox.critical(self, "Commit Error", str(e))
586
+
587
+ def _update_ui_controls(self, enabled: bool):
588
+ """
589
+ Enable or disable UI controls depending on repo state.
590
+ 根據是否有開啟 repo 來啟用或停用 UI 控制項。
591
+ """
592
+ for widget in (
593
+ self.branch_selector, self.checkout_button, self.changes_list_widget,
594
+ self.diff_viewer, self.commit_message_input, self.stage_selected_button,
595
+ self.unstage_selected_button, self.stage_all_button, self.commit_button,
596
+ self.unstage_all_button, self.track_all_untracked_button, self.git_push_button
597
+ ):
598
+ widget.setEnabled(enabled)
599
+
600
+ def on_unstage_all_changes(self):
601
+ """
602
+ Unstage all changes.
603
+ 將所有檔案從暫存區移除。
604
+ """
605
+ if not self.current_repo:
606
+ return
607
+ try:
608
+ if self.current_repo.head.is_valid():
609
+ # 有 HEAD 的情況:reset index
610
+ self.current_repo.git.reset("HEAD")
611
+ else:
612
+ # 初始 commit(沒有 HEAD):清空 index
613
+ self.current_repo.index.clear()
614
+ self.current_repo.index.write()
615
+ self._refresh_change_list()
616
+ except Exception as e:
617
+ QMessageBox.critical(self, "Unstage All Error", str(e))
618
+
619
+ def on_track_all_untracked_files(self):
620
+ """
621
+ Track all untracked files.
622
+ 將所有未追蹤檔案加入暫存區。
623
+ """
624
+ if not self.current_repo:
625
+ return
626
+ try:
627
+ untracked_files = self.current_repo.untracked_files
628
+ if not untracked_files:
629
+ QMessageBox.information(self, "Track Untracked", "No untracked_files files found.")
630
+ return
631
+ self.current_repo.index.add(untracked_files)
632
+ self.current_repo.index.write()
633
+ self._refresh_change_list()
634
+ QMessageBox.information(self, "Track Untracked", f"Tracked {len(untracked_files)} untracked_files files.")
635
+ except Exception as e:
636
+ QMessageBox.critical(self, "Track Untracked Error", str(e))
637
+
638
+ def on_clone_repository_requested(self):
639
+ """
640
+ Clone a remote repository into a local folder.
641
+ 複製遠端 Git 儲存庫到本地資料夾。
642
+ """
643
+ # 輸入遠端 URL
644
+ remote_url, is_confirmed = QInputDialog.getText(self, "Clone Repository", "Remote URL:")
645
+ if not is_confirmed or not remote_url.strip():
646
+ return
647
+
648
+ # 選擇目標資料夾
649
+ target_directory = QFileDialog.getExistingDirectory(self, "Select Target Directory")
650
+ if not target_directory:
651
+ return
652
+
653
+ try:
654
+ # 在目標資料夾下建立 repo
655
+ repository_path = Path(target_directory) / Path(remote_url).name.replace(".git", "")
656
+ if repository_path.exists():
657
+ QMessageBox.warning(self, "Clone Repo", f"Target folder already exists:\n{repository_path}")
658
+ return
659
+
660
+ Repo.clone_from(remote_url, repository_path)
661
+ QMessageBox.information(self, "Clone Repo", f"Repository cloned to:\n{repository_path}")
662
+
663
+ # 自動載入新 repo
664
+ self._load_repository_from_path(repository_path)
665
+
666
+ except GitCommandError as e:
667
+ QMessageBox.critical(self, "Clone Error", str(e))
668
+
669
+ # ===== GitHub =====
670
+
671
+ def on_push_to_github(self):
672
+ if not self.current_repo:
673
+ QMessageBox.warning(self, "Warning", "No repository opened.")
674
+ return
675
+ try:
676
+ origin = self.current_repo.remote(name="origin")
677
+ result = origin.push()
678
+ msg = "\n".join(str(r) for r in result)
679
+ QMessageBox.information(self, "Push Result", f"Pushed to origin:\n{msg}")
680
+ except Exception as e:
681
+ QMessageBox.critical(self, "Track Untracked Error", str(e))
682
+
683
+ def get_unpushed_commit_count(self, remote_name: str = "origin") -> dict:
684
+ try:
685
+ repo = self.current_repo
686
+ if repo is None:
687
+ return {"ahead": 0, "behind": 0, "error": "No repo loaded"}
688
+ if repo.bare:
689
+ return {"ahead": 0, "behind": 0, "error": "Repository is bare"}
690
+ if repo.head.is_detached:
691
+ return {"ahead": 0, "behind": 0, "error": "HEAD is detached"}
692
+
693
+ branch = repo.active_branch
694
+ remote = repo.remote(remote_name)
695
+ remote.fetch()
696
+
697
+ upstream_ref = f"{remote_name}/{branch.name}"
698
+ if upstream_ref not in repo.refs:
699
+ return {"ahead": 0, "behind": 0, "error": f"No upstream branch for {branch.name}"}
700
+
701
+ ahead_commits = list(repo.iter_commits(f"{upstream_ref}..{branch.name}"))
702
+ behind_commits = list(repo.iter_commits(f"{branch.name}..{upstream_ref}"))
703
+
704
+ return {"ahead": len(ahead_commits), "behind": len(behind_commits), "error": None}
705
+
706
+ except GitCommandError as e:
707
+ return {"ahead": 0, "behind": 0, "error": f"Git error: {e}"}
708
+ except Exception as e:
709
+ return {"ahead": 0, "behind": 0, "error": str(e)}
710
+
711
+ def update_commit_status(self):
712
+ result = self.get_unpushed_commit_count()
713
+ if result["error"]:
714
+ self.commit_status_label.setText(f"Error: {result['error']}")
715
+ else:
716
+ self.commit_status_label.setText(
717
+ f"Ahead (push): {result['ahead']} | Behind (pull): {result['behind']}"
718
+ )
719
+
720
+ # ===== Theme =====
721
+
722
+ def apply_light_theme(self):
723
+ """
724
+ Switch to light theme highlighting.
725
+ 切換到淺色主題的高亮顯示。
726
+ """
727
+ self.highlighter.configure_theme_colors(use_light_mode=True)
728
+ self.highlighter.rehighlight()
729
+
730
+ def apply_dark_theme(self):
731
+ """
732
+ Switch to dark theme highlighting.
733
+ 切換到深色主題的高亮顯示。
734
+ """
735
+ self.highlighter.configure_theme_colors()
736
+ self.highlighter.rehighlight()
737
+
738
+
739
+ class GitDiffHighlighter(QSyntaxHighlighter):
740
+ """
741
+ Syntax highlighter for Git diff text.
742
+ Git diff 文字的語法高亮器。
743
+ """
744
+
745
+ def __init__(self, parent):
746
+ super().__init__(parent)
747
+ self.configure_theme_colors()
748
+
749
+ def configure_theme_colors(self, use_light_mode: bool = False):
750
+ """
751
+ Update colors depending on theme.
752
+ 根據主題更新顏色。
753
+ """
754
+ if use_light_mode:
755
+ # 淺色模式配色(GitHub 風格)
756
+ self.color_added = QColor("#22863a") # 綠色(新增行)
757
+ self.color_removed = QColor("#cb2431") # 紅色(刪除行)
758
+ self.color_header = QColor("#005cc5") # 藍色(hunk header)
759
+ self.color_meta = QColor("#6a737d") # 灰色(meta 資訊)
760
+ else:
761
+ # 深色模式配色(VSCode / GitHub Dark 風格)
762
+ self.color_added = QColor("#85e89d") # 淺綠
763
+ self.color_removed = QColor("#f97583") # 淺紅
764
+ self.color_header = QColor("#79b8ff") # 淺藍
765
+ self.color_meta = QColor("#959da5") # 淺灰
766
+
767
+ # === 格式定義 / Format definitions ===
768
+ self.added_format = QTextCharFormat()
769
+ self.added_format.setForeground(self.color_added)
770
+
771
+ self.removed_format = QTextCharFormat()
772
+ self.removed_format.setForeground(self.color_removed)
773
+
774
+ self.header_format = QTextCharFormat()
775
+ self.header_format.setForeground(self.color_header)
776
+ self.header_format.setFontWeight(QFont.Weight.Bold)
777
+
778
+ self.meta_format = QTextCharFormat()
779
+ self.meta_format.setForeground(self.color_meta)
780
+
781
+ def highlightBlock(self, line_text: str):
782
+ """
783
+ Apply highlighting rules to each line of diff text.
784
+ 對 diff 文字的每一行套用高亮規則。
785
+ """
786
+ if line_text.startswith("+") and not line_text.startswith("+++"):
787
+ # 新增行 / Added line
788
+ self.setFormat(0, len(line_text), self.added_format)
789
+ elif line_text.startswith("-") and not line_text.startswith("---"):
790
+ # 刪除行 / Removed line
791
+ self.setFormat(0, len(line_text), self.removed_format)
792
+ elif line_text.startswith("@@"):
793
+ # Hunk header
794
+ self.setFormat(0, len(line_text), self.header_format)
795
+ elif line_text.startswith("diff ") or line_text.startswith("index ") or line_text.startswith(
796
+ "---") or line_text.startswith(
797
+ "+++"):
798
+ # Meta 資訊(檔案路徑、index、diff header)
799
+ self.setFormat(0, len(line_text), self.meta_format)