diff --git a/je_editor/git_client/git_action.py b/je_editor/git_client/git_action.py index 5d897e55..748fc999 100644 --- a/je_editor/git_client/git_action.py +++ b/je_editor/git_client/git_action.py @@ -1,8 +1,6 @@ import os from datetime import datetime -from typing import Any, Callable -from PySide6.QtCore import QThread, Signal from git import Repo, GitCommandError, InvalidGitRepositoryError, NoSuchPathError from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -35,7 +33,25 @@ def __init__(self) -> None: self.repo: Repo | None = None self.repo_path: str | None = None + def close(self) -> None: + """ + 關掉目前的儲存庫 + Close the repository currently open. + + 每個開著的 ``Repo`` 都帶著常駐的 ``git cat-file`` 子程序,不關掉就會一直 + 累積;開了幾十個之後,同一個程序裡連要開一條新執行緒都會變慢。 + Every open ``Repo`` carries long-lived ``git cat-file`` child processes and + they pile up if it is never closed; after a few dozen, even starting a + thread in the same process gets slow. + """ + repo, self.repo = self.repo, None + if repo is not None: + repo.close() + def open_repo(self, path: str) -> None: + # 換儲存庫時先把上一個關掉,否則它的子程序會一直留著 + # Close the previous one first, or its child processes stay behind + self.close() try: self.repo = Repo(path) self.repo_path = path @@ -251,25 +267,3 @@ def _ensure_repo(self) -> None: # Null tree constant for initial commit diff NULL_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" - - -# Worker thread wrapper -class GitWorker(QThread): - """ - Runs a function in a separate thread to avoid blocking the UI. - Emits (result, error) when done. - """ - done = Signal(object, object) - - def __init__(self, fn: Callable, *args: Any, **kwargs: Any) -> None: - super().__init__() - self.fn = fn - self.args = args - self.kwargs = kwargs - - def run(self) -> None: - try: - res = self.fn(*self.args, **self.kwargs) - self.done.emit(res, None) - except Exception as e: - self.done.emit(None, e) diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index f2372804..e0345116 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -482,6 +482,10 @@ def complete(self) -> None: column = self.textCursor().positionInBlock() self._complete_thread = QThread(self) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self._complete_thread.setObjectName("JediCompleteThread") self._complete_worker = _JediCompleteWorker(code, line, column, self.env) self._complete_worker.moveToThread(self._complete_thread) self._complete_thread.started.connect(self._complete_worker.run) @@ -3122,12 +3126,35 @@ def closeEvent(self, event: QtGui.QCloseEvent) -> None: self._lint_timer.stop() self._diff_timer.stop() self._complete_timer.stop() + self.stop_completion_thread() self.lint_manager.stop() self.diff_marker_manager.stop() self.blame_manager.stop() self.lsp_client.stop() super().closeEvent(event) + def stop_completion_thread(self) -> None: + """ + 等 jedi 補全的執行緒結束 + Wait for the jedi completion thread to finish. + + 它是編輯器唯一一條沒有管理器代管的執行緒,因此要在這裡自己等;補全跑在 + 大檔上要一段時間,關閉時它還在跑的話 Qt 會直接讓程序中止。 + It is the editor's one thread without a manager looking after it, so it is + waited for here: completion takes a while on a large file, and Qt aborts + the process outright if it is still running at the close. + """ + thread, self._complete_thread = self._complete_thread, None + if thread is None: + return + try: + if thread.isRunning(): + thread.quit() + thread.wait() + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + return + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: """ 滑鼠點擊事件(Alt+點按用於多重游標) diff --git a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py index 0d954eaf..a3e275e5 100644 --- a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py +++ b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py @@ -34,6 +34,10 @@ class _SearchWorker(QThread): def __init__(self, root: str, pattern: str, case_sensitive: bool, use_regex: bool) -> None: super().__init__() + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("SearchWorker") self.root = root self.pattern = pattern self.case_sensitive = case_sensitive diff --git a/je_editor/pyside_ui/git_ui/git_client/git_client_gui.py b/je_editor/pyside_ui/git_ui/git_client/git_client_gui.py index 2120af0e..0cf532cc 100644 --- a/je_editor/pyside_ui/git_ui/git_client/git_client_gui.py +++ b/je_editor/pyside_ui/git_ui/git_client/git_client_gui.py @@ -10,13 +10,40 @@ ) from git import Repo, InvalidGitRepositoryError, NoSuchPathError, GitCommandError +from je_editor.git_client.git_action import GitService from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper # UI 常數 / UI constants _CLONE_REPO_LABEL = "Clone Repo" _REPO_STATUS_DEFAULT = "Status: -" +def _text(key: str) -> str: + """ + 取得翻譯後的文字 + The translated text for a key. + + 這個面板的字串原本是寫死的英文,但語言檔裡一直有對應的翻譯沒被用到。 + This panel's strings used to be hard-coded English while the translations for + them sat unused in the language files. + + :param key: 語言鍵 / the language key + :return: 翻譯後的文字 / the translated text + """ + return language_wrapper.language_word_dict.get(key, key) + + +def _word(key: str) -> QPushButton: + """建立一個帶翻譯文字的按鈕 / A button labelled with a translated key.""" + return QPushButton(_text(key)) + + +def _label(key: str) -> QLabel: + """建立一個帶翻譯文字的標籤 / A label showing a translated key.""" + return QLabel(_text(key)) + + class _GitWorker(QObject): """背景執行 Git 操作的 Worker / Background worker for Git operations""" finished = Signal(object) # result @@ -49,6 +76,9 @@ class GitGui(QWidget): def __init__(self) -> None: super().__init__() self.current_repo: Repo | None = None + # stash 與衝突處理走的服務層,開啟儲存庫後才建立 + # The service layer stashing and conflicts use, built once a repo is open + self._git_service: GitService | None = None self.last_opened_repo_path = None self._init_ui() # 初始化 UI self._restore_last_opened_repository() # 嘗試還原上次開啟的 repo @@ -59,6 +89,10 @@ def _run_git_in_background(self, func: Callable, on_done: Callable | None = None Run Git operation in background thread to avoid blocking UI """ thread = QThread(self) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + thread.setObjectName("GitGuiWorker") worker = _GitWorker(func) worker.moveToThread(thread) thread.started.connect(worker.run) @@ -80,16 +114,16 @@ def _run_git_in_background(self, func: Callable, on_done: Callable | None = None def _init_ui(self) -> None: # === Top controls / 上方控制區 === self.repo_path_label = QLabel("Repository: (none)") - self.open_repo_button = QPushButton("Open Repo") + self.open_repo_button = _word("btn_open_repo") self.branch_selector = QComboBox() - self.checkout_button = QPushButton("Checkout") + self.checkout_button = _word("btn_checkout") self.clone_repo_button = QPushButton(_CLONE_REPO_LABEL) self.repo_status_label = QLabel(_REPO_STATUS_DEFAULT) self.commit_status_label = QLabel("Unpushed commits: ...") top = QHBoxLayout() top.addWidget(self.repo_path_label, 1) - top.addWidget(QLabel("Branch:")) + top.addWidget(_label("label_branch")) top.addWidget(self.branch_selector, 1) top.addWidget(self.checkout_button) top.addWidget(self.open_repo_button) @@ -126,17 +160,21 @@ def _init_ui(self) -> None: # === Bottom: staging and commit controls / 下方:stage 與 commit 控制 === self.commit_message_input = QLineEdit() - self.commit_message_input.setPlaceholderText("Commit message...") - self.stage_selected_button = QPushButton("Stage Selected") - self.unstage_selected_button = QPushButton("Unstage Selected") - self.stage_all_button = QPushButton("Stage All") - self.commit_button = QPushButton("Commit") - self.unstage_all_button = QPushButton("Unstage All") - self.track_all_untracked_button = QPushButton("Track All Untracked") - self.git_push_button = QPushButton("Push") + self.commit_message_input.setPlaceholderText(_text("placeholder_commit_message")) + self.stage_selected_button = _word("btn_stage_selected") + self.unstage_selected_button = _word("btn_unstage_selected") + self.stage_all_button = _word("btn_stage_all") + self.commit_button = _word("btn_commit") + self.unstage_all_button = _word("btn_unstage_all") + self.track_all_untracked_button = _word("btn_track_all_untracked") + self.git_push_button = _word("btn_push") + # 收起手邊的修改與解決合併衝突 / Putting work down, and settling a merge + self.stash_button = _word("btn_stash") + self.stash_pop_button = _word("btn_stash_pop") + self.resolve_button = _word("btn_resolve_conflict") bottom = QHBoxLayout() - bottom.addWidget(QLabel("Message:")) + bottom.addWidget(_label("label_message")) bottom.addWidget(self.commit_message_input, 1) bottom.addWidget(self.stage_selected_button) bottom.addWidget(self.unstage_selected_button) @@ -145,6 +183,9 @@ def _init_ui(self) -> None: bottom.addWidget(self.commit_button) bottom.addWidget(self.track_all_untracked_button) bottom.addWidget(self.git_push_button) + bottom.addWidget(self.stash_button) + bottom.addWidget(self.stash_pop_button) + bottom.addWidget(self.resolve_button) # === Main layout / 主版面配置 === center_layout = QVBoxLayout() @@ -168,6 +209,9 @@ def _init_ui(self) -> None: self.unstage_all_button.clicked.connect(self.on_unstage_all_changes) self.track_all_untracked_button.clicked.connect(self.on_track_all_untracked_files) self.git_push_button.clicked.connect(self.on_push_to_github) + self.stash_button.clicked.connect(self.on_stash_changes) + self.stash_pop_button.clicked.connect(self.on_pop_stash) + self.resolve_button.clicked.connect(self.on_resolve_conflict) self._update_ui_controls(enabled=False) @@ -220,6 +264,10 @@ def _load_repository_from_path(self, selected_directory_path: Path) -> None: Load a Git repository from a given folder. 從指定資料夾載入 Git 儲存庫。 """ + # 換了儲存庫:先關掉上一個,它的常駐子程序才不會留著 + # A different repository: close the previous one so its long-lived child + # processes do not stay behind + self._close_repositories() try: self.current_repo = Repo(selected_directory_path) if self.current_repo.bare: @@ -641,10 +689,149 @@ def _update_ui_controls(self, enabled: bool) -> None: self.branch_selector, self.checkout_button, self.changes_list_widget, self.diff_viewer, self.commit_message_input, self.stage_selected_button, self.unstage_selected_button, self.stage_all_button, self.commit_button, - self.unstage_all_button, self.track_all_untracked_button, self.git_push_button + self.unstage_all_button, self.track_all_untracked_button, self.git_push_button, + self.stash_button, self.stash_pop_button, self.resolve_button ): widget.setEnabled(enabled) + def closeEvent(self, event) -> None: + """ + 關閉前等背景的 git 操作結束 + Wait for the background git operations before closing. + + 這些執行緒掛在這個面板底下,面板被銷毀時它們還在跑的話,Qt 會直接讓程序 + 中止;fetch 之類的網路操作要跑上一段時間。 + They hang off this panel, and Qt aborts the process outright if one is + still running when the panel is destroyed -- and a network operation such + as fetch takes a while. + """ + self.update_commit_status_timer.stop() + for thread, _worker in getattr(self, "_bg_threads", []): + try: + if thread.isRunning(): + thread.quit() + thread.wait() + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + continue + self._bg_threads = [] + # 開著的儲存庫也要關:每一個都帶著常駐的 git 子程序 + # Close the repository too: each keeps long-lived git child processes + self._close_repositories() + super().closeEvent(event) + + def _close_repositories(self) -> None: + """關掉面板持有的儲存庫 / Close the repositories this panel holds.""" + repo, self.current_repo = self.current_repo, None + if repo is not None: + repo.close() + service, self._git_service = self._git_service, None + if service is not None: + service.close() + + # ---------- Stash and conflicts ---------- + # ---------- 暫存修改與衝突 ---------- + + def _service(self) -> GitService | None: + """ + 取得綁在目前儲存庫上的 Git 服務 + The git service bound to the repository currently open. + + stash 與衝突處理走 ``GitService``,它已經有稽核紀錄與測試;這個面板其餘的 + 操作是直接呼叫 GitPython 的舊寫法,沒有必要為了新功能再複製一次。 + Stashing and conflicts go through ``GitService``, which already has audit + logging and tests; the rest of this panel calls GitPython directly, and + there is no reason to copy that again for something new. + + :return: 服務,沒有開啟儲存庫時為 ``None`` / the service, or ``None`` + """ + if self.current_repo is None: + return None + if self._git_service is None: + self._git_service = GitService() + self._git_service.open_repo(self.current_repo.working_tree_dir) + return self._git_service + + def on_stash_changes(self) -> None: + """ + 把目前的修改收進 stash + Put the current changes away in a stash. + """ + service = self._service() + if service is None: + return + message = self.commit_message_input.text().strip() + try: + service.stash_save(message) + except GitCommandError as error: + QMessageBox.critical(self, "Error", f"{_text('err_stash')}:\n{error}") + return + self.commit_message_input.clear() + self._refresh_change_list() + + def on_pop_stash(self) -> None: + """ + 取回一個 stash + Take a stash back. + + 有好幾個時讓使用者挑,因為最上面那個未必是想要的那個。 + With more than one the user picks, since the top of the pile is not + necessarily the one they want. + """ + service = self._service() + if service is None: + return + stashes = service.stash_list() + if not stashes: + QMessageBox.information( + self, _text("btn_stash"), _text("info_no_stash")) + return + chosen, confirmed = QInputDialog.getItem( + self, _text("dialog_stash_pop_title"), _text("dialog_stash_pop_prompt"), + stashes, 0, False) + if not confirmed or not chosen: + return + try: + service.stash_pop(stashes.index(chosen)) + except GitCommandError as error: + QMessageBox.critical(self, "Error", f"{_text('err_stash_pop')}:\n{error}") + return + self._refresh_change_list() + + def on_resolve_conflict(self) -> None: + """ + 解決一個檔案的合併衝突 + Settle one file's merge conflict. + + 由使用者決定保留哪一邊;兩邊都要留的話得自己編輯,這裡不猜。 + The user decides which side stays; keeping parts of both means editing the + file, and this does not guess at that. + """ + service = self._service() + if service is None: + return + conflicts = service.conflicted_files() + if not conflicts: + QMessageBox.information( + self, _text("dialog_resolve_title"), _text("info_no_conflicts")) + return + chosen, confirmed = QInputDialog.getItem( + self, _text("dialog_resolve_title"), _text("dialog_resolve_file_prompt"), + conflicts, 0, False) + if not confirmed or not chosen: + return + keep, confirmed = QInputDialog.getItem( + self, _text("dialog_resolve_title"), + _text("dialog_resolve_side_prompt").format(file=chosen), + ["ours", "theirs"], 0, False) + if not confirmed or not keep: + return + if not service.resolve_conflict(chosen, keep): + QMessageBox.critical( + self, "Error", _text("err_resolve").format(file=chosen)) + return + self._refresh_change_list() + def on_unstage_all_changes(self) -> None: """ Unstage all changes. diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 1e6b0c2b..30b0bf7f 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -553,6 +553,13 @@ def closeEvent(self, event: QCloseEvent) -> None: widget = self.tab_widget.widget(i) if widget and isinstance(widget, EditorWidget): widget.close() + # 工具列的背景工作也要收掉:視窗銷毀時它們還在跑的話 Qt 會中止整個程序 + # The toolbar's background work goes too: Qt aborts the process if one of + # those is still running when the window is destroyed + from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import ( + stop_background_threads + ) + stop_background_threads() write_user_setting() write_user_color_setting() super().closeEvent(event) diff --git a/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py b/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py index 5011ce72..e7efea4e 100644 --- a/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py @@ -12,7 +12,9 @@ from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper from je_editor.utils.symbols.outline_tree import OutlineNode, build_outline_tree -from je_editor.utils.symbols.python_symbols import SymbolInfo, extract_python_symbols +from je_editor.utils.symbols.python_symbols import ( + SymbolInfo, extract_python_symbols, symbols_from_server +) # 樹狀項目中儲存符號行號的資料角色 / Item data role holding a symbol's line number _LINE_ROLE = Qt.ItemDataRole.UserRole @@ -35,6 +37,8 @@ def __init__(self, main_window=None) -> None: super().__init__() word = language_wrapper.language_word_dict self._main_window = main_window + # 目前正在聽哪個編輯器的語言伺服器 / Whose language server is being listened to + self._connected_client = None self.refresh_button = QPushButton(word.get("outline_panel_refresh")) self.refresh_button.clicked.connect(self.refresh) @@ -88,14 +92,66 @@ def refresh(self) -> None: language_wrapper.language_word_dict.get("outline_panel_no_editor")) return symbols = extract_python_symbols(code_edit.toPlainText()) - roots = build_outline_tree(symbols) - for node in roots: + if not symbols and self._ask_the_language_server(code_edit): + return + self._show_symbols(symbols) + + def _show_symbols(self, symbols: list[SymbolInfo]) -> None: + """把符號畫成大綱 / Draw the symbols as an outline.""" + self.tree.clear() + for node in build_outline_tree(symbols): self.tree.addTopLevelItem(self._build_item(node)) self.tree.expandAll() jeditor_logger.info(f"outline_panel_widget.py built outline of {len(symbols)} symbols") self.status_label.setText( language_wrapper.language_word_dict.get("outline_panel_found").format(count=len(symbols))) + def _ask_the_language_server(self, code_edit) -> bool: + """ + 向語言伺服器要這個檔案的符號 + Ask the language server for this file's symbols. + + 大綱原本只靠 ``ast`` 解析 Python,其他語言一律是空的。有語言伺服器的話它 + 知道同一件事,問它就好。 + The outline only ever parsed Python with ``ast``, leaving every other + language empty. A language server knows the same thing, so it is asked. + + :param code_edit: 目前的編輯器 / the current editor + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + client = getattr(code_edit, "lsp_client", None) + if client is None or not client.running: + return False + if client is not self._connected_client: + self._disconnect_client() + client.symbols_ready.connect(self._on_server_symbols) + self._connected_client = client + if not code_edit.request_document_symbols(): + return False + self.status_label.setText( + language_wrapper.language_word_dict.get("outline_panel_ready")) + return True + + def _disconnect_client(self) -> None: + """不再聽上一個編輯器的回覆 / Stop listening to the previous editor's replies.""" + client, self._connected_client = self._connected_client, None + if client is None: + return + try: + client.symbols_ready.disconnect(self._on_server_symbols) + except (RuntimeError, TypeError): + # 它已經跟著分頁一起消失了 / It went away with its tab + return + + def _on_server_symbols(self, symbols: list) -> None: + """ + 把語言伺服器回報的符號畫成大綱 + Draw the symbols a language server reported. + + :param symbols: ``{"name", "kind", "line", "depth"}`` 的清單 / the symbols + """ + self._show_symbols(symbols_from_server(symbols)) + def _build_item(self, node: OutlineNode) -> QTreeWidgetItem: """把大綱節點轉成樹狀項目 / Turn an outline node into a tree item.""" symbol = node.symbol diff --git a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py index 1f9e8e95..0d1dc512 100644 --- a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py +++ b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py @@ -277,6 +277,10 @@ def _run_in_background(worker: QObject, thread_parent: QObject) -> QThread: """通用的背景執行緒啟動器 / Generic background thread runner""" global _bg_threads thread = QThread(thread_parent) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + thread.setObjectName(f"Toolbar{type(worker).__name__}") worker.moveToThread(thread) thread.started.connect(worker.run) worker.finished.connect(thread.quit) @@ -289,6 +293,37 @@ def _run_in_background(worker: QObject, thread_parent: QObject) -> QThread: return thread +def stop_background_threads() -> int: + """ + 等所有工具列的背景執行緒結束 + Wait for every toolbar background thread to finish. + + 這些執行緒掛在主視窗底下,視窗被銷毀時若還在跑,Qt 會直接讓程序中止 + (``QThread: Destroyed while thread is still running``)。git 分支掃描在大的 + 或放在網路磁碟上的儲存庫要跑上一段時間,關閉時剛好還沒跑完並不罕見。 + They hang off the main window, and Qt aborts the process outright + (``QThread: Destroyed while thread is still running``) if one is still going + when the window is destroyed. Scanning git branches takes a while on a large + repository or one on a network share, so still running at closing time is not + unusual. + + :return: 等了幾條執行緒 / how many threads were waited for + """ + global _bg_threads + threads, _bg_threads = _bg_threads, [] + waited = 0 + for thread in threads: + try: + if thread.isRunning(): + thread.quit() + thread.wait() + waited += 1 + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + continue + return waited + + def _git_refresh_branches(main_window: EditorMain) -> None: """重新載入 Git 分支清單 (背景執行) / Refresh git branch list in background""" combo: QComboBox = main_window.toolbar_branch_combo diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 12947a26..180c7f8d 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -204,6 +204,24 @@ "placeholder_commit_message": "Commit message...", "btn_stage_all": "Stage All", "btn_commit": "Commit", + "btn_checkout": "Checkout", + "btn_stage_selected": "Stage Selected", + "btn_unstage_selected": "Unstage Selected", + "btn_unstage_all": "Unstage All", + "btn_track_all_untracked": "Track All Untracked", + "btn_stash": "Stash", + "btn_stash_pop": "Pop Stash", + "btn_resolve_conflict": "Resolve Conflict", + "dialog_stash_pop_title": "Pop Stash", + "dialog_stash_pop_prompt": "Take back:", + "info_no_stash": "There is nothing stashed.", + "err_stash": "Could not stash", + "err_stash_pop": "Could not pop the stash", + "dialog_resolve_title": "Resolve Conflict", + "dialog_resolve_file_prompt": "File:", + "dialog_resolve_side_prompt": "Keep which side of {file}?", + "info_no_conflicts": "Nothing is in conflict.", + "err_resolve": "Could not resolve {file}", "label_message": "Message:", "dialog_choose_repo": "Choose Git Repo", "err_open_repo": "Failed to open repository", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 4b41fb8a..801ed0de 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -195,6 +195,24 @@ "placeholder_commit_message": "提交訊息...", "btn_stage_all": "暫存全部", "btn_commit": "提交", + "btn_checkout": "切換", + "btn_stage_selected": "暫存選取", + "btn_unstage_selected": "取消暫存選取", + "btn_unstage_all": "全部取消暫存", + "btn_track_all_untracked": "追蹤所有未追蹤檔案", + "btn_stash": "收起修改", + "btn_stash_pop": "取回修改", + "btn_resolve_conflict": "解決衝突", + "dialog_stash_pop_title": "取回修改", + "dialog_stash_pop_prompt": "要取回哪一筆:", + "info_no_stash": "目前沒有收起任何修改。", + "err_stash": "無法收起修改", + "err_stash_pop": "無法取回修改", + "dialog_resolve_title": "解決衝突", + "dialog_resolve_file_prompt": "檔案:", + "dialog_resolve_side_prompt": "{file} 要保留哪一邊?", + "info_no_conflicts": "目前沒有衝突。", + "err_resolve": "無法解決 {file}", "label_message": "訊息:", "dialog_choose_repo": "選擇 Git 儲存庫", "err_open_repo": "開啟儲存庫失敗", diff --git a/je_editor/utils/symbols/python_symbols.py b/je_editor/utils/symbols/python_symbols.py index 81594765..59332bec 100644 --- a/je_editor/utils/symbols/python_symbols.py +++ b/je_editor/utils/symbols/python_symbols.py @@ -19,6 +19,15 @@ # 限定名稱的分隔字元 / Separator used in qualified names QUALIFIED_SEPARATOR = "." +# LSP 的 SymbolKind 編號對應的種類名稱;只列大綱看得到的那幾種 +# The kind name for each LSP SymbolKind number, for the ones an outline shows +SERVER_SYMBOL_KINDS: dict[int, str] = { + 5: CLASS_KIND, 6: METHOD_KIND, 9: METHOD_KIND, 11: CLASS_KIND, + 12: FUNCTION_KIND, 13: VARIABLE_KIND, 14: VARIABLE_KIND, 23: CLASS_KIND, +} +# 認不得的種類 / What an unrecognised kind is called +SERVER_SYMBOL_FALLBACK = VARIABLE_KIND + @dataclass(frozen=True) class SymbolInfo: @@ -38,6 +47,39 @@ class SymbolInfo: qualified_name: str +def symbols_from_server(entries: list) -> list[SymbolInfo]: + """ + 把語言伺服器回報的符號轉成大綱用的形式 + Turn the symbols a language server reported into what the outline draws. + + 伺服器給的是「名稱、種類、行號、巢狀深度」,大綱靠限定名稱決定巢狀關係,因此 + 這裡依深度維護一個外層名稱的堆疊,把限定名稱組回來。 + A server gives a name, a kind, a line and a nesting depth, while the outline + nests by qualified name, so this keeps a stack of enclosing names by depth and + rebuilds the qualified name from it. + + :param entries: ``{"name", "kind", "line", "depth"}`` 的清單 / the reported symbols + :return: 大綱用的符號 / the symbols the outline draws + """ + symbols: list[SymbolInfo] = [] + enclosing: list[str] = [] + for entry in entries or []: + name = str(entry.get("name", "")).strip() + if not name: + continue + depth = max(0, int(entry.get("depth", 0) or 0)) + del enclosing[depth:] + qualified = QUALIFIED_SEPARATOR.join([*enclosing, name]) + enclosing.append(name) + symbols.append(SymbolInfo( + name=name, + kind=SERVER_SYMBOL_KINDS.get(entry.get("kind"), SERVER_SYMBOL_FALLBACK), + line=max(1, int(entry.get("line", 1) or 1)), + qualified_name=qualified, + )) + return symbols + + class _SymbolCollector(ast.NodeVisitor): """走訪語法樹並收集符號 / Walk the AST and collect symbols.""" diff --git a/test/test_git_panel_stash.py b/test/test_git_panel_stash.py new file mode 100644 index 00000000..fb496250 --- /dev/null +++ b/test/test_git_panel_stash.py @@ -0,0 +1,205 @@ +"""Tests that the git panel reaches the stash and conflict operations.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +git = pytest.importorskip("git") + + +@pytest.fixture() +def repo(tmp_path): + """A throw-away repository with one committed file.""" + repository = git.Repo.init(tmp_path, initial_branch="main") + with repository.config_writer() as config: + config.set_value("user", "name", "Panel Tester") + config.set_value("user", "email", "panel@example.com") + tracked = tmp_path / "tracked.txt" + tracked.write_text("first\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("initial") + repository.close() + return tmp_path + + +@pytest.fixture() +def panel(qapp, qtbot, repo): + """The git panel with a repository open.""" + from je_editor.pyside_ui.git_ui.git_client.git_client_gui import GitGui + with patch.object(GitGui, "_restore_last_opened_repository", lambda self: None): + widget = GitGui() + qtbot.addWidget(widget) + widget._load_repository_from_path(repo) + yield widget, repo + widget.close() + + +class TestTheLabelsAreTranslated: + """ + The panel's strings were hard-coded English while the translations for them + sat unused in the language files. + """ + + def test_a_button_shows_the_translated_text(self, panel): + from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + widget, _repo = panel + assert widget.commit_button.text() == \ + language_wrapper.language_word_dict.get("btn_commit") + + def test_the_new_buttons_are_translated_too(self, panel): + from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + widget, _repo = panel + assert widget.stash_button.text() == \ + language_wrapper.language_word_dict.get("btn_stash") + + def test_a_missing_key_falls_back_to_itself(self): + from je_editor.pyside_ui.git_ui.git_client.git_client_gui import _text + assert _text("no_such_key") == "no_such_key" + + +class TestTheButtonsExist: + """ + The operations had tests but no way to reach them: GitService had no UI + consumer at all, since the panel talks to GitPython directly. + """ + + def test_stashing_is_offered(self, panel): + widget, _repo = panel + assert widget.stash_button.isEnabled() is True + + def test_popping_is_offered(self, panel): + widget, _repo = panel + assert widget.stash_pop_button.isEnabled() is True + + def test_resolving_is_offered(self, panel): + widget, _repo = panel + assert widget.resolve_button.isEnabled() is True + + def test_they_are_disabled_without_a_repository(self, qapp, qtbot): + from je_editor.pyside_ui.git_ui.git_client.git_client_gui import GitGui + with patch.object(GitGui, "_restore_last_opened_repository", lambda self: None): + widget = GitGui() + qtbot.addWidget(widget) + assert widget.stash_button.isEnabled() is False + widget.close() + + +class TestStashingFromThePanel: + def test_stashing_puts_the_change_away(self, panel): + widget, repo = panel + (repo / "tracked.txt").write_text("changed\n", encoding="utf-8") + widget.on_stash_changes() + assert (repo / "tracked.txt").read_text(encoding="utf-8") == "first\n" + + def test_the_message_box_is_used_as_the_stash_name(self, panel): + widget, repo = panel + (repo / "tracked.txt").write_text("changed\n", encoding="utf-8") + widget.commit_message_input.setText("half done") + widget.on_stash_changes() + assert any("half done" in line for line in widget._service().stash_list()) + + def test_the_message_is_cleared_afterwards(self, panel): + widget, repo = panel + (repo / "tracked.txt").write_text("changed\n", encoding="utf-8") + widget.commit_message_input.setText("half done") + widget.on_stash_changes() + assert widget.commit_message_input.text() == "" + + def test_popping_brings_it_back(self, panel): + widget, repo = panel + (repo / "tracked.txt").write_text("changed\n", encoding="utf-8") + widget.on_stash_changes() + with patch( + "je_editor.pyside_ui.git_ui.git_client.git_client_gui.QInputDialog.getItem", + side_effect=lambda *args, **kwargs: (args[3][0], True), + ): + widget.on_pop_stash() + assert (repo / "tracked.txt").read_text(encoding="utf-8") == "changed\n" + + def test_popping_with_nothing_stashed_says_so(self, panel): + widget, _repo = panel + with patch( + "je_editor.pyside_ui.git_ui.git_client.git_client_gui.QMessageBox.information" + ) as told: + widget.on_pop_stash() + assert told.called + + def test_a_service_is_only_built_once(self, panel): + widget, _repo = panel + assert widget._service() is widget._service() + + def test_no_repository_means_no_service(self, qapp, qtbot): + from je_editor.pyside_ui.git_ui.git_client.git_client_gui import GitGui + with patch.object(GitGui, "_restore_last_opened_repository", lambda self: None): + widget = GitGui() + qtbot.addWidget(widget) + assert widget._service() is None + widget.close() + + +class TestResolvingFromThePanel: + @staticmethod + def _conflict(repo): + repository = git.Repo(repo) + repository.git.checkout("-b", "other") + (repo / "tracked.txt").write_text("theirs\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("theirs") + repository.git.checkout("main") + (repo / "tracked.txt").write_text("ours\n", encoding="utf-8") + repository.index.add(["tracked.txt"]) + repository.index.commit("ours") + try: + repository.git.merge("other") + except git.GitCommandError: + pass + repository.close() + + def test_nothing_in_conflict_says_so(self, panel): + widget, _repo = panel + with patch( + "je_editor.pyside_ui.git_ui.git_client.git_client_gui.QMessageBox.information" + ) as told: + widget.on_resolve_conflict() + assert told.called + + def test_keeping_one_side_settles_it(self, panel): + widget, repo = panel + self._conflict(repo) + answers = iter([("tracked.txt", True), ("theirs", True)]) + with patch( + "je_editor.pyside_ui.git_ui.git_client.git_client_gui.QInputDialog.getItem", + side_effect=lambda *args, **kwargs: next(answers), + ): + widget.on_resolve_conflict() + assert (repo / "tracked.txt").read_text(encoding="utf-8") == "theirs\n" + + def test_cancelling_leaves_the_conflict(self, panel): + widget, repo = panel + self._conflict(repo) + with patch( + "je_editor.pyside_ui.git_ui.git_client.git_client_gui.QInputDialog.getItem", + return_value=("tracked.txt", False), + ): + widget.on_resolve_conflict() + assert widget._service().conflicted_files() == ["tracked.txt"] + + +class TestClosingThePanel: + def test_background_work_is_waited_for(self, panel): + # The panel's threads hang off it, and Qt aborts the process if one + # outlives the widget it belongs to. + widget, _repo = panel + widget._bg_threads = [(MagicMock(isRunning=MagicMock(return_value=True)), MagicMock())] + thread = widget._bg_threads[0][0] + widget.close() + thread.wait.assert_called_once() + + def test_a_thread_already_gone_is_tolerated(self, panel): + widget, _repo = panel + dead = MagicMock() + dead.isRunning.side_effect = RuntimeError("already deleted") + widget._bg_threads = [(dead, MagicMock())] + widget.close() + assert widget._bg_threads == [] diff --git a/test/test_git_stash_conflicts.py b/test/test_git_stash_conflicts.py index c7bb5094..9991fa36 100644 --- a/test/test_git_stash_conflicts.py +++ b/test/test_git_stash_conflicts.py @@ -23,7 +23,7 @@ def service(tmp_path): instance = GitService() instance.open_repo(str(tmp_path)) yield instance, tmp_path - instance.repo.close() + instance.close() class TestStashing: @@ -121,6 +121,32 @@ def test_resolving_a_file_that_is_not_in_conflict_reports_failure(self, service) assert instance.resolve_conflict("tracked.txt", "ours") is False +class TestClosing: + """ + Every open Repo keeps long-lived git child processes, and dozens of them + slow the whole process down -- including starting an unrelated thread. + """ + + def test_closing_lets_go_of_the_repository(self, service): + instance, _root = service + instance.close() + assert instance.repo is None + + def test_closing_twice_is_safe(self, service): + instance, _root = service + instance.close() + instance.close() + assert instance.repo is None + + def test_opening_another_closes_the_first(self, service, tmp_path_factory): + instance, _root = service + first = instance.repo + another = tmp_path_factory.mktemp("second") + git.Repo.init(another).close() + instance.open_repo(str(another)) + assert instance.repo is not first + + class TestWithoutARepository: def test_stashing_needs_an_open_repository(self): unopened = GitService() diff --git a/test/test_lint_manager.py b/test/test_lint_manager.py index fc1df4c1..de755b59 100644 --- a/test/test_lint_manager.py +++ b/test/test_lint_manager.py @@ -63,6 +63,16 @@ def test_no_lint_worker_is_left_running(self, editor): editor.close() assert editor.lint_manager._worker is None + def test_the_completion_thread_is_waited_for(self, editor): + # It is the editor's one thread without a manager, so closeEvent has to + # wait for it itself; Qt aborts the process if it outlives the editor. + editor.close() + assert editor._complete_thread is None + + def test_waiting_for_a_completion_thread_that_never_started_is_safe(self, editor): + editor.stop_completion_thread() + assert editor._complete_thread is None + class TestLintManagerState: def test_starts_empty(self, editor): diff --git a/test/test_symbols.py b/test/test_symbols.py index 32347e75..90a7e3c6 100644 --- a/test/test_symbols.py +++ b/test/test_symbols.py @@ -1,6 +1,7 @@ """Tests for Python symbol extraction and the go-to-symbol picker.""" from __future__ import annotations + import textwrap import pytest @@ -9,6 +10,7 @@ build_symbol_entries, make_symbol_jumper, ) +from je_editor.utils.symbols.outline_tree import build_outline_tree from je_editor.utils.symbols.python_symbols import ( CLASS_KIND, FUNCTION_KIND, @@ -16,9 +18,60 @@ VARIABLE_KIND, SymbolInfo, extract_python_symbols, + symbols_from_server, ) +class TestSymbolsFromServer: + """ + The outline only ever parsed Python, so every other language showed nothing. + A language server reports the same thing for the languages it handles. + """ + + def test_a_symbol_keeps_its_name_and_line(self): + symbols = symbols_from_server([{"name": "main", "kind": 12, "line": 4, "depth": 0}]) + assert (symbols[0].name, symbols[0].line) == ("main", 4) + + def test_a_known_kind_is_named(self): + symbols = symbols_from_server([{"name": "Thing", "kind": 5, "line": 1, "depth": 0}]) + assert symbols[0].kind == "class" + + def test_an_unknown_kind_falls_back(self): + symbols = symbols_from_server([{"name": "x", "kind": 99, "line": 1, "depth": 0}]) + assert symbols[0].kind == "variable" + + def test_nesting_becomes_a_qualified_name(self): + symbols = symbols_from_server([ + {"name": "Thing", "kind": 5, "line": 1, "depth": 0}, + {"name": "method", "kind": 6, "line": 2, "depth": 1}, + ]) + assert symbols[1].qualified_name == "Thing.method" + + def test_returning_to_the_top_level_drops_the_enclosing_name(self): + symbols = symbols_from_server([ + {"name": "Thing", "kind": 5, "line": 1, "depth": 0}, + {"name": "method", "kind": 6, "line": 2, "depth": 1}, + {"name": "helper", "kind": 12, "line": 9, "depth": 0}, + ]) + assert symbols[2].qualified_name == "helper" + + def test_the_outline_nests_them(self): + roots = build_outline_tree(symbols_from_server([ + {"name": "Thing", "kind": 5, "line": 1, "depth": 0}, + {"name": "method", "kind": 6, "line": 2, "depth": 1}, + ])) + assert len(roots) == 1 and roots[0].children[0].symbol.name == "method" + + def test_an_entry_without_a_name_is_skipped(self): + assert symbols_from_server([{"kind": 12, "line": 1, "depth": 0}]) == [] + + def test_nothing_reported_gives_nothing(self): + assert symbols_from_server([]) == [] + + def test_a_line_below_one_is_clamped(self): + assert symbols_from_server([{"name": "x", "line": 0, "depth": 0}])[0].line == 1 + + def _symbols(source: str) -> list[SymbolInfo]: return extract_python_symbols(textwrap.dedent(source)) diff --git a/test/test_toolbar_actions.py b/test/test_toolbar_actions.py index 8e1858f9..12c9654f 100644 --- a/test/test_toolbar_actions.py +++ b/test/test_toolbar_actions.py @@ -60,6 +60,22 @@ def test_new_actions_are_on_the_toolbar(self, toolbar_window): for attribute, _shortcut in self.PICKER_ACTIONS: assert getattr(toolbar_window, attribute) in toolbar_actions + def test_background_work_can_be_waited_for(self, toolbar_window): + # Building the toolbar starts a git scan. Qt aborts the process if one is + # still running when the window it hangs off is destroyed, and scanning a + # large repository is not quick. + from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import ( + stop_background_threads + ) + assert stop_background_threads() >= 0 + + def test_waiting_twice_is_safe(self, toolbar_window): + from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import ( + stop_background_threads + ) + stop_background_threads() + assert stop_background_threads() == 0 + def test_every_toolbar_shortcut_is_reserved(self, toolbar_window): # The editor checks its own shortcuts against this table, so a sequence # the toolbar takes without listing it there could be claimed twice.