je-editor 0.0.220__py3-none-any.whl → 0.0.222__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.

Potentially problematic release.


This version of je-editor might be problematic. Click here for more details.

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