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.
- je_editor/code_scan/ruff_thread.py +33 -6
- je_editor/code_scan/watchdog_implement.py +42 -20
- je_editor/code_scan/watchdog_thread.py +54 -9
- je_editor/git_client/commit_graph.py +32 -43
- je_editor/git_client/{git.py → git_action.py} +1 -1
- je_editor/git_client/git_cli.py +4 -4
- je_editor/git_client/github.py +36 -5
- je_editor/pyside_ui/browser/browser_download_window.py +41 -5
- je_editor/pyside_ui/browser/browser_serach_lineedit.py +25 -1
- je_editor/pyside_ui/browser/browser_view.py +42 -1
- je_editor/pyside_ui/browser/browser_widget.py +43 -14
- je_editor/pyside_ui/git_ui/code_diff_compare/__init__.py +0 -0
- je_editor/pyside_ui/git_ui/code_diff_compare/code_diff_viewer_widget.py +90 -0
- je_editor/pyside_ui/git_ui/code_diff_compare/line_number_code_viewer.py +141 -0
- je_editor/pyside_ui/git_ui/code_diff_compare/multi_file_diff_viewer.py +88 -0
- je_editor/pyside_ui/git_ui/code_diff_compare/side_by_side_diff_widget.py +271 -0
- je_editor/pyside_ui/git_ui/git_client/__init__.py +0 -0
- je_editor/pyside_ui/git_ui/{git_branch_tree_widget.py → git_client/git_branch_tree_widget.py} +17 -14
- je_editor/pyside_ui/git_ui/git_client/git_client_gui.py +734 -0
- je_editor/pyside_ui/main_ui/ai_widget/langchain_interface.py +1 -1
- je_editor/pyside_ui/main_ui/main_editor.py +0 -3
- je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py +14 -3
- je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +19 -4
- je_editor/utils/multi_language/english.py +2 -1
- je_editor/utils/multi_language/traditional_chinese.py +2 -2
- {je_editor-0.0.220.dist-info → je_editor-0.0.222.dist-info}/METADATA +4 -4
- {je_editor-0.0.220.dist-info → je_editor-0.0.222.dist-info}/RECORD +32 -26
- je_editor/pyside_ui/git_ui/git_client_gui.py +0 -291
- /je_editor/pyside_ui/git_ui/{commit_table.py → git_client/commit_table.py} +0 -0
- /je_editor/pyside_ui/git_ui/{graph_view.py → git_client/graph_view.py} +0 -0
- {je_editor-0.0.220.dist-info → je_editor-0.0.222.dist-info}/WHEEL +0 -0
- {je_editor-0.0.220.dist-info → je_editor-0.0.222.dist-info}/licenses/LICENSE +0 -0
- {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)
|