From 201665f76d8c8c760e7e5d5bab76f3bf181589ca Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 17:09:36 +0800 Subject: [PATCH 1/3] Wait for the last two background threads, and fill the outline from the server Reviewing what went in turned up three gaps. The editor waited for its lint, baseline and blame workers on close but not for the jedi completion thread, and the toolbar''s git-branch scan was never waited for at all -- both abort the process outright if they outlive what they hang off, and scanning branches on a large or network-mounted repository is not quick. Both are waited for now, and the remaining unnamed threads carry names so Qt''s abort message says which one. Document symbols were requested from the language server and parsed, but nothing connected the reply. The outline panel only ever parsed Python with ast, leaving every other language blank, so it now asks the server when ast finds nothing and nests what comes back by the depth the server reports. --- je_editor/git_client/git_action.py | 4 ++ .../code_edit_plaintext.py | 27 ++++++++ .../dialog/search_ui/search_replace_widget.py | 4 ++ je_editor/pyside_ui/main_ui/main_editor.py | 7 +++ .../outline_panel/outline_panel_widget.py | 62 ++++++++++++++++++- .../main_ui/toolbar/toolbar_builder.py | 35 +++++++++++ je_editor/utils/symbols/python_symbols.py | 42 +++++++++++++ test/test_lint_manager.py | 10 +++ test/test_symbols.py | 53 ++++++++++++++++ test/test_toolbar_actions.py | 16 +++++ 10 files changed, 257 insertions(+), 3 deletions(-) diff --git a/je_editor/git_client/git_action.py b/je_editor/git_client/git_action.py index 5d897e55..afb5020e 100644 --- a/je_editor/git_client/git_action.py +++ b/je_editor/git_client/git_action.py @@ -263,6 +263,10 @@ class GitWorker(QThread): def __init__(self, fn: Callable, *args: Any, **kwargs: Any) -> 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("GitWorker") self.fn = fn self.args = args self.kwargs = kwargs 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/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/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_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. From ecebdd54d8b1a94c171c97c6bfa9457113ddf838 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 17:53:09 +0800 Subject: [PATCH 2/3] Give the stash and conflict operations a way in, and close repositories GitService had no UI consumer at all -- the git panel talks to GitPython directly -- so the stash and conflict work landed in a module nothing called. The panel now reaches those through the service rather than growing a third copy of the same logic, with buttons for stashing, popping and resolving. Wiring them up surfaced a leak: an open Repo keeps long-lived git child processes, and GitService never closed one. It showed as an unrelated test timing out waiting for a thread to start -- dozens of stray child processes make even that slow. The service closes its repository, closes the previous one when opening another, and the panel closes both when it goes away, along with waiting for its own background threads. --- je_editor/git_client/git_action.py | 18 ++ .../git_ui/git_client/git_client_gui.py | 157 ++++++++++++++- test/test_git_panel_stash.py | 182 ++++++++++++++++++ test/test_git_stash_conflicts.py | 28 ++- 4 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 test/test_git_panel_stash.py diff --git a/je_editor/git_client/git_action.py b/je_editor/git_client/git_action.py index afb5020e..5fe1f596 100644 --- a/je_editor/git_client/git_action.py +++ b/je_editor/git_client/git_action.py @@ -35,7 +35,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 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..bcaca559 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,6 +10,7 @@ ) 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 # UI 常數 / UI constants @@ -49,6 +50,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 +63,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) @@ -134,6 +142,10 @@ def _init_ui(self) -> None: self.unstage_all_button = QPushButton("Unstage All") self.track_all_untracked_button = QPushButton("Track All Untracked") self.git_push_button = QPushButton("Push") + # 收起手邊的修改與解決合併衝突 / Putting work down, and settling a merge + self.stash_button = QPushButton("Stash") + self.stash_pop_button = QPushButton("Pop Stash") + self.resolve_button = QPushButton("Resolve Conflict") bottom = QHBoxLayout() bottom.addWidget(QLabel("Message:")) @@ -145,6 +157,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 +183,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 +238,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 +663,143 @@ 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"Could not 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, "Stash", "There is nothing stashed.") + return + chosen, confirmed = QInputDialog.getItem( + self, "Pop Stash", "Take back:", 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"Could not pop the stash:\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, "Conflicts", "Nothing is in conflict.") + return + chosen, confirmed = QInputDialog.getItem( + self, "Resolve Conflict", "File:", conflicts, 0, False) + if not confirmed or not chosen: + return + keep, confirmed = QInputDialog.getItem( + self, "Resolve Conflict", f"Keep which side of {chosen}?", + ["ours", "theirs"], 0, False) + if not confirmed or not keep: + return + if not service.resolve_conflict(chosen, keep): + QMessageBox.critical(self, "Error", f"Could not resolve {chosen}.") + return + self._refresh_change_list() + def on_unstage_all_changes(self) -> None: """ Unstage all changes. diff --git a/test/test_git_panel_stash.py b/test/test_git_panel_stash.py new file mode 100644 index 00000000..2306d681 --- /dev/null +++ b/test/test_git_panel_stash.py @@ -0,0 +1,182 @@ +"""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 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() From 4a490335755025de5a7eb9cc992fc4c6f3605438 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 18:12:00 +0800 Subject: [PATCH 3/3] Translate the git panel, and drop a worker nothing used Reviewing the new code again turned up two things. GitWorker was never instantiated anywhere: the panel has a worker of its own and the service layer is called directly. It is gone rather than left as a class that looks available but is not. The panel''s buttons and prompts were hard-coded English, while the language files have carried translations for exactly those strings all along -- the new stash and conflict buttons had followed the same pattern. They read from the dictionary now, which puts eighteen unused keys back to work and adds the ones the new buttons and dialogs need. --- je_editor/git_client/git_action.py | 28 ------- .../git_ui/git_client/git_client_gui.py | 78 +++++++++++++------ je_editor/utils/multi_language/english.py | 18 +++++ .../multi_language/traditional_chinese.py | 18 +++++ test/test_git_panel_stash.py | 23 ++++++ 5 files changed, 114 insertions(+), 51 deletions(-) diff --git a/je_editor/git_client/git_action.py b/je_editor/git_client/git_action.py index 5fe1f596..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 @@ -269,29 +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__() - # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 - # A named thread, so Qt's abort message says which one if it is ever - # destroyed while still running - self.setObjectName("GitWorker") - 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/git_ui/git_client/git_client_gui.py b/je_editor/pyside_ui/git_ui/git_client/git_client_gui.py index bcaca559..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 @@ -12,12 +12,38 @@ 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 @@ -88,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) @@ -134,21 +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 = QPushButton("Stash") - self.stash_pop_button = QPushButton("Pop Stash") - self.resolve_button = QPushButton("Resolve Conflict") + 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) @@ -738,7 +764,7 @@ def on_stash_changes(self) -> None: try: service.stash_save(message) except GitCommandError as error: - QMessageBox.critical(self, "Error", f"Could not stash:\n{error}") + QMessageBox.critical(self, "Error", f"{_text('err_stash')}:\n{error}") return self.commit_message_input.clear() self._refresh_change_list() @@ -757,16 +783,18 @@ def on_pop_stash(self) -> None: return stashes = service.stash_list() if not stashes: - QMessageBox.information(self, "Stash", "There is nothing stashed.") + QMessageBox.information( + self, _text("btn_stash"), _text("info_no_stash")) return chosen, confirmed = QInputDialog.getItem( - self, "Pop Stash", "Take back:", stashes, 0, False) + 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"Could not pop the stash:\n{error}") + QMessageBox.critical(self, "Error", f"{_text('err_stash_pop')}:\n{error}") return self._refresh_change_list() @@ -784,19 +812,23 @@ def on_resolve_conflict(self) -> None: return conflicts = service.conflicted_files() if not conflicts: - QMessageBox.information(self, "Conflicts", "Nothing is in conflict.") + QMessageBox.information( + self, _text("dialog_resolve_title"), _text("info_no_conflicts")) return chosen, confirmed = QInputDialog.getItem( - self, "Resolve Conflict", "File:", conflicts, 0, False) + 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, "Resolve Conflict", f"Keep which side of {chosen}?", + 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", f"Could not resolve {chosen}.") + QMessageBox.critical( + self, "Error", _text("err_resolve").format(file=chosen)) return self._refresh_change_list() 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/test/test_git_panel_stash.py b/test/test_git_panel_stash.py index 2306d681..fb496250 100644 --- a/test/test_git_panel_stash.py +++ b/test/test_git_panel_stash.py @@ -35,6 +35,29 @@ def panel(qapp, qtbot, 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