From 17ce4dfbd740a309ec9cc904286032c2f4c45f81 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 16:52:57 +0800 Subject: [PATCH 01/36] Read a file committed content and diff it line by line Groundwork for showing in the editor what changed since the last commit. line_status compares a buffer against a baseline with difflib and reports which lines were added, modified, or sit just below a deletion, plus the navigation helpers for stepping between them. It is pure logic: no Qt, no git, no I/O, and it gives up on very large buffers rather than stalling the edit it is meant to annotate. file_baseline reads the HEAD version of a path through GitPython, finding the repository from the file itself. Line endings are normalised to \n so the comparison is unaffected by how the repository stores them; a file outside a repository, never committed, or not utf-8 text simply has no baseline. --- je_editor/git_client/file_baseline.py | 65 ++++++++++++ je_editor/utils/file_diff/__init__.py | 0 je_editor/utils/file_diff/line_status.py | 124 +++++++++++++++++++++++ test/test_file_baseline.py | 82 +++++++++++++++ test/test_line_status.py | 108 ++++++++++++++++++++ 5 files changed, 379 insertions(+) create mode 100644 je_editor/git_client/file_baseline.py create mode 100644 je_editor/utils/file_diff/__init__.py create mode 100644 je_editor/utils/file_diff/line_status.py create mode 100644 test/test_file_baseline.py create mode 100644 test/test_line_status.py diff --git a/je_editor/git_client/file_baseline.py b/je_editor/git_client/file_baseline.py new file mode 100644 index 00000000..3526d2e8 --- /dev/null +++ b/je_editor/git_client/file_baseline.py @@ -0,0 +1,65 @@ +""" +讀取檔案在 HEAD 的內容,作為變更標記的比較基準 +Read a file's committed content, to serve as the baseline for change markers. + +編輯器需要知道「上次提交時這個檔案長什麼樣」才能標出改動。這裡只做讀取, +不執行任何會改動工作區的 git 指令。 +The editor needs to know what a file looked like at the last commit before it +can mark what changed. This only reads; it never runs a git command that +touches the working tree. +""" +from __future__ import annotations + +from pathlib import Path + +from git import InvalidGitRepositoryError, NoSuchPathError, Repo +from git.exc import GitError + +from je_editor.utils.logging.loggin_instance import jeditor_logger + + +def open_repository(file_path: str | Path) -> Repo | None: + """ + 找出包含此檔案的 git 儲存庫 + Find the git repository that contains *file_path*. + + :param file_path: 檔案路徑 / the file to locate + :return: 儲存庫,不在儲存庫內時為 ``None`` / the repository, or ``None`` + """ + try: + return Repo(Path(file_path).parent, search_parent_directories=True) + except (InvalidGitRepositoryError, NoSuchPathError, ValueError, OSError): + # 不在 git 儲存庫內是常態,不是錯誤 / Not being in a repo is normal + return None + + +def baseline_text(file_path: str | Path) -> str | None: + """ + 取得檔案在 HEAD 的文字內容(行尾統一為 ``\\n``) + Return the file's content as of HEAD, with line endings normalised to ``\\n``. + + 編輯器的文件一律以 ``\\n`` 表示換行,基準也統一成同樣形式,比對才不會受 + 儲存庫的行尾設定(例如 Windows 的 ``core.autocrlf``)影響。 + A Qt document always uses ``\\n``, so the baseline is normalised to match and + the comparison is unaffected by how the repository stores line endings (for + example ``core.autocrlf`` on Windows). + + :param file_path: 檔案路徑 / the file to read + :return: HEAD 版本的文字;不在儲存庫、尚未提交過、或不是文字檔時為 ``None`` + the committed text, or ``None`` when the file is not in a repository, + has never been committed, or is not text + """ + repo = open_repository(file_path) + if repo is None: + return None + try: + relative = Path(file_path).resolve().relative_to(Path(repo.working_tree_dir).resolve()) + blob = repo.head.commit.tree / relative.as_posix() + text = blob.data_stream.read().decode("utf-8") + return text.replace("\r\n", "\n").replace("\r", "\n") + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): + # 新檔案、尚無提交、或路徑不在工作區內 / New file, no commit yet, or outside the tree + return None + except UnicodeDecodeError: + jeditor_logger.debug("file_baseline: %s is not utf-8 text", file_path) + return None diff --git a/je_editor/utils/file_diff/__init__.py b/je_editor/utils/file_diff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/file_diff/line_status.py b/je_editor/utils/file_diff/line_status.py new file mode 100644 index 00000000..12e69b12 --- /dev/null +++ b/je_editor/utils/file_diff/line_status.py @@ -0,0 +1,124 @@ +""" +逐行比較緩衝區與基準文字,算出每一行的變更狀態 +Work out, line by line, how a buffer differs from its committed baseline. + +編輯器用這份結果在行號區畫出變更標記,讓「這次改了哪些行」一眼可見。 +The editor paints the result in its gutter, so what changed since the last +commit is visible without leaving the file. + +這個模組是純邏輯:不碰 Qt、不碰 git、不做 I/O,因此可以單獨測試。 +Pure logic: no Qt, no git, no I/O, so it can be tested on its own. +""" +from __future__ import annotations + +from difflib import SequenceMatcher + +# 一行相對於基準的狀態 / How one line differs from the baseline +LINE_ADDED = "added" +LINE_MODIFIED = "modified" +# 這一行的「上方」刪掉了內容 / Lines were deleted just above this one +LINE_REMOVED_ABOVE = "removed" + +# 超過這個行數就不比較:標記只是輔助,不值得讓編輯卡住 +# Buffers longer than this are not diffed: the markers are a convenience and are +# not worth stalling the edit they are meant to annotate. +MAX_DIFFED_LINES = 20000 + + +def _split_lines(text: str) -> list[str]: + """ + 切成行;空字串視為零行,這樣新檔案的每一行都算新增 + Split into lines, treating an empty text as no lines so that every line of a + brand-new file counts as added. + """ + return text.splitlines() if text else [] + + +def _mark_removal(statuses: dict[int, str], line: int, line_count: int) -> None: + """ + 在刪除位置的下一行標記;已有狀態的行不覆蓋 + Mark the line that follows a deletion, without overwriting a line that + already carries a status of its own. + """ + if line_count <= 0: + return + index = min(line, line_count - 1) + statuses.setdefault(index, LINE_REMOVED_ABOVE) + + +def line_statuses(baseline: str, current: str) -> dict[int, str]: + """ + 比對基準與目前內容,回傳每行的狀態 + Compare the baseline with the current text and report each line's status. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :return: 以 0 起算的行號對應狀態,未變更的行不在其中 + 0-based line number -> status, with unchanged lines left out + """ + current_lines = _split_lines(current) + if len(current_lines) > MAX_DIFFED_LINES: + return {} + baseline_lines = _split_lines(baseline) + if len(baseline_lines) > MAX_DIFFED_LINES: + return {} + + statuses: dict[int, str] = {} + matcher = SequenceMatcher(None, baseline_lines, current_lines, autojunk=False) + for tag, _base_start, _base_end, start, end in matcher.get_opcodes(): + if tag == "replace": + statuses.update({line: LINE_MODIFIED for line in range(start, end)}) + elif tag == "insert": + statuses.update({line: LINE_ADDED for line in range(start, end)}) + elif tag == "delete": + _mark_removal(statuses, start, len(current_lines)) + return statuses + + +def changed_line_numbers(statuses: dict[int, str]) -> list[int]: + """ + 取得有變更的行號(由小到大) + Return the changed line numbers, in order. + + :param statuses: :func:`line_statuses` 的結果 / the statuses to read + :return: 排序後的行號 / the line numbers, ascending + """ + return sorted(statuses) + + +def next_changed_line(statuses: dict[int, str], line: int) -> int | None: + """ + 找出 *line* 之後的下一個變更行,找不到就從頭繞回 + Return the next changed line after *line*, wrapping around to the first. + + :param statuses: :func:`line_statuses` 的結果 / the statuses to read + :param line: 目前所在行(0 起算)/ the current 0-based line + :return: 目標行號,沒有任何變更時為 ``None`` + the line to jump to, or ``None`` when nothing changed + """ + changed = changed_line_numbers(statuses) + if not changed: + return None + for candidate in changed: + if candidate > line: + return candidate + return changed[0] + + +def previous_changed_line(statuses: dict[int, str], line: int) -> int | None: + """ + 找出 *line* 之前的上一個變更行,找不到就繞回最後一個 + Return the previous changed line before *line*, wrapping around to the last. + + :param statuses: :func:`line_statuses` 的結果 / the statuses to read + :param line: 目前所在行(0 起算)/ the current 0-based line + :return: 目標行號,沒有任何變更時為 ``None`` + the line to jump to, or ``None`` when nothing changed + """ + changed = changed_line_numbers(statuses) + if not changed: + return None + for candidate in reversed(changed): + if candidate < line: + return candidate + return changed[-1] diff --git a/test/test_file_baseline.py b/test/test_file_baseline.py new file mode 100644 index 00000000..bba8e4e4 --- /dev/null +++ b/test/test_file_baseline.py @@ -0,0 +1,82 @@ +"""Tests for reading a file's committed content as the diff baseline.""" +from __future__ import annotations + +import pytest + +from je_editor.git_client.file_baseline import baseline_text, open_repository + +git = pytest.importorskip("git") + + +@pytest.fixture() +def repo(tmp_path): + """A throw-away repository with one committed file.""" + repository = git.Repo.init(tmp_path) + with repository.config_writer() as config: + config.set_value("user", "name", "Test") + config.set_value("user", "email", "test@example.com") + tracked = tmp_path / "tracked.py" + tracked.write_text("first\nsecond\n", encoding="utf-8") + repository.index.add(["tracked.py"]) + repository.index.commit("initial") + yield repository, tmp_path + repository.close() + + +class TestOpenRepository: + def test_finds_the_repository_of_a_tracked_file(self, repo): + _repository, root = repo + assert open_repository(root / "tracked.py") is not None + + def test_finds_the_repository_from_a_subdirectory(self, repo): + _repository, root = repo + nested = root / "pkg" + nested.mkdir() + assert open_repository(nested / "module.py") is not None + + def test_path_outside_any_repository(self, tmp_path): + assert open_repository(tmp_path / "loose.py") is None + + +class TestBaselineText: + def test_committed_content_is_returned(self, repo): + _repository, root = repo + assert baseline_text(root / "tracked.py") == "first\nsecond\n" + + def test_working_tree_edits_do_not_change_the_baseline(self, repo): + _repository, root = repo + tracked = root / "tracked.py" + tracked.write_text("first\nCHANGED\n", encoding="utf-8") + assert baseline_text(tracked) == "first\nsecond\n" + + def test_uncommitted_file_has_no_baseline(self, repo): + _repository, root = repo + new_file = root / "untracked.py" + new_file.write_text("brand new\n", encoding="utf-8") + assert baseline_text(new_file) is None + + def test_file_outside_a_repository_has_no_baseline(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert baseline_text(loose) is None + + def test_missing_file_has_no_baseline(self, tmp_path): + assert baseline_text(tmp_path / "gone.py") is None + + def test_line_endings_are_normalised(self, repo): + # A repository may store CRLF (or be committed through a client that + # does); the editor's document always uses \n, so the baseline must too. + _repository, root = repo + crlf = root / "crlf.py" + crlf.write_bytes(b"first\r\nsecond\r\n") + _repository.index.add(["crlf.py"]) + _repository.index.commit("add crlf file") + assert baseline_text(crlf) == "first\nsecond\n" + + def test_binary_content_has_no_baseline(self, repo): + _repository, root = repo + blob = root / "logo.bin" + blob.write_bytes(b"\xff\xfe\x00\x01binary") + _repository.index.add(["logo.bin"]) + _repository.index.commit("add binary") + assert baseline_text(blob) is None diff --git a/test/test_line_status.py b/test/test_line_status.py new file mode 100644 index 00000000..25cc3773 --- /dev/null +++ b/test/test_line_status.py @@ -0,0 +1,108 @@ +"""Tests for the line-by-line diff behind the editor's git change markers.""" +from __future__ import annotations + +from je_editor.utils.file_diff.line_status import ( + LINE_ADDED, + LINE_MODIFIED, + LINE_REMOVED_ABOVE, + MAX_DIFFED_LINES, + changed_line_numbers, + line_statuses, + next_changed_line, + previous_changed_line, +) + + +class TestLineStatuses: + def test_identical_text_has_no_markers(self): + assert line_statuses("a\nb\nc\n", "a\nb\nc\n") == {} + + def test_appended_line_is_added(self): + assert line_statuses("a\nb\n", "a\nb\nc\n") == {2: LINE_ADDED} + + def test_inserted_line_is_added(self): + assert line_statuses("a\nc\n", "a\nb\nc\n") == {1: LINE_ADDED} + + def test_changed_line_is_modified(self): + assert line_statuses("a\nb\nc\n", "a\nB\nc\n") == {1: LINE_MODIFIED} + + def test_several_changed_lines_are_each_modified(self): + statuses = line_statuses("a\nb\nc\n", "a\nB\nC\n") + assert statuses == {1: LINE_MODIFIED, 2: LINE_MODIFIED} + + def test_deleted_line_marks_the_line_below(self): + assert line_statuses("a\nb\nc\n", "a\nc\n") == {1: LINE_REMOVED_ABOVE} + + def test_deletion_at_the_end_marks_the_last_line(self): + assert line_statuses("a\nb\nc\n", "a\nb\n") == {1: LINE_REMOVED_ABOVE} + + def test_new_file_marks_every_line_added(self): + assert line_statuses("", "a\nb\n") == {0: LINE_ADDED, 1: LINE_ADDED} + + def test_emptied_file_has_no_lines_to_mark(self): + assert line_statuses("a\nb\n", "") == {} + + def test_both_empty(self): + assert line_statuses("", "") == {} + + def test_a_real_status_is_not_overwritten_by_a_deletion_marker(self): + # "b" and "c" are gone and "d" is new: the new line keeps its own status. + statuses = line_statuses("a\nb\nc\n", "a\nd\n") + assert statuses[1] in {LINE_MODIFIED, LINE_ADDED} + + def test_no_trailing_newline_is_handled(self): + assert line_statuses("a\nb", "a\nB") == {1: LINE_MODIFIED} + + def test_leading_insertion(self): + assert line_statuses("b\n", "a\nb\n") == {0: LINE_ADDED} + + def test_indentation_only_change_counts_as_modified(self): + assert line_statuses("x = 1\n", " x = 1\n") == {0: LINE_MODIFIED} + + def test_oversized_buffer_is_skipped(self): + big = "\n".join(str(number) for number in range(MAX_DIFFED_LINES + 5)) + assert line_statuses("a\n", big) == {} + + def test_oversized_baseline_is_skipped(self): + big = "\n".join(str(number) for number in range(MAX_DIFFED_LINES + 5)) + assert line_statuses(big, "a\n") == {} + + +class TestChangedLineNumbers: + def test_sorted_ascending(self): + statuses = {5: LINE_ADDED, 1: LINE_MODIFIED, 3: LINE_ADDED} + assert changed_line_numbers(statuses) == [1, 3, 5] + + def test_empty(self): + assert changed_line_numbers({}) == [] + + +class TestChangeNavigation: + def _statuses(self): + return {2: LINE_ADDED, 5: LINE_MODIFIED, 9: LINE_ADDED} + + def test_next_from_before_the_first(self): + assert next_changed_line(self._statuses(), 0) == 2 + + def test_next_skips_the_current_line(self): + assert next_changed_line(self._statuses(), 2) == 5 + + def test_next_wraps_around(self): + assert next_changed_line(self._statuses(), 20) == 2 + + def test_previous_from_after_the_last(self): + assert previous_changed_line(self._statuses(), 20) == 9 + + def test_previous_skips_the_current_line(self): + assert previous_changed_line(self._statuses(), 5) == 2 + + def test_previous_wraps_around(self): + assert previous_changed_line(self._statuses(), 0) == 9 + + def test_no_changes_yields_none(self): + assert next_changed_line({}, 3) is None + assert previous_changed_line({}, 3) is None + + def test_single_change_returns_itself_when_wrapping(self): + assert next_changed_line({4: LINE_ADDED}, 4) == 4 + assert previous_changed_line({4: LINE_ADDED}, 4) == 4 From a8b2bb7a37c5a8161a1202a90034e7c35186404d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 16:53:13 +0800 Subject: [PATCH 02/36] Mark git changes in the editor gutter The gutter now shows how the open file differs from its last commit: a green bar for an added line, orange for a modified one, and a thin red line where lines were deleted. Ctrl+Alt+Down and Ctrl+Alt+Up step through the changes, wrapping around. The baseline is read on a background thread when a file is opened, since that is git and file I/O, and a read left in flight when the file changes is discarded rather than applied to the wrong buffer. Recomputing against that baseline is a pure in-memory diff, debounced so a burst of typing diffs once, skipped entirely when there is no baseline, and repainting only when the markers actually changed. --- README.md | 1 + je_editor/pyside_ui/code/git_diff/__init__.py | 0 .../code/git_diff/diff_marker_manager.py | 157 ++++++++++++++++ .../code_edit_plaintext.py | 124 ++++++++++++- .../pyside_ui/main_ui/editor/editor_widget.py | 5 + .../save_settings/user_color_setting_file.py | 8 +- test/test_diff_marker_manager.py | 173 ++++++++++++++++++ 7 files changed, 461 insertions(+), 7 deletions(-) create mode 100644 je_editor/pyside_ui/code/git_diff/__init__.py create mode 100644 je_editor/pyside_ui/code/git_diff/diff_marker_manager.py create mode 100644 test/test_diff_marker_manager.py diff --git a/README.md b/README.md index 929b87e0..81817bf7 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ The editor launches in a maximized window with a dark amber theme by default. - **Search & Replace** -- Search within the current file, across folders, or project-wide with regex and case-sensitive options. Runs in background threads for large projects. - **Code Folding** -- Collapse and expand indented blocks (functions, classes, loops) from the gutter fold triangles or the keyboard. Folding is indentation-based and only toggles line visibility -- it never modifies the text, so saving always writes the complete file. Folds self-heal after edits: a fold whose header no longer exists simply reopens instead of hiding the wrong lines. - **Bookmarks** -- Mark lines and jump between them with the keyboard, or click the gutter to toggle. Bookmarks are anchored to the text (via `QTextCursor`), so they follow their code when lines are inserted or removed above them instead of drifting. +- **Git Change Markers** -- The gutter shows how the file differs from its last commit: a green bar for added lines, orange for modified, and a thin red line where lines were deleted. Jump between changes with `Ctrl+Alt+Down` / `Ctrl+Alt+Up`. The committed version is read on a background thread when the file opens, and the comparison itself is a pure in-memory diff, recomputed only after typing pauses -- so editing never waits on git. Files outside a repository, or not yet committed, simply show no markers. - **Occurrence Highlighting** -- Placing the caret on an identifier highlights every other whole-word occurrence of it in the file. Keywords and single characters are ignored, and the scan is skipped on very large files to keep caret movement instant. - **Line Operations** -- Delete the current line or selection (`Ctrl+Shift+D`), sort selected lines (`Ctrl+Alt+S`), join selected lines into one (`Ctrl+Shift+J`), and (from the Text menu) natural sort, remove duplicate lines, remove blank lines, reverse line order, or align lines on a delimiter (e.g. `=`). Each is a single undo step. - **Duplicate** (`Ctrl+D`) -- Duplicates the selection when there is one (selecting the new copy), or the whole line when there isn't. diff --git a/je_editor/pyside_ui/code/git_diff/__init__.py b/je_editor/pyside_ui/code/git_diff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py new file mode 100644 index 00000000..d0a7876c --- /dev/null +++ b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py @@ -0,0 +1,157 @@ +""" +管理編輯器的 git 變更標記狀態 +Hold the git change-marker state for one editor. + +基準文字(HEAD 版本)由背景執行緒讀取,因為那是 git 與檔案 I/O; +之後每次重算都只是記憶體內的比對,因此可以在輸入時便宜地重算。 +The baseline (the HEAD version) is read on a background thread because that is +git and file I/O; recomputing against it afterwards is a pure in-memory +comparison, cheap enough to redo while typing. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QThread, Signal + +from je_editor.git_client.file_baseline import baseline_text +from je_editor.utils.file_diff.line_status import ( + line_statuses, + next_changed_line, + previous_changed_line, +) + + +class BaselineLoader(QThread): + """ + 在背景讀取檔案的 HEAD 內容 + Read a file's HEAD content off the UI thread. + """ + + loaded = Signal(object) # str | None + + def __init__(self, file_path: str | Path, parent=None) -> None: + """ + :param file_path: 要讀取基準的檔案 / the file whose baseline to read + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._file_path = file_path + + def run(self) -> None: + """讀取並回報基準文字 / Read the baseline and report it.""" + self.loaded.emit(baseline_text(self._file_path)) + + +class DiffMarkerManager: + """ + 追蹤緩衝區相對於已提交版本的逐行差異 + Track how the buffer differs, line by line, from its committed version. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些標記所屬的編輯器 / the editor these markers belong to + """ + self._code_edit = code_edit + self._baseline: str | None = None + self._statuses: dict[int, str] = {} + self._loader: BaselineLoader | None = None + + @property + def has_baseline(self) -> bool: + """是否已取得基準(檔案在 git 中且有提交過)/ Whether a baseline is known.""" + return self._baseline is not None + + def set_baseline(self, text: str | None) -> None: + """ + 設定基準文字並重算標記 + Set the baseline and recompute the markers. + + :param text: HEAD 版本的內容,``None`` 表示沒有基準 + the committed content, or ``None`` when there is none + """ + self._baseline = text + self.refresh() + + def clear(self) -> None: + """清除基準與標記 / Forget the baseline and every marker.""" + self._baseline = None + self._statuses = {} + + def refresh(self) -> bool: + """ + 依目前緩衝區內容重算標記 + Recompute the markers from the buffer's current text. + + :return: 標記是否改變(未改變時呼叫端可省下重繪) + whether the markers changed, so the caller can skip a repaint + """ + if self._baseline is None: + changed = bool(self._statuses) + self._statuses = {} + return changed + statuses = line_statuses(self._baseline, self._code_edit.toPlainText()) + if statuses == self._statuses: + return False + self._statuses = statuses + return True + + def status(self, line: int) -> str | None: + """ + 取得某一行的狀態 + Return one line's status. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 狀態字串,未變更時為 ``None`` / the status, or ``None`` + """ + return self._statuses.get(line) + + def statuses(self) -> dict[int, str]: + """取得所有變更行的狀態副本 / A copy of every changed line's status.""" + return dict(self._statuses) + + def next_change(self, line: int) -> int | None: + """下一個變更行 / The next changed line, wrapping around.""" + return next_changed_line(self._statuses, line) + + def previous_change(self, line: int) -> int | None: + """上一個變更行 / The previous changed line, wrapping around.""" + return previous_changed_line(self._statuses, line) + + def load_baseline(self, file_path: str | Path | None) -> None: + """ + 在背景重新讀取基準;沒有檔案時直接清除 + Reload the baseline in the background, or clear it when there is no file. + + :param file_path: 目前編輯的檔案 / the file being edited + """ + self.stop() + if file_path is None: + self.clear() + return + loader = BaselineLoader(file_path) + self._loader = loader + # 只接受目前這個 loader 的結果,換檔案時舊結果就過期了 + # Accept a result only from the current loader; switching files makes an + # in-flight read stale. + loader.loaded.connect(lambda text: self._on_loaded(loader, text)) + loader.finished.connect(loader.deleteLater) + loader.start() + + def _on_loaded(self, loader: BaselineLoader, text: str | None) -> None: + """套用背景讀取的結果 / Apply a baseline that finished loading.""" + if loader is not self._loader: + return + self.set_baseline(text) + self._code_edit.line_number.update() + + def stop(self) -> None: + """ + 結束仍在進行的背景讀取 + Stop a baseline read that is still running. + """ + loader, self._loader = self._loader, None + if loader is not None and loader.isRunning(): + loader.quit() + loader.wait() 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 e86e27c2..7fa2fa4a 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 @@ -14,7 +14,11 @@ from je_editor.pyside_ui.code.bookmark.bookmark_manager import BookmarkManager from je_editor.pyside_ui.code.folding.folding_manager import FoldingManager +from je_editor.pyside_ui.code.git_diff.diff_marker_manager import DiffMarkerManager from je_editor.pyside_ui.code.selection.smart_selection_manager import SmartSelectionManager +from je_editor.utils.file_diff.line_status import ( + LINE_ADDED, LINE_MODIFIED, LINE_REMOVED_ABOVE +) from je_editor.utils.indentation.indent_convert import ( convert_leading_spaces_to_tabs, convert_leading_tabs_to_spaces, detect_indent_width, detect_indentation_uses_tabs @@ -74,10 +78,22 @@ def run(self) -> None: self.finished.emit([]) -# 行號區域中書籤欄與折疊欄的寬度(像素) -# Width in pixels of the bookmark and fold columns inside the gutter +# 行號區域中書籤欄、折疊欄與 git 變更欄的寬度(像素) +# Width in pixels of the bookmark, fold and git-change columns inside the gutter _BOOKMARK_MARKER_WIDTH = 14 _FOLD_MARKER_WIDTH = 14 +_DIFF_MARKER_WIDTH = 4 + +# 輸入停止多久之後才重算 git 變更標記(毫秒) +# How long typing must pause before the git change markers are recomputed +_DIFF_REFRESH_DELAY_MS = 400 + +# 變更狀態對應的顏色設定鍵 / Colour setting key for each change status +_DIFF_MARKER_COLOR_KEYS = { + LINE_ADDED: "diff_added_marker_color", + LINE_MODIFIED: "diff_modified_marker_color", + LINE_REMOVED_ABOVE: "diff_removed_marker_color", +} # 游標移動幾行以上才視為「跳轉」並記入導覽歷史 # How many lines the caret must move to count as a "jump" recorded in history @@ -227,6 +243,17 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # Per-file detected indent width (None means use the global setting) self._indent_size_override: Union[int, None] = None + # git 變更標記:基準在背景讀取,重算則在輸入停止後 debounce 執行 + # git change markers: the baseline loads in the background, and the + # recompute is debounced so typing does not diff on every keystroke + self.diff_marker_manager = DiffMarkerManager(self) + self._diff_timer = QTimer(self) + self._diff_timer.setSingleShot(True) + self._diff_timer.setInterval(_DIFF_REFRESH_DELAY_MS) + self._diff_timer.timeout.connect(self._refresh_diff_markers) + self._register_diff_marker_actions() + self.load_git_baseline() + def reset_highlighter(self) -> None: """重設語法高亮 / Reset syntax highlighter""" jeditor_logger.info("CodeEditor reset_highlighter") @@ -420,6 +447,62 @@ def _on_text_changed_for_features(self) -> None: # Only re-apply when something is folded, so unfolded editing has no cost if self.folding_manager.is_any_folded(): self.folding_manager.refresh() + # 沒有基準時完全不需要排程重算 + # With no baseline there is nothing to recompute + if self.diff_marker_manager.has_baseline: + self._diff_timer.start() + + def _refresh_diff_markers(self) -> None: + """重算 git 變更標記,有變化才重繪 / Recompute markers, repainting only on a change.""" + if self.diff_marker_manager.refresh(): + self.line_number.update() + + def load_git_baseline(self) -> None: + """ + 重新讀取目前檔案在 HEAD 的內容作為比較基準 + Reload the current file's committed content as the comparison baseline. + + 開檔或換檔後呼叫;讀取在背景執行緒進行。 + Call this after opening or switching files; the read runs in a thread. + """ + self.diff_marker_manager.load_baseline(self.current_file) + + def _register_diff_marker_actions(self) -> None: + """註冊變更跳轉快捷鍵 / Register the change-navigation shortcuts.""" + for shortcut, handler in ( + ("Ctrl+Alt+Down", self.next_change), + ("Ctrl+Alt+Up", self.previous_change), + ): + action = QAction(self) + action.setShortcut(shortcut) + action.triggered.connect(handler) + self.addAction(action) + + def next_change(self) -> bool: + """ + 跳到下一個有變更的行 + Jump to the next changed line. + + :return: 是否有跳轉 / whether the caret moved + """ + return self._go_to_change( + self.diff_marker_manager.next_change(self.textCursor().blockNumber())) + + def previous_change(self) -> bool: + """ + 跳到上一個有變更的行 + Jump to the previous changed line. + + :return: 是否有跳轉 / whether the caret moved + """ + return self._go_to_change( + self.diff_marker_manager.previous_change(self.textCursor().blockNumber())) + + def _go_to_change(self, line: Union[int, None]) -> bool: + """移動游標到指定變更行 / Move the caret to a changed line.""" + if line is None: + return False + return self.jump_to_line(line + 1) def _foldable_header_lines(self) -> set: """取得可折疊標頭行號(快取)/ Foldable header lines (cached).""" @@ -578,6 +661,7 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: bookmarked = set(self.bookmark_manager.bookmarked_lines()) fold_headers = self._foldable_header_lines() folded_headers = self.folding_manager.folded_header_lines() + diff_statuses = self.diff_marker_manager.statuses() gutter_width = self.line_number.width() line_height = self.fontMetrics().height() @@ -594,11 +678,16 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: painter.drawText( _BOOKMARK_MARKER_WIDTH, int(top), - gutter_width - _BOOKMARK_MARKER_WIDTH - _FOLD_MARKER_WIDTH, + gutter_width - _BOOKMARK_MARKER_WIDTH - _FOLD_MARKER_WIDTH + - _DIFF_MARKER_WIDTH, line_height, Qt.AlignmentFlag.AlignCenter, str(block_number + 1), ) + diff_status = diff_statuses.get(block_number) + if diff_status is not None: + self._paint_diff_marker( + painter, top, line_height, gutter_width, diff_status) if block_number in bookmarked: self._paint_bookmark_marker(painter, top, line_height) if block_number in fold_headers: @@ -640,13 +729,36 @@ def _paint_fold_marker( ) painter.restore() + def _paint_diff_marker( + self, painter: QPainter, top: float, line_height: int, + gutter_width: int, status: str) -> None: + """ + 在折疊欄左側繪製 git 變更長條 + Draw the git change bar just left of the fold column. + """ + color = _DIFF_MARKER_COLOR_KEYS.get(status) + if color is None: + return + painter.save() + painter.fillRect( + gutter_width - _FOLD_MARKER_WIDTH - _DIFF_MARKER_WIDTH, + int(top), + _DIFF_MARKER_WIDTH, + # 刪除以細線表示,因為被刪的行已經不在畫面上 + # A deletion is a thin line: the removed lines are no longer on screen + 2 if status == LINE_REMOVED_ABOVE else line_height, + actually_color_dict.get(color), + ) + painter.restore() + def line_number_width(self) -> int: """ - 計算行號區域寬度(含書籤與折疊欄) - Calculate gutter width, including the bookmark and fold columns. + 計算行號區域寬度(含書籤、git 變更與折疊欄) + Calculate gutter width, including the bookmark, git-change and fold columns. """ digits = len(str(self.blockCount())) # 根據總行數決定位數 - return 12 * digits + _BOOKMARK_MARKER_WIDTH + _FOLD_MARKER_WIDTH + return (12 * digits + _BOOKMARK_MARKER_WIDTH + _FOLD_MARKER_WIDTH + + _DIFF_MARKER_WIDTH) def update_line_number_area_width(self, value: int) -> None: """ diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index 4f3461fa..8723f316 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -260,6 +260,8 @@ def open_an_file(self, path: Path) -> bool: self.current_file = file self.code_edit.current_file = file self.code_edit.reset_highlighter() + # 換檔後重新取得 git 比較基準 / Reload the git baseline for the new file + self.code_edit.load_git_baseline() # 更新使用者設定中的最後開啟檔案 / Update last opened file in user settings user_setting_dict.update({"last_file": str(self.current_file)}) @@ -459,6 +461,9 @@ def close(self) -> bool: self.exec_shell = None self.exec_python_debugger = None + # 停止仍在讀取 git 基準的背景執行緒 / Stop a git baseline read still running + self.code_edit.diff_marker_manager.stop() + if self.current_file: file_is_open_manager_dict.pop(str(Path(self.current_file)), None) auto_save_manager_dict.pop(self.current_file, None) diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index d4cb2a44..2de8e774 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -39,6 +39,9 @@ def update_actually_color_dict() -> None: "bookmark_marker_color": _to_qcolor("bookmark_marker_color", [66, 165, 245]), "fold_marker_color": _to_qcolor("fold_marker_color", [120, 120, 120]), "occurrence_highlight_color": _to_qcolor("occurrence_highlight_color", [80, 90, 60]), + "diff_added_marker_color": _to_qcolor("diff_added_marker_color", [76, 175, 80]), + "diff_modified_marker_color": _to_qcolor("diff_modified_marker_color", [255, 167, 38]), + "diff_removed_marker_color": _to_qcolor("diff_removed_marker_color", [229, 57, 53]), } ) @@ -54,7 +57,10 @@ def update_actually_color_dict() -> None: "warning_output_color": [204, 204, 0], "bookmark_marker_color": [66, 165, 245], "fold_marker_color": [120, 120, 120], - "occurrence_highlight_color": [80, 90, 60] + "occurrence_highlight_color": [80, 90, 60], + "diff_added_marker_color": [76, 175, 80], + "diff_modified_marker_color": [255, 167, 38], + "diff_removed_marker_color": [229, 57, 53] } # 實際使用的顏色字典 (以 QColor 表示) diff --git a/test/test_diff_marker_manager.py b/test/test_diff_marker_manager.py new file mode 100644 index 00000000..e4d5a526 --- /dev/null +++ b/test/test_diff_marker_manager.py @@ -0,0 +1,173 @@ +"""Tests for the git change-marker manager and its editor wiring.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.file_diff.line_status import ( + LINE_ADDED, LINE_MODIFIED, LINE_REMOVED_ABOVE +) + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.diff_marker_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestDiffMarkerManager: + def test_no_baseline_means_no_markers(self, editor): + editor.setPlainText("a\nb\n") + assert editor.diff_marker_manager.statuses() == {} + assert not editor.diff_marker_manager.has_baseline + + def test_baseline_marks_the_changed_line(self, editor): + editor.setPlainText("a\nB\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + assert editor.diff_marker_manager.status(1) == LINE_MODIFIED + assert editor.diff_marker_manager.status(0) is None + + def test_added_line_after_the_baseline_is_marked(self, editor): + editor.setPlainText("a\nb\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + assert editor.diff_marker_manager.status(2) == LINE_ADDED + + def test_deletion_marks_the_following_line(self, editor): + editor.setPlainText("a\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + assert editor.diff_marker_manager.status(1) == LINE_REMOVED_ABOVE + + def test_refresh_tracks_later_edits(self, editor): + editor.setPlainText("a\nb\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + assert editor.diff_marker_manager.statuses() == {} + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.refresh() + assert editor.diff_marker_manager.status(1) == LINE_MODIFIED + + def test_refresh_reports_whether_anything_changed(self, editor): + editor.setPlainText("a\n") + editor.diff_marker_manager.set_baseline("a\n") + assert editor.diff_marker_manager.refresh() is False + editor.setPlainText("b\n") + assert editor.diff_marker_manager.refresh() is True + assert editor.diff_marker_manager.refresh() is False + + def test_clear_forgets_the_baseline(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + editor.diff_marker_manager.clear() + assert not editor.diff_marker_manager.has_baseline + assert editor.diff_marker_manager.statuses() == {} + + def test_setting_a_none_baseline_drops_the_markers(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + editor.diff_marker_manager.set_baseline(None) + assert editor.diff_marker_manager.statuses() == {} + + def test_statuses_returns_a_copy(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + editor.diff_marker_manager.statuses()[1] = "tampered" + assert editor.diff_marker_manager.status(1) == LINE_MODIFIED + + +class TestChangeNavigationWiring: + def _place_cursor(self, editor, line: int) -> None: + block = editor.document().findBlockByNumber(line) + cursor = editor.textCursor() + cursor.setPosition(block.position()) + editor.setTextCursor(cursor) + + def test_next_change_moves_the_caret(self, editor): + editor.setPlainText("a\nB\nc\nD\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\nd\n") + self._place_cursor(editor, 0) + assert editor.next_change() is True + assert editor.textCursor().blockNumber() == 1 + + def test_previous_change_moves_the_caret(self, editor): + editor.setPlainText("a\nB\nc\nD\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\nd\n") + self._place_cursor(editor, 3) + assert editor.previous_change() is True + assert editor.textCursor().blockNumber() == 1 + + def test_navigation_without_changes_does_nothing(self, editor): + editor.setPlainText("a\nb\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + assert editor.next_change() is False + assert editor.previous_change() is False + + +class TestGutterWiring: + def test_gutter_is_wider_than_the_bookmark_and_fold_columns(self, editor): + from je_editor.pyside_ui.code.plaintext_code_edit import code_edit_plaintext + minimum = (code_edit_plaintext._BOOKMARK_MARKER_WIDTH + + code_edit_plaintext._FOLD_MARKER_WIDTH + + code_edit_plaintext._DIFF_MARKER_WIDTH) + assert editor.line_number_width() > minimum + + def test_every_status_has_a_colour(self, editor): + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import ( + _DIFF_MARKER_COLOR_KEYS + ) + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict + ) + for status in (LINE_ADDED, LINE_MODIFIED, LINE_REMOVED_ABOVE): + assert actually_color_dict.get(_DIFF_MARKER_COLOR_KEYS[status]) is not None + + def test_painting_the_gutter_with_markers_does_not_raise(self, editor): + editor.setPlainText("a\nB\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\nd\n") + editor.show() + editor.line_number.update() + QApplication.processEvents() + editor.hide() + + def test_edits_schedule_a_refresh_only_with_a_baseline(self, editor): + editor.setPlainText("a\n") + assert not editor._diff_timer.isActive() + editor.diff_marker_manager.set_baseline("a\n") + editor.setPlainText("b\n") + assert editor._diff_timer.isActive() + + +class TestBaselineLoading: + def test_no_file_clears_the_baseline(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + editor.current_file = None + editor.load_git_baseline() + assert not editor.diff_marker_manager.has_baseline + + def test_loading_a_file_outside_a_repository_leaves_no_baseline(self, editor, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("print('x')\n", encoding="utf-8") + editor.current_file = str(loose) + editor.load_git_baseline() + editor.diff_marker_manager.stop() + assert not editor.diff_marker_manager.has_baseline + + def test_stop_is_safe_without_a_loader(self, editor): + editor.diff_marker_manager.stop() + editor.diff_marker_manager.stop() From 786c318124dc1be06061c73d70de233e068c8e5a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:03:32 +0800 Subject: [PATCH 03/36] Show ruff diagnostics in the editor and a problems panel code_scan could already run ruff, but nothing in the UI consumed its output, so the results went into a queue nobody read. Findings are now underlined where they occur and listed in a Problems dock panel that jumps to the line on double-click. The buffer is linted rather than the file on disk, so unsaved edits are covered, and the run happens on a worker thread after typing pauses. A result from a run that has since been superseded is discarded instead of being applied to newer text. A missing or failing ruff yields no diagnostics rather than an error, so the editor keeps working without it. Parsing ruff JSON lives in utils/lint as pure logic: anything it cannot recognise is skipped, because a change in the linter output must not break the editor. --- README.md | 1 + je_editor/code_scan/ruff_lint.py | 108 ++++++++++ je_editor/pyside_ui/code/lint/__init__.py | 0 je_editor/pyside_ui/code/lint/lint_manager.py | 139 ++++++++++++ .../code_edit_plaintext.py | 103 +++++++++ .../pyside_ui/main_ui/editor/editor_widget.py | 3 +- .../main_ui/menu/dock_menu/build_dock_menu.py | 11 + .../main_ui/problems_panel/__init__.py | 0 .../problems_panel/problems_panel_widget.py | 139 ++++++++++++ .../save_settings/user_color_setting_file.py | 4 +- je_editor/utils/lint/__init__.py | 0 je_editor/utils/lint/ruff_diagnostics.py | 130 ++++++++++++ je_editor/utils/multi_language/english.py | 9 + .../multi_language/traditional_chinese.py | 9 + test/test_lint_manager.py | 198 ++++++++++++++++++ test/test_ruff_diagnostics.py | 114 ++++++++++ test/test_ruff_lint_runner.py | 71 +++++++ 17 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 je_editor/code_scan/ruff_lint.py create mode 100644 je_editor/pyside_ui/code/lint/__init__.py create mode 100644 je_editor/pyside_ui/code/lint/lint_manager.py create mode 100644 je_editor/pyside_ui/main_ui/problems_panel/__init__.py create mode 100644 je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py create mode 100644 je_editor/utils/lint/__init__.py create mode 100644 je_editor/utils/lint/ruff_diagnostics.py create mode 100644 test/test_lint_manager.py create mode 100644 test/test_ruff_diagnostics.py create mode 100644 test/test_ruff_lint_runner.py diff --git a/README.md b/README.md index 81817bf7..c2b5f5d3 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ The editor launches in a maximized window with a dark amber theme by default. - **Search & Replace** -- Search within the current file, across folders, or project-wide with regex and case-sensitive options. Runs in background threads for large projects. - **Code Folding** -- Collapse and expand indented blocks (functions, classes, loops) from the gutter fold triangles or the keyboard. Folding is indentation-based and only toggles line visibility -- it never modifies the text, so saving always writes the complete file. Folds self-heal after edits: a fold whose header no longer exists simply reopens instead of hiding the wrong lines. - **Bookmarks** -- Mark lines and jump between them with the keyboard, or click the gutter to toggle. Bookmarks are anchored to the text (via `QTextCursor`), so they follow their code when lines are inserted or removed above them instead of drifting. +- **Lint Diagnostics** -- Findings from `ruff` are underlined in the editor and listed in a Problems dock panel (rule, message, line), with double-click to jump. The **buffer** is checked, not the file on disk, so unsaved edits are covered; the run happens on a worker thread after typing pauses, and a stale result from a superseded run is discarded. If `ruff` is not installed, or a run fails, the editor simply shows no diagnostics rather than reporting an error. - **Git Change Markers** -- The gutter shows how the file differs from its last commit: a green bar for added lines, orange for modified, and a thin red line where lines were deleted. Jump between changes with `Ctrl+Alt+Down` / `Ctrl+Alt+Up`. The committed version is read on a background thread when the file opens, and the comparison itself is a pure in-memory diff, recomputed only after typing pauses -- so editing never waits on git. Files outside a repository, or not yet committed, simply show no markers. - **Occurrence Highlighting** -- Placing the caret on an identifier highlights every other whole-word occurrence of it in the file. Keywords and single characters are ignored, and the scan is skipped on very large files to keep caret movement instant. - **Line Operations** -- Delete the current line or selection (`Ctrl+Shift+D`), sort selected lines (`Ctrl+Alt+S`), join selected lines into one (`Ctrl+Shift+J`), and (from the Text menu) natural sort, remove duplicate lines, remove blank lines, reverse line order, or align lines on a delimiter (e.g. `=`). Each is a single undo step. diff --git a/je_editor/code_scan/ruff_lint.py b/je_editor/code_scan/ruff_lint.py new file mode 100644 index 00000000..e7302741 --- /dev/null +++ b/je_editor/code_scan/ruff_lint.py @@ -0,0 +1,108 @@ +""" +對編輯中的內容執行 ruff,取得診斷 +Run ruff over the text being edited and collect its diagnostics. + +檢查的是**緩衝區內容**而不是磁碟上的檔案,所以還沒存檔的修改也會被檢查; +內容經由標準輸入傳給 ruff,只用 ``--stdin-filename`` 告訴它檔名,好讓 per-file +的設定與規則照常套用。 +This lints the **buffer**, not the file on disk, so unsaved edits are checked +too. The text goes in through standard input and only ``--stdin-filename`` tells +ruff what the file is called, so per-file configuration still applies. +""" +from __future__ import annotations + +import shutil +import subprocess # nosec B404 - 以引數清單呼叫 ruff,未使用 shell +import sys +from pathlib import Path + +from je_editor.utils.lint.ruff_diagnostics import Diagnostic, parse_ruff_json +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# ruff 執行檔名稱 / The ruff executable's name +_RUFF_NAME = "ruff.exe" if sys.platform == "win32" else "ruff" +# 單次檢查的逾時(秒):ruff 很快,超過就是出了別的問題 +# Timeout for one run: ruff is fast, so exceeding this means something else is wrong +LINT_TIMEOUT_SECONDS = 20 +# 可以檢查的副檔名 / Suffixes worth linting +PYTHON_SUFFIXES = (".py", ".pyi") + + +def find_ruff_executable() -> str | None: + """ + 找出可用的 ruff 執行檔 + Locate a usable ruff executable. + + 先找目前直譯器所在的環境(通常就是專案的虛擬環境),再退回 PATH。 + The running interpreter's environment is tried first, since that is usually + the project's virtual environment, then PATH. + + :return: 執行檔路徑,找不到時為 ``None`` / the path, or ``None`` when absent + """ + beside_interpreter = Path(sys.executable).parent / _RUFF_NAME + if beside_interpreter.is_file(): + return str(beside_interpreter) + return shutil.which("ruff") + + +def is_lintable(file_path: str | Path | None) -> bool: + """ + 判斷這個檔案是否值得檢查 + Whether this file is worth linting. + + :param file_path: 檔案路徑 / the file to consider + :return: 是 Python 檔時為 ``True`` / ``True`` for a Python file + """ + if file_path is None: + return False + return Path(file_path).suffix.lower() in PYTHON_SUFFIXES + + +def lint_command(executable: str, file_path: str | Path) -> list[str]: + """ + 組出檢查用的指令 + Build the command that lints one buffer. + + :param executable: ruff 執行檔 / the ruff executable + :param file_path: 緩衝區對應的檔名 / the name the buffer is saved under + :return: 引數清單(不經過 shell)/ the argument list, never a shell string + """ + return [ + executable, "check", + "--output-format", "json", + "--stdin-filename", str(file_path), + "-", + ] + + +def lint_text(text: str, file_path: str | Path) -> list[Diagnostic]: + """ + 檢查一段內容並回傳診斷 + Lint *text* as if it were *file_path*. + + ruff 找到問題時回傳碼是 1,那是正常結果而不是失敗,因此不特別處理回傳碼。 + ruff exits with 1 when it finds problems, which is a result rather than a + failure, so the return code is not inspected. + + :param text: 要檢查的內容 / the text to lint + :param file_path: 內容對應的檔名 / the name the text is saved under + :return: 診斷清單;ruff 不存在或執行失敗時為空清單 + the diagnostics, or an empty list when ruff is missing or fails + """ + executable = find_ruff_executable() + if executable is None: + return [] + try: + completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 + lint_command(executable, file_path), + input=text, + capture_output=True, + text=True, + encoding="utf-8", + timeout=LINT_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + jeditor_logger.debug(f"ruff_lint: could not run ruff: {error!r}") + return [] + return parse_ruff_json(completed.stdout or "") diff --git a/je_editor/pyside_ui/code/lint/__init__.py b/je_editor/pyside_ui/code/lint/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/lint/lint_manager.py b/je_editor/pyside_ui/code/lint/lint_manager.py new file mode 100644 index 00000000..960b5cba --- /dev/null +++ b/je_editor/pyside_ui/code/lint/lint_manager.py @@ -0,0 +1,139 @@ +""" +管理一個編輯器的 lint 診斷狀態 +Hold the lint state for one editor. + +檢查在背景執行緒進行(那是子程序呼叫),完成後把診斷交回主執行緒。 +The check runs on a worker thread because it spawns a subprocess, and hands the +diagnostics back to the UI thread when it finishes. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QThread, Signal + +from je_editor.code_scan.ruff_lint import is_lintable, lint_text +from je_editor.utils.lint.ruff_diagnostics import Diagnostic, message_for_line + + +class LintWorker(QThread): + """ + 在背景對緩衝區內容執行 ruff + Run ruff over a buffer's text off the UI thread. + """ + + linted = Signal(object) # list[Diagnostic] + + def __init__(self, text: str, file_path: str | Path, parent=None) -> None: + """ + :param text: 要檢查的內容 / the text to lint + :param file_path: 內容對應的檔名 / the name the text is saved under + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._text = text + self._file_path = file_path + + def run(self) -> None: + """執行檢查並回報結果 / Lint and report the result.""" + self.linted.emit(lint_text(self._text, self._file_path)) + + +class LintManager: + """ + 追蹤編輯器目前的診斷 + Track the diagnostics currently reported for an editor. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些診斷所屬的編輯器 / the editor being linted + """ + self._code_edit = code_edit + self._diagnostics: list[Diagnostic] = [] + self._worker: LintWorker | None = None + + def diagnostics(self) -> list[Diagnostic]: + """取得目前診斷的副本 / A copy of the current diagnostics.""" + return list(self._diagnostics) + + def for_line(self, line: int) -> list[Diagnostic]: + """ + 取得某行的診斷 + The diagnostics reported on one line. + + :param line: 1 起算的行號 / the 1-based line number + :return: 該行的診斷 / the diagnostics on that line + """ + return [item for item in self._diagnostics if item.line == line] + + def message_for_line(self, line: int) -> str | None: + """ + 取得某行診斷的說明文字 + The messages reported on one line, joined for display. + + :param line: 1 起算的行號 / the 1-based line number + :return: 說明文字,沒有診斷時為 ``None`` / the text, or ``None`` + """ + return message_for_line(self._diagnostics, line) + + def clear(self) -> bool: + """ + 清除所有診斷 + Drop every diagnostic. + + :return: 是否真的有東西被清掉 / whether anything was actually dropped + """ + if not self._diagnostics: + return False + self._diagnostics = [] + return True + + def set_diagnostics(self, diagnostics: list[Diagnostic]) -> bool: + """ + 套用一組診斷 + Apply a set of diagnostics. + + :param diagnostics: 新的診斷清單 / the diagnostics to apply + :return: 內容是否改變(未改變時呼叫端可省下重繪) + whether they differ from the previous set, so a repaint can be skipped + """ + if diagnostics == self._diagnostics: + return False + self._diagnostics = list(diagnostics) + return True + + def request(self, file_path: str | Path | None) -> bool: + """ + 對目前緩衝區內容排一次檢查 + Start one check of the buffer's current text. + + :param file_path: 目前編輯的檔案 / the file being edited + :return: 是否真的啟動了檢查 / whether a check was actually started + """ + self.stop() + if not is_lintable(file_path): + return False + worker = LintWorker(self._code_edit.toPlainText(), file_path) + self._worker = worker + # 只接受目前這個 worker 的結果;換檔或再次輸入都會讓舊結果過期 + # Accept a result only from the current worker: switching files or typing + # again makes an in-flight check stale. + worker.linted.connect(lambda diagnostics: self._on_linted(worker, diagnostics)) + worker.finished.connect(worker.deleteLater) + worker.start() + return True + + def _on_linted(self, worker: LintWorker, diagnostics: list) -> None: + """套用背景檢查的結果 / Apply diagnostics that finished arriving.""" + if worker is not self._worker: + return + if self.set_diagnostics(diagnostics): + self._code_edit.refresh_lint_display() + + def stop(self) -> None: + """結束仍在執行的檢查 / Stop a check that is still running.""" + worker, self._worker = self._worker, None + if worker is not None and worker.isRunning(): + worker.blockSignals(True) + worker.wait() 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 7fa2fa4a..b7763a21 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 @@ -15,6 +15,7 @@ from je_editor.pyside_ui.code.bookmark.bookmark_manager import BookmarkManager from je_editor.pyside_ui.code.folding.folding_manager import FoldingManager from je_editor.pyside_ui.code.git_diff.diff_marker_manager import DiffMarkerManager +from je_editor.pyside_ui.code.lint.lint_manager import LintManager from je_editor.pyside_ui.code.selection.smart_selection_manager import SmartSelectionManager from je_editor.utils.file_diff.line_status import ( LINE_ADDED, LINE_MODIFIED, LINE_REMOVED_ABOVE @@ -88,6 +89,19 @@ def run(self) -> None: # How long typing must pause before the git change markers are recomputed _DIFF_REFRESH_DELAY_MS = 400 +# 輸入停止多久之後才重新執行 lint(毫秒);比變更標記長,因為要開子程序 +# How long typing must pause before ruff runs again; longer than the change +# markers because it spawns a subprocess +_LINT_REFRESH_DELAY_MS = 900 + +def _lint_underline_format() -> QTextCharFormat: + """診斷底線的樣式 / The format used to underline a diagnostic.""" + formats = QTextCharFormat() + formats.setUnderlineStyle(QTextCharFormat.UnderlineStyle.WaveUnderline) + formats.setUnderlineColor(actually_color_dict.get("lint_underline_color")) + return formats + + # 變更狀態對應的顏色設定鍵 / Colour setting key for each change status _DIFF_MARKER_COLOR_KEYS = { LINE_ADDED: "diff_added_marker_color", @@ -137,6 +151,11 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.main_window = main_window self.current_file = main_window.current_file + # lint 診斷狀態;必須在第一次高亮之前建立,因為高亮會附加診斷底線 + # Lint state, created before the first highlight because highlighting + # appends the diagnostic underlines + self.lint_manager = LintManager(self) + # 定義哪些按鍵不會觸發補全視窗 self.skip_popup_behavior_list = [ Qt.Key.Key_Enter, Qt.Key.Key_Return, Qt.Key.Key_Up, Qt.Key.Key_Down, @@ -254,6 +273,15 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self._register_diff_marker_actions() self.load_git_baseline() + # lint 檢查會開子程序,因此同樣等輸入停下來才跑(管理器已於前面建立) + # The lint check spawns a subprocess, so it too waits for a pause in + # typing; its manager was created earlier in __init__ + self._lint_timer = QTimer(self) + self._lint_timer.setSingleShot(True) + self._lint_timer.setInterval(_LINT_REFRESH_DELAY_MS) + self._lint_timer.timeout.connect(self.request_lint) + self.request_lint() + def reset_highlighter(self) -> None: """重設語法高亮 / Reset syntax highlighter""" jeditor_logger.info("CodeEditor reset_highlighter") @@ -451,6 +479,7 @@ def _on_text_changed_for_features(self) -> None: # With no baseline there is nothing to recompute if self.diff_marker_manager.has_baseline: self._diff_timer.start() + self._lint_timer.start() def _refresh_diff_markers(self) -> None: """重算 git 變更標記,有變化才重繪 / Recompute markers, repainting only on a change.""" @@ -504,6 +533,77 @@ def _go_to_change(self, line: Union[int, None]) -> bool: return False return self.jump_to_line(line + 1) + def _show_lint_message_for_caret(self) -> None: + """ + 把游標所在行的診斷顯示為提示文字 + Show the caret line's diagnostics as the editor's tooltip. + """ + message = self.lint_manager.message_for_line(self.textCursor().blockNumber() + 1) + self.setToolTip(message or "") + + def request_lint(self) -> bool: + """ + 對目前內容排一次 lint 檢查 + Start one lint check of the current text. + + :return: 是否真的啟動了檢查(非 Python 檔不檢查) + whether a check started; non-Python files are not linted + """ + started = self.lint_manager.request(self.current_file) + if not started and self.lint_manager.clear(): + self.refresh_lint_display() + return started + + def refresh_lint_display(self) -> None: + """重畫診斷底線 / Repaint the diagnostic underlines.""" + self._highlight_matching_bracket() + + def _append_lint_selections(self, selections: list) -> None: + """ + 把診斷位置加入波浪底線 + Append a wavy underline for each diagnostic. + + :param selections: 要附加的選取清單 / the selection list to append to + """ + diagnostics = self.lint_manager.diagnostics() + if not diagnostics: + return + document = self.document() + for diagnostic in diagnostics: + cursor = self._diagnostic_cursor(document, diagnostic) + if cursor is None: + continue + selection = QTextEdit.ExtraSelection() + selection.cursor = cursor + selection.format = _lint_underline_format() + selections.append(selection) + + @staticmethod + def _diagnostic_cursor( + document: QTextDocument, diagnostic) -> Union[QTextCursor, None]: + """ + 取得診斷範圍的游標,範圍不存在時回傳 ``None`` + Return a cursor spanning a diagnostic, or ``None`` when it is out of range. + """ + block = document.findBlockByNumber(diagnostic.line - 1) + if not block.isValid(): + return None + start = block.position() + max(0, diagnostic.column - 1) + end_block = document.findBlockByNumber(diagnostic.end_line - 1) + if end_block.isValid(): + end = end_block.position() + max(0, diagnostic.end_column - 1) + else: + end = block.position() + block.length() - 1 + # 零寬度的範圍看不見,至少標一個字元 + # A zero-width range is invisible, so mark at least one character + end = min(max(end, start + 1), document.characterCount() - 1) + if start >= end: + return None + cursor = QTextCursor(document) + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + return cursor + def _foldable_header_lines(self) -> set: """取得可折疊標頭行號(快取)/ Foldable header lines (cached).""" if self._fold_header_cache is None: @@ -811,6 +911,7 @@ def highlight_current_line(self) -> None: selections.append(selection) selection.format.setBackground(color_of_the_line) selection.format.setProperty(QTextFormat.FullWidthSelection, True) + self._append_lint_selections(selections) self.setExtraSelections(selections) def _highlight_matching_bracket(self) -> None: @@ -852,7 +953,9 @@ def _highlight_matching_bracket(self) -> None: selections.append(sel) self._append_occurrence_selections(selections, text, pos) + self._append_lint_selections(selections) self.setExtraSelections(selections) + self._show_lint_message_for_caret() def word_occurrences_under_cursor(self, text: str, position: int) -> list[int]: """ diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index 8723f316..6862a69f 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -461,8 +461,9 @@ def close(self) -> bool: self.exec_shell = None self.exec_python_debugger = None - # 停止仍在讀取 git 基準的背景執行緒 / Stop a git baseline read still running + # 停止仍在執行的背景檢查 / Stop background checks still running self.code_edit.diff_marker_manager.stop() + self.code_edit.lint_manager.stop() if self.current_file: file_is_open_manager_dict.pop(str(Path(self.current_file)), None) diff --git a/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py b/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py index 83ca0983..9723392d 100644 --- a/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py @@ -20,6 +20,7 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget_dock import FullEditorWidget from je_editor.pyside_ui.main_ui.ipython_widget.ipython_console import IpythonWidget from je_editor.pyside_ui.main_ui.outline_panel.outline_panel_widget import OutlinePanelWidget +from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ProblemsPanelWidget from je_editor.pyside_ui.main_ui.todo_panel.todo_panel_widget import TodoPanelWidget from je_editor.utils.file.open.open_file import read_file # 檔案讀取工具 / File reading utility from je_editor.utils.logging.loggin_instance import jeditor_logger # 日誌紀錄器 / Logger @@ -138,6 +139,14 @@ def set_dock_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_todo_panel) + # === Problems Panel Dock === + ui_we_want_to_set.dock_menu.new_problems_panel = QAction( + language_wrapper.language_word_dict.get("tab_menu_problems_panel_tab_name")) + ui_we_want_to_set.dock_menu.new_problems_panel.triggered.connect( + lambda: add_dock_widget(ui_we_want_to_set, "problems_panel") + ) + ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_problems_panel) + # === Outline Panel Dock === ui_we_want_to_set.dock_menu.new_outline_panel = QAction( language_wrapper.language_word_dict.get("tab_menu_outline_panel_tab_name")) @@ -180,6 +189,8 @@ def _dock_builders(ui_we_want_to_set: EditorMain) -> dict: lambda: TodoPanelWidget(ui_we_want_to_set)), "outline_panel": ("tab_menu_outline_panel_tab_name", lambda: OutlinePanelWidget(ui_we_want_to_set)), + "problems_panel": ("tab_menu_problems_panel_tab_name", + lambda: ProblemsPanelWidget(ui_we_want_to_set)), } diff --git a/je_editor/pyside_ui/main_ui/problems_panel/__init__.py b/je_editor/pyside_ui/main_ui/problems_panel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py new file mode 100644 index 00000000..3cc58cfa --- /dev/null +++ b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py @@ -0,0 +1,139 @@ +""" +問題面板:列出目前分頁的 lint 診斷 +Problems panel: list the lint diagnostics of the current tab. + +診斷是由編輯器在背景檢查後持有的,面板只負責顯示與跳轉,不自己執行 linter。 +The editor already holds the diagnostics from its background check; the panel +only displays them and jumps to a line, never running the linter itself. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget +) + +from je_editor.utils.lint.ruff_diagnostics import Diagnostic +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + +# 樹狀清單欄位索引 / Column indexes in the tree +COLUMN_CODE = 0 +COLUMN_MESSAGE = 1 +COLUMN_LINE = 2 +# 訊息欄的預設寬度 / Default width of the message column +MESSAGE_COLUMN_WIDTH = 460 + + +def current_code_editor(main_window): + """ + 取得目前分頁的程式碼編輯器 + Return the code editor of the current tab. + + :param main_window: 主編輯器視窗 / the main editor window + :return: 編輯器,目前分頁不是編輯器時為 ``None`` / the editor, or ``None`` + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(main_window, "tab_widget", None) + if tab_widget is None: + return None + widget = tab_widget.currentWidget() + return widget.code_edit if isinstance(widget, EditorWidget) else None + + +class ProblemsPanelWidget(QWidget): + """ + 顯示目前檔案的 lint 診斷 + Show the lint diagnostics of the file in the current tab. + """ + + def __init__(self, main_window=None) -> None: + """ + :param main_window: 用來取得目前編輯器的主視窗 / the window holding the tabs + """ + super().__init__() + word = language_wrapper.language_word_dict + self._main_window = main_window + self._diagnostics: list[Diagnostic] = [] + + self.refresh_button = QPushButton(word.get("problems_panel_refresh")) + self.refresh_button.clicked.connect(self.refresh) + self.status_label = QLabel(word.get("problems_panel_ready")) + + self.result_tree = QTreeWidget() + self.result_tree.setColumnCount(3) + self.result_tree.setHeaderLabels([ + word.get("problems_panel_col_code"), + word.get("problems_panel_col_message"), + word.get("problems_panel_col_line"), + ]) + self.result_tree.setColumnWidth(COLUMN_MESSAGE, MESSAGE_COLUMN_WIDTH) + self.result_tree.setRootIsDecorated(False) + self.result_tree.itemDoubleClicked.connect(self._open_item) + + controls = QHBoxLayout() + controls.addWidget(self.refresh_button) + controls.addWidget(self.status_label) + controls.addStretch() + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(self.result_tree) + self.setLayout(layout) + + self.refresh() + + def diagnostics(self) -> list[Diagnostic]: + """取得面板目前顯示的診斷 / The diagnostics currently listed.""" + return list(self._diagnostics) + + def refresh(self) -> None: + """ + 重新讀取目前分頁的診斷並重畫清單 + Re-read the current tab's diagnostics and rebuild the list. + + 編輯器持續在背景檢查,所以這裡只是取用最新結果。 + The editor keeps checking in the background, so this only picks up its + latest result. + """ + code_edit = current_code_editor(self._main_window) + if code_edit is None: + self._diagnostics = [] + else: + code_edit.request_lint() + self._diagnostics = code_edit.lint_manager.diagnostics() + self._render_items() + + def _render_items(self) -> None: + """依目前診斷重建清單 / Rebuild the tree from the current diagnostics.""" + word = language_wrapper.language_word_dict + self.result_tree.clear() + for diagnostic in self._diagnostics: + row = QTreeWidgetItem([ + diagnostic.code, diagnostic.message, str(diagnostic.line)]) + row.setData(COLUMN_CODE, Qt.ItemDataRole.UserRole, diagnostic) + self.result_tree.addTopLevelItem(row) + if self._diagnostics: + self.status_label.setText( + word.get("problems_panel_found").format(count=len(self._diagnostics))) + else: + self.status_label.setText(word.get("problems_panel_clean")) + + def _open_item(self, row: QTreeWidgetItem, _column: int) -> None: + """跳到被雙擊的診斷所在行 / Jump to the double-clicked diagnostic's line.""" + diagnostic = row.data(COLUMN_CODE, Qt.ItemDataRole.UserRole) + if diagnostic is None: + return + self.jump_to_diagnostic(diagnostic) + + def jump_to_diagnostic(self, diagnostic: Diagnostic) -> bool: + """ + 把目前分頁的游標移到診斷所在行 + Move the current tab's caret to a diagnostic's line. + + :param diagnostic: 目標診斷 / the diagnostic to jump to + :return: 成功跳轉時為 ``True`` / ``True`` when the caret moved + """ + code_edit = current_code_editor(self._main_window) + if code_edit is None: + return False + return code_edit.jump_to_line(diagnostic.line) diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 2de8e774..31a91d1d 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -42,6 +42,7 @@ def update_actually_color_dict() -> None: "diff_added_marker_color": _to_qcolor("diff_added_marker_color", [76, 175, 80]), "diff_modified_marker_color": _to_qcolor("diff_modified_marker_color", [255, 167, 38]), "diff_removed_marker_color": _to_qcolor("diff_removed_marker_color", [229, 57, 53]), + "lint_underline_color": _to_qcolor("lint_underline_color", [255, 138, 101]), } ) @@ -60,7 +61,8 @@ def update_actually_color_dict() -> None: "occurrence_highlight_color": [80, 90, 60], "diff_added_marker_color": [76, 175, 80], "diff_modified_marker_color": [255, 167, 38], - "diff_removed_marker_color": [229, 57, 53] + "diff_removed_marker_color": [229, 57, 53], + "lint_underline_color": [255, 138, 101] } # 實際使用的顏色字典 (以 QColor 表示) diff --git a/je_editor/utils/lint/__init__.py b/je_editor/utils/lint/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/lint/ruff_diagnostics.py b/je_editor/utils/lint/ruff_diagnostics.py new file mode 100644 index 00000000..5dd96f4c --- /dev/null +++ b/je_editor/utils/lint/ruff_diagnostics.py @@ -0,0 +1,130 @@ +""" +解析 ruff 的 JSON 輸出成診斷清單 +Turn ruff's JSON output into a list of diagnostics. + +編輯器用這些診斷畫波浪底線並列在問題面板中。這裡是純邏輯:不執行 ruff、 +不碰 Qt,因此可以用固定的輸出樣本測試。 +The editor underlines these and lists them in the problems panel. Pure logic: +it neither runs ruff nor touches Qt, so it can be tested against fixed samples. + +任何無法辨識的輸出都會被忽略而不是拋出例外——linter 的輸出格式改變不應該 +讓編輯器壞掉。 +Anything unrecognisable is skipped rather than raised: a change in the linter's +output must not break the editor. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass + +# ruff 對語法錯誤不會給規則代碼 / ruff reports no rule code for a syntax error +SYNTAX_ERROR_CODE = "SyntaxError" + + +@dataclass(frozen=True) +class Diagnostic: + """ + 一筆 linter 診斷 + One linter finding. + + :param line: 起始行(1 起算,與 ruff 一致)/ start line, 1-based as ruff reports + :param column: 起始欄(1 起算)/ start column, 1-based + :param end_line: 結束行 / end line + :param end_column: 結束欄 / end column + :param code: 規則代碼,例如 ``F401`` / the rule code, e.g. ``F401`` + :param message: 說明文字 / the human-readable message + """ + + line: int + column: int + end_line: int + end_column: int + code: str + message: str + + @property + def label(self) -> str: + """給面板顯示的一行說明 / A single line for the panel.""" + return f"{self.code} {self.message}" if self.code else self.message + + +def _as_position(raw: object, fallback_row: int, fallback_column: int) -> tuple[int, int]: + """讀出 ``{"row": n, "column": n}``,缺漏時退回預設 / Read a position, with fallbacks.""" + if not isinstance(raw, dict): + return fallback_row, fallback_column + row = raw.get("row") + column = raw.get("column") + return ( + row if isinstance(row, int) and row > 0 else fallback_row, + column if isinstance(column, int) and column > 0 else fallback_column, + ) + + +def _as_diagnostic(entry: object) -> Diagnostic | None: + """把一筆 ruff 記錄轉成診斷,無法辨識時回傳 ``None`` / Convert one ruff record.""" + if not isinstance(entry, dict): + return None + message = entry.get("message") + if not isinstance(message, str) or not message: + return None + line, column = _as_position(entry.get("location"), 1, 1) + end_line, end_column = _as_position(entry.get("end_location"), line, column) + code = entry.get("code") + return Diagnostic( + line=line, + column=column, + # 結束位置若在起始之前(輸出有誤)就退回起始位置 + # An end before the start (malformed output) falls back to the start + end_line=max(end_line, line), + end_column=end_column, + code=code if isinstance(code, str) else SYNTAX_ERROR_CODE, + message=message, + ) + + +def parse_ruff_json(output: str) -> list[Diagnostic]: + """ + 解析 ruff ``--output-format json`` 的輸出 + Parse the output of ruff's ``--output-format json``. + + :param output: ruff 的標準輸出 / ruff's standard output + :return: 診斷清單;輸出為空或無法解析時為空清單 + the diagnostics, or an empty list when the output is empty or unusable + """ + if not output.strip(): + return [] + try: + entries = json.loads(output) + except ValueError: + return [] + if not isinstance(entries, list): + return [] + diagnostics = [_as_diagnostic(entry) for entry in entries] + return [diagnostic for diagnostic in diagnostics if diagnostic is not None] + + +def diagnostics_by_line(diagnostics: list[Diagnostic]) -> dict[int, list[Diagnostic]]: + """ + 依行號分組 + Group diagnostics by their starting line. + + :param diagnostics: 診斷清單 / the diagnostics to group + :return: 行號(1 起算)對應該行的診斷 / 1-based line number -> its diagnostics + """ + grouped: dict[int, list[Diagnostic]] = {} + for diagnostic in diagnostics: + grouped.setdefault(diagnostic.line, []).append(diagnostic) + return grouped + + +def message_for_line(diagnostics: list[Diagnostic], line: int) -> str | None: + """ + 取得某行的診斷說明(多筆以換行分隔) + Return the messages reported on *line*, one per line of text. + + :param diagnostics: 診斷清單 / the diagnostics to search + :param line: 1 起算的行號 / the 1-based line number + :return: 說明文字,該行沒有診斷時為 ``None`` / the text, or ``None`` + """ + on_line = [item.label for item in diagnostics if item.line == line] + return "\n".join(on_line) if on_line else None diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 88c15b8b..a350e15d 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -377,6 +377,15 @@ "todo_panel_col_message": "Message", "todo_panel_col_file": "File", "todo_panel_col_line": "Line", + # Problems panel + "tab_menu_problems_panel_tab_name": "Problems", + "problems_panel_refresh": "Recheck", + "problems_panel_ready": "Ready", + "problems_panel_clean": "No problems found", + "problems_panel_found": "{count} problems", + "problems_panel_col_code": "Rule", + "problems_panel_col_message": "Message", + "problems_panel_col_line": "Line", # Outline panel "tab_menu_outline_panel_tab_name": "Outline", "outline_panel_refresh": "Refresh", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 6c2697ee..e49b7fe2 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -367,6 +367,15 @@ "todo_panel_col_message": "說明", "todo_panel_col_file": "檔案", "todo_panel_col_line": "行號", + # Problems panel + "tab_menu_problems_panel_tab_name": "問題", + "problems_panel_refresh": "重新檢查", + "problems_panel_ready": "就緒", + "problems_panel_clean": "沒有發現問題", + "problems_panel_found": "{count} 個問題", + "problems_panel_col_code": "規則", + "problems_panel_col_message": "訊息", + "problems_panel_col_line": "行號", # Outline panel "tab_menu_outline_panel_tab_name": "大綱", "outline_panel_refresh": "重新整理", diff --git a/test/test_lint_manager.py b/test/test_lint_manager.py new file mode 100644 index 00000000..a6abe1ea --- /dev/null +++ b/test/test_lint_manager.py @@ -0,0 +1,198 @@ +"""Tests for the lint manager, the editor's underlines, and the problems panel.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtGui import QTextCharFormat +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lint.ruff_diagnostics import Diagnostic + +SAMPLE = Diagnostic( + line=1, column=1, end_line=1, end_column=7, code="F401", message="unused import") +OTHER = Diagnostic( + line=2, column=1, end_line=2, end_column=4, code="E701", message="multiple statements") + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestLintManagerState: + def test_starts_empty(self, editor): + assert editor.lint_manager.diagnostics() == [] + + def test_set_diagnostics_reports_a_change(self, editor): + assert editor.lint_manager.set_diagnostics([SAMPLE]) is True + assert editor.lint_manager.set_diagnostics([SAMPLE]) is False + + def test_for_line(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE, OTHER]) + assert editor.lint_manager.for_line(1) == [SAMPLE] + assert editor.lint_manager.for_line(3) == [] + + def test_message_for_line(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE]) + assert "unused import" in editor.lint_manager.message_for_line(1) + assert editor.lint_manager.message_for_line(2) is None + + def test_clear_reports_whether_anything_went(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE]) + assert editor.lint_manager.clear() is True + assert editor.lint_manager.clear() is False + + def test_diagnostics_returns_a_copy(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.lint_manager.diagnostics().clear() + assert editor.lint_manager.diagnostics() == [SAMPLE] + + def test_a_non_python_file_is_not_checked(self, editor): + editor.current_file = "notes.txt" + assert editor.request_lint() is False + + def test_no_file_is_not_checked(self, editor): + editor.current_file = None + assert editor.request_lint() is False + + def test_requesting_a_check_clears_stale_diagnostics(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.current_file = "notes.txt" + editor.request_lint() + assert editor.lint_manager.diagnostics() == [] + + +class TestUnderlines: + def _wave_selections(self, editor): + wave = QTextCharFormat.UnderlineStyle.WaveUnderline + return [ + selection for selection in editor.extraSelections() + if selection.format.underlineStyle() == wave + ] + + def test_diagnostics_are_underlined(self, editor): + editor.setPlainText("import os\nx=1\n") + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.refresh_lint_display() + assert len(self._wave_selections(editor)) == 1 + + def test_underline_covers_the_reported_range(self, editor): + # The range is checked on the cursor the editor builds, rather than on + # the one inside an ExtraSelection, whose C++ object dies with the list. + editor.setPlainText("import os\nx=1\n") + cursor = editor._diagnostic_cursor(editor.document(), SAMPLE) + assert cursor.selectedText() == "import" + + def test_range_on_a_missing_line_is_refused(self, editor): + editor.setPlainText("x = 1\n") + missing = Diagnostic( + line=99, column=1, end_line=99, end_column=4, code="E1", message="gone") + assert editor._diagnostic_cursor(editor.document(), missing) is None + + def test_no_diagnostics_means_no_underline(self, editor): + editor.setPlainText("x = 1\n") + editor.lint_manager.set_diagnostics([]) + editor.refresh_lint_display() + assert self._wave_selections(editor) == [] + + def test_a_diagnostic_past_the_end_is_skipped(self, editor): + editor.setPlainText("x = 1\n") + editor.lint_manager.set_diagnostics([ + Diagnostic(line=99, column=1, end_line=99, end_column=4, code="E1", message="gone")]) + editor.refresh_lint_display() + assert self._wave_selections(editor) == [] + + def test_zero_width_range_still_marks_a_character(self, editor): + editor.setPlainText("x = 1\n") + editor.lint_manager.set_diagnostics([ + Diagnostic(line=1, column=1, end_line=1, end_column=1, code="E2", message="here")]) + editor.refresh_lint_display() + assert len(self._wave_selections(editor)) == 1 + + def test_current_line_highlight_survives_alongside_underlines(self, editor): + editor.setPlainText("import os\n") + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.highlight_current_line() + assert len(editor.extraSelections()) > len(self._wave_selections(editor)) + + def test_caret_line_message_becomes_the_tooltip(self, editor): + editor.setPlainText("import os\nx = 1\n") + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.refresh_lint_display() + assert "unused import" in editor.toolTip() + + +class _FakeTabWidget: + def __init__(self, widget=None): + self._widget = widget + + def currentWidget(self): + return self._widget + + +class TestProblemsPanel: + def test_lists_the_current_editor_diagnostics(self, app, editor): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + tab = MagicMock(spec=EditorWidget) + tab.code_edit = editor + editor.current_file = None + editor.lint_manager.set_diagnostics([SAMPLE, OTHER]) + window = MagicMock() + window.tab_widget = _FakeTabWidget(tab) + with patch.object(editor, "request_lint", return_value=False): + panel = ProblemsPanelWidget(window) + assert panel.result_tree.topLevelItemCount() == 2 + assert panel.result_tree.topLevelItem(0).text(0) == "F401" + panel.close() + panel.deleteLater() + + def test_no_editor_tab_shows_nothing(self, app): + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + window = MagicMock() + window.tab_widget = _FakeTabWidget(None) + panel = ProblemsPanelWidget(window) + assert panel.diagnostics() == [] + assert panel.result_tree.topLevelItemCount() == 0 + panel.close() + panel.deleteLater() + + def test_double_click_jumps_to_the_line(self, app, editor): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + editor.setPlainText("import os\nx = 1\ny = 2\n") + editor.lint_manager.set_diagnostics([OTHER]) + tab = MagicMock(spec=EditorWidget) + tab.code_edit = editor + window = MagicMock() + window.tab_widget = _FakeTabWidget(tab) + with patch.object(editor, "request_lint", return_value=False): + panel = ProblemsPanelWidget(window) + assert panel.jump_to_diagnostic(OTHER) is True + assert editor.textCursor().blockNumber() == 1 + panel.close() + panel.deleteLater() diff --git a/test/test_ruff_diagnostics.py b/test/test_ruff_diagnostics.py new file mode 100644 index 00000000..e60ea298 --- /dev/null +++ b/test/test_ruff_diagnostics.py @@ -0,0 +1,114 @@ +"""Tests for parsing ruff's JSON output into diagnostics.""" +from __future__ import annotations + +import json + +from je_editor.utils.lint.ruff_diagnostics import ( + SYNTAX_ERROR_CODE, + Diagnostic, + diagnostics_by_line, + message_for_line, + parse_ruff_json, +) + + +def _entry(**overrides) -> dict: + entry = { + "code": "F401", + "message": "`os` imported but unused", + "filename": "/project/app.py", + "location": {"row": 1, "column": 8}, + "end_location": {"row": 1, "column": 10}, + "fix": None, + "noqa_row": 1, + "url": "https://docs.astral.sh/ruff/rules/unused-import", + } + entry.update(overrides) + return entry + + +class TestParseRuffJson: + def test_single_finding(self): + found = parse_ruff_json(json.dumps([_entry()])) + assert found == [Diagnostic( + line=1, column=8, end_line=1, end_column=10, + code="F401", message="`os` imported but unused")] + + def test_several_findings_keep_their_order(self): + output = json.dumps([ + _entry(location={"row": 3, "column": 1}), + _entry(location={"row": 1, "column": 1}), + ]) + assert [item.line for item in parse_ruff_json(output)] == [3, 1] + + def test_empty_output(self): + assert parse_ruff_json("") == [] + assert parse_ruff_json(" \n") == [] + + def test_empty_json_array(self): + assert parse_ruff_json("[]") == [] + + def test_invalid_json_is_ignored(self): + assert parse_ruff_json("ruff: command failed") == [] + + def test_json_object_instead_of_array_is_ignored(self): + assert parse_ruff_json('{"error": "boom"}') == [] + + def test_entry_without_a_message_is_skipped(self): + assert parse_ruff_json(json.dumps([_entry(message=None)])) == [] + + def test_entry_that_is_not_an_object_is_skipped(self): + assert parse_ruff_json(json.dumps(["nonsense", _entry()])) == [ + parse_ruff_json(json.dumps([_entry()]))[0]] + + def test_syntax_error_without_a_code(self): + found = parse_ruff_json(json.dumps([_entry(code=None, message="SyntaxError: bad")])) + assert found[0].code == SYNTAX_ERROR_CODE + assert found[0].label == "SyntaxError SyntaxError: bad" + + def test_missing_location_falls_back_to_the_first_line(self): + found = parse_ruff_json(json.dumps([_entry(location=None, end_location=None)])) + assert (found[0].line, found[0].column) == (1, 1) + + def test_missing_end_location_falls_back_to_the_start(self): + found = parse_ruff_json(json.dumps([_entry(end_location=None)])) + assert (found[0].end_line, found[0].end_column) == (1, 8) + + def test_end_before_start_is_clamped(self): + found = parse_ruff_json(json.dumps([ + _entry(location={"row": 5, "column": 2}, end_location={"row": 2, "column": 1})])) + assert found[0].end_line == 5 + + def test_non_integer_position_is_ignored(self): + found = parse_ruff_json(json.dumps([_entry(location={"row": "x", "column": None})])) + assert (found[0].line, found[0].column) == (1, 1) + + def test_label_includes_the_code(self): + assert parse_ruff_json(json.dumps([_entry()]))[0].label.startswith("F401 ") + + +class TestGrouping: + def test_diagnostics_by_line(self): + output = json.dumps([ + _entry(location={"row": 2, "column": 1}), + _entry(location={"row": 2, "column": 5}), + _entry(location={"row": 7, "column": 1}), + ]) + grouped = diagnostics_by_line(parse_ruff_json(output)) + assert sorted(grouped) == [2, 7] + assert len(grouped[2]) == 2 + + def test_message_for_line_joins_every_finding(self): + output = json.dumps([ + _entry(location={"row": 2, "column": 1}, message="first"), + _entry(location={"row": 2, "column": 5}, message="second"), + ]) + message = message_for_line(parse_ruff_json(output), 2) + assert "first" in message and "second" in message + assert message.count("\n") == 1 + + def test_message_for_a_clean_line(self): + assert message_for_line(parse_ruff_json(json.dumps([_entry()])), 99) is None + + def test_message_with_no_diagnostics(self): + assert message_for_line([], 1) is None diff --git a/test/test_ruff_lint_runner.py b/test/test_ruff_lint_runner.py new file mode 100644 index 00000000..760d8692 --- /dev/null +++ b/test/test_ruff_lint_runner.py @@ -0,0 +1,71 @@ +"""Tests for running ruff over a buffer.""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from je_editor.code_scan.ruff_lint import ( + find_ruff_executable, + is_lintable, + lint_command, + lint_text, +) + +UNUSED_IMPORT = "import os\n\n\nprint('hello')\n" + + +class TestIsLintable: + @pytest.mark.parametrize("name", ["a.py", "a.pyi", "PACKAGE/MODULE.PY"]) + def test_python_files(self, name): + assert is_lintable(name) + + @pytest.mark.parametrize("name", ["notes.txt", "page.html", "Makefile", "a.pyc"]) + def test_other_files(self, name): + assert not is_lintable(name) + + def test_no_file(self): + assert not is_lintable(None) + + +class TestLintCommand: + def test_passes_the_buffer_through_stdin(self): + command = lint_command("ruff", Path("/project/app.py")) + assert command[-1] == "-" + assert "--stdin-filename" in command + + def test_asks_for_json(self): + command = lint_command("ruff", "app.py") + assert command[command.index("--output-format") + 1] == "json" + + def test_is_an_argument_list_not_a_shell_string(self): + # A shell string would make the file name injectable. + assert all(isinstance(part, str) for part in lint_command("ruff", "a b.py")) + assert lint_command("ruff", "a b.py")[0] == "ruff" + + +class TestLintText: + def test_reports_an_unused_import(self): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + diagnostics = lint_text(UNUSED_IMPORT, "sample.py") + assert any(item.code == "F401" for item in diagnostics) + + def test_clean_code_reports_nothing(self): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + assert lint_text("print('hello')\n", "sample.py") == [] + + def test_missing_ruff_yields_no_diagnostics(self): + with patch("je_editor.code_scan.ruff_lint.find_ruff_executable", return_value=None): + assert lint_text(UNUSED_IMPORT, "sample.py") == [] + + def test_a_failing_ruff_yields_no_diagnostics(self): + # The editor must keep working when the linter cannot run at all. + with patch( + "je_editor.code_scan.ruff_lint.find_ruff_executable", return_value="ruff" + ), patch( + "je_editor.code_scan.ruff_lint.subprocess.run", side_effect=OSError("boom") + ): + assert lint_text(UNUSED_IMPORT, "sample.py") == [] From 931aa7653cbd5f38f864eb2ea4c1b0206508a15c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:12:43 +0800 Subject: [PATCH 04/36] Revert the git change under the caret The baseline the gutter markers compare against was already in memory, so restoring a change needed no new git call: Ctrl+Alt+Z puts the block under the caret back to its committed content as a single undo step. Reverting a purely added line removes the whole line rather than blanking it, and a deletion is restored by inserting the committed lines back at that point. --- .../code/git_diff/diff_marker_manager.py | 36 ++++ .../code_edit_plaintext.py | 126 ++++++++++++++ je_editor/utils/file_diff/line_status.py | 88 ++++++++++ test/test_revert_hunk.py | 157 ++++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 test/test_revert_hunk.py diff --git a/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py index d0a7876c..72f33f5b 100644 --- a/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py +++ b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py @@ -16,6 +16,9 @@ from je_editor.git_client.file_baseline import baseline_text from je_editor.utils.file_diff.line_status import ( + Hunk, + baseline_lines_of, + hunk_at_line, line_statuses, next_changed_line, previous_changed_line, @@ -63,6 +66,15 @@ def has_baseline(self) -> bool: """是否已取得基準(檔案在 git 中且有提交過)/ Whether a baseline is known.""" return self._baseline is not None + def baseline(self) -> str | None: + """ + 取得目前的基準文字 + Return the baseline the markers are computed against. + + :return: HEAD 版本的內容,沒有基準時為 ``None`` / the committed text, or ``None`` + """ + return self._baseline + def set_baseline(self, text: str | None) -> None: """ 設定基準文字並重算標記 @@ -119,6 +131,30 @@ def previous_change(self, line: int) -> int | None: """上一個變更行 / The previous changed line, wrapping around.""" return previous_changed_line(self._statuses, line) + def hunk_at(self, line: int) -> Hunk | None: + """ + 取得包含指定行的變更區塊 + Return the hunk of change containing *line*. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 變更區塊,該行沒有變更時為 ``None`` / the hunk, or ``None`` + """ + if self._baseline is None: + return None + return hunk_at_line(self._baseline, self._code_edit.toPlainText(), line) + + def baseline_lines(self, hunk: Hunk) -> list[str]: + """ + 取得一段變更在基準中的原始內容 + The baseline lines a hunk replaced. + + :param hunk: 目標變更區塊 / the hunk to look up + :return: 原始行 / the original lines + """ + if self._baseline is None: + return [] + return baseline_lines_of(self._baseline, hunk) + def load_baseline(self, file_path: str | Path | None) -> None: """ 在背景重新讀取基準;沒有檔案時直接清除 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 b7763a21..16935bce 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 @@ -14,6 +14,7 @@ from je_editor.pyside_ui.code.bookmark.bookmark_manager import BookmarkManager from je_editor.pyside_ui.code.folding.folding_manager import FoldingManager +from je_editor.pyside_ui.code.git_diff.blame_manager import BlameManager from je_editor.pyside_ui.code.git_diff.diff_marker_manager import DiffMarkerManager from je_editor.pyside_ui.code.lint.lint_manager import LintManager from je_editor.pyside_ui.code.selection.smart_selection_manager import SmartSelectionManager @@ -102,6 +103,10 @@ def _lint_underline_format() -> QTextCharFormat: return formats +# 行尾文字與 blame 標註之間的間隔(像素) +# Gap in pixels between a line's text and its blame annotation +_BLAME_TEXT_GAP = 24 + # 變更狀態對應的顏色設定鍵 / Colour setting key for each change status _DIFF_MARKER_COLOR_KEYS = { LINE_ADDED: "diff_added_marker_color", @@ -266,6 +271,7 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # git change markers: the baseline loads in the background, and the # recompute is debounced so typing does not diff on every keystroke self.diff_marker_manager = DiffMarkerManager(self) + self.blame_manager = BlameManager(self) self._diff_timer = QTimer(self) self._diff_timer.setSingleShot(True) self._diff_timer.setInterval(_DIFF_REFRESH_DELAY_MS) @@ -501,6 +507,8 @@ def _register_diff_marker_actions(self) -> None: for shortcut, handler in ( ("Ctrl+Alt+Down", self.next_change), ("Ctrl+Alt+Up", self.previous_change), + ("Ctrl+Alt+Z", self.revert_change_at_cursor), + ("Ctrl+Alt+B", self.toggle_blame), ): action = QAction(self) action.setShortcut(shortcut) @@ -533,6 +541,124 @@ def _go_to_change(self, line: Union[int, None]) -> bool: return False return self.jump_to_line(line + 1) + def toggle_blame(self) -> bool: + """ + 切換行內 blame 顯示 + Toggle the inline blame annotations. + + :return: 切換後是否為開啟 / whether the annotations are now shown + """ + enabled = self.blame_manager.toggle(self.current_file) + self.viewport().update() + return enabled + + def paintEvent(self, event: QtGui.QPaintEvent) -> None: + """ + 繪製內容,並在開啟時附上行尾的 blame 標註 + Paint the text, then the end-of-line blame annotations when they are on. + """ + QPlainTextEdit.paintEvent(self, event) + if self.blame_manager.enabled: + self._paint_blame_annotations() + + def _paint_blame_annotations(self) -> None: + """在每個可見行的文字後面畫出 blame 標註 / Draw blame after each visible line.""" + painter = QPainter(self.viewport()) + painter.setPen(actually_color_dict.get("blame_annotation_color")) + metrics = self.fontMetrics() + line_height = metrics.height() + block = self.firstVisibleBlock() + block_number = block.blockNumber() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + while block.isValid() and top <= self.viewport().rect().bottom(): + if block.isVisible(): + annotation = self.blame_manager.annotation(block_number) + if annotation: + # 接在該行文字之後,中間留一段空白 + # Placed after the line's own text, with a gap between them + text_width = metrics.horizontalAdvance(block.text()) + painter.drawText( + int(text_width) + _BLAME_TEXT_GAP, int(top), + self.viewport().width(), line_height, + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + annotation) + top += self.blockBoundingRect(block).height() + block = block.next() + block_number += 1 + painter.end() + + def revert_change_at_cursor(self) -> bool: + """ + 把游標所在的變更區塊還原成已提交的內容 + Restore the change block under the caret to its committed content. + + 還原是單一復原步驟,因此按一次 Ctrl+Z 就能取消。 + The revert is one undo step, so a single Ctrl+Z takes it back. + + :return: 有東西被還原時為 ``True`` / ``True`` when something was reverted + """ + line = self.textCursor().blockNumber() + hunk = self.diff_marker_manager.hunk_at(line) + if hunk is None: + return False + original = self.diff_marker_manager.baseline_lines(hunk) + cursor = self.textCursor() + cursor.beginEditBlock() + try: + self._replace_lines_with(cursor, hunk, original) + finally: + cursor.endEditBlock() + self._refresh_diff_markers() + return True + + def _replace_lines_with(self, cursor: QTextCursor, hunk, original: List[str]) -> None: + """ + 以基準內容取代一段變更的行 + Replace a hunk's lines with the baseline content. + + 純刪除在目前內容中沒有範圍,因此改為在該位置插回原本的行;純新增在基準 + 中沒有內容,因此要連同換行整行移除,只清掉文字會留下一個空行。 + A pure deletion spans no lines, so its content is inserted back instead; + a pure insertion has no baseline content, so the whole line is removed + including its line break — clearing only the text would leave a blank line. + """ + document = self.document() + if hunk.is_pure_deletion: + block = document.findBlockByNumber(min(hunk.start, document.blockCount() - 1)) + cursor.setPosition(block.position()) + cursor.insertText("\n".join(original) + "\n") + return + if not original: + self._remove_blocks(cursor, hunk.start, hunk.end) + return + start_block = document.findBlockByNumber(hunk.start) + end_block = document.findBlockByNumber(hunk.end - 1) + cursor.setPosition(start_block.position()) + cursor.setPosition( + end_block.position() + end_block.length() - 1, QTextCursor.MoveMode.KeepAnchor) + cursor.insertText("\n".join(original)) + + def _remove_blocks(self, cursor: QTextCursor, start: int, end: int) -> None: + """ + 移除整段行,包含行尾的換行 + Remove whole lines, line breaks included. + """ + document = self.document() + start_block = document.findBlockByNumber(start) + end_block = document.findBlockByNumber(end - 1) + start_position = start_block.position() + end_position = end_block.position() + end_block.length() + last_position = document.characterCount() - 1 + if end_position > last_position: + # 刪到檔尾:改為往前吃掉上一行的換行,才不會留下空行 + # Deleting to the end of the file: take the preceding line break + # instead, so no blank line is left behind + end_position = last_position + start_position = max(0, start_position - 1) + cursor.setPosition(start_position) + cursor.setPosition(end_position, QTextCursor.MoveMode.KeepAnchor) + cursor.removeSelectedText() + def _show_lint_message_for_caret(self) -> None: """ 把游標所在行的診斷顯示為提示文字 diff --git a/je_editor/utils/file_diff/line_status.py b/je_editor/utils/file_diff/line_status.py index 12e69b12..9cb236e8 100644 --- a/je_editor/utils/file_diff/line_status.py +++ b/je_editor/utils/file_diff/line_status.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +from dataclasses import dataclass from difflib import SequenceMatcher # 一行相對於基準的狀態 / How one line differs from the baseline @@ -75,6 +76,93 @@ def line_statuses(baseline: str, current: str) -> dict[int, str]: return statuses +@dataclass(frozen=True) +class Hunk: + """ + 一段連續的變更,以及它在基準中的對應範圍 + One run of change, and the baseline range it corresponds to. + + :param start: 目前內容中的起始行(0 起算)/ start line in the current text + :param end: 目前內容中的結束行(不含)/ end line in the current text, exclusive + :param baseline_start: 基準中的起始行 / start line in the baseline + :param baseline_end: 基準中的結束行(不含)/ end line in the baseline, exclusive + """ + + start: int + end: int + baseline_start: int + baseline_end: int + + @property + def is_pure_deletion(self) -> bool: + """這段變更是否只有刪除(目前內容中沒有對應的行)/ Whether only lines were removed.""" + return self.start == self.end + + def contains(self, line: int) -> bool: + """ + 判斷某行是否屬於這段變更 + Whether *line* belongs to this hunk. + + 純刪除在目前內容中沒有範圍,因此以刪除位置那一行為準。 + A pure deletion spans no lines, so the line at the deletion point counts. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 屬於這段變更時為 ``True`` / ``True`` when the line is inside + """ + if self.is_pure_deletion: + return line == self.start + return self.start <= line < self.end + + +def hunks(baseline: str, current: str) -> list[Hunk]: + """ + 找出所有變更區塊 + Find every run of change between the baseline and the current text. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :return: 變更區塊,依出現順序 / the hunks, in order + """ + baseline_lines = _split_lines(baseline) + current_lines = _split_lines(current) + if max(len(baseline_lines), len(current_lines)) > MAX_DIFFED_LINES: + return [] + matcher = SequenceMatcher(None, baseline_lines, current_lines, autojunk=False) + return [ + Hunk(start=start, end=end, baseline_start=base_start, baseline_end=base_end) + for tag, base_start, base_end, start, end in matcher.get_opcodes() + if tag != "equal" + ] + + +def hunk_at_line(baseline: str, current: str, line: int) -> Hunk | None: + """ + 取得包含指定行的變更區塊 + Return the hunk that contains *line*. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :param line: 以 0 起算的行號 / the 0-based line number + :return: 該變更區塊,該行沒有變更時為 ``None`` / the hunk, or ``None`` + """ + for hunk in hunks(baseline, current): + if hunk.contains(line): + return hunk + return None + + +def baseline_lines_of(baseline: str, hunk: Hunk) -> list[str]: + """ + 取得一段變更在基準中的原始內容 + Return the baseline lines a hunk replaced. + + :param baseline: 已提交的內容 / the committed content + :param hunk: 目標變更區塊 / the hunk to look up + :return: 原始行(不含換行字元)/ the original lines, without line breaks + """ + return _split_lines(baseline)[hunk.baseline_start:hunk.baseline_end] + + def changed_line_numbers(statuses: dict[int, str]) -> list[int]: """ 取得有變更的行號(由小到大) diff --git a/test/test_revert_hunk.py b/test/test_revert_hunk.py new file mode 100644 index 00000000..141bdf04 --- /dev/null +++ b/test/test_revert_hunk.py @@ -0,0 +1,157 @@ +"""Tests for hunk detection and reverting a change back to its committed form.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.file_diff.line_status import ( + baseline_lines_of, + hunk_at_line, + hunks, +) + + +class TestHunks: + def test_no_change_has_no_hunks(self): + assert hunks("a\nb\n", "a\nb\n") == [] + + def test_a_modified_line(self): + found = hunks("a\nb\nc\n", "a\nB\nc\n") + assert len(found) == 1 + assert (found[0].start, found[0].end) == (1, 2) + assert (found[0].baseline_start, found[0].baseline_end) == (1, 2) + + def test_an_inserted_line_spans_no_baseline(self): + found = hunks("a\nc\n", "a\nb\nc\n") + assert (found[0].start, found[0].end) == (1, 2) + assert found[0].baseline_start == found[0].baseline_end + + def test_a_deleted_line_spans_no_current_lines(self): + found = hunks("a\nb\nc\n", "a\nc\n") + assert found[0].is_pure_deletion + assert (found[0].baseline_start, found[0].baseline_end) == (1, 2) + + def test_several_hunks_are_reported_in_order(self): + found = hunks("a\nb\nc\nd\n", "A\nb\nc\nD\n") + assert [hunk.start for hunk in found] == [0, 3] + + def test_hunk_at_line_finds_the_containing_hunk(self): + hunk = hunk_at_line("a\nb\nc\n", "a\nB\nc\n", 1) + assert hunk is not None and hunk.start == 1 + + def test_hunk_at_an_unchanged_line(self): + assert hunk_at_line("a\nb\nc\n", "a\nB\nc\n", 0) is None + + def test_hunk_at_a_deletion_point(self): + hunk = hunk_at_line("a\nb\nc\n", "a\nc\n", 1) + assert hunk is not None and hunk.is_pure_deletion + + def test_baseline_lines_of_a_hunk(self): + hunk = hunk_at_line("a\nold\nc\n", "a\nnew\nc\n", 1) + assert baseline_lines_of("a\nold\nc\n", hunk) == ["old"] + + def test_baseline_lines_of_an_insertion_is_empty(self): + hunk = hunk_at_line("a\nc\n", "a\nb\nc\n", 1) + assert baseline_lines_of("a\nc\n", hunk) == [] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +def _place_cursor(editor, line: int) -> None: + block = editor.document().findBlockByNumber(line) + cursor = editor.textCursor() + cursor.setPosition(block.position()) + editor.setTextCursor(cursor) + + +class TestRevertChange: + def test_modified_line_goes_back_to_the_committed_text(self, editor): + editor.setPlainText("a\nCHANGED\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 1) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb\nc\n" + + def test_added_line_is_removed(self, editor): + editor.setPlainText("a\nextra\nb\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + _place_cursor(editor, 1) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb\n" + + def test_deleted_line_comes_back(self, editor): + editor.setPlainText("a\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 1) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb\nc\n" + + def test_only_the_hunk_under_the_caret_is_reverted(self, editor): + editor.setPlainText("A\nb\nC\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 0) + editor.revert_change_at_cursor() + assert editor.toPlainText() == "a\nb\nC\n" + + def test_reverting_an_unchanged_line_does_nothing(self, editor): + editor.setPlainText("a\nB\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 0) + assert editor.revert_change_at_cursor() is False + assert editor.toPlainText() == "a\nB\nc\n" + + def test_reverting_without_a_baseline_does_nothing(self, editor): + editor.setPlainText("a\nb\n") + _place_cursor(editor, 0) + assert editor.revert_change_at_cursor() is False + + def test_revert_is_a_single_undo_step(self, editor): + editor.setPlainText("a\nCHANGED\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 1) + editor.revert_change_at_cursor() + editor.undo() + assert editor.toPlainText() == "a\nCHANGED\nc\n" + + def test_markers_are_updated_after_a_revert(self, editor): + editor.setPlainText("a\nCHANGED\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 1) + editor.revert_change_at_cursor() + assert editor.diff_marker_manager.statuses() == {} + + def test_added_line_at_the_end_of_file_is_removed(self, editor): + editor.setPlainText("a\nb\nextra") + editor.diff_marker_manager.set_baseline("a\nb") + _place_cursor(editor, 2) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb" + + def test_reverting_a_multi_line_hunk(self, editor): + editor.setPlainText("a\nX\nY\nd\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\nd\n") + _place_cursor(editor, 1) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb\nc\nd\n" From 22d2a5082b10803a26c163044c455197cbde7172 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:12:57 +0800 Subject: [PATCH 05/36] Diff the open file against HEAD GitService could only show a whole commit's diff, so seeing what changed in the file being edited meant leaving the editor. The Git menu now opens the current file's diff against its committed version in the existing side-by-side viewer, built from the baseline the editor already holds rather than a fresh git read. An unchanged file opens nothing instead of an empty tab. --- .../menu/tab_menu/build_tab_git_menu.py | 74 ++++++++++ je_editor/utils/file_diff/unified.py | 46 +++++++ test/test_head_diff.py | 126 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 je_editor/utils/file_diff/unified.py create mode 100644 test/test_head_diff.py diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_git_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_git_menu.py index 8650b221..413356a8 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_git_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_git_menu.py @@ -1,11 +1,14 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtGui import QAction from je_editor.pyside_ui.git_ui.code_diff_compare.code_diff_viewer_widget import DiffViewerWidget +from je_editor.utils.file_diff.unified import unified_diff_text + from je_editor.pyside_ui.git_ui.git_client.git_branch_tree_widget import GitTreeViewGUI from je_editor.pyside_ui.git_ui.git_client.git_client_gui import GitGui @@ -48,6 +51,77 @@ def set_tab_git_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.tab_menu.git_menu.addAction(ui_we_want_to_set.tab_menu.git_menu.add_code_diff_viewer_ui_action) + # === 目前檔案與 HEAD 的差異 === + # === Diff the current file against HEAD === + ui_we_want_to_set.tab_menu.git_menu.add_file_diff_action = QAction( + language_wrapper.language_word_dict.get("tab_menu_diff_against_head_name")) + ui_we_want_to_set.tab_menu.git_menu.add_file_diff_action.triggered.connect( + lambda: add_head_diff_tab(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.git_menu.addAction(ui_we_want_to_set.tab_menu.git_menu.add_file_diff_action) + + +def current_editor_widget(ui_we_want_to_set: EditorMain): + """ + 取得目前分頁的編輯器 + Return the editor in the current tab. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 編輯器分頁,目前分頁不是編輯器時為 ``None`` / the editor tab, or ``None`` + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return None + widget = tab_widget.currentWidget() + return widget if isinstance(widget, EditorWidget) else None + + +def head_diff_text(ui_we_want_to_set: EditorMain) -> str: + """ + 取得目前檔案相對於 HEAD 的差異 + Return the current file's diff against its committed version. + + 使用編輯器已經在背景取得的基準,因此不需要再讀一次 git。 + Uses the baseline the editor already fetched in the background, so git is + not read again. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: diff 文字;沒有可比較的內容時為空字串 / the diff, or an empty string + """ + editor_widget = current_editor_widget(ui_we_want_to_set) + if editor_widget is None: + return "" + code_edit = editor_widget.code_edit + baseline = code_edit.diff_marker_manager.baseline() + if baseline is None: + return "" + name = Path(code_edit.current_file).name if code_edit.current_file else "" + return unified_diff_text(baseline, code_edit.toPlainText(), name) + + +def add_head_diff_tab(ui_we_want_to_set: EditorMain) -> bool: + """ + 以並排比對開啟目前檔案與 HEAD 的差異 + Open the current file's diff against HEAD in the side-by-side viewer. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 有差異並開啟分頁時為 ``True`` / ``True`` when a tab was opened + """ + jeditor_logger.info("build_tab_git_menu.py add head diff tab") + diff_text = head_diff_text(ui_we_want_to_set) + if not diff_text: + return False + viewer = DiffViewerWidget() + viewer.viewer.set_diff_text(diff_text) + ui_we_want_to_set.tab_widget.addTab( + viewer, + f"{language_wrapper.language_word_dict.get('tab_menu_diff_against_head_name')} " + f"{ui_we_want_to_set.tab_widget.count()}" + ) + return True + + def add_git_client_tab(ui_we_want_to_set: EditorMain) -> None: # 紀錄日誌:新增 Git Client 分頁 # Log: add a Git Client tab diff --git a/je_editor/utils/file_diff/unified.py b/je_editor/utils/file_diff/unified.py new file mode 100644 index 00000000..0a2d7cf2 --- /dev/null +++ b/je_editor/utils/file_diff/unified.py @@ -0,0 +1,46 @@ +""" +產生「已提交版本 vs 編輯中內容」的 unified diff +Produce a unified diff of the committed version against the buffer. + +輸出格式與 ``git diff`` 相同,因此既有的並排比對元件可以直接解析。 +The output has the same shape as ``git diff``, so the existing side-by-side +viewer can parse it as-is. Pure logic: no Qt, no git. +""" +from __future__ import annotations + +from difflib import unified_diff + +# diff 標頭中代表「已提交版本」與「編輯中內容」的標籤 +# Labels used in the diff header for the committed and edited sides +COMMITTED_LABEL = "HEAD" +WORKING_LABEL = "working copy" +# 變更前後保留的上下文行數,與 git 預設一致 +# Context lines kept around a change, matching git's default +CONTEXT_LINES = 3 + + +def unified_diff_text( + baseline: str, current: str, file_name: str = "", + context_lines: int = CONTEXT_LINES) -> str: + """ + 比較兩份內容並產生 unified diff + Compare two texts and render a unified diff. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :param file_name: 顯示在標頭的檔名 / the file name shown in the header + :param context_lines: 保留的上下文行數 / how many context lines to keep + :return: diff 文字;兩者相同時為空字串 / the diff, or an empty string when equal + """ + if baseline == current: + return "" + from_label = f"a/{file_name} ({COMMITTED_LABEL})" if file_name else COMMITTED_LABEL + to_label = f"b/{file_name} ({WORKING_LABEL})" if file_name else WORKING_LABEL + lines = unified_diff( + baseline.splitlines(keepends=True), + current.splitlines(keepends=True), + fromfile=from_label, + tofile=to_label, + n=context_lines, + ) + return "".join(lines) diff --git a/test/test_head_diff.py b/test/test_head_diff.py new file mode 100644 index 00000000..f2c2e8cf --- /dev/null +++ b/test/test_head_diff.py @@ -0,0 +1,126 @@ +"""Tests for diffing the open file against its committed version.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.file_diff.unified import unified_diff_text + + +class TestUnifiedDiffText: + def test_identical_text_produces_no_diff(self): + assert unified_diff_text("a\nb\n", "a\nb\n") == "" + + def test_changed_line_appears_on_both_sides(self): + diff = unified_diff_text("a\nb\n", "a\nB\n") + assert "-b" in diff + assert "+B" in diff + + def test_added_line_is_marked(self): + diff = unified_diff_text("a\n", "a\nb\n") + assert "+b" in diff + + def test_removed_line_is_marked(self): + diff = unified_diff_text("a\nb\n", "a\n") + assert "-b" in diff + + def test_header_names_both_sides(self): + diff = unified_diff_text("a\n", "b\n", "app.py") + assert "a/app.py" in diff and "HEAD" in diff + assert "b/app.py" in diff and "working copy" in diff + + def test_header_without_a_file_name(self): + diff = unified_diff_text("a\n", "b\n") + assert "HEAD" in diff and "working copy" in diff + + def test_context_lines_are_limited(self): + baseline = "".join(f"line{index}\n" for index in range(40)) + current = baseline.replace("line20", "CHANGED") + diff = unified_diff_text(baseline, current, context_lines=1) + assert "line19" in diff + assert "line10" not in diff + + def test_empty_baseline_shows_the_whole_file_as_added(self): + diff = unified_diff_text("", "a\nb\n") + assert "+a" in diff and "+b" in diff + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +class _FakeTabWidget: + def __init__(self, widget=None): + self._widget = widget + self.added = [] + + def currentWidget(self): + return self._widget + + def count(self): + return len(self.added) + + def addTab(self, widget, label): + self.added.append((widget, label)) + + +def _window_with(editor, monkey_editor_widget=True): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab = MagicMock(spec=EditorWidget) if monkey_editor_widget else object() + if monkey_editor_widget: + tab.code_edit = editor + window = MagicMock() + window.tab_widget = _FakeTabWidget(tab) + return window + + +class TestHeadDiffTab: + def test_diff_text_uses_the_editor_baseline(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import head_diff_text + editor.setPlainText("a\nCHANGED\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + diff = head_diff_text(_window_with(editor)) + assert "-b" in diff and "+CHANGED" in diff + + def test_no_baseline_means_no_diff(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import head_diff_text + editor.setPlainText("a\n") + assert head_diff_text(_window_with(editor)) == "" + + def test_unchanged_file_means_no_diff(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import head_diff_text + editor.setPlainText("a\nb\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + assert head_diff_text(_window_with(editor)) == "" + + def test_a_non_editor_tab_means_no_diff(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import head_diff_text + assert head_diff_text(_window_with(editor, monkey_editor_widget=False)) == "" + + def test_opening_the_tab_without_changes_does_nothing(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import add_head_diff_tab + editor.setPlainText("a\n") + editor.diff_marker_manager.set_baseline("a\n") + window = _window_with(editor) + assert add_head_diff_tab(window) is False + assert window.tab_widget.added == [] From f3ab39fc587c63b4cb249246c04182d0bdb9ee53 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:12:57 +0800 Subject: [PATCH 06/36] Show inline git blame Ctrl+Alt+B annotates each line with the commit, author and summary that last touched it, drawn after the line text so the code itself is not moved. The blame runs git, so it is fetched on a worker thread and only once the display is actually turned on; toggling off drops the data. Also silences the repository''s one remaining ruff finding: the Qt import in the CI smoke test must follow the platform variable it sets, which is now stated in a comment rather than left to look like an oversight. --- README.md | 2 +- je_editor/git_client/file_blame.py | 86 +++++++++++ .../pyside_ui/code/git_diff/blame_manager.py | 121 +++++++++++++++ .../pyside_ui/main_ui/editor/editor_widget.py | 1 + .../save_settings/user_color_setting_file.py | 4 +- je_editor/utils/multi_language/english.py | 1 + .../multi_language/traditional_chinese.py | 1 + test/qt_ui/unit_test/extend_test.py | 6 +- test/test_blame.py | 146 ++++++++++++++++++ 9 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 je_editor/git_client/file_blame.py create mode 100644 je_editor/pyside_ui/code/git_diff/blame_manager.py create mode 100644 test/test_blame.py diff --git a/README.md b/README.md index c2b5f5d3..71d7ca23 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ The editor launches in a maximized window with a dark amber theme by default. - **Code Folding** -- Collapse and expand indented blocks (functions, classes, loops) from the gutter fold triangles or the keyboard. Folding is indentation-based and only toggles line visibility -- it never modifies the text, so saving always writes the complete file. Folds self-heal after edits: a fold whose header no longer exists simply reopens instead of hiding the wrong lines. - **Bookmarks** -- Mark lines and jump between them with the keyboard, or click the gutter to toggle. Bookmarks are anchored to the text (via `QTextCursor`), so they follow their code when lines are inserted or removed above them instead of drifting. - **Lint Diagnostics** -- Findings from `ruff` are underlined in the editor and listed in a Problems dock panel (rule, message, line), with double-click to jump. The **buffer** is checked, not the file on disk, so unsaved edits are covered; the run happens on a worker thread after typing pauses, and a stale result from a superseded run is discarded. If `ruff` is not installed, or a run fails, the editor simply shows no diagnostics rather than reporting an error. -- **Git Change Markers** -- The gutter shows how the file differs from its last commit: a green bar for added lines, orange for modified, and a thin red line where lines were deleted. Jump between changes with `Ctrl+Alt+Down` / `Ctrl+Alt+Up`. The committed version is read on a background thread when the file opens, and the comparison itself is a pure in-memory diff, recomputed only after typing pauses -- so editing never waits on git. Files outside a repository, or not yet committed, simply show no markers. +- **Git Change Markers** -- The gutter shows how the file differs from its last commit: a green bar for added lines, orange for modified, and a thin red line where lines were deleted. Jump between changes with `Ctrl+Alt+Down` / `Ctrl+Alt+Up`, revert the change under the caret back to its committed form with `Ctrl+Alt+Z` (one undo step), and open a side-by-side diff of the whole file against HEAD from the Git menu. `Ctrl+Alt+B` toggles inline blame, showing the commit, author and summary that last touched each line. The committed version is read on a background thread when the file opens, and the comparison itself is a pure in-memory diff, recomputed only after typing pauses -- so editing never waits on git. Files outside a repository, or not yet committed, simply show no markers. - **Occurrence Highlighting** -- Placing the caret on an identifier highlights every other whole-word occurrence of it in the file. Keywords and single characters are ignored, and the scan is skipped on very large files to keep caret movement instant. - **Line Operations** -- Delete the current line or selection (`Ctrl+Shift+D`), sort selected lines (`Ctrl+Alt+S`), join selected lines into one (`Ctrl+Shift+J`), and (from the Text menu) natural sort, remove duplicate lines, remove blank lines, reverse line order, or align lines on a delimiter (e.g. `=`). Each is a single undo step. - **Duplicate** (`Ctrl+D`) -- Duplicates the selection when there is one (selecting the new copy), or the whole line when there isn't. diff --git a/je_editor/git_client/file_blame.py b/je_editor/git_client/file_blame.py new file mode 100644 index 00000000..fbe91262 --- /dev/null +++ b/je_editor/git_client/file_blame.py @@ -0,0 +1,86 @@ +""" +取得檔案每一行最後修改的提交 +Find the commit that last touched each line of a file. + +用於在編輯器行尾顯示「這行是誰、哪個提交改的」。只讀取,不改動工作區。 +Used to show who last changed a line, and in which commit, at the end of that +line in the editor. Read-only; the working tree is never touched. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from git.exc import GitError + +from je_editor.git_client.file_baseline import open_repository +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 顯示用的提交編號長度 / How much of the commit hash to show +SHORT_HASH_LENGTH = 8 +# 摘要過長時截斷的長度 / Where an over-long summary is cut +MAX_SUMMARY_LENGTH = 60 + + +@dataclass(frozen=True) +class BlameLine: + """ + 一行的提交資訊 + The commit information for one line. + + :param commit: 提交編號(已截短)/ the shortened commit hash + :param author: 作者名稱 / the author's name + :param summary: 提交訊息第一行 / the first line of the commit message + """ + + commit: str + author: str + summary: str + + @property + def annotation(self) -> str: + """行尾顯示的一行文字 / The single line shown at the end of the code line.""" + return f"{self.commit} {self.author} {self.summary}" + + +def _short_summary(message: object) -> str: + """取提交訊息的第一行並截斷 / Take the first line of a commit message, shortened.""" + text = str(message or "").strip().splitlines() + if not text: + return "" + first = text[0] + return first if len(first) <= MAX_SUMMARY_LENGTH else f"{first[:MAX_SUMMARY_LENGTH]}…" + + +def blame_lines(file_path: str | Path) -> dict[int, BlameLine]: + """ + 取得檔案每一行的提交資訊 + Return the commit information for each line of a file. + + :param file_path: 檔案路徑 / the file to annotate + :return: 以 0 起算的行號對應提交資訊;無法取得時為空字典 + 0-based line number -> its commit, or an empty mapping when unavailable + """ + repo = open_repository(file_path) + if repo is None: + return {} + try: + relative = Path(file_path).resolve().relative_to(Path(repo.working_tree_dir).resolve()) + entries = repo.blame("HEAD", relative.as_posix()) + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError) as error: + # 未提交過、不在工作區內、或沒有 HEAD / Never committed, outside the tree, or no HEAD + jeditor_logger.debug(f"file_blame: no blame for {file_path}: {error!r}") + return {} + annotations: dict[int, BlameLine] = {} + line_number = 0 + for entry in entries or []: + commit, lines = entry[0], entry[1] + blame = BlameLine( + commit=str(commit.hexsha)[:SHORT_HASH_LENGTH], + author=str(getattr(commit.author, "name", "")), + summary=_short_summary(getattr(commit, "summary", "")), + ) + for _line in lines or []: + annotations[line_number] = blame + line_number += 1 + return annotations diff --git a/je_editor/pyside_ui/code/git_diff/blame_manager.py b/je_editor/pyside_ui/code/git_diff/blame_manager.py new file mode 100644 index 00000000..af18b65f --- /dev/null +++ b/je_editor/pyside_ui/code/git_diff/blame_manager.py @@ -0,0 +1,121 @@ +""" +管理編輯器的行內 blame 標註 +Hold the inline blame annotations for one editor. + +blame 要跑 git,因此在背景執行緒取得;只有使用者開啟顯示時才會去取。 +Blame runs git, so it is fetched on a worker thread, and only once the user has +actually turned the display on. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QThread, Signal + +from je_editor.git_client.file_blame import BlameLine, blame_lines + + +class BlameLoader(QThread): + """ + 在背景取得檔案的 blame 資訊 + Fetch a file's blame off the UI thread. + """ + + loaded = Signal(object) # dict[int, BlameLine] + + def __init__(self, file_path: str | Path, parent=None) -> None: + """ + :param file_path: 要取得 blame 的檔案 / the file to annotate + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._file_path = file_path + + def run(self) -> None: + """取得並回報 blame 資訊 / Fetch the blame and report it.""" + self.loaded.emit(blame_lines(self._file_path)) + + +class BlameManager: + """ + 追蹤行內 blame 的開關與內容 + Track whether inline blame is shown, and what it says. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些標註所屬的編輯器 / the editor being annotated + """ + self._code_edit = code_edit + self._annotations: dict[int, BlameLine] = {} + self._enabled = False + self._loader: BlameLoader | None = None + + @property + def enabled(self) -> bool: + """目前是否顯示 blame / Whether the annotations are being shown.""" + return self._enabled + + def annotation(self, line: int) -> str: + """ + 取得某行的標註文字 + The annotation text for one line. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 標註文字,關閉或該行無資料時為空字串 / the text, or an empty string + """ + if not self._enabled: + return "" + blame = self._annotations.get(line) + return blame.annotation if blame is not None else "" + + def set_annotations(self, annotations: dict[int, BlameLine]) -> None: + """ + 套用一組標註 + Apply a set of annotations. + + :param annotations: 行號對應的提交資訊 / line number -> commit information + """ + self._annotations = dict(annotations) + + def clear(self) -> None: + """清除標註並關閉顯示 / Drop the annotations and turn the display off.""" + self._annotations = {} + self._enabled = False + + def toggle(self, file_path: str | Path | None) -> bool: + """ + 切換顯示;開啟時在背景取得資料 + Toggle the display, fetching the data in the background when turning on. + + :param file_path: 目前編輯的檔案 / the file being edited + :return: 切換後是否為開啟 / whether the display is now on + """ + self.stop() + if self._enabled: + self._enabled = False + self._annotations = {} + return False + if file_path is None: + return False + self._enabled = True + loader = BlameLoader(file_path) + self._loader = loader + loader.loaded.connect(lambda annotations: self._on_loaded(loader, annotations)) + loader.finished.connect(loader.deleteLater) + loader.start() + return True + + def _on_loaded(self, loader: BlameLoader, annotations: dict) -> None: + """套用背景取得的結果 / Apply annotations that finished loading.""" + if loader is not self._loader: + return + self.set_annotations(annotations) + self._code_edit.viewport().update() + + def stop(self) -> None: + """結束仍在進行的取得 / Stop a fetch that is still running.""" + loader, self._loader = self._loader, None + if loader is not None and loader.isRunning(): + loader.blockSignals(True) + loader.wait() diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index 6862a69f..d8ab8bdc 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -464,6 +464,7 @@ def close(self) -> bool: # 停止仍在執行的背景檢查 / Stop background checks still running self.code_edit.diff_marker_manager.stop() self.code_edit.lint_manager.stop() + self.code_edit.blame_manager.stop() if self.current_file: file_is_open_manager_dict.pop(str(Path(self.current_file)), None) diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 31a91d1d..4475eb69 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -43,6 +43,7 @@ def update_actually_color_dict() -> None: "diff_modified_marker_color": _to_qcolor("diff_modified_marker_color", [255, 167, 38]), "diff_removed_marker_color": _to_qcolor("diff_removed_marker_color", [229, 57, 53]), "lint_underline_color": _to_qcolor("lint_underline_color", [255, 138, 101]), + "blame_annotation_color": _to_qcolor("blame_annotation_color", [130, 130, 130]), } ) @@ -62,7 +63,8 @@ def update_actually_color_dict() -> None: "diff_added_marker_color": [76, 175, 80], "diff_modified_marker_color": [255, 167, 38], "diff_removed_marker_color": [229, 57, 53], - "lint_underline_color": [255, 138, 101] + "lint_underline_color": [255, 138, 101], + "blame_annotation_color": [130, 130, 130] } # 實際使用的顏色字典 (以 QColor 表示) diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index a350e15d..dc8385d5 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -377,6 +377,7 @@ "todo_panel_col_message": "Message", "todo_panel_col_file": "File", "todo_panel_col_line": "Line", + "tab_menu_diff_against_head_name": "Diff Against HEAD", # Problems panel "tab_menu_problems_panel_tab_name": "Problems", "problems_panel_refresh": "Recheck", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index e49b7fe2..6a310ca2 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -367,6 +367,7 @@ "todo_panel_col_message": "說明", "todo_panel_col_file": "檔案", "todo_panel_col_line": "行號", + "tab_menu_diff_against_head_name": "與 HEAD 比較差異", # Problems panel "tab_menu_problems_panel_tab_name": "問題", "problems_panel_refresh": "重新檢查", diff --git a/test/qt_ui/unit_test/extend_test.py b/test/qt_ui/unit_test/extend_test.py index 2f240a09..0ed28887 100644 --- a/test/qt_ui/unit_test/extend_test.py +++ b/test/qt_ui/unit_test/extend_test.py @@ -15,7 +15,11 @@ def _log(msg): sys.__stderr__.flush() -from PySide6.QtWidgets import QWidget, QGridLayout, QLineEdit, QPushButton, QLabel +# Imported after the platform variable above is set: Qt reads it while loading, +# so importing any Qt module first would lock in the wrong platform plugin. +from PySide6.QtWidgets import ( # noqa: E402 + QWidget, QGridLayout, QLineEdit, QPushButton, QLabel +) class TestUI(QWidget): diff --git a/test/test_blame.py b/test/test_blame.py new file mode 100644 index 00000000..011e40e1 --- /dev/null +++ b/test/test_blame.py @@ -0,0 +1,146 @@ +"""Tests for reading git blame and showing it inline.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.git_client.file_blame import BlameLine, blame_lines + +git = pytest.importorskip("git") + + +@pytest.fixture() +def repo(tmp_path): + """A throw-away repository with one committed file.""" + repository = git.Repo.init(tmp_path) + with repository.config_writer() as config: + config.set_value("user", "name", "Blame Tester") + config.set_value("user", "email", "blame@example.com") + tracked = tmp_path / "tracked.py" + tracked.write_text("first\nsecond\n", encoding="utf-8") + repository.index.add(["tracked.py"]) + repository.index.commit("initial commit") + yield repository, tmp_path + repository.close() + + +class TestBlameLines: + def test_every_committed_line_is_annotated(self, repo): + _repository, root = repo + annotations = blame_lines(root / "tracked.py") + assert set(annotations) == {0, 1} + + def test_annotation_names_the_author_and_summary(self, repo): + _repository, root = repo + blame = blame_lines(root / "tracked.py")[0] + assert blame.author == "Blame Tester" + assert blame.summary == "initial commit" + assert len(blame.commit) == 8 + + def test_annotation_line_joins_the_parts(self, repo): + _repository, root = repo + annotation = blame_lines(root / "tracked.py")[0].annotation + assert "Blame Tester" in annotation and "initial commit" in annotation + + def test_untracked_file_has_no_blame(self, repo): + _repository, root = repo + loose = root / "untracked.py" + loose.write_text("x\n", encoding="utf-8") + assert blame_lines(loose) == {} + + def test_file_outside_a_repository_has_no_blame(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert blame_lines(loose) == {} + + def test_long_summary_is_shortened(self, repo): + _repository, root = repo + wordy = root / "wordy.py" + wordy.write_text("x\n", encoding="utf-8") + _repository.index.add(["wordy.py"]) + _repository.index.commit("w" * 200) + assert blame_lines(wordy)[0].summary.endswith("…") + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +SAMPLE_BLAME = { + 0: BlameLine(commit="abc12345", author="Ann", summary="first change"), + 1: BlameLine(commit="def67890", author="Bob", summary="second change"), +} + + +class TestBlameManager: + def test_starts_hidden(self, editor): + assert editor.blame_manager.enabled is False + assert editor.blame_manager.annotation(0) == "" + + def test_annotations_are_hidden_while_disabled(self, editor): + editor.blame_manager.set_annotations(SAMPLE_BLAME) + assert editor.blame_manager.annotation(0) == "" + + def test_toggle_without_a_file_stays_off(self, editor): + editor.current_file = None + assert editor.toggle_blame() is False + + def test_annotation_is_shown_once_enabled(self, editor): + editor.blame_manager._enabled = True + editor.blame_manager.set_annotations(SAMPLE_BLAME) + assert "Ann" in editor.blame_manager.annotation(0) + assert "second change" in editor.blame_manager.annotation(1) + + def test_line_without_blame_shows_nothing(self, editor): + editor.blame_manager._enabled = True + editor.blame_manager.set_annotations(SAMPLE_BLAME) + assert editor.blame_manager.annotation(9) == "" + + def test_clear_turns_it_off(self, editor): + editor.blame_manager._enabled = True + editor.blame_manager.set_annotations(SAMPLE_BLAME) + editor.blame_manager.clear() + assert editor.blame_manager.enabled is False + assert editor.blame_manager.annotation(0) == "" + + def test_toggling_off_drops_the_annotations(self, editor, repo): + _repository, root = repo + editor.current_file = str(root / "tracked.py") + assert editor.toggle_blame() is True + editor.blame_manager.stop() + assert editor.toggle_blame() is False + assert editor.blame_manager.annotation(0) == "" + + def test_painting_with_annotations_does_not_raise(self, editor): + editor.setPlainText("first\nsecond\n") + editor.blame_manager._enabled = True + editor.blame_manager.set_annotations(SAMPLE_BLAME) + editor.show() + editor.viewport().update() + QApplication.processEvents() + editor.hide() + + def test_stop_is_safe_without_a_loader(self, editor): + editor.blame_manager.stop() + editor.blame_manager.stop() From 4333f2604f1dcd0e0796f2e930c047dc8bf1280d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:20:55 +0800 Subject: [PATCH 07/36] Make the encoding menu take effect, and add line endings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Encodings menu recorded the chosen encoding in the settings, but nothing on the read or write path ever consulted it: every file was opened and saved as UTF-8 regardless. Choosing an encoding now applies to the current tab. An unmodified file is re-read with it, so mojibake can be corrected in place, while a file with unsaved work only changes the encoding it will be saved with — re-reading would have thrown that work away. An encoding that cannot decode the file leaves the text alone instead of clearing it. A file''s line ending is detected on open and written back on save, so editing one line of a CRLF file no longer rewrites every line in it, and a new Line Endings menu converts between LF, CRLF and CR. Auto-save follows the same encoding and ending as the tab it belongs to. --- .../code/auto_save/auto_save_manager.py | 3 + .../code/auto_save/auto_save_thread.py | 10 +- .../dialog/file_dialog/save_file_dialog.py | 10 +- .../pyside_ui/main_ui/editor/editor_widget.py | 18 ++- .../main_ui/menu/file_menu/build_file_menu.py | 26 ++++ .../menu/file_menu/encoding_actions.py | 94 ++++++++++++ je_editor/utils/encodings/text_codec.py | 143 ++++++++++++++++++ je_editor/utils/file/open/open_file.py | 43 ++++++ je_editor/utils/file/save/save_file.py | 39 +++++ je_editor/utils/multi_language/english.py | 1 + .../multi_language/traditional_chinese.py | 1 + test/test_encoding_actions.py | 123 +++++++++++++++ test/test_text_codec.py | 137 +++++++++++++++++ 13 files changed, 639 insertions(+), 9 deletions(-) create mode 100644 je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py create mode 100644 je_editor/utils/encodings/text_codec.py create mode 100644 test/test_encoding_actions.py create mode 100644 test/test_text_codec.py diff --git a/je_editor/pyside_ui/code/auto_save/auto_save_manager.py b/je_editor/pyside_ui/code/auto_save/auto_save_manager.py index a46c1417..06acc842 100644 --- a/je_editor/pyside_ui/code/auto_save/auto_save_manager.py +++ b/je_editor/pyside_ui/code/auto_save/auto_save_manager.py @@ -49,6 +49,9 @@ def init_new_auto_save_thread(file_path: str, widget: EditorWidget) -> None: file_to_save=widget.current_file, editor=widget.code_edit, before_write_callback=widget.mark_ignore_next_file_change, ) + # 沿用該分頁偵測到的編碼與行尾 / Keep the tab's detected encoding and line ending + widget.code_save_thread.encoding = getattr(widget, "file_encoding", "utf-8") + widget.code_save_thread.line_ending = getattr(widget, "line_ending", "\n") # 更新管理字典,記錄該檔案對應的執行緒 # Update manager dict with the new thread diff --git a/je_editor/pyside_ui/code/auto_save/auto_save_thread.py b/je_editor/pyside_ui/code/auto_save/auto_save_thread.py index e8f2852c..792aee8b 100644 --- a/je_editor/pyside_ui/code/auto_save/auto_save_thread.py +++ b/je_editor/pyside_ui/code/auto_save/auto_save_thread.py @@ -6,7 +6,8 @@ from PySide6.QtCore import QObject, Qt, Signal, Slot from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor -from je_editor.utils.file.save.save_file import write_file +from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, LINE_ENDING_LF +from je_editor.utils.file.save.save_file import write_file_with_encoding from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -66,6 +67,11 @@ def __init__( self.daemon = True self.skip_this_round: bool = False self.before_write_callback = before_write_callback + # 自動儲存要沿用該檔案原本的編碼與行尾,否則背景存檔會悄悄改寫整份檔案 + # Auto-save keeps the file's own encoding and line ending, or a background + # save would quietly rewrite the whole file + self.encoding: str = DEFAULT_ENCODING + self.line_ending: str = LINE_ENDING_LF # 建立主執行緒上的文字提取器 / Create text fetcher on main thread self._text_fetcher: Union[_TextFetcher, None] = None if editor is not None: @@ -91,7 +97,7 @@ def _attempt_save(self) -> None: return if self.before_write_callback is not None: self.before_write_callback() - write_file(self.file, text) + write_file_with_encoding(self.file, text, self.encoding, self.line_ending) except (OSError, RuntimeError) as e: jeditor_logger.error(f"Auto-save failed for {self.file}: {e}") diff --git a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py index b2554f22..47427df5 100644 --- a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py +++ b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py @@ -15,7 +15,8 @@ from PySide6.QtWidgets import QFileDialog from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget -from je_editor.utils.file.save.save_file import write_file +from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, LINE_ENDING_LF +from je_editor.utils.file.save.save_file import write_file_with_encoding def _build_save_file_filters() -> list[str]: @@ -98,8 +99,11 @@ def choose_file_get_save_file_path(parent_qt_instance: EditorMain) -> bool: # 更新目前檔案路徑 / Update current file path widget.current_file = file_path - # 將編輯器內容寫入檔案 / Write editor content to file - write_file(file_path, widget.code_edit.toPlainText()) + # 以該分頁的編碼與行尾寫入 / Write with the tab's encoding and line ending + write_file_with_encoding( + file_path, widget.code_edit.toPlainText(), + getattr(widget, "file_encoding", DEFAULT_ENCODING), + getattr(widget, "line_ending", LINE_ENDING_LF)) # 更新已開啟檔案管理字典 / Update opened file manager dictionary path = Path(file_path) diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index d8ab8bdc..ff25c03d 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -32,7 +32,10 @@ from je_editor.pyside_ui.code.textedit_code_result.code_record import CodeRecord from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict -from je_editor.utils.file.open.open_file import read_file +from je_editor.utils.encodings.text_codec import ( + DEFAULT_ENCODING, LINE_ENDING_LF +) +from je_editor.utils.file.open.open_file import read_file, read_file_with_encoding class EditorWidget(QWidget): @@ -58,6 +61,11 @@ def __init__(self, main_window: EditorMain) -> None: # ---------------- Init variables 初始化變數 ---------------- self.checker: Union[PEP8FormatChecker, None] = None self.current_file = None + # 目前檔案的編碼與行尾,開檔時偵測,存檔時照原樣寫回 + # The current file's encoding and line ending, detected on open and + # written back unchanged on save + self.file_encoding: str = DEFAULT_ENCODING + self.line_ending: str = LINE_ENDING_LF self.tree_view_scroll_area = None self.project_treeview: Union[QTreeView, None] = None self.project_treeview_model = None @@ -243,11 +251,13 @@ def open_an_file(self, path: Path) -> bool: if self.code_save_thread: self.code_save_thread.skip_this_round = True - # 讀取檔案內容 / Read file content - result = read_file(str(path)) + # 讀取檔案內容,同時記下編碼與行尾,存檔時照原樣寫回 + # Read the content, remembering the encoding and line ending so a save + # writes the file back the way it was found + result = read_file_with_encoding(str(path)) if result is None: return False - file, file_content = result + file, file_content, self.file_encoding, self.line_ending = result self.code_edit.setPlainText(file_content) # 依內容偵測縮排寬度;失敗不可影響開檔 / Detect indent; must not break opening diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py index 1107966a..693ec745 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py @@ -12,6 +12,12 @@ # 匯入 Python 編碼清單 (例如 utf-8, gbk 等) # Import list of Python encodings (e.g., utf-8, gbk, etc.) from je_editor.utils.encodings.python_encodings import python_encodings_list +from je_editor.utils.encodings.text_codec import ( + LINE_ENDING_CR, LINE_ENDING_CRLF, LINE_ENDING_LF, line_ending_name +) +from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_encoding, apply_line_ending_choice +) # 匯入日誌紀錄器 # Import logger instance from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -99,6 +105,7 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: add_font_menu(ui_we_want_to_set) add_font_size_menu(ui_we_want_to_set) add_encoding_menu(ui_we_want_to_set) + add_line_ending_menu(ui_we_want_to_set) # 最近開啟的檔案選單 / Recent Files menu @@ -174,6 +181,25 @@ def set_encoding(ui_we_want_to_set: EditorMain, action: QAction) -> None: f"action: {action}") ui_we_want_to_set.encoding = action.text() user_setting_dict.update({"encoding": action.text()}) + # 讓選擇真的作用到目前分頁的讀寫,而不只是記在設定裡 + # Make the choice reach the current tab's read and write path, rather than + # only being recorded in the settings + apply_encoding(ui_we_want_to_set, action.text()) + + +# 建立行尾選單 +# Add line-ending menu +def add_line_ending_menu(ui_we_want_to_set: EditorMain) -> None: + jeditor_logger.info("build_file_menu.py add_line_ending_menu") + ui_we_want_to_set.file_menu.line_ending_menu = ui_we_want_to_set.file_menu.addMenu( + language_wrapper.language_word_dict.get("file_menu_line_ending_label")) + for ending in (LINE_ENDING_LF, LINE_ENDING_CRLF, LINE_ENDING_CR): + ending_action = QAction( + line_ending_name(ending), parent=ui_we_want_to_set.file_menu.line_ending_menu) + ending_action.triggered.connect( + lambda checked=False, value=ending: apply_line_ending_choice( + ui_we_want_to_set, value)) + ui_we_want_to_set.file_menu.line_ending_menu.addAction(ending_action) # 顯示新建檔案對話框 diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py new file mode 100644 index 00000000..b72a53e9 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py @@ -0,0 +1,94 @@ +""" +套用編碼與行尾設定到目前的編輯分頁 +Apply an encoding or line-ending choice to the current editor tab. + +編碼選單原本只把選擇存進設定檔,讀寫檔案時並沒有用到;這裡讓它真的生效。 +The encoding menu used to only record the choice in the settings without it +reaching the read or write path; this makes the choice take effect. +""" +from __future__ import annotations + +from je_editor.utils.encodings.text_codec import LINE_ENDING_LF +from je_editor.utils.exception.exceptions import JEditorOpenFileException +from je_editor.utils.file.open.open_file import read_file_with_encoding +from je_editor.utils.logging.loggin_instance import jeditor_logger + + +def current_editor_tab(ui_we_want_to_set): + """ + 取得目前的編輯分頁 + Return the current editor tab. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 編輯分頁,目前分頁不是編輯器時為 ``None`` / the editor tab, or ``None`` + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return None + widget = tab_widget.currentWidget() + return widget if isinstance(widget, EditorWidget) else None + + +def _update_auto_save(widget) -> None: + """讓自動儲存沿用同樣的編碼與行尾 / Keep auto-save on the same encoding and ending.""" + thread = getattr(widget, "code_save_thread", None) + if thread is not None: + thread.encoding = widget.file_encoding + thread.line_ending = widget.line_ending + + +def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: + """ + 以指定編碼重新解讀目前檔案,並用它存檔 + Re-read the current file with *encoding*, and save with it from now on. + + 只有在沒有未儲存修改時才重新讀取,否則改個編碼就會把使用者正在編輯的內容 + 丟掉;有修改時仍會改用新編碼存檔。 + The file is only re-read when nothing is unsaved, since re-reading would + otherwise throw away what the user is editing; the new encoding still + applies to the next save either way. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :param encoding: 要使用的編碼 / the encoding to use + :return: 有套用到某個分頁時為 ``True`` / ``True`` when a tab was updated + """ + widget = current_editor_tab(ui_we_want_to_set) + if widget is None: + return False + widget.file_encoding = encoding + _update_auto_save(widget) + if widget.current_file is None or getattr(widget, "_is_modified", False): + return True + try: + result = read_file_with_encoding(str(widget.current_file), encoding) + except JEditorOpenFileException: + # 這個編碼解不開這個檔案:保留畫面上的內容,讓使用者再選一次 + # The file does not decode as that encoding: leave the text alone so the + # user can simply choose another + jeditor_logger.info(f"encoding_actions: {encoding} cannot decode {widget.current_file}") + return True + if result is not None: + _path, content, used_encoding, line_ending = result + widget.code_edit.setPlainText(content) + widget.file_encoding = used_encoding + widget.line_ending = line_ending + _update_auto_save(widget) + return True + + +def apply_line_ending_choice(ui_we_want_to_set, line_ending: str = LINE_ENDING_LF) -> bool: + """ + 設定目前檔案存檔時使用的行尾 + Set the line ending the current file is saved with. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :param line_ending: 要使用的行尾 / the line ending to write + :return: 有套用到某個分頁時為 ``True`` / ``True`` when a tab was updated + """ + widget = current_editor_tab(ui_we_want_to_set) + if widget is None: + return False + widget.line_ending = line_ending + _update_auto_save(widget) + return True diff --git a/je_editor/utils/encodings/text_codec.py b/je_editor/utils/encodings/text_codec.py new file mode 100644 index 00000000..8ab317f3 --- /dev/null +++ b/je_editor/utils/encodings/text_codec.py @@ -0,0 +1,143 @@ +""" +處理文字檔的編碼與行尾 +Handle a text file's encoding and line endings. + +編輯器內部一律以 ``\\n`` 表示換行(Qt 文件就是這樣),因此讀進來要正規化、 +寫出去要換回檔案原本的行尾,否則存一次檔就會把整份檔案的行尾都改掉。 +The editor always uses ``\\n`` internally, as a Qt document does, so text is +normalised on the way in and converted back on the way out — otherwise a single +save would rewrite every line ending in the file. + +純邏輯:不碰 Qt,也不做檔案 I/O。 +Pure logic: no Qt and no file I/O. +""" +from __future__ import annotations + +import codecs + +# 行尾種類 / The line-ending styles +LINE_ENDING_LF = "\n" +LINE_ENDING_CRLF = "\r\n" +LINE_ENDING_CR = "\r" + +# 顯示名稱對應 / Display name for each style +LINE_ENDING_NAMES = { + LINE_ENDING_LF: "LF", + LINE_ENDING_CRLF: "CRLF", + LINE_ENDING_CR: "CR", +} + +# 預設編碼 / The encoding assumed when nothing else is known +DEFAULT_ENCODING = "utf-8" + +# BOM 與對應編碼;順序重要,UTF-32 的 BOM 開頭與 UTF-16 相同 +# Byte-order marks and their encodings. Order matters: a UTF-32 BOM starts with +# the same two bytes as a UTF-16 one. +_BOM_ENCODINGS = ( + (codecs.BOM_UTF8, "utf-8-sig"), + (codecs.BOM_UTF32_LE, "utf-32"), + (codecs.BOM_UTF32_BE, "utf-32"), + (codecs.BOM_UTF16_LE, "utf-16"), + (codecs.BOM_UTF16_BE, "utf-16"), +) + + +def detect_line_ending(text: str) -> str: + """ + 判斷文字使用的行尾 + Work out which line ending a text uses. + + 以出現次數最多的為準;沒有任何換行時視為 ``LF``。 + The most common one wins; a text with no line break at all counts as ``LF``. + + :param text: 要判斷的文字 / the text to inspect + :return: 行尾字串 / the line-ending string + """ + crlf = text.count(LINE_ENDING_CRLF) + # 單獨的 CR 與 LF 不能把 CRLF 算進去 / Lone CR and LF must not count CRLF twice + lone_cr = text.count(LINE_ENDING_CR) - crlf + lone_lf = text.count(LINE_ENDING_LF) - crlf + if crlf >= lone_lf and crlf >= lone_cr and crlf > 0: + return LINE_ENDING_CRLF + if lone_cr > lone_lf: + return LINE_ENDING_CR + return LINE_ENDING_LF + + +def line_ending_name(ending: str) -> str: + """ + 取得行尾的顯示名稱 + The display name of a line ending. + + :param ending: 行尾字串 / the line-ending string + :return: 名稱,未知時回傳 ``LF`` / its name, defaulting to ``LF`` + """ + return LINE_ENDING_NAMES.get(ending, LINE_ENDING_NAMES[LINE_ENDING_LF]) + + +def normalise_line_endings(text: str) -> str: + """ + 把所有行尾正規化為 ``\\n`` + Normalise every line ending to ``\\n``. + + :param text: 原始文字 / the original text + :return: 正規化後的文字 / the normalised text + """ + return text.replace(LINE_ENDING_CRLF, LINE_ENDING_LF).replace( + LINE_ENDING_CR, LINE_ENDING_LF) + + +def apply_line_ending(text: str, ending: str) -> str: + """ + 把文字的行尾換成指定樣式 + Convert a text's line endings to *ending*. + + :param text: 要轉換的文字(任何行尾)/ the text to convert, in any style + :param ending: 目標行尾 / the line ending to write + :return: 轉換後的文字 / the converted text + """ + normalised = normalise_line_endings(text) + if ending == LINE_ENDING_LF: + return normalised + return normalised.replace(LINE_ENDING_LF, ending) + + +def encoding_from_bom(raw: bytes) -> str | None: + """ + 由 BOM 判斷編碼 + Work out an encoding from a byte-order mark. + + :param raw: 檔案開頭的位元組 / the file's leading bytes + :return: 編碼名稱,沒有 BOM 時為 ``None`` / the encoding, or ``None`` + """ + for bom, encoding in _BOM_ENCODINGS: + if raw.startswith(bom): + return encoding + return None + + +def decode_bytes(raw: bytes, encoding: str | None = None) -> tuple[str, str]: + """ + 解碼檔案內容,並回報實際使用的編碼 + Decode a file's bytes, reporting which encoding actually worked. + + 指定編碼時就用它;沒有指定時先看 BOM,再試 UTF-8,最後退回 latin-1—— + latin-1 不會失敗,因此檔案至少能開起來讓使用者自己選正確的編碼。 + An explicit encoding is used as given. Otherwise a BOM decides, then UTF-8 is + tried, and latin-1 is the fallback: it cannot fail, so the file always opens + and the user can pick the right encoding themselves. + + :param raw: 檔案內容 / the file's bytes + :param encoding: 指定的編碼,``None`` 表示自動判斷 / the encoding, or ``None`` to detect + :return: ``(文字, 使用的編碼)`` / ``(text, encoding used)`` + :raises UnicodeDecodeError: 指定的編碼無法解碼時 / when an explicit encoding fails + """ + if encoding is not None: + return raw.decode(encoding), encoding + from_bom = encoding_from_bom(raw) + if from_bom is not None: + return raw.decode(from_bom), from_bom + try: + return raw.decode(DEFAULT_ENCODING), DEFAULT_ENCODING + except UnicodeDecodeError: + return raw.decode("latin-1"), "latin-1" diff --git a/je_editor/utils/file/open/open_file.py b/je_editor/utils/file/open/open_file.py index a6fc1506..ddf78e0f 100644 --- a/je_editor/utils/file/open/open_file.py +++ b/je_editor/utils/file/open/open_file.py @@ -4,6 +4,9 @@ # 匯入自訂例外與日誌工具 # Import custom exception and logging utility +from je_editor.utils.encodings.text_codec import ( + decode_bytes, detect_line_ending, normalise_line_endings +) from je_editor.utils.exception.exceptions import JEditorOpenFileException from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -12,6 +15,46 @@ _file_read_lock = Lock() +def read_file_with_encoding( + file_path: Optional[str], encoding: Optional[str] = None) -> tuple: + """ + 讀取檔案,並回報使用的編碼與原本的行尾 + Read a file, reporting the encoding used and the line ending it had. + + 內容的行尾會正規化為 ``\\n``,因為編輯器內部就是這樣表示換行;原本的行尾 + 另外回傳,存檔時才能寫回去。 + The content's line endings are normalised to ``\\n``, which is how the editor + represents them; the original style is returned separately so saving can put + it back. + + :param file_path: 檔案完整路徑 / the full path of the file to read + :param encoding: 指定編碼,``None`` 表示自動判斷 / the encoding, or ``None`` to detect + :return: ``(路徑, 內容, 編碼, 行尾)``;路徑無效時為 ``None`` + ``(path, content, encoding, line ending)``, or ``None`` for an invalid path + :raises JEditorOpenFileException: 讀取或解碼失敗時 / when the read or decode fails + """ + jeditor_logger.info(f"open_file.py read_file_with_encoding file_path: {file_path}") + if not file_path: + return None + path = Path(file_path) + try: + _file_read_lock.acquire() + if not (path.exists() and path.is_file()): + return None + raw = path.read_bytes() + except OSError as error: + jeditor_logger.error(f"Failed to read file {file_path}: {error}") + raise JEditorOpenFileException from error + finally: + _file_read_lock.release() + try: + text, used_encoding = decode_bytes(raw, encoding) + except (UnicodeDecodeError, LookupError) as error: + jeditor_logger.error(f"Failed to decode file {file_path}: {error}") + raise JEditorOpenFileException from error + return path, normalise_line_endings(text), used_encoding, detect_line_ending(text) + + def read_file(file_path: Optional[str]) -> list[Path | str] | None: """ 功能說明 (Function Description): diff --git a/je_editor/utils/file/save/save_file.py b/je_editor/utils/file/save/save_file.py index c8da33a7..4a9b1651 100644 --- a/je_editor/utils/file/save/save_file.py +++ b/je_editor/utils/file/save/save_file.py @@ -2,6 +2,9 @@ # 匯入自訂例外與日誌工具 # Import custom exception and logging utility +from je_editor.utils.encodings.text_codec import ( + DEFAULT_ENCODING, LINE_ENDING_LF, apply_line_ending +) from je_editor.utils.exception.exceptions import JEditorSaveFileException from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -10,6 +13,42 @@ _file_write_lock = Lock() +def write_file_with_encoding( + file_path: str, content: str, + encoding: str = DEFAULT_ENCODING, line_ending: str = LINE_ENDING_LF) -> None: + """ + 以指定的編碼與行尾寫入檔案 + Write a file using a given encoding and line-ending style. + + 編輯器內部一律以 ``\\n`` 換行,寫出前轉回檔案原本的樣式,存檔才不會把整份 + 檔案的行尾都改掉。 + The editor always uses ``\\n``; converting back before writing keeps a save + from rewriting every line ending in the file. + + :param file_path: 要寫入的檔案路徑 / the file path to write + :param content: 要寫入的內容 / the content to write + :param encoding: 使用的編碼 / the encoding to write with + :param line_ending: 使用的行尾 / the line ending to write + :raises JEditorSaveFileException: 寫入或編碼失敗時 / when the write or encode fails + """ + jeditor_logger.info("save_file.py write_file_with_encoding " + f"file_path: {file_path} encoding: {encoding}") + if not file_path: + return + text = apply_line_ending(str(content), line_ending) + try: + _file_write_lock.acquire() + # newline="" 讓上面轉好的行尾原樣寫出,不再被 Python 轉換一次 + # newline="" writes the endings above as-is instead of translating again + with open(file_path, "w", encoding=encoding, newline="") as file_to_write: + file_to_write.write(text) + except (OSError, UnicodeEncodeError, LookupError) as error: + jeditor_logger.error(f"Failed to write file {file_path}: {error}") + raise JEditorSaveFileException from error + finally: + _file_write_lock.release() + + def write_file(file_path: str, content: str) -> None: """ 功能說明 (Function Description): diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index dc8385d5..4ef8fe15 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -85,6 +85,7 @@ "file_menu_open_folder_label": "Open Folder", "file_menu_save_file_label": "Save File", "file_menu_encoding_label": "Encodings", + "file_menu_line_ending_label": "Line Endings", "file_menu_font_label": "Font", "file_menu_font_size_label": _FONT_SIZE_LABEL, # Help Menu diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 6a310ca2..60f80720 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -82,6 +82,7 @@ "file_menu_open_folder_label": "開啟資料夾", "file_menu_save_file_label": "儲存檔案", "file_menu_encoding_label": "編碼", + "file_menu_line_ending_label": "行尾", "file_menu_font_label": "字體", "file_menu_font_size_label": "字體大小", # Help Menu diff --git a/test/test_encoding_actions.py b/test/test_encoding_actions.py new file mode 100644 index 00000000..0fb93ab7 --- /dev/null +++ b/test/test_encoding_actions.py @@ -0,0 +1,123 @@ +"""Tests for applying an encoding or line-ending choice to the current tab.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_encoding, + apply_line_ending_choice, + current_editor_tab, +) +from je_editor.utils.encodings.text_codec import LINE_ENDING_CRLF, LINE_ENDING_LF + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +class _FakeTabWidget: + def __init__(self, widget=None): + self._widget = widget + + def currentWidget(self): + return self._widget + + +@pytest.fixture() +def editor_tab(app): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from PySide6.QtWidgets import QPlainTextEdit + tab = MagicMock(spec=EditorWidget) + tab.code_edit = QPlainTextEdit() + tab.current_file = None + tab.file_encoding = "utf-8" + tab.line_ending = LINE_ENDING_LF + tab.code_save_thread = None + tab._is_modified = False + yield tab + tab.code_edit.deleteLater() + + +def _window(tab): + window = MagicMock() + window.tab_widget = _FakeTabWidget(tab) + return window + + +class TestCurrentEditorTab: + def test_finds_an_editor_tab(self, editor_tab): + assert current_editor_tab(_window(editor_tab)) is editor_tab + + def test_other_tab_types_are_ignored(self, app): + assert current_editor_tab(_window(object())) is None + + def test_window_without_tabs(self, app): + window = MagicMock() + window.tab_widget = None + assert current_editor_tab(window) is None + + +class TestApplyEncoding: + def test_sets_the_tab_encoding(self, editor_tab): + assert apply_encoding(_window(editor_tab), "big5") is True + assert editor_tab.file_encoding == "big5" + + def test_without_an_editor_tab(self, app): + assert apply_encoding(_window(object()), "big5") is False + + def test_rereads_an_unmodified_file(self, editor_tab, tmp_path): + path = tmp_path / "big5.txt" + path.write_bytes("中文\n".encode("big5")) + editor_tab.current_file = str(path) + editor_tab._is_modified = False + apply_encoding(_window(editor_tab), "big5") + assert editor_tab.code_edit.toPlainText() == "中文\n" + assert editor_tab.file_encoding == "big5" + + def test_unsaved_changes_are_never_discarded(self, editor_tab, tmp_path): + path = tmp_path / "sample.txt" + path.write_text("on disk\n", encoding="utf-8") + editor_tab.current_file = str(path) + editor_tab.code_edit.setPlainText("unsaved work") + editor_tab._is_modified = True + apply_encoding(_window(editor_tab), "utf-8") + assert editor_tab.code_edit.toPlainText() == "unsaved work" + assert editor_tab.file_encoding == "utf-8" + + def test_an_encoding_that_cannot_decode_leaves_the_text_alone(self, editor_tab, tmp_path): + path = tmp_path / "big5.txt" + path.write_bytes("中文\n".encode("big5")) + editor_tab.current_file = str(path) + editor_tab.code_edit.setPlainText("previous text") + apply_encoding(_window(editor_tab), "ascii") + assert editor_tab.code_edit.toPlainText() == "previous text" + + def test_auto_save_follows_the_encoding(self, editor_tab): + thread = MagicMock() + editor_tab.code_save_thread = thread + apply_encoding(_window(editor_tab), "big5") + assert thread.encoding == "big5" + + +class TestApplyLineEnding: + def test_sets_the_tab_line_ending(self, editor_tab): + assert apply_line_ending_choice(_window(editor_tab), LINE_ENDING_CRLF) is True + assert editor_tab.line_ending == LINE_ENDING_CRLF + + def test_without_an_editor_tab(self, app): + assert apply_line_ending_choice(_window(object()), LINE_ENDING_CRLF) is False + + def test_auto_save_follows_the_line_ending(self, editor_tab): + thread = MagicMock() + editor_tab.code_save_thread = thread + apply_line_ending_choice(_window(editor_tab), LINE_ENDING_CRLF) + assert thread.line_ending == LINE_ENDING_CRLF + + def test_the_text_itself_is_untouched(self, editor_tab): + editor_tab.code_edit.setPlainText("a\nb\n") + apply_line_ending_choice(_window(editor_tab), LINE_ENDING_CRLF) + assert editor_tab.code_edit.toPlainText() == "a\nb\n" diff --git a/test/test_text_codec.py b/test/test_text_codec.py new file mode 100644 index 00000000..e2ed584e --- /dev/null +++ b/test/test_text_codec.py @@ -0,0 +1,137 @@ +"""Tests for encoding detection, line-ending handling, and round-tripping files.""" +from __future__ import annotations + +import codecs + +import pytest + +from je_editor.utils.encodings.text_codec import ( + LINE_ENDING_CR, + LINE_ENDING_CRLF, + LINE_ENDING_LF, + apply_line_ending, + decode_bytes, + detect_line_ending, + encoding_from_bom, + line_ending_name, + normalise_line_endings, +) +from je_editor.utils.file.open.open_file import read_file_with_encoding +from je_editor.utils.file.save.save_file import write_file_with_encoding + + +class TestDetectLineEnding: + def test_unix(self): + assert detect_line_ending("a\nb\n") == LINE_ENDING_LF + + def test_windows(self): + assert detect_line_ending("a\r\nb\r\n") == LINE_ENDING_CRLF + + def test_classic_mac(self): + assert detect_line_ending("a\rb\r") == LINE_ENDING_CR + + def test_no_line_break_defaults_to_lf(self): + assert detect_line_ending("single line") == LINE_ENDING_LF + + def test_mixed_endings_pick_the_most_common(self): + assert detect_line_ending("a\r\nb\r\nc\n") == LINE_ENDING_CRLF + + def test_crlf_is_not_counted_as_a_lone_cr(self): + # Two CRLF and one lone LF: CRLF wins, and the CRs are not double-counted. + assert detect_line_ending("a\r\nb\r\nc\nd") == LINE_ENDING_CRLF + + @pytest.mark.parametrize("ending,name", [ + (LINE_ENDING_LF, "LF"), (LINE_ENDING_CRLF, "CRLF"), (LINE_ENDING_CR, "CR")]) + def test_names(self, ending, name): + assert line_ending_name(ending) == name + + def test_unknown_ending_name_falls_back(self): + assert line_ending_name("??") == "LF" + + +class TestApplyLineEnding: + def test_to_crlf(self): + assert apply_line_ending("a\nb\n", LINE_ENDING_CRLF) == "a\r\nb\r\n" + + def test_to_lf(self): + assert apply_line_ending("a\r\nb\r\n", LINE_ENDING_LF) == "a\nb\n" + + def test_to_cr(self): + assert apply_line_ending("a\nb\n", LINE_ENDING_CR) == "a\rb\r" + + def test_mixed_input_becomes_consistent(self): + assert apply_line_ending("a\r\nb\nc\r", LINE_ENDING_LF) == "a\nb\nc\n" + + def test_normalise(self): + assert normalise_line_endings("a\r\nb\rc\n") == "a\nb\nc\n" + + +class TestDecodeBytes: + def test_utf8_without_a_bom(self): + text, encoding = decode_bytes("héllo".encode("utf-8")) + assert text == "héllo" and encoding == "utf-8" + + def test_utf8_bom_is_recognised(self): + text, encoding = decode_bytes(codecs.BOM_UTF8 + "hi".encode("utf-8")) + assert text == "hi" and encoding == "utf-8-sig" + + def test_utf16_bom_is_recognised(self): + text, encoding = decode_bytes("hi".encode("utf-16")) + assert text == "hi" and encoding == "utf-16" + + def test_explicit_encoding_is_honoured(self): + text, encoding = decode_bytes("中文".encode("big5"), "big5") + assert text == "中文" and encoding == "big5" + + def test_undecodable_bytes_fall_back_instead_of_failing(self): + text, encoding = decode_bytes(b"\xff\xfe\x00abc"[1:]) + assert isinstance(text, str) and encoding in {"utf-8", "latin-1"} + + def test_explicit_encoding_that_cannot_decode_raises(self): + with pytest.raises(UnicodeDecodeError): + decode_bytes("中文".encode("big5"), "ascii") + + def test_bom_detection_without_a_bom(self): + assert encoding_from_bom(b"plain") is None + + +class TestFileRoundTrip: + def test_crlf_file_survives_a_save(self, tmp_path): + path = tmp_path / "windows.txt" + path.write_bytes(b"a\r\nb\r\n") + _p, content, encoding, ending = read_file_with_encoding(str(path)) + assert content == "a\nb\n" # normalised for the editor + assert ending == LINE_ENDING_CRLF + write_file_with_encoding(str(path), content, encoding, ending) + assert path.read_bytes() == b"a\r\nb\r\n" + + def test_lf_file_stays_lf(self, tmp_path): + path = tmp_path / "unix.txt" + path.write_bytes(b"a\nb\n") + _p, content, encoding, ending = read_file_with_encoding(str(path)) + write_file_with_encoding(str(path), content, encoding, ending) + assert path.read_bytes() == b"a\nb\n" + + def test_big5_file_round_trips(self, tmp_path): + path = tmp_path / "big5.txt" + path.write_bytes("中文內容\n".encode("big5")) + _p, content, _encoding, ending = read_file_with_encoding(str(path), "big5") + assert content == "中文內容\n" + write_file_with_encoding(str(path), content, "big5", ending) + assert path.read_bytes() == "中文內容\n".encode("big5") + + def test_line_ending_can_be_converted_on_save(self, tmp_path): + path = tmp_path / "convert.txt" + path.write_bytes(b"a\nb\n") + _p, content, encoding, _ending = read_file_with_encoding(str(path)) + write_file_with_encoding(str(path), content, encoding, LINE_ENDING_CRLF) + assert path.read_bytes() == b"a\r\nb\r\n" + + def test_missing_file_returns_none(self, tmp_path): + assert read_file_with_encoding(str(tmp_path / "gone.txt")) is None + + def test_empty_path_returns_none(self): + assert read_file_with_encoding("") is None + + def test_writing_an_empty_path_does_nothing(self, tmp_path): + write_file_with_encoding("", "text") # must not raise From 8227e41f66f58cc92cac1e6a02b09c73599ae00b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:25:07 +0800 Subject: [PATCH 08/36] Format Python on save, behind a setting yapf was only reachable as a manual menu action. A Format on Save toggle in the Check menu now applies the same formatting when a file is saved, for Python files only, keeping the caret on its line afterwards. The formatting itself moves into utils/format_code so the menu action and the save path share one implementation. That implementation also catches yapf''s own YapfError, which it raises instead of SyntaxError for unparseable input: without that, saving half-written code with the setting on would have failed the save, and the existing menu action would have raised. --- .../dialog/file_dialog/save_file_dialog.py | 5 + .../build_check_style_menu.py | 32 ++++-- .../menu/file_menu/encoding_actions.py | 34 ++++++ je_editor/utils/format_code/__init__.py | 0 je_editor/utils/format_code/yapf_format.py | 46 ++++++++ je_editor/utils/multi_language/english.py | 1 + .../multi_language/traditional_chinese.py | 1 + test/test_format_on_save.py | 103 ++++++++++++++++++ 8 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 je_editor/utils/format_code/__init__.py create mode 100644 je_editor/utils/format_code/yapf_format.py create mode 100644 test/test_format_on_save.py diff --git a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py index 47427df5..15fe6dfe 100644 --- a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py +++ b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import QFileDialog from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget +from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import format_before_save from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, LINE_ENDING_LF from je_editor.utils.file.save.save_file import write_file_with_encoding @@ -99,6 +100,10 @@ def choose_file_get_save_file_path(parent_qt_instance: EditorMain) -> bool: # 更新目前檔案路徑 / Update current file path widget.current_file = file_path + # 若已開啟「存檔時格式化」,先套用再寫出 + # Apply format-on-save, when that setting is on, before writing + format_before_save(widget) + # 以該分頁的編碼與行尾寫入 / Write with the tab's encoding and line ending write_file_with_encoding( file_path, widget.code_edit.toPlainText(), diff --git a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py index ebedba19..ea1fc95f 100644 --- a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py @@ -10,8 +10,9 @@ from je_editor.pyside_ui.main_ui.main_editor import EditorMain # 僅在型別檢查時匯入 / Import only for type checking from PySide6.QtGui import QAction, QKeySequence # Qt 動作與快捷鍵 / Qt actions and shortcuts -from yapf.yapflib.yapf_api import FormatCode # YAPF 程式碼格式化工具 / YAPF code formatter +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 使用者設定 / User settings +from je_editor.utils.format_code.yapf_format import format_python_source # 格式化邏輯 / Formatting logic from je_editor.utils.json_format.json_process import reformat_json # JSON 格式化工具 / JSON reformatter @@ -61,6 +62,26 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.check_menu.addAction(ui_we_want_to_set.check_menu.check_python_format) + # === 4. 存檔時自動格式化 / Format on save === + ui_we_want_to_set.check_menu.format_on_save_action = QAction( + language_wrapper.language_word_dict.get("format_on_save_label")) + ui_we_want_to_set.check_menu.format_on_save_action.setCheckable(True) + ui_we_want_to_set.check_menu.format_on_save_action.setChecked( + bool(user_setting_dict.get("format_on_save", False))) + ui_we_want_to_set.check_menu.format_on_save_action.toggled.connect(set_format_on_save) + ui_we_want_to_set.check_menu.addAction(ui_we_want_to_set.check_menu.format_on_save_action) + + +def set_format_on_save(enabled: bool) -> None: + """ + 設定是否在存檔時自動格式化 + Turn format-on-save on or off. + + :param enabled: 是否開啟 / whether it should be on + """ + jeditor_logger.info(f"build_check_style_menu.py set_format_on_save enabled: {enabled}") + user_setting_dict.update({"format_on_save": bool(enabled)}) + def yapf_check_python_code(ui_we_want_to_set: EditorMain) -> None: """ @@ -72,12 +93,9 @@ def yapf_check_python_code(ui_we_want_to_set: EditorMain) -> None: if isinstance(widget, EditorWidget): code_text = widget.code_edit.toPlainText() # 取得編輯器文字 / Get code text widget.code_result.setPlainText("") # 清空結果區域 / Clear result area - format_code = FormatCode( - unformatted_source=code_text, - style_config="google" # 使用 Google 風格 / Use Google style - ) - if isinstance(format_code, tuple): - widget.code_edit.setPlainText(format_code[0]) # 將格式化後的程式碼寫回編輯器 / Write formatted code back + formatted = format_python_source(code_text) + if formatted != code_text: + widget.code_edit.setPlainText(formatted) # 寫回格式化結果 / Write the result back def reformat_json_text(ui_we_want_to_set: EditorMain) -> None: diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py index b72a53e9..202ea132 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py @@ -8,6 +8,8 @@ """ from __future__ import annotations +from pathlib import Path + from je_editor.utils.encodings.text_codec import LINE_ENDING_LF from je_editor.utils.exception.exceptions import JEditorOpenFileException from je_editor.utils.file.open.open_file import read_file_with_encoding @@ -77,6 +79,38 @@ def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: return True +def format_before_save(widget) -> bool: + """ + 存檔前套用格式化(若已開啟該設定) + Format the buffer before saving, when the setting is on. + + 只格式化 Python 檔;格式化失敗(例如程式碼還沒寫完)時保持原內容。 + Only Python files are formatted, and source that cannot be formatted — half + -written code, say — is left exactly as it is. + + :param widget: 要格式化的編輯分頁 / the editor tab to format + :return: 內容有被改寫時為 ``True`` / ``True`` when the text was rewritten + """ + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + from je_editor.utils.format_code.yapf_format import format_python_source + if not user_setting_dict.get("format_on_save", False): + return False + file_path = getattr(widget, "current_file", None) + if file_path is None or Path(str(file_path)).suffix.lower() != ".py": + return False + source = widget.code_edit.toPlainText() + formatted = format_python_source(source) + if formatted == source: + return False + # 保留游標所在行,格式化後才不會跳到檔頭 + # Keep the caret's line so formatting does not throw it back to the top + cursor = widget.code_edit.textCursor() + line = cursor.blockNumber() + widget.code_edit.setPlainText(formatted) + widget.code_edit.jump_to_line(line + 1) + return True + + def apply_line_ending_choice(ui_we_want_to_set, line_ending: str = LINE_ENDING_LF) -> bool: """ 設定目前檔案存檔時使用的行尾 diff --git a/je_editor/utils/format_code/__init__.py b/je_editor/utils/format_code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/format_code/yapf_format.py b/je_editor/utils/format_code/yapf_format.py new file mode 100644 index 00000000..d6921d6d --- /dev/null +++ b/je_editor/utils/format_code/yapf_format.py @@ -0,0 +1,46 @@ +""" +用 yapf 格式化 Python 程式碼 +Format Python source with yapf. + +格式化本身是純文字轉換,抽出來之後選單動作與「存檔時自動格式化」可以共用同 +一份邏輯,也能單獨測試。 +Formatting is a plain text-to-text transform. Extracting it lets the menu action +and format-on-save share one implementation, and makes it testable on its own. +""" +from __future__ import annotations + +from yapf.yapflib.errors import YapfError +from yapf.yapflib.yapf_api import FormatCode + +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 預設的 yapf 風格 / The yapf style used by default +DEFAULT_STYLE = "google" + + +def format_python_source(source: str, style: str = DEFAULT_STYLE) -> str: + """ + 格式化 Python 原始碼 + Reformat Python source. + + 語法錯誤的程式碼無法格式化,這時回傳原內容而不是拋出例外——存檔時自動格式化 + 絕不能因為程式碼還沒寫完就中斷存檔。yapf 對語法錯誤丟的是自己的 ``YapfError`` + 而不是 ``SyntaxError``,兩者都要接。 + Source with a syntax error cannot be formatted; the original is returned + rather than raising, because format-on-save must never block a save just + because the code is still half-written. yapf raises its own ``YapfError`` + for a syntax error rather than ``SyntaxError``, so both are caught. + + :param source: 原始碼 / the source to format + :param style: yapf 風格名稱 / the yapf style to apply + :return: 格式化後的原始碼;無法格式化時為原內容 + the formatted source, or the original when it cannot be formatted + """ + if not source.strip(): + return source + try: + formatted, _changed = FormatCode(unformatted_source=source, style_config=style) + except (YapfError, SyntaxError, ValueError, IndentationError, UnicodeDecodeError) as error: + jeditor_logger.debug(f"yapf_format: source was not formatted: {error!r}") + return source + return formatted if isinstance(formatted, str) else source diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 4ef8fe15..3fda37e4 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -86,6 +86,7 @@ "file_menu_save_file_label": "Save File", "file_menu_encoding_label": "Encodings", "file_menu_line_ending_label": "Line Endings", + "format_on_save_label": "Format on Save", "file_menu_font_label": "Font", "file_menu_font_size_label": _FONT_SIZE_LABEL, # Help Menu diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 60f80720..a27b295d 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -83,6 +83,7 @@ "file_menu_save_file_label": "儲存檔案", "file_menu_encoding_label": "編碼", "file_menu_line_ending_label": "行尾", + "format_on_save_label": "存檔時自動格式化", "file_menu_font_label": "字體", "file_menu_font_size_label": "字體大小", # Help Menu diff --git a/test/test_format_on_save.py b/test/test_format_on_save.py new file mode 100644 index 00000000..d01ad359 --- /dev/null +++ b/test/test_format_on_save.py @@ -0,0 +1,103 @@ +"""Tests for yapf formatting and applying it on save.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import format_before_save +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.utils.format_code.yapf_format import format_python_source + +BADLY_SPACED = "x = 1\ny=2\n" + + +class TestFormatPythonSource: + def test_reformats_spacing(self): + formatted = format_python_source(BADLY_SPACED) + assert "x = 1" in formatted + + def test_already_formatted_source_is_unchanged(self): + source = "x = 1\ny = 2\n" + assert format_python_source(source) == source + + def test_syntax_error_is_returned_untouched(self): + # Half-written code must not break a save. + broken = "def broken(:\n pass\n" + assert format_python_source(broken) == broken + + def test_empty_source(self): + assert format_python_source("") == "" + assert format_python_source(" \n") == " \n" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def widget(app, tmp_path): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from PySide6.QtWidgets import QPlainTextEdit + tab = MagicMock(spec=EditorWidget) + tab.code_edit = QPlainTextEdit() + tab.code_edit.jump_to_line = MagicMock() + tab.current_file = str(tmp_path / "sample.py") + yield tab + tab.code_edit.deleteLater() + + +@pytest.fixture(autouse=True) +def restore_setting(): + original = user_setting_dict.get("format_on_save", False) + yield + user_setting_dict["format_on_save"] = original + + +class TestFormatBeforeSave: + def test_does_nothing_while_the_setting_is_off(self, widget): + user_setting_dict["format_on_save"] = False + widget.code_edit.setPlainText(BADLY_SPACED) + assert format_before_save(widget) is False + assert widget.code_edit.toPlainText() == BADLY_SPACED + + def test_formats_when_the_setting_is_on(self, widget): + user_setting_dict["format_on_save"] = True + widget.code_edit.setPlainText(BADLY_SPACED) + assert format_before_save(widget) is True + assert "x = 1" in widget.code_edit.toPlainText() + + def test_only_python_files_are_formatted(self, widget, tmp_path): + user_setting_dict["format_on_save"] = True + widget.current_file = str(tmp_path / "notes.txt") + widget.code_edit.setPlainText(BADLY_SPACED) + assert format_before_save(widget) is False + + def test_a_file_that_was_never_saved_is_skipped(self, widget): + user_setting_dict["format_on_save"] = True + widget.current_file = None + widget.code_edit.setPlainText(BADLY_SPACED) + assert format_before_save(widget) is False + + def test_already_formatted_text_is_left_alone(self, widget): + user_setting_dict["format_on_save"] = True + widget.code_edit.setPlainText("x = 1\n") + assert format_before_save(widget) is False + + def test_broken_code_still_saves(self, widget): + user_setting_dict["format_on_save"] = True + broken = "def broken(:\n pass\n" + widget.code_edit.setPlainText(broken) + assert format_before_save(widget) is False + assert widget.code_edit.toPlainText() == broken + + def test_the_caret_line_is_restored(self, widget): + user_setting_dict["format_on_save"] = True + widget.code_edit.setPlainText(BADLY_SPACED) + cursor = widget.code_edit.textCursor() + cursor.setPosition(widget.code_edit.document().findBlockByNumber(1).position()) + widget.code_edit.setTextCursor(cursor) + format_before_save(widget) + widget.code_edit.jump_to_line.assert_called_once_with(2) From 9e30614ca8c8d4b83db43ba85d4b07a74f20ed08 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:28:02 +0800 Subject: [PATCH 09/36] Restore the caret, bookmarks and folds with the session Reopening a session brought the files back but nothing else: every file returned at line one, with its bookmarks and folds gone. Each tab now stores its caret line, bookmarked lines and folded headers alongside the file list, and restoring applies them. Restoring is best-effort: a file that shrank outside the editor simply skips the lines that no longer exist, and a hand-edited settings entry is cleaned rather than trusted, since neither should be able to break startup. --- je_editor/pyside_ui/main_ui/main_editor.py | 31 +++- je_editor/utils/session/editor_state.py | 63 ++++++++ je_editor/utils/session/open_files_session.py | 82 ++++++++++ test/test_session_state.py | 150 ++++++++++++++++++ 4 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 je_editor/utils/session/editor_state.py create mode 100644 test/test_session_state.py diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 1431eed2..1cc8023b 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -37,9 +37,13 @@ ) from je_editor.pyside_ui.main_ui.system_tray.extend_system_tray import ExtendSystemTray from je_editor.utils.file.open.open_file import read_file +from je_editor.utils.session.editor_state import editor_state, restore_editor_state from je_editor.utils.session.open_files_session import ( SESSION_SETTING_KEY, + SESSION_STATE_KEY, + collect_file_states, collect_open_files, + restorable_file_state, restorable_files, ) from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -393,21 +397,44 @@ def _restore_open_files_session(self) -> None: user_setting_dict.get(SESSION_SETTING_KEY), already_open=[path for path in self._open_file_paths() if path], ) + stored_states = user_setting_dict.get(SESSION_STATE_KEY) for file_path in to_restore: self.go_to_new_tab(Path(file_path)) + restore_editor_state( + self.tab_widget.currentWidget(), + restorable_file_state(stored_states, file_path)) except Exception as error: jeditor_logger.warning(f"Restoring the open-file session failed: {error}") def _save_open_files_session(self) -> None: """ - 記錄目前開啟的分頁,供下次啟動還原 - Record the currently open tabs so the next startup can restore them. + 記錄目前開啟的分頁與其編輯狀態,供下次啟動還原 + Record the open tabs and their editor state so the next startup can + restore both. """ try: user_setting_dict[SESSION_SETTING_KEY] = collect_open_files(self._open_file_paths()) + user_setting_dict[SESSION_STATE_KEY] = collect_file_states( + self._open_file_states()) except Exception as error: jeditor_logger.warning(f"Saving the open-file session failed: {error}") + def _open_file_states(self) -> dict: + """ + 取得每個編輯分頁目前的游標、書籤與折疊狀態 + Collect each editor tab's caret, bookmarks and folds. + + :return: 路徑對應狀態 / path -> state + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + states: dict = {} + for index in range(self.tab_widget.count()): + widget = self.tab_widget.widget(index) + if not isinstance(widget, EditorWidget) or not widget.current_file: + continue + states[str(widget.current_file)] = editor_state(widget) + return states + def go_to_new_tab(self, file_path: Path) -> None: """ 開啟新分頁並載入檔案 diff --git a/je_editor/utils/session/editor_state.py b/je_editor/utils/session/editor_state.py new file mode 100644 index 00000000..e677a23b --- /dev/null +++ b/je_editor/utils/session/editor_state.py @@ -0,0 +1,63 @@ +""" +讀取與套用一個編輯分頁的游標、書籤與折疊狀態 +Read and apply one editor tab's caret, bookmarks and folds. + +工作階段還原原本只重新開檔,開起來的檔案永遠停在第一行、書籤與折疊全部消失。 +Session restore used to only reopen files: every one came back at line one with +its bookmarks and folds gone. +""" +from __future__ import annotations + +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.session.open_files_session import build_file_state + + +def editor_state(widget) -> dict: + """ + 取得一個編輯分頁目前的狀態 + Collect the current state of one editor tab. + + :param widget: 編輯分頁 / the editor tab + :return: 可寫入設定的狀態 / the state, ready to store + """ + code_edit = widget.code_edit + return build_file_state( + code_edit.textCursor().blockNumber(), + list(code_edit.bookmark_manager.bookmarked_lines()), + list(code_edit.folding_manager.folded_header_lines()), + ) + + +def restore_editor_state(widget, state: dict | None) -> bool: + """ + 把狀態套回一個編輯分頁 + Apply a stored state back onto an editor tab. + + 還原是「盡力而為」:檔案在編輯器外被改短時,超出範圍的行號會被跳過,而不是 + 讓整個還原失敗。 + Restoring is best-effort: if the file shrank outside the editor, lines beyond + its end are skipped rather than failing the whole restore. + + :param widget: 編輯分頁,可為 ``None`` / the editor tab, may be ``None`` + :param state: 要套用的狀態,可為 ``None`` / the state to apply, may be ``None`` + :return: 有套用時為 ``True`` / ``True`` when the state was applied + """ + if widget is None or not state: + return False + code_edit = getattr(widget, "code_edit", None) + if code_edit is None: + return False + last_line = code_edit.blockCount() - 1 + try: + for line in state.get("bookmarks", []): + if line <= last_line: + code_edit.bookmark_manager.toggle(line) + for line in state.get("folds", []): + if line <= last_line: + code_edit.folding_manager.toggle_fold(line) + caret = min(state.get("caret_line", 0), last_line) + code_edit.jump_to_line(caret + 1) + except (AttributeError, RuntimeError, ValueError) as error: + jeditor_logger.warning(f"Restoring editor state failed: {error}") + return False + return True diff --git a/je_editor/utils/session/open_files_session.py b/je_editor/utils/session/open_files_session.py index 8d5e4c41..0bdc9834 100644 --- a/je_editor/utils/session/open_files_session.py +++ b/je_editor/utils/session/open_files_session.py @@ -12,9 +12,14 @@ # 設定檔中儲存工作階段的鍵名 / Settings key holding the session SESSION_SETTING_KEY = "open_files" +# 設定檔中儲存各檔案編輯狀態的鍵名 / Settings key holding each file's editor state +SESSION_STATE_KEY = "open_file_states" # 還原檔案數量上限,避免上次留下大量分頁時啟動變慢 # Cap on restored files so a huge previous session cannot slow startup MAX_SESSION_FILES = 20 +# 每個檔案記錄的書籤與折疊行數上限,避免設定檔無限成長 +# Cap on bookmarks and folds stored per file, so the settings cannot grow forever +MAX_STATE_LINES = 200 def collect_open_files(current_files: Iterable[str | None]) -> list[str]: @@ -78,6 +83,83 @@ def restorable_files( return restorable +def _clean_line_list(value: object) -> list[int]: + """ + 整理成排序過、去重、非負的行號清單 + Clean a stored value into sorted, unique, non-negative line numbers. + + :param value: 從設定讀出的值,任何型別 / the stored value, any type + :return: 可用的行號 / the usable line numbers + """ + if not isinstance(value, list): + return [] + lines = { + entry for entry in value + if isinstance(entry, int) and not isinstance(entry, bool) and entry >= 0 + } + return sorted(lines)[:MAX_STATE_LINES] + + +def build_file_state(caret_line: int, bookmarks: object, folds: object) -> dict: + """ + 整理一個檔案要記錄的編輯狀態 + Build the editor state to persist for one file. + + :param caret_line: 游標所在行(0 起算)/ the caret's 0-based line + :param bookmarks: 書籤行號 / the bookmarked line numbers + :param folds: 折疊起始行號 / the folded header line numbers + :return: 可寫入設定的狀態 / the state, ready to store + """ + return { + "caret_line": max(0, int(caret_line)), + "bookmarks": _clean_line_list(bookmarks), + "folds": _clean_line_list(folds), + } + + +def collect_file_states(states: dict) -> dict: + """ + 整理所有檔案的編輯狀態 + Clean every file's editor state before storing it. + + :param states: 路徑對應狀態 / path -> state + :return: 只含有效項目的對照表 / the mapping with unusable entries dropped + """ + collected: dict = {} + for path, state in states.items(): + if not path or not isinstance(state, dict): + continue + collected[str(path)] = build_file_state( + state.get("caret_line", 0), state.get("bookmarks"), state.get("folds")) + return collected + + +def restorable_file_state(stored_states: object, file_path: str) -> dict | None: + """ + 取出某個檔案可還原的編輯狀態 + Return the restorable editor state for one file. + + 設定檔可能是手動編輯或舊版寫的,因此格式不符時視為沒有狀態,而不是讓還原失敗。 + A hand-edited or older settings file may hold anything, so an unusable entry + counts as no state rather than breaking the restore. + + :param stored_states: 從設定讀出的值 / the stored value, any type + :param file_path: 目標檔案路徑 / the file to look up + :return: 狀態,沒有可用狀態時為 ``None`` / the state, or ``None`` + """ + if not isinstance(stored_states, dict): + return None + state = stored_states.get(str(file_path)) + if not isinstance(state, dict): + return None + caret = state.get("caret_line", 0) + return { + "caret_line": caret if isinstance(caret, int) and caret >= 0 else 0, + "bookmarks": _clean_line_list(state.get("bookmarks")), + "folds": _clean_line_list(state.get("folds")), + } + + def _is_readable_file(entry: str) -> bool: """判斷路徑是否為現存的檔案 / Whether the path is an existing file.""" try: diff --git a/test/test_session_state.py b/test/test_session_state.py new file mode 100644 index 00000000..e07caa06 --- /dev/null +++ b/test/test_session_state.py @@ -0,0 +1,150 @@ +"""Tests for persisting and restoring per-file editor state in the session.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.session.editor_state import editor_state, restore_editor_state +from je_editor.utils.session.open_files_session import ( + MAX_STATE_LINES, + build_file_state, + collect_file_states, + restorable_file_state, +) + + +class TestBuildFileState: + def test_keeps_caret_bookmarks_and_folds(self): + state = build_file_state(4, [2, 7], [1]) + assert state == {"caret_line": 4, "bookmarks": [2, 7], "folds": [1]} + + def test_lines_are_sorted_and_deduplicated(self): + assert build_file_state(0, [5, 1, 5], [])["bookmarks"] == [1, 5] + + def test_negative_lines_are_dropped(self): + assert build_file_state(0, [-3, 2], [])["bookmarks"] == [2] + + def test_booleans_are_not_treated_as_lines(self): + assert build_file_state(0, [True, 3], [])["bookmarks"] == [3] + + def test_negative_caret_is_clamped(self): + assert build_file_state(-5, [], [])["caret_line"] == 0 + + def test_non_list_values_become_empty(self): + assert build_file_state(0, "nonsense", None)["bookmarks"] == [] + + def test_line_count_is_capped(self): + state = build_file_state(0, list(range(MAX_STATE_LINES + 50)), []) + assert len(state["bookmarks"]) == MAX_STATE_LINES + + +class TestCollectFileStates: + def test_collects_each_file(self): + collected = collect_file_states({ + "a.py": {"caret_line": 3, "bookmarks": [1], "folds": []}, + "b.py": {"caret_line": 0, "bookmarks": [], "folds": [2]}, + }) + assert collected["a.py"]["caret_line"] == 3 + assert collected["b.py"]["folds"] == [2] + + def test_entries_without_a_path_are_dropped(self): + assert collect_file_states({"": {"caret_line": 1}}) == {} + + def test_entries_that_are_not_dicts_are_dropped(self): + assert collect_file_states({"a.py": "nonsense"}) == {} + + +class TestRestorableFileState: + def test_reads_back_a_stored_state(self): + stored = {"a.py": {"caret_line": 5, "bookmarks": [2], "folds": [1]}} + assert restorable_file_state(stored, "a.py") == { + "caret_line": 5, "bookmarks": [2], "folds": [1]} + + def test_unknown_file(self): + assert restorable_file_state({"a.py": {}}, "b.py") is None + + def test_stored_value_of_the_wrong_type(self): + assert restorable_file_state("nonsense", "a.py") is None + + def test_hand_edited_entry_is_cleaned_rather_than_trusted(self): + stored = {"a.py": {"caret_line": "top", "bookmarks": ["x", 4], "folds": None}} + assert restorable_file_state(stored, "a.py") == { + "caret_line": 0, "bookmarks": [4], "folds": []} + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + code_editor.setPlainText("\n".join(f"line {index}" for index in range(20))) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +@pytest.fixture() +def tab(editor): + widget = MagicMock() + widget.code_edit = editor + return widget + + +class TestEditorStateRoundTrip: + def test_state_records_the_caret_line(self, editor, tab): + editor.jump_to_line(6) + assert editor_state(tab)["caret_line"] == 5 + + def test_state_records_bookmarks(self, editor, tab): + editor.bookmark_manager.toggle(3) + assert 3 in editor_state(tab)["bookmarks"] + + def test_restoring_moves_the_caret(self, editor, tab): + restore_editor_state(tab, {"caret_line": 8, "bookmarks": [], "folds": []}) + assert editor.textCursor().blockNumber() == 8 + + def test_restoring_brings_bookmarks_back(self, editor, tab): + restore_editor_state(tab, {"caret_line": 0, "bookmarks": [2, 5], "folds": []}) + assert set(editor.bookmark_manager.bookmarked_lines()) >= {2, 5} + + def test_round_trip_through_the_session_format(self, editor, tab): + editor.bookmark_manager.toggle(4) + editor.jump_to_line(7) + stored = collect_file_states({"a.py": editor_state(tab)}) + state = restorable_file_state(stored, "a.py") + # A fresh editor state, then restore into it + editor.bookmark_manager.toggle(4) + editor.jump_to_line(1) + restore_editor_state(tab, state) + assert editor.textCursor().blockNumber() == 6 + assert 4 in editor.bookmark_manager.bookmarked_lines() + + def test_lines_past_the_end_are_skipped(self, editor, tab): + assert restore_editor_state( + tab, {"caret_line": 999, "bookmarks": [900], "folds": []}) is True + assert editor.textCursor().blockNumber() <= editor.blockCount() - 1 + + def test_no_state_is_a_no_op(self, tab): + assert restore_editor_state(tab, None) is False + assert restore_editor_state(tab, {}) is False + + def test_no_widget_is_a_no_op(self): + assert restore_editor_state(None, {"caret_line": 1}) is False + + def test_a_widget_without_an_editor_is_a_no_op(self): + assert restore_editor_state(object(), {"caret_line": 1}) is False From 0f2952e91d6582bf0b870b42d2214102c533ea8b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:31:55 +0800 Subject: [PATCH 10/36] Show indent guides and trailing whitespace A vertical guide now marks each indentation level, and stray whitespace at the end of a line is shaded so it can be seen before it reaches a diff. Both are drawn in the viewport and can be turned off from the Style menu; guides paint behind the text so they never obscure it. Where the marks go is computed in utils/indentation as pure logic: tabs advance to the next stop rather than adding a fixed width, partial indentation rounds down to the level it reached, and a blank line shows no guide because it carries no indentation to describe. --- .../code_edit_plaintext.py | 56 +++++++- .../menu/style_menu/build_style_menu.py | 47 ++++++ .../save_settings/user_color_setting_file.py | 6 +- je_editor/utils/indentation/indent_guides.py | 75 ++++++++++ je_editor/utils/multi_language/english.py | 2 + .../multi_language/traditional_chinese.py | 2 + test/test_indent_guides.py | 135 ++++++++++++++++++ 7 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 je_editor/utils/indentation/indent_guides.py create mode 100644 test/test_indent_guides.py 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 16935bce..08b94f3e 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 @@ -25,6 +25,9 @@ convert_leading_spaces_to_tabs, convert_leading_tabs_to_spaces, detect_indent_width, detect_indentation_uses_tabs ) +from je_editor.utils.indentation.indent_guides import ( + guide_columns, trailing_whitespace_start +) from je_editor.utils.line_ops.line_operations import ( join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines ) @@ -38,6 +41,7 @@ from je_editor.pyside_ui.dialog.search_ui.search_text_box import SearchBox from je_editor.pyside_ui.dialog.search_ui.search_replace_widget import SearchReplaceDialog from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict from je_editor.utils.align.align import align_by_delimiter from je_editor.utils.case_convert.case_convert import ( to_camel_case, to_kebab_case, to_pascal_case, to_snake_case @@ -554,13 +558,61 @@ def toggle_blame(self) -> bool: def paintEvent(self, event: QtGui.QPaintEvent) -> None: """ - 繪製內容,並在開啟時附上行尾的 blame 標註 - Paint the text, then the end-of-line blame annotations when they are on. + 繪製內容,並疊上縮排參考線、尾端空白與 blame 標註 + Paint the text, then overlay indent guides, trailing whitespace and the + blame annotations. """ + # 參考線畫在文字之前,才不會蓋住字 + # Guides are painted first so they sit behind the text + if user_setting_dict.get("show_indent_guides", True): + self._paint_indent_guides() QPlainTextEdit.paintEvent(self, event) + if user_setting_dict.get("show_trailing_whitespace", True): + self._paint_trailing_whitespace() if self.blame_manager.enabled: self._paint_blame_annotations() + def _visible_blocks(self): + """逐一產生可見的區塊與其頂端座標 / Yield each visible block and its top.""" + block = self.firstVisibleBlock() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + bottom_limit = self.viewport().rect().bottom() + while block.isValid() and top <= bottom_limit: + if block.isVisible(): + yield block, top + top += self.blockBoundingRect(block).height() + block = block.next() + + def _paint_indent_guides(self) -> None: + """在每一層縮排畫出垂直參考線 / Draw a vertical line at each indent level.""" + painter = QPainter(self.viewport()) + painter.setPen(actually_color_dict.get("indent_guide_color")) + metrics = self.fontMetrics() + space_width = metrics.horizontalAdvance(" ") + line_height = metrics.height() + indent_size = self.indent_size() + for block, top in self._visible_blocks(): + for column in guide_columns(block.text(), indent_size): + position = int(column * space_width) + painter.drawLine(position, int(top), position, int(top) + line_height) + painter.end() + + def _paint_trailing_whitespace(self) -> None: + """標出每一行尾端多餘的空白 / Mark the stray whitespace at each line's end.""" + painter = QPainter(self.viewport()) + colour = actually_color_dict.get("trailing_whitespace_color") + metrics = self.fontMetrics() + line_height = metrics.height() + for block, top in self._visible_blocks(): + text = block.text() + start = trailing_whitespace_start(text) + if start is None: + continue + left = metrics.horizontalAdvance(text[:start]) + width = metrics.horizontalAdvance(text[start:]) + painter.fillRect(int(left), int(top), max(1, int(width)), line_height, colour) + painter.end() + def _paint_blame_annotations(self) -> None: """在每個可見行的文字後面畫出 blame 標註 / Draw blame after each visible line.""" painter = QPainter(self.viewport()) diff --git a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py index 6c435680..7759c467 100644 --- a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py @@ -57,6 +57,53 @@ def set_style_menu(ui_we_want_to_set: EditorMain) -> None: # Add action to the Style menu ui_we_want_to_set.menu.style_menu.addAction(change_style_action) + # 編輯器疊加顯示的開關 / Toggles for the editor's overlays + ui_we_want_to_set.menu.style_menu.addSeparator() + add_overlay_toggles(ui_we_want_to_set) + + +def add_overlay_toggles(ui_we_want_to_set: EditorMain) -> None: + """ + 加入縮排參考線與尾端空白的顯示開關 + Add the toggles for indent guides and trailing-whitespace marking. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + """ + jeditor_logger.info("build_style_menu.py add_overlay_toggles") + for setting_key, label_key in ( + ("show_indent_guides", "style_menu_indent_guides_label"), + ("show_trailing_whitespace", "style_menu_trailing_whitespace_label"), + ): + toggle = QAction( + language_wrapper.language_word_dict.get(label_key), + parent=ui_we_want_to_set.menu.style_menu) + toggle.setCheckable(True) + toggle.setChecked(bool(user_setting_dict.get(setting_key, True))) + toggle.toggled.connect( + lambda checked, key=setting_key: set_overlay_setting(ui_we_want_to_set, key, checked)) + ui_we_want_to_set.menu.style_menu.addAction(toggle) + + +def set_overlay_setting(ui_we_want_to_set: EditorMain, setting_key: str, enabled: bool) -> None: + """ + 儲存疊加顯示的開關並立即重畫 + Store an overlay toggle and repaint straight away. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :param setting_key: 設定鍵名 / the settings key to update + :param enabled: 是否顯示 / whether the overlay is shown + """ + jeditor_logger.info(f"build_style_menu.py set_overlay_setting {setting_key}: {enabled}") + user_setting_dict.update({setting_key: bool(enabled)}) + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + for index in range(tab_widget.count()): + widget = tab_widget.widget(index) + if isinstance(widget, EditorWidget): + widget.code_edit.viewport().update() + # 套用選擇的樣式 # Apply the selected style diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 4475eb69..0100e722 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -44,6 +44,8 @@ def update_actually_color_dict() -> None: "diff_removed_marker_color": _to_qcolor("diff_removed_marker_color", [229, 57, 53]), "lint_underline_color": _to_qcolor("lint_underline_color", [255, 138, 101]), "blame_annotation_color": _to_qcolor("blame_annotation_color", [130, 130, 130]), + "indent_guide_color": _to_qcolor("indent_guide_color", [90, 90, 110]), + "trailing_whitespace_color": _to_qcolor("trailing_whitespace_color", [120, 70, 70]), } ) @@ -64,7 +66,9 @@ def update_actually_color_dict() -> None: "diff_modified_marker_color": [255, 167, 38], "diff_removed_marker_color": [229, 57, 53], "lint_underline_color": [255, 138, 101], - "blame_annotation_color": [130, 130, 130] + "blame_annotation_color": [130, 130, 130], + "indent_guide_color": [90, 90, 110], + "trailing_whitespace_color": [120, 70, 70] } # 實際使用的顏色字典 (以 QColor 表示) diff --git a/je_editor/utils/indentation/indent_guides.py b/je_editor/utils/indentation/indent_guides.py new file mode 100644 index 00000000..953ea368 --- /dev/null +++ b/je_editor/utils/indentation/indent_guides.py @@ -0,0 +1,75 @@ +""" +計算縮排參考線與尾端空白的位置 +Work out where indent guides and trailing whitespace sit on a line. + +繪製交給編輯器,這裡只做位置計算,因此可以單獨測試。 +The editor does the drawing; this only computes positions, so it can be tested +on its own. +""" +from __future__ import annotations + +# 一行最多畫幾條參考線,避免極深縮排時畫滿整行 +# How many guides one line may show, so very deep indentation cannot fill it +MAX_GUIDES_PER_LINE = 16 + + +def leading_space_width(text: str, tab_size: int) -> int: + """ + 計算一行前導空白的顯示寬度(tab 換算成空格) + The display width of a line's leading whitespace, counting a tab as *tab_size*. + + :param text: 該行文字 / the line's text + :param tab_size: 一個 tab 相當於幾個空格 / how many spaces a tab stands for + :return: 前導空白的寬度 / the width of the leading whitespace + """ + width = 0 + for character in text: + if character == " ": + width += 1 + elif character == "\t": + # tab 跳到下一個定位點,不是固定加 tab_size + # A tab advances to the next stop rather than adding a fixed amount + width += tab_size - (width % tab_size) + else: + break + return width + + +def guide_columns(text: str, indent_size: int) -> list[int]: + """ + 取得一行要畫縮排參考線的欄位 + The columns where a line should show indent guides. + + 只在該行自己的縮排範圍內畫;空白行沒有縮排資訊,因此不畫,免得畫出誤導的線。 + Guides are drawn only within the line's own indentation. A blank line carries + no indentation information, so it shows none rather than a misleading one. + + :param text: 該行文字 / the line's text + :param indent_size: 一層縮排的寬度 / the width of one indentation level + :return: 要畫線的欄位(0 起算)/ the 0-based columns to draw at + """ + if indent_size <= 0 or not text.strip(): + return [] + width = leading_space_width(text, indent_size) + levels = min(width // indent_size, MAX_GUIDES_PER_LINE) + return [level * indent_size for level in range(1, levels + 1)] + + +def trailing_whitespace_start(text: str) -> int | None: + """ + 取得一行尾端空白的起始位置 + Where a line's trailing whitespace starts. + + 整行都是空白時視為尾端空白;完全空白的行不算(那只是空行)。 + A line of nothing but whitespace counts, while an empty line does not, since + that is simply a blank line. + + :param text: 該行文字 / the line's text + :return: 起始字元索引,沒有尾端空白時為 ``None`` / the index, or ``None`` + """ + if not text: + return None + stripped = text.rstrip() + if len(stripped) == len(text): + return None + return len(stripped) diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 3fda37e4..59289515 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -87,6 +87,8 @@ "file_menu_encoding_label": "Encodings", "file_menu_line_ending_label": "Line Endings", "format_on_save_label": "Format on Save", + "style_menu_indent_guides_label": "Show Indent Guides", + "style_menu_trailing_whitespace_label": "Show Trailing Whitespace", "file_menu_font_label": "Font", "file_menu_font_size_label": _FONT_SIZE_LABEL, # Help Menu diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index a27b295d..7fa992bf 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -84,6 +84,8 @@ "file_menu_encoding_label": "編碼", "file_menu_line_ending_label": "行尾", "format_on_save_label": "存檔時自動格式化", + "style_menu_indent_guides_label": "顯示縮排參考線", + "style_menu_trailing_whitespace_label": "顯示尾端空白", "file_menu_font_label": "字體", "file_menu_font_size_label": "字體大小", # Help Menu diff --git a/test/test_indent_guides.py b/test/test_indent_guides.py new file mode 100644 index 00000000..74896041 --- /dev/null +++ b/test/test_indent_guides.py @@ -0,0 +1,135 @@ +"""Tests for indent-guide columns and trailing-whitespace detection.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.indentation.indent_guides import ( + MAX_GUIDES_PER_LINE, + guide_columns, + leading_space_width, + trailing_whitespace_start, +) + + +class TestLeadingSpaceWidth: + def test_spaces(self): + assert leading_space_width(" code", 4) == 4 + + def test_no_indent(self): + assert leading_space_width("code", 4) == 0 + + def test_tab_advances_to_the_next_stop(self): + assert leading_space_width("\tcode", 4) == 4 + + def test_space_then_tab_fills_the_stop(self): + # Two spaces then a tab lands on the next multiple of four, not 2 + 4. + assert leading_space_width(" \tcode", 4) == 4 + + def test_only_leading_whitespace_counts(self): + assert leading_space_width(" a b", 4) == 2 + + +class TestGuideColumns: + def test_one_level(self): + assert guide_columns(" code", 4) == [4] + + def test_two_levels(self): + assert guide_columns(" code", 4) == [4, 8] + + def test_unindented_line_has_no_guides(self): + assert guide_columns("code", 4) == [] + + def test_blank_line_has_no_guides(self): + assert guide_columns("", 4) == [] + assert guide_columns(" ", 4) == [] + + def test_partial_indentation_rounds_down(self): + assert guide_columns(" code", 4) == [4] + + def test_tab_indentation(self): + assert guide_columns("\t\tcode", 4) == [4, 8] + + def test_guides_are_capped(self): + deep = " " * (4 * (MAX_GUIDES_PER_LINE + 10)) + "code" + assert len(guide_columns(deep, 4)) == MAX_GUIDES_PER_LINE + + def test_invalid_indent_size(self): + assert guide_columns(" code", 0) == [] + + +class TestTrailingWhitespace: + def test_trailing_spaces(self): + assert trailing_whitespace_start("code ") == 4 + + def test_trailing_tab(self): + assert trailing_whitespace_start("code\t") == 4 + + def test_clean_line(self): + assert trailing_whitespace_start("code") is None + + def test_empty_line_is_not_reported(self): + assert trailing_whitespace_start("") is None + + def test_whitespace_only_line_is_reported_from_the_start(self): + assert trailing_whitespace_start(" ") == 0 + + def test_inner_spaces_are_not_trailing(self): + assert trailing_whitespace_start("a b") is None + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestPainting: + def test_painting_indented_text_with_trailing_spaces_does_not_raise(self, editor): + editor.setPlainText("def run():\n x = 1 \n y = 2\n") + editor.show() + QApplication.processEvents() + editor.hide() + + def test_settings_can_turn_the_overlays_off(self, editor): + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + original = ( + user_setting_dict.get("show_indent_guides", True), + user_setting_dict.get("show_trailing_whitespace", True), + ) + user_setting_dict["show_indent_guides"] = False + user_setting_dict["show_trailing_whitespace"] = False + try: + editor.setPlainText(" x = 1 \n") + editor.show() + QApplication.processEvents() + editor.hide() + finally: + user_setting_dict["show_indent_guides"] = original[0] + user_setting_dict["show_trailing_whitespace"] = original[1] + + def test_every_overlay_colour_is_defined(self, app): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict + ) + for key in ("indent_guide_color", "trailing_whitespace_color"): + assert actually_color_dict.get(key) is not None From 1a03a722af313b7d4b57e7455b03e31078202f5f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:35:52 +0800 Subject: [PATCH 11/36] Add a split view of the same file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+Alt+\ opens a second view below the editor showing the same document: an edit in either side appears in the other at once, while scrolling and the caret stay independent, so one end of a file can be read while the other is edited. The view shares the QTextDocument rather than copying text, which also means the existing syntax highlighter — attached to the document, not the editor — colours it without a second highlighter. Closing it detaches from the shared document first, so tearing the view down cannot take the main editor''s document state with it. --- .../pyside_ui/code/split_view/__init__.py | 0 .../code/split_view/split_editor_view.py | 55 +++++++++ .../pyside_ui/main_ui/editor/editor_widget.py | 28 +++++ .../main_ui/menu/tab_menu/build_tab_menu.py | 28 +++++ je_editor/utils/multi_language/english.py | 1 + .../multi_language/traditional_chinese.py | 1 + test/test_split_view.py | 115 ++++++++++++++++++ 7 files changed, 228 insertions(+) create mode 100644 je_editor/pyside_ui/code/split_view/__init__.py create mode 100644 je_editor/pyside_ui/code/split_view/split_editor_view.py create mode 100644 test/test_split_view.py diff --git a/je_editor/pyside_ui/code/split_view/__init__.py b/je_editor/pyside_ui/code/split_view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/split_view/split_editor_view.py b/je_editor/pyside_ui/code/split_view/split_editor_view.py new file mode 100644 index 00000000..cd0616fc --- /dev/null +++ b/je_editor/pyside_ui/code/split_view/split_editor_view.py @@ -0,0 +1,55 @@ +""" +同一份文件的第二個檢視 +A second view of the same document. + +分割檢視共用 ``QTextDocument``:兩邊看到的是同一份內容,任一邊編輯另一邊立刻 +跟著變,捲動與游標則各自獨立,因此可以一邊看檔案開頭一邊改結尾。 +A split view shares the ``QTextDocument``: both sides show one piece of content +and an edit in either appears in the other at once, while scrolling and the +caret stay independent — so the top of a file can be read while its end is +edited. + +語法高亮是掛在文件上而不是編輯器上,因此第二個檢視不需要自己的高亮器就會上色。 +Syntax highlighting attaches to the document rather than to an editor, so the +second view is highlighted without needing a highlighter of its own. +""" +from __future__ import annotations + +from PySide6.QtGui import QFontMetricsF +from PySide6.QtWidgets import QPlainTextEdit, QWidget + + +class SplitEditorView(QPlainTextEdit): + """ + 共用文件的輕量編輯檢視 + A lightweight editing view onto a shared document. + """ + + def __init__(self, source_editor: QPlainTextEdit, parent: QWidget | None = None) -> None: + """ + :param source_editor: 要共用文件的主編輯器 / the editor whose document is shared + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + self.setDocument(source_editor.document()) + self.setFont(source_editor.font()) + self.setLineWrapMode(source_editor.lineWrapMode()) + self.setTabStopDistance( + QFontMetricsF(source_editor.font()).horizontalAdvance(" " * 8)) + # 從主編輯器目前的位置開始看,而不是從檔案開頭 + # Start where the main editor is, rather than at the top of the file + self.setTextCursor(source_editor.textCursor()) + + def closeEvent(self, event) -> None: + """ + 關閉前先放開共用文件 + Release the shared document before closing. + + 若仍持有主編輯器的文件就被銷毀,Qt 會連帶清掉那份文件的檢視狀態;改指向 + 一份空文件可以乾淨地脫鉤。 + Destroying this view while it still holds the main editor's document lets + Qt tear down view state that document still needs; pointing it at an + empty document detaches cleanly first. + """ + self.setDocument(None) + super().closeEvent(event) diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index ff25c03d..6f3e492b 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -29,6 +29,7 @@ from je_editor.pyside_ui.code.auto_save.auto_save_thread import CodeEditSaveThread from je_editor.pyside_ui.code.code_format.pep8_format import PEP8FormatChecker from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor +from je_editor.pyside_ui.code.split_view.split_editor_view import SplitEditorView from je_editor.pyside_ui.code.textedit_code_result.code_record import CodeRecord from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict @@ -158,6 +159,9 @@ def __init__(self, main_window: EditorMain) -> None: self.edit_splitter.setStretchFactor(1, 1) self.edit_splitter.setSizes([300, 100]) + # 同檔分割檢視,開啟時才建立 / The same-file split view, created on demand + self.split_view: Union[SplitEditorView, None] = None + self.full_splitter.addWidget(self.project_treeview) self.full_splitter.addWidget(self.edit_splitter) self.full_splitter.setStretchFactor(0, 1) @@ -333,6 +337,30 @@ def mark_saved(self) -> None: if title.endswith(" *"): self.tab_manager.setTabText(idx, title[:-2]) + def toggle_split_view(self) -> bool: + """ + 切換同檔分割檢視 + Toggle the split view of the same file. + + 兩個檢視共用同一份文件,因此任一邊的編輯會立刻出現在另一邊,而捲動與游標 + 各自獨立。 + Both views share one document, so an edit in either appears in the other + at once, while scrolling and the caret stay independent. + + :return: 切換後是否為開啟 / whether the split view is now shown + """ + if self.split_view is not None: + self.split_view.close() + self.split_view.setParent(None) + self.split_view.deleteLater() + self.split_view = None + return False + self.split_view = SplitEditorView(self.code_edit) + # 插在主編輯器下方、輸出區上方 / Below the main editor, above the output + self.edit_splitter.insertWidget(1, self.split_view) + self.edit_splitter.setSizes([200, 200, 100]) + return True + def rename_self_tab(self) -> None: """ 將分頁的標籤名稱改為目前檔案名稱 (不限當前分頁)。 diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py index bfac0835..573e3575 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py @@ -59,10 +59,38 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.add_console_widget_ui_action) + # === 同檔分割檢視 === + # === Split view of the same file === + ui_we_want_to_set.tab_menu.toggle_split_view_action = QAction( + language_wrapper.language_word_dict.get("tab_menu_split_view_label")) + ui_we_want_to_set.tab_menu.toggle_split_view_action.setShortcut("Ctrl+Alt+\\") + ui_we_want_to_set.tab_menu.toggle_split_view_action.triggered.connect( + lambda: toggle_split_view(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.toggle_split_view_action) + set_tab_tools_menu(ui_we_want_to_set=ui_we_want_to_set) set_tab_git_menu(ui_we_want_to_set=ui_we_want_to_set) +def toggle_split_view(ui_we_want_to_set: EditorMain) -> bool: + """ + 切換目前分頁的同檔分割檢視 + Toggle the split view of the current tab's file. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 切換後是否為開啟 / whether the split view is now shown + """ + jeditor_logger.info("build_tab_menu.py toggle_split_view") + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return False + widget = tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + return False + return widget.toggle_split_view() + + # === 以下為各分頁新增函式 === # === Functions to add each tab === diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 59289515..329c2677 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -382,6 +382,7 @@ "todo_panel_col_file": "File", "todo_panel_col_line": "Line", "tab_menu_diff_against_head_name": "Diff Against HEAD", + "tab_menu_split_view_label": "Toggle Split View", # Problems panel "tab_menu_problems_panel_tab_name": "Problems", "problems_panel_refresh": "Recheck", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 7fa992bf..f0916fa4 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -372,6 +372,7 @@ "todo_panel_col_file": "檔案", "todo_panel_col_line": "行號", "tab_menu_diff_against_head_name": "與 HEAD 比較差異", + "tab_menu_split_view_label": "切換分割檢視", # Problems panel "tab_menu_problems_panel_tab_name": "問題", "problems_panel_refresh": "重新檢查", diff --git a/test/test_split_view.py b/test/test_split_view.py new file mode 100644 index 00000000..c15c414e --- /dev/null +++ b/test/test_split_view.py @@ -0,0 +1,115 @@ +"""Tests for the same-file split view.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication, QPlainTextEdit + +from je_editor.pyside_ui.code.split_view.split_editor_view import SplitEditorView + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def source(app): + editor = QPlainTextEdit() + editor.setPlainText("first\nsecond\nthird\n") + yield editor + editor.deleteLater() + + +class TestSplitEditorView: + def test_shows_the_same_content(self, source): + view = SplitEditorView(source) + assert view.toPlainText() == source.toPlainText() + view.close() + view.deleteLater() + + def test_shares_one_document(self, source): + view = SplitEditorView(source) + assert view.document() is source.document() + view.close() + view.deleteLater() + + def test_an_edit_in_the_split_reaches_the_main_editor(self, source): + view = SplitEditorView(source) + view.setPlainText("rewritten\n") + assert source.toPlainText() == "rewritten\n" + view.close() + view.deleteLater() + + def test_an_edit_in_the_main_editor_reaches_the_split(self, source): + view = SplitEditorView(source) + source.setPlainText("from the main editor\n") + assert view.toPlainText() == "from the main editor\n" + view.close() + view.deleteLater() + + def test_carets_are_independent(self, source): + view = SplitEditorView(source) + cursor = view.textCursor() + cursor.setPosition(view.document().findBlockByNumber(2).position()) + view.setTextCursor(cursor) + assert view.textCursor().blockNumber() == 2 + assert source.textCursor().blockNumber() == 0 + view.close() + view.deleteLater() + + def test_closing_releases_the_shared_document(self, source): + view = SplitEditorView(source) + view.close() + # The main editor keeps its document and stays usable. + source.setPlainText("still fine\n") + assert source.toPlainText() == "still fine\n" + view.deleteLater() + + +@pytest.fixture() +def editor_widget(app): + from PySide6.QtWidgets import QTabWidget + main_window = MagicMock() + main_window.working_dir = None + main_window.tab_widget = QTabWidget() + main_window.python_compiler = None + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + widget = EditorWidget(main_window) + yield widget + widget.code_edit.lint_manager.stop() + widget.code_edit.diff_marker_manager.stop() + widget.code_edit.blame_manager.stop() + widget.deleteLater() + + +class TestToggleSplitView: + def test_starts_without_a_split(self, editor_widget): + assert editor_widget.split_view is None + + def test_toggling_on_adds_the_view(self, editor_widget): + assert editor_widget.toggle_split_view() is True + assert editor_widget.split_view is not None + assert editor_widget.split_view.document() is editor_widget.code_edit.document() + + def test_toggling_off_removes_it(self, editor_widget): + editor_widget.toggle_split_view() + assert editor_widget.toggle_split_view() is False + assert editor_widget.split_view is None + + def test_the_main_editor_survives_closing_the_split(self, editor_widget): + editor_widget.code_edit.setPlainText("content\n") + editor_widget.toggle_split_view() + editor_widget.toggle_split_view() + editor_widget.code_edit.setPlainText("still editable\n") + assert editor_widget.code_edit.toPlainText() == "still editable\n" + + def test_split_shows_edits_made_after_opening(self, editor_widget): + editor_widget.toggle_split_view() + editor_widget.code_edit.setPlainText("typed later\n") + assert editor_widget.split_view.toPlainText() == "typed later\n" From af7c878ec6a48e13645eb8976490ee4e7588c765 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:40:08 +0800 Subject: [PATCH 12/36] Add a minimap beside the editor Ctrl+Alt+M shows an overview of the whole file: each line is drawn as a bar following its length and indentation, so the shape of the code is recognisable, and a band marks what is currently on screen. Clicking or dragging jumps there. A file with more lines than the minimap has pixels is sampled rather than drawn line by line, and the redraw after an edit is debounced, so a large file costs the same to display as a small one. --- je_editor/pyside_ui/code/minimap/__init__.py | 0 .../pyside_ui/code/minimap/minimap_widget.py | 121 ++++++++++++++ .../pyside_ui/main_ui/editor/editor_widget.py | 36 ++++- .../main_ui/menu/tab_menu/build_tab_menu.py | 28 ++++ .../save_settings/user_color_setting_file.py | 6 + je_editor/utils/minimap/__init__.py | 0 je_editor/utils/minimap/minimap_layout.py | 112 +++++++++++++ je_editor/utils/multi_language/english.py | 1 + .../multi_language/traditional_chinese.py | 1 + test/test_minimap.py | 149 ++++++++++++++++++ 10 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 je_editor/pyside_ui/code/minimap/__init__.py create mode 100644 je_editor/pyside_ui/code/minimap/minimap_widget.py create mode 100644 je_editor/utils/minimap/__init__.py create mode 100644 je_editor/utils/minimap/minimap_layout.py create mode 100644 test/test_minimap.py diff --git a/je_editor/pyside_ui/code/minimap/__init__.py b/je_editor/pyside_ui/code/minimap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/minimap/minimap_widget.py b/je_editor/pyside_ui/code/minimap/minimap_widget.py new file mode 100644 index 00000000..77e165d9 --- /dev/null +++ b/je_editor/pyside_ui/code/minimap/minimap_widget.py @@ -0,0 +1,121 @@ +""" +編輯器右側的縮圖 +The minimap shown beside the editor. + +把整份檔案畫成細長條的輪廓,並標出目前畫面的位置;點按或拖曳就能跳到該處。 +Draws the whole file as thin bars showing the shape of the code, marks where the +screen currently is, and jumps there when clicked or dragged. +""" +from __future__ import annotations + +from PySide6.QtCore import QTimer, Qt +from PySide6.QtGui import QPainter +from PySide6.QtWidgets import QPlainTextEdit, QWidget + +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict +from je_editor.utils.minimap.minimap_layout import ( + LINE_PIXELS, + MINIMAP_WIDTH, + bar_offset, + bar_width, + line_at_row, + row_for_line, + sample_step, + viewport_band, +) + +# 文件變更後多久重畫縮圖(毫秒);輸入時不需要每個字都重畫 +# How long after an edit the minimap repaints; it need not follow every keystroke +_REPAINT_DELAY_MS = 300 + + +class MinimapWidget(QWidget): + """ + 顯示整份檔案輪廓的縮圖 + A minimap showing the shape of the whole file. + """ + + def __init__(self, code_edit: QPlainTextEdit, parent: QWidget | None = None) -> None: + """ + :param code_edit: 要對應的編輯器 / the editor this minimap follows + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + self._code_edit = code_edit + self.setFixedWidth(MINIMAP_WIDTH) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self._repaint_timer = QTimer(self) + self._repaint_timer.setSingleShot(True) + self._repaint_timer.setInterval(_REPAINT_DELAY_MS) + self._repaint_timer.timeout.connect(self.update) + + code_edit.document().contentsChanged.connect(self._repaint_timer.start) + code_edit.verticalScrollBar().valueChanged.connect(self.update) + + def _step(self) -> int: + """目前的取樣間隔 / The sampling step in use right now.""" + return sample_step(self._code_edit.blockCount(), self.height()) + + def _visible_line_count(self) -> int: + """畫面上放得下幾行 / How many lines currently fit on screen.""" + line_height = max(1, self._code_edit.fontMetrics().height()) + return max(1, self._code_edit.viewport().height() // line_height) + + def paintEvent(self, event) -> None: + """畫出每一行的輪廓與目前的可視範圍 / Draw each line's bar and the visible band.""" + painter = QPainter(self) + painter.fillRect(event.rect(), actually_color_dict.get("minimap_background_color")) + step = self._step() + self._paint_bars(painter, step) + self._paint_viewport_band(painter, step) + painter.end() + + def _paint_bars(self, painter: QPainter, step: int) -> None: + """把每一行畫成一條與其長度相當的長條 / Draw each line as a bar of its length.""" + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(actually_color_dict.get("minimap_line_color")) + document = self._code_edit.document() + line = 0 + while line < document.blockCount(): + block = document.findBlockByNumber(line) + if not block.isValid(): + break + row = row_for_line(line, step) + if row > self.height(): + break + width = bar_width(block.text(), MINIMAP_WIDTH) + if width: + painter.drawRect(bar_offset(block.text()), row, width, LINE_PIXELS - 1) + line += step + + def _paint_viewport_band(self, painter: QPainter, step: int) -> None: + """標出目前畫面對應的範圍 / Mark the part of the file currently on screen.""" + first_visible = self._code_edit.firstVisibleBlock().blockNumber() + top, height = viewport_band(first_visible, self._visible_line_count(), step) + painter.fillRect( + 0, top, self.width(), height, actually_color_dict.get("minimap_viewport_color")) + + def line_at_position(self, y_position: int) -> int: + """ + 取得縮圖上某個位置對應的行號 + The line a position in the minimap points at. + + :param y_position: 縮圖上的 y 座標 / the y coordinate in the minimap + :return: 以 0 起算的行號 / the 0-based line number + """ + return line_at_row(y_position, self._step(), self._code_edit.blockCount()) + + def _scroll_to(self, y_position: int) -> None: + """把編輯器捲到縮圖上被按下的位置 / Scroll the editor to the position clicked.""" + self._code_edit.jump_to_line(self.line_at_position(y_position) + 1) + self.update() + + def mousePressEvent(self, event) -> None: + """按下時跳到該處 / Jump to the position pressed.""" + self._scroll_to(int(event.position().y())) + + def mouseMoveEvent(self, event) -> None: + """拖曳時持續跳轉 / Keep jumping while dragging.""" + if event.buttons() & Qt.MouseButton.LeftButton: + self._scroll_to(int(event.position().y())) diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index 6f3e492b..3cbe79f5 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -19,7 +19,7 @@ from PySide6.QtCore import Qt, QFileInfo, QDir, QFileSystemWatcher from PySide6.QtGui import QDragEnterEvent, QDropEvent from PySide6.QtWidgets import QWidget, QGridLayout, QSplitter, QScrollArea, QFileSystemModel, QTreeView, QTabWidget, \ - QMessageBox + QMessageBox, QHBoxLayout from je_editor.pyside_ui.code.auto_save.auto_save_manager import auto_save_manager_dict, init_new_auto_save_thread, \ file_is_open_manager_dict @@ -29,6 +29,7 @@ from je_editor.pyside_ui.code.auto_save.auto_save_thread import CodeEditSaveThread from je_editor.pyside_ui.code.code_format.pep8_format import PEP8FormatChecker from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor +from je_editor.pyside_ui.code.minimap.minimap_widget import MinimapWidget from je_editor.pyside_ui.code.split_view.split_editor_view import SplitEditorView from je_editor.pyside_ui.code.textedit_code_result.code_record import CodeRecord from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict @@ -152,16 +153,24 @@ def __init__(self, main_window: EditorMain) -> None: self.code_difference_result.addTab( self.git_gui, language_wrapper.language_word_dict.get("tab_menu_git_client_tab_name")) + # 同檔分割檢視與縮圖,開啟時才建立 + # The same-file split view and the minimap, both created on demand + self.split_view: Union[SplitEditorView, None] = None + self.minimap: Union[MinimapWidget, None] = None + # 編輯器與縮圖並排的容器 / Holds the editor and the minimap side by side + self.editor_row = QWidget() + self.editor_row_layout = QHBoxLayout(self.editor_row) + self.editor_row_layout.setContentsMargins(0, 0, 0, 0) + self.editor_row_layout.setSpacing(0) + self.editor_row_layout.addWidget(self.code_edit_scroll_area) + # 加入分割器 / Add widgets to splitters - self.edit_splitter.addWidget(self.code_edit_scroll_area) + self.edit_splitter.addWidget(self.editor_row) self.edit_splitter.addWidget(self.code_difference_result) self.edit_splitter.setStretchFactor(0, 3) self.edit_splitter.setStretchFactor(1, 1) self.edit_splitter.setSizes([300, 100]) - # 同檔分割檢視,開啟時才建立 / The same-file split view, created on demand - self.split_view: Union[SplitEditorView, None] = None - self.full_splitter.addWidget(self.project_treeview) self.full_splitter.addWidget(self.edit_splitter) self.full_splitter.setStretchFactor(0, 1) @@ -337,6 +346,23 @@ def mark_saved(self) -> None: if title.endswith(" *"): self.tab_manager.setTabText(idx, title[:-2]) + def toggle_minimap(self) -> bool: + """ + 切換縮圖顯示 + Toggle the minimap. + + :return: 切換後是否為開啟 / whether the minimap is now shown + """ + if self.minimap is not None: + self.editor_row_layout.removeWidget(self.minimap) + self.minimap.setParent(None) + self.minimap.deleteLater() + self.minimap = None + return False + self.minimap = MinimapWidget(self.code_edit) + self.editor_row_layout.addWidget(self.minimap) + return True + def toggle_split_view(self) -> bool: """ 切換同檔分割檢視 diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py index 573e3575..caecbc18 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py @@ -69,10 +69,38 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.toggle_split_view_action) + # === 縮圖 === + # === Minimap === + ui_we_want_to_set.tab_menu.toggle_minimap_action = QAction( + language_wrapper.language_word_dict.get("tab_menu_minimap_label")) + ui_we_want_to_set.tab_menu.toggle_minimap_action.setShortcut("Ctrl+Alt+M") + ui_we_want_to_set.tab_menu.toggle_minimap_action.triggered.connect( + lambda: toggle_minimap(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.toggle_minimap_action) + set_tab_tools_menu(ui_we_want_to_set=ui_we_want_to_set) set_tab_git_menu(ui_we_want_to_set=ui_we_want_to_set) +def toggle_minimap(ui_we_want_to_set: EditorMain) -> bool: + """ + 切換目前分頁的縮圖 + Toggle the current tab's minimap. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 切換後是否為開啟 / whether the minimap is now shown + """ + jeditor_logger.info("build_tab_menu.py toggle_minimap") + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return False + widget = tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + return False + return widget.toggle_minimap() + + def toggle_split_view(ui_we_want_to_set: EditorMain) -> bool: """ 切換目前分頁的同檔分割檢視 diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 0100e722..9296fb8a 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -45,6 +45,9 @@ def update_actually_color_dict() -> None: "lint_underline_color": _to_qcolor("lint_underline_color", [255, 138, 101]), "blame_annotation_color": _to_qcolor("blame_annotation_color", [130, 130, 130]), "indent_guide_color": _to_qcolor("indent_guide_color", [90, 90, 110]), + "minimap_background_color": _to_qcolor("minimap_background_color", [40, 40, 48]), + "minimap_line_color": _to_qcolor("minimap_line_color", [130, 130, 150]), + "minimap_viewport_color": _to_qcolor("minimap_viewport_color", [80, 80, 110]), "trailing_whitespace_color": _to_qcolor("trailing_whitespace_color", [120, 70, 70]), } ) @@ -68,6 +71,9 @@ def update_actually_color_dict() -> None: "lint_underline_color": [255, 138, 101], "blame_annotation_color": [130, 130, 130], "indent_guide_color": [90, 90, 110], + "minimap_background_color": [40, 40, 48], + "minimap_line_color": [130, 130, 150], + "minimap_viewport_color": [80, 80, 110], "trailing_whitespace_color": [120, 70, 70] } diff --git a/je_editor/utils/minimap/__init__.py b/je_editor/utils/minimap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/minimap/minimap_layout.py b/je_editor/utils/minimap/minimap_layout.py new file mode 100644 index 00000000..4231e966 --- /dev/null +++ b/je_editor/utils/minimap/minimap_layout.py @@ -0,0 +1,112 @@ +""" +縮圖(minimap)的座標換算 +Work out the minimap's geometry. + +把「第幾行」與「縮圖上的第幾個像素」互相對應,並在行數多到畫不下時決定取樣間隔。 +Maps a line number to a pixel row in the minimap and back, and decides how to +sample when there are more lines than pixels to draw them on. + +純邏輯,不含 Qt,因此可以單獨測試。 +Pure logic with no Qt, so it can be tested on its own. +""" +from __future__ import annotations + +# 縮圖上每一行佔的像素高度 / How many pixels one line occupies in the minimap +LINE_PIXELS = 2 +# 縮圖的寬度(像素)/ The minimap's width in pixels +MINIMAP_WIDTH = 90 +# 一行畫成長條時,一個字元佔的寬度(像素) +# How wide one character is when a line is drawn as a bar +CHAR_PIXELS = 1 + + +def sample_step(total_lines: int, available_height: int) -> int: + """ + 決定取樣間隔:行數多到畫不下時,每隔幾行畫一條 + How many lines each drawn row stands for when they cannot all be drawn. + + :param total_lines: 文件總行數 / the document's line count + :param available_height: 縮圖可用高度(像素)/ the minimap's height in pixels + :return: 取樣間隔,至少為 1 / the step, never below 1 + """ + if total_lines <= 0 or available_height <= 0: + return 1 + drawable = max(1, available_height // LINE_PIXELS) + if total_lines <= drawable: + return 1 + return -(-total_lines // drawable) # 無條件進位 / round up + + +def row_for_line(line: int, step: int) -> int: + """ + 取得某一行在縮圖上的像素位置 + The pixel row a line is drawn at. + + :param line: 以 0 起算的行號 / the 0-based line number + :param step: 取樣間隔 / the sampling step + :return: 縮圖上的 y 座標 / the y coordinate in the minimap + """ + return (line // max(1, step)) * LINE_PIXELS + + +def line_at_row(row: int, step: int, total_lines: int) -> int: + """ + 取得縮圖上某個像素位置對應的行號 + The line a pixel row in the minimap points at. + + :param row: 縮圖上的 y 座標 / the y coordinate in the minimap + :param step: 取樣間隔 / the sampling step + :param total_lines: 文件總行數 / the document's line count + :return: 以 0 起算的行號,會夾在文件範圍內 / the 0-based line, clamped to the document + """ + if total_lines <= 0: + return 0 + line = (max(0, row) // LINE_PIXELS) * max(1, step) + return min(line, total_lines - 1) + + +def bar_width(text: str, width_limit: int = MINIMAP_WIDTH) -> int: + """ + 取得一行畫成長條時的寬度 + How wide a line's bar should be. + + 以行的長度表示,因此縮圖看起來像程式碼的輪廓;空白行寬度為零。 + The width follows the line's length, so the minimap reads as the shape of the + code; a blank line has no bar at all. + + :param text: 該行文字 / the line's text + :param width_limit: 縮圖寬度上限 / the minimap's width + :return: 長條寬度(像素)/ the bar's width in pixels + """ + stripped = text.rstrip() + if not stripped: + return 0 + return min(len(stripped) * CHAR_PIXELS, width_limit) + + +def bar_offset(text: str, width_limit: int = MINIMAP_WIDTH) -> int: + """ + 取得一行長條的起始位置(依縮排縮進) + Where a line's bar starts, following its indentation. + + :param text: 該行文字 / the line's text + :param width_limit: 縮圖寬度上限 / the minimap's width + :return: 起始 x 座標 / the starting x coordinate + """ + indent = len(text) - len(text.lstrip()) + return min(indent * CHAR_PIXELS, width_limit) + + +def viewport_band(first_visible: int, visible_lines: int, step: int) -> tuple[int, int]: + """ + 取得代表目前可視範圍的方框位置與高度 + The position and height of the band showing what is currently on screen. + + :param first_visible: 畫面最上方的行號 / the top visible line + :param visible_lines: 畫面可容納的行數 / how many lines fit on screen + :param step: 取樣間隔 / the sampling step + :return: ``(起始 y, 高度)``,高度至少 ``LINE_PIXELS`` / ``(top, height)`` + """ + top = row_for_line(max(0, first_visible), step) + height = max(LINE_PIXELS, row_for_line(max(1, visible_lines), step)) + return top, height diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 329c2677..17097e96 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -383,6 +383,7 @@ "todo_panel_col_line": "Line", "tab_menu_diff_against_head_name": "Diff Against HEAD", "tab_menu_split_view_label": "Toggle Split View", + "tab_menu_minimap_label": "Toggle Minimap", # Problems panel "tab_menu_problems_panel_tab_name": "Problems", "problems_panel_refresh": "Recheck", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index f0916fa4..1891eb92 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -373,6 +373,7 @@ "todo_panel_col_line": "行號", "tab_menu_diff_against_head_name": "與 HEAD 比較差異", "tab_menu_split_view_label": "切換分割檢視", + "tab_menu_minimap_label": "切換縮圖", # Problems panel "tab_menu_problems_panel_tab_name": "問題", "problems_panel_refresh": "重新檢查", diff --git a/test/test_minimap.py b/test/test_minimap.py new file mode 100644 index 00000000..a01855bd --- /dev/null +++ b/test/test_minimap.py @@ -0,0 +1,149 @@ +"""Tests for the minimap's geometry and its wiring into the editor.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication, QTabWidget + +from je_editor.utils.minimap.minimap_layout import ( + LINE_PIXELS, + MINIMAP_WIDTH, + bar_offset, + bar_width, + line_at_row, + row_for_line, + sample_step, + viewport_band, +) + + +class TestSampleStep: + def test_short_file_draws_every_line(self): + assert sample_step(10, 400) == 1 + + def test_long_file_samples(self): + # 1000 lines into 200 pixels: 100 drawable rows, so every 10th line. + assert sample_step(1000, 200) == 10 + + def test_rounding_never_loses_the_tail(self): + step = sample_step(101, 200) + assert step >= 1 + assert row_for_line(100, step) <= 200 + + def test_empty_document(self): + assert sample_step(0, 200) == 1 + + def test_zero_height(self): + assert sample_step(100, 0) == 1 + + +class TestRowMapping: + def test_line_to_row_without_sampling(self): + assert row_for_line(5, 1) == 5 * LINE_PIXELS + + def test_line_to_row_with_sampling(self): + assert row_for_line(20, 10) == 2 * LINE_PIXELS + + def test_row_back_to_line(self): + assert line_at_row(row_for_line(30, 1), 1, 100) == 30 + + def test_row_beyond_the_document_is_clamped(self): + assert line_at_row(10_000, 1, 50) == 49 + + def test_negative_row_is_clamped(self): + assert line_at_row(-20, 1, 50) == 0 + + def test_empty_document(self): + assert line_at_row(40, 1, 0) == 0 + + +class TestBars: + def test_bar_follows_the_line_length(self): + assert bar_width("abcd") == 4 + + def test_blank_line_has_no_bar(self): + assert bar_width("") == 0 + assert bar_width(" ") == 0 + + def test_trailing_space_does_not_lengthen_the_bar(self): + assert bar_width("ab ") == 2 + + def test_bar_is_capped_at_the_minimap_width(self): + assert bar_width("x" * 500) == MINIMAP_WIDTH + + def test_indentation_offsets_the_bar(self): + assert bar_offset(" code") == 4 + + def test_unindented_line_starts_at_the_edge(self): + assert bar_offset("code") == 0 + + +class TestViewportBand: + def test_band_starts_at_the_first_visible_line(self): + top, _height = viewport_band(10, 30, 1) + assert top == row_for_line(10, 1) + + def test_band_covers_the_visible_lines(self): + _top, height = viewport_band(0, 30, 1) + assert height == row_for_line(30, 1) + + def test_band_is_never_invisible(self): + _top, height = viewport_band(0, 0, 50) + assert height >= LINE_PIXELS + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor_widget(app): + main_window = MagicMock() + main_window.working_dir = None + main_window.tab_widget = QTabWidget() + main_window.python_compiler = None + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + widget = EditorWidget(main_window) + widget.code_edit.setPlainText("\n".join(f"line {index}" for index in range(200))) + yield widget + widget.code_edit.lint_manager.stop() + widget.code_edit.diff_marker_manager.stop() + widget.code_edit.blame_manager.stop() + widget.deleteLater() + + +class TestMinimapWiring: + def test_starts_hidden(self, editor_widget): + assert editor_widget.minimap is None + + def test_toggling_on_adds_it(self, editor_widget): + assert editor_widget.toggle_minimap() is True + assert editor_widget.minimap is not None + + def test_toggling_off_removes_it(self, editor_widget): + editor_widget.toggle_minimap() + assert editor_widget.toggle_minimap() is False + assert editor_widget.minimap is None + + def test_painting_does_not_raise(self, editor_widget): + editor_widget.toggle_minimap() + editor_widget.show() + QApplication.processEvents() + editor_widget.hide() + + def test_clicking_maps_to_a_line_in_range(self, editor_widget): + editor_widget.toggle_minimap() + minimap = editor_widget.minimap + line = minimap.line_at_position(20) + assert 0 <= line < editor_widget.code_edit.blockCount() + + def test_an_empty_document_is_safe_to_map(self, editor_widget): + editor_widget.code_edit.setPlainText("") + editor_widget.toggle_minimap() + assert editor_widget.minimap.line_at_position(50) == 0 From 8047a4e88bcb566d779da23bddb79b76b19681ed Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:43:49 +0800 Subject: [PATCH 13/36] Expand code snippets on Tab Typing a trigger word and pressing Tab now expands a snippet, with Tab stepping through its placeholders and selecting each default value on the way. Once the stops run out, Tab indents as it always did, and a selection still indents rather than expanding anything. Snippets use the ``$1`` / ``${2:default}`` / ``$0`` notation other editors use, so existing ones can be pasted straight into snippets.json beside the settings. A missing or broken snippet file falls back to the built-in Python set instead of leaving the editor unable to expand anything, and an expansion is a single undo step. --- .../code_edit_plaintext.py | 20 ++ je_editor/pyside_ui/code/snippets/__init__.py | 0 .../code/snippets/snippet_manager.py | 155 ++++++++++++++++ je_editor/utils/snippets/__init__.py | 0 je_editor/utils/snippets/snippet_expand.py | 111 ++++++++++++ test/test_snippets.py | 171 ++++++++++++++++++ 6 files changed, 457 insertions(+) create mode 100644 je_editor/pyside_ui/code/snippets/__init__.py create mode 100644 je_editor/pyside_ui/code/snippets/snippet_manager.py create mode 100644 je_editor/utils/snippets/__init__.py create mode 100644 je_editor/utils/snippets/snippet_expand.py create mode 100644 test/test_snippets.py 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 08b94f3e..a345d5b4 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 @@ -18,6 +18,7 @@ from je_editor.pyside_ui.code.git_diff.diff_marker_manager import DiffMarkerManager from je_editor.pyside_ui.code.lint.lint_manager import LintManager from je_editor.pyside_ui.code.selection.smart_selection_manager import SmartSelectionManager +from je_editor.pyside_ui.code.snippets.snippet_manager import SnippetManager from je_editor.utils.file_diff.line_status import ( LINE_ADDED, LINE_MODIFIED, LINE_REMOVED_ABOVE ) @@ -292,6 +293,9 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self._lint_timer.timeout.connect(self.request_lint) self.request_lint() + # 片段展開與定位點 / Snippet expansion and its tab stops + self.snippet_manager = SnippetManager(self) + def reset_highlighter(self) -> None: """重設語法高亮 / Reset syntax highlighter""" jeditor_logger.info("CodeEditor reset_highlighter") @@ -1961,6 +1965,8 @@ def _jump_to_definition(self) -> None: def _handle_tab_indent(self, event: QKeyEvent) -> bool: """處理 Tab/Shift+Tab 區塊縮排 / Handle block indent; return True if consumed.""" key = event.key() + if key == Qt.Key.Key_Tab and self._handle_snippet_tab(): + return True if key == Qt.Key.Key_Tab and self.textCursor().hasSelection(): self._indent_selection(indent=True) return True @@ -1970,6 +1976,20 @@ def _handle_tab_indent(self, event: QKeyEvent) -> bool: return True return False + def _handle_snippet_tab(self) -> bool: + """ + 以 Tab 展開片段,或跳到片段的下一個定位點 + Expand a snippet on Tab, or move to its next stop. + + 兩者都不適用時回傳 ``False``,Tab 就維持原本的縮排行為。 + Returns ``False`` when neither applies, leaving Tab to indent as before. + + :return: Tab 是否被片段處理掉 / whether Tab was consumed by a snippet + """ + if self.snippet_manager.has_pending_stops: + return self.snippet_manager.next_stop() + return self.snippet_manager.expand_at_cursor() + def _handle_enter_autoindent(self, event: QKeyEvent) -> None: """Enter 自動縮排 / Auto-indent on Enter.""" cursor = self.textCursor() diff --git a/je_editor/pyside_ui/code/snippets/__init__.py b/je_editor/pyside_ui/code/snippets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/snippets/snippet_manager.py b/je_editor/pyside_ui/code/snippets/snippet_manager.py new file mode 100644 index 00000000..a081aa87 --- /dev/null +++ b/je_editor/pyside_ui/code/snippets/snippet_manager.py @@ -0,0 +1,155 @@ +""" +在編輯器中展開片段並在定位點之間移動 +Expand snippets in the editor and step between their tab stops. + +輸入觸發字後按 Tab 展開;展開後 Tab 會依序跳到下一個定位點,走完就恢復成一般的 +Tab 縮排。 +Type a trigger word and press Tab to expand it. After expanding, Tab moves to the +next stop, and once they are used up Tab goes back to indenting. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from PySide6.QtGui import QTextCursor + +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.snippets.snippet_expand import SnippetStop, expand_snippet, merge_snippets + +# 使用者片段檔名(放在設定資料夾內)/ The user snippet file, kept beside the settings +SNIPPET_FILE_NAME = "snippets.json" +# 設定資料夾 / The settings directory +SETTING_DIR_NAME = ".jeditor" + + +def snippet_file_path() -> Path: + """ + 使用者片段檔的位置 + Where the user's snippet file lives. + + :return: 片段檔路徑 / the snippet file's path + """ + return Path.cwd() / SETTING_DIR_NAME / SNIPPET_FILE_NAME + + +def load_snippets(path: Path | None = None) -> dict[str, str]: + """ + 載入片段:內建的加上使用者定義的 + Load the snippets: the built-in set plus anything the user defined. + + 檔案不存在、不是 JSON、或內容不是物件時只回傳內建片段,因為片段損毀不該讓 + 編輯器無法輸入。 + A missing file, invalid JSON, or a non-object all fall back to the built-in + set: a broken snippet file must not stop the user typing. + + :param path: 片段檔路徑,``None`` 表示預設位置 / the file, or ``None`` for the default + :return: 觸發字對應片段內容 / trigger word -> snippet body + """ + target = path if path is not None else snippet_file_path() + try: + stored = json.loads(target.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + jeditor_logger.debug(f"snippet_manager: using built-in snippets only: {error!r}") + return merge_snippets(None) + return merge_snippets(stored) + + +class SnippetManager: + """ + 管理一個編輯器的片段展開狀態 + Track one editor's snippet expansion. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 要展開片段的編輯器 / the editor snippets expand into + """ + self._code_edit = code_edit + self._snippets = load_snippets() + # 尚未走訪的定位點(文件中的絕對位置)/ Stops not yet visited, as document positions + self._pending: list[SnippetStop] = [] + + def snippets(self) -> dict[str, str]: + """取得目前可用的片段 / The snippets currently available.""" + return dict(self._snippets) + + def reload(self, path: Path | None = None) -> None: + """ + 重新載入使用者片段 + Reload the user's snippets from disk. + + :param path: 片段檔路徑,``None`` 表示預設位置 / the file, or ``None`` for the default + """ + self._snippets = load_snippets(path) + + @property + def has_pending_stops(self) -> bool: + """是否還有沒走訪的定位點 / Whether any tab stop is still waiting.""" + return bool(self._pending) + + def trigger_word(self) -> str: + """ + 取得游標前的觸發字 + The trigger word immediately before the caret. + + :return: 觸發字,取不到時為空字串 / the word, or an empty string + """ + cursor = self._code_edit.textCursor() + if cursor.hasSelection(): + return "" + cursor.select(QTextCursor.SelectionType.WordUnderCursor) + return cursor.selectedText() + + def expand_at_cursor(self) -> bool: + """ + 若游標前是觸發字就展開對應片段 + Expand the snippet for the trigger word before the caret, if there is one. + + 展開是單一復原步驟,因此一次 Ctrl+Z 就能取消整個片段。 + The expansion is one undo step, so a single Ctrl+Z removes the whole + snippet. + + :return: 有展開時為 ``True`` / ``True`` when a snippet was expanded + """ + word = self.trigger_word() + body = self._snippets.get(word) + if not body: + return False + text, stops = expand_snippet(body) + cursor = self._code_edit.textCursor() + cursor.beginEditBlock() + cursor.select(QTextCursor.SelectionType.WordUnderCursor) + start = cursor.selectionStart() + cursor.insertText(text) + cursor.endEditBlock() + self._pending = [ + SnippetStop(position=start + stop.position, length=stop.length) + for stop in stops + ] + self.next_stop() + return True + + def next_stop(self) -> bool: + """ + 移到下一個定位點,並選取它的預設值 + Move to the next tab stop, selecting its default value. + + :return: 有移動時為 ``True`` / ``True`` when the caret moved + """ + if not self._pending: + return False + stop = self._pending.pop(0) + cursor = self._code_edit.textCursor() + cursor.setPosition(min(stop.position, self._code_edit.document().characterCount() - 1)) + if stop.length: + cursor.setPosition( + min(stop.position + stop.length, + self._code_edit.document().characterCount() - 1), + QTextCursor.MoveMode.KeepAnchor) + self._code_edit.setTextCursor(cursor) + return True + + def clear_stops(self) -> None: + """放棄剩下的定位點 / Give up on the remaining stops.""" + self._pending = [] diff --git a/je_editor/utils/snippets/__init__.py b/je_editor/utils/snippets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/snippets/snippet_expand.py b/je_editor/utils/snippets/snippet_expand.py new file mode 100644 index 00000000..2c31cf91 --- /dev/null +++ b/je_editor/utils/snippets/snippet_expand.py @@ -0,0 +1,111 @@ +""" +展開程式碼片段,並算出每個定位點的位置 +Expand a code snippet and work out where each tab stop lands. + +片段用 ``$1``、``${2:預設值}`` 標定位點,``$0`` 是展開後游標最後停留的位置—— +與大多數編輯器的寫法一致,使用者既有的片段可以直接沿用。 +Snippets mark their stops with ``$1``, ``${2:default}`` and ``$0`` for where the +caret ends up, the same notation most editors use, so existing snippets can be +pasted in as they are. + +純邏輯:只做文字與位置計算,插入與游標移動交給編輯器。 +Pure logic: it computes text and offsets only, leaving insertion and caret +movement to the editor. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +# 比對 $1 或 ${1:預設值} / Matches $1 or ${1:default} +_STOP_PATTERN = re.compile(r"\$(\d+)|\$\{(\d+):([^}]*)\}") +# ``$0`` 代表最後停留的位置,排序時要放到最後 +# ``$0`` is where the caret finishes, so it sorts after every other stop +_FINAL_STOP = 0 + + +@dataclass(frozen=True) +class SnippetStop: + """ + 展開後的一個定位點 + One tab stop in the expanded text. + + :param position: 相對於片段起點的字元位置 / offset from the snippet's start + :param length: 預設值的長度(沒有預設值時為 0)/ the default value's length + """ + + position: int + length: int + + +def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: + """ + 展開片段內容,回傳文字與定位點 + Expand a snippet body into text and its tab stops. + + 定位點依編號排序,``$0`` 排在最後;同一個編號出現多次時只取第一次。 + Stops come back in numeric order with ``$0`` last, and a number used more + than once keeps only its first appearance. + + :param body: 片段內容 / the snippet body + :return: ``(展開後的文字, 定位點清單)`` / ``(expanded text, stops)`` + """ + pieces: list[str] = [] + stops: dict[int, SnippetStop] = {} + length = 0 + last_end = 0 + for match in _STOP_PATTERN.finditer(body): + literal = body[last_end:match.start()] + pieces.append(literal) + length += len(literal) + number = int(match.group(1) or match.group(2)) + default = match.group(3) or "" + if number not in stops: + stops[number] = SnippetStop(position=length, length=len(default)) + pieces.append(default) + length += len(default) + last_end = match.end() + pieces.append(body[last_end:]) + text = "".join(pieces) + ordered = sorted(stops.items(), key=lambda item: (item[0] == _FINAL_STOP, item[0])) + return text, [stop for _number, stop in ordered] + + +def default_snippets() -> dict[str, str]: + """ + 內建的 Python 片段 + The Python snippets that ship with the editor. + + :return: 觸發字對應片段內容 / trigger word -> snippet body + """ + return { + "def": "def ${1:name}(${2:args}):\n $0", + "class": "class ${1:Name}:\n def __init__(self${2:, args}):\n $0", + "for": "for ${1:item} in ${2:iterable}:\n $0", + "while": "while ${1:condition}:\n $0", + "if": "if ${1:condition}:\n $0", + "try": "try:\n ${1:pass}\nexcept ${2:Exception} as error:\n $0", + "with": "with ${1:expression} as ${2:name}:\n $0", + "main": 'if __name__ == "__main__":\n $0', + } + + +def merge_snippets(stored: object) -> dict[str, str]: + """ + 把使用者定義的片段併入內建片段 + Merge user-defined snippets over the built-in ones. + + 設定檔可能被手動編輯,因此型別不符的項目會被略過而不是讓載入失敗。 + A hand-edited file may hold anything, so entries with the wrong type are + skipped rather than failing the load. + + :param stored: 讀進來的使用者片段,任何型別 / the loaded user snippets, any type + :return: 可用的片段對照表 / the usable snippets + """ + snippets = default_snippets() + if not isinstance(stored, dict): + return snippets + for trigger, body in stored.items(): + if isinstance(trigger, str) and trigger and isinstance(body, str): + snippets[trigger] = body + return snippets diff --git a/test/test_snippets.py b/test/test_snippets.py new file mode 100644 index 00000000..3ce3d711 --- /dev/null +++ b/test/test_snippets.py @@ -0,0 +1,171 @@ +"""Tests for snippet expansion and its tab stops.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtGui import QKeyEvent +from PySide6.QtCore import QEvent, Qt +from PySide6.QtWidgets import QApplication + +from je_editor.pyside_ui.code.snippets.snippet_manager import load_snippets +from je_editor.utils.snippets.snippet_expand import ( + default_snippets, + expand_snippet, + merge_snippets, +) + + +class TestExpandSnippet: + def test_plain_text_has_no_stops(self): + text, stops = expand_snippet("print('hello')") + assert text == "print('hello')" + assert stops == [] + + def test_numbered_stop_is_removed_from_the_text(self): + text, stops = expand_snippet("def $1():") + assert text == "def ():" + assert [stop.position for stop in stops] == [4] + + def test_default_value_is_kept_and_selectable(self): + text, stops = expand_snippet("def ${1:name}():") + assert text == "def name():" + assert stops[0].position == 4 + assert stops[0].length == len("name") + + def test_stops_come_back_in_order(self): + _text, stops = expand_snippet("${2:second} ${1:first}") + # $1 is visited before $2 even though it appears later. + assert stops[0].position > stops[1].position + + def test_final_stop_sorts_last(self): + _text, stops = expand_snippet("$0 ${1:first}") + assert stops[-1].position == 0 + + def test_repeated_number_keeps_the_first(self): + text, stops = expand_snippet("$1 and $1") + assert text == " and " + assert len(stops) == 1 + + def test_multiline_body(self): + text, stops = expand_snippet("for ${1:item} in ${2:rows}:\n $0") + assert text == "for item in rows:\n " + assert len(stops) == 3 + + +class TestSnippetSets: + def test_defaults_cover_common_python(self): + assert {"def", "class", "for", "if", "main"} <= set(default_snippets()) + + def test_user_snippets_are_merged_over_the_defaults(self): + merged = merge_snippets({"def": "custom $0", "mine": "body"}) + assert merged["def"] == "custom $0" + assert merged["mine"] == "body" + assert "class" in merged + + def test_a_hand_edited_file_cannot_break_the_set(self): + merged = merge_snippets({"ok": "body", 5: "bad key", "bad value": 7}) + assert merged["ok"] == "body" + assert "class" in merged + + def test_non_dict_falls_back_to_the_defaults(self): + assert merge_snippets("nonsense") == default_snippets() + + def test_loading_a_missing_file_uses_the_defaults(self, tmp_path): + assert load_snippets(tmp_path / "absent.json") == default_snippets() + + def test_loading_invalid_json_uses_the_defaults(self, tmp_path): + broken = tmp_path / "snippets.json" + broken.write_text("{not json", encoding="utf-8") + assert load_snippets(broken) == default_snippets() + + def test_loading_a_user_file(self, tmp_path): + path = tmp_path / "snippets.json" + path.write_text(json.dumps({"log": "print($0)"}), encoding="utf-8") + assert load_snippets(path)["log"] == "print($0)" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +def _press_tab(editor) -> None: + editor.keyPressEvent( + QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier, "\t")) + + +class TestSnippetExpansionInEditor: + def test_tab_expands_a_trigger_word(self, editor): + editor.setPlainText("for") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + assert "in" in editor.toPlainText() + assert "for" in editor.toPlainText() + + def test_the_first_default_value_is_selected(self, editor): + editor.setPlainText("for") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + assert editor.textCursor().selectedText() == "item" + + def test_tab_moves_to_the_next_stop(self, editor): + editor.setPlainText("for") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + _press_tab(editor) + assert editor.textCursor().selectedText() == "iterable" + + def test_an_unknown_word_is_left_alone(self, editor): + editor.setPlainText("notasnippet") + editor.moveCursor(editor.textCursor().MoveOperation.End) + assert editor.snippet_manager.expand_at_cursor() is False + + def test_expansion_is_one_undo_step(self, editor): + editor.setPlainText("if") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + editor.undo() + assert editor.toPlainText() == "if" + + def test_stops_run_out_after_the_last_one(self, editor): + editor.setPlainText("if") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + while editor.snippet_manager.has_pending_stops: + editor.snippet_manager.next_stop() + assert editor.snippet_manager.next_stop() is False + + def test_tab_still_indents_a_selection(self, editor): + editor.setPlainText("alpha\nbeta\n") + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(9, cursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + _press_tab(editor) + assert editor.toPlainText().startswith(" ") + + def test_reloading_picks_up_user_snippets(self, editor, tmp_path): + path = tmp_path / "snippets.json" + path.write_text(json.dumps({"zzz": "expanded $0"}), encoding="utf-8") + editor.snippet_manager.reload(path) + assert "zzz" in editor.snippet_manager.snippets() From 9de26ac325c3ceb25d02f511622c0afeb9c9cd3b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:47:49 +0800 Subject: [PATCH 14/36] Run the project tests from a panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Tests dock panel runs pytest in the project directory and lists what it reported, failures first, with the summary line as its status. Double- clicking a row opens that test, jumping to the failing line when the test failed. The run happens on a worker thread since it spawns a subprocess, a re-entrant Run is ignored rather than dropping a thread that is still going, and closing the panel waits for it. Parsing lives in utils/test_runner as pure logic, so a result line — which also contains a colon — is never mistaken for a failure location. --- .../main_ui/menu/dock_menu/build_dock_menu.py | 11 + .../pyside_ui/main_ui/test_panel/__init__.py | 0 .../main_ui/test_panel/test_panel_widget.py | 249 ++++++++++++++++++ je_editor/utils/multi_language/english.py | 9 + .../multi_language/traditional_chinese.py | 9 + je_editor/utils/test_runner/__init__.py | 0 je_editor/utils/test_runner/pytest_output.py | 162 ++++++++++++ test/test_pytest_panel.py | 162 ++++++++++++ 8 files changed, 602 insertions(+) create mode 100644 je_editor/pyside_ui/main_ui/test_panel/__init__.py create mode 100644 je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py create mode 100644 je_editor/utils/test_runner/__init__.py create mode 100644 je_editor/utils/test_runner/pytest_output.py create mode 100644 test/test_pytest_panel.py diff --git a/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py b/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py index 9723392d..0d30ed3a 100644 --- a/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py @@ -21,6 +21,7 @@ from je_editor.pyside_ui.main_ui.ipython_widget.ipython_console import IpythonWidget from je_editor.pyside_ui.main_ui.outline_panel.outline_panel_widget import OutlinePanelWidget from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ProblemsPanelWidget +from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import TestPanelWidget from je_editor.pyside_ui.main_ui.todo_panel.todo_panel_widget import TodoPanelWidget from je_editor.utils.file.open.open_file import read_file # 檔案讀取工具 / File reading utility from je_editor.utils.logging.loggin_instance import jeditor_logger # 日誌紀錄器 / Logger @@ -147,6 +148,14 @@ def set_dock_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_problems_panel) + # === Test Panel Dock === + ui_we_want_to_set.dock_menu.new_test_panel = QAction( + language_wrapper.language_word_dict.get("tab_menu_test_panel_tab_name")) + ui_we_want_to_set.dock_menu.new_test_panel.triggered.connect( + lambda: add_dock_widget(ui_we_want_to_set, "test_panel") + ) + ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_test_panel) + # === Outline Panel Dock === ui_we_want_to_set.dock_menu.new_outline_panel = QAction( language_wrapper.language_word_dict.get("tab_menu_outline_panel_tab_name")) @@ -191,6 +200,8 @@ def _dock_builders(ui_we_want_to_set: EditorMain) -> dict: lambda: OutlinePanelWidget(ui_we_want_to_set)), "problems_panel": ("tab_menu_problems_panel_tab_name", lambda: ProblemsPanelWidget(ui_we_want_to_set)), + "test_panel": ("tab_menu_test_panel_tab_name", + lambda: TestPanelWidget(ui_we_want_to_set)), } diff --git a/je_editor/pyside_ui/main_ui/test_panel/__init__.py b/je_editor/pyside_ui/main_ui/test_panel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py new file mode 100644 index 00000000..9e0c4c35 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py @@ -0,0 +1,249 @@ +""" +測試面板:執行 pytest 並列出結果 +Test panel: run pytest and list what it reported. + +測試在背景執行緒中執行(那是子程序),結束後把輸出解析成一列一列的結果;雙擊 +失敗的項目會跳到出錯的那一行。 +The run happens on a worker thread because it spawns a subprocess; its output is +then parsed into one row per test, and double-clicking a failure jumps to the +line it failed on. +""" +from __future__ import annotations + +import os +import subprocess # nosec B404 - 以引數清單執行 pytest,未使用 shell +import sys +from pathlib import Path + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget +) + +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.test_runner.pytest_output import ( + PytestResult, failure_for_result, parse_failures, parse_results, parse_summary +) + +# 樹狀清單欄位索引 / Column indexes in the tree +COLUMN_OUTCOME = 0 +COLUMN_TEST = 1 +COLUMN_FILE = 2 +# 測試名稱欄的預設寬度 / Default width of the test column +TEST_COLUMN_WIDTH = 360 +# 單次測試執行的逾時(秒)/ Timeout for one test run +RUN_TIMEOUT_SECONDS = 600 + + +def pytest_command() -> list[str]: + """ + 組出執行測試的指令 + Build the command that runs the tests. + + ``-v`` 才會逐項列出結果,``--tb=line`` 讓每個失敗只印一行位置,剛好夠面板用。 + ``-v`` is what lists each test, and ``--tb=line`` prints one location per + failure, which is exactly what the panel needs. + + :return: 引數清單(不經過 shell)/ the argument list, never a shell string + """ + return [sys.executable, "-m", "pytest", "-v", "--tb=line", "-p", "no:cacheprovider"] + + +class PytestRunThread(QThread): + """ + 在背景執行 pytest + Run pytest off the UI thread. + """ + + finished_output = Signal(str) + + def __init__(self, working_dir: str, parent=None) -> None: + """ + :param working_dir: 執行測試的目錄 / the directory to run the tests in + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._working_dir = working_dir + + def run(self) -> None: + """執行測試並回報輸出 / Run the tests and report their output.""" + try: + completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 + pytest_command(), + cwd=self._working_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=RUN_TIMEOUT_SECONDS, + check=False, + ) + output = f"{completed.stdout or ''}\n{completed.stderr or ''}" + except (OSError, subprocess.SubprocessError) as error: + jeditor_logger.error(f"test_panel_widget.py could not run pytest: {error!r}") + output = "" + self.finished_output.emit(output) + + +class TestPanelWidget(QWidget): + """ + 執行專案測試並顯示結果 + Run the project's tests and show what they reported. + """ + + def __init__(self, main_window=None, working_dir: str | None = None) -> None: + """ + :param main_window: 用來開檔跳行的主視窗 / the window used to open files + :param working_dir: 執行測試的目錄,``None`` 時自動判斷 / where to run; auto when ``None`` + """ + super().__init__() + word = language_wrapper.language_word_dict + self._main_window = main_window + self._working_dir = working_dir or resolve_working_dir(main_window) + self._results: list[PytestResult] = [] + self._failures: list = [] + self._thread: PytestRunThread | None = None + + self.run_button = QPushButton(word.get("test_panel_run")) + self.run_button.clicked.connect(self.start_run) + self.status_label = QLabel(word.get("test_panel_ready")) + + self.result_tree = QTreeWidget() + self.result_tree.setColumnCount(3) + self.result_tree.setHeaderLabels([ + word.get("test_panel_col_outcome"), + word.get("test_panel_col_test"), + word.get("test_panel_col_file"), + ]) + self.result_tree.setColumnWidth(COLUMN_TEST, TEST_COLUMN_WIDTH) + self.result_tree.setRootIsDecorated(False) + self.result_tree.itemDoubleClicked.connect(self._open_item) + + controls = QHBoxLayout() + controls.addWidget(self.run_button) + controls.addWidget(self.status_label) + controls.addStretch() + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(self.result_tree) + self.setLayout(layout) + + def results(self) -> list[PytestResult]: + """取得目前顯示的結果 / The results currently listed.""" + return list(self._results) + + def start_run(self) -> bool: + """ + 啟動一次測試執行 + Start one test run. + + 已經在執行時會忽略重複觸發,避免覆寫仍在跑的執行緒。 + A re-entrant trigger is ignored so a still-running thread is never dropped. + + :return: 是否真的啟動 / whether a run actually started + """ + if self._thread is not None and self._thread.isRunning(): + return False + self.run_button.setEnabled(False) + self.status_label.setText(language_wrapper.language_word_dict.get("test_panel_running")) + self._thread = PytestRunThread(self._working_dir) + self._thread.finished_output.connect(self.apply_output) + self._thread.finished.connect(self._thread.deleteLater) + self._thread.start() + return True + + def apply_output(self, output: str) -> None: + """ + 解析輸出並更新清單 + Parse the output and rebuild the list. + + :param output: pytest 的輸出 / pytest's output + """ + self._results = parse_results(output) + self._failures = parse_failures(output) + summary = parse_summary(output) + self.run_button.setEnabled(True) + self._render_items() + if summary: + self.status_label.setText(summary) + elif not self._results: + self.status_label.setText( + language_wrapper.language_word_dict.get("test_panel_no_results")) + + def _render_items(self) -> None: + """依目前結果重建清單,失敗的排在最前面 / Rebuild the tree, failures first.""" + self.result_tree.clear() + ordered = sorted(self._results, key=lambda result: not result.failed) + for result in ordered: + row = QTreeWidgetItem([result.outcome, result.name, result.file_path]) + row.setData(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole, result) + self.result_tree.addTopLevelItem(row) + + def _open_item(self, row: QTreeWidgetItem, _column: int) -> None: + """開啟被雙擊的測試 / Open the double-clicked test.""" + result = row.data(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole) + if result is not None: + self.open_result(result) + + def open_result(self, result: PytestResult) -> bool: + """ + 在編輯器開啟一個測試,失敗的話跳到出錯的行 + Open a test in the editor, jumping to its failing line when it failed. + + :param result: 要開啟的測試結果 / the result to open + :return: 成功要求開檔時為 ``True`` / ``True`` when the open was requested + """ + if self._main_window is None or not hasattr(self._main_window, "go_to_new_tab"): + return False + failure = failure_for_result(result, self._failures) + path = Path(failure.path) if failure is not None else Path(self._working_dir) / result.file_path + self._main_window.go_to_new_tab(path) + if failure is not None: + jump_to_line(self._main_window, failure.line) + return True + + def closeEvent(self, event) -> None: + """ + 關閉前先停掉仍在執行的測試 + Stop a run that is still going before the panel goes away. + """ + thread = self._thread + if thread is not None and thread.isRunning(): + thread.blockSignals(True) + thread.wait() + super().closeEvent(event) + + +def resolve_working_dir(main_window) -> str: + """ + 取得執行測試的目錄 + Resolve the directory the tests should run in. + + :param main_window: 主編輯器視窗,可為 ``None`` / the main window, may be ``None`` + :return: 目錄路徑 / the directory path + """ + working_dir = getattr(main_window, "working_dir", None) + if working_dir and Path(str(working_dir)).is_dir(): + return str(working_dir) + return os.getcwd() + + +def jump_to_line(main_window, line: int) -> bool: + """ + 把目前分頁的游標移到指定行 + Move the current tab's caret to a line. + + :param main_window: 主編輯器視窗 / the main editor window + :param line: 1 起算的行號 / the 1-based line number + :return: 成功跳轉時為 ``True`` / ``True`` when the caret moved + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(main_window, "tab_widget", None) + if tab_widget is None: + return False + widget = tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + return False + return widget.code_edit.jump_to_line(line) diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 17097e96..0038487c 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -384,6 +384,15 @@ "tab_menu_diff_against_head_name": "Diff Against HEAD", "tab_menu_split_view_label": "Toggle Split View", "tab_menu_minimap_label": "Toggle Minimap", + # Test panel + "tab_menu_test_panel_tab_name": "Tests", + "test_panel_run": "Run Tests", + "test_panel_ready": "Ready", + "test_panel_running": "Running tests...", + "test_panel_no_results": "No tests were reported", + "test_panel_col_outcome": "Outcome", + "test_panel_col_test": "Test", + "test_panel_col_file": "File", # Problems panel "tab_menu_problems_panel_tab_name": "Problems", "problems_panel_refresh": "Recheck", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 1891eb92..b8369cf5 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -374,6 +374,15 @@ "tab_menu_diff_against_head_name": "與 HEAD 比較差異", "tab_menu_split_view_label": "切換分割檢視", "tab_menu_minimap_label": "切換縮圖", + # Test panel + "tab_menu_test_panel_tab_name": "測試", + "test_panel_run": "執行測試", + "test_panel_ready": "就緒", + "test_panel_running": "測試執行中...", + "test_panel_no_results": "沒有回報任何測試", + "test_panel_col_outcome": "結果", + "test_panel_col_test": "測試", + "test_panel_col_file": "檔案", # Problems panel "tab_menu_problems_panel_tab_name": "問題", "problems_panel_refresh": "重新檢查", diff --git a/je_editor/utils/test_runner/__init__.py b/je_editor/utils/test_runner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/test_runner/pytest_output.py b/je_editor/utils/test_runner/pytest_output.py new file mode 100644 index 00000000..b3834ae4 --- /dev/null +++ b/je_editor/utils/test_runner/pytest_output.py @@ -0,0 +1,162 @@ +""" +解析 pytest 的輸出成測試結果 +Turn pytest's output into test results. + +面板用這些結果列出每個測試,並讓失敗的項目可以直接跳到出錯的那一行。 +The panel lists each test from these results and jumps to the failing line. + +純邏輯:不執行 pytest,因此可以用固定的輸出樣本測試。 +Pure logic: it does not run pytest, so it can be tested against fixed output. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +# 比對 ``path::test_name PASSED [ 50%]`` 這類結果行 +# Matches a result line such as ``path::test_name PASSED [ 50%]`` +_RESULT_PATTERN = re.compile( + r"^(?P\S+::\S+)\s+(?PPASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)\b") +# 比對 ``--tb=line`` 的失敗位置,例如 ``D:\p\test_x.py:42: AssertionError`` +# Matches a ``--tb=line`` failure location, e.g. ``D:\p\test_x.py:42: AssertionError`` +_FAILURE_PATTERN = re.compile(r"^(?P.+?):(?P\d+): (?P.+)$") +# 比對結尾統計行,例如 ``3 failed, 5 passed in 0.42s`` +# Matches the summary line, e.g. ``3 failed, 5 passed in 0.42s`` +_SUMMARY_PATTERN = re.compile(r"^=+\s*(?P.*?(?:passed|failed|error|no tests ran).*?)\s*=+$") + +# 視為失敗的結果 / Outcomes that count as a failure +FAILING_OUTCOMES = frozenset({"FAILED", "ERROR"}) + + +@dataclass(frozen=True) +class PytestResult: + """ + 一個測試的結果 + One test's result. + + :param node_id: pytest 的節點名稱 / pytest's node id + :param outcome: 結果,例如 ``PASSED`` / the outcome, such as ``PASSED`` + """ + + node_id: str + outcome: str + + @property + def failed(self) -> bool: + """這個結果是否算失敗 / Whether this result counts as a failure.""" + return self.outcome in FAILING_OUTCOMES + + @property + def file_path(self) -> str: + """節點所屬的檔案 / The file the node belongs to.""" + return self.node_id.split("::", 1)[0] + + @property + def name(self) -> str: + """節點的測試名稱 / The test's name within its file.""" + return self.node_id.split("::", 1)[1] if "::" in self.node_id else self.node_id + + +@dataclass(frozen=True) +class FailureLocation: + """ + 一個失敗的位置 + Where a failure happened. + + :param path: 檔案路徑 / the file's path + :param line: 行號(1 起算)/ the 1-based line number + :param message: 錯誤訊息 / the error message + """ + + path: str + line: int + message: str + + +def parse_results(output: str) -> list[PytestResult]: + """ + 解析每個測試的結果 + Parse each test's result. + + :param output: pytest 的輸出(需要 ``-v``)/ pytest's output, as produced with ``-v`` + :return: 測試結果,依出現順序 / the results, in the order they were reported + """ + results: list[PytestResult] = [] + for line in output.splitlines(): + match = _RESULT_PATTERN.match(line.strip()) + if match is not None: + results.append( + PytestResult(node_id=match.group("node"), outcome=match.group("outcome"))) + return results + + +def parse_failures(output: str) -> list[FailureLocation]: + """ + 解析失敗的位置 + Parse the reported failure locations. + + :param output: pytest 的輸出(需要 ``--tb=line``)/ pytest's output, with ``--tb=line`` + :return: 失敗位置,依出現順序且不重複 / the locations, in order, without repeats + """ + failures: list[FailureLocation] = [] + seen: set[tuple[str, int]] = set() + for line in output.splitlines(): + stripped = line.strip() + match = _FAILURE_PATTERN.match(stripped) + if match is None: + continue + try: + line_number = int(match.group("line")) + except ValueError: + continue + path = match.group("path").strip() + # 結果行本身也含有冒號,排除掉才不會被誤認成失敗位置 + # A result line also contains a colon; skipping those avoids reading one + # as a failure location + if "::" in path or not path: + continue + key = (path, line_number) + if key in seen: + continue + seen.add(key) + failures.append( + FailureLocation(path=path, line=line_number, message=match.group("message"))) + return failures + + +def parse_summary(output: str) -> str: + """ + 取得結尾的統計文字 + The summary line pytest finished with. + + :param output: pytest 的輸出 / pytest's output + :return: 統計文字,找不到時為空字串 / the summary, or an empty string + """ + for line in reversed(output.splitlines()): + match = _SUMMARY_PATTERN.match(line.strip()) + if match is not None: + return match.group("summary").strip() + return "" + + +def failure_for_result( + result: PytestResult, failures: list[FailureLocation]) -> FailureLocation | None: + """ + 找出某個失敗測試對應的位置 + Find the failure location belonging to a failed test. + + 以檔案路徑比對,因為 ``--tb=line`` 只報位置不報節點名稱。 + Matching is by file path, since ``--tb=line`` reports a location without the + node it belongs to. + + :param result: 測試結果 / the test result + :param failures: 所有失敗位置 / every reported failure location + :return: 對應的位置,找不到時為 ``None`` / the location, or ``None`` + """ + if not result.failed: + return None + file_name = result.file_path.replace("\\", "/").split("/")[-1] + for failure in failures: + if failure.path.replace("\\", "/").split("/")[-1] == file_name: + return failure + return None diff --git a/test/test_pytest_panel.py b/test/test_pytest_panel.py new file mode 100644 index 00000000..69b9b970 --- /dev/null +++ b/test/test_pytest_panel.py @@ -0,0 +1,162 @@ +"""Tests for parsing pytest output and showing it in the test panel.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.test_runner.pytest_output import ( + PytestResult, + failure_for_result, + parse_failures, + parse_results, + parse_summary, +) + +SAMPLE_OUTPUT = """ +============================= test session starts ============================= +collected 3 items + +test/test_alpha.py::TestAlpha::test_one PASSED [ 33%] +test/test_alpha.py::TestAlpha::test_two FAILED [ 66%] +test/test_beta.py::test_three SKIPPED [100%] + +=================================== FAILURES ================================== +D:\\project\\test\\test_alpha.py:42: AssertionError: expected 1 got 2 +=========================== short test summary info =========================== +========================= 1 failed, 1 passed, 1 skipped in 0.42s ============== +""" + + +class TestParseResults: + def test_every_reported_test_is_read(self): + assert len(parse_results(SAMPLE_OUTPUT)) == 3 + + def test_outcomes_are_kept(self): + outcomes = [result.outcome for result in parse_results(SAMPLE_OUTPUT)] + assert outcomes == ["PASSED", "FAILED", "SKIPPED"] + + def test_node_is_split_into_file_and_name(self): + result = parse_results(SAMPLE_OUTPUT)[0] + assert result.file_path == "test/test_alpha.py" + assert result.name == "TestAlpha::test_one" + + def test_failed_flag(self): + results = parse_results(SAMPLE_OUTPUT) + assert results[1].failed is True + assert results[0].failed is False + assert results[2].failed is False + + def test_an_error_counts_as_a_failure(self): + assert parse_results("test/x.py::test_a ERROR [100%]")[0].failed is True + + def test_output_without_results(self): + assert parse_results("collected 0 items\n") == [] + + def test_empty_output(self): + assert parse_results("") == [] + + +class TestParseFailures: + def test_failure_location_is_read(self): + failures = parse_failures(SAMPLE_OUTPUT) + assert len(failures) == 1 + assert failures[0].line == 42 + assert failures[0].path.endswith("test_alpha.py") + + def test_message_is_kept(self): + assert "expected 1 got 2" in parse_failures(SAMPLE_OUTPUT)[0].message + + def test_result_lines_are_not_mistaken_for_locations(self): + assert parse_failures("test/test_a.py::test_one PASSED [100%]") == [] + + def test_duplicates_are_dropped(self): + repeated = "x.py:10: boom\nx.py:10: boom\n" + assert len(parse_failures(repeated)) == 1 + + def test_no_failures(self): + assert parse_failures("all good\n") == [] + + +class TestParseSummary: + def test_summary_is_read(self): + assert "1 failed" in parse_summary(SAMPLE_OUTPUT) + + def test_output_without_a_summary(self): + assert parse_summary("nothing here\n") == "" + + +class TestFailureForResult: + def test_matches_by_file(self): + results = parse_results(SAMPLE_OUTPUT) + failure = failure_for_result(results[1], parse_failures(SAMPLE_OUTPUT)) + assert failure is not None and failure.line == 42 + + def test_passing_test_has_no_failure(self): + results = parse_results(SAMPLE_OUTPUT) + assert failure_for_result(results[0], parse_failures(SAMPLE_OUTPUT)) is None + + def test_failure_in_another_file_is_not_matched(self): + result = PytestResult(node_id="test/test_other.py::test_x", outcome="FAILED") + assert failure_for_result(result, parse_failures(SAMPLE_OUTPUT)) is None + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def panel(app, tmp_path): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import TestPanelWidget + widget = TestPanelWidget(main_window=None, working_dir=str(tmp_path)) + yield widget + widget.close() + widget.deleteLater() + + +class TestTestPanel: + def test_starts_empty(self, panel): + assert panel.results() == [] + assert panel.result_tree.topLevelItemCount() == 0 + + def test_output_fills_the_tree(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert panel.result_tree.topLevelItemCount() == 3 + + def test_failures_are_listed_first(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert panel.result_tree.topLevelItem(0).text(0) == "FAILED" + + def test_summary_reaches_the_status_label(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert "1 failed" in panel.status_label.text() + + def test_output_without_tests_says_so(self, panel): + panel.apply_output("collected 0 items\n") + assert panel.status_label.text() != "" + assert panel.results() == [] + + def test_opening_without_a_window_is_safe(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert panel.open_result(panel.results()[0]) is False + + def test_opening_a_failure_jumps_to_its_line(self, app, tmp_path): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import TestPanelWidget + window = MagicMock() + window.tab_widget = MagicMock() + window.tab_widget.currentWidget.return_value = None + panel = TestPanelWidget(main_window=window, working_dir=str(tmp_path)) + panel.apply_output(SAMPLE_OUTPUT) + failed = [result for result in panel.results() if result.failed][0] + assert panel.open_result(failed) is True + window.go_to_new_tab.assert_called_once() + panel.close() + panel.deleteLater() + + def test_command_is_an_argument_list(self): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command + command = pytest_command() + assert command[1:3] == ["-m", "pytest"] + assert "-v" in command and "--tb=line" in command From 6ec65ea05ce7ca0f99ed61a6865bec3332c25e55 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 18:54:42 +0800 Subject: [PATCH 15/36] Edit at several carets at once Ctrl+Shift+L puts a caret at the end of every selected line and Alt-click adds or removes one anywhere; typing and Backspace then apply at all of them as a single undo step. Escape, or a plain click, goes back to one caret. QPlainTextEdit has only one caret, so the extra ones are tracked and drawn here. Edits run from the end of the document backwards, which keeps an edit from moving a position that has not been handled yet, and the carets are then advanced by how much text went in ahead of each. Backspace is refused outright when any caret sits at the start of the document, since deleting for the others would pull the carets out of step. --- .../pyside_ui/code/multi_cursor/__init__.py | 0 .../code/multi_cursor/multi_cursor_manager.py | 190 ++++++++++++++++++ .../code_edit_plaintext.py | 92 ++++++++- .../save_settings/user_color_setting_file.py | 2 + je_editor/utils/multi_cursor/__init__.py | 0 .../utils/multi_cursor/cursor_positions.py | 107 ++++++++++ test/test_multi_cursor.py | 188 +++++++++++++++++ 7 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 je_editor/pyside_ui/code/multi_cursor/__init__.py create mode 100644 je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py create mode 100644 je_editor/utils/multi_cursor/__init__.py create mode 100644 je_editor/utils/multi_cursor/cursor_positions.py create mode 100644 test/test_multi_cursor.py diff --git a/je_editor/pyside_ui/code/multi_cursor/__init__.py b/je_editor/pyside_ui/code/multi_cursor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py new file mode 100644 index 00000000..a5d51da1 --- /dev/null +++ b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py @@ -0,0 +1,190 @@ +""" +在編輯器中維護並套用多重游標 +Keep and apply extra carets in the editor. + +QPlainTextEdit 只有一個游標,所以額外的游標由這裡自己記錄位置、自己畫出來, +輸入與刪除時再逐一套用;整批編輯算一個復原步驟。 +QPlainTextEdit has only one caret, so the extra ones are tracked and drawn here +and each edit is applied to every position in turn, as a single undo step. +""" +from __future__ import annotations + +from PySide6.QtGui import QTextCursor + +from je_editor.utils.multi_cursor.cursor_positions import clamp_positions, toggle_position + + +class MultiCursorManager: + """ + 管理一個編輯器的額外游標 + Track one editor's extra carets. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些游標所屬的編輯器 / the editor the carets belong to + """ + self._code_edit = code_edit + self._positions: list[int] = [] + + @property + def active(self) -> bool: + """是否有額外游標 / Whether any extra caret exists.""" + return bool(self._positions) + + def positions(self) -> list[int]: + """取得額外游標的位置副本 / A copy of the extra caret positions.""" + return list(self._positions) + + def clear(self) -> bool: + """ + 清除所有額外游標 + Drop every extra caret. + + :return: 是否真的清掉了什麼 / whether anything was actually dropped + """ + if not self._positions: + return False + self._positions = [] + self._code_edit.viewport().update() + return True + + def toggle_at(self, position: int) -> None: + """ + 在指定位置加入或移除一個游標 + Add or remove a caret at a position. + + :param position: 文件中的字元位置 / a character position in the document + """ + self._positions = toggle_position(self._positions, position) + self._code_edit.viewport().update() + + def add_to_selected_lines(self) -> int: + """ + 在選取範圍的每一行加入游標 + Put a caret on every line of the selection. + + 游標放在各行的行尾,這是逐行改結尾(例如補上逗號)最常用的形式。 + Each caret goes to its line's end, which is the form most used for + editing line by line — appending a comma to each, say. + + :return: 加入的游標數量 / how many carets were added + """ + cursor = self._code_edit.textCursor() + if not cursor.hasSelection(): + return 0 + document = self._code_edit.document() + first = document.findBlock(cursor.selectionStart()).blockNumber() + last = document.findBlock(cursor.selectionEnd()).blockNumber() + positions: list[int] = [] + for line in range(first, last + 1): + block = document.findBlockByNumber(line) + if block.isValid(): + positions.append(block.position() + len(block.text())) + # 主游標留在最後一行,其餘交給額外游標 + # The primary caret keeps the last line; the rest become extra carets + self._positions = sorted(set(positions[:-1])) + main = self._code_edit.textCursor() + main.setPosition(positions[-1] if positions else cursor.position()) + self._code_edit.setTextCursor(main) + self._code_edit.viewport().update() + return len(self._positions) + + def _edit_targets(self) -> list[int]: + """ + 取得所有要編輯的位置(含主游標),由小到大 + Every position to edit, primary caret included, in ascending order. + """ + return sorted({*self._positions, self._code_edit.textCursor().position()}) + + def _apply_at_targets(self, edit, shift_per_target: int) -> None: + """ + 由後往前在每個位置套用一次編輯,再把游標移到新位置 + Apply one edit at each position from the end backwards, then move the + carets to where they ended up. + + 由後往前做,先做的編輯就不會影響還沒處理的位置;做完之後,第 n 個位置前面 + 共發生了 n 次編輯,加上自己那一次,所以位移量是 ``(n + 1) * 每次的位移``。 + Working backwards keeps an edit from moving a position not yet handled. + Afterwards, the n-th position has n edits before it plus its own, so it + moves by ``(n + 1) * shift_per_target``. + + :param edit: 對單一位置執行的編輯 / the edit to run at one position + :param shift_per_target: 每次編輯造成的位移(插入為正,刪除為負) + how far one edit moves what follows it: positive to insert, negative + to delete + """ + targets = self._edit_targets() + main_position = self._code_edit.textCursor().position() + cursor = self._code_edit.textCursor() + cursor.beginEditBlock() + try: + for position in reversed(targets): + edit(cursor, position) + finally: + cursor.endEditBlock() + + moved = { + position: position + (index + 1) * shift_per_target + for index, position in enumerate(targets) + } + main = self._code_edit.textCursor() + main.setPosition(min( + max(0, moved.get(main_position, main_position)), self._document_limit())) + self._code_edit.setTextCursor(main) + self._positions = clamp_positions( + [moved[position] for position in self._positions if position in moved], + self._document_limit()) + self._code_edit.viewport().update() + + def insert_text(self, text: str) -> bool: + """ + 在每個游標插入文字 + Insert text at every caret. + + :param text: 要插入的文字 / the text to insert + :return: 有插入時為 ``True`` / ``True`` when anything was inserted + """ + if not self._positions or not text: + return False + + def insert(cursor: QTextCursor, position: int) -> None: + cursor.setPosition(position) + cursor.insertText(text) + + self._apply_at_targets(insert, len(text)) + return True + + def delete_before(self) -> bool: + """ + 在每個游標刪除前一個字元(Backspace) + Delete the character before every caret, as Backspace does. + + 任何一個游標已經在文件開頭時就整批不做,因為那一個沒有東西可刪,其餘照做 + 會讓各游標的相對位置錯開。 + Nothing is deleted when any caret sits at the very start: that one has + nothing to delete, and deleting for the others would pull the carets out + of step with each other. + + :return: 有刪除時為 ``True`` / ``True`` when anything was deleted + """ + if not self._positions or any(position <= 0 for position in self._edit_targets()): + return False + + def delete(cursor: QTextCursor, position: int) -> None: + cursor.setPosition(position) + cursor.deletePreviousChar() + + self._apply_at_targets(delete, -1) + return True + + def _document_limit(self) -> int: + """文件中最大的有效位置 / The highest valid position in the document.""" + return max(0, self._code_edit.document().characterCount() - 1) + + def refresh_after_external_edit(self) -> None: + """ + 文件被其他方式改動後,把游標拉回有效範圍 + Pull the carets back into range after the document changed elsewhere. + """ + self._positions = clamp_positions(self._positions, self._document_limit()) 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 a345d5b4..6d77f7dd 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 @@ -17,6 +17,7 @@ from je_editor.pyside_ui.code.git_diff.blame_manager import BlameManager from je_editor.pyside_ui.code.git_diff.diff_marker_manager import DiffMarkerManager from je_editor.pyside_ui.code.lint.lint_manager import LintManager +from je_editor.pyside_ui.code.multi_cursor.multi_cursor_manager import MultiCursorManager from je_editor.pyside_ui.code.selection.smart_selection_manager import SmartSelectionManager from je_editor.pyside_ui.code.snippets.snippet_manager import SnippetManager from je_editor.utils.file_diff.line_status import ( @@ -296,6 +297,10 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # 片段展開與定位點 / Snippet expansion and its tab stops self.snippet_manager = SnippetManager(self) + # 多重游標 / Extra carets + self.multi_cursor_manager = MultiCursorManager(self) + self._register_multi_cursor_actions() + def reset_highlighter(self) -> None: """重設語法高亮 / Reset syntax highlighter""" jeditor_logger.info("CodeEditor reset_highlighter") @@ -573,6 +578,8 @@ def paintEvent(self, event: QtGui.QPaintEvent) -> None: QPlainTextEdit.paintEvent(self, event) if user_setting_dict.get("show_trailing_whitespace", True): self._paint_trailing_whitespace() + if self.multi_cursor_manager.active: + self._paint_extra_cursors() if self.blame_manager.enabled: self._paint_blame_annotations() @@ -1976,6 +1983,82 @@ def _handle_tab_indent(self, event: QKeyEvent) -> bool: return True return False + def _register_multi_cursor_actions(self) -> None: + """註冊多重游標的快捷鍵 / Register the multi-caret shortcuts.""" + for shortcut, handler in ( + ("Ctrl+Shift+L", self.add_cursors_to_selected_lines), + ("Ctrl+Shift+Escape", self.clear_extra_cursors), + ): + action = QAction(self) + action.setShortcut(shortcut) + action.triggered.connect(handler) + self.addAction(action) + + def add_cursors_to_selected_lines(self) -> int: + """ + 在選取範圍的每一行行尾放一個游標 + Put a caret at the end of every line in the selection. + + :return: 額外游標的數量 / how many extra carets were added + """ + return self.multi_cursor_manager.add_to_selected_lines() + + def clear_extra_cursors(self) -> bool: + """ + 清除所有額外游標 + Drop every extra caret. + + :return: 是否真的清掉了什麼 / whether anything was actually dropped + """ + return self.multi_cursor_manager.clear() + + def _handle_multi_cursor_click(self, event: QtGui.QMouseEvent) -> bool: + """ + Alt+點按新增或移除一個游標;一般點按則收掉額外游標 + Alt-click adds or removes a caret; a plain click drops the extra ones. + + :param event: 滑鼠事件 / the mouse event + :return: 事件是否已被處理 / whether the event was handled here + """ + if event.modifiers() & Qt.KeyboardModifier.AltModifier: + self.multi_cursor_manager.toggle_at(self.cursorForPosition(event.pos()).position()) + return True + if self.multi_cursor_manager.active: + # 一般點按等於重新開始,因此先收掉額外游標 + # A plain click starts over, so the extra carets go first + self.multi_cursor_manager.clear() + return False + + def _paint_extra_cursors(self) -> None: + """畫出每個額外游標 / Draw each extra caret.""" + painter = QPainter(self.viewport()) + painter.setPen(actually_color_dict.get("extra_cursor_color")) + cursor = QTextCursor(self.document()) + for position in self.multi_cursor_manager.positions(): + cursor.setPosition(min(position, self.document().characterCount() - 1)) + rect = self.cursorRect(cursor) + painter.drawLine(rect.left(), rect.top(), rect.left(), rect.bottom()) + painter.end() + + def _handle_multi_cursor_key(self, event: QKeyEvent) -> bool: + """ + 把輸入與退格套用到每個額外游標 + Apply typing and Backspace at every extra caret. + + :param event: 按鍵事件 / the key event + :return: 事件是否已被處理 / whether the event was handled here + """ + if not self.multi_cursor_manager.active: + return False + key = event.key() + if key == Qt.Key.Key_Escape: + return self.multi_cursor_manager.clear() + if key == Qt.Key.Key_Backspace: + return self.multi_cursor_manager.delete_before() + if event.text() and event.text().isprintable(): + return self.multi_cursor_manager.insert_text(event.text()) + return False + def _handle_snippet_tab(self) -> bool: """ 以 Tab 展開片段,或跳到片段的下一個定位點 @@ -2036,6 +2119,9 @@ def keyPressEvent(self, event: QKeyEvent) -> None: key = event.key() modifiers = event.modifiers() + if self._handle_multi_cursor_key(event): + return + if modifiers & Qt.KeyboardModifier.ControlModifier and self._handle_ctrl_shortcuts(event): return @@ -2074,9 +2160,11 @@ def keyPressEvent(self, event: QKeyEvent) -> None: def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: """ - 滑鼠點擊事件 - Mouse press event + 滑鼠點擊事件(Alt+點按用於多重游標) + Mouse press event; Alt-click drives the extra carets. """ + if self._handle_multi_cursor_click(event): + return super().mousePressEvent(event) self.highlight_current_line() diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 9296fb8a..812447ca 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -48,6 +48,7 @@ def update_actually_color_dict() -> None: "minimap_background_color": _to_qcolor("minimap_background_color", [40, 40, 48]), "minimap_line_color": _to_qcolor("minimap_line_color", [130, 130, 150]), "minimap_viewport_color": _to_qcolor("minimap_viewport_color", [80, 80, 110]), + "extra_cursor_color": _to_qcolor("extra_cursor_color", [255, 215, 64]), "trailing_whitespace_color": _to_qcolor("trailing_whitespace_color", [120, 70, 70]), } ) @@ -74,6 +75,7 @@ def update_actually_color_dict() -> None: "minimap_background_color": [40, 40, 48], "minimap_line_color": [130, 130, 150], "minimap_viewport_color": [80, 80, 110], + "extra_cursor_color": [255, 215, 64], "trailing_whitespace_color": [120, 70, 70] } diff --git a/je_editor/utils/multi_cursor/__init__.py b/je_editor/utils/multi_cursor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/multi_cursor/cursor_positions.py b/je_editor/utils/multi_cursor/cursor_positions.py new file mode 100644 index 00000000..03126586 --- /dev/null +++ b/je_editor/utils/multi_cursor/cursor_positions.py @@ -0,0 +1,107 @@ +""" +維護多重游標的位置 +Keep track of where the extra carets are. + +在其中一個位置插入或刪除文字之後,排在後面的位置都要跟著位移,否則第二個之後 +的游標就會落在錯的地方。這裡只做位置運算,實際編輯交給編輯器。 +Inserting or deleting at one caret shifts every caret after it; without that, +the second and later carets would land in the wrong place. This module does the +arithmetic only, leaving the editing to the editor. +""" +from __future__ import annotations + + +def add_position(positions: list[int], position: int) -> list[int]: + """ + 加入一個游標位置(已存在則不重複加入) + Add a caret position, ignoring one that is already there. + + :param positions: 現有位置 / the positions so far + :param position: 要加入的位置 / the position to add + :return: 排序後的新清單 / the new sorted list + """ + if position < 0 or position in positions: + return sorted(positions) + return sorted([*positions, position]) + + +def remove_position(positions: list[int], position: int) -> list[int]: + """ + 移除一個游標位置 + Remove a caret position. + + :param positions: 現有位置 / the positions so far + :param position: 要移除的位置 / the position to remove + :return: 排序後的新清單 / the new sorted list + """ + return sorted(value for value in positions if value != position) + + +def toggle_position(positions: list[int], position: int) -> list[int]: + """ + 切換一個游標位置:不在就加入,已在就移除 + Toggle a caret position: add it when absent, remove it when present. + + :param positions: 現有位置 / the positions so far + :param position: 要切換的位置 / the position to toggle + :return: 排序後的新清單 / the new sorted list + """ + if position in positions: + return remove_position(positions, position) + return add_position(positions, position) + + +def shift_after_insert(positions: list[int], at: int, length: int) -> list[int]: + """ + 在 *at* 插入 *length* 個字元之後,調整其他位置 + Adjust the positions after *length* characters are inserted at *at*. + + 插入點之後的位置往後移;插入點本身與之前的位置不變。 + Positions after the insertion move along; the insertion point itself and + anything before it stay put. + + :param positions: 現有位置 / the positions so far + :param at: 插入位置 / where the text went in + :param length: 插入的字元數 / how many characters were inserted + :return: 調整後的位置 / the adjusted positions + """ + return [value + length if value > at else value for value in positions] + + +def shift_after_delete(positions: list[int], at: int, length: int) -> list[int]: + """ + 在 *at* 刪除 *length* 個字元之後,調整其他位置 + Adjust the positions after *length* characters are deleted from *at*. + + 落在被刪除範圍內的位置會被移到刪除點,避免指向已經不存在的位置。 + A position inside the deleted range collapses onto the deletion point, so it + cannot point at text that no longer exists. + + :param positions: 現有位置 / the positions so far + :param at: 刪除起點 / where the deletion started + :param length: 刪除的字元數 / how many characters were deleted + :return: 調整後的位置(去重並排序)/ the adjusted positions, unique and sorted + """ + adjusted: list[int] = [] + for value in positions: + if value <= at: + adjusted.append(value) + elif value >= at + length: + adjusted.append(value - length) + else: + adjusted.append(at) + return sorted(set(adjusted)) + + +def clamp_positions(positions: list[int], limit: int) -> list[int]: + """ + 把位置限制在文件範圍內 + Clamp the positions to the document's length. + + :param positions: 現有位置 / the positions so far + :param limit: 文件可用的最大位置 / the highest valid position + :return: 範圍內的位置(去重並排序)/ the in-range positions, unique and sorted + """ + if limit < 0: + return [] + return sorted({min(max(0, value), limit) for value in positions}) diff --git a/test/test_multi_cursor.py b/test/test_multi_cursor.py new file mode 100644 index 00000000..bbcf07b8 --- /dev/null +++ b/test/test_multi_cursor.py @@ -0,0 +1,188 @@ +"""Tests for multiple carets: position bookkeeping and editing at each.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtCore import QEvent, Qt +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QApplication + +from je_editor.utils.multi_cursor.cursor_positions import ( + add_position, + clamp_positions, + remove_position, + shift_after_delete, + shift_after_insert, + toggle_position, +) + + +class TestPositionBookkeeping: + def test_adding_keeps_them_sorted(self): + assert add_position([5, 1], 3) == [1, 3, 5] + + def test_adding_a_duplicate_changes_nothing(self): + assert add_position([1, 3], 3) == [1, 3] + + def test_negative_positions_are_refused(self): + assert add_position([1], -5) == [1] + + def test_removing(self): + assert remove_position([1, 3, 5], 3) == [1, 5] + + def test_removing_an_absent_position(self): + assert remove_position([1, 5], 3) == [1, 5] + + def test_toggle_adds_then_removes(self): + positions = toggle_position([], 4) + assert positions == [4] + assert toggle_position(positions, 4) == [] + + +class TestShifting: + def test_insert_moves_later_positions(self): + assert shift_after_insert([2, 10], 5, 3) == [2, 13] + + def test_insert_at_a_position_leaves_it_alone(self): + assert shift_after_insert([5], 5, 3) == [5] + + def test_delete_moves_later_positions_back(self): + assert shift_after_delete([2, 10], 5, 3) == [2, 7] + + def test_delete_collapses_positions_inside_the_range(self): + assert shift_after_delete([6], 5, 3) == [5] + + def test_delete_leaves_earlier_positions_alone(self): + assert shift_after_delete([2], 5, 3) == [2] + + def test_clamping_to_the_document(self): + assert clamp_positions([-2, 5, 99], 10) == [0, 5, 10] + + def test_clamping_deduplicates(self): + assert clamp_positions([12, 15], 10) == [10] + + def test_clamping_an_empty_document(self): + assert clamp_positions([3], -1) == [] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +def _select_all(editor) -> None: + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(editor.document().characterCount() - 1, cursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + +def _type(editor, text: str) -> None: + editor.keyPressEvent( + QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_A, Qt.KeyboardModifier.NoModifier, text)) + + +class TestMultiCursorEditing: + def test_no_extra_cursors_at_first(self, editor): + assert editor.multi_cursor_manager.active is False + + def test_cursors_are_added_to_each_selected_line(self, editor): + editor.setPlainText("one\ntwo\nthree\n") + _select_all(editor) + added = editor.add_cursors_to_selected_lines() + assert added >= 1 + assert editor.multi_cursor_manager.active + + def test_typing_reaches_every_line(self, editor): + editor.setPlainText("one\ntwo\nthree") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _type(editor, ",") + assert editor.toPlainText() == "one,\ntwo,\nthree," + + def test_typing_several_characters(self, editor): + editor.setPlainText("a\nb") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _type(editor, "x") + _type(editor, "y") + assert editor.toPlainText() == "axy\nbxy" + + def test_backspace_reaches_every_line(self, editor): + editor.setPlainText("one!\ntwo!\nthree!") + _select_all(editor) + editor.add_cursors_to_selected_lines() + editor.keyPressEvent(QKeyEvent( + QEvent.Type.KeyPress, Qt.Key.Key_Backspace, Qt.KeyboardModifier.NoModifier)) + assert editor.toPlainText() == "one\ntwo\nthree" + + def test_multi_cursor_edit_is_one_undo_step(self, editor): + editor.setPlainText("one\ntwo") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _type(editor, ";") + editor.undo() + assert editor.toPlainText() == "one\ntwo" + + def test_escape_clears_the_extra_cursors(self, editor): + editor.setPlainText("one\ntwo") + _select_all(editor) + editor.add_cursors_to_selected_lines() + editor.keyPressEvent(QKeyEvent( + QEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier)) + assert editor.multi_cursor_manager.active is False + + def test_clearing_leaves_typing_to_the_primary_caret(self, editor): + editor.setPlainText("one\ntwo") + _select_all(editor) + editor.add_cursors_to_selected_lines() + editor.clear_extra_cursors() + _type(editor, "!") + assert editor.toPlainText().count("!") == 1 + + def test_adding_without_a_selection_does_nothing(self, editor): + editor.setPlainText("one\ntwo") + assert editor.add_cursors_to_selected_lines() == 0 + + def test_toggling_the_same_position_twice_removes_it(self, editor): + editor.setPlainText("hello") + editor.multi_cursor_manager.toggle_at(2) + assert editor.multi_cursor_manager.positions() == [2] + editor.multi_cursor_manager.toggle_at(2) + assert editor.multi_cursor_manager.positions() == [] + + def test_backspace_at_the_document_start_is_refused(self, editor): + editor.setPlainText("abc") + editor.multi_cursor_manager.toggle_at(0) + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + assert editor.multi_cursor_manager.delete_before() is False + assert editor.toPlainText() == "abc" + + def test_painting_extra_cursors_does_not_raise(self, editor): + editor.setPlainText("one\ntwo\nthree") + _select_all(editor) + editor.add_cursors_to_selected_lines() + editor.show() + QApplication.processEvents() + editor.hide() From 71a6fdf176d4c4a7641fa5d2bf0117c77a2f485e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 19:00:23 +0800 Subject: [PATCH 16/36] Complete non-Python files through a language server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completion was jedi-only, so it covered Python and nothing else. Files with another suffix now talk to a language server over stdio — TypeScript, Rust, Go, C/C++, Lua and JSON out of the box, and any other suffix through a setting — while Python keeps jedi rather than gaining a second source of completions for the same file. The client uses QProcess rather than a thread of its own, since QProcess already signals when output arrives. A server that is not installed, fails to start, or dies mid-session simply means no completions; the editor is never blocked on it, and it is shut down politely before being killed. Message framing lives in utils/lsp as pure logic: a body arriving split across reads is held until the rest turns up, and a header without a usable length or a body that is not JSON is dropped rather than raised, so a misbehaving server cannot stall the editor. --- README.md | 9 + je_editor/pyside_ui/code/lsp/__init__.py | 0 je_editor/pyside_ui/code/lsp/lsp_client.py | 205 +++++++++++++ .../code_edit_plaintext.py | 44 +++ .../pyside_ui/main_ui/editor/editor_widget.py | 5 +- je_editor/utils/lsp/__init__.py | 0 je_editor/utils/lsp/language_servers.py | 95 ++++++ je_editor/utils/lsp/lsp_protocol.py | 189 ++++++++++++ test/test_lsp.py | 273 ++++++++++++++++++ 9 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 je_editor/pyside_ui/code/lsp/__init__.py create mode 100644 je_editor/pyside_ui/code/lsp/lsp_client.py create mode 100644 je_editor/utils/lsp/__init__.py create mode 100644 je_editor/utils/lsp/language_servers.py create mode 100644 je_editor/utils/lsp/lsp_protocol.py create mode 100644 test/test_lsp.py diff --git a/README.md b/README.md index 71d7ca23..2ff146c2 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,15 @@ The editor launches in a maximized window with a dark amber theme by default. - **Search & Replace** -- Search within the current file, across folders, or project-wide with regex and case-sensitive options. Runs in background threads for large projects. - **Code Folding** -- Collapse and expand indented blocks (functions, classes, loops) from the gutter fold triangles or the keyboard. Folding is indentation-based and only toggles line visibility -- it never modifies the text, so saving always writes the complete file. Folds self-heal after edits: a fold whose header no longer exists simply reopens instead of hiding the wrong lines. - **Bookmarks** -- Mark lines and jump between them with the keyboard, or click the gutter to toggle. Bookmarks are anchored to the text (via `QTextCursor`), so they follow their code when lines are inserted or removed above them instead of drifting. +- **Multiple Carets** -- `Ctrl+Shift+L` puts a caret at the end of every selected line, and `Alt`-click adds or removes one anywhere. Typing and Backspace then apply at all of them as a single undo step; `Esc` or a plain click returns to one caret. +- **Split View** (`Ctrl+Alt+\`) -- A second view of the same file, sharing one document: an edit in either side shows up in the other at once, while scrolling and the caret stay independent. +- **Minimap** (`Ctrl+Alt+M`) -- An overview of the whole file drawn as bars following each line's length and indentation, with a band marking what is on screen. Click or drag to jump. Large files are sampled rather than drawn line by line. +- **Snippets** -- Type a trigger word and press Tab to expand it, then Tab through the placeholders with each default value selected. Uses the usual `$1` / `${2:default}` / `$0` notation, so existing snippets drop straight into `snippets.json`; a missing or broken file falls back to the built-in Python set. +- **Test Panel** -- Run pytest from a dock panel and read the results, failures first, with the summary as the status line. Double-click a row to open that test, jumping to the failing line. +- **Language Server Support** -- Non-Python files complete through a language server over stdio (TypeScript, Rust, Go, C/C++, Lua, JSON and more, configurable per suffix), while Python keeps using jedi. A server that is not installed simply means no completions rather than an error. +- **Encoding & Line Endings** -- A file's encoding and line-ending style are detected when it opens and written back unchanged when it saves, so editing one line of a CRLF file no longer rewrites every line. Both can be changed from the File menu; changing the encoding re-reads an unmodified file so mojibake can be fixed in place, and never discards unsaved work. +- **Format on Save** -- Optionally run yapf when a file is saved, keeping the caret on its line. Source that cannot be parsed is left alone rather than blocking the save. +- **Indent Guides & Trailing Whitespace** -- A vertical guide at each indentation level and shading on stray end-of-line whitespace, both toggleable from the Style menu. - **Lint Diagnostics** -- Findings from `ruff` are underlined in the editor and listed in a Problems dock panel (rule, message, line), with double-click to jump. The **buffer** is checked, not the file on disk, so unsaved edits are covered; the run happens on a worker thread after typing pauses, and a stale result from a superseded run is discarded. If `ruff` is not installed, or a run fails, the editor simply shows no diagnostics rather than reporting an error. - **Git Change Markers** -- The gutter shows how the file differs from its last commit: a green bar for added lines, orange for modified, and a thin red line where lines were deleted. Jump between changes with `Ctrl+Alt+Down` / `Ctrl+Alt+Up`, revert the change under the caret back to its committed form with `Ctrl+Alt+Z` (one undo step), and open a side-by-side diff of the whole file against HEAD from the Git menu. `Ctrl+Alt+B` toggles inline blame, showing the commit, author and summary that last touched each line. The committed version is read on a background thread when the file opens, and the comparison itself is a pure in-memory diff, recomputed only after typing pauses -- so editing never waits on git. Files outside a repository, or not yet committed, simply show no markers. - **Occurrence Highlighting** -- Placing the caret on an identifier highlights every other whole-word occurrence of it in the file. Keywords and single characters are ignored, and the scan is skipped on very large files to keep caret movement instant. diff --git a/je_editor/pyside_ui/code/lsp/__init__.py b/je_editor/pyside_ui/code/lsp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py new file mode 100644 index 00000000..4f1b86d4 --- /dev/null +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -0,0 +1,205 @@ +""" +以 stdio 與語言伺服器溝通 +Talk to a language server over stdio. + +用 QProcess 而不是自己開執行緒:QProcess 本來就是非同步的,讀到資料會發訊號, +所以不需要為了等待輸出而佔住一條執行緒。 +This uses QProcess rather than a thread of its own: QProcess is already +asynchronous and signals when data arrives, so no thread has to sit waiting for +output. + +伺服器沒安裝、啟動失敗或中途結束時都只是「沒有補全」,不會影響編輯。 +A server that is missing, fails to start, or dies mid-session simply means no +completions; editing carries on. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QObject, QProcess, Signal + +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.lsp.language_servers import language_id, server_command +from je_editor.utils.lsp.lsp_protocol import ( + MessageReader, + completion_labels, + diagnostic_entries, + encode_message, + file_uri, + notification, + request, +) + +# 等待伺服器結束的時間(毫秒)/ How long to wait for the server to exit +_SHUTDOWN_WAIT_MS = 2000 + + +class LspClient(QObject): + """ + 一個檔案對應的語言伺服器連線 + One language server connection, for one file. + """ + + completions_ready = Signal(list) # list[str] + diagnostics_ready = Signal(list) # list[dict] + + def __init__(self, parent: QObject | None = None) -> None: + """ + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._process: QProcess | None = None + self._reader = MessageReader() + self._next_id = 1 + self._file_path: str | None = None + self._version = 0 + self._pending_completion_id: int | None = None + + @property + def running(self) -> bool: + """伺服器是否正在執行 / Whether the server is running.""" + return self._process is not None and self._process.state() != QProcess.ProcessState.NotRunning + + def start_for(self, file_path: str, servers: dict | None = None) -> bool: + """ + 為某個檔案啟動對應的語言伺服器 + Start the language server that handles a file. + + :param file_path: 檔案路徑 / the file to serve + :param servers: 伺服器對照表 / the server mapping to consult + :return: 有啟動時為 ``True`` / ``True`` when a server was started + """ + command = server_command(Path(file_path).suffix, servers) + if command is None: + return False + self.stop() + process = QProcess(self) + process.setProgram(command[0]) + process.setArguments(command[1:]) + process.readyReadStandardOutput.connect(self._read_output) + process.start() + if not process.waitForStarted(_SHUTDOWN_WAIT_MS): + jeditor_logger.debug(f"lsp_client: {command[0]} did not start") + process.deleteLater() + return False + self._process = process + self._file_path = file_path + self._send(request(self._take_id(), "initialize", { + "processId": None, + "rootUri": file_uri(str(Path(file_path).parent)), + "capabilities": {}, + })) + self._send(notification("initialized", {})) + return True + + def _take_id(self) -> int: + """取得下一個請求編號 / Take the next request id.""" + request_id = self._next_id + self._next_id += 1 + return request_id + + def _send(self, payload: dict) -> bool: + """把訊息寫給伺服器 / Write a message to the server.""" + if not self.running: + return False + self._process.write(encode_message(payload)) + return True + + def did_open(self, text: str) -> bool: + """ + 通知伺服器檔案已開啟 + Tell the server the file is open. + + :param text: 目前內容 / the current content + :return: 有送出時為 ``True`` / ``True`` when the notification was sent + """ + if self._file_path is None: + return False + self._version = 1 + return self._send(notification("textDocument/didOpen", { + "textDocument": { + "uri": file_uri(self._file_path), + "languageId": language_id(Path(self._file_path).suffix), + "version": self._version, + "text": text, + } + })) + + def did_change(self, text: str) -> bool: + """ + 通知伺服器內容已變更(整份取代) + Tell the server the content changed, sending the whole document. + + :param text: 目前內容 / the current content + :return: 有送出時為 ``True`` / ``True`` when the notification was sent + """ + if self._file_path is None: + return False + self._version += 1 + return self._send(notification("textDocument/didChange", { + "textDocument": {"uri": file_uri(self._file_path), "version": self._version}, + "contentChanges": [{"text": text}], + })) + + def request_completion(self, line: int, column: int) -> bool: + """ + 要求某個位置的補全候選 + Ask for the completions at a position. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_completion_id = request_id + return self._send(request(request_id, "textDocument/completion", { + "textDocument": {"uri": file_uri(self._file_path)}, + "position": {"line": line, "character": column}, + })) + + def handle_message(self, message: dict) -> None: + """ + 處理伺服器送來的一則訊息 + Handle one message from the server. + + :param message: 已解析的訊息 / the parsed message + """ + if message.get("method") == "textDocument/publishDiagnostics": + entries = diagnostic_entries(message.get("params")) + self.diagnostics_ready.emit(entries) + return + if message.get("id") == self._pending_completion_id and "result" in message: + self._pending_completion_id = None + self.completions_ready.emit(completion_labels(message.get("result"))) + + def _read_output(self) -> None: + """讀取伺服器輸出並逐則處理 / Read the server's output and handle each message.""" + if self._process is None: + return + data = bytes(self._process.readAllStandardOutput()) + for message in self._reader.feed(data): + self.handle_message(message) + + def stop(self) -> None: + """ + 結束伺服器 + Shut the server down. + + 先以 ``shutdown``/``exit`` 請它自己結束,逾時才強制終止。 + It is asked to finish with ``shutdown``/``exit`` first, and only killed + if it does not. + """ + process, self._process = self._process, None + self._reader = MessageReader() + self._pending_completion_id = None + if process is None: + return + if process.state() != QProcess.ProcessState.NotRunning: + process.write(encode_message(request(self._take_id(), "shutdown"))) + process.write(encode_message(notification("exit"))) + if not process.waitForFinished(_SHUTDOWN_WAIT_MS): + process.kill() + process.waitForFinished(_SHUTDOWN_WAIT_MS) + process.deleteLater() 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 6d77f7dd..e02a9d7b 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 @@ -17,6 +17,7 @@ from je_editor.pyside_ui.code.git_diff.blame_manager import BlameManager from je_editor.pyside_ui.code.git_diff.diff_marker_manager import DiffMarkerManager from je_editor.pyside_ui.code.lint.lint_manager import LintManager +from je_editor.pyside_ui.code.lsp.lsp_client import LspClient from je_editor.pyside_ui.code.multi_cursor.multi_cursor_manager import MultiCursorManager from je_editor.pyside_ui.code.selection.smart_selection_manager import SmartSelectionManager from je_editor.pyside_ui.code.snippets.snippet_manager import SnippetManager @@ -301,6 +302,12 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.multi_cursor_manager = MultiCursorManager(self) self._register_multi_cursor_actions() + # 非 Python 檔的補全交給語言伺服器;Python 仍走 jedi + # Non-Python files complete through a language server; Python keeps jedi + self.lsp_client = LspClient(self) + self.lsp_client.completions_ready.connect(self.set_complete) + self.start_language_server() + def reset_highlighter(self) -> None: """重設語法高亮 / Reset syntax highlighter""" jeditor_logger.info("CodeEditor reset_highlighter") @@ -361,6 +368,11 @@ def complete(self) -> None: 使用 Jedi 在背景執行緒進行自動補全,避免阻塞 UI Run Jedi autocomplete in background thread to avoid blocking UI """ + # 非 Python 檔改問語言伺服器,jedi 只懂 Python + # A non-Python file asks its language server instead; jedi only knows Python + if self.request_language_server_completion(): + return + # 如果上一次補全還在執行,跳過 / Skip if previous completion is still running if self._complete_thread is not None and self._complete_thread.isRunning(): return @@ -1983,6 +1995,38 @@ def _handle_tab_indent(self, event: QKeyEvent) -> bool: return True return False + def start_language_server(self) -> bool: + """ + 為目前檔案啟動語言伺服器(如果有對應的) + Start the language server for the current file, when one applies. + + Python 檔不啟動,因為補全已經由 jedi 負責。 + Python files start none, since jedi already provides their completion. + + :return: 有啟動時為 ``True`` / ``True`` when a server was started + """ + if self.current_file is None or Path(str(self.current_file)).suffix.lower() == ".py": + self.lsp_client.stop() + return False + if not self.lsp_client.start_for(str(self.current_file)): + return False + self.lsp_client.did_open(self.toPlainText()) + return True + + def request_language_server_completion(self) -> bool: + """ + 向語言伺服器要求游標位置的補全 + Ask the language server for completions at the caret. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + if not self.lsp_client.running: + return False + cursor = self.textCursor() + self.lsp_client.did_change(self.toPlainText()) + return self.lsp_client.request_completion( + cursor.blockNumber(), cursor.positionInBlock()) + def _register_multi_cursor_actions(self) -> None: """註冊多重游標的快捷鍵 / Register the multi-caret shortcuts.""" for shortcut, handler in ( diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index 3cbe79f5..fc41fb2f 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -283,8 +283,10 @@ def open_an_file(self, path: Path) -> bool: self.current_file = file self.code_edit.current_file = file self.code_edit.reset_highlighter() - # 換檔後重新取得 git 比較基準 / Reload the git baseline for the new file + # 換檔後重新取得 git 比較基準與語言伺服器 + # Reload the git baseline and the language server for the new file self.code_edit.load_git_baseline() + self.code_edit.start_language_server() # 更新使用者設定中的最後開啟檔案 / Update last opened file in user settings user_setting_dict.update({"last_file": str(self.current_file)}) @@ -529,6 +531,7 @@ def close(self) -> bool: self.code_edit.diff_marker_manager.stop() self.code_edit.lint_manager.stop() self.code_edit.blame_manager.stop() + self.code_edit.lsp_client.stop() if self.current_file: file_is_open_manager_dict.pop(str(Path(self.current_file)), None) diff --git a/je_editor/utils/lsp/__init__.py b/je_editor/utils/lsp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/lsp/language_servers.py b/je_editor/utils/lsp/language_servers.py new file mode 100644 index 00000000..584d7855 --- /dev/null +++ b/je_editor/utils/lsp/language_servers.py @@ -0,0 +1,95 @@ +""" +依副檔名決定要用哪一個語言伺服器 +Decide which language server a file should use, based on its suffix. + +Python 仍然走既有的 jedi 補全,因此這裡刻意不列 ``.py``——多接一個伺服器只會 +讓同一份檔案有兩套補全來源。 +Python keeps using the existing jedi completion, so ``.py`` is deliberately not +listed here: adding a server for it would give one file two sources of +completion. +""" +from __future__ import annotations + +import shutil + +# 副檔名對應的語言伺服器指令 / The server command for each file suffix +DEFAULT_SERVERS: dict[str, list[str]] = { + ".ts": ["typescript-language-server", "--stdio"], + ".tsx": ["typescript-language-server", "--stdio"], + ".js": ["typescript-language-server", "--stdio"], + ".jsx": ["typescript-language-server", "--stdio"], + ".json": ["vscode-json-language-server", "--stdio"], + ".rs": ["rust-analyzer"], + ".go": ["gopls"], + ".c": ["clangd"], + ".cpp": ["clangd"], + ".h": ["clangd"], + ".hpp": ["clangd"], + ".lua": ["lua-language-server"], +} + +# 副檔名對應的 LSP language id / The LSP language id for each suffix +LANGUAGE_IDS: dict[str, str] = { + ".ts": "typescript", ".tsx": "typescriptreact", + ".js": "javascript", ".jsx": "javascriptreact", + ".json": "json", ".rs": "rust", ".go": "go", + ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp", + ".lua": "lua", +} + + +def merge_servers(stored: object) -> dict[str, list[str]]: + """ + 把使用者設定的伺服器併入預設值 + Merge user-configured servers over the defaults. + + 設定可能被手動編輯,因此型別不符的項目會被略過而不是讓整份設定失效。 + Settings may be hand-edited, so entries with the wrong type are skipped + rather than invalidating the whole mapping. + + :param stored: 從設定讀出的值,任何型別 / the stored value, any type + :return: 副檔名對應指令 / suffix -> server command + """ + servers = {suffix: list(command) for suffix, command in DEFAULT_SERVERS.items()} + if not isinstance(stored, dict): + return servers + for suffix, command in stored.items(): + if not isinstance(suffix, str) or not suffix.startswith("."): + continue + if isinstance(command, list) and command and all( + isinstance(part, str) for part in command): + servers[suffix.lower()] = list(command) + return servers + + +def server_command(suffix: str, servers: dict[str, list[str]] | None = None) -> list[str] | None: + """ + 取得某個副檔名對應的伺服器指令 + The server command for a file suffix. + + 只有指令真的存在於系統上才會回傳,因為沒安裝的伺服器啟動只會失敗。 + A command is only returned when it actually exists on the system, since + starting a server that is not installed would only fail. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :param servers: 伺服器對照表,``None`` 表示用預設 / the mapping, or ``None`` for the default + :return: 指令,沒有對應或沒安裝時為 ``None`` / the command, or ``None`` + """ + table = servers if servers is not None else DEFAULT_SERVERS + command = table.get(suffix.lower()) + if not command: + return None + if shutil.which(command[0]) is None: + return None + return list(command) + + +def language_id(suffix: str) -> str: + """ + 取得副檔名對應的 LSP language id + The LSP language id for a file suffix. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: language id,未知時去掉點當作 id / the id, or the bare suffix when unknown + """ + return LANGUAGE_IDS.get(suffix.lower(), suffix.lower().lstrip(".")) diff --git a/je_editor/utils/lsp/lsp_protocol.py b/je_editor/utils/lsp/lsp_protocol.py new file mode 100644 index 00000000..1e3ea67d --- /dev/null +++ b/je_editor/utils/lsp/lsp_protocol.py @@ -0,0 +1,189 @@ +""" +Language Server Protocol 的訊息編解碼 +Encode and decode Language Server Protocol messages. + +LSP 走 JSON-RPC,每則訊息前面有 ``Content-Length`` 標頭;從管線讀到的資料可能 +切在任意位置,因此讀取端要能把不完整的訊息留著等下一批資料。 +LSP speaks JSON-RPC with a ``Content-Length`` header before each message. Data +arrives from a pipe split at arbitrary points, so the reader has to hold an +incomplete message until the rest turns up. + +純邏輯:不啟動任何程序,因此可以直接餵位元組測試。 +Pure logic: it starts no process, so it can be tested by feeding it bytes. +""" +from __future__ import annotations + +import json + +# 標頭與內容之間的分隔 / What separates the header from the body +HEADER_SEPARATOR = b"\r\n\r\n" +# 內容長度標頭 / The content-length header +CONTENT_LENGTH_HEADER = b"Content-Length:" + + +def encode_message(payload: dict) -> bytes: + """ + 把一則訊息編碼成 LSP 的傳輸格式 + Encode one message in the framing LSP uses on the wire. + + :param payload: JSON-RPC 訊息內容 / the JSON-RPC message + :return: 可直接寫入管線的位元組 / bytes ready to write to the pipe + """ + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + return header + body + + +def _content_length(header: bytes) -> int | None: + """讀出標頭中的內容長度 / Read the content length out of a header block.""" + for line in header.split(b"\r\n"): + if line.lower().startswith(CONTENT_LENGTH_HEADER.lower()): + try: + return int(line.split(b":", 1)[1].strip()) + except ValueError: + return None + return None + + +class MessageReader: + """ + 把陸續讀到的位元組組回一則則訊息 + Reassemble whole messages from bytes as they arrive. + """ + + def __init__(self) -> None: + self._buffer = b"" + + def feed(self, data: bytes) -> list[dict]: + """ + 餵入剛讀到的資料,取出其中完整的訊息 + Feed in what was just read and take out every complete message. + + 內容長度不合理或內容不是 JSON 時,該則訊息會被丟掉而不是拋出例外—— + 伺服器輸出異常不該讓編輯器停擺。 + A nonsensical length or a body that is not JSON drops that message rather + than raising: a misbehaving server must not stall the editor. + + :param data: 剛讀到的位元組 / the bytes just read + :return: 這次能組出的完整訊息 / the messages that are now complete + """ + self._buffer += data + messages: list[dict] = [] + while True: + separator = self._buffer.find(HEADER_SEPARATOR) + if separator < 0: + break + header = self._buffer[:separator] + length = _content_length(header) + body_start = separator + len(HEADER_SEPARATOR) + if length is None: + # 標頭讀不出長度:丟掉這段標頭,繼續找下一則 + # No usable length: drop this header and look for the next message + self._buffer = self._buffer[body_start:] + continue + if len(self._buffer) < body_start + length: + break # 內容還沒到齊 / the body has not all arrived yet + body = self._buffer[body_start:body_start + length] + self._buffer = self._buffer[body_start + length:] + try: + messages.append(json.loads(body.decode("utf-8"))) + except (ValueError, UnicodeDecodeError): + continue + return messages + + @property + def pending_bytes(self) -> int: + """還沒組成訊息的位元組數 / How many bytes are still waiting.""" + return len(self._buffer) + + +def request(request_id: int, method: str, params: dict | None = None) -> dict: + """ + 組出一則 JSON-RPC 請求 + Build a JSON-RPC request. + + :param request_id: 請求編號 / the request's id + :param method: 方法名稱 / the method to call + :param params: 參數 / the parameters + :return: 請求訊息 / the request message + """ + return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}} + + +def notification(method: str, params: dict | None = None) -> dict: + """ + 組出一則 JSON-RPC 通知(不需要回應) + Build a JSON-RPC notification, which expects no reply. + + :param method: 方法名稱 / the method to call + :param params: 參數 / the parameters + :return: 通知訊息 / the notification message + """ + return {"jsonrpc": "2.0", "method": method, "params": params or {}} + + +def file_uri(path: str) -> str: + """ + 把檔案路徑轉成 LSP 使用的 URI + Turn a file path into the URI form LSP uses. + + :param path: 檔案路徑 / the file path + :return: ``file://`` 開頭的 URI / a ``file://`` URI + """ + normalised = str(path).replace("\\", "/") + if normalised.startswith("/"): + return f"file://{normalised}" + return f"file:///{normalised}" + + +def completion_labels(response: object) -> list[str]: + """ + 從 completion 回應取出候選字 + Take the candidate words out of a completion response. + + 回應可能是清單,也可能是 ``{"items": [...]}``,兩種形式都要接受。 + A response may be a plain list or ``{"items": [...]}``, and both are accepted. + + :param response: 伺服器的回應內容 / the server's result + :return: 候選字,去重且保留順序 / the candidates, unique and in order + """ + items = response.get("items") if isinstance(response, dict) else response + if not isinstance(items, list): + return [] + labels: list[str] = [] + for item in items: + label = item.get("label") if isinstance(item, dict) else item + if isinstance(label, str) and label and label not in labels: + labels.append(label) + return labels + + +def diagnostic_entries(params: object) -> list[dict]: + """ + 從 publishDiagnostics 通知取出診斷 + Take the diagnostics out of a ``publishDiagnostics`` notification. + + :param params: 通知的參數 / the notification's parameters + :return: 每筆診斷的 ``行、欄、訊息`` / each diagnostic's line, column and message + """ + if not isinstance(params, dict): + return [] + raw = params.get("diagnostics") + if not isinstance(raw, list): + return [] + entries: list[dict] = [] + for item in raw: + if not isinstance(item, dict): + continue + start = (item.get("range") or {}).get("start") or {} + message = item.get("message") + if not isinstance(message, str): + continue + entries.append({ + # LSP 的行列是 0 起算,編輯器用 1 起算 + # LSP counts lines and columns from zero; the editor counts from one + "line": int(start.get("line", 0)) + 1, + "column": int(start.get("character", 0)) + 1, + "message": message, + }) + return entries diff --git a/test/test_lsp.py b/test/test_lsp.py new file mode 100644 index 00000000..19af6e59 --- /dev/null +++ b/test/test_lsp.py @@ -0,0 +1,273 @@ +"""Tests for the LSP wire protocol, the server registry, and the client.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lsp.language_servers import ( + DEFAULT_SERVERS, + language_id, + merge_servers, + server_command, +) +from je_editor.utils.lsp.lsp_protocol import ( + MessageReader, + completion_labels, + diagnostic_entries, + encode_message, + file_uri, + notification, + request, +) + + +class TestEncoding: + def test_message_starts_with_a_content_length(self): + encoded = encode_message({"jsonrpc": "2.0", "method": "ping"}) + assert encoded.startswith(b"Content-Length: ") + + def test_length_matches_the_body(self): + encoded = encode_message({"method": "ping"}) + header, body = encoded.split(b"\r\n\r\n", 1) + declared = int(header.split(b":")[1].strip()) + assert declared == len(body) + + def test_body_is_the_json_payload(self): + encoded = encode_message({"method": "ping", "id": 3}) + body = encoded.split(b"\r\n\r\n", 1)[1] + assert json.loads(body) == {"method": "ping", "id": 3} + + def test_non_ascii_survives(self): + encoded = encode_message({"text": "中文"}) + body = encoded.split(b"\r\n\r\n", 1)[1] + assert json.loads(body)["text"] == "中文" + + +class TestMessageReader: + def test_one_whole_message(self): + reader = MessageReader() + assert reader.feed(encode_message({"id": 1})) == [{"id": 1}] + + def test_two_messages_in_one_read(self): + reader = MessageReader() + data = encode_message({"id": 1}) + encode_message({"id": 2}) + assert [message["id"] for message in reader.feed(data)] == [1, 2] + + def test_a_message_split_across_reads(self): + reader = MessageReader() + encoded = encode_message({"id": 7}) + assert reader.feed(encoded[:10]) == [] + assert reader.feed(encoded[10:]) == [{"id": 7}] + + def test_a_body_split_from_its_header(self): + reader = MessageReader() + header, body = encode_message({"id": 9}).split(b"\r\n\r\n", 1) + assert reader.feed(header + b"\r\n\r\n") == [] + assert reader.feed(body) == [{"id": 9}] + + def test_pending_bytes_are_reported(self): + reader = MessageReader() + reader.feed(encode_message({"id": 1})[:8]) + assert reader.pending_bytes > 0 + + def test_a_body_that_is_not_json_is_dropped(self): + reader = MessageReader() + broken = b"Content-Length: 5\r\n\r\nnotjs" + assert reader.feed(broken) == [] + + def test_a_header_without_a_length_is_skipped(self): + reader = MessageReader() + data = b"Bad-Header: 1\r\n\r\n" + encode_message({"id": 4}) + assert reader.feed(data) == [{"id": 4}] + + def test_empty_feed(self): + assert MessageReader().feed(b"") == [] + + +class TestMessageBuilders: + def test_request_has_an_id(self): + assert request(5, "textDocument/completion")["id"] == 5 + + def test_notification_has_no_id(self): + assert "id" not in notification("initialized") + + def test_posix_file_uri(self): + assert file_uri("/home/user/app.ts") == "file:///home/user/app.ts" + + def test_windows_file_uri(self): + assert file_uri("D:\\project\\app.ts") == "file:///D:/project/app.ts" + + +class TestResultParsing: + def test_completion_list_form(self): + assert completion_labels([{"label": "alpha"}, {"label": "beta"}]) == ["alpha", "beta"] + + def test_completion_items_form(self): + assert completion_labels({"items": [{"label": "gamma"}]}) == ["gamma"] + + def test_plain_string_items(self): + assert completion_labels(["delta"]) == ["delta"] + + def test_duplicates_are_dropped(self): + assert completion_labels([{"label": "same"}, {"label": "same"}]) == ["same"] + + def test_unusable_result(self): + assert completion_labels(None) == [] + assert completion_labels({"unexpected": 1}) == [] + + def test_diagnostics_are_converted_to_one_based_lines(self): + entries = diagnostic_entries({"diagnostics": [ + {"range": {"start": {"line": 4, "character": 2}}, "message": "boom"}]}) + assert entries == [{"line": 5, "column": 3, "message": "boom"}] + + def test_diagnostics_without_a_message_are_skipped(self): + assert diagnostic_entries({"diagnostics": [{"range": {}}]}) == [] + + def test_unusable_diagnostics_params(self): + assert diagnostic_entries(None) == [] + + +class TestServerRegistry: + def test_python_is_not_served_by_lsp(self): + # Python keeps jedi; two completion sources for one file helps nobody. + assert ".py" not in DEFAULT_SERVERS + + def test_known_suffixes_have_commands(self): + assert DEFAULT_SERVERS[".ts"][0] == "typescript-language-server" + + def test_user_servers_override_the_defaults(self): + merged = merge_servers({".ts": ["my-server", "--stdio"], ".zig": ["zls"]}) + assert merged[".ts"] == ["my-server", "--stdio"] + assert merged[".zig"] == ["zls"] + + def test_hand_edited_entries_are_skipped(self): + merged = merge_servers({"ts": ["no-dot"], ".x": "not-a-list", ".y": []}) + assert "ts" not in merged and ".x" not in merged and ".y" not in merged + + def test_non_dict_falls_back(self): + assert merge_servers("nonsense")[".go"] == ["gopls"] + + def test_an_uninstalled_server_is_not_offered(self): + with patch("je_editor.utils.lsp.language_servers.shutil.which", return_value=None): + assert server_command(".ts") is None + + def test_an_installed_server_is_offered(self): + with patch( + "je_editor.utils.lsp.language_servers.shutil.which", return_value="/usr/bin/x" + ): + assert server_command(".ts")[0] == "typescript-language-server" + + def test_an_unknown_suffix_has_no_server(self): + assert server_command(".unknownsuffix") is None + + def test_language_ids(self): + assert language_id(".ts") == "typescript" + assert language_id(".RS") == "rust" + + def test_unknown_language_id_falls_back_to_the_suffix(self): + assert language_id(".zig") == "zig" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +class TestLspClient: + def test_starts_stopped(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + assert client.running is False + client.stop() + + def test_a_missing_server_does_not_start(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + with patch("je_editor.pyside_ui.code.lsp.lsp_client.server_command", return_value=None): + assert client.start_for("app.ts") is False + client.stop() + + def test_sending_without_a_server_is_refused(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + assert client.did_open("text") is False + assert client.request_completion(0, 0) is False + client.stop() + + def test_completion_results_are_emitted(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + received = [] + client.completions_ready.connect(received.append) + client._pending_completion_id = 12 + client.handle_message({"id": 12, "result": [{"label": "answer"}]}) + assert received == [["answer"]] + client.stop() + + def test_a_reply_to_another_request_is_ignored(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + received = [] + client.completions_ready.connect(received.append) + client._pending_completion_id = 12 + client.handle_message({"id": 99, "result": [{"label": "stale"}]}) + assert received == [] + client.stop() + + def test_diagnostics_are_emitted(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + received = [] + client.diagnostics_ready.connect(received.append) + client.handle_message({ + "method": "textDocument/publishDiagnostics", + "params": {"diagnostics": [ + {"range": {"start": {"line": 0, "character": 0}}, "message": "oops"}]}, + }) + assert received[0][0]["message"] == "oops" + client.stop() + + def test_stopping_twice_is_safe(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + client.stop() + client.stop() + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.lsp_client.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestEditorIntegration: + def test_python_files_start_no_server(self, editor): + editor.current_file = "module.py" + assert editor.start_language_server() is False + + def test_a_file_without_a_server_starts_none(self, editor): + editor.current_file = "notes.unknownsuffix" + assert editor.start_language_server() is False + + def test_no_file_starts_none(self, editor): + editor.current_file = None + assert editor.start_language_server() is False + + def test_completion_falls_back_to_jedi_without_a_server(self, editor): + assert editor.request_language_server_completion() is False From 5a98f937b2057903aedae450e6e00874023530fe Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 20:40:59 +0800 Subject: [PATCH 17/36] Stop background workers cleanly, and send test keys through QTest The full test suite had started crashing the interpreter partway through (0xC0000409), always at the first test that spins the event loop. Bisecting placed it at the multi-caret commit, and the cause was in its tests: a QKeyEvent built in Python and handed straight to a handler leaves Qt holding an object Python is free to collect, which is only noticed once the queued events are processed. Those tests now send keys through QTest, which keeps ownership with Qt. Two real defects surfaced while tracking that down: - Each worker was connected with a lambda, which gives Qt no receiver to disconnect, so a queued result could arrive at an editor already destroyed. The lint, git-baseline and blame managers are now QObject children of their editor and connect bound methods instead. - A manager kept holding its worker after finished/deleteLater had removed the C++ object, so the next stop() raised RuntimeError. Each now drops the reference when the worker finishes and tolerates one already gone. The git baseline and blame readers also close the GitPython Repo they open, which was leaving a pair of long-lived git subprocesses behind on every read. --- je_editor/git_client/file_baseline.py | 27 ++++++---- je_editor/git_client/file_blame.py | 26 ++++++--- .../pyside_ui/code/git_diff/blame_manager.py | 44 +++++++++++---- .../code/git_diff/diff_marker_manager.py | 53 ++++++++++++++----- je_editor/pyside_ui/code/lint/lint_manager.py | 53 ++++++++++++++----- test/test_multi_cursor.py | 24 ++++++--- test/test_snippets.py | 9 ++-- 7 files changed, 170 insertions(+), 66 deletions(-) diff --git a/je_editor/git_client/file_baseline.py b/je_editor/git_client/file_baseline.py index 3526d2e8..7f5119c4 100644 --- a/je_editor/git_client/file_baseline.py +++ b/je_editor/git_client/file_baseline.py @@ -52,14 +52,19 @@ def baseline_text(file_path: str | Path) -> str | None: repo = open_repository(file_path) if repo is None: return None - try: - relative = Path(file_path).resolve().relative_to(Path(repo.working_tree_dir).resolve()) - blob = repo.head.commit.tree / relative.as_posix() - text = blob.data_stream.read().decode("utf-8") - return text.replace("\r\n", "\n").replace("\r", "\n") - except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): - # 新檔案、尚無提交、或路徑不在工作區內 / New file, no commit yet, or outside the tree - return None - except UnicodeDecodeError: - jeditor_logger.debug("file_baseline: %s is not utf-8 text", file_path) - return None + # 每個 Repo 都會開著常駐的 git 子程序,用完一定要關掉 + # Every Repo keeps long-lived git subprocesses open, so it must be closed + with repo: + try: + relative = Path(file_path).resolve().relative_to( + Path(repo.working_tree_dir).resolve()) + blob = repo.head.commit.tree / relative.as_posix() + text = blob.data_stream.read().decode("utf-8") + return text.replace("\r\n", "\n").replace("\r", "\n") + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): + # 新檔案、尚無提交、或路徑不在工作區內 + # New file, no commit yet, or outside the tree + return None + except UnicodeDecodeError: + jeditor_logger.debug("file_baseline: %s is not utf-8 text", file_path) + return None diff --git a/je_editor/git_client/file_blame.py b/je_editor/git_client/file_blame.py index fbe91262..456d44a4 100644 --- a/je_editor/git_client/file_blame.py +++ b/je_editor/git_client/file_blame.py @@ -64,13 +64,25 @@ def blame_lines(file_path: str | Path) -> dict[int, BlameLine]: repo = open_repository(file_path) if repo is None: return {} - try: - relative = Path(file_path).resolve().relative_to(Path(repo.working_tree_dir).resolve()) - entries = repo.blame("HEAD", relative.as_posix()) - except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError) as error: - # 未提交過、不在工作區內、或沒有 HEAD / Never committed, outside the tree, or no HEAD - jeditor_logger.debug(f"file_blame: no blame for {file_path}: {error!r}") - return {} + # 每個 Repo 都會開著常駐的 git 子程序,用完一定要關掉 + # Every Repo keeps long-lived git subprocesses open, so it must be closed + with repo: + try: + relative = Path(file_path).resolve().relative_to( + Path(repo.working_tree_dir).resolve()) + entries = repo.blame("HEAD", relative.as_posix()) + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError) as error: + # 未提交過、不在工作區內、或沒有 HEAD + # Never committed, outside the tree, or no HEAD + jeditor_logger.debug(f"file_blame: no blame for {file_path}: {error!r}") + return {} + # 提交資料在 Repo 關閉前就要讀完,關閉之後就取不到了 + # The commit data is read before the Repo closes; afterwards it is gone + return _annotations_from(entries) + + +def _annotations_from(entries: object) -> dict[int, BlameLine]: + """把 GitPython 的 blame 結果轉成逐行標註 / Turn a blame result into per-line notes.""" annotations: dict[int, BlameLine] = {} line_number = 0 for entry in entries or []: diff --git a/je_editor/pyside_ui/code/git_diff/blame_manager.py b/je_editor/pyside_ui/code/git_diff/blame_manager.py index af18b65f..d7863ae2 100644 --- a/je_editor/pyside_ui/code/git_diff/blame_manager.py +++ b/je_editor/pyside_ui/code/git_diff/blame_manager.py @@ -10,7 +10,7 @@ from pathlib import Path -from PySide6.QtCore import QThread, Signal +from PySide6.QtCore import QObject, QThread, Signal from je_editor.git_client.file_blame import BlameLine, blame_lines @@ -36,16 +36,21 @@ def run(self) -> None: self.loaded.emit(blame_lines(self._file_path)) -class BlameManager: +class BlameManager(QObject): """ 追蹤行內 blame 的開關與內容 Track whether inline blame is shown, and what it says. + + 是編輯器的 QObject 子物件,取得執行緒的訊號才有接收者可斷開。 + A QObject child of the editor, so the loader's signal has a receiver Qt can + disconnect. """ def __init__(self, code_edit) -> None: """ :param code_edit: 這些標註所屬的編輯器 / the editor being annotated """ + super().__init__(code_edit) self._code_edit = code_edit self._annotations: dict[int, BlameLine] = {} self._enabled = False @@ -99,16 +104,29 @@ def toggle(self, file_path: str | Path | None) -> bool: if file_path is None: return False self._enabled = True - loader = BlameLoader(file_path) + loader = BlameLoader(file_path, self) self._loader = loader - loader.loaded.connect(lambda annotations: self._on_loaded(loader, annotations)) + loader.loaded.connect(self._on_loaded) + # 先放掉參考再刪除,避免之後對已刪除的物件呼叫方法 + # Drop the reference before deleting, so nothing calls a deleted object + loader.finished.connect(self._on_loader_finished) loader.finished.connect(loader.deleteLater) loader.start() return True - def _on_loaded(self, loader: BlameLoader, annotations: dict) -> None: - """套用背景取得的結果 / Apply annotations that finished loading.""" - if loader is not self._loader: + def _on_loader_finished(self) -> None: + """取得結束後放掉參考 / Let go of the loader once it has finished.""" + if self.sender() is self._loader: + self._loader = None + + def _on_loaded(self, annotations: dict) -> None: + """ + 套用背景取得的結果 + Apply annotations that finished loading. + + 只接受目前這個 loader 的結果 / Only the current loader's result is accepted. + """ + if self.sender() is not self._loader: return self.set_annotations(annotations) self._code_edit.viewport().update() @@ -116,6 +134,12 @@ def _on_loaded(self, loader: BlameLoader, annotations: dict) -> None: def stop(self) -> None: """結束仍在進行的取得 / Stop a fetch that is still running.""" loader, self._loader = self._loader, None - if loader is not None and loader.isRunning(): - loader.blockSignals(True) - loader.wait() + if loader is None: + return + try: + if loader.isRunning(): + loader.blockSignals(True) + loader.wait() + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + return diff --git a/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py index 72f33f5b..208440e5 100644 --- a/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py +++ b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py @@ -12,7 +12,7 @@ from pathlib import Path -from PySide6.QtCore import QThread, Signal +from PySide6.QtCore import QObject, QThread, Signal from je_editor.git_client.file_baseline import baseline_text from je_editor.utils.file_diff.line_status import ( @@ -46,16 +46,23 @@ def run(self) -> None: self.loaded.emit(baseline_text(self._file_path)) -class DiffMarkerManager: +class DiffMarkerManager(QObject): """ 追蹤緩衝區相對於已提交版本的逐行差異 Track how the buffer differs, line by line, from its committed version. + + 是編輯器的 QObject 子物件,讀取執行緒的訊號才有接收者可斷開;否則編輯器銷毀 + 後,佇列中的結果會打到已經釋放的物件上。 + A QObject child of the editor, so the loader's signal has a receiver Qt can + disconnect; otherwise a queued result would arrive at an editor that is + already gone. """ def __init__(self, code_edit) -> None: """ :param code_edit: 這些標記所屬的編輯器 / the editor these markers belong to """ + super().__init__(code_edit) self._code_edit = code_edit self._baseline: str | None = None self._statuses: dict[int, str] = {} @@ -166,18 +173,31 @@ def load_baseline(self, file_path: str | Path | None) -> None: if file_path is None: self.clear() return - loader = BaselineLoader(file_path) + loader = BaselineLoader(file_path, self) self._loader = loader - # 只接受目前這個 loader 的結果,換檔案時舊結果就過期了 - # Accept a result only from the current loader; switching files makes an - # in-flight read stale. - loader.loaded.connect(lambda text: self._on_loaded(loader, text)) + loader.loaded.connect(self._on_loaded) + # 先放掉參考再刪除:留著已被刪除的 wrapper,之後呼叫它就會拋 RuntimeError + # Drop the reference before deleting: keeping a wrapper whose C++ object + # is gone makes the next call on it raise RuntimeError + loader.finished.connect(self._on_loader_finished) loader.finished.connect(loader.deleteLater) loader.start() - def _on_loaded(self, loader: BaselineLoader, text: str | None) -> None: - """套用背景讀取的結果 / Apply a baseline that finished loading.""" - if loader is not self._loader: + def _on_loader_finished(self) -> None: + """讀取結束後放掉參考 / Let go of the loader once it has finished.""" + if self.sender() is self._loader: + self._loader = None + + def _on_loaded(self, text: str | None) -> None: + """ + 套用背景讀取的結果 + Apply a baseline that finished loading. + + 只接受目前這個 loader 的結果,換檔案時舊結果就過期了。 + Only the current loader's result is accepted; switching files makes an + in-flight read stale. + """ + if self.sender() is not self._loader: return self.set_baseline(text) self._code_edit.line_number.update() @@ -188,6 +208,13 @@ def stop(self) -> None: Stop a baseline read that is still running. """ loader, self._loader = self._loader, None - if loader is not None and loader.isRunning(): - loader.quit() - loader.wait() + if loader is None: + return + try: + if loader.isRunning(): + loader.quit() + loader.wait() + except RuntimeError: + # 它已經跑完並被刪除了,沒有東西要停 + # It already finished and was deleted, so there is nothing to stop + return diff --git a/je_editor/pyside_ui/code/lint/lint_manager.py b/je_editor/pyside_ui/code/lint/lint_manager.py index 960b5cba..99f0ed8f 100644 --- a/je_editor/pyside_ui/code/lint/lint_manager.py +++ b/je_editor/pyside_ui/code/lint/lint_manager.py @@ -10,7 +10,7 @@ from pathlib import Path -from PySide6.QtCore import QThread, Signal +from PySide6.QtCore import QObject, QThread, Signal from je_editor.code_scan.ruff_lint import is_lintable, lint_text from je_editor.utils.lint.ruff_diagnostics import Diagnostic, message_for_line @@ -39,16 +39,25 @@ def run(self) -> None: self.linted.emit(lint_text(self._text, self._file_path)) -class LintManager: +class LintManager(QObject): """ 追蹤編輯器目前的診斷 Track the diagnostics currently reported for an editor. + + 是編輯器的 QObject 子物件:worker 的訊號要接到「有接收者」的槽,編輯器被銷毀 + 時 Qt 才會自動斷開。用 lambda 接收會讓 Qt 找不到接收者,佇列中的訊號之後就會 + 打到已經釋放的編輯器上。 + A QObject child of the editor: a worker's signal must reach a slot that has a + receiver, so Qt disconnects it when the editor is destroyed. A lambda gives Qt + no receiver, which lets a queued signal arrive at an editor that is already + gone. """ def __init__(self, code_edit) -> None: """ :param code_edit: 這些診斷所屬的編輯器 / the editor being linted """ + super().__init__(code_edit) self._code_edit = code_edit self._diagnostics: list[Diagnostic] = [] self._worker: LintWorker | None = None @@ -114,19 +123,31 @@ def request(self, file_path: str | Path | None) -> bool: self.stop() if not is_lintable(file_path): return False - worker = LintWorker(self._code_edit.toPlainText(), file_path) + worker = LintWorker(self._code_edit.toPlainText(), file_path, self) self._worker = worker - # 只接受目前這個 worker 的結果;換檔或再次輸入都會讓舊結果過期 - # Accept a result only from the current worker: switching files or typing - # again makes an in-flight check stale. - worker.linted.connect(lambda diagnostics: self._on_linted(worker, diagnostics)) + worker.linted.connect(self._on_linted) + # 先放掉參考再刪除,避免之後對已刪除的物件呼叫方法 + # Drop the reference before deleting, so nothing calls a deleted object + worker.finished.connect(self._on_worker_finished) worker.finished.connect(worker.deleteLater) worker.start() return True - def _on_linted(self, worker: LintWorker, diagnostics: list) -> None: - """套用背景檢查的結果 / Apply diagnostics that finished arriving.""" - if worker is not self._worker: + def _on_worker_finished(self) -> None: + """檢查結束後放掉參考 / Let go of the worker once it has finished.""" + if self.sender() is self._worker: + self._worker = None + + def _on_linted(self, diagnostics: list) -> None: + """ + 套用背景檢查的結果 + Apply diagnostics that finished arriving. + + 只接受目前這個 worker 的結果;換檔或再次輸入都會讓舊結果過期。 + Only the current worker's result is accepted: switching files or typing + again makes an in-flight check stale. + """ + if self.sender() is not self._worker: return if self.set_diagnostics(diagnostics): self._code_edit.refresh_lint_display() @@ -134,6 +155,12 @@ def _on_linted(self, worker: LintWorker, diagnostics: list) -> None: def stop(self) -> None: """結束仍在執行的檢查 / Stop a check that is still running.""" worker, self._worker = self._worker, None - if worker is not None and worker.isRunning(): - worker.blockSignals(True) - worker.wait() + if worker is None: + return + try: + if worker.isRunning(): + worker.blockSignals(True) + worker.wait() + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + return diff --git a/test/test_multi_cursor.py b/test/test_multi_cursor.py index bbcf07b8..387ff291 100644 --- a/test/test_multi_cursor.py +++ b/test/test_multi_cursor.py @@ -4,8 +4,8 @@ from unittest.mock import MagicMock, patch import pytest -from PySide6.QtCore import QEvent, Qt -from PySide6.QtGui import QKeyEvent +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication from je_editor.utils.multi_cursor.cursor_positions import ( @@ -97,8 +97,18 @@ def _select_all(editor) -> None: def _type(editor, text: str) -> None: - editor.keyPressEvent( - QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_A, Qt.KeyboardModifier.NoModifier, text)) + """Send a keystroke the way Qt does, so it owns the event object. + + Building a QKeyEvent in Python and handing it to the handler leaves Qt + holding an object Python may collect, which crashes the interpreter later + when the event queue is processed. + """ + QTest.keyClicks(editor, text) + + +def _press(editor, key) -> None: + """Send one non-printable key.""" + QTest.keyClick(editor, key) class TestMultiCursorEditing: @@ -131,8 +141,7 @@ def test_backspace_reaches_every_line(self, editor): editor.setPlainText("one!\ntwo!\nthree!") _select_all(editor) editor.add_cursors_to_selected_lines() - editor.keyPressEvent(QKeyEvent( - QEvent.Type.KeyPress, Qt.Key.Key_Backspace, Qt.KeyboardModifier.NoModifier)) + _press(editor, Qt.Key.Key_Backspace) assert editor.toPlainText() == "one\ntwo\nthree" def test_multi_cursor_edit_is_one_undo_step(self, editor): @@ -147,8 +156,7 @@ def test_escape_clears_the_extra_cursors(self, editor): editor.setPlainText("one\ntwo") _select_all(editor) editor.add_cursors_to_selected_lines() - editor.keyPressEvent(QKeyEvent( - QEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier)) + _press(editor, Qt.Key.Key_Escape) assert editor.multi_cursor_manager.active is False def test_clearing_leaves_typing_to_the_primary_caret(self, editor): diff --git a/test/test_snippets.py b/test/test_snippets.py index 3ce3d711..6bd2f9a3 100644 --- a/test/test_snippets.py +++ b/test/test_snippets.py @@ -5,8 +5,8 @@ from unittest.mock import MagicMock, patch import pytest -from PySide6.QtGui import QKeyEvent -from PySide6.QtCore import QEvent, Qt +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication from je_editor.pyside_ui.code.snippets.snippet_manager import load_snippets @@ -110,8 +110,9 @@ def editor(app): def _press_tab(editor) -> None: - editor.keyPressEvent( - QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier, "\t")) + # Sent through QTest so Qt owns the event object; a QKeyEvent built here + # could be collected while Qt still holds it. + QTest.keyClick(editor, Qt.Key.Key_Tab) class TestSnippetExpansionInEditor: From 69a714785d10a623630e091b963031d3f3366b20 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 20:41:14 +0800 Subject: [PATCH 18/36] Show language-server diagnostics in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LSP client already emitted diagnostics_ready, but nothing was connected to it, so errors from a language server were fetched and then dropped. They now take the same path as ruff''s findings: the same wavy underlines, the same tooltip, the same problems panel — a non-Python file is no longer a blank slate. The two sources report different fields, so both are converted into one diagnostic shape and the display never has to know which tool found what. The ruff pass, which skips non-Python files, no longer clears the diagnostics on its way out when a server is supplying them; otherwise every pause in typing wiped what the server had just reported. CodeEditor also stops its workers in closeEvent, so a directly created editor releases its threads the way an editor tab already did. --- .../code_edit_plaintext.py | 57 ++++++- je_editor/utils/lint/ruff_diagnostics.py | 48 ++++++ je_editor/utils/lsp/lsp_protocol.py | 53 +++++-- test/test_lsp.py | 4 +- test/test_lsp_diagnostics.py | 142 ++++++++++++++++++ 5 files changed, 284 insertions(+), 20 deletions(-) create mode 100644 test/test_lsp_diagnostics.py 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 e02a9d7b..1beb62e3 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 @@ -31,6 +31,7 @@ from je_editor.utils.indentation.indent_guides import ( guide_columns, trailing_whitespace_start ) +from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries from je_editor.utils.line_ops.line_operations import ( join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines ) @@ -167,6 +168,10 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # Lint state, created before the first highlight because highlighting # appends the diagnostic underlines self.lint_manager = LintManager(self) + # 語言伺服器連線;lint 會問它是否正在提供診斷,所以要一起先建立 + # The language server connection: the lint pass asks whether it is + # supplying diagnostics, so it has to exist by then too + self.lsp_client = LspClient(self) # 定義哪些按鍵不會觸發補全視窗 self.skip_popup_behavior_list = [ @@ -302,10 +307,11 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.multi_cursor_manager = MultiCursorManager(self) self._register_multi_cursor_actions() - # 非 Python 檔的補全交給語言伺服器;Python 仍走 jedi - # Non-Python files complete through a language server; Python keeps jedi - self.lsp_client = LspClient(self) + # 非 Python 檔的補全與診斷都交給語言伺服器;Python 仍走 jedi 與 ruff + # A non-Python file gets its completion and diagnostics from a language + # server; Python keeps jedi and ruff self.lsp_client.completions_ready.connect(self.set_complete) + self.lsp_client.diagnostics_ready.connect(self.apply_server_diagnostics) self.start_language_server() def reset_highlighter(self) -> None: @@ -751,9 +757,31 @@ def request_lint(self) -> bool: whether a check started; non-Python files are not linted """ started = self.lint_manager.request(self.current_file) - if not started and self.lint_manager.clear(): + if started or self.lsp_client.running: + # 診斷由語言伺服器提供時不能清掉,否則每次輸入停下來就會被抹掉 + # Diagnostics from a language server must survive: clearing them here + # would wipe them at every pause in typing + return started + if self.lint_manager.clear(): self.refresh_lint_display() - return started + return False + + def apply_server_diagnostics(self, entries: list) -> bool: + """ + 把語言伺服器回報的診斷顯示出來 + Show the diagnostics a language server reported. + + 與 ruff 的診斷走同一條顯示路徑,因此非 Python 檔也有波浪底線與問題面板。 + These take the same path as ruff's, so a non-Python file gets the same + underlines and the same problems panel. + + :param entries: 伺服器回報的診斷 / the diagnostics the server reported + :return: 顯示內容有變時為 ``True`` / ``True`` when the display changed + """ + if not self.lint_manager.set_diagnostics(diagnostics_from_entries(entries)): + return False + self.refresh_lint_display() + return True def refresh_lint_display(self) -> None: """重畫診斷底線 / Repaint the diagnostic underlines.""" @@ -2202,6 +2230,25 @@ def keyPressEvent(self, event: QKeyEvent) -> None: self.completer.popup().close() self._complete_timer.start() + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + """ + 關閉前停掉所有背景工作 + Stop every background worker before closing. + + lint、git 基準、blame 與語言伺服器都各自持有執行緒或子程序;編輯器被銷毀 + 時若它們還在跑,Qt 會在之後某個時間點刪掉仍在執行的物件而當掉,而當掉的 + 位置離真正的原因很遠。 + The lint pass, the git baseline, blame and the language server each hold a + thread or a subprocess. If they are still running when the editor is + destroyed, Qt tears down a live object later and crashes far away from the + actual cause. + """ + self.lint_manager.stop() + self.diff_marker_manager.stop() + self.blame_manager.stop() + self.lsp_client.stop() + super().closeEvent(event) + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: """ 滑鼠點擊事件(Alt+點按用於多重游標) diff --git a/je_editor/utils/lint/ruff_diagnostics.py b/je_editor/utils/lint/ruff_diagnostics.py index 5dd96f4c..069b096f 100644 --- a/je_editor/utils/lint/ruff_diagnostics.py +++ b/je_editor/utils/lint/ruff_diagnostics.py @@ -103,6 +103,54 @@ def parse_ruff_json(output: str) -> list[Diagnostic]: return [diagnostic for diagnostic in diagnostics if diagnostic is not None] +def diagnostic_from_entry(entry: dict) -> Diagnostic | None: + """ + 把其他來源的診斷轉成同一種形式 + Convert a diagnostic from another source into the same shape. + + 語言伺服器回報的診斷與 ruff 的欄位不同,但編輯器只認得一種形式;統一之後 + 底線與問題面板就不必分辨診斷是誰報的。 + A language server reports different fields from ruff, but the editor knows + only one shape. Converting here means the underlines and the problems panel + never have to care which tool produced a finding. + + :param entry: 來源診斷,需含 ``line`` 與 ``message`` / the source diagnostic + :return: 統一形式的診斷,資料不足時為 ``None`` / the diagnostic, or ``None`` + """ + if not isinstance(entry, dict): + return None + message = entry.get("message") + line = entry.get("line") + if not isinstance(message, str) or not message: + return None + if not isinstance(line, int) or line < 1: + return None + column = entry.get("column") + column = column if isinstance(column, int) and column >= 1 else 1 + end_line = entry.get("end_line") + end_line = end_line if isinstance(end_line, int) and end_line >= line else line + end_column = entry.get("end_column") + end_column = end_column if isinstance(end_column, int) and end_column >= 1 else column + code = entry.get("code") + return Diagnostic( + line=line, column=column, end_line=end_line, end_column=end_column, + code=code if isinstance(code, str) and code else SYNTAX_ERROR_CODE, message=message) + + +def diagnostics_from_entries(entries: list) -> list[Diagnostic]: + """ + 批次轉換其他來源的診斷 + Convert a batch of diagnostics from another source. + + :param entries: 來源診斷清單 / the source diagnostics + :return: 可用的診斷 / the usable diagnostics + """ + if not isinstance(entries, list): + return [] + converted = [diagnostic_from_entry(entry) for entry in entries] + return [item for item in converted if item is not None] + + def diagnostics_by_line(diagnostics: list[Diagnostic]) -> dict[int, list[Diagnostic]]: """ 依行號分組 diff --git a/je_editor/utils/lsp/lsp_protocol.py b/je_editor/utils/lsp/lsp_protocol.py index 1e3ea67d..8788ab0f 100644 --- a/je_editor/utils/lsp/lsp_protocol.py +++ b/je_editor/utils/lsp/lsp_protocol.py @@ -164,7 +164,7 @@ def diagnostic_entries(params: object) -> list[dict]: Take the diagnostics out of a ``publishDiagnostics`` notification. :param params: 通知的參數 / the notification's parameters - :return: 每筆診斷的 ``行、欄、訊息`` / each diagnostic's line, column and message + :return: 每筆診斷的位置、代碼與訊息 / each diagnostic's range, code and message """ if not isinstance(params, dict): return [] @@ -173,17 +173,42 @@ def diagnostic_entries(params: object) -> list[dict]: return [] entries: list[dict] = [] for item in raw: - if not isinstance(item, dict): - continue - start = (item.get("range") or {}).get("start") or {} - message = item.get("message") - if not isinstance(message, str): - continue - entries.append({ - # LSP 的行列是 0 起算,編輯器用 1 起算 - # LSP counts lines and columns from zero; the editor counts from one - "line": int(start.get("line", 0)) + 1, - "column": int(start.get("character", 0)) + 1, - "message": message, - }) + entry = _diagnostic_entry(item) + if entry is not None: + entries.append(entry) return entries + + +def _position(raw: object, fallback_line: int = 0, fallback_column: int = 0) -> tuple[int, int]: + """讀出 LSP 位置並轉成 1 起算 / Read an LSP position, converted to 1-based.""" + if not isinstance(raw, dict): + return fallback_line + 1, fallback_column + 1 + line = raw.get("line") + column = raw.get("character") + return ( + (line if isinstance(line, int) and line >= 0 else fallback_line) + 1, + (column if isinstance(column, int) and column >= 0 else fallback_column) + 1, + ) + + +def _diagnostic_entry(item: object) -> dict | None: + """把一筆 LSP 診斷轉成編輯器用的形式 / Convert one LSP diagnostic for the editor.""" + if not isinstance(item, dict): + return None + message = item.get("message") + if not isinstance(message, str) or not message: + return None + span = item.get("range") if isinstance(item.get("range"), dict) else {} + line, column = _position(span.get("start")) + end_line, end_column = _position(span.get("end"), line - 1, column - 1) + code = item.get("code") + return { + # LSP 的行列是 0 起算,編輯器用 1 起算 + # LSP counts lines and columns from zero; the editor counts from one + "line": line, + "column": column, + "end_line": max(end_line, line), + "end_column": end_column, + "code": str(code) if isinstance(code, (str, int)) else "", + "message": message, + } diff --git a/test/test_lsp.py b/test/test_lsp.py index 19af6e59..06667991 100644 --- a/test/test_lsp.py +++ b/test/test_lsp.py @@ -121,7 +121,9 @@ def test_unusable_result(self): def test_diagnostics_are_converted_to_one_based_lines(self): entries = diagnostic_entries({"diagnostics": [ {"range": {"start": {"line": 4, "character": 2}}, "message": "boom"}]}) - assert entries == [{"line": 5, "column": 3, "message": "boom"}] + assert entries[0]["line"] == 5 + assert entries[0]["column"] == 3 + assert entries[0]["message"] == "boom" def test_diagnostics_without_a_message_are_skipped(self): assert diagnostic_entries({"diagnostics": [{"range": {}}]}) == [] diff --git a/test/test_lsp_diagnostics.py b/test/test_lsp_diagnostics.py new file mode 100644 index 00000000..2077f077 --- /dev/null +++ b/test/test_lsp_diagnostics.py @@ -0,0 +1,142 @@ +"""Tests for showing language-server diagnostics through the lint display.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lint.ruff_diagnostics import ( + SYNTAX_ERROR_CODE, + diagnostic_from_entry, + diagnostics_from_entries, +) +from je_editor.utils.lsp.lsp_protocol import diagnostic_entries + +SERVER_NOTIFICATION = { + "diagnostics": [ + { + "range": {"start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 9}}, + "message": "Cannot find name 'foo'", + "code": 2304, + } + ] +} + + +class TestServerDiagnosticShape: + def test_range_becomes_one_based(self): + entry = diagnostic_entries(SERVER_NOTIFICATION)[0] + assert entry["line"] == 4 and entry["column"] == 5 + assert entry["end_line"] == 4 and entry["end_column"] == 10 + + def test_numeric_code_is_kept_as_text(self): + assert diagnostic_entries(SERVER_NOTIFICATION)[0]["code"] == "2304" + + def test_entry_without_a_range_still_works(self): + entry = diagnostic_entries({"diagnostics": [{"message": "broken"}]})[0] + assert entry["line"] == 1 and entry["column"] == 1 + + def test_entry_without_a_message_is_dropped(self): + assert diagnostic_entries({"diagnostics": [{"range": {}}]}) == [] + + +class TestConversionToTheEditorShape: + def test_server_entry_becomes_a_diagnostic(self): + diagnostic = diagnostic_from_entry(diagnostic_entries(SERVER_NOTIFICATION)[0]) + assert diagnostic.line == 4 + assert diagnostic.code == "2304" + assert "Cannot find name" in diagnostic.message + + def test_batch_conversion(self): + assert len(diagnostics_from_entries(diagnostic_entries(SERVER_NOTIFICATION))) == 1 + + def test_entry_without_a_code_falls_back(self): + diagnostic = diagnostic_from_entry({"line": 2, "message": "no code here"}) + assert diagnostic.code == SYNTAX_ERROR_CODE + + def test_missing_columns_default_to_the_line_start(self): + diagnostic = diagnostic_from_entry({"line": 5, "message": "somewhere"}) + assert diagnostic.column == 1 and diagnostic.end_line == 5 + + def test_unusable_entries_are_dropped(self): + assert diagnostic_from_entry({"message": "no line"}) is None + assert diagnostic_from_entry({"line": 0, "message": "bad line"}) is None + assert diagnostic_from_entry("nonsense") is None + + def test_non_list_batch(self): + assert diagnostics_from_entries("nonsense") == [] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.lsp_client.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestEditorShowsServerDiagnostics: + def test_diagnostics_reach_the_lint_manager(self, editor): + editor.setPlainText("const value = foo;\n") + assert editor.apply_server_diagnostics(diagnostic_entries({ + "diagnostics": [{ + "range": {"start": {"line": 0, "character": 6}, + "end": {"line": 0, "character": 11}}, + "message": "unused", "code": 6133, + }] + })) is True + assert editor.lint_manager.diagnostics()[0].message == "unused" + + def test_the_same_diagnostics_twice_change_nothing(self, editor): + entries = diagnostic_entries(SERVER_NOTIFICATION) + editor.setPlainText("\n".join("line" for _ in range(10))) + editor.apply_server_diagnostics(entries) + assert editor.apply_server_diagnostics(entries) is False + + def test_underlines_are_drawn_for_server_diagnostics(self, editor): + from PySide6.QtGui import QTextCharFormat + editor.setPlainText("const value = foo;\n") + editor.apply_server_diagnostics(diagnostic_entries({ + "diagnostics": [{ + "range": {"start": {"line": 0, "character": 1}, + "end": {"line": 0, "character": 5}}, + "message": "here", + }] + })) + wave = QTextCharFormat.UnderlineStyle.WaveUnderline + assert any( + selection.format.underlineStyle() == wave + for selection in editor.extraSelections()) + + def test_a_lint_pass_does_not_wipe_server_diagnostics(self, editor): + # The ruff pass skips non-Python files; without a guard it would clear + # whatever the language server had just reported. + editor.current_file = "app.ts" + editor.setPlainText("const value = foo;\n") + editor.apply_server_diagnostics(diagnostic_entries(SERVER_NOTIFICATION)) + with patch.object(type(editor.lsp_client), "running", property(lambda _self: True)): + editor.request_lint() + assert editor.lint_manager.diagnostics() != [] + + def test_without_a_server_a_lint_pass_still_clears(self, editor): + editor.current_file = "notes.txt" + editor.apply_server_diagnostics(diagnostic_entries(SERVER_NOTIFICATION)) + editor.request_lint() + assert editor.lint_manager.diagnostics() == [] From 72ca1e3ab89a016a1e8d4068f2d0d75eb7f42fcf Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:02:55 +0800 Subject: [PATCH 19/36] Complete the multi-caret keys Typing and Backspace were the only keys that reached every caret. Delete, Enter and the left/right arrows do too now, and three shortcuts grow the set: Ctrl+Alt+Shift+Up/Down puts a caret on the neighbouring line at the same column, and Ctrl+D adds one at the next occurrence of the word under the caret, wrapping to the top like other editors. Delete needed the edit arithmetic split in two: an insertion or Backspace moves the caret that made it, while Delete removes the character after the caret and so moves only what follows. A caret on a shorter line clamps to its end rather than dangling past it. --- .../code/multi_cursor/multi_cursor_manager.py | 124 +++++++++++++++++- .../code_edit_plaintext.py | 50 ++++++- test/test_multi_cursor.py | 102 ++++++++++++++ 3 files changed, 266 insertions(+), 10 deletions(-) diff --git a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py index a5d51da1..be9b0f7d 100644 --- a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py +++ b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py @@ -11,7 +11,9 @@ from PySide6.QtGui import QTextCursor -from je_editor.utils.multi_cursor.cursor_positions import clamp_positions, toggle_position +from je_editor.utils.multi_cursor.cursor_positions import ( + add_position, clamp_positions, toggle_position +) class MultiCursorManager: @@ -97,7 +99,8 @@ def _edit_targets(self) -> list[int]: """ return sorted({*self._positions, self._code_edit.textCursor().position()}) - def _apply_at_targets(self, edit, shift_per_target: int) -> None: + def _apply_at_targets( + self, edit, shift_per_target: int, shift_others: int | None = None) -> None: """ 由後往前在每個位置套用一次編輯,再把游標移到新位置 Apply one edit at each position from the end backwards, then move the @@ -110,10 +113,17 @@ def _apply_at_targets(self, edit, shift_per_target: int) -> None: moves by ``(n + 1) * shift_per_target``. :param edit: 對單一位置執行的編輯 / the edit to run at one position - :param shift_per_target: 每次編輯造成的位移(插入為正,刪除為負) - how far one edit moves what follows it: positive to insert, negative - to delete + :param shift_per_target: 這個位置自己編輯後要移動多少(插入為正,Backspace + 為負,Delete 為 0,因為刪的是游標後面的字元) + how far this position itself moves after its own edit: positive to + insert, negative for Backspace, zero for Delete, which removes the + character after the caret + :param shift_others: 每次編輯讓「後面的位置」移動多少;省略時與 + *shift_per_target* 相同 + how far each edit moves the positions after it; defaults to + *shift_per_target* """ + following = shift_per_target if shift_others is None else shift_others targets = self._edit_targets() main_position = self._code_edit.textCursor().position() cursor = self._code_edit.textCursor() @@ -125,7 +135,7 @@ def _apply_at_targets(self, edit, shift_per_target: int) -> None: cursor.endEditBlock() moved = { - position: position + (index + 1) * shift_per_target + position: position + index * following + shift_per_target for index, position in enumerate(targets) } main = self._code_edit.textCursor() @@ -178,6 +188,108 @@ def delete(cursor: QTextCursor, position: int) -> None: self._apply_at_targets(delete, -1) return True + def delete_after(self) -> bool: + """ + 在每個游標刪除後一個字元(Delete) + Delete the character after every caret, as Delete does. + + 任何一個游標已經在文件結尾時就整批不做,理由與 Backspace 相同。 + Nothing is deleted when any caret sits at the very end, for the same + reason as Backspace. + + :return: 有刪除時為 ``True`` / ``True`` when anything was deleted + """ + limit = self._document_limit() + if not self._positions or any(position >= limit for position in self._edit_targets()): + return False + + def delete(cursor: QTextCursor, position: int) -> None: + cursor.setPosition(position) + cursor.deleteChar() + + # 刪除的是游標之後的字元,游標自己不動,只有其後的位置要往前 + # The character after the caret goes, so the caret stays where it is and + # only what follows shifts back + self._apply_at_targets(delete, 0, shift_others=-1) + return True + + def insert_newline(self) -> bool: + """ + 在每個游標插入換行 + Insert a line break at every caret. + + :return: 有插入時為 ``True`` / ``True`` when anything was inserted + """ + return self.insert_text("\n") + + def move_all(self, offset: int) -> bool: + """ + 把每個額外游標左右移動 + Move every extra caret left or right. + + :param offset: 移動量(負數往左)/ how far to move, negative for left + :return: 有移動時為 ``True`` / ``True`` when the carets moved + """ + if not self._positions: + return False + self._positions = clamp_positions( + [position + offset for position in self._positions], self._document_limit()) + self._code_edit.viewport().update() + return True + + def add_caret_on_neighbouring_line(self, direction: int) -> bool: + """ + 在上一行或下一行的同一欄加一個游標 + Add a caret on the line above or below, at the same column. + + :param direction: ``-1`` 為上一行,``1`` 為下一行 / ``-1`` above, ``1`` below + :return: 有加入時為 ``True`` / ``True`` when a caret was added + """ + document = self._code_edit.document() + cursor = self._code_edit.textCursor() + reference = max(self._positions, default=cursor.position()) if direction > 0 else \ + min(self._positions, default=cursor.position()) + block = document.findBlock(reference) + column = reference - block.position() + target = document.findBlockByNumber(block.blockNumber() + direction) + if not target.isValid(): + return False + position = target.position() + min(column, len(target.text())) + self._positions = add_position(self._positions, position) + self._code_edit.viewport().update() + return True + + def add_caret_at_next_occurrence(self) -> bool: + """ + 在游標所在字詞的下一個出現處加一個游標 + Add a caret at the next occurrence of the word under the caret. + + 游標放在該字詞的結尾,接著輸入就會接在它後面——這是「同時改掉每一處」 + 最常用的形式。 + The caret goes to the end of that occurrence, so typing continues after + it, which is the form most used for changing every occurrence at once. + + :return: 找到並加入時為 ``True`` / ``True`` when another occurrence was found + """ + cursor = self._code_edit.textCursor() + word_cursor = self._code_edit.textCursor() + word_cursor.select(QTextCursor.SelectionType.WordUnderCursor) + word = word_cursor.selectedText() + if not word: + return False + text = self._code_edit.toPlainText() + start_from = max([cursor.position(), *self._positions], default=0) + found = text.find(word, start_from) + if found < 0: + # 找到檔尾就從頭再找一次,與其他編輯器的行為一致 + # Wrapping to the top matches what other editors do + found = text.find(word) + if found < 0 or found + len(word) in self._positions: + return False + self._positions = add_position(self._positions, found + len(word)) + self._code_edit.viewport().update() + return True + def _document_limit(self) -> int: """文件中最大的有效位置 / The highest valid position in the document.""" return max(0, self._code_edit.document().characterCount() - 1) 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 1beb62e3..727e8d37 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 @@ -103,6 +103,19 @@ def run(self) -> None: # markers because it spawns a subprocess _LINT_REFRESH_DELAY_MS = 900 +# 多重游標啟用時,這些按鍵各自對應一個整批動作 +# With extra carets active, each of these keys drives one batched action +_MULTI_CURSOR_KEYS = { + Qt.Key.Key_Escape: lambda manager: manager.clear(), + Qt.Key.Key_Backspace: lambda manager: manager.delete_before(), + Qt.Key.Key_Delete: lambda manager: manager.delete_after(), + Qt.Key.Key_Return: lambda manager: manager.insert_newline(), + Qt.Key.Key_Enter: lambda manager: manager.insert_newline(), + Qt.Key.Key_Left: lambda manager: manager.move_all(-1), + Qt.Key.Key_Right: lambda manager: manager.move_all(1), +} + + def _lint_underline_format() -> QTextCharFormat: """診斷底線的樣式 / The format used to underline a diagnostic.""" formats = QTextCharFormat() @@ -2060,6 +2073,9 @@ def _register_multi_cursor_actions(self) -> None: for shortcut, handler in ( ("Ctrl+Shift+L", self.add_cursors_to_selected_lines), ("Ctrl+Shift+Escape", self.clear_extra_cursors), + ("Ctrl+Alt+Shift+Up", self.add_cursor_above), + ("Ctrl+Alt+Shift+Down", self.add_cursor_below), + ("Ctrl+D", self.add_cursor_at_next_occurrence), ): action = QAction(self) action.setShortcut(shortcut) @@ -2075,6 +2091,33 @@ def add_cursors_to_selected_lines(self) -> int: """ return self.multi_cursor_manager.add_to_selected_lines() + def add_cursor_above(self) -> bool: + """ + 在上一行的同一欄加一個游標 + Add a caret on the line above, at the same column. + + :return: 有加入時為 ``True`` / ``True`` when a caret was added + """ + return self.multi_cursor_manager.add_caret_on_neighbouring_line(-1) + + def add_cursor_below(self) -> bool: + """ + 在下一行的同一欄加一個游標 + Add a caret on the line below, at the same column. + + :return: 有加入時為 ``True`` / ``True`` when a caret was added + """ + return self.multi_cursor_manager.add_caret_on_neighbouring_line(1) + + def add_cursor_at_next_occurrence(self) -> bool: + """ + 在游標所在字詞的下一個出現處加一個游標 + Add a caret at the next occurrence of the word under the caret. + + :return: 找到並加入時為 ``True`` / ``True`` when another occurrence was found + """ + return self.multi_cursor_manager.add_caret_at_next_occurrence() + def clear_extra_cursors(self) -> bool: """ 清除所有額外游標 @@ -2123,10 +2166,9 @@ def _handle_multi_cursor_key(self, event: QKeyEvent) -> bool: if not self.multi_cursor_manager.active: return False key = event.key() - if key == Qt.Key.Key_Escape: - return self.multi_cursor_manager.clear() - if key == Qt.Key.Key_Backspace: - return self.multi_cursor_manager.delete_before() + handler = _MULTI_CURSOR_KEYS.get(key) + if handler is not None: + return handler(self.multi_cursor_manager) if event.text() and event.text().isprintable(): return self.multi_cursor_manager.insert_text(event.text()) return False diff --git a/test/test_multi_cursor.py b/test/test_multi_cursor.py index 387ff291..0024cfa2 100644 --- a/test/test_multi_cursor.py +++ b/test/test_multi_cursor.py @@ -187,6 +187,108 @@ def test_backspace_at_the_document_start_is_refused(self, editor): assert editor.multi_cursor_manager.delete_before() is False assert editor.toPlainText() == "abc" + def test_delete_reaches_every_line(self, editor): + editor.setPlainText("one!\ntwo!\nthree!") + _select_all(editor) + editor.add_cursors_to_selected_lines() + # Each caret sits at its line end, so Delete takes the newline after it. + editor.multi_cursor_manager.move_all(-1) + cursor = editor.textCursor() + cursor.setPosition(cursor.position() - 1) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Delete) + assert editor.toPlainText() == "one\ntwo\nthree" + + def test_delete_at_the_document_end_is_refused(self, editor): + editor.setPlainText("abc") + editor.multi_cursor_manager.toggle_at(3) + assert editor.multi_cursor_manager.delete_after() is False + assert editor.toPlainText() == "abc" + + def test_enter_splits_every_line(self, editor): + editor.setPlainText("ab\ncd") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _press(editor, Qt.Key.Key_Return) + assert editor.toPlainText() == "ab\n\ncd\n" + + def test_arrow_keys_move_the_extra_carets(self, editor): + editor.setPlainText("hello world") + editor.multi_cursor_manager.toggle_at(5) + _press(editor, Qt.Key.Key_Right) + assert editor.multi_cursor_manager.positions() == [6] + _press(editor, Qt.Key.Key_Left) + assert editor.multi_cursor_manager.positions() == [5] + + def test_carets_never_move_outside_the_document(self, editor): + editor.setPlainText("ab") + editor.multi_cursor_manager.toggle_at(0) + editor.multi_cursor_manager.move_all(-5) + assert editor.multi_cursor_manager.positions() == [0] + editor.multi_cursor_manager.move_all(99) + assert editor.multi_cursor_manager.positions() == [2] + + def test_a_caret_can_be_added_on_the_line_below(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + assert editor.add_cursor_below() is True + assert editor.multi_cursor_manager.positions() == [8] + + def test_a_caret_can_be_added_on_the_line_above(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + cursor = editor.textCursor() + cursor.setPosition(8) + editor.setTextCursor(cursor) + assert editor.add_cursor_above() is True + assert editor.multi_cursor_manager.positions() == [2] + + def test_no_caret_beyond_the_first_or_last_line(self, editor): + editor.setPlainText("only one line") + assert editor.add_cursor_above() is False + assert editor.add_cursor_below() is False + + def test_a_short_line_clamps_the_column(self, editor): + editor.setPlainText("longer line\nab") + cursor = editor.textCursor() + cursor.setPosition(9) + editor.setTextCursor(cursor) + editor.add_cursor_below() + assert editor.multi_cursor_manager.positions() == [14] + + def test_next_occurrence_adds_a_caret(self, editor): + editor.setPlainText("name = name + name") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + assert editor.add_cursor_at_next_occurrence() is True + assert editor.multi_cursor_manager.positions() == [11] + + def test_next_occurrence_of_a_unique_word_wraps_to_itself(self, editor): + editor.setPlainText("unique word") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + editor.add_cursor_at_next_occurrence() + assert editor.multi_cursor_manager.positions() == [6] + + def test_next_occurrence_without_a_word_does_nothing(self, editor): + editor.setPlainText(" ") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + assert editor.add_cursor_at_next_occurrence() is False + + def test_typing_after_next_occurrence_changes_both(self, editor): + editor.setPlainText("name = name") + cursor = editor.textCursor() + cursor.setPosition(4) + editor.setTextCursor(cursor) + editor.add_cursor_at_next_occurrence() + _type(editor, "s") + assert editor.toPlainText() == "names = names" + def test_painting_extra_cursors_does_not_raise(self, editor): editor.setPlainText("one\ntwo\nthree") _select_all(editor) From a1003264a701a6e9614e74f4346024b4b715710c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:06:05 +0800 Subject: [PATCH 20/36] Select a column by Alt-dragging Alt-drag now puts a caret on every line the rectangle covers, at the column the pointer reached, which is what makes editing a block of lines at once possible. Dragging upwards works the same, and a line shorter than that column stops at its end rather than dangling past it. An Alt-click that does not drag still toggles a single caret, so the two gestures share the modifier without either losing out: the anchor is recorded on press and only becomes a column selection once the pointer actually moves. --- .../code/multi_cursor/multi_cursor_manager.py | 44 +++++++++++++- .../code_edit_plaintext.py | 36 ++++++++++- .../utils/multi_cursor/cursor_positions.py | 33 +++++++++++ test/test_multi_cursor.py | 59 +++++++++++++++++++ 4 files changed, 169 insertions(+), 3 deletions(-) diff --git a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py index be9b0f7d..b193d873 100644 --- a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py +++ b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py @@ -12,7 +12,7 @@ from PySide6.QtGui import QTextCursor from je_editor.utils.multi_cursor.cursor_positions import ( - add_position, clamp_positions, toggle_position + add_position, clamp_positions, column_caret_columns, column_span, toggle_position ) @@ -237,6 +237,48 @@ def move_all(self, offset: int) -> bool: self._code_edit.viewport().update() return True + def select_column(self, anchor_position: int, current_position: int) -> int: + """ + 以矩形範圍在每一行放一個游標 + Put a caret on every line a rectangle covers. + + 這就是欄選取:從起點拖到目前位置,涵蓋的每一行都在同一欄得到一個游標, + 比該欄短的行則停在行尾。 + This is column selection: dragging from the anchor to here gives every + covered line a caret at the same column, with shorter lines stopping at + their end. + + :param anchor_position: 拖曳起點的字元位置 / where the drag started + :param current_position: 目前的字元位置 / where the pointer is now + :return: 額外游標的數量 / how many extra carets there now are + """ + document = self._code_edit.document() + anchor_block = document.findBlock(anchor_position) + current_block = document.findBlock(current_position) + lines = column_span(anchor_block.blockNumber(), current_block.blockNumber()) + blocks = [document.findBlockByNumber(line) for line in lines] + blocks = [block for block in blocks if block.isValid()] + if not blocks: + return 0 + columns = column_caret_columns( + anchor_position - anchor_block.position(), + current_position - current_block.position(), + [len(block.text()) for block in blocks], + ) + positions = [ + block.position() + column for block, column in zip(blocks, columns) + ] + # 主游標留在拖曳到的那一行,其餘是額外游標 + # The primary caret keeps the line the drag reached; the rest are extra + main_position = positions[-1] if current_block.blockNumber() >= anchor_block.blockNumber() \ + else positions[0] + self._positions = sorted(set(positions) - {main_position}) + main = self._code_edit.textCursor() + main.setPosition(min(main_position, self._document_limit())) + self._code_edit.setTextCursor(main) + self._code_edit.viewport().update() + return len(self._positions) + def add_caret_on_neighbouring_line(self, direction: int) -> bool: """ 在上一行或下一行的同一欄加一個游標 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 727e8d37..40296a71 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 @@ -316,8 +316,11 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # 片段展開與定位點 / Snippet expansion and its tab stops self.snippet_manager = SnippetManager(self) - # 多重游標 / Extra carets + # 多重游標;欄選取拖曳的起點在按下 Alt 時記下 + # Extra carets; a column drag records its anchor when Alt is pressed self.multi_cursor_manager = MultiCursorManager(self) + self._column_anchor: Union[int, None] = None + self._column_dragged = False self._register_multi_cursor_actions() # 非 Python 檔的補全與診斷都交給語言伺服器;Python 仍走 jedi 與 ruff @@ -2136,14 +2139,43 @@ def _handle_multi_cursor_click(self, event: QtGui.QMouseEvent) -> bool: :return: 事件是否已被處理 / whether the event was handled here """ if event.modifiers() & Qt.KeyboardModifier.AltModifier: - self.multi_cursor_manager.toggle_at(self.cursorForPosition(event.pos()).position()) + # 記下起點,之後拖曳就是欄選取;沒有拖曳就當作切換一個游標 + # Remember the anchor: dragging from here is a column selection, and + # not dragging counts as toggling one caret + self._column_anchor = self.cursorForPosition(event.pos()).position() + self._column_dragged = False return True + self._column_anchor = None if self.multi_cursor_manager.active: # 一般點按等於重新開始,因此先收掉額外游標 # A plain click starts over, so the extra carets go first self.multi_cursor_manager.clear() return False + def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: + """ + Alt+拖曳做欄選取,其餘照原本行為 + Alt-drag makes a column selection; every other drag behaves as before. + """ + if self._column_anchor is not None and event.buttons() & Qt.MouseButton.LeftButton: + self._column_dragged = True + self.multi_cursor_manager.select_column( + self._column_anchor, self.cursorForPosition(event.pos()).position()) + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: + """ + 放開 Alt+點按時,若沒有拖曳就切換一個游標 + On releasing an Alt-click that did not drag, toggle a single caret. + """ + if self._column_anchor is not None: + if not self._column_dragged: + self.multi_cursor_manager.toggle_at(self._column_anchor) + self._column_anchor = None + return + super().mouseReleaseEvent(event) + def _paint_extra_cursors(self) -> None: """畫出每個額外游標 / Draw each extra caret.""" painter = QPainter(self.viewport()) diff --git a/je_editor/utils/multi_cursor/cursor_positions.py b/je_editor/utils/multi_cursor/cursor_positions.py index 03126586..c555a302 100644 --- a/je_editor/utils/multi_cursor/cursor_positions.py +++ b/je_editor/utils/multi_cursor/cursor_positions.py @@ -93,6 +93,39 @@ def shift_after_delete(positions: list[int], at: int, length: int) -> list[int]: return sorted(set(adjusted)) +def column_span(anchor_line: int, current_line: int) -> range: + """ + 取得欄選取涵蓋的行範圍(可上可下) + The lines a column selection covers, dragged in either direction. + + :param anchor_line: 起點所在行 / the line the drag started on + :param current_line: 目前所在行 / the line the pointer is on now + :return: 涵蓋的行號,含頭含尾 / the lines covered, both ends included + """ + first, last = sorted((max(0, anchor_line), max(0, current_line))) + return range(first, last + 1) + + +def column_caret_columns( + anchor_column: int, current_column: int, line_lengths: list[int]) -> list[int]: + """ + 取得欄選取在每一行的欄位 + The column a rectangular selection lands on for each line it covers. + + 游標放在拖曳到的那一欄;比該欄短的行就停在行尾,因此短行不會產生指向不存在 + 位置的游標。 + The caret goes to the column the pointer reached, and a line shorter than + that stops at its end, so a short line never gets a caret pointing past it. + + :param anchor_column: 起點的欄位 / the column the drag started at + :param current_column: 目前的欄位 / the column the pointer is at now + :param line_lengths: 各涵蓋行的長度 / the length of each covered line + :return: 各行的欄位 / the column for each line + """ + target = max(0, current_column if current_column != anchor_column else anchor_column) + return [min(target, max(0, length)) for length in line_lengths] + + def clamp_positions(positions: list[int], limit: int) -> list[int]: """ 把位置限制在文件範圍內 diff --git a/test/test_multi_cursor.py b/test/test_multi_cursor.py index 0024cfa2..b12bdd7a 100644 --- a/test/test_multi_cursor.py +++ b/test/test_multi_cursor.py @@ -11,6 +11,8 @@ from je_editor.utils.multi_cursor.cursor_positions import ( add_position, clamp_positions, + column_caret_columns, + column_span, remove_position, shift_after_delete, shift_after_insert, @@ -66,6 +68,29 @@ def test_clamping_an_empty_document(self): assert clamp_positions([3], -1) == [] +class TestColumnGeometry: + def test_span_downwards(self): + assert list(column_span(2, 5)) == [2, 3, 4, 5] + + def test_span_upwards_is_the_same_range(self): + assert list(column_span(5, 2)) == [2, 3, 4, 5] + + def test_span_of_one_line(self): + assert list(column_span(3, 3)) == [3] + + def test_columns_follow_the_pointer(self): + assert column_caret_columns(2, 6, [10, 10, 10]) == [6, 6, 6] + + def test_a_short_line_stops_at_its_end(self): + assert column_caret_columns(2, 6, [10, 3, 10]) == [6, 3, 6] + + def test_dragging_straight_down_keeps_the_anchor_column(self): + assert column_caret_columns(4, 4, [10, 10]) == [4, 4] + + def test_no_lines_covered(self): + assert column_caret_columns(0, 3, []) == [] + + @pytest.fixture(scope="module") def app(): return QApplication.instance() or QApplication([]) @@ -289,6 +314,40 @@ def test_typing_after_next_occurrence_changes_both(self, editor): _type(editor, "s") assert editor.toPlainText() == "names = names" + def test_column_selection_puts_a_caret_on_each_line(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + # Drag from column 2 of line 0 to column 2 of line 2. + extra = editor.multi_cursor_manager.select_column(2, 14) + assert extra == 2 + assert editor.textCursor().blockNumber() == 2 + + def test_column_selection_covers_every_line_between(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + editor.multi_cursor_manager.select_column(2, 14) + lines = { + editor.document().findBlock(position).blockNumber() + for position in editor.multi_cursor_manager.positions() + } + assert lines == {0, 1} + + def test_column_selection_clamps_short_lines(self, editor): + editor.setPlainText("a longer line\nab\nanother line") + editor.multi_cursor_manager.select_column(8, 8 + 14 + 8) + for position in editor.multi_cursor_manager.positions(): + block = editor.document().findBlock(position) + assert position - block.position() <= len(block.text()) + + def test_dragging_upwards_works_the_same(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + assert editor.multi_cursor_manager.select_column(14, 2) == 2 + assert editor.textCursor().blockNumber() == 0 + + def test_typing_after_a_column_selection_edits_each_line(self, editor): + editor.setPlainText("aaa\nbbb\nccc") + editor.multi_cursor_manager.select_column(0, 8) + _type(editor, "-") + assert editor.toPlainText() == "-aaa\n-bbb\n-ccc" + def test_painting_extra_cursors_does_not_raise(self, editor): editor.setPlainText("one\ntwo\nthree") _select_all(editor) From a30d7d9fc6f9d04cb923e790838eb468b96f6a4d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:09:46 +0800 Subject: [PATCH 21/36] Add a right-click menu to the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor had no context menu at all, so cut, paste, commenting and the rest were keyboard-only. Right-click now opens Qt''s own edit menu — which already tracks what is enabled — followed by toggle comment, toggle bookmark, go to definition and revert this change. The two that depend on something being available say so by being greyed out: revert only when a git baseline is known, go to definition only when a language server is running. Definition itself is a new LSP request, with its reply parsed from any of the three shapes servers use for it. --- je_editor/pyside_ui/code/lsp/lsp_client.py | 36 +++++- .../code_edit_plaintext.py | 50 +++++++- je_editor/utils/lsp/lsp_protocol.py | 44 +++++++ je_editor/utils/multi_language/english.py | 5 + .../multi_language/traditional_chinese.py | 5 + test/test_context_menu.py | 118 ++++++++++++++++++ 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 test/test_context_menu.py diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index 4f1b86d4..f80fe2db 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -23,6 +23,7 @@ from je_editor.utils.lsp.lsp_protocol import ( MessageReader, completion_labels, + definition_location, diagnostic_entries, encode_message, file_uri, @@ -42,6 +43,7 @@ class LspClient(QObject): completions_ready = Signal(list) # list[str] diagnostics_ready = Signal(list) # list[dict] + definition_ready = Signal(dict) # {"path": str, "line": int, "column": int} def __init__(self, parent: QObject | None = None) -> None: """ @@ -54,6 +56,7 @@ def __init__(self, parent: QObject | None = None) -> None: self._file_path: str | None = None self._version = 0 self._pending_completion_id: int | None = None + self._pending_definition_id: int | None = None @property def running(self) -> bool: @@ -159,6 +162,29 @@ def request_completion(self, line: int, column: int) -> bool: "position": {"line": line, "character": column}, })) + def request_definition(self, line: int, column: int) -> bool: + """ + 要求某個位置的定義位置 + Ask where the symbol at a position is defined. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + return self._position_request("textDocument/definition", line, column, "_pending_definition_id") + + def _position_request( + self, method: str, line: int, column: int, pending_attribute: str) -> bool: + """送出一則以游標位置為參數的請求 / Send one request about a caret position.""" + if self._file_path is None: + return False + request_id = self._take_id() + setattr(self, pending_attribute, request_id) + return self._send(request(request_id, method, { + "textDocument": {"uri": file_uri(self._file_path)}, + "position": {"line": line, "character": column}, + })) + def handle_message(self, message: dict) -> None: """ 處理伺服器送來的一則訊息 @@ -170,9 +196,17 @@ def handle_message(self, message: dict) -> None: entries = diagnostic_entries(message.get("params")) self.diagnostics_ready.emit(entries) return - if message.get("id") == self._pending_completion_id and "result" in message: + if "result" not in message: + return + if message.get("id") == self._pending_completion_id: self._pending_completion_id = None self.completions_ready.emit(completion_labels(message.get("result"))) + return + if message.get("id") == self._pending_definition_id: + self._pending_definition_id = None + location = definition_location(message.get("result")) + if location is not None: + self.definition_ready.emit(location) def _read_output(self) -> None: """讀取伺服器輸出並逐則處理 / Read the server's output and handle each message.""" 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 40296a71..2ec41401 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 @@ -10,7 +10,9 @@ QPainter, QTextCharFormat, QTextFormat, QKeyEvent, QAction, QTextDocument, QTextCursor, QTextOption, QColor, QWheelEvent ) -from PySide6.QtWidgets import QPlainTextEdit, QWidget, QTextEdit, QCompleter, QInputDialog +from PySide6.QtWidgets import ( + QPlainTextEdit, QWidget, QTextEdit, QCompleter, QInputDialog, QMenu +) from je_editor.pyside_ui.code.bookmark.bookmark_manager import BookmarkManager from je_editor.pyside_ui.code.folding.folding_manager import FoldingManager @@ -2304,6 +2306,52 @@ def keyPressEvent(self, event: QKeyEvent) -> None: self.completer.popup().close() self._complete_timer.start() + def build_context_menu(self) -> QMenu: + """ + 建立右鍵選單 + Build the right-click menu. + + 用 Qt 內建的編輯選單當底(剪下/複製/貼上/復原都在裡面,且啟用狀態由 Qt + 自己維護),再接上編輯器自己的動作。 + Qt's own edit menu is the base — cut, copy, paste and undo are already + there with Qt keeping their enabled state right — and the editor's own + actions are appended to it. + + :return: 可直接顯示的選單 / a menu ready to show + """ + word = language_wrapper.language_word_dict + menu = self.createStandardContextMenu() + menu.addSeparator() + for label_key, handler, enabled in ( + ("context_menu_toggle_comment", self.toggle_comment, True), + ("context_menu_toggle_bookmark", self.toggle_bookmark, True), + ("context_menu_go_to_definition", self.go_to_definition, + self.lsp_client.running), + ("context_menu_revert_hunk", self.revert_change_at_cursor, + self.diff_marker_manager.has_baseline), + ): + action = menu.addAction(word.get(label_key)) + action.triggered.connect(handler) + action.setEnabled(enabled) + return menu + + def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None: + """顯示右鍵選單 / Show the right-click menu.""" + menu = self.build_context_menu() + menu.exec(event.globalPos()) + menu.deleteLater() + + def go_to_definition(self) -> bool: + """ + 請語言伺服器跳到游標所在符號的定義 + Ask the language server to jump to the definition under the caret. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + return self.lsp_client.request_definition( + cursor.blockNumber(), cursor.positionInBlock()) + def closeEvent(self, event: QtGui.QCloseEvent) -> None: """ 關閉前停掉所有背景工作 diff --git a/je_editor/utils/lsp/lsp_protocol.py b/je_editor/utils/lsp/lsp_protocol.py index 8788ab0f..10ec011a 100644 --- a/je_editor/utils/lsp/lsp_protocol.py +++ b/je_editor/utils/lsp/lsp_protocol.py @@ -14,6 +14,7 @@ from __future__ import annotations import json +from urllib.parse import unquote # 標頭與內容之間的分隔 / What separates the header from the body HEADER_SEPARATOR = b"\r\n\r\n" @@ -158,6 +159,49 @@ def completion_labels(response: object) -> list[str]: return labels +def path_from_uri(uri: object) -> str: + """ + 把 LSP 的 ``file://`` URI 轉回檔案路徑 + Turn an LSP ``file://`` URI back into a file path. + + :param uri: 伺服器回報的 URI / the URI the server reported + :return: 檔案路徑,無法辨識時為空字串 / the path, or an empty string + """ + if not isinstance(uri, str) or not uri.startswith("file://"): + return "" + path = unquote(uri[len("file://"):]) + # Windows 的 URI 會多一個開頭斜線:``/D:/x`` 要還原成 ``D:/x`` + # A Windows URI carries a leading slash: ``/D:/x`` becomes ``D:/x`` + if len(path) > 2 and path[0] == "/" and path[2] == ":": + return path[1:] + return path + + +def definition_location(result: object) -> dict | None: + """ + 從 definition 回應取出第一個位置 + Take the first location out of a definition response. + + 回應可能是單一位置、位置清單,或 ``LocationLink`` 清單,三種都要接受。 + A response may be a single location, a list of them, or a list of + ``LocationLink``, and all three are accepted. + + :param result: 伺服器的回應內容 / the server's result + :return: ``{"path", "line", "column"}``,無法辨識時為 ``None`` + the location, or ``None`` when it cannot be read + """ + first = result[0] if isinstance(result, list) and result else result + if not isinstance(first, dict): + return None + uri = first.get("uri") or first.get("targetUri") + span = first.get("range") or first.get("targetSelectionRange") or first.get("targetRange") + path = path_from_uri(uri) + if not path or not isinstance(span, dict): + return None + line, column = _position(span.get("start")) + return {"path": path, "line": line, "column": column} + + def diagnostic_entries(params: object) -> list[dict]: """ 從 publishDiagnostics 通知取出診斷 diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 0038487c..283ccf1f 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -384,6 +384,11 @@ "tab_menu_diff_against_head_name": "Diff Against HEAD", "tab_menu_split_view_label": "Toggle Split View", "tab_menu_minimap_label": "Toggle Minimap", + # Editor context menu + "context_menu_toggle_comment": "Toggle Comment", + "context_menu_toggle_bookmark": "Toggle Bookmark", + "context_menu_go_to_definition": "Go to Definition", + "context_menu_revert_hunk": "Revert This Change", # Test panel "tab_menu_test_panel_tab_name": "Tests", "test_panel_run": "Run Tests", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index b8369cf5..f3e2cfd5 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -374,6 +374,11 @@ "tab_menu_diff_against_head_name": "與 HEAD 比較差異", "tab_menu_split_view_label": "切換分割檢視", "tab_menu_minimap_label": "切換縮圖", + # 編輯器右鍵選單 + "context_menu_toggle_comment": "切換註解", + "context_menu_toggle_bookmark": "切換書籤", + "context_menu_go_to_definition": "跳到定義", + "context_menu_revert_hunk": "還原這段變更", # Test panel "tab_menu_test_panel_tab_name": "測試", "test_panel_run": "執行測試", diff --git a/test/test_context_menu.py b/test/test_context_menu.py new file mode 100644 index 00000000..04133b52 --- /dev/null +++ b/test/test_context_menu.py @@ -0,0 +1,118 @@ +"""Tests for the editor's right-click menu and go-to-definition.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lsp.lsp_protocol import definition_location, path_from_uri + + +class TestDefinitionParsing: + def test_single_location(self): + location = definition_location({ + "uri": "file:///project/app.ts", + "range": {"start": {"line": 9, "character": 4}}, + }) + assert location == {"path": "/project/app.ts", "line": 10, "column": 5} + + def test_list_of_locations_takes_the_first(self): + location = definition_location([ + {"uri": "file:///a.ts", "range": {"start": {"line": 0, "character": 0}}}, + {"uri": "file:///b.ts", "range": {"start": {"line": 5, "character": 0}}}, + ]) + assert location["path"] == "/a.ts" + + def test_location_link_form(self): + location = definition_location([{ + "targetUri": "file:///project/app.rs", + "targetSelectionRange": {"start": {"line": 2, "character": 1}}, + }]) + assert location["path"] == "/project/app.rs" and location["line"] == 3 + + def test_windows_uri_drops_the_leading_slash(self): + assert path_from_uri("file:///D:/project/app.ts") == "D:/project/app.ts" + + def test_percent_encoding_is_decoded(self): + assert path_from_uri("file:///project/my%20app.ts") == "/project/my app.ts" + + def test_unusable_results(self): + assert definition_location(None) is None + assert definition_location([]) is None + assert definition_location({"uri": "file:///a.ts"}) is None + assert definition_location({"range": {"start": {"line": 1}}}) is None + + def test_non_file_uri(self): + assert path_from_uri("http://example.com/a.ts") == "" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.close() + code_editor.deleteLater() + + +def _labels(menu) -> list[str]: + return [action.text() for action in menu.actions()] + + +class TestContextMenu: + def test_menu_keeps_the_standard_editing_actions(self, editor): + editor.setPlainText("some text") + menu = editor.build_context_menu() + assert any("Paste" in label or "貼上" in label for label in _labels(menu)) + menu.deleteLater() + + def test_menu_adds_the_editor_actions(self, editor): + menu = editor.build_context_menu() + labels = " ".join(_labels(menu)) + assert "Comment" in labels + assert "Bookmark" in labels + assert "Definition" in labels + menu.deleteLater() + + def test_revert_is_disabled_without_a_baseline(self, editor): + menu = editor.build_context_menu() + revert = [a for a in menu.actions() if "Revert" in a.text()][0] + assert revert.isEnabled() is False + menu.deleteLater() + + def test_revert_is_enabled_with_a_baseline(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + menu = editor.build_context_menu() + revert = [a for a in menu.actions() if "Revert" in a.text()][0] + assert revert.isEnabled() is True + menu.deleteLater() + + def test_definition_is_disabled_without_a_server(self, editor): + menu = editor.build_context_menu() + definition = [a for a in menu.actions() if "Definition" in a.text()][0] + assert definition.isEnabled() is False + menu.deleteLater() + + def test_toggle_comment_from_the_menu_edits_the_line(self, editor): + editor.setPlainText("value = 1\n") + menu = editor.build_context_menu() + comment = [a for a in menu.actions() if "Comment" in a.text()][0] + comment.trigger() + assert editor.toPlainText().lstrip().startswith("#") + menu.deleteLater() + + def test_go_to_definition_without_a_server_is_refused(self, editor): + assert editor.go_to_definition() is False From 65adfea16ebc30d105b719609afbd3dff6563853 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:12:17 +0800 Subject: [PATCH 22/36] Mark diagnostics, changes and occurrences in the minimap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimap showed the shape of the file but not where anything was. Ticks down its left edge now mark diagnostics, ticks down its right edge mark lines that differ from the last commit, and a middle column marks the other occurrences of the word under the caret — so the overview says where to look, not just how long the file is. Every marker reads state the editor has already computed, so nothing rescans the file, and moving the caret repaints on the same debounce as an edit. --- .../pyside_ui/code/minimap/minimap_widget.py | 47 +++++++++++++++++++ test/test_minimap.py | 38 +++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/je_editor/pyside_ui/code/minimap/minimap_widget.py b/je_editor/pyside_ui/code/minimap/minimap_widget.py index 77e165d9..24ce54aa 100644 --- a/je_editor/pyside_ui/code/minimap/minimap_widget.py +++ b/je_editor/pyside_ui/code/minimap/minimap_widget.py @@ -27,6 +27,8 @@ # 文件變更後多久重畫縮圖(毫秒);輸入時不需要每個字都重畫 # How long after an edit the minimap repaints; it need not follow every keystroke _REPAINT_DELAY_MS = 300 +# 標記條的寬度(像素)/ Width in pixels of a marker tick +_MARKER_WIDTH = 3 class MinimapWidget(QWidget): @@ -52,6 +54,9 @@ def __init__(self, code_edit: QPlainTextEdit, parent: QWidget | None = None) -> code_edit.document().contentsChanged.connect(self._repaint_timer.start) code_edit.verticalScrollBar().valueChanged.connect(self.update) + # 游標移動會換掉「同字詞出現處」的標記,所以也要跟著重畫 + # Moving the caret changes which occurrences are marked, so repaint too + code_edit.cursorPositionChanged.connect(self._repaint_timer.start) def _step(self) -> int: """目前的取樣間隔 / The sampling step in use right now.""" @@ -68,9 +73,51 @@ def paintEvent(self, event) -> None: painter.fillRect(event.rect(), actually_color_dict.get("minimap_background_color")) step = self._step() self._paint_bars(painter, step) + self._paint_markers(painter, step) self._paint_viewport_band(painter, step) painter.end() + def marker_lines(self) -> dict[str, list[int]]: + """ + 取得要在縮圖上標記的行 + The lines the minimap should mark. + + 資料全部來自編輯器已經算好的東西——git 變更、診斷、游標所在字詞的其他 + 出現處——所以標記不需要自己再掃一次檔案。 + Everything comes from what the editor has already worked out: git + changes, diagnostics, and the other occurrences of the word under the + caret. The markers never rescan the file themselves. + + :return: 標記種類對應行號(0 起算)/ marker kind -> 0-based line numbers + """ + editor = self._code_edit + diagnostics = sorted({item.line - 1 for item in editor.lint_manager.diagnostics()}) + changes = sorted(editor.diff_marker_manager.statuses()) + document = editor.document() + occurrences = sorted({ + document.findBlock(position).blockNumber() + for position in editor.word_occurrences_under_cursor( + editor.toPlainText(), editor.textCursor().position()) + }) + return {"diagnostic": diagnostics, "change": changes, "occurrence": occurrences} + + def _paint_markers(self, painter: QPainter, step: int) -> None: + """在縮圖兩側畫出診斷、變更與出現處的標記 / Mark diagnostics, changes and hits.""" + markers = self.marker_lines() + for kind, colour_key, left, width in ( + ("diagnostic", "lint_underline_color", 0, _MARKER_WIDTH), + ("occurrence", "occurrence_highlight_color", + (self.width() - _MARKER_WIDTH) // 2, _MARKER_WIDTH), + ("change", "diff_modified_marker_color", + self.width() - _MARKER_WIDTH, _MARKER_WIDTH), + ): + colour = actually_color_dict.get(colour_key) + for line in markers[kind]: + row = row_for_line(line, step) + if row > self.height(): + break + painter.fillRect(left, row, width, LINE_PIXELS, colour) + def _paint_bars(self, painter: QPainter, step: int) -> None: """把每一行畫成一條與其長度相當的長條 / Draw each line as a bar of its length.""" painter.setPen(Qt.PenStyle.NoPen) diff --git a/test/test_minimap.py b/test/test_minimap.py index a01855bd..1a947720 100644 --- a/test/test_minimap.py +++ b/test/test_minimap.py @@ -143,6 +143,44 @@ def test_clicking_maps_to_a_line_in_range(self, editor_widget): line = minimap.line_at_position(20) assert 0 <= line < editor_widget.code_edit.blockCount() + def test_markers_report_diagnostics(self, editor_widget): + from je_editor.utils.lint.ruff_diagnostics import Diagnostic + editor_widget.toggle_minimap() + editor_widget.code_edit.lint_manager.set_diagnostics([ + Diagnostic(line=4, column=1, end_line=4, end_column=3, code="F401", message="x")]) + assert editor_widget.minimap.marker_lines()["diagnostic"] == [3] + + def test_markers_report_git_changes(self, editor_widget): + editor_widget.toggle_minimap() + editor_widget.code_edit.setPlainText("a\nCHANGED\nc\n") + editor_widget.code_edit.diff_marker_manager.set_baseline("a\nb\nc\n") + assert editor_widget.minimap.marker_lines()["change"] == [1] + + def test_markers_report_occurrences_of_the_word_under_the_caret(self, editor_widget): + editor_widget.toggle_minimap() + editor_widget.code_edit.setPlainText("total = 1\nx = total\ny = total\n") + cursor = editor_widget.code_edit.textCursor() + cursor.setPosition(2) + editor_widget.code_edit.setTextCursor(cursor) + assert editor_widget.minimap.marker_lines()["occurrence"] == [0, 1, 2] + + def test_no_markers_when_there_is_nothing_to_mark(self, editor_widget): + editor_widget.toggle_minimap() + editor_widget.code_edit.setPlainText("alpha\nbeta\n") + markers = editor_widget.minimap.marker_lines() + assert markers["diagnostic"] == [] and markers["change"] == [] + + def test_painting_with_markers_does_not_raise(self, editor_widget): + from je_editor.utils.lint.ruff_diagnostics import Diagnostic + editor_widget.toggle_minimap() + editor_widget.code_edit.setPlainText("a\nCHANGED\nc\n") + editor_widget.code_edit.diff_marker_manager.set_baseline("a\nb\nc\n") + editor_widget.code_edit.lint_manager.set_diagnostics([ + Diagnostic(line=1, column=1, end_line=1, end_column=2, code="E1", message="x")]) + editor_widget.show() + QApplication.processEvents() + editor_widget.hide() + def test_an_empty_document_is_safe_to_map(self, editor_widget): editor_widget.code_edit.setPlainText("") editor_widget.toggle_minimap() From ffaaada2473ae24d492ba236fd321f688992bbcf Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:15:34 +0800 Subject: [PATCH 23/36] Run one test, re-run failures, and filter the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the whole suite to check one fix was the only option the panel offered. Run Selected takes whatever is highlighted, Re-run Failures takes what failed last time — the action wanted most after a fix — and a filter box narrows the list as it is typed, keeping failures at the top. The node ids handed back to pytest are the ones it printed, passed as separate arguments rather than through a shell. --- .../main_ui/test_panel/test_panel_widget.py | 100 ++++++++++++++++-- je_editor/utils/multi_language/english.py | 3 + .../multi_language/traditional_chinese.py | 3 + test/test_pytest_panel.py | 52 +++++++++ 4 files changed, 148 insertions(+), 10 deletions(-) diff --git a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py index 9e0c4c35..dc0a0a0c 100644 --- a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py @@ -17,7 +17,8 @@ from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget + QHBoxLayout, QLabel, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget ) from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -36,7 +37,7 @@ RUN_TIMEOUT_SECONDS = 600 -def pytest_command() -> list[str]: +def pytest_command(node_ids: list[str] | None = None) -> list[str]: """ 組出執行測試的指令 Build the command that runs the tests. @@ -45,9 +46,15 @@ def pytest_command() -> list[str]: ``-v`` is what lists each test, and ``--tb=line`` prints one location per failure, which is exactly what the panel needs. + :param node_ids: 只跑這些測試,``None`` 表示全部 / run only these, or all when ``None`` :return: 引數清單(不經過 shell)/ the argument list, never a shell string """ - return [sys.executable, "-m", "pytest", "-v", "--tb=line", "-p", "no:cacheprovider"] + command = [sys.executable, "-m", "pytest", "-v", "--tb=line", "-p", "no:cacheprovider"] + # 節點名稱是 pytest 自己印出來的,原樣傳回去;仍然是獨立引數,不經過 shell + # The node ids came from pytest itself and go straight back as separate + # arguments, never through a shell + command.extend(node_ids or []) + return command class PytestRunThread(QThread): @@ -58,19 +65,21 @@ class PytestRunThread(QThread): finished_output = Signal(str) - def __init__(self, working_dir: str, parent=None) -> None: + def __init__(self, working_dir: str, node_ids: list[str] | None = None, parent=None) -> None: """ :param working_dir: 執行測試的目錄 / the directory to run the tests in + :param node_ids: 只跑這些測試 / run only these tests :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) self._working_dir = working_dir + self._node_ids = node_ids def run(self) -> None: """執行測試並回報輸出 / Run the tests and report their output.""" try: completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 - pytest_command(), + pytest_command(self._node_ids), cwd=self._working_dir, capture_output=True, text=True, @@ -107,6 +116,13 @@ def __init__(self, main_window=None, working_dir: str | None = None) -> None: self.run_button = QPushButton(word.get("test_panel_run")) self.run_button.clicked.connect(self.start_run) + self.run_selected_button = QPushButton(word.get("test_panel_run_selected")) + self.run_selected_button.clicked.connect(self.start_selected_run) + self.rerun_failures_button = QPushButton(word.get("test_panel_rerun_failures")) + self.rerun_failures_button.clicked.connect(self.start_failure_run) + self.filter_edit = QLineEdit() + self.filter_edit.setPlaceholderText(word.get("test_panel_filter_placeholder")) + self.filter_edit.textChanged.connect(self._render_items) self.status_label = QLabel(word.get("test_panel_ready")) self.result_tree = QTreeWidget() @@ -122,6 +138,9 @@ def __init__(self, main_window=None, working_dir: str | None = None) -> None: controls = QHBoxLayout() controls.addWidget(self.run_button) + controls.addWidget(self.run_selected_button) + controls.addWidget(self.rerun_failures_button) + controls.addWidget(self.filter_edit) controls.addWidget(self.status_label) controls.addStretch() @@ -134,7 +153,7 @@ def results(self) -> list[PytestResult]: """取得目前顯示的結果 / The results currently listed.""" return list(self._results) - def start_run(self) -> bool: + def start_run(self, node_ids: list[str] | None = None) -> bool: """ 啟動一次測試執行 Start one test run. @@ -142,18 +161,66 @@ def start_run(self) -> bool: 已經在執行時會忽略重複觸發,避免覆寫仍在跑的執行緒。 A re-entrant trigger is ignored so a still-running thread is never dropped. + :param node_ids: 只跑這些測試,``None`` 表示全部 / run only these, or all :return: 是否真的啟動 / whether a run actually started """ if self._thread is not None and self._thread.isRunning(): return False self.run_button.setEnabled(False) self.status_label.setText(language_wrapper.language_word_dict.get("test_panel_running")) - self._thread = PytestRunThread(self._working_dir) + self._thread = PytestRunThread(self._working_dir, node_ids) self._thread.finished_output.connect(self.apply_output) self._thread.finished.connect(self._thread.deleteLater) self._thread.start() return True + def selected_node_ids(self) -> list[str]: + """ + 取得清單中被選取的測試 + The tests currently selected in the list. + + :return: 節點名稱 / their node ids + """ + node_ids: list[str] = [] + for item in self.result_tree.selectedItems(): + result = item.data(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole) + if result is not None: + node_ids.append(result.node_id) + return node_ids + + def failed_node_ids(self) -> list[str]: + """ + 取得上一輪失敗的測試 + The tests that failed in the last run. + + :return: 節點名稱 / their node ids + """ + return [result.node_id for result in self._results if result.failed] + + def start_selected_run(self) -> bool: + """ + 只重跑選取的測試 + Re-run only the selected tests. + + :return: 是否真的啟動 / whether a run actually started + """ + selected = self.selected_node_ids() + return self.start_run(selected) if selected else False + + def start_failure_run(self) -> bool: + """ + 只重跑上一輪失敗的測試 + Re-run only the tests that failed last time. + + 修一個失敗之後不必等整輪跑完,這是這個面板最常用的動作。 + After fixing one failure there is no need to wait for the whole suite, + which makes this the panel's most used action. + + :return: 是否真的啟動 / whether a run actually started + """ + failures = self.failed_node_ids() + return self.start_run(failures) if failures else False + def apply_output(self, output: str) -> None: """ 解析輸出並更新清單 @@ -172,11 +239,24 @@ def apply_output(self, output: str) -> None: self.status_label.setText( language_wrapper.language_word_dict.get("test_panel_no_results")) + def visible_results(self) -> list[PytestResult]: + """ + 取得符合篩選條件的結果,失敗的排在最前面 + The results matching the filter, failures first. + + :return: 要顯示的結果 / the results to show + """ + needle = self.filter_edit.text().strip().lower() + matching = [ + result for result in self._results + if not needle or needle in result.node_id.lower() + ] + return sorted(matching, key=lambda result: not result.failed) + def _render_items(self) -> None: - """依目前結果重建清單,失敗的排在最前面 / Rebuild the tree, failures first.""" + """依目前結果與篩選條件重建清單 / Rebuild the tree for the current filter.""" self.result_tree.clear() - ordered = sorted(self._results, key=lambda result: not result.failed) - for result in ordered: + for result in self.visible_results(): row = QTreeWidgetItem([result.outcome, result.name, result.file_path]) row.setData(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole, result) self.result_tree.addTopLevelItem(row) diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 283ccf1f..4fd81547 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -392,6 +392,9 @@ # Test panel "tab_menu_test_panel_tab_name": "Tests", "test_panel_run": "Run Tests", + "test_panel_run_selected": "Run Selected", + "test_panel_rerun_failures": "Re-run Failures", + "test_panel_filter_placeholder": "Filter tests...", "test_panel_ready": "Ready", "test_panel_running": "Running tests...", "test_panel_no_results": "No tests were reported", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index f3e2cfd5..923feb50 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -382,6 +382,9 @@ # Test panel "tab_menu_test_panel_tab_name": "測試", "test_panel_run": "執行測試", + "test_panel_run_selected": "只跑選取的", + "test_panel_rerun_failures": "重跑失敗的", + "test_panel_filter_placeholder": "篩選測試...", "test_panel_ready": "就緒", "test_panel_running": "測試執行中...", "test_panel_no_results": "沒有回報任何測試", diff --git a/test/test_pytest_panel.py b/test/test_pytest_panel.py index 69b9b970..7f4f1e57 100644 --- a/test/test_pytest_panel.py +++ b/test/test_pytest_panel.py @@ -160,3 +160,55 @@ def test_command_is_an_argument_list(self): command = pytest_command() assert command[1:3] == ["-m", "pytest"] assert "-v" in command and "--tb=line" in command + + def test_command_can_target_specific_tests(self): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command + command = pytest_command(["test/test_a.py::test_one"]) + assert command[-1] == "test/test_a.py::test_one" + + +class TestTargetedRuns: + def test_failed_node_ids_are_collected(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert panel.failed_node_ids() == ["test/test_alpha.py::TestAlpha::test_two"] + + def test_rerunning_failures_without_any_does_nothing(self, panel): + panel.apply_output("collected 0 items\n") + assert panel.start_failure_run() is False + + def test_running_the_selection_without_one_does_nothing(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.result_tree.clearSelection() + assert panel.start_selected_run() is False + + def test_selected_node_ids_follow_the_tree(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.result_tree.topLevelItem(0).setSelected(True) + assert panel.selected_node_ids() == ["test/test_alpha.py::TestAlpha::test_two"] + + +class TestFiltering: + def test_an_empty_filter_shows_everything(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert len(panel.visible_results()) == 3 + + def test_filtering_narrows_the_list(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.filter_edit.setText("beta") + assert [result.file_path for result in panel.visible_results()] == ["test/test_beta.py"] + assert panel.result_tree.topLevelItemCount() == 1 + + def test_filtering_is_case_insensitive(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.filter_edit.setText("ALPHA") + assert len(panel.visible_results()) == 2 + + def test_a_filter_matching_nothing_empties_the_list(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.filter_edit.setText("nothing matches this") + assert panel.visible_results() == [] + + def test_failures_still_come_first_when_filtered(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.filter_edit.setText("alpha") + assert panel.visible_results()[0].failed is True From dbce35505c0be6bb541681976cce66feeb74674c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:20:49 +0800 Subject: [PATCH 24/36] Give the problems panel severity, scope and fixes The panel listed the current tab''s findings and nothing else. It now also checks the whole project on demand, filters by severity, and applies the fixes ruff can make. Severity comes from the rule code for ruff findings and from the server''s own field for LSP ones, so both sources sort into the same three levels. Project results carry the file they came from, and double-clicking one opens that file before jumping. Applying fixes rewrites files on disk, so the open tab is re-read afterwards instead of being left on stale text. --- je_editor/code_scan/ruff_lint.py | 63 +++++++ .../problems_panel/problems_panel_widget.py | 117 +++++++++++-- je_editor/utils/lint/ruff_diagnostics.py | 50 +++++- je_editor/utils/multi_language/english.py | 4 + .../multi_language/traditional_chinese.py | 4 + test/test_problems_panel.py | 163 ++++++++++++++++++ test/test_ruff_diagnostics.py | 3 +- 7 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 test/test_problems_panel.py diff --git a/je_editor/code_scan/ruff_lint.py b/je_editor/code_scan/ruff_lint.py index e7302741..5762d444 100644 --- a/je_editor/code_scan/ruff_lint.py +++ b/je_editor/code_scan/ruff_lint.py @@ -75,6 +75,69 @@ def lint_command(executable: str, file_path: str | Path) -> list[str]: ] +def _run_ruff(arguments: list[str], text: str | None = None) -> str: + """ + 執行一次 ruff 並回傳其標準輸出 + Run ruff once and return what it printed. + + ruff 找到問題時回傳碼是 1,那是結果而不是失敗,因此不檢查回傳碼。 + ruff exits with 1 when it finds problems, which is a result rather than a + failure, so the return code is not inspected. + """ + executable = find_ruff_executable() + if executable is None: + return "" + try: + completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 + [executable, *arguments], + input=text, + capture_output=True, + text=True, + encoding="utf-8", + timeout=LINT_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + jeditor_logger.debug(f"ruff_lint: could not run ruff: {error!r}") + return "" + return completed.stdout or "" + + +def lint_project(root: str | Path) -> list[Diagnostic]: + """ + 檢查整個專案目錄 + Lint a whole project directory. + + 這裡檢查的是磁碟上的檔案,因此未存檔的修改不會反映在結果中——與單檔檢查 + 不同,那是直接檢查緩衝區。 + This lints the files on disk, so unsaved edits are not reflected, unlike the + single-file check which lints the buffer itself. + + :param root: 專案根目錄 / the project root + :return: 診斷清單,含各自的檔案路徑 / the diagnostics, each with its file + """ + return parse_ruff_json( + _run_ruff(["check", "--output-format", "json", str(root)])) + + +def apply_fixes(target: str | Path) -> bool: + """ + 對檔案或目錄套用 ruff 能自動修正的部分 + Apply the fixes ruff can make to a file or directory. + + 會改寫磁碟上的檔案,因此呼叫端要負責重新載入編輯器中的內容。 + This rewrites the files on disk, so the caller is responsible for reloading + whatever the editor is showing. + + :param target: 檔案或目錄 / the file or directory to fix + :return: ruff 可用且已執行時為 ``True`` / ``True`` when ruff ran + """ + if find_ruff_executable() is None: + return False + _run_ruff(["check", "--fix", "--output-format", "json", str(target)]) + return True + + def lint_text(text: str, file_path: str | Path) -> list[Diagnostic]: """ 檢查一段內容並回傳診斷 diff --git a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py index 3cc58cfa..3f8f2fab 100644 --- a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py @@ -8,20 +8,31 @@ """ from __future__ import annotations +import os +from pathlib import Path + from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget + QCheckBox, QComboBox, QHBoxLayout, QLabel, QPushButton, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget ) -from je_editor.utils.lint.ruff_diagnostics import Diagnostic +from je_editor.code_scan.ruff_lint import apply_fixes, lint_project +from je_editor.utils.file.open.open_file import read_file_with_encoding +from je_editor.utils.lint.ruff_diagnostics import ( + SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING, Diagnostic +) from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper # 樹狀清單欄位索引 / Column indexes in the tree COLUMN_CODE = 0 COLUMN_MESSAGE = 1 COLUMN_LINE = 2 +COLUMN_FILE = 3 # 訊息欄的預設寬度 / Default width of the message column MESSAGE_COLUMN_WIDTH = 460 +# 「全部嚴重度」的篩選值 / Filter value meaning "every severity" +ALL_SEVERITIES = "*" def current_code_editor(main_window): @@ -57,14 +68,24 @@ def __init__(self, main_window=None) -> None: self.refresh_button = QPushButton(word.get("problems_panel_refresh")) self.refresh_button.clicked.connect(self.refresh) + self.project_check = QCheckBox(word.get("problems_panel_whole_project")) + self.project_check.stateChanged.connect(self.refresh) + self.severity_filter = QComboBox() + self.severity_filter.addItem(word.get("problems_panel_all_severities"), ALL_SEVERITIES) + for severity in (SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO): + self.severity_filter.addItem(severity, severity) + self.severity_filter.currentIndexChanged.connect(self._render_items) + self.fix_button = QPushButton(word.get("problems_panel_fix")) + self.fix_button.clicked.connect(self.apply_available_fixes) self.status_label = QLabel(word.get("problems_panel_ready")) self.result_tree = QTreeWidget() - self.result_tree.setColumnCount(3) + self.result_tree.setColumnCount(4) self.result_tree.setHeaderLabels([ word.get("problems_panel_col_code"), word.get("problems_panel_col_message"), word.get("problems_panel_col_line"), + word.get("problems_panel_col_file"), ]) self.result_tree.setColumnWidth(COLUMN_MESSAGE, MESSAGE_COLUMN_WIDTH) self.result_tree.setRootIsDecorated(False) @@ -72,6 +93,9 @@ def __init__(self, main_window=None) -> None: controls = QHBoxLayout() controls.addWidget(self.refresh_button) + controls.addWidget(self.project_check) + controls.addWidget(self.severity_filter) + controls.addWidget(self.fix_button) controls.addWidget(self.status_label) controls.addStretch() @@ -95,26 +119,87 @@ def refresh(self) -> None: The editor keeps checking in the background, so this only picks up its latest result. """ - code_edit = current_code_editor(self._main_window) - if code_edit is None: - self._diagnostics = [] + if self.project_check.isChecked(): + self._diagnostics = lint_project(self._project_root()) else: - code_edit.request_lint() - self._diagnostics = code_edit.lint_manager.diagnostics() + code_edit = current_code_editor(self._main_window) + if code_edit is None: + self._diagnostics = [] + else: + code_edit.request_lint() + self._diagnostics = code_edit.lint_manager.diagnostics() self._render_items() + def _project_root(self) -> str: + """取得要檢查的專案根目錄 / The project root to check.""" + working_dir = getattr(self._main_window, "working_dir", None) + if working_dir and Path(str(working_dir)).is_dir(): + return str(working_dir) + return os.getcwd() + + def visible_diagnostics(self) -> list[Diagnostic]: + """ + 取得符合嚴重度篩選的診斷 + The diagnostics matching the severity filter. + + :return: 要顯示的診斷 / the diagnostics to show + """ + selected = self.severity_filter.currentData() + if selected in (None, ALL_SEVERITIES): + return list(self._diagnostics) + return [item for item in self._diagnostics if item.level == selected] + + def apply_available_fixes(self) -> bool: + """ + 套用 ruff 能自動修正的部分,並重新檢查 + Apply the fixes ruff can make, then check again. + + 修正會改寫磁碟上的檔案,因此完成後把目前分頁重新讀進來,畫面才不會停在 + 舊內容上。 + Fixing rewrites the files on disk, so the current tab is reloaded + afterwards rather than left showing the old content. + + :return: ruff 可用並已執行時為 ``True`` / ``True`` when ruff ran + """ + target = self._project_root() if self.project_check.isChecked() else self._current_file() + if target is None: + return False + if not apply_fixes(target): + return False + self._reload_current_tab() + self.refresh() + return True + + def _current_file(self) -> str | None: + """目前分頁的檔案 / The current tab's file.""" + code_edit = current_code_editor(self._main_window) + path = getattr(code_edit, "current_file", None) if code_edit is not None else None + return str(path) if path else None + + def _reload_current_tab(self) -> None: + """把目前分頁的內容重新讀進來 / Re-read the current tab's file from disk.""" + code_edit = current_code_editor(self._main_window) + path = self._current_file() + if code_edit is None or path is None: + return + result = read_file_with_encoding(path) + if result is not None: + code_edit.setPlainText(result[1]) + def _render_items(self) -> None: - """依目前診斷重建清單 / Rebuild the tree from the current diagnostics.""" + """依目前診斷與篩選條件重建清單 / Rebuild the tree for the current filter.""" word = language_wrapper.language_word_dict + visible = self.visible_diagnostics() self.result_tree.clear() - for diagnostic in self._diagnostics: + for diagnostic in visible: row = QTreeWidgetItem([ - diagnostic.code, diagnostic.message, str(diagnostic.line)]) + diagnostic.code, diagnostic.message, str(diagnostic.line), + Path(diagnostic.file_path).name if diagnostic.file_path else ""]) row.setData(COLUMN_CODE, Qt.ItemDataRole.UserRole, diagnostic) self.result_tree.addTopLevelItem(row) - if self._diagnostics: + if visible: self.status_label.setText( - word.get("problems_panel_found").format(count=len(self._diagnostics))) + word.get("problems_panel_found").format(count=len(visible))) else: self.status_label.setText(word.get("problems_panel_clean")) @@ -127,12 +212,14 @@ def _open_item(self, row: QTreeWidgetItem, _column: int) -> None: def jump_to_diagnostic(self, diagnostic: Diagnostic) -> bool: """ - 把目前分頁的游標移到診斷所在行 - Move the current tab's caret to a diagnostic's line. + 跳到診斷所在的位置,必要時先開啟該檔案 + Jump to a diagnostic, opening its file first when it is another one. :param diagnostic: 目標診斷 / the diagnostic to jump to :return: 成功跳轉時為 ``True`` / ``True`` when the caret moved """ + if diagnostic.file_path and hasattr(self._main_window, "go_to_new_tab"): + self._main_window.go_to_new_tab(Path(diagnostic.file_path)) code_edit = current_code_editor(self._main_window) if code_edit is None: return False diff --git a/je_editor/utils/lint/ruff_diagnostics.py b/je_editor/utils/lint/ruff_diagnostics.py index 069b096f..56543ac5 100644 --- a/je_editor/utils/lint/ruff_diagnostics.py +++ b/je_editor/utils/lint/ruff_diagnostics.py @@ -20,6 +20,38 @@ # ruff 對語法錯誤不會給規則代碼 / ruff reports no rule code for a syntax error SYNTAX_ERROR_CODE = "SyntaxError" +# 嚴重度 / Severity levels +SEVERITY_ERROR = "error" +SEVERITY_WARNING = "warning" +SEVERITY_INFO = "info" + +# ruff 規則代碼開頭對應的嚴重度;其餘視為提示 +# What each ruff rule-code prefix means; anything else counts as a hint +_SEVERITY_BY_PREFIX = { + "E": SEVERITY_ERROR, # pycodestyle errors + "F": SEVERITY_ERROR, # pyflakes + "W": SEVERITY_WARNING, # pycodestyle warnings + "C": SEVERITY_WARNING, # complexity + "B": SEVERITY_WARNING, # bugbear + "S": SEVERITY_WARNING, # security +} + +# LSP 的嚴重度編號 / The numbers LSP uses for severity +_LSP_SEVERITY = {1: SEVERITY_ERROR, 2: SEVERITY_WARNING, 3: SEVERITY_INFO, 4: SEVERITY_INFO} + + +def severity_for_code(code: str) -> str: + """ + 由規則代碼推出嚴重度 + Work out a severity from a rule code. + + :param code: 規則代碼,例如 ``F401`` / the rule code, such as ``F401`` + :return: 嚴重度 / the severity + """ + if not code or code == SYNTAX_ERROR_CODE: + return SEVERITY_ERROR + return _SEVERITY_BY_PREFIX.get(code[0].upper(), SEVERITY_INFO) + @dataclass(frozen=True) class Diagnostic: @@ -33,6 +65,10 @@ class Diagnostic: :param end_column: 結束欄 / end column :param code: 規則代碼,例如 ``F401`` / the rule code, e.g. ``F401`` :param message: 說明文字 / the human-readable message + :param severity: 嚴重度,未給時由代碼推出 / the severity, derived from the code + when not given + :param file_path: 所屬檔案;只檢查目前緩衝區時為空 + the file it belongs to, empty when only the current buffer was checked """ line: int @@ -41,12 +77,19 @@ class Diagnostic: end_column: int code: str message: str + severity: str = "" + file_path: str = "" @property def label(self) -> str: """給面板顯示的一行說明 / A single line for the panel.""" return f"{self.code} {self.message}" if self.code else self.message + @property + def level(self) -> str: + """嚴重度;沒有明講時由規則代碼推出 / The severity, derived from the code if unset.""" + return self.severity or severity_for_code(self.code) + def _as_position(raw: object, fallback_row: int, fallback_column: int) -> tuple[int, int]: """讀出 ``{"row": n, "column": n}``,缺漏時退回預設 / Read a position, with fallbacks.""" @@ -79,6 +122,7 @@ def _as_diagnostic(entry: object) -> Diagnostic | None: end_column=end_column, code=code if isinstance(code, str) else SYNTAX_ERROR_CODE, message=message, + file_path=str(entry.get("filename") or ""), ) @@ -132,9 +176,13 @@ def diagnostic_from_entry(entry: dict) -> Diagnostic | None: end_column = entry.get("end_column") end_column = end_column if isinstance(end_column, int) and end_column >= 1 else column code = entry.get("code") + severity = entry.get("severity") return Diagnostic( line=line, column=column, end_line=end_line, end_column=end_column, - code=code if isinstance(code, str) and code else SYNTAX_ERROR_CODE, message=message) + code=code if isinstance(code, str) and code else SYNTAX_ERROR_CODE, + message=message, + severity=_LSP_SEVERITY.get(severity, "") if isinstance(severity, int) else "", + file_path=str(entry.get("file_path") or "")) def diagnostics_from_entries(entries: list) -> list[Diagnostic]: diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 4fd81547..db8d40ca 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -410,6 +410,10 @@ "problems_panel_col_code": "Rule", "problems_panel_col_message": "Message", "problems_panel_col_line": "Line", + "problems_panel_col_file": "File", + "problems_panel_whole_project": "Whole project", + "problems_panel_all_severities": "All severities", + "problems_panel_fix": "Apply Fixes", # Outline panel "tab_menu_outline_panel_tab_name": "Outline", "outline_panel_refresh": "Refresh", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 923feb50..689180b1 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -400,6 +400,10 @@ "problems_panel_col_code": "規則", "problems_panel_col_message": "訊息", "problems_panel_col_line": "行號", + "problems_panel_col_file": "檔案", + "problems_panel_whole_project": "整個專案", + "problems_panel_all_severities": "所有嚴重度", + "problems_panel_fix": "套用修正", # Outline panel "tab_menu_outline_panel_tab_name": "大綱", "outline_panel_refresh": "重新整理", diff --git a/test/test_problems_panel.py b/test/test_problems_panel.py new file mode 100644 index 00000000..150ab164 --- /dev/null +++ b/test/test_problems_panel.py @@ -0,0 +1,163 @@ +"""Tests for severity, project-wide checking and applying fixes.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.code_scan.ruff_lint import apply_fixes, find_ruff_executable, lint_project +from je_editor.utils.lint.ruff_diagnostics import ( + SEVERITY_ERROR, + SEVERITY_INFO, + SEVERITY_WARNING, + Diagnostic, + parse_ruff_json, + severity_for_code, +) + + +class TestSeverity: + @pytest.mark.parametrize("code,expected", [ + ("F401", SEVERITY_ERROR), + ("E501", SEVERITY_ERROR), + ("W291", SEVERITY_WARNING), + ("B008", SEVERITY_WARNING), + ("S603", SEVERITY_WARNING), + ("D100", SEVERITY_INFO), + ]) + def test_code_prefix_decides(self, code, expected): + assert severity_for_code(code) == expected + + def test_a_syntax_error_is_an_error(self): + assert severity_for_code("") == SEVERITY_ERROR + assert severity_for_code("SyntaxError") == SEVERITY_ERROR + + def test_diagnostic_derives_its_level(self): + diagnostic = Diagnostic( + line=1, column=1, end_line=1, end_column=2, code="W291", message="x") + assert diagnostic.level == SEVERITY_WARNING + + def test_an_explicit_severity_wins(self): + diagnostic = Diagnostic( + line=1, column=1, end_line=1, end_column=2, code="W291", message="x", + severity=SEVERITY_ERROR) + assert diagnostic.level == SEVERITY_ERROR + + def test_ruff_output_carries_the_file(self): + output = json.dumps([{ + "code": "F401", "message": "unused", + "filename": "/project/app.py", + "location": {"row": 1, "column": 1}, + "end_location": {"row": 1, "column": 5}, + }]) + assert parse_ruff_json(output)[0].file_path == "/project/app.py" + + +class TestProjectLint: + def test_finds_problems_across_files(self, tmp_path): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + (tmp_path / "one.py").write_text("import os\n", encoding="utf-8") + (tmp_path / "two.py").write_text("import sys\n", encoding="utf-8") + diagnostics = lint_project(tmp_path) + assert len(diagnostics) >= 2 + # Each finding says which file it came from, which is what makes a + # project-wide list navigable. + assert all(item.file_path for item in diagnostics) + assert len({item.file_path for item in diagnostics}) == 2 + + def test_a_clean_project_reports_nothing(self, tmp_path): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + (tmp_path / "clean.py").write_text("print('ok')\n", encoding="utf-8") + assert lint_project(tmp_path) == [] + + def test_missing_ruff_yields_nothing(self, tmp_path): + with patch("je_editor.code_scan.ruff_lint.find_ruff_executable", return_value=None): + assert lint_project(tmp_path) == [] + + +class TestApplyFixes: + def test_removes_an_unused_import(self, tmp_path): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + target = tmp_path / "fixable.py" + target.write_text("import os\nprint('hello')\n", encoding="utf-8") + assert apply_fixes(target) is True + assert "import os" not in target.read_text(encoding="utf-8") + + def test_missing_ruff_is_reported(self, tmp_path): + with patch("je_editor.code_scan.ruff_lint.find_ruff_executable", return_value=None): + assert apply_fixes(tmp_path) is False + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +class _FakeTabWidget: + def __init__(self, widget=None): + self._widget = widget + + def currentWidget(self): + return self._widget + + +@pytest.fixture() +def panel(app): + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + window = MagicMock() + window.tab_widget = _FakeTabWidget(None) + widget = ProblemsPanelWidget(window) + yield widget + widget.close() + widget.deleteLater() + + +def _diagnostics() -> list[Diagnostic]: + return [ + Diagnostic(line=1, column=1, end_line=1, end_column=2, code="F401", message="unused"), + Diagnostic(line=2, column=1, end_line=2, end_column=2, code="W291", message="space"), + Diagnostic(line=3, column=1, end_line=3, end_column=2, code="D100", message="docstring"), + ] + + +class TestSeverityFilter: + def test_everything_is_shown_by_default(self, panel): + panel._diagnostics = _diagnostics() + assert len(panel.visible_diagnostics()) == 3 + + def test_filtering_to_errors(self, panel): + panel._diagnostics = _diagnostics() + panel.severity_filter.setCurrentIndex(panel.severity_filter.findData(SEVERITY_ERROR)) + assert [item.code for item in panel.visible_diagnostics()] == ["F401"] + + def test_filtering_to_warnings(self, panel): + panel._diagnostics = _diagnostics() + panel.severity_filter.setCurrentIndex(panel.severity_filter.findData(SEVERITY_WARNING)) + assert [item.code for item in panel.visible_diagnostics()] == ["W291"] + + def test_the_tree_follows_the_filter(self, panel): + panel._diagnostics = _diagnostics() + panel.severity_filter.setCurrentIndex(panel.severity_filter.findData(SEVERITY_INFO)) + assert panel.result_tree.topLevelItemCount() == 1 + + +class TestProjectScope: + def test_project_mode_lints_the_root(self, panel, tmp_path): + with patch( + "je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget.lint_project", + return_value=_diagnostics(), + ) as mock_lint: + panel.project_check.setChecked(True) + assert mock_lint.called + assert len(panel.diagnostics()) == 3 + + def test_fixing_without_a_file_does_nothing(self, panel): + panel.project_check.setChecked(False) + assert panel.apply_available_fixes() is False diff --git a/test/test_ruff_diagnostics.py b/test/test_ruff_diagnostics.py index e60ea298..e385c6b3 100644 --- a/test/test_ruff_diagnostics.py +++ b/test/test_ruff_diagnostics.py @@ -32,7 +32,8 @@ def test_single_finding(self): found = parse_ruff_json(json.dumps([_entry()])) assert found == [Diagnostic( line=1, column=8, end_line=1, end_column=10, - code="F401", message="`os` imported but unused")] + code="F401", message="`os` imported but unused", + file_path="/project/app.py")] def test_several_findings_keep_their_order(self): output = json.dumps([ From 5afc63ada889ba58996fecb9d270030ad5338eb4 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:26:28 +0800 Subject: [PATCH 25/36] Use the language server for hover, rename and formatting Completion and diagnostics were all the client asked for. The context menu now also describes the symbol under the caret, renames it, and formats the document, each backed by the matching LSP request. Renaming falls back to the existing whole-file word replace when no server is running, so a Python file keeps the behaviour it had. Edits coming back from a server are applied from the end of the document backwards, so one edit never shifts another that has not been applied yet, and the batch is a single undo step. The reply parsing covers the shapes servers actually use: hover contents as a string, an object or a list of both, and edits either as a plain list or inside a WorkspaceEdit keyed by URI. --- je_editor/pyside_ui/code/lsp/lsp_client.py | 68 +++++++ .../code_edit_plaintext.py | 132 ++++++++++++++ je_editor/utils/lsp/lsp_protocol.py | 71 ++++++++ je_editor/utils/multi_language/english.py | 4 + .../multi_language/traditional_chinese.py | 4 + test/test_lsp_features.py | 167 ++++++++++++++++++ 6 files changed, 446 insertions(+) create mode 100644 test/test_lsp_features.py diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index f80fe2db..d18b9b51 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -27,8 +27,10 @@ diagnostic_entries, encode_message, file_uri, + hover_text, notification, request, + text_edits, ) # 等待伺服器結束的時間(毫秒)/ How long to wait for the server to exit @@ -44,6 +46,8 @@ class LspClient(QObject): completions_ready = Signal(list) # list[str] diagnostics_ready = Signal(list) # list[dict] definition_ready = Signal(dict) # {"path": str, "line": int, "column": int} + hover_ready = Signal(str) + edits_ready = Signal(list) # list[dict] of text edits to apply def __init__(self, parent: QObject | None = None) -> None: """ @@ -57,6 +61,8 @@ def __init__(self, parent: QObject | None = None) -> None: self._version = 0 self._pending_completion_id: int | None = None self._pending_definition_id: int | None = None + self._pending_hover_id: int | None = None + self._pending_edit_id: int | None = None @property def running(self) -> bool: @@ -162,6 +168,55 @@ def request_completion(self, line: int, column: int) -> bool: "position": {"line": line, "character": column}, })) + def request_hover(self, line: int, column: int) -> bool: + """ + 要求某個位置的說明 + Ask for the description of what is at a position. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + return self._position_request( + "textDocument/hover", line, column, "_pending_hover_id") + + def request_rename(self, line: int, column: int, new_name: str) -> bool: + """ + 要求把某個位置的符號重新命名 + Ask to rename the symbol at a position. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :param new_name: 新名稱 / the new name + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None or not new_name: + return False + request_id = self._take_id() + self._pending_edit_id = request_id + return self._send(request(request_id, "textDocument/rename", { + "textDocument": {"uri": file_uri(self._file_path)}, + "position": {"line": line, "character": column}, + "newName": new_name, + })) + + def request_formatting(self, tab_size: int = 4) -> bool: + """ + 要求格式化整份檔案 + Ask the server to format the whole file. + + :param tab_size: 縮排寬度 / the indent width to format with + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_edit_id = request_id + return self._send(request(request_id, "textDocument/formatting", { + "textDocument": {"uri": file_uri(self._file_path)}, + "options": {"tabSize": tab_size, "insertSpaces": True}, + })) + def request_definition(self, line: int, column: int) -> bool: """ 要求某個位置的定義位置 @@ -207,6 +262,19 @@ def handle_message(self, message: dict) -> None: location = definition_location(message.get("result")) if location is not None: self.definition_ready.emit(location) + return + if message.get("id") == self._pending_hover_id: + self._pending_hover_id = None + text = hover_text(message.get("result")) + if text: + self.hover_ready.emit(text) + return + if message.get("id") == self._pending_edit_id: + self._pending_edit_id = None + uri = file_uri(self._file_path) if self._file_path else "" + edits = text_edits(message.get("result"), uri) + if edits: + self.edits_ready.emit(edits) def _read_output(self) -> None: """讀取伺服器輸出並逐則處理 / Read the server's output and handle each message.""" 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 2ec41401..52fddc7b 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 @@ -118,6 +118,22 @@ def run(self) -> None: } +def _document_position(document: QTextDocument, line: int, column: int) -> Union[int, None]: + """ + 把 1 起算的行列換成文件中的字元位置 + Turn a 1-based line and column into a character position in the document. + + :param document: 目標文件 / the document to look in + :param line: 1 起算的行號 / the 1-based line + :param column: 1 起算的欄位 / the 1-based column + :return: 字元位置,該行不存在時為 ``None`` / the position, or ``None`` + """ + block = document.findBlockByNumber(line - 1) + if not block.isValid(): + return None + return block.position() + min(max(0, column - 1), len(block.text())) + + def _lint_underline_format() -> QTextCharFormat: """診斷底線的樣式 / The format used to underline a diagnostic.""" formats = QTextCharFormat() @@ -330,6 +346,9 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # server; Python keeps jedi and ruff self.lsp_client.completions_ready.connect(self.set_complete) self.lsp_client.diagnostics_ready.connect(self.apply_server_diagnostics) + self.lsp_client.hover_ready.connect(self.show_hover_text) + self.lsp_client.edits_ready.connect(self.apply_server_edits) + self.lsp_client.definition_ready.connect(self.go_to_definition_location) self.start_language_server() def reset_highlighter(self) -> None: @@ -2327,6 +2346,10 @@ def build_context_menu(self) -> QMenu: ("context_menu_toggle_bookmark", self.toggle_bookmark, True), ("context_menu_go_to_definition", self.go_to_definition, self.lsp_client.running), + ("context_menu_hover", self.request_hover, self.lsp_client.running), + ("context_menu_rename_symbol", self.rename_symbol, True), + ("context_menu_format_document", self.format_with_language_server, + self.lsp_client.running), ("context_menu_revert_hunk", self.revert_change_at_cursor, self.diff_marker_manager.has_baseline), ): @@ -2341,6 +2364,115 @@ def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None: menu.exec(event.globalPos()) menu.deleteLater() + def go_to_definition_location(self, location: dict) -> bool: + """ + 跳到語言伺服器回報的定義位置 + Jump to the definition a language server reported. + + 定義在別的檔案時交給主視窗開啟;在同一個檔案就直接跳行。 + A definition in another file is opened by the main window; one in this + file is just a jump. + + :param location: ``{"path", "line", "column"}`` + :return: 有跳轉時為 ``True`` / ``True`` when the caret moved + """ + path = location.get("path", "") + line = location.get("line", 1) + same_file = self.current_file is not None and Path(str(self.current_file)) == Path(path) + if not same_file and path: + window = getattr(self.main_window, "main_window", None) + if window is not None and hasattr(window, "go_to_new_tab"): + window.go_to_new_tab(Path(path)) + return True + return False + return self.jump_to_line(line) + + def apply_server_edits(self, edits: list) -> bool: + """ + 套用語言伺服器回傳的文字編輯 + Apply the text edits a language server returned. + + 由後往前套用,前面的編輯位置就不會被前一次改動推移;整批算一個復原步驟。 + The edits are applied from the end backwards so an earlier one is never + moved by a change already made after it, and the batch is one undo step. + + :param edits: 編輯清單 / the edits to apply + :return: 有套用時為 ``True`` / ``True`` when anything was applied + """ + if not edits: + return False + document = self.document() + ordered = sorted( + edits, key=lambda edit: (edit["start_line"], edit["start_column"]), reverse=True) + cursor = self.textCursor() + cursor.beginEditBlock() + try: + for edit in ordered: + start = _document_position(document, edit["start_line"], edit["start_column"]) + end = _document_position(document, edit["end_line"], edit["end_column"]) + if start is None or end is None: + continue + cursor.setPosition(start) + cursor.setPosition(max(end, start), QTextCursor.MoveMode.KeepAnchor) + cursor.insertText(edit["new_text"]) + finally: + cursor.endEditBlock() + return True + + def show_hover_text(self, text: str) -> None: + """ + 顯示語言伺服器回傳的說明 + Show the description a language server returned. + + :param text: 說明文字 / the description + """ + self.setToolTip(text) + + def request_hover(self) -> bool: + """ + 向語言伺服器要求游標所在符號的說明 + Ask the language server to describe the symbol under the caret. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + return self.lsp_client.request_hover( + cursor.blockNumber(), cursor.positionInBlock()) + + def rename_symbol(self) -> bool: + """ + 透過語言伺服器重新命名游標所在的符號 + Rename the symbol under the caret through the language server. + + 沒有伺服器時退回既有的整份檔案字詞取代,因此 Python 檔仍然可以改名。 + Without a server this falls back to the existing whole-file word replace, + so renaming still works in a Python file. + + :return: 有進行重新命名時為 ``True`` / ``True`` when a rename happened + """ + if not self.lsp_client.running: + return self.rename_word_under_cursor() + cursor = self.textCursor() + current = self.textCursor() + current.select(QTextCursor.SelectionType.WordUnderCursor) + new_name, confirmed = QInputDialog.getText( + self, language_wrapper.language_word_dict.get("context_menu_rename_symbol"), + language_wrapper.language_word_dict.get("context_menu_rename_prompt"), + text=current.selectedText()) + if not confirmed or not new_name: + return False + return self.lsp_client.request_rename( + cursor.blockNumber(), cursor.positionInBlock(), new_name) + + def format_with_language_server(self) -> bool: + """ + 請語言伺服器格式化整份檔案 + Ask the language server to format the whole file. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + return self.lsp_client.request_formatting(self.indent_size()) + def go_to_definition(self) -> bool: """ 請語言伺服器跳到游標所在符號的定義 diff --git a/je_editor/utils/lsp/lsp_protocol.py b/je_editor/utils/lsp/lsp_protocol.py index 10ec011a..f6c93103 100644 --- a/je_editor/utils/lsp/lsp_protocol.py +++ b/je_editor/utils/lsp/lsp_protocol.py @@ -202,6 +202,77 @@ def definition_location(result: object) -> dict | None: return {"path": path, "line": line, "column": column} +def hover_text(result: object) -> str: + """ + 從 hover 回應取出說明文字 + Take the description out of a hover response. + + ``contents`` 可能是字串、``{"value": ...}``,或兩者混合的清單,全部都要接受。 + ``contents`` may be a string, a ``{"value": ...}`` object, or a list mixing + both, and all of those are accepted. + + :param result: 伺服器的回應內容 / the server's result + :return: 說明文字,沒有內容時為空字串 / the text, or an empty string + """ + if not isinstance(result, dict): + return "" + contents = result.get("contents") + parts = contents if isinstance(contents, list) else [contents] + texts: list[str] = [] + for part in parts: + if isinstance(part, str) and part.strip(): + texts.append(part.strip()) + elif isinstance(part, dict): + value = part.get("value") + if isinstance(value, str) and value.strip(): + texts.append(value.strip()) + return "\n".join(texts) + + +def text_edits(result: object, file_uri_text: str = "") -> list[dict]: + """ + 從 rename 或 formatting 回應取出要套用的編輯 + Take the edits to apply out of a rename or formatting response. + + 格式化回傳的是編輯清單;重新命名回傳的是 ``WorkspaceEdit``,其中的 ``changes`` + 以 URI 分組。只取目前這個檔案的編輯,跨檔案的重新命名不在這裡處理。 + Formatting returns a list of edits, while a rename returns a + ``WorkspaceEdit`` whose ``changes`` are grouped by URI. Only the edits for + this file are taken; a rename spanning several files is not handled here. + + :param result: 伺服器的回應內容 / the server's result + :param file_uri_text: 目前檔案的 URI / this file's URI + :return: 每筆編輯的範圍與新文字 / each edit's range and replacement text + """ + raw = result + if isinstance(result, dict): + changes = result.get("changes") + if isinstance(changes, dict): + raw = changes.get(file_uri_text) or next(iter(changes.values()), []) + else: + raw = result.get("documentChanges") or [] + if raw and isinstance(raw[0], dict): + raw = raw[0].get("edits", []) + if not isinstance(raw, list): + return [] + edits: list[dict] = [] + for item in raw: + if not isinstance(item, dict): + continue + span = item.get("range") + new_text = item.get("newText") + if not isinstance(span, dict) or not isinstance(new_text, str): + continue + start_line, start_column = _position(span.get("start")) + end_line, end_column = _position(span.get("end"), start_line - 1, start_column - 1) + edits.append({ + "start_line": start_line, "start_column": start_column, + "end_line": end_line, "end_column": end_column, + "new_text": new_text, + }) + return edits + + def diagnostic_entries(params: object) -> list[dict]: """ 從 publishDiagnostics 通知取出診斷 diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index db8d40ca..749e288c 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -389,6 +389,10 @@ "context_menu_toggle_bookmark": "Toggle Bookmark", "context_menu_go_to_definition": "Go to Definition", "context_menu_revert_hunk": "Revert This Change", + "context_menu_hover": "Describe Symbol", + "context_menu_rename_symbol": "Rename Symbol", + "context_menu_rename_prompt": "New name:", + "context_menu_format_document": "Format Document", # Test panel "tab_menu_test_panel_tab_name": "Tests", "test_panel_run": "Run Tests", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 689180b1..b69f874e 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -379,6 +379,10 @@ "context_menu_toggle_bookmark": "切換書籤", "context_menu_go_to_definition": "跳到定義", "context_menu_revert_hunk": "還原這段變更", + "context_menu_hover": "說明這個符號", + "context_menu_rename_symbol": "重新命名符號", + "context_menu_rename_prompt": "新名稱:", + "context_menu_format_document": "格式化整份文件", # Test panel "tab_menu_test_panel_tab_name": "測試", "test_panel_run": "執行測試", diff --git a/test/test_lsp_features.py b/test/test_lsp_features.py new file mode 100644 index 00000000..0f42f6aa --- /dev/null +++ b/test/test_lsp_features.py @@ -0,0 +1,167 @@ +"""Tests for hover, rename and formatting through the language server.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lsp.lsp_protocol import file_uri, hover_text, text_edits + + +class TestHoverText: + def test_plain_string_contents(self): + assert hover_text({"contents": "a function"}) == "a function" + + def test_marked_string_contents(self): + assert hover_text({"contents": {"value": "def run()"}}) == "def run()" + + def test_a_list_joins_every_part(self): + text = hover_text({"contents": ["first", {"value": "second"}]}) + assert text == "first\nsecond" + + def test_empty_parts_are_dropped(self): + assert hover_text({"contents": [" ", {"value": ""}]}) == "" + + def test_unusable_result(self): + assert hover_text(None) == "" + assert hover_text({"other": 1}) == "" + + +class TestTextEdits: + def test_formatting_returns_a_plain_list(self): + edits = text_edits([{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 3}}, + "newText": "xyz", + }]) + assert edits == [{ + "start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "xyz"}] + + def test_rename_returns_changes_keyed_by_uri(self): + uri = file_uri("/project/app.ts") + edits = text_edits({"changes": {uri: [{ + "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 9}}, + "newText": "renamed", + }]}}, uri) + assert edits[0]["new_text"] == "renamed" + assert edits[0]["start_line"] == 3 + + def test_rename_falls_back_to_the_only_file_present(self): + edits = text_edits({"changes": {"file:///other.ts": [{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "newText": "z", + }]}}, "file:///not-there.ts") + assert len(edits) == 1 + + def test_document_changes_form(self): + edits = text_edits({"documentChanges": [{"edits": [{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 2}}, + "newText": "ab", + }]}]}) + assert edits[0]["new_text"] == "ab" + + def test_entries_without_a_range_are_skipped(self): + assert text_edits([{"newText": "x"}]) == [] + + def test_unusable_result(self): + assert text_edits(None) == [] + assert text_edits({"unexpected": True}) == [] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.close() + code_editor.deleteLater() + + +class TestApplyingEdits: + def test_a_single_edit_replaces_its_range(self, editor): + editor.setPlainText("old value\n") + assert editor.apply_server_edits([{ + "start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "new"}]) is True + assert editor.toPlainText() == "new value\n" + + def test_several_edits_do_not_disturb_each_other(self, editor): + editor.setPlainText("aaa bbb ccc\n") + editor.apply_server_edits([ + {"start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "111"}, + {"start_line": 1, "start_column": 9, "end_line": 1, "end_column": 12, + "new_text": "333"}, + ]) + assert editor.toPlainText() == "111 bbb 333\n" + + def test_edits_of_different_lengths_still_land_correctly(self, editor): + editor.setPlainText("one two\n") + editor.apply_server_edits([ + {"start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "a-much-longer-word"}, + {"start_line": 1, "start_column": 5, "end_line": 1, "end_column": 8, + "new_text": "x"}, + ]) + assert editor.toPlainText() == "a-much-longer-word x\n" + + def test_edits_are_one_undo_step(self, editor): + editor.setPlainText("aaa bbb\n") + editor.apply_server_edits([ + {"start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "111"}, + {"start_line": 1, "start_column": 5, "end_line": 1, "end_column": 8, + "new_text": "222"}, + ]) + editor.undo() + assert editor.toPlainText() == "aaa bbb\n" + + def test_an_edit_on_a_missing_line_is_skipped(self, editor): + editor.setPlainText("only one line\n") + editor.apply_server_edits([{ + "start_line": 99, "start_column": 1, "end_line": 99, "end_column": 2, + "new_text": "x"}]) + assert editor.toPlainText() == "only one line\n" + + def test_no_edits(self, editor): + assert editor.apply_server_edits([]) is False + + +class TestEditorRequests: + def test_hover_without_a_server_is_refused(self, editor): + assert editor.request_hover() is False + + def test_formatting_without_a_server_is_refused(self, editor): + assert editor.format_with_language_server() is False + + def test_hover_text_becomes_the_tooltip(self, editor): + editor.show_hover_text("def run() -> None") + assert editor.toolTip() == "def run() -> None" + + def test_rename_without_a_server_falls_back_to_word_replace(self, editor): + editor.setPlainText("value = value + 1\n") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + with patch.object(editor, "rename_word_under_cursor", return_value=True) as fallback: + assert editor.rename_symbol() is True + fallback.assert_called_once() + + def test_definition_in_the_same_file_jumps(self, editor): + editor.current_file = "app.ts" + editor.setPlainText("one\ntwo\nthree\n") + assert editor.go_to_definition_location( + {"path": "app.ts", "line": 3, "column": 1}) is True + assert editor.textCursor().blockNumber() == 2 From ff7d7cd1d62dc91320591a5718d4dc53c704e43d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:32:18 +0800 Subject: [PATCH 26/36] Record macros, surround selections, and save every tab Three conveniences that were missing: - Ctrl+Shift+R records the keystrokes that follow and Ctrl+Shift+P plays them back as one undo step. Playback sends each event synchronously rather than queuing it, so the event objects live only for that call. - Typing a bracket or quote over a selection now wraps it instead of replacing it, leaving the original text selected so it can be wrapped again. - Ctrl+Alt+S saves every tab that has a file name, each with its own encoding and line ending, and running format-on-save first when that is turned on. A tab that has never been saved is skipped rather than having a location chosen for it silently. --- .../code_edit_plaintext.py | 90 +++++++- .../main_ui/menu/file_menu/build_file_menu.py | 10 +- .../menu/file_menu/encoding_actions.py | 32 +++ je_editor/utils/macro/__init__.py | 0 je_editor/utils/macro/keystroke_macro.py | 89 ++++++++ je_editor/utils/multi_language/english.py | 1 + .../multi_language/traditional_chinese.py | 1 + je_editor/utils/selection/surround.py | 45 ++++ test/test_editor_conveniences.py | 213 ++++++++++++++++++ 9 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 je_editor/utils/macro/__init__.py create mode 100644 je_editor/utils/macro/keystroke_macro.py create mode 100644 je_editor/utils/selection/surround.py create mode 100644 test/test_editor_conveniences.py 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 52fddc7b..7b3d37e1 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 @@ -5,7 +5,7 @@ import jedi # Python 自動補全與靜態分析工具 from PySide6 import QtGui -from PySide6.QtCore import Qt, QRect, QTimer, QThread, Signal, QObject +from PySide6.QtCore import Qt, QEvent, QRect, QTimer, QThread, Signal, QObject from PySide6.QtGui import ( QPainter, QTextCharFormat, QTextFormat, QKeyEvent, QAction, QTextDocument, QTextCursor, QTextOption, QColor, QWheelEvent @@ -34,6 +34,8 @@ guide_columns, trailing_whitespace_start ) from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries +from je_editor.utils.macro.keystroke_macro import KeystrokeMacro +from je_editor.utils.selection.surround import SURROUND_PAIRS, surround from je_editor.utils.line_ops.line_operations import ( join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines ) @@ -341,6 +343,10 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self._column_dragged = False self._register_multi_cursor_actions() + # 巨集錄製 / Macro recording + self.macro = KeystrokeMacro() + self._register_macro_actions() + # 非 Python 檔的補全與診斷都交給語言伺服器;Python 仍走 jedi 與 ruff # A non-Python file gets its completion and diagnostics from a language # server; Python keeps jedi and ruff @@ -2092,6 +2098,77 @@ def request_language_server_completion(self) -> bool: return self.lsp_client.request_completion( cursor.blockNumber(), cursor.positionInBlock()) + def _register_macro_actions(self) -> None: + """註冊巨集與環繞選取的快捷鍵 / Register the macro and surround shortcuts.""" + for shortcut, handler in ( + ("Ctrl+Shift+R", self.toggle_macro_recording), + ("Ctrl+Shift+P", self.play_macro), + ): + action = QAction(self) + action.setShortcut(shortcut) + action.triggered.connect(handler) + self.addAction(action) + + def toggle_macro_recording(self) -> bool: + """ + 開始或結束錄製巨集 + Start recording a macro, or stop the one under way. + + :return: 切換後是否正在錄製 / whether recording is now under way + """ + return self.macro.toggle() + + def play_macro(self) -> bool: + """ + 重播錄好的巨集 + Play back the recorded macro. + + 重播用同步送出事件,而不是排入佇列,因此事件物件的生命週期完全在這次呼叫 + 之內。整段重播算一個復原步驟。 + Playback sends each event synchronously rather than queuing it, so every + event object lives only for the duration of this call, and the whole run + is a single undo step. + + :return: 有重播時為 ``True`` / ``True`` when anything was played back + """ + if self.macro.recording or self.macro.is_empty: + return False + cursor = self.textCursor() + cursor.beginEditBlock() + try: + for stroke in list(self.macro.keystrokes): + event = QKeyEvent( + QEvent.Type.KeyPress, stroke.key, + Qt.KeyboardModifier(stroke.modifiers), stroke.text) + self.keyPressEvent(event) + finally: + cursor.endEditBlock() + return True + + def surround_selection(self, opening: str) -> bool: + """ + 用成對字元包住選取的文字 + Wrap the selection in a matching pair of characters. + + :param opening: 開頭字元 / the opening character + :return: 有包住時為 ``True`` / ``True`` when the selection was wrapped + """ + cursor = self.textCursor() + if not cursor.hasSelection(): + return False + wrapped = surround(cursor.selectedText(), opening) + if wrapped is None: + return False + start = cursor.selectionStart() + cursor.insertText(wrapped) + # 重新選取原本的內容,方便接著再包一層 + # Re-select the original text so it can be wrapped again straight away + caret = self.textCursor() + caret.setPosition(start + 1) + caret.setPosition(start + len(wrapped) - 1, QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(caret) + return True + def _register_multi_cursor_actions(self) -> None: """註冊多重游標的快捷鍵 / Register the multi-caret shortcuts.""" for shortcut, handler in ( @@ -2286,6 +2363,17 @@ def keyPressEvent(self, event: QKeyEvent) -> None: key = event.key() modifiers = event.modifiers() + # 錄製中就先記下這個按鍵,再照常處理 + # While recording, note the keystroke before handling it as usual + self.macro.record(int(key), int(modifiers.value), event.text()) + + # 對選取範圍輸入成對字元時包住它,而不是取代掉 + # Typing a pairing character over a selection wraps it instead of + # replacing it + if event.text() in SURROUND_PAIRS and self.textCursor().hasSelection(): + if self.surround_selection(event.text()): + return + if self._handle_multi_cursor_key(event): return diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py index 693ec745..3671c0af 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py @@ -16,7 +16,7 @@ LINE_ENDING_CR, LINE_ENDING_CRLF, LINE_ENDING_LF, line_ending_name ) from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( - apply_encoding, apply_line_ending_choice + apply_encoding, apply_line_ending_choice, save_all_tabs ) # 匯入日誌紀錄器 # Import logger instance @@ -107,6 +107,14 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: add_encoding_menu(ui_we_want_to_set) add_line_ending_menu(ui_we_want_to_set) + # 儲存所有分頁 / Save every modified tab + ui_we_want_to_set.file_menu.save_all_action = QAction( + language_wrapper.language_word_dict.get("file_menu_save_all_label")) + ui_we_want_to_set.file_menu.save_all_action.setShortcut("Ctrl+Alt+S") + ui_we_want_to_set.file_menu.save_all_action.triggered.connect( + lambda: save_all_tabs(ui_we_want_to_set)) + ui_we_want_to_set.file_menu.addAction(ui_we_want_to_set.file_menu.save_all_action) + # 最近開啟的檔案選單 / Recent Files menu def add_recent_files_menu(ui_we_want_to_set: EditorMain) -> None: diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py index 202ea132..4b77a9ba 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py @@ -111,6 +111,38 @@ def format_before_save(widget) -> bool: return True +def save_all_tabs(ui_we_want_to_set) -> int: + """ + 儲存每個有未存修改的編輯分頁 + Save every editor tab that has unsaved changes. + + 只存已經有檔名的分頁;沒有檔名的需要「另存新檔」對話框,不能默默決定位置。 + Only tabs that already have a file name are saved: one without needs the + Save As dialog, and its location must not be decided silently. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 實際存檔的分頁數 / how many tabs were written + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING + from je_editor.utils.file.save.save_file import write_file_with_encoding + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return 0 + saved = 0 + for index in range(tab_widget.count()): + widget = tab_widget.widget(index) + if not isinstance(widget, EditorWidget) or not widget.current_file: + continue + format_before_save(widget) + write_file_with_encoding( + str(widget.current_file), widget.code_edit.toPlainText(), + getattr(widget, "file_encoding", DEFAULT_ENCODING), + getattr(widget, "line_ending", LINE_ENDING_LF)) + saved += 1 + return saved + + def apply_line_ending_choice(ui_we_want_to_set, line_ending: str = LINE_ENDING_LF) -> bool: """ 設定目前檔案存檔時使用的行尾 diff --git a/je_editor/utils/macro/__init__.py b/je_editor/utils/macro/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/macro/keystroke_macro.py b/je_editor/utils/macro/keystroke_macro.py new file mode 100644 index 00000000..5626d46f --- /dev/null +++ b/je_editor/utils/macro/keystroke_macro.py @@ -0,0 +1,89 @@ +""" +記錄與重播一段按鍵操作 +Record a run of keystrokes and play it back. + +錄製時把每個按鍵存成 ``(鍵碼, 修飾鍵, 文字)``,重播時再依序送出。純資料結構, +不含 Qt,因此錄製內容可以單獨測試。 +Recording stores each key as ``(key, modifiers, text)`` and playback sends them +again in order. Pure data with no Qt, so what was recorded can be tested on its +own. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +# 一段巨集最多記幾個按鍵,避免錄製中忘記停止而無限成長 +# How many keystrokes one macro holds, so a forgotten recording cannot grow forever +MAX_KEYSTROKES = 2000 + + +@dataclass(frozen=True) +class Keystroke: + """ + 一個被記錄下來的按鍵 + One recorded keystroke. + + :param key: Qt 的鍵碼 / Qt's key code + :param modifiers: 修飾鍵的位元值 / the modifier flags + :param text: 該按鍵產生的文字 / the text the key produced + """ + + key: int + modifiers: int + text: str + + +@dataclass +class KeystrokeMacro: + """ + 一段巨集的錄製狀態 + The recording state of one macro. + + :param keystrokes: 已錄下的按鍵 / the keystrokes recorded so far + :param recording: 是否正在錄製 / whether recording is under way + """ + + keystrokes: list[Keystroke] = field(default_factory=list) + recording: bool = False + + def start(self) -> None: + """開始錄製,並丟掉上一段 / Start recording, discarding the previous run.""" + self.keystrokes = [] + self.recording = True + + def stop(self) -> None: + """停止錄製 / Stop recording.""" + self.recording = False + + def toggle(self) -> bool: + """ + 切換錄製狀態 + Start recording, or stop if already going. + + :return: 切換後是否正在錄製 / whether recording is now under way + """ + if self.recording: + self.stop() + else: + self.start() + return self.recording + + def record(self, key: int, modifiers: int, text: str) -> bool: + """ + 記下一個按鍵 + Record one keystroke. + + :param key: Qt 的鍵碼 / Qt's key code + :param modifiers: 修飾鍵的位元值 / the modifier flags + :param text: 該按鍵產生的文字 / the text the key produced + :return: 有記下時為 ``True`` / ``True`` when it was recorded + """ + if not self.recording or len(self.keystrokes) >= MAX_KEYSTROKES: + return False + self.keystrokes.append(Keystroke(key=key, modifiers=modifiers, text=text)) + return True + + @property + def is_empty(self) -> bool: + """是否還沒錄到任何按鍵 / Whether nothing has been recorded yet.""" + return not self.keystrokes diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 749e288c..22cc2406 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -86,6 +86,7 @@ "file_menu_save_file_label": "Save File", "file_menu_encoding_label": "Encodings", "file_menu_line_ending_label": "Line Endings", + "file_menu_save_all_label": "Save All", "format_on_save_label": "Format on Save", "style_menu_indent_guides_label": "Show Indent Guides", "style_menu_trailing_whitespace_label": "Show Trailing Whitespace", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index b69f874e..d3c165e0 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -83,6 +83,7 @@ "file_menu_save_file_label": "儲存檔案", "file_menu_encoding_label": "編碼", "file_menu_line_ending_label": "行尾", + "file_menu_save_all_label": "全部儲存", "format_on_save_label": "存檔時自動格式化", "style_menu_indent_guides_label": "顯示縮排參考線", "style_menu_trailing_whitespace_label": "顯示尾端空白", diff --git a/je_editor/utils/selection/surround.py b/je_editor/utils/selection/surround.py new file mode 100644 index 00000000..8b469496 --- /dev/null +++ b/je_editor/utils/selection/surround.py @@ -0,0 +1,45 @@ +""" +用括號或引號包住選取的文字 +Wrap a selection in brackets or quotes. + +純文字轉換,插入與游標處理留給編輯器。 +A plain text transform; inserting it and moving the caret is the editor's job. +""" +from __future__ import annotations + +# 開頭字元對應的結尾字元 / The closing character for each opening one +SURROUND_PAIRS = { + "(": ")", + "[": "]", + "{": "}", + "<": ">", + "'": "'", + '"': '"', + "`": "`", +} + + +def closing_for(opening: str) -> str | None: + """ + 取得對應的結尾字元 + The closing character that matches an opening one. + + :param opening: 開頭字元 / the opening character + :return: 結尾字元,不成對時為 ``None`` / the closing one, or ``None`` + """ + return SURROUND_PAIRS.get(opening) + + +def surround(text: str, opening: str) -> str | None: + """ + 把文字包在成對的字元之間 + Put text between a matching pair of characters. + + :param text: 要包住的文字 / the text to wrap + :param opening: 開頭字元 / the opening character + :return: 包好的文字;字元不成對時為 ``None`` / the wrapped text, or ``None`` + """ + closing = closing_for(opening) + if closing is None: + return None + return f"{opening}{text}{closing}" diff --git a/test/test_editor_conveniences.py b/test/test_editor_conveniences.py new file mode 100644 index 00000000..1038e8d8 --- /dev/null +++ b/test/test_editor_conveniences.py @@ -0,0 +1,213 @@ +"""Tests for macros, surrounding a selection, and saving every tab.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.macro.keystroke_macro import MAX_KEYSTROKES, KeystrokeMacro +from je_editor.utils.selection.surround import closing_for, surround + + +class TestSurroundText: + @pytest.mark.parametrize("opening,expected", [ + ("(", "(text)"), ("[", "[text]"), ("{", "{text}"), + ('"', '"text"'), ("'", "'text'"), ("`", "`text`"), + ]) + def test_pairs(self, opening, expected): + assert surround("text", opening) == expected + + def test_an_unpaired_character_is_refused(self): + assert surround("text", "x") is None + assert closing_for("x") is None + + def test_empty_selection_still_wraps(self): + assert surround("", "(") == "()" + + +class TestKeystrokeMacro: + def test_starts_empty_and_idle(self): + macro = KeystrokeMacro() + assert macro.is_empty and macro.recording is False + + def test_recording_collects_keystrokes(self): + macro = KeystrokeMacro() + macro.start() + macro.record(65, 0, "a") + assert len(macro.keystrokes) == 1 + + def test_nothing_is_recorded_while_idle(self): + macro = KeystrokeMacro() + assert macro.record(65, 0, "a") is False + assert macro.is_empty + + def test_toggling_starts_then_stops(self): + macro = KeystrokeMacro() + assert macro.toggle() is True + assert macro.toggle() is False + + def test_a_new_recording_discards_the_previous(self): + macro = KeystrokeMacro() + macro.start() + macro.record(65, 0, "a") + macro.start() + assert macro.is_empty + + def test_recording_is_capped(self): + macro = KeystrokeMacro() + macro.start() + for _ in range(MAX_KEYSTROKES + 10): + macro.record(65, 0, "a") + assert len(macro.keystrokes) == MAX_KEYSTROKES + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.close() + code_editor.deleteLater() + + +def _select(editor, start: int, end: int) -> None: + cursor = editor.textCursor() + cursor.setPosition(start) + cursor.setPosition(end, cursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + +class TestSurroundInEditor: + def test_typing_a_bracket_over_a_selection_wraps_it(self, editor): + editor.setPlainText("wrap me\n") + _select(editor, 0, 4) + QTest.keyClicks(editor, "(") + assert editor.toPlainText() == "(wrap) me\n" + + def test_the_wrapped_text_stays_selected(self, editor): + editor.setPlainText("wrap me\n") + _select(editor, 0, 4) + QTest.keyClicks(editor, "[") + assert editor.textCursor().selectedText() == "wrap" + + def test_wrapping_twice_nests(self, editor): + editor.setPlainText("word\n") + _select(editor, 0, 4) + QTest.keyClicks(editor, "(") + QTest.keyClicks(editor, '"') + assert editor.toPlainText() == '("word")\n' + + def test_typing_without_a_selection_inserts_normally(self, editor): + editor.setPlainText("") + QTest.keyClicks(editor, "(") + assert "(" in editor.toPlainText() + + def test_surround_without_a_selection_is_refused(self, editor): + editor.setPlainText("text") + assert editor.surround_selection("(") is False + + +class TestMacroInEditor: + def test_recording_then_playing_repeats_the_typing(self, editor): + editor.setPlainText("") + editor.toggle_macro_recording() + QTest.keyClicks(editor, "abc") + editor.toggle_macro_recording() + editor.play_macro() + assert editor.toPlainText() == "abcabc" + + def test_playing_while_recording_is_refused(self, editor): + editor.toggle_macro_recording() + assert editor.play_macro() is False + editor.toggle_macro_recording() + + def test_playing_an_empty_macro_is_refused(self, editor): + editor.macro.stop() + editor.macro.keystrokes = [] + assert editor.play_macro() is False + + def test_playback_is_one_undo_step(self, editor): + editor.setPlainText("") + editor.toggle_macro_recording() + QTest.keyClicks(editor, "xy") + editor.toggle_macro_recording() + before = editor.toPlainText() + editor.play_macro() + editor.undo() + assert editor.toPlainText() == before + + +class _FakeTabs: + """Stands in for QTabWidget, which cannot hold a mocked widget.""" + + def __init__(self, widgets): + self._widgets = list(widgets) + + def count(self): + return len(self._widgets) + + def widget(self, index): + return self._widgets[index] + + +def _editor_tab(app, path, text="new content\n"): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from PySide6.QtWidgets import QPlainTextEdit + tab = MagicMock(spec=EditorWidget) + tab.code_edit = QPlainTextEdit() + tab.code_edit.setPlainText(text) + tab.current_file = str(path) if path else None + tab.file_encoding = "utf-8" + tab.line_ending = "\n" + return tab + + +class TestSaveAll: + def test_every_named_tab_is_written(self, app, tmp_path): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import save_all_tabs + paths = [] + tabs = [] + for name in ("one.py", "two.py"): + path = tmp_path / name + path.write_text("old\n", encoding="utf-8") + paths.append(path) + tabs.append(_editor_tab(app, path)) + window = MagicMock() + window.tab_widget = _FakeTabs(tabs) + assert save_all_tabs(window) == 2 + assert all(path.read_text(encoding="utf-8") == "new content\n" for path in paths) + + def test_a_tab_without_a_file_is_skipped(self, app): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import save_all_tabs + window = MagicMock() + window.tab_widget = _FakeTabs([_editor_tab(app, None)]) + assert save_all_tabs(window) == 0 + + def test_the_tab_encoding_is_used(self, app, tmp_path): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import save_all_tabs + path = tmp_path / "big5.txt" + tab = _editor_tab(app, path, "中文\n") + tab.file_encoding = "big5" + window = MagicMock() + window.tab_widget = _FakeTabs([tab]) + save_all_tabs(window) + assert path.read_bytes() == "中文\n".encode("big5") + + def test_a_window_without_tabs(self): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import save_all_tabs + window = MagicMock() + window.tab_widget = None + assert save_all_tabs(window) == 0 From 5a3cb3bcb71b694fec4de83d7b045c65197934e9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:36:04 +0800 Subject: [PATCH 27/36] List recent locations to jump back to The navigation history could only be stepped through one entry at a time. Ctrl+Alt+E now lists the lines visited recently, newest first and labelled with their text, and jumping to one takes a single choice rather than several presses of back. --- .../code_edit_plaintext.py | 34 +++++++++++++++++++ je_editor/utils/multi_language/english.py | 2 ++ .../multi_language/traditional_chinese.py | 2 ++ test/test_editor_conveniences.py | 25 ++++++++++++++ 4 files changed, 63 insertions(+) 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 7b3d37e1..70ab74cf 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 @@ -2103,12 +2103,46 @@ def _register_macro_actions(self) -> None: for shortcut, handler in ( ("Ctrl+Shift+R", self.toggle_macro_recording), ("Ctrl+Shift+P", self.play_macro), + ("Ctrl+Alt+E", self.show_recent_locations), ): action = QAction(self) action.setShortcut(shortcut) action.triggered.connect(handler) self.addAction(action) + def recent_location_labels(self) -> list[str]: + """ + 取得最近去過的位置,最新的排在前面 + The locations visited recently, most recent first. + + :return: 每個位置的顯示文字 / a label for each location + """ + document = self.document() + labels: list[str] = [] + for line in reversed(self.location_history.entries): + block = document.findBlockByNumber(line) + text = block.text().strip() if block.isValid() else "" + labels.append(f"{line + 1}: {text}" if text else f"{line + 1}") + return labels + + def show_recent_locations(self) -> bool: + """ + 列出最近去過的位置,選一個就跳過去 + List the recently visited locations and jump to the chosen one. + + :return: 有跳轉時為 ``True`` / ``True`` when the caret moved + """ + labels = self.recent_location_labels() + if not labels: + return False + chosen, confirmed = QInputDialog.getItem( + self, language_wrapper.language_word_dict.get("recent_locations_title"), + language_wrapper.language_word_dict.get("recent_locations_prompt"), + labels, 0, False) + if not confirmed or not chosen: + return False + return self.jump_to_line(int(chosen.split(":", 1)[0])) + def toggle_macro_recording(self) -> bool: """ 開始或結束錄製巨集 diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 22cc2406..dd86506e 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -87,6 +87,8 @@ "file_menu_encoding_label": "Encodings", "file_menu_line_ending_label": "Line Endings", "file_menu_save_all_label": "Save All", + "recent_locations_title": "Recent Locations", + "recent_locations_prompt": "Jump to:", "format_on_save_label": "Format on Save", "style_menu_indent_guides_label": "Show Indent Guides", "style_menu_trailing_whitespace_label": "Show Trailing Whitespace", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index d3c165e0..9c6d13be 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -84,6 +84,8 @@ "file_menu_encoding_label": "編碼", "file_menu_line_ending_label": "行尾", "file_menu_save_all_label": "全部儲存", + "recent_locations_title": "最近位置", + "recent_locations_prompt": "跳到:", "format_on_save_label": "存檔時自動格式化", "style_menu_indent_guides_label": "顯示縮排參考線", "style_menu_trailing_whitespace_label": "顯示尾端空白", diff --git a/test/test_editor_conveniences.py b/test/test_editor_conveniences.py index 1038e8d8..8b95d741 100644 --- a/test/test_editor_conveniences.py +++ b/test/test_editor_conveniences.py @@ -150,6 +150,31 @@ def test_playback_is_one_undo_step(self, editor): assert editor.toPlainText() == before +class TestRecentLocations: + def test_visited_lines_are_listed_most_recent_first(self, editor): + editor.setPlainText("alpha\nbeta\ngamma\ndelta\n") + for line in (0, 2, 3): + editor.location_history.visit(line) + labels = editor.recent_location_labels() + assert labels[0].startswith("4:") + assert labels[-1].startswith("1:") + + def test_a_label_shows_the_line_text(self, editor): + editor.setPlainText("first line\nsecond line\n") + editor.location_history.visit(1) + assert "second line" in editor.recent_location_labels()[0] + + def test_a_blank_line_still_gets_a_label(self, editor): + editor.setPlainText("\n\n") + editor.location_history.visit(1) + assert editor.recent_location_labels()[0] == "2" + + def test_no_history_means_nothing_to_show(self, editor): + editor.location_history.__init__() + assert editor.recent_location_labels() == [] + assert editor.show_recent_locations() is False + + class _FakeTabs: """Stands in for QTabWidget, which cannot hold a mocked widget.""" From 2ae58d55c0ab831124333fa78b557dcfeeaa51fb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:40:36 +0800 Subject: [PATCH 28/36] Highlight languages other than Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Python was ever coloured, so every other file opened as plain text. A keyword-driven highlighter now covers TypeScript, JavaScript, Rust, Go, C, C++, Java, shell, SQL, JSON, YAML and TOML, colouring keywords, strings, comments and numbers — short of real parsing, but enough to read the shape of the code. The rules are plain data, so adding a language is a table entry rather than a class. A block comment carries its state across lines, a language without one skips that pass, and Python still uses the highlighter written for it. A file whose suffix has no rules also falls back to that one, since a new unnamed file is usually about to become Python. --- .../code_edit_plaintext.py | 17 +- .../pyside_ui/code/syntax/generic_syntax.py | 111 +++++++++++++ .../save_settings/user_color_setting_file.py | 8 + je_editor/utils/syntax/__init__.py | 0 je_editor/utils/syntax/language_rules.py | 136 ++++++++++++++++ test/test_generic_syntax.py | 149 ++++++++++++++++++ 6 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 je_editor/pyside_ui/code/syntax/generic_syntax.py create mode 100644 je_editor/utils/syntax/__init__.py create mode 100644 je_editor/utils/syntax/language_rules.py create mode 100644 test/test_generic_syntax.py 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 70ab74cf..92a3f055 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 @@ -45,6 +45,7 @@ find_occurrences, replace_whole_word, word_at ) from je_editor.utils.text_cleanup.text_cleanup import trim_trailing_whitespace +from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter from je_editor.pyside_ui.dialog.search_ui.search_text_box import SearchBox from je_editor.pyside_ui.dialog.search_ui.search_replace_widget import SearchReplaceDialog @@ -358,9 +359,21 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.start_language_server() def reset_highlighter(self) -> None: - """重設語法高亮 / Reset syntax highlighter""" + """ + 依目前檔案的副檔名重設語法高亮 + Reset the syntax highlighter to match the current file's suffix. + + Python 用專屬的高亮器;其他有規則的語言用通用高亮器;都不符合時仍套用 + Python 的(新檔案還沒有副檔名,多半就是要寫 Python)。 + Python gets its own highlighter, another language with rules gets the + generic one, and anything else still gets Python's — a new file has no + suffix yet and is usually about to become Python. + """ jeditor_logger.info("CodeEditor reset_highlighter") - self.highlighter = PythonHighlighter(self.document(), main_window=self) + suffix = Path(str(self.current_file)).suffix if self.current_file else "" + generic = highlighter_for(self.document(), suffix) if suffix else None + self.highlighter = generic if generic is not None else PythonHighlighter( + self.document(), main_window=self) self.highlight_current_line() def check_env(self) -> None: diff --git a/je_editor/pyside_ui/code/syntax/generic_syntax.py b/je_editor/pyside_ui/code/syntax/generic_syntax.py new file mode 100644 index 00000000..d9af1192 --- /dev/null +++ b/je_editor/pyside_ui/code/syntax/generic_syntax.py @@ -0,0 +1,111 @@ +""" +以關鍵字規則為基礎的通用語法高亮 +Keyword-driven highlighting for languages other than Python. + +Python 由專屬的高亮器處理;其他語言以「關鍵字、字串、註解、數字」四類上色, +雖然比不上真正的語法分析,但足以讓程式碼結構一眼可辨。 +Python has a highlighter of its own. Everything else is coloured in four +categories — keywords, strings, comments and numbers — which is short of real +parsing but enough to make the shape of the code readable. +""" +from __future__ import annotations + +import re + +from PySide6.QtCore import QRegularExpression +from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument + +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict +from je_editor.utils.syntax.language_rules import LanguageRules, rules_for + +# 區塊註解的狀態值 / The block state marking "inside a block comment" +_INSIDE_BLOCK_COMMENT = 1 + + +def _format_for(colour_key: str) -> QTextCharFormat: + """建立指定顏色的格式 / Build a format in the given colour.""" + text_format = QTextCharFormat() + text_format.setForeground(actually_color_dict.get(colour_key)) + return text_format + + +class GenericHighlighter(QSyntaxHighlighter): + """ + 依語言規則上色的通用高亮器 + A highlighter that colours a document from a language's rules. + """ + + def __init__(self, document: QTextDocument, rules: LanguageRules) -> None: + """ + :param document: 要上色的文件 / the document to highlight + :param rules: 該語言的規則 / that language's rules + """ + super().__init__(document) + self._rules = rules + self._patterns: list[tuple[QRegularExpression, QTextCharFormat]] = [] + keyword_format = _format_for("syntax_keyword_color") + for keyword in rules.keywords: + self._patterns.append( + (QRegularExpression(rf"\b{re.escape(keyword)}\b"), keyword_format)) + number_format = _format_for("syntax_number_color") + self._patterns.append((QRegularExpression(r"\b\d+(\.\d+)?\b"), number_format)) + string_format = _format_for("syntax_string_color") + for quote in rules.string_delimiters: + escaped = re.escape(quote) + self._patterns.append( + (QRegularExpression(rf"{escaped}[^{escaped}\\]*(\\.[^{escaped}\\]*)*{escaped}"), + string_format)) + self._comment_format = _format_for("syntax_comment_color") + + def highlightBlock(self, text: str) -> None: + """為一行上色 / Colour one line.""" + for pattern, text_format in self._patterns: + matches = pattern.globalMatch(text) + while matches.hasNext(): + match = matches.next() + self.setFormat(match.capturedStart(), match.capturedLength(), text_format) + self._highlight_line_comment(text) + self._highlight_block_comment(text) + + def _highlight_line_comment(self, text: str) -> None: + """單行註解從標記處一路到行尾 / A line comment runs from its marker to the line end.""" + marker = self._rules.line_comment + if not marker: + return + start = text.find(marker) + if start >= 0: + self.setFormat(start, len(text) - start, self._comment_format) + + def _highlight_block_comment(self, text: str) -> None: + """ + 區塊註解可能跨行,因此用區塊狀態記住是否還在註解裡 + A block comment may span lines, so the block state remembers whether one + is still open. + """ + if self._rules.block_comment is None: + return + opening, closing = self._rules.block_comment + start = 0 if self.previousBlockState() == _INSIDE_BLOCK_COMMENT else text.find(opening) + while start >= 0: + end = text.find(closing, start + len(opening)) + if end < 0: + self.setFormat(start, len(text) - start, self._comment_format) + self.setCurrentBlockState(_INSIDE_BLOCK_COMMENT) + return + length = end - start + len(closing) + self.setFormat(start, length, self._comment_format) + start = text.find(opening, start + length) + self.setCurrentBlockState(0) + + +def highlighter_for(document: QTextDocument, suffix: str) -> GenericHighlighter | None: + """ + 為某個副檔名建立高亮器 + Build a highlighter for a file suffix. + + :param document: 要上色的文件 / the document to highlight + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 高亮器,沒有對應規則時為 ``None`` / the highlighter, or ``None`` + """ + rules = rules_for(suffix) + return GenericHighlighter(document, rules) if rules is not None else None diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 812447ca..1e4e476a 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -49,6 +49,10 @@ def update_actually_color_dict() -> None: "minimap_line_color": _to_qcolor("minimap_line_color", [130, 130, 150]), "minimap_viewport_color": _to_qcolor("minimap_viewport_color", [80, 80, 110]), "extra_cursor_color": _to_qcolor("extra_cursor_color", [255, 215, 64]), + "syntax_keyword_color": _to_qcolor("syntax_keyword_color", [86, 156, 214]), + "syntax_string_color": _to_qcolor("syntax_string_color", [206, 145, 120]), + "syntax_comment_color": _to_qcolor("syntax_comment_color", [106, 153, 85]), + "syntax_number_color": _to_qcolor("syntax_number_color", [181, 206, 168]), "trailing_whitespace_color": _to_qcolor("trailing_whitespace_color", [120, 70, 70]), } ) @@ -76,6 +80,10 @@ def update_actually_color_dict() -> None: "minimap_line_color": [130, 130, 150], "minimap_viewport_color": [80, 80, 110], "extra_cursor_color": [255, 215, 64], + "syntax_keyword_color": [86, 156, 214], + "syntax_string_color": [206, 145, 120], + "syntax_comment_color": [106, 153, 85], + "syntax_number_color": [181, 206, 168], "trailing_whitespace_color": [120, 70, 70] } diff --git a/je_editor/utils/syntax/__init__.py b/je_editor/utils/syntax/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/syntax/language_rules.py b/je_editor/utils/syntax/language_rules.py new file mode 100644 index 00000000..56c33f6d --- /dev/null +++ b/je_editor/utils/syntax/language_rules.py @@ -0,0 +1,136 @@ +""" +各語言的語法高亮規則 +The highlighting rules for each language. + +Python 已經有專屬的高亮器;這裡放的是其他語言的規則,讓打開 TypeScript、Rust、 +Go 這類檔案時不再是一片白。 +Python already has a highlighter of its own; these are the rules for everything +else, so opening a TypeScript, Rust or Go file is no longer a wall of plain text. + +純資料:不含 Qt,因此規則本身可以單獨測試。 +Pure data with no Qt, so the rules themselves can be tested on their own. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +# 幾乎每個 C 家族語言都共用的關鍵字 / Keywords shared across the C-family languages +_COMMON = ( + "if", "else", "for", "while", "return", "break", "continue", "switch", + "case", "default", "do", "goto", "struct", "enum", "union", "const", + "static", "void", "int", "char", "long", "short", "float", "double", + "unsigned", "signed", "sizeof", "typedef", +) + + +@dataclass(frozen=True) +class LanguageRules: + """ + 一個語言的高亮規則 + The highlighting rules for one language. + + :param name: 語言名稱 / the language's name + :param keywords: 關鍵字 / its keywords + :param line_comment: 單行註解的開頭 / what starts a line comment + :param block_comment: 區塊註解的起訖,沒有就是 ``None`` + the delimiters of a block comment, or ``None`` when it has none + :param string_delimiters: 字串的引號 / the quote characters for strings + """ + + name: str + keywords: tuple[str, ...] + line_comment: str = "//" + block_comment: tuple[str, str] | None = ("/*", "*/") + string_delimiters: tuple[str, ...] = field(default=('"', "'")) + + +# 副檔名對應的規則 / The rules for each file suffix +LANGUAGE_RULES: dict[str, LanguageRules] = { + ".js": LanguageRules("JavaScript", ( + "function", "var", "let", "const", "class", "extends", "new", "this", + "return", "if", "else", "for", "while", "of", "in", "try", "catch", + "finally", "throw", "typeof", "instanceof", "async", "await", "import", + "export", "from", "default", "null", "undefined", "true", "false", + ), string_delimiters=('"', "'", "`")), + ".ts": LanguageRules("TypeScript", ( + "function", "var", "let", "const", "class", "interface", "type", "enum", + "extends", "implements", "new", "this", "return", "if", "else", "for", + "while", "of", "in", "try", "catch", "finally", "throw", "typeof", + "async", "await", "import", "export", "from", "default", "public", + "private", "protected", "readonly", "as", "null", "undefined", + "true", "false", "string", "number", "boolean", "any", "void", + ), string_delimiters=('"', "'", "`")), + ".rs": LanguageRules("Rust", ( + "fn", "let", "mut", "const", "struct", "enum", "impl", "trait", "pub", + "use", "mod", "match", "if", "else", "loop", "while", "for", "in", + "return", "self", "Self", "where", "async", "await", "move", "ref", + "dyn", "unsafe", "true", "false", "Some", "None", "Ok", "Err", + )), + ".go": LanguageRules("Go", ( + "func", "var", "const", "type", "struct", "interface", "map", "chan", + "package", "import", "return", "if", "else", "for", "range", "switch", + "case", "default", "go", "defer", "select", "nil", "true", "false", + "string", "int", "error", + ), string_delimiters=('"', "`")), + ".c": LanguageRules("C", _COMMON), + ".h": LanguageRules("C header", _COMMON), + ".cpp": LanguageRules("C++", _COMMON + ( + "class", "public", "private", "protected", "virtual", "template", + "namespace", "using", "new", "delete", "this", "nullptr", "auto", + "override", "constexpr", "true", "false", + )), + ".hpp": LanguageRules("C++ header", _COMMON + ( + "class", "public", "private", "protected", "virtual", "template", + "namespace", "using", "nullptr", "auto", "true", "false", + )), + ".java": LanguageRules("Java", ( + "class", "interface", "extends", "implements", "public", "private", + "protected", "static", "final", "void", "new", "this", "super", + "return", "if", "else", "for", "while", "switch", "case", "try", + "catch", "finally", "throw", "throws", "import", "package", "null", + "true", "false", "int", "long", "double", "boolean", "String", + )), + ".sh": LanguageRules("Shell", ( + "if", "then", "else", "elif", "fi", "for", "while", "do", "done", + "case", "esac", "function", "return", "export", "local", "echo", + ), line_comment="#", block_comment=None), + ".yaml": LanguageRules("YAML", ("true", "false", "null"), + line_comment="#", block_comment=None), + ".yml": LanguageRules("YAML", ("true", "false", "null"), + line_comment="#", block_comment=None), + ".toml": LanguageRules("TOML", ("true", "false"), + line_comment="#", block_comment=None), + ".json": LanguageRules("JSON", ("true", "false", "null"), + line_comment="//", block_comment=None, + string_delimiters=('"',)), + ".sql": LanguageRules("SQL", ( + "select", "from", "where", "insert", "into", "values", "update", "set", + "delete", "create", "table", "drop", "alter", "join", "left", "right", + "inner", "outer", "on", "group", "order", "by", "having", "limit", + "and", "or", "not", "null", "as", "distinct", + ), line_comment="--", block_comment=("/*", "*/")), +} + + +def rules_for(suffix: str) -> LanguageRules | None: + """ + 取得副檔名對應的高亮規則 + The highlighting rules for a file suffix. + + Python 不在其中,因為它有專屬的高亮器。 + Python is absent, since it has a highlighter of its own. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 規則,沒有對應時為 ``None`` / the rules, or ``None`` + """ + return LANGUAGE_RULES.get(suffix.lower()) + + +def supported_suffixes() -> tuple[str, ...]: + """ + 取得所有支援的副檔名 + Every suffix that has rules. + + :return: 副檔名 / the suffixes + """ + return tuple(sorted(LANGUAGE_RULES)) diff --git a/test/test_generic_syntax.py b/test/test_generic_syntax.py new file mode 100644 index 00000000..3a19ccad --- /dev/null +++ b/test/test_generic_syntax.py @@ -0,0 +1,149 @@ +"""Tests for highlighting languages other than Python.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtGui import QTextDocument +from PySide6.QtWidgets import QApplication + +from je_editor.utils.syntax.language_rules import LANGUAGE_RULES, rules_for, supported_suffixes + + +class TestLanguageRules: + @pytest.mark.parametrize("suffix", [".ts", ".js", ".rs", ".go", ".c", ".cpp", ".java"]) + def test_common_languages_have_rules(self, suffix): + assert rules_for(suffix) is not None + + def test_python_is_left_to_its_own_highlighter(self): + assert rules_for(".py") is None + + def test_suffix_matching_ignores_case(self): + assert rules_for(".TS") is rules_for(".ts") + + def test_an_unknown_suffix_has_no_rules(self): + assert rules_for(".unknown") is None + + def test_shell_uses_a_hash_comment_and_no_block(self): + rules = rules_for(".sh") + assert rules.line_comment == "#" + assert rules.block_comment is None + + def test_sql_uses_a_double_dash_comment(self): + assert rules_for(".sql").line_comment == "--" + + def test_template_literals_count_as_strings_in_js(self): + assert "`" in rules_for(".js").string_delimiters + + def test_every_rule_set_names_its_language(self): + assert all(rules.name for rules in LANGUAGE_RULES.values()) + + def test_supported_suffixes_are_sorted(self): + suffixes = supported_suffixes() + assert list(suffixes) == sorted(suffixes) + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def _highlighted(suffix: str, text: str): + """Return (highlighter, document) with *text* already highlighted.""" + from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for + document = QTextDocument() + highlighter = highlighter_for(document, suffix) + document.setPlainText(text) + highlighter.rehighlight() + return highlighter, document + + +def _formats_at(document: QTextDocument, line: int) -> list: + block = document.findBlockByNumber(line) + layout = block.layout() + if layout is None: + return [] + return [(fmt.start, fmt.length) for fmt in layout.formats()] + + +class TestGenericHighlighter: + def test_a_keyword_is_coloured(self, app): + highlighter, document = _highlighted(".ts", "const value = 1;") + assert _formats_at(document, 0) != [] + highlighter.setDocument(None) + + def test_an_unknown_suffix_gets_no_highlighter(self, app): + from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for + assert highlighter_for(QTextDocument(), ".unknown") is None + + def test_a_line_comment_is_coloured_to_the_end(self, app): + line = "value := 1 // explain" + highlighter, document = _highlighted(".go", line) + assert any(start + length == len(line) for start, length in _formats_at(document, 0)) + highlighter.setDocument(None) + + def test_a_block_comment_spans_lines(self, app): + highlighter, document = _highlighted(".c", "/* first\nstill inside\n*/ after") + assert _formats_at(document, 1) != [] + highlighter.setDocument(None) + + def test_a_language_without_block_comments_is_safe(self, app): + highlighter, document = _highlighted(".sh", "echo hi # comment\n") + assert _formats_at(document, 0) != [] + highlighter.setDocument(None) + + def test_plain_text_gets_no_colouring(self, app): + highlighter, document = _highlighted(".ts", "plainwords here\n") + assert _formats_at(document, 0) == [] + highlighter.setDocument(None) + + def test_every_syntax_colour_is_defined(self, app): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict + ) + for key in ( + "syntax_keyword_color", "syntax_string_color", + "syntax_comment_color", "syntax_number_color", + ): + assert actually_color_dict.get(key) is not None + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.close() + code_editor.deleteLater() + + +class TestHighlighterSelection: + def test_a_typescript_file_uses_the_generic_highlighter(self, editor): + from je_editor.pyside_ui.code.syntax.generic_syntax import GenericHighlighter + editor.current_file = "app.ts" + editor.reset_highlighter() + assert isinstance(editor.highlighter, GenericHighlighter) + + def test_a_python_file_keeps_its_own_highlighter(self, editor): + from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + editor.current_file = "module.py" + editor.reset_highlighter() + assert isinstance(editor.highlighter, PythonHighlighter) + + def test_an_unknown_suffix_falls_back_to_python(self, editor): + from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + editor.current_file = "notes.unknown" + editor.reset_highlighter() + assert isinstance(editor.highlighter, PythonHighlighter) + + def test_a_file_without_a_name_falls_back_to_python(self, editor): + from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + editor.current_file = None + editor.reset_highlighter() + assert isinstance(editor.highlighter, PythonHighlighter) From 689452f2138d3b7274925461185a149954b52b36 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:45:26 +0800 Subject: [PATCH 29/36] Edit snippets in the editor, and scope them per language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snippets could only be changed by hand-editing snippets.json, and the built-in set was Python whatever the file. A dialog now adds, edits and removes them, and saving reloads every open tab so the change is live. Snippet sets are now layered from general to specific: the built-in Python set, then the ones belonging to the file''s language (TypeScript, JavaScript, Go and Rust to start), then the user''s own — so a user definition always wins. The user file may list snippets flat or grouped by suffix, and a group meant for another language is ignored rather than leaking into this one. --- .../code/snippets/snippet_manager.py | 39 ++++- .../dialog/snippet_dialog/__init__.py | 0 .../snippet_dialog/snippet_editor_dialog.py | 148 ++++++++++++++++++ .../main_ui/menu/tab_menu/build_tab_menu.py | 26 +++ je_editor/utils/multi_language/english.py | 6 + .../multi_language/traditional_chinese.py | 6 + je_editor/utils/snippets/snippet_expand.py | 66 +++++++- test/test_snippet_editor_dialog.py | 84 ++++++++++ test/test_snippets.py | 47 ++++++ 9 files changed, 411 insertions(+), 11 deletions(-) create mode 100644 je_editor/pyside_ui/dialog/snippet_dialog/__init__.py create mode 100644 je_editor/pyside_ui/dialog/snippet_dialog/snippet_editor_dialog.py create mode 100644 test/test_snippet_editor_dialog.py diff --git a/je_editor/pyside_ui/code/snippets/snippet_manager.py b/je_editor/pyside_ui/code/snippets/snippet_manager.py index a081aa87..b636c2b2 100644 --- a/je_editor/pyside_ui/code/snippets/snippet_manager.py +++ b/je_editor/pyside_ui/code/snippets/snippet_manager.py @@ -33,7 +33,27 @@ def snippet_file_path() -> Path: return Path.cwd() / SETTING_DIR_NAME / SNIPPET_FILE_NAME -def load_snippets(path: Path | None = None) -> dict[str, str]: +def save_snippets(snippets: dict[str, str], path: Path | None = None) -> bool: + """ + 把片段寫回使用者的片段檔 + Write the snippets back to the user's snippet file. + + :param snippets: 要儲存的片段 / the snippets to store + :param path: 片段檔路徑,``None`` 表示預設位置 / the file, or ``None`` for the default + :return: 寫入成功時為 ``True`` / ``True`` when the file was written + """ + target = path if path is not None else snippet_file_path() + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(snippets, indent=4, ensure_ascii=False), encoding="utf-8") + except OSError as error: + jeditor_logger.error(f"snippet_manager: could not save snippets: {error!r}") + return False + return True + + +def load_snippets(path: Path | None = None, suffix: str = "") -> dict[str, str]: """ 載入片段:內建的加上使用者定義的 Load the snippets: the built-in set plus anything the user defined. @@ -51,8 +71,8 @@ def load_snippets(path: Path | None = None) -> dict[str, str]: stored = json.loads(target.read_text(encoding="utf-8")) except (OSError, ValueError) as error: jeditor_logger.debug(f"snippet_manager: using built-in snippets only: {error!r}") - return merge_snippets(None) - return merge_snippets(stored) + return merge_snippets(None, suffix) + return merge_snippets(stored, suffix) class SnippetManager: @@ -66,7 +86,7 @@ def __init__(self, code_edit) -> None: :param code_edit: 要展開片段的編輯器 / the editor snippets expand into """ self._code_edit = code_edit - self._snippets = load_snippets() + self._snippets = load_snippets(suffix=self._suffix()) # 尚未走訪的定位點(文件中的絕對位置)/ Stops not yet visited, as document positions self._pending: list[SnippetStop] = [] @@ -74,14 +94,19 @@ def snippets(self) -> dict[str, str]: """取得目前可用的片段 / The snippets currently available.""" return dict(self._snippets) + def _suffix(self) -> str: + """目前檔案的副檔名 / The current file's suffix.""" + current = getattr(self._code_edit, "current_file", None) + return Path(str(current)).suffix if current else "" + def reload(self, path: Path | None = None) -> None: """ - 重新載入使用者片段 - Reload the user's snippets from disk. + 重新載入使用者片段,並套用目前檔案的語言 + Reload the user's snippets, for the current file's language. :param path: 片段檔路徑,``None`` 表示預設位置 / the file, or ``None`` for the default """ - self._snippets = load_snippets(path) + self._snippets = load_snippets(path, self._suffix()) @property def has_pending_stops(self) -> bool: diff --git a/je_editor/pyside_ui/dialog/snippet_dialog/__init__.py b/je_editor/pyside_ui/dialog/snippet_dialog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/dialog/snippet_dialog/snippet_editor_dialog.py b/je_editor/pyside_ui/dialog/snippet_dialog/snippet_editor_dialog.py new file mode 100644 index 00000000..33d36fea --- /dev/null +++ b/je_editor/pyside_ui/dialog/snippet_dialog/snippet_editor_dialog.py @@ -0,0 +1,148 @@ +""" +編輯使用者片段的對話框 +A dialog for editing the user's snippets. + +先前只能手動改 ``snippets.json``;這裡讓新增、修改與刪除都在編輯器裡完成,存檔 +後立刻對開著的分頁生效。 +Editing ``snippets.json`` by hand used to be the only way. This adds, changes and +removes them inside the editor, and saving takes effect in the open tabs at once. +""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QListWidget, QPlainTextEdit, QPushButton, + QVBoxLayout, QWidget +) + +from je_editor.pyside_ui.code.snippets.snippet_manager import load_snippets, save_snippets +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + +# 新片段的預設觸發字 / The trigger a new snippet starts with +NEW_TRIGGER = "new" + + +class SnippetEditorDialog(QDialog): + """ + 列出片段並讓使用者編輯 + List the snippets and let the user edit them. + """ + + def __init__(self, main_window=None, parent: QWidget | None = None) -> None: + """ + :param main_window: 儲存後要通知的主視窗 / the window told about a save + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + word = language_wrapper.language_word_dict + self._main_window = main_window + self.setWindowTitle(word.get("snippet_editor_title")) + self._snippets = load_snippets() + + self.trigger_list = QListWidget() + self.trigger_list.addItems(sorted(self._snippets)) + self.trigger_list.currentTextChanged.connect(self._show_body) + + self.body_edit = QPlainTextEdit() + self.body_edit.setPlaceholderText(word.get("snippet_editor_body_placeholder")) + + self.add_button = QPushButton(word.get("snippet_editor_add")) + self.add_button.clicked.connect(self.add_snippet) + self.remove_button = QPushButton(word.get("snippet_editor_remove")) + self.remove_button.clicked.connect(self.remove_snippet) + self.save_button = QPushButton(word.get("snippet_editor_save")) + self.save_button.clicked.connect(self.save) + + buttons = QHBoxLayout() + buttons.addWidget(self.add_button) + buttons.addWidget(self.remove_button) + buttons.addWidget(self.save_button) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(word.get("snippet_editor_hint"))) + body_row = QHBoxLayout() + body_row.addWidget(self.trigger_list, 1) + body_row.addWidget(self.body_edit, 2) + layout.addLayout(body_row) + layout.addLayout(buttons) + self.setLayout(layout) + if self.trigger_list.count(): + self.trigger_list.setCurrentRow(0) + + def snippets(self) -> dict[str, str]: + """取得目前編輯中的片段 / The snippets as they stand in the dialog.""" + return dict(self._snippets) + + def _show_body(self, trigger: str) -> None: + """切換到另一個片段時顯示它的內容 / Show a snippet's body when it is selected.""" + self._remember_body() + self._current_trigger = trigger + self.body_edit.setPlainText(self._snippets.get(trigger, "")) + + def _remember_body(self) -> None: + """把編輯中的內容記回目前的片段 / Keep the edited body against its trigger.""" + trigger = getattr(self, "_current_trigger", "") + if trigger: + self._snippets[trigger] = self.body_edit.toPlainText() + + def add_snippet(self) -> str: + """ + 新增一個片段 + Add a snippet. + + 觸發字重複時自動加上編號,因此新片段不會覆蓋既有的。 + A repeated trigger is numbered, so a new snippet never overwrites one + that is already there. + + :return: 新片段的觸發字 / the new snippet's trigger + """ + self._remember_body() + trigger = NEW_TRIGGER + index = 2 + while trigger in self._snippets: + trigger = f"{NEW_TRIGGER}{index}" + index += 1 + self._snippets[trigger] = "$0" + self.trigger_list.addItem(trigger) + self.trigger_list.setCurrentRow(self.trigger_list.count() - 1) + return trigger + + def remove_snippet(self) -> bool: + """ + 刪除選取的片段 + Remove the selected snippet. + + :return: 有刪除時為 ``True`` / ``True`` when one was removed + """ + row = self.trigger_list.currentRow() + if row < 0: + return False + trigger = self.trigger_list.item(row).text() + self._snippets.pop(trigger, None) + self._current_trigger = "" + self.trigger_list.takeItem(row) + self.body_edit.setPlainText("") + return True + + def save(self) -> bool: + """ + 儲存片段,並讓開著的分頁立刻套用 + Save the snippets and let the open tabs pick them up at once. + + :return: 儲存成功時為 ``True`` / ``True`` when they were saved + """ + self._remember_body() + if not save_snippets(self._snippets): + return False + self._reload_open_tabs() + return True + + def _reload_open_tabs(self) -> None: + """請每個編輯分頁重新載入片段 / Ask each editor tab to reload its snippets.""" + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(self._main_window, "tab_widget", None) + if tab_widget is None: + return + for index in range(tab_widget.count()): + widget = tab_widget.widget(index) + if isinstance(widget, EditorWidget): + widget.code_edit.snippet_manager.reload() diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py index caecbc18..d767b9b1 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py @@ -79,10 +79,36 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.toggle_minimap_action) + # === 片段編輯器 === + # === Snippet editor === + ui_we_want_to_set.tab_menu.edit_snippets_action = QAction( + language_wrapper.language_word_dict.get("snippet_editor_title")) + ui_we_want_to_set.tab_menu.edit_snippets_action.triggered.connect( + lambda: show_snippet_editor(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.edit_snippets_action) + set_tab_tools_menu(ui_we_want_to_set=ui_we_want_to_set) set_tab_git_menu(ui_we_want_to_set=ui_we_want_to_set) +def show_snippet_editor(ui_we_want_to_set: EditorMain): + """ + 開啟片段編輯器 + Open the snippet editor. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 開啟的對話框 / the dialog that was opened + """ + jeditor_logger.info("build_tab_menu.py show_snippet_editor") + from je_editor.pyside_ui.dialog.snippet_dialog.snippet_editor_dialog import ( + SnippetEditorDialog + ) + dialog = SnippetEditorDialog(ui_we_want_to_set) + dialog.show() + return dialog + + def toggle_minimap(ui_we_want_to_set: EditorMain) -> bool: """ 切換目前分頁的縮圖 diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index dd86506e..683d069b 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -387,6 +387,12 @@ "tab_menu_diff_against_head_name": "Diff Against HEAD", "tab_menu_split_view_label": "Toggle Split View", "tab_menu_minimap_label": "Toggle Minimap", + "snippet_editor_title": "Snippet Editor", + "snippet_editor_hint": "Tab stops are written $1, ${2:default} and $0.", + "snippet_editor_body_placeholder": "Snippet body...", + "snippet_editor_add": "Add", + "snippet_editor_remove": "Remove", + "snippet_editor_save": "Save", # Editor context menu "context_menu_toggle_comment": "Toggle Comment", "context_menu_toggle_bookmark": "Toggle Bookmark", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 9c6d13be..58fe05c2 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -377,6 +377,12 @@ "tab_menu_diff_against_head_name": "與 HEAD 比較差異", "tab_menu_split_view_label": "切換分割檢視", "tab_menu_minimap_label": "切換縮圖", + "snippet_editor_title": "片段編輯器", + "snippet_editor_hint": "定位點請用 $1、${2:預設值} 與 $0。", + "snippet_editor_body_placeholder": "片段內容...", + "snippet_editor_add": "新增", + "snippet_editor_remove": "刪除", + "snippet_editor_save": "儲存", # 編輯器右鍵選單 "context_menu_toggle_comment": "切換註解", "context_menu_toggle_bookmark": "切換書籤", diff --git a/je_editor/utils/snippets/snippet_expand.py b/je_editor/utils/snippets/snippet_expand.py index 2c31cf91..f71e8491 100644 --- a/je_editor/utils/snippets/snippet_expand.py +++ b/je_editor/utils/snippets/snippet_expand.py @@ -90,22 +90,80 @@ def default_snippets() -> dict[str, str]: } -def merge_snippets(stored: object) -> dict[str, str]: +# 各語言自己的片段;沒有列到的語言只會拿到通用片段 +# Per-language snippets; a language not listed here gets only the shared ones +LANGUAGE_SNIPPETS: dict[str, dict[str, str]] = { + ".ts": { + "fn": "function ${1:name}(${2:args}) {\n $0\n}", + "cls": "class ${1:Name} {\n constructor(${2:args}) {\n $0\n }\n}", + "log": "console.log($0);", + "iff": "if (${1:condition}) {\n $0\n}", + }, + ".js": { + "fn": "function ${1:name}(${2:args}) {\n $0\n}", + "log": "console.log($0);", + "iff": "if (${1:condition}) {\n $0\n}", + }, + ".go": { + "fn": "func ${1:name}(${2:args}) ${3:error} {\n $0\n}", + "iferr": "if err != nil {\n return $0\n}", + "forr": "for ${1:index}, ${2:value} := range ${3:items} {\n $0\n}", + }, + ".rs": { + "fn": "fn ${1:name}(${2:args}) -> ${3:()} {\n $0\n}", + "match": "match ${1:value} {\n ${2:pattern} => $0,\n}", + "test": "#[test]\nfn ${1:name}() {\n $0\n}", + }, +} + + +def language_snippets(suffix: str) -> dict[str, str]: + """ + 取得某個副檔名專屬的片段 + The snippets belonging to one file suffix. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 該語言的片段 / that language's snippets + """ + return dict(LANGUAGE_SNIPPETS.get(suffix.lower(), {})) + + +def merge_snippets(stored: object, suffix: str = "") -> dict[str, str]: """ 把使用者定義的片段併入內建片段 Merge user-defined snippets over the built-in ones. + 順序由通用到專屬:內建的 Python 片段、該語言的片段,最後才是使用者定義的, + 因此使用者永遠可以蓋掉任何一個。 + The order runs from general to specific — the built-in Python set, then the + language's own, then the user's — so a user definition always wins. + + 使用者檔案可以是 ``{觸發字: 內容}``,也可以用副檔名分組,例如 + ``{".ts": {...}}``;兩種都接受。 + A user file may be ``{trigger: body}`` or grouped by suffix such as + ``{".ts": {...}}``, and both forms are accepted. + 設定檔可能被手動編輯,因此型別不符的項目會被略過而不是讓載入失敗。 A hand-edited file may hold anything, so entries with the wrong type are skipped rather than failing the load. :param stored: 讀進來的使用者片段,任何型別 / the loaded user snippets, any type + :param suffix: 目前檔案的副檔名 / the current file's suffix :return: 可用的片段對照表 / the usable snippets """ snippets = default_snippets() + snippets.update(language_snippets(suffix)) if not isinstance(stored, dict): return snippets - for trigger, body in stored.items(): - if isinstance(trigger, str) and trigger and isinstance(body, str): - snippets[trigger] = body + for key, value in stored.items(): + if not isinstance(key, str) or not key: + continue + if isinstance(value, str): + snippets[key] = value + elif isinstance(value, dict) and suffix and key.lower() == suffix.lower(): + # 這一組是給這個副檔名的 / This group belongs to this suffix + snippets.update({ + trigger: body for trigger, body in value.items() + if isinstance(trigger, str) and trigger and isinstance(body, str) + }) return snippets diff --git a/test/test_snippet_editor_dialog.py b/test/test_snippet_editor_dialog.py new file mode 100644 index 00000000..8663d47a --- /dev/null +++ b/test/test_snippet_editor_dialog.py @@ -0,0 +1,84 @@ +"""Tests for the snippet editor dialog.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def dialog(app, tmp_path): + from je_editor.pyside_ui.dialog.snippet_dialog.snippet_editor_dialog import ( + SnippetEditorDialog + ) + path = tmp_path / "snippets.json" + with patch( + "je_editor.pyside_ui.code.snippets.snippet_manager.snippet_file_path", + return_value=path, + ): + window = MagicMock() + window.tab_widget = None + editor = SnippetEditorDialog(window) + editor._test_path = path + yield editor + editor.close() + editor.deleteLater() + + +class TestSnippetEditorDialog: + def test_the_built_in_snippets_are_listed(self, dialog): + triggers = [ + dialog.trigger_list.item(row).text() + for row in range(dialog.trigger_list.count()) + ] + assert "def" in triggers and "for" in triggers + + def test_selecting_a_trigger_shows_its_body(self, dialog): + row = [ + index for index in range(dialog.trigger_list.count()) + if dialog.trigger_list.item(index).text() == "for" + ][0] + dialog.trigger_list.setCurrentRow(row) + assert "in" in dialog.body_edit.toPlainText() + + def test_adding_creates_a_new_trigger(self, dialog): + before = dialog.trigger_list.count() + trigger = dialog.add_snippet() + assert dialog.trigger_list.count() == before + 1 + assert trigger in dialog.snippets() + + def test_adding_twice_does_not_overwrite(self, dialog): + first = dialog.add_snippet() + second = dialog.add_snippet() + assert first != second + + def test_editing_a_body_is_kept(self, dialog): + trigger = dialog.add_snippet() + dialog.body_edit.setPlainText("changed $0") + dialog.add_snippet() # moving away keeps the edited body + assert dialog.snippets()[trigger] == "changed $0" + + def test_removing_takes_it_out_of_the_list(self, dialog): + trigger = dialog.add_snippet() + assert dialog.remove_snippet() is True + assert trigger not in dialog.snippets() + + def test_removing_without_a_selection_is_refused(self, dialog): + dialog.trigger_list.setCurrentRow(-1) + assert dialog.remove_snippet() is False + + def test_saving_writes_the_file(self, dialog): + with patch( + "je_editor.pyside_ui.code.snippets.snippet_manager.snippet_file_path", + return_value=dialog._test_path, + ): + trigger = dialog.add_snippet() + dialog.body_edit.setPlainText("saved body $0") + assert dialog.save() is True + assert trigger in dialog._test_path.read_text(encoding="utf-8") diff --git a/test/test_snippets.py b/test/test_snippets.py index 6bd2f9a3..a641a93d 100644 --- a/test/test_snippets.py +++ b/test/test_snippets.py @@ -85,6 +85,42 @@ def test_loading_a_user_file(self, tmp_path): path.write_text(json.dumps({"log": "print($0)"}), encoding="utf-8") assert load_snippets(path)["log"] == "print($0)" + def test_language_snippets_are_added_for_their_suffix(self): + assert "iferr" in merge_snippets(None, ".go") + assert "iferr" not in merge_snippets(None, ".py") + + def test_a_user_group_for_a_suffix_is_applied(self): + merged = merge_snippets({".go": {"mine": "body"}}, ".go") + assert merged["mine"] == "body" + + def test_a_group_for_another_suffix_is_ignored(self): + assert "mine" not in merge_snippets({".ts": {"mine": "body"}}, ".go") + + def test_a_user_definition_beats_the_language_set(self): + merged = merge_snippets({"iferr": "custom"}, ".go") + assert merged["iferr"] == "custom" + + +class TestSaveSnippets: + def test_round_trip_through_the_file(self, tmp_path): + from je_editor.pyside_ui.code.snippets.snippet_manager import save_snippets + path = tmp_path / "snippets.json" + assert save_snippets({"mine": "body $0"}, path) is True + assert load_snippets(path)["mine"] == "body $0" + + def test_saving_creates_the_directory(self, tmp_path): + from je_editor.pyside_ui.code.snippets.snippet_manager import save_snippets + path = tmp_path / "nested" / "snippets.json" + assert save_snippets({"a": "b"}, path) is True + assert path.is_file() + + def test_an_unwritable_path_is_reported(self, tmp_path): + from je_editor.pyside_ui.code.snippets.snippet_manager import save_snippets + # A directory where the file should be cannot be written to. + target = tmp_path / "snippets.json" + target.mkdir() + assert save_snippets({"a": "b"}, target) is False + @pytest.fixture(scope="module") def app(): @@ -165,6 +201,17 @@ def test_tab_still_indents_a_selection(self, editor): _press_tab(editor) assert editor.toPlainText().startswith(" ") + def test_a_typescript_file_gets_typescript_snippets(self, editor): + editor.current_file = "app.ts" + editor.snippet_manager.reload() + snippets = editor.snippet_manager.snippets() + assert "log" in snippets and "console.log" in snippets["log"] + + def test_a_python_file_keeps_the_python_set(self, editor): + editor.current_file = "module.py" + editor.snippet_manager.reload() + assert "def" in editor.snippet_manager.snippets() + def test_reloading_picks_up_user_snippets(self, editor, tmp_path): path = tmp_path / "snippets.json" path.write_text(json.dumps({"zzz": "expanded $0"}), encoding="utf-8") From 6a9bf4e4c29fef52d25e50ccadc11af8c96b9b70 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:49:05 +0800 Subject: [PATCH 30/36] Stage one change at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging was all-or-nothing: GitService could only add the whole file. Stage This Change in the context menu now puts just the block under the caret into the index, by assembling the committed content with that one hunk applied and writing it as the index entry — the file on disk is left exactly as it is being edited, and every other change stays uncommitted. Reading the index back also gives a diff of staged against working, so what is about to be committed can be checked before committing it. --- je_editor/git_client/file_staging.py | 94 ++++++++++++ .../code_edit_plaintext.py | 41 +++++ je_editor/utils/file_diff/line_status.py | 26 ++++ je_editor/utils/multi_language/english.py | 1 + .../multi_language/traditional_chinese.py | 1 + test/test_hunk_staging.py | 145 ++++++++++++++++++ 6 files changed, 308 insertions(+) create mode 100644 je_editor/git_client/file_staging.py create mode 100644 test/test_hunk_staging.py diff --git a/je_editor/git_client/file_staging.py b/je_editor/git_client/file_staging.py new file mode 100644 index 00000000..21d57926 --- /dev/null +++ b/je_editor/git_client/file_staging.py @@ -0,0 +1,94 @@ +""" +把內容寫進 git 索引,並讀回已暫存的版本 +Write content into the git index, and read the staged version back. + +``GitService`` 只能一次暫存整個檔案;要「只暫存這一段」就得自己組出該版本的內容 +再寫進索引,工作區的檔案則保持不動。 +``GitService`` can only stage a whole file. Staging one hunk means assembling the +content that version would have and writing it into the index, while the file in +the working tree is left untouched. +""" +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +from git import Blob, IndexEntry +from git.exc import GitError +from gitdb.base import IStream + +from je_editor.git_client.file_baseline import open_repository +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# git 中一般檔案的模式 / The mode git uses for a normal file +_FILE_MODE = 0o100644 + + +def _relative_to(repo, file_path: str | Path) -> str | None: + """取得檔案相對於工作區的路徑 / The file's path relative to the working tree.""" + try: + relative = Path(file_path).resolve().relative_to(Path(repo.working_tree_dir).resolve()) + except (ValueError, TypeError, AttributeError, OSError): + return None + return relative.as_posix() + + +def staged_text(file_path: str | Path) -> str | None: + """ + 取得檔案目前在索引中的內容 + Return the file's content as it currently stands in the index. + + :param file_path: 檔案路徑 / the file to read + :return: 已暫存的文字;不在索引或不是文字時為 ``None`` + the staged text, or ``None`` when it is not in the index or not text + """ + repo = open_repository(file_path) + if repo is None: + return None + with repo: + relative = _relative_to(repo, file_path) + if relative is None: + return None + try: + entry = repo.index.entries[(relative, 0)] + blob = Blob(repo, entry.binsha, entry.mode, relative) + text = blob.data_stream.read().decode("utf-8") + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): + return None + except UnicodeDecodeError: + jeditor_logger.debug(f"file_staging: {file_path} is not utf-8 text") + return None + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def stage_content(file_path: str | Path, content: str) -> bool: + """ + 把指定內容寫進索引,工作區的檔案不動 + Write the given content into the index, leaving the working tree alone. + + 這正是「只暫存這一段」的做法:索引拿到套用了該段變更的版本,磁碟上的檔案仍是 + 使用者正在編輯的樣子。 + This is how one hunk is staged: the index receives the version with that hunk + applied while the file on disk stays as the user is editing it. + + :param file_path: 檔案路徑 / the file to stage + :param content: 要暫存的內容 / the content to stage + :return: 寫入成功時為 ``True`` / ``True`` when the index was updated + """ + repo = open_repository(file_path) + if repo is None: + return False + with repo: + relative = _relative_to(repo, file_path) + if relative is None: + return False + data = content.encode("utf-8") + try: + stream = repo.odb.store(IStream(Blob.type, len(data), BytesIO(data))) + repo.index.add([IndexEntry.from_blob( + Blob(repo, stream.binsha, _FILE_MODE, relative))]) + repo.index.write() + except (ValueError, TypeError, AttributeError, GitError, OSError) as error: + jeditor_logger.error(f"file_staging: could not stage {file_path}: {error!r}") + return False + return True 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 92a3f055..15d07a5b 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 @@ -33,6 +33,9 @@ from je_editor.utils.indentation.indent_guides import ( guide_columns, trailing_whitespace_start ) +from je_editor.git_client.file_staging import stage_content, staged_text +from je_editor.utils.file_diff.line_status import apply_hunk +from je_editor.utils.file_diff.unified import unified_diff_text from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries from je_editor.utils.macro.keystroke_macro import KeystrokeMacro from je_editor.utils.selection.surround import SURROUND_PAIRS, surround @@ -724,6 +727,42 @@ def _paint_blame_annotations(self) -> None: block_number += 1 painter.end() + def stage_change_at_cursor(self) -> bool: + """ + 只把游標所在的變更區塊加入索引 + Stage just the change block under the caret. + + 索引拿到「基準加上這一段」的版本,其他變更維持已提交的樣子,磁碟上的檔案 + 完全不動。 + The index receives the baseline with this one hunk applied, every other + change stays as committed, and the file on disk is untouched. + + :return: 有暫存時為 ``True`` / ``True`` when the hunk was staged + """ + baseline = self.diff_marker_manager.baseline() + if baseline is None or self.current_file is None: + return False + hunk = self.diff_marker_manager.hunk_at(self.textCursor().blockNumber()) + if hunk is None: + return False + staged = apply_hunk(baseline, self.toPlainText(), hunk) + return stage_content(str(self.current_file), staged) + + def staged_diff_text(self) -> str: + """ + 取得索引版本與編輯中內容的差異 + The diff between what is staged and what is being edited. + + :return: diff 文字;沒有差異或不在索引中時為空字串 / the diff, or an empty string + """ + if self.current_file is None: + return "" + staged = staged_text(str(self.current_file)) + if staged is None: + return "" + return unified_diff_text( + staged, self.toPlainText(), Path(str(self.current_file)).name) + def revert_change_at_cursor(self) -> bool: """ 把游標所在的變更區塊還原成已提交的內容 @@ -2485,6 +2524,8 @@ def build_context_menu(self) -> QMenu: ("context_menu_rename_symbol", self.rename_symbol, True), ("context_menu_format_document", self.format_with_language_server, self.lsp_client.running), + ("context_menu_stage_hunk", self.stage_change_at_cursor, + self.diff_marker_manager.has_baseline), ("context_menu_revert_hunk", self.revert_change_at_cursor, self.diff_marker_manager.has_baseline), ): diff --git a/je_editor/utils/file_diff/line_status.py b/je_editor/utils/file_diff/line_status.py index 9cb236e8..898bf18c 100644 --- a/je_editor/utils/file_diff/line_status.py +++ b/je_editor/utils/file_diff/line_status.py @@ -163,6 +163,32 @@ def baseline_lines_of(baseline: str, hunk: Hunk) -> list[str]: return _split_lines(baseline)[hunk.baseline_start:hunk.baseline_end] +def apply_hunk(baseline: str, current: str, hunk: Hunk) -> str: + """ + 只把一段變更套用到基準內容上 + Apply one hunk of change to the baseline, leaving the rest of it alone. + + 這是「只暫存這一段」需要的內容:其他變更維持成已提交的樣子。 + This is the content needed to stage one hunk: every other change stays as it + was committed. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :param hunk: 要套用的變更 / the hunk to apply + :return: 套用後的內容 / the content with that one hunk applied + """ + baseline_lines = _split_lines(baseline) + current_lines = _split_lines(current) + merged = ( + baseline_lines[:hunk.baseline_start] + + current_lines[hunk.start:hunk.end] + + baseline_lines[hunk.baseline_end:] + ) + if not merged: + return "" + return "\n".join(merged) + "\n" + + def changed_line_numbers(statuses: dict[int, str]) -> list[int]: """ 取得有變更的行號(由小到大) diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 683d069b..6690de3a 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -398,6 +398,7 @@ "context_menu_toggle_bookmark": "Toggle Bookmark", "context_menu_go_to_definition": "Go to Definition", "context_menu_revert_hunk": "Revert This Change", + "context_menu_stage_hunk": "Stage This Change", "context_menu_hover": "Describe Symbol", "context_menu_rename_symbol": "Rename Symbol", "context_menu_rename_prompt": "New name:", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 58fe05c2..503d0b0f 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -388,6 +388,7 @@ "context_menu_toggle_bookmark": "切換書籤", "context_menu_go_to_definition": "跳到定義", "context_menu_revert_hunk": "還原這段變更", + "context_menu_stage_hunk": "暫存這段變更", "context_menu_hover": "說明這個符號", "context_menu_rename_symbol": "重新命名符號", "context_menu_rename_prompt": "新名稱:", diff --git a/test/test_hunk_staging.py b/test/test_hunk_staging.py new file mode 100644 index 00000000..b0e0cefb --- /dev/null +++ b/test/test_hunk_staging.py @@ -0,0 +1,145 @@ +"""Tests for staging one hunk and comparing staged against working.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.git_client.file_staging import stage_content, staged_text +from je_editor.utils.file_diff.line_status import apply_hunk, hunk_at_line + +git = pytest.importorskip("git") + + +class TestApplyHunk: + def test_only_the_named_hunk_is_applied(self): + baseline = "a\nb\nc\nd\n" + current = "A\nb\nc\nD\n" + first = hunk_at_line(baseline, current, 0) + assert apply_hunk(baseline, current, first) == "A\nb\nc\nd\n" + + def test_the_other_hunk_applies_on_its_own(self): + baseline = "a\nb\nc\nd\n" + current = "A\nb\nc\nD\n" + last = hunk_at_line(baseline, current, 3) + assert apply_hunk(baseline, current, last) == "a\nb\nc\nD\n" + + def test_an_added_line_is_inserted(self): + baseline = "a\nb\n" + current = "a\nnew\nb\n" + hunk = hunk_at_line(baseline, current, 1) + assert apply_hunk(baseline, current, hunk) == "a\nnew\nb\n" + + def test_a_deleted_line_is_removed(self): + baseline = "a\nb\nc\n" + current = "a\nc\n" + hunk = hunk_at_line(baseline, current, 1) + assert apply_hunk(baseline, current, hunk) == "a\nc\n" + + def test_emptying_a_file_yields_empty_content(self): + baseline = "a\n" + hunk = hunk_at_line(baseline, "", 0) + assert hunk is None or apply_hunk(baseline, "", hunk) == "" + + +@pytest.fixture() +def repo(tmp_path): + """A throw-away repository with one committed file.""" + repository = git.Repo.init(tmp_path) + with repository.config_writer() as config: + config.set_value("user", "name", "Stage Tester") + config.set_value("user", "email", "stage@example.com") + tracked = tmp_path / "tracked.py" + tracked.write_text("a\nb\nc\n", encoding="utf-8") + repository.index.add(["tracked.py"]) + repository.index.commit("initial") + repository.close() + yield tmp_path + + +class TestStaging: + def test_staged_text_reads_the_index(self, repo): + assert staged_text(repo / "tracked.py") == "a\nb\nc\n" + + def test_staging_updates_the_index_only(self, repo): + tracked = repo / "tracked.py" + tracked.write_text("a\nWORKING\nc\n", encoding="utf-8") + assert stage_content(tracked, "a\nSTAGED\nc\n") is True + assert staged_text(tracked) == "a\nSTAGED\nc\n" + # The working tree keeps what the user is editing. + assert tracked.read_text(encoding="utf-8") == "a\nWORKING\nc\n" + + def test_staging_one_hunk_leaves_the_other_committed(self, repo): + tracked = repo / "tracked.py" + baseline = "a\nb\nc\n" + current = "A\nb\nC\n" + tracked.write_text(current, encoding="utf-8") + first = hunk_at_line(baseline, current, 0) + stage_content(tracked, apply_hunk(baseline, current, first)) + assert staged_text(tracked) == "A\nb\nc\n" + + def test_a_file_outside_a_repository_cannot_be_staged(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert stage_content(loose, "y\n") is False + assert staged_text(loose) is None + + def test_an_untracked_file_has_no_staged_text(self, repo): + new_file = repo / "untracked.py" + new_file.write_text("x\n", encoding="utf-8") + assert staged_text(new_file) is None + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.close() + code_editor.deleteLater() + + +class TestEditorStaging: + def test_staging_the_hunk_under_the_caret(self, editor, repo): + tracked = repo / "tracked.py" + editor.current_file = str(tracked) + editor.setPlainText("A\nb\nC\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + cursor = editor.textCursor() + cursor.setPosition(0) + editor.setTextCursor(cursor) + assert editor.stage_change_at_cursor() is True + assert staged_text(tracked) == "A\nb\nc\n" + + def test_staging_without_a_baseline_is_refused(self, editor): + editor.setPlainText("x\n") + assert editor.stage_change_at_cursor() is False + + def test_staging_an_unchanged_line_is_refused(self, editor, repo): + editor.current_file = str(repo / "tracked.py") + editor.setPlainText("a\nb\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + assert editor.stage_change_at_cursor() is False + + def test_staged_diff_shows_the_unstaged_edit(self, editor, repo): + tracked = repo / "tracked.py" + editor.current_file = str(tracked) + editor.setPlainText("a\nEDITED\nc\n") + diff = editor.staged_diff_text() + assert "-b" in diff and "+EDITED" in diff + + def test_no_diff_without_a_file(self, editor): + editor.current_file = None + assert editor.staged_diff_text() == "" From d7baa57eabed04ef3cdf144c7d5648508a56aebf Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Jul 2026 21:53:51 +0800 Subject: [PATCH 31/36] Set breakpoints and step through a debug run The debugger could be started but not directed: there was no way to say where to stop or what to do once it did. F9 toggles a breakpoint on the caret''s line, drawn in the gutter, and the marks are anchored to the text so inserting a line above one moves it with its code rather than leaving it pointing at the wrong place. F5, F10, F11 and Shift+F11 continue, step over, step into and step out by writing the matching pdb command to the running debugger, and the breakpoints can be handed to it the same way. Nothing is sent when no debugger is running, since a pdb command only means anything while it waits for input, and a broken pipe is reported rather than raised. --- .../pyside_ui/code/breakpoint/__init__.py | 0 .../code/breakpoint/breakpoint_manager.py | 87 ++++++++++ .../code_edit_plaintext.py | 111 +++++++++++- .../save_settings/user_color_setting_file.py | 2 + je_editor/utils/debugger/__init__.py | 0 je_editor/utils/debugger/pdb_commands.py | 75 +++++++++ test/test_breakpoints.py | 159 ++++++++++++++++++ 7 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 je_editor/pyside_ui/code/breakpoint/__init__.py create mode 100644 je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py create mode 100644 je_editor/utils/debugger/__init__.py create mode 100644 je_editor/utils/debugger/pdb_commands.py create mode 100644 test/test_breakpoints.py diff --git a/je_editor/pyside_ui/code/breakpoint/__init__.py b/je_editor/pyside_ui/code/breakpoint/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py b/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py new file mode 100644 index 00000000..f766e657 --- /dev/null +++ b/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py @@ -0,0 +1,87 @@ +""" +管理一個編輯器的中斷點 +Track the breakpoints set in one editor. + +中斷點跟著文字走:在上方插入或刪除行時,標記要跟著移動,否則加一行就會讓每個 +中斷點都指到錯的地方。書籤已經用 ``QTextCursor`` 解決過同樣的問題,這裡沿用。 +Breakpoints follow the text: inserting or removing a line above one has to move +it, or adding a line would leave every breakpoint pointing at the wrong place. +Bookmarks already solve this with ``QTextCursor``, and the same approach is used +here. +""" +from __future__ import annotations + +from PySide6.QtGui import QTextCursor + + +class BreakpointManager: + """ + 追蹤中斷點所在的行 + Track which lines have a breakpoint. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些中斷點所屬的編輯器 / the editor they belong to + """ + self._code_edit = code_edit + self._cursors: list[QTextCursor] = [] + + def lines(self) -> list[int]: + """ + 取得目前有中斷點的行(0 起算,已排序) + The lines that currently have a breakpoint, 0-based and sorted. + + :return: 行號 / the line numbers + """ + return sorted({cursor.blockNumber() for cursor in self._cursors}) + + def has_breakpoint(self, line: int) -> bool: + """ + 判斷某行是否有中斷點 + Whether a line has a breakpoint. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 有的話為 ``True`` / ``True`` when it has one + """ + return line in self.lines() + + def toggle(self, line: int) -> bool: + """ + 切換某一行的中斷點 + Add or remove the breakpoint on a line. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 切換後是否有中斷點 / whether the line now has one + """ + for cursor in list(self._cursors): + if cursor.blockNumber() == line: + self._cursors.remove(cursor) + return False + block = self._code_edit.document().findBlockByNumber(line) + if not block.isValid(): + return False + cursor = QTextCursor(block) + self._cursors.append(cursor) + return True + + def clear(self) -> bool: + """ + 清除所有中斷點 + Remove every breakpoint. + + :return: 是否真的清掉了什麼 / whether anything was removed + """ + if not self._cursors: + return False + self._cursors = [] + return True + + def pdb_lines(self) -> list[int]: + """ + 取得給 pdb 用的行號(1 起算) + The line numbers as pdb counts them, from one. + + :return: 行號 / the line numbers + """ + return [line + 1 for line in self.lines()] 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 15d07a5b..b836613d 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 @@ -15,6 +15,7 @@ ) from je_editor.pyside_ui.code.bookmark.bookmark_manager import BookmarkManager +from je_editor.pyside_ui.code.breakpoint.breakpoint_manager import BreakpointManager from je_editor.pyside_ui.code.folding.folding_manager import FoldingManager from je_editor.pyside_ui.code.git_diff.blame_manager import BlameManager from je_editor.pyside_ui.code.git_diff.diff_marker_manager import DiffMarkerManager @@ -34,6 +35,7 @@ guide_columns, trailing_whitespace_start ) from je_editor.git_client.file_staging import stage_content, staged_text +from je_editor.utils.debugger.pdb_commands import breakpoint_commands, step_command from je_editor.utils.file_diff.line_status import apply_hunk from je_editor.utils.file_diff.unified import unified_diff_text from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries @@ -351,6 +353,10 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.macro = KeystrokeMacro() self._register_macro_actions() + # 中斷點 / Breakpoints + self.breakpoint_manager = BreakpointManager(self) + self._register_breakpoint_actions() + # 非 Python 檔的補全與診斷都交給語言伺服器;Python 仍走 jedi 與 ruff # A non-Python file gets its completion and diagnostics from a language # server; Python keeps jedi and ruff @@ -1083,6 +1089,7 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: painter.fillRect(event.rect(), actually_color_dict.get("line_number_background_color")) bookmarked = set(self.bookmark_manager.bookmarked_lines()) + breakpoints = set(self.breakpoint_manager.lines()) fold_headers = self._foldable_header_lines() folded_headers = self.folding_manager.folded_header_lines() diff_statuses = self.diff_marker_manager.statuses() @@ -1112,7 +1119,9 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: if diff_status is not None: self._paint_diff_marker( painter, top, line_height, gutter_width, diff_status) - if block_number in bookmarked: + if block_number in breakpoints: + self._paint_breakpoint_marker(painter, top, line_height) + elif block_number in bookmarked: self._paint_bookmark_marker(painter, top, line_height) if block_number in fold_headers: self._paint_fold_marker( @@ -1123,6 +1132,28 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: bottom = top + self.blockBoundingRect(block).height() block_number += 1 + def _paint_breakpoint_marker( + self, painter: QPainter, top: float, line_height: int) -> None: + """ + 在行號左側繪製中斷點 + Draw the breakpoint dot on the gutter's left. + + 與書籤共用同一欄,因此畫在同一個位置;顏色不同,且中斷點優先顯示,因為 + 它會影響執行。 + It shares the bookmark column and so sits in the same place, but in a + different colour, and takes precedence because it changes what runs. + """ + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + colour = actually_color_dict.get("breakpoint_marker_color") + painter.setBrush(colour) + painter.setPen(colour) + radius = max(3, line_height // 4) + center_y = int(top) + line_height // 2 + painter.drawEllipse( + _BOOKMARK_MARKER_WIDTH // 2 - radius, center_y - radius, radius * 2, radius * 2) + painter.restore() + def _paint_bookmark_marker(self, painter: QPainter, top: float, line_height: int) -> None: """在行號左側繪製書籤圓點 / Draw the bookmark dot on the gutter's left.""" painter.save() @@ -2150,6 +2181,84 @@ def request_language_server_completion(self) -> bool: return self.lsp_client.request_completion( cursor.blockNumber(), cursor.positionInBlock()) + def _register_breakpoint_actions(self) -> None: + """註冊中斷點與逐步執行的快捷鍵 / Register breakpoint and stepping shortcuts.""" + for shortcut, handler in ( + ("F9", self.toggle_breakpoint), + ("F5", lambda: self.send_debugger_command("continue")), + ("F10", lambda: self.send_debugger_command("over")), + ("F11", lambda: self.send_debugger_command("into")), + ("Shift+F11", lambda: self.send_debugger_command("out")), + ): + action = QAction(self) + action.setShortcut(shortcut) + action.triggered.connect(handler) + self.addAction(action) + + def toggle_breakpoint(self) -> bool: + """ + 切換游標所在行的中斷點 + Add or remove a breakpoint on the caret's line. + + :return: 切換後是否有中斷點 / whether the line now has one + """ + result = self.breakpoint_manager.toggle(self.textCursor().blockNumber()) + self.line_number.update() + return result + + def _debugger_process(self): + """取得正在執行的除錯器程序 / The debugger process that is running, if any.""" + manager = getattr(self.main_window, "exec_python_debugger", None) + return getattr(manager, "process", None) if manager is not None else None + + def send_debugger_command(self, action: str) -> bool: + """ + 送出一個逐步執行指令給除錯器 + Send one stepping command to the debugger. + + 除錯器沒在跑時什麼都不做,因為 pdb 的指令只有在它等待輸入時才有意義。 + Nothing happens when the debugger is not running, since a pdb command + only means something while it is waiting for input. + + :param action: 動作名稱(``into``/``over``/``out``/``continue``/``quit``) + the action's name + :return: 有送出時為 ``True`` / ``True`` when the command was sent + """ + command = step_command(action) + if command is None: + return False + return self._write_debugger_line(command) + + def send_breakpoints_to_debugger(self) -> int: + """ + 把目前的中斷點送給正在執行的除錯器 + Send the current breakpoints to the running debugger. + + :return: 送出的中斷點數量 / how many breakpoints were sent + """ + if self.current_file is None: + return 0 + sent = 0 + for command in breakpoint_commands( + str(self.current_file), self.breakpoint_manager.pdb_lines()): + if self._write_debugger_line(command): + sent += 1 + return sent + + def _write_debugger_line(self, command: str) -> bool: + """把一行指令寫進除錯器的標準輸入 / Write one command to the debugger's stdin.""" + process = self._debugger_process() + stdin = getattr(process, "stdin", None) if process is not None else None + if stdin is None: + return False + try: + stdin.write(command.encode() + b"\n") + stdin.flush() + except (OSError, ValueError) as error: + jeditor_logger.warning(f"CodeEditor debugger command failed: {error}") + return False + return True + def _register_macro_actions(self) -> None: """註冊巨集與環繞選取的快捷鍵 / Register the macro and surround shortcuts.""" for shortcut, handler in ( diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index 1e4e476a..9fea4d8c 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -49,6 +49,7 @@ def update_actually_color_dict() -> None: "minimap_line_color": _to_qcolor("minimap_line_color", [130, 130, 150]), "minimap_viewport_color": _to_qcolor("minimap_viewport_color", [80, 80, 110]), "extra_cursor_color": _to_qcolor("extra_cursor_color", [255, 215, 64]), + "breakpoint_marker_color": _to_qcolor("breakpoint_marker_color", [229, 57, 53]), "syntax_keyword_color": _to_qcolor("syntax_keyword_color", [86, 156, 214]), "syntax_string_color": _to_qcolor("syntax_string_color", [206, 145, 120]), "syntax_comment_color": _to_qcolor("syntax_comment_color", [106, 153, 85]), @@ -80,6 +81,7 @@ def update_actually_color_dict() -> None: "minimap_line_color": [130, 130, 150], "minimap_viewport_color": [80, 80, 110], "extra_cursor_color": [255, 215, 64], + "breakpoint_marker_color": [229, 57, 53], "syntax_keyword_color": [86, 156, 214], "syntax_string_color": [206, 145, 120], "syntax_comment_color": [106, 153, 85], diff --git a/je_editor/utils/debugger/__init__.py b/je_editor/utils/debugger/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/debugger/pdb_commands.py b/je_editor/utils/debugger/pdb_commands.py new file mode 100644 index 00000000..bf172579 --- /dev/null +++ b/je_editor/utils/debugger/pdb_commands.py @@ -0,0 +1,75 @@ +""" +組出要送給 pdb 的指令 +Build the commands to send to pdb. + +編輯器的除錯器就是 ``python -m pdb``,它從標準輸入讀指令。中斷點與逐步執行因此 +只是「送出正確的指令字串」,這裡負責組字串,實際傳送交給既有的除錯輸入。 +The editor's debugger is ``python -m pdb``, which reads commands from standard +input. Breakpoints and stepping are therefore a matter of sending the right +command, and this module builds those strings; sending them is the existing +debugger input's job. + +純邏輯:不啟動任何程序。 +Pure logic: it starts no process. +""" +from __future__ import annotations + +from pathlib import Path + +# pdb 的逐步執行指令 / pdb's stepping commands +STEP_INTO = "step" +STEP_OVER = "next" +STEP_OUT = "return" +CONTINUE = "continue" +QUIT = "quit" + +# 逐步動作對應的指令 / The command for each stepping action +STEP_COMMANDS = { + "into": STEP_INTO, + "over": STEP_OVER, + "out": STEP_OUT, + "continue": CONTINUE, + "quit": QUIT, +} + + +def breakpoint_command(file_path: str | Path, line: int) -> str: + """ + 組出設定一個中斷點的指令 + Build the command that sets one breakpoint. + + pdb 的行號從 1 起算,與編輯器顯示的一致。 + pdb counts lines from one, the same as the editor shows. + + :param file_path: 檔案路徑 / the file to break in + :param line: 1 起算的行號 / the 1-based line number + :return: pdb 指令 / the pdb command + """ + return f"break {Path(file_path).as_posix()}:{max(1, line)}" + + +def breakpoint_commands(file_path: str | Path, lines: list[int]) -> list[str]: + """ + 組出設定多個中斷點的指令 + Build the commands that set several breakpoints. + + 行號排序且去重,因此送出的指令與畫面上的標記一一對應。 + The lines are sorted and de-duplicated, so the commands match the markers on + screen one for one. + + :param file_path: 檔案路徑 / the file to break in + :param lines: 1 起算的行號 / the 1-based line numbers + :return: pdb 指令 / the pdb commands + """ + return [breakpoint_command(file_path, line) for line in sorted(set(lines)) if line >= 1] + + +def step_command(action: str) -> str | None: + """ + 取得某個逐步動作的指令 + The command for a stepping action. + + :param action: 動作名稱 / the action's name + :return: pdb 指令,動作未知時為 ``None`` / the command, or ``None`` + """ + return STEP_COMMANDS.get(action) diff --git a/test/test_breakpoints.py b/test/test_breakpoints.py new file mode 100644 index 00000000..270d5f49 --- /dev/null +++ b/test/test_breakpoints.py @@ -0,0 +1,159 @@ +"""Tests for breakpoints and the stepping commands sent to pdb.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.debugger.pdb_commands import ( + CONTINUE, + STEP_INTO, + STEP_OUT, + STEP_OVER, + breakpoint_command, + breakpoint_commands, + step_command, +) + + +class TestPdbCommands: + def test_a_breakpoint_names_the_file_and_line(self): + assert breakpoint_command("/project/app.py", 12) == "break /project/app.py:12" + + def test_windows_paths_use_forward_slashes(self): + assert breakpoint_command("D:\\project\\app.py", 3) == "break D:/project/app.py:3" + + def test_line_numbers_below_one_are_clamped(self): + assert breakpoint_command("app.py", 0).endswith(":1") + + def test_several_breakpoints_are_sorted_and_unique(self): + commands = breakpoint_commands("app.py", [7, 3, 7]) + assert commands == ["break app.py:3", "break app.py:7"] + + def test_invalid_lines_are_dropped(self): + assert breakpoint_commands("app.py", [0, -2]) == [] + + @pytest.mark.parametrize("action,expected", [ + ("into", STEP_INTO), ("over", STEP_OVER), + ("out", STEP_OUT), ("continue", CONTINUE), + ]) + def test_stepping_actions(self, action, expected): + assert step_command(action) == expected + + def test_an_unknown_action_has_no_command(self): + assert step_command("sideways") is None + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + parent.exec_python_debugger = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.close() + code_editor.deleteLater() + + +def _place_cursor(editor, line: int) -> None: + block = editor.document().findBlockByNumber(line) + cursor = editor.textCursor() + cursor.setPosition(block.position()) + editor.setTextCursor(cursor) + + +class TestBreakpointManager: + def test_none_at_first(self, editor): + editor.setPlainText("one\ntwo\nthree\n") + assert editor.breakpoint_manager.lines() == [] + + def test_toggling_sets_then_clears(self, editor): + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 1) + assert editor.toggle_breakpoint() is True + assert editor.breakpoint_manager.lines() == [1] + assert editor.toggle_breakpoint() is False + assert editor.breakpoint_manager.lines() == [] + + def test_breakpoints_follow_inserted_lines(self, editor): + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 2) + editor.toggle_breakpoint() + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.insertText("inserted above\n") + # The breakpoint stays on "three", which is now one line further down. + assert editor.breakpoint_manager.lines() == [3] + + def test_pdb_lines_are_one_based(self, editor): + editor.setPlainText("one\ntwo\n") + _place_cursor(editor, 0) + editor.toggle_breakpoint() + assert editor.breakpoint_manager.pdb_lines() == [1] + + def test_clearing_removes_everything(self, editor): + editor.setPlainText("one\ntwo\n") + _place_cursor(editor, 0) + editor.toggle_breakpoint() + assert editor.breakpoint_manager.clear() is True + assert editor.breakpoint_manager.lines() == [] + + def test_a_line_past_the_end_is_refused(self, editor): + editor.setPlainText("only\n") + assert editor.breakpoint_manager.toggle(99) is False + + def test_painting_with_a_breakpoint_does_not_raise(self, editor): + editor.setPlainText("one\ntwo\n") + _place_cursor(editor, 0) + editor.toggle_breakpoint() + editor.show() + QApplication.processEvents() + editor.hide() + + +class TestSendingToTheDebugger: + def test_nothing_is_sent_without_a_debugger(self, editor): + assert editor.send_debugger_command("over") is False + + def test_an_unknown_action_is_refused(self, editor): + assert editor.send_debugger_command("sideways") is False + + def test_a_stepping_command_reaches_stdin(self, editor): + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + assert editor.send_debugger_command("into") is True + stdin.write.assert_called_once_with(b"step\n") + + def test_breakpoints_are_sent_for_the_current_file(self, editor): + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 1) + editor.toggle_breakpoint() + assert editor.send_breakpoints_to_debugger() == 1 + stdin.write.assert_called_once_with(b"break app.py:2\n") + + def test_no_breakpoints_are_sent_without_a_file(self, editor): + editor.current_file = None + assert editor.send_breakpoints_to_debugger() == 0 + + def test_a_broken_pipe_is_reported_rather_than_raised(self, editor): + stdin = MagicMock() + stdin.write.side_effect = OSError("broken pipe") + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + assert editor.send_debugger_command("continue") is False From 94b88fa030cc1cf0ab18ae286fe15fd49945ac6b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 11:49:44 +0800 Subject: [PATCH 32/36] Give every editor shortcut one owner Seven key sequences had two owners, and Qt runs neither action when that happens, so each clash quietly disabled both features: - Ctrl+D added a caret instead of duplicating the line - Ctrl+Shift+P collided with pip install - Ctrl+Alt+S collided with Save All - Ctrl+Alt+Up/Down collided with incrementing the number under the caret - F5 and F9 collided with the toolbar Run and Debug Change-navigation moves to F7 and Shift+F7, macro playback to Ctrl+Shift+G, the caret-at-next-occurrence to Ctrl+Alt+N, breakpoints to Ctrl+F9 and Ctrl+F5, Save All to Ctrl+Shift+S, and find-and-replace to Ctrl+H so the toolbar keeps Ctrl+Shift+F for searching across files. Registration went through eight copies of the same four lines. They now share one helper backed by a registry that records who owns each sequence, logs a clash instead of letting it pass, and gives every editor command a stable name. Tests walk the real actions rather than a list, so the next duplicate fails the build. --- README.md | 23 +- .../code_edit_plaintext.py | 248 ++++++++++-------- .../main_ui/menu/file_menu/build_file_menu.py | 5 +- je_editor/utils/shortcuts/__init__.py | 0 .../utils/shortcuts/shortcut_registry.py | 191 ++++++++++++++ test/test_shortcut_registry.py | 148 +++++++++++ test/test_toolbar_actions.py | 12 + 7 files changed, 517 insertions(+), 110 deletions(-) create mode 100644 je_editor/utils/shortcuts/__init__.py create mode 100644 je_editor/utils/shortcuts/shortcut_registry.py create mode 100644 test/test_shortcut_registry.py diff --git a/README.md b/README.md index 2ff146c2..f5583717 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ Plugins are automatically discovered from the `jeditor_plugins/` directory. See | `Ctrl+O` | Open file | | `Ctrl+K` | Open folder | | `Ctrl+S` | Save file | +| `Ctrl+Shift+S` | Save every modified tab | | `Ctrl+Shift+A` | Command palette | | `Ctrl+P` | Quick open (go to file) | | `Ctrl+Shift+O` | Go to symbol | @@ -325,13 +326,33 @@ Plugins are automatically discovered from the `jeditor_plugins/` directory. See | `Ctrl+Alt+Up` | Increment number under caret | | `Ctrl+Alt+Down` | Decrement number under caret | | `F2` | Rename occurrences in file | +| `Ctrl+D` | Duplicate line / selection | +| `Alt+Up` / `Alt+Down` | Move line up / down | +| `Ctrl+/` | Toggle comment | +| `Ctrl+Shift+L` | Caret at the end of every selected line | +| `Ctrl+Alt+N` | Add caret at next occurrence | +| `Ctrl+Alt+Shift+Up` / `Down` | Add caret above / below | +| `Ctrl+Shift+Esc` | Back to a single caret | +| `Ctrl+Shift+R` | Start / stop macro recording | +| `Ctrl+Shift+G` | Play macro | +| `Ctrl+Alt+E` | Recent locations | +| `Ctrl+Alt+\` | Toggle split view | +| `Ctrl+Alt+M` | Toggle minimap | +| `F7` / `Shift+F7` | Next / previous change | +| `Ctrl+Alt+Z` | Revert the change at the caret | +| `Ctrl+Alt+B` | Toggle inline blame | | `Ctrl+J` | Reformat JSON | | `Ctrl+Shift+Y` | YAPF Python format | | `Ctrl+Alt+P` | PEP8 format checker | -| `Ctrl+F` | Find text (browser) | +| `Ctrl+F` | Find text (editor, browser) | +| `Ctrl+H` | Find and replace | +| `Ctrl+G` | Go to line | | `F5` | Run program | | `F9` | Debug | | `Shift+F5` | Stop program | +| `Ctrl+F9` | Toggle breakpoint | +| `Ctrl+F5` | Debugger: continue | +| `F10` / `F11` / `Shift+F11` | Debugger: step over / into / out | | `Up/Down` | Command history (console) | --- 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 b836613d..9d907363 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 @@ -41,6 +41,9 @@ from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries from je_editor.utils.macro.keystroke_macro import KeystrokeMacro from je_editor.utils.selection.surround import SURROUND_PAIRS, surround +from je_editor.utils.shortcuts.shortcut_registry import ( + WINDOW_SHORTCUTS, ShortcutRegistry +) from je_editor.utils.line_ops.line_operations import ( join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines ) @@ -252,23 +255,20 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.setLineWrapMode(self.LineWrapMode.NoWrap) self.setWordWrapMode(QTextOption.WrapMode.WrapAnywhere) - # 搜尋功能 (Ctrl+F) - self.search_action = QAction("Search") - self.search_action.setShortcut("Ctrl+f") - self.search_action.triggered.connect(self.start_search_dialog) - self.addAction(self.search_action) - - # 搜尋與取代 (Ctrl+Shift+F) / Search & Replace shortcut - self.search_replace_action = QAction("Search & Replace") - self.search_replace_action.setShortcut("Ctrl+Shift+f") - self.search_replace_action.triggered.connect(self.open_search_replace_dialog) - self.addAction(self.search_replace_action) - - # 跳到指定行 (Ctrl+G) / Go to Line shortcut - self.goto_line_action = QAction("Go to Line") - self.goto_line_action.setShortcut("Ctrl+g") - self.goto_line_action.triggered.connect(self.go_to_line) - self.addAction(self.goto_line_action) + # 這個編輯器已經指派出去的快捷鍵;以選單與工具列佔用的組合作為起點, + # 重複指派時會留下警告紀錄 + # The sequences this editor has handed out, seeded with the ones the menus + # and toolbar already take; a clash is logged rather than passing silently + self.shortcut_registry = ShortcutRegistry(WINDOW_SHORTCUTS) + + # 搜尋 (Ctrl+F)、搜尋與取代 (Ctrl+H)、跳到指定行 (Ctrl+G) + # Search (Ctrl+F), search & replace (Ctrl+H) and go to line (Ctrl+G) + self.search_action = self._add_shortcut_action( + "Ctrl+F", "search", self.start_search_dialog) + self.search_replace_action = self._add_shortcut_action( + "Ctrl+H", "search_and_replace", self.open_search_replace_dialog) + self.goto_line_action = self._add_shortcut_action( + "Ctrl+G", "go_to_line", self.go_to_line) # 自動補全初始化 self.completer: Union[None, QCompleter] = None @@ -548,22 +548,58 @@ def find_back_text(self) -> None: text = self.search_box.search_input.text() self.find(text, QTextDocument.FindFlag.FindBackward) + # ── 快捷鍵註冊 / Shortcut registration ──────────────────────── + + def _add_shortcut_action(self, sequence: str, command: str, handler) -> QAction: + """ + 建立一個綁定快捷鍵的動作,並登記到本編輯器的快捷鍵表 + Create an action bound to a key sequence and record it in this editor's table. + + 兩個動作共用同一組按鍵時,Qt 不會挑一個執行而是兩個都不執行,因此每組按鍵 + 都要先登記;重複指派會留下警告紀錄,測試也會直接失敗。 + Two actions sharing a sequence make Qt run neither of them, so every + sequence is recorded here first: a clash is logged, and a test fails on it + outright. + + :param sequence: 按鍵組合 / the key sequence + :param command: 指令名稱,用於衝突訊息 / the command name, used in clash messages + :param handler: 觸發時呼叫的函式 / what to call when it fires + :return: 建立好的動作 / the action that was created + """ + action = QAction(self) + action.setObjectName(command) + action.setShortcut(sequence) + action.triggered.connect(handler) + owner = self.shortcut_registry.register(sequence, command) + if owner is not None: + jeditor_logger.warning( + f"CodeEditor shortcut {sequence} for {command} is already taken by {owner}") + self.addAction(action) + return action + + def _add_shortcut_actions(self, bindings) -> None: + """ + 一次註冊多組快捷鍵 + Register several shortcuts at once. + + :param bindings: ``(按鍵, 指令名稱, 處理函式)`` 的序列 + / a sequence of ``(key sequence, command name, handler)`` + """ + for sequence, command, handler in bindings: + self._add_shortcut_action(sequence, command, handler) + # ── 程式碼折疊與書籤 / Code folding and bookmarks ────────────── def _register_fold_bookmark_actions(self) -> None: """註冊折疊與書籤的快捷鍵 / Register folding and bookmark shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Shift+[", self.toggle_fold_at_cursor), - ("Ctrl+Alt+[", self.fold_all), - ("Ctrl+Alt+]", self.unfold_all), - ("Ctrl+Alt+K", self.toggle_bookmark), - ("Ctrl+Alt+L", self.next_bookmark), - ("Ctrl+Alt+J", self.previous_bookmark), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + self._add_shortcut_actions(( + ("Ctrl+Shift+[", "toggle_fold", self.toggle_fold_at_cursor), + ("Ctrl+Alt+[", "fold_all", self.fold_all), + ("Ctrl+Alt+]", "unfold_all", self.unfold_all), + ("Ctrl+Alt+K", "toggle_bookmark", self.toggle_bookmark), + ("Ctrl+Alt+L", "next_bookmark", self.next_bookmark), + ("Ctrl+Alt+J", "previous_bookmark", self.previous_bookmark), + )) def _on_text_changed_for_features(self) -> None: """ @@ -599,17 +635,20 @@ def load_git_baseline(self) -> None: self.diff_marker_manager.load_baseline(self.current_file) def _register_diff_marker_actions(self) -> None: - """註冊變更跳轉快捷鍵 / Register the change-navigation shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Alt+Down", self.next_change), - ("Ctrl+Alt+Up", self.previous_change), - ("Ctrl+Alt+Z", self.revert_change_at_cursor), - ("Ctrl+Alt+B", self.toggle_blame), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + """ + 註冊變更跳轉快捷鍵 + Register the change-navigation shortcuts. + + 用 F7/Shift+F7 而不是 Ctrl+Alt+Up/Down:後者是游標處數字加減的按鍵。 + F7 and Shift+F7 rather than Ctrl+Alt+Up/Down, which increment and + decrement the number under the caret. + """ + self._add_shortcut_actions(( + ("F7", "next_change", self.next_change), + ("Shift+F7", "previous_change", self.previous_change), + ("Ctrl+Alt+Z", "revert_change", self.revert_change_at_cursor), + ("Ctrl+Alt+B", "toggle_blame", self.toggle_blame), + )) def next_change(self) -> bool: """ @@ -972,14 +1011,10 @@ def previous_bookmark(self) -> None: def _register_history_actions(self) -> None: """註冊上一步/下一步快捷鍵 / Register back/forward shortcuts.""" - for shortcut, handler in ( - ("Alt+Left", self.navigate_back), - ("Alt+Right", self.navigate_forward), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + self._add_shortcut_actions(( + ("Alt+Left", "navigate_back", self.navigate_back), + ("Alt+Right", "navigate_forward", self.navigate_forward), + )) def _record_cursor_jump(self) -> None: """ @@ -1432,14 +1467,10 @@ def _duplicate_selection(self, cursor: QTextCursor) -> None: def _register_smart_selection_actions(self) -> None: """註冊智慧選取快捷鍵 / Register smart selection shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Alt+Right", self.expand_selection), - ("Ctrl+Alt+Left", self.shrink_selection), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + self._add_shortcut_actions(( + ("Ctrl+Alt+Right", "expand_selection", self.expand_selection), + ("Ctrl+Alt+Left", "shrink_selection", self.shrink_selection), + )) def expand_selection(self) -> None: """把選取擴大到下一個更大的範圍 / Expand the selection to the next larger range.""" @@ -1453,15 +1484,11 @@ def shrink_selection(self) -> None: def _register_number_actions(self) -> None: """註冊數字加減快捷鍵 / Register number increment/decrement shortcuts.""" - for shortcut, delta in (("Ctrl+Alt+Up", 1), ("Ctrl+Alt+Down", -1)): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(lambda checked=False, step=delta: self.adjust_number(step)) - self.addAction(action) - rename_action = QAction(self) - rename_action.setShortcut("F2") - rename_action.triggered.connect(self.rename_word_under_cursor) - self.addAction(rename_action) + self._add_shortcut_actions(( + ("Ctrl+Alt+Up", "increment_number", lambda: self.adjust_number(1)), + ("Ctrl+Alt+Down", "decrement_number", lambda: self.adjust_number(-1)), + ("F2", "rename_occurrences", self.rename_word_under_cursor), + )) def rename_word_under_cursor(self) -> bool: """ @@ -1521,15 +1548,11 @@ def adjust_number(self, delta: int) -> bool: def _register_line_operation_actions(self) -> None: """註冊行操作快捷鍵 / Register line-operation shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Shift+D", self.delete_current_line), - ("Ctrl+Shift+J", self.join_selected_lines), - ("Ctrl+Alt+S", self.sort_selected_lines), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + self._add_shortcut_actions(( + ("Ctrl+Shift+D", "delete_line", self.delete_current_line), + ("Ctrl+Shift+J", "join_lines", self.join_selected_lines), + ("Ctrl+Alt+S", "sort_lines", self.sort_selected_lines), + )) def _selected_block_range(self, cursor: QTextCursor) -> tuple[int, int]: """取得選取(或游標所在)涵蓋的 block 區間 / The block range covered by the selection.""" @@ -2182,18 +2205,22 @@ def request_language_server_completion(self) -> bool: cursor.blockNumber(), cursor.positionInBlock()) def _register_breakpoint_actions(self) -> None: - """註冊中斷點與逐步執行的快捷鍵 / Register breakpoint and stepping shortcuts.""" - for shortcut, handler in ( - ("F9", self.toggle_breakpoint), - ("F5", lambda: self.send_debugger_command("continue")), - ("F10", lambda: self.send_debugger_command("over")), - ("F11", lambda: self.send_debugger_command("into")), - ("Shift+F11", lambda: self.send_debugger_command("out")), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + """ + 註冊中斷點與逐步執行的快捷鍵 + Register breakpoint and stepping shortcuts. + + F9 與 F5 是工具列的「執行除錯器」與「執行」,因此切換中斷點與繼續執行改用 + Ctrl+F9 與 Ctrl+F5。 + The toolbar runs the debugger on F9 and the program on F5, so toggling a + breakpoint and continuing use Ctrl+F9 and Ctrl+F5 instead. + """ + self._add_shortcut_actions(( + ("Ctrl+F9", "toggle_breakpoint", self.toggle_breakpoint), + ("Ctrl+F5", "debug_continue", lambda: self.send_debugger_command("continue")), + ("F10", "debug_step_over", lambda: self.send_debugger_command("over")), + ("F11", "debug_step_into", lambda: self.send_debugger_command("into")), + ("Shift+F11", "debug_step_out", lambda: self.send_debugger_command("out")), + )) def toggle_breakpoint(self) -> bool: """ @@ -2260,16 +2287,18 @@ def _write_debugger_line(self, command: str) -> bool: return True def _register_macro_actions(self) -> None: - """註冊巨集與環繞選取的快捷鍵 / Register the macro and surround shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Shift+R", self.toggle_macro_recording), - ("Ctrl+Shift+P", self.play_macro), - ("Ctrl+Alt+E", self.show_recent_locations), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + """ + 註冊巨集與最近位置的快捷鍵 + Register the macro and recent-location shortcuts. + + 重播用 Ctrl+Shift+G,因為 Ctrl+Shift+P 是選單的 pip 安裝。 + Playback uses Ctrl+Shift+G because Ctrl+Shift+P installs packages with pip. + """ + self._add_shortcut_actions(( + ("Ctrl+Shift+R", "record_macro", self.toggle_macro_recording), + ("Ctrl+Shift+G", "play_macro", self.play_macro), + ("Ctrl+Alt+E", "recent_locations", self.show_recent_locations), + )) def recent_location_labels(self) -> list[str]: """ @@ -2365,18 +2394,21 @@ def surround_selection(self, opening: str) -> bool: return True def _register_multi_cursor_actions(self) -> None: - """註冊多重游標的快捷鍵 / Register the multi-caret shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Shift+L", self.add_cursors_to_selected_lines), - ("Ctrl+Shift+Escape", self.clear_extra_cursors), - ("Ctrl+Alt+Shift+Up", self.add_cursor_above), - ("Ctrl+Alt+Shift+Down", self.add_cursor_below), - ("Ctrl+D", self.add_cursor_at_next_occurrence), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + """ + 註冊多重游標的快捷鍵 + Register the multi-caret shortcuts. + + 「在下一個出現處加游標」用 Ctrl+Alt+N:Ctrl+D 一直是複製目前行。 + Adding a caret at the next occurrence uses Ctrl+Alt+N, because Ctrl+D has + always duplicated the current line. + """ + self._add_shortcut_actions(( + ("Ctrl+Shift+L", "cursors_on_selected_lines", self.add_cursors_to_selected_lines), + ("Ctrl+Shift+Escape", "clear_extra_cursors", self.clear_extra_cursors), + ("Ctrl+Alt+Shift+Up", "cursor_above", self.add_cursor_above), + ("Ctrl+Alt+Shift+Down", "cursor_below", self.add_cursor_below), + ("Ctrl+Alt+N", "cursor_at_next_occurrence", self.add_cursor_at_next_occurrence), + )) def add_cursors_to_selected_lines(self) -> int: """ diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py index 3671c0af..eb045160 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py @@ -110,7 +110,10 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: # 儲存所有分頁 / Save every modified tab ui_we_want_to_set.file_menu.save_all_action = QAction( language_wrapper.language_word_dict.get("file_menu_save_all_label")) - ui_we_want_to_set.file_menu.save_all_action.setShortcut("Ctrl+Alt+S") + # Ctrl+Alt+S 是編輯器的「排序選取的行」,因此這裡用慣例的 Ctrl+Shift+S + # Ctrl+Alt+S sorts the selected lines in the editor, so this uses the + # conventional Ctrl+Shift+S + ui_we_want_to_set.file_menu.save_all_action.setShortcut("Ctrl+Shift+S") ui_we_want_to_set.file_menu.save_all_action.triggered.connect( lambda: save_all_tabs(ui_we_want_to_set)) ui_we_want_to_set.file_menu.addAction(ui_we_want_to_set.file_menu.save_all_action) diff --git a/je_editor/utils/shortcuts/__init__.py b/je_editor/utils/shortcuts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/shortcuts/shortcut_registry.py b/je_editor/utils/shortcuts/shortcut_registry.py new file mode 100644 index 00000000..8856dec4 --- /dev/null +++ b/je_editor/utils/shortcuts/shortcut_registry.py @@ -0,0 +1,191 @@ +""" +記錄已經被指派出去的快捷鍵,並在重複指派時指出來 +Track which key sequences are already spoken for, and say so when one is reused. + +兩個動作綁到同一組按鍵時,Qt 不會挑一個執行,而是兩個都不執行("Ambiguous +shortcut overload")。也就是說重複指派不會噴例外,只會讓兩個功能一起失效——除非 +有人剛好去按,否則不會被發現。這個模組把「這組按鍵歸誰」變成可以查詢、也可以測試 +的資料。 +When two actions share a key sequence Qt runs neither of them ("Ambiguous +shortcut overload"). A clash therefore raises nothing and simply makes both +features stop working, which nobody notices until they press the keys. This +module turns "who owns this sequence" into something that can be queried and +tested. + +純邏輯,不匯入 Qt,因此可以單獨測試。 +Pure logic with no Qt import, so it can be tested on its own. +""" +from __future__ import annotations + +from typing import Dict, Iterable, List, Optional, Tuple + +# 修飾鍵的各種寫法對應到同一個名字 +# The spellings each modifier is accepted under +_MODIFIER_ALIASES: Dict[str, str] = { + "ctrl": "ctrl", + "control": "ctrl", + "alt": "alt", + "option": "alt", + "shift": "shift", + "meta": "meta", + "cmd": "meta", + "command": "meta", +} + +# 修飾鍵的固定排列順序,讓 "Shift+Ctrl+A" 與 "Ctrl+Shift+A" 得到同一個字串 +# A fixed modifier order, so "Shift+Ctrl+A" and "Ctrl+Shift+A" normalise alike +_MODIFIER_ORDER: Tuple[str, ...] = ("ctrl", "alt", "shift", "meta") + +# 分隔符 / The separator used by Qt key sequences +_SEPARATOR = "+" + + +def normalise_sequence(sequence: str) -> str: + """ + 把按鍵組合轉成可以互相比較的固定寫法 + Reduce a key sequence to a form that compares equal to its variants. + + 大小寫、修飾鍵順序與別名都會被統一,所以 ``"Ctrl+Shift+f"``、``"Shift+Ctrl+F"`` + 與 ``"control+shift+F"`` 都會得到同一個結果。 + Case, modifier order and modifier aliases are all levelled, so ``"Ctrl+Shift+f"``, + ``"Shift+Ctrl+F"`` and ``"control+shift+F"`` all come out the same. + + :param sequence: 按鍵組合,例如 ``"Ctrl+Alt+S"`` / a sequence such as ``"Ctrl+Alt+S"`` + :return: 固定寫法,空字串代表沒有指派 / the canonical form; empty means unassigned + """ + text = str(sequence).strip() + if not text: + return "" + # 「+」自己也可以是那個鍵(Ctrl++),先拆下來再切開其餘部分 + # "+" can be the key itself (Ctrl++), so take it off before splitting + plus_is_the_key = text.endswith(_SEPARATOR) and len(text) > 1 + if plus_is_the_key: + text = text[:-1] + parts = [part.strip().lower() for part in text.split(_SEPARATOR) if part.strip()] + if plus_is_the_key: + parts.append(_SEPARATOR) + modifiers = {_MODIFIER_ALIASES[part] for part in parts if part in _MODIFIER_ALIASES} + keys = [part for part in parts if part not in _MODIFIER_ALIASES] + ordered = [name for name in _MODIFIER_ORDER if name in modifiers] + return _SEPARATOR.join(ordered + keys) + + +class ShortcutRegistry: + """ + 記錄每組按鍵屬於哪個指令 + Remember which command each key sequence belongs to. + """ + + def __init__(self, reserved: Optional[Dict[str, str]] = None) -> None: + """ + :param reserved: 已經被外部(選單、工具列)佔用的按鍵,對應到指令名稱 + / sequences already taken elsewhere (menus, toolbar), mapped to command names + """ + self._owners: Dict[str, str] = {} + self._conflicts: List[Tuple[str, str, str]] = [] + for sequence, command in (reserved or {}).items(): + key = normalise_sequence(sequence) + if key: + self._owners[key] = command + + def owner_of(self, sequence: str) -> Optional[str]: + """ + 查出這組按鍵目前歸誰 + Which command currently owns a sequence. + + :param sequence: 按鍵組合 / the key sequence + :return: 指令名稱,未指派時為 ``None`` / the command name, or ``None`` + """ + return self._owners.get(normalise_sequence(sequence)) + + def register(self, sequence: str, command: str) -> Optional[str]: + """ + 指派一組按鍵給某個指令 + Assign a sequence to a command. + + 已經被別人佔用時不會覆蓋原本的擁有者,而是把衝突記下來並回報,讓呼叫端能夠 + 記錄或讓測試能夠斷言。 + When the sequence is already taken the existing owner is kept rather than + overwritten; the clash is recorded and returned so the caller can log it + and a test can assert on it. + + :param sequence: 按鍵組合 / the key sequence + :param command: 指令名稱 / the command name + :return: 原本的擁有者,沒有衝突時為 ``None`` / the previous owner, or ``None`` + """ + key = normalise_sequence(sequence) + if not key: + return None + existing = self._owners.get(key) + if existing is not None and existing != command: + self._conflicts.append((key, existing, command)) + return existing + self._owners[key] = command + return None + + def register_all(self, bindings: Iterable[Tuple[str, str]]) -> List[Tuple[str, str, str]]: + """ + 一次指派多組按鍵 + Assign several sequences at once. + + :param bindings: ``(按鍵, 指令)`` 的序列 / pairs of ``(sequence, command)`` + :return: 這次產生的衝突 / the clashes this call produced + """ + before = len(self._conflicts) + for sequence, command in bindings: + self.register(sequence, command) + return self._conflicts[before:] + + def conflicts(self) -> List[Tuple[str, str, str]]: + """ + 取得目前累積的所有衝突 + Every clash recorded so far. + + :return: ``(按鍵, 原擁有者, 後來者)`` 的清單 / ``(sequence, owner, newcomer)`` triples + """ + return list(self._conflicts) + + def commands(self) -> Dict[str, str]: + """ + 取得目前的指派表 + The current assignment table. + + :return: 按鍵對應指令 / sequence mapped to command name + """ + return dict(self._owners) + + +# 選單與工具列佔用的按鍵:編輯器的動作不能重複使用這些組合,否則兩邊都不會作用。 +# 這裡列出的是「視窗層級」的指令,編輯器有焦點時它們仍然必須能用。 +# The sequences the menus and toolbar take. Editor actions must not reuse them or +# neither side fires. These are window-level commands that have to keep working +# while the editor has focus. +WINDOW_SHORTCUTS: Dict[str, str] = { + # 工具列 / Toolbar + "F5": "run_program", + "F9": "run_debugger", + "Shift+F5": "stop_program", + "Ctrl+Shift+F": "search_in_files", + "Ctrl+Shift+A": "command_palette", + "Ctrl+P": "quick_open", + "Ctrl+Shift+O": "go_to_symbol", + # 檔案選單 / File menu + "Ctrl+N": "new_file", + "Ctrl+O": "open_file", + "Ctrl+K": "open_folder", + "Ctrl+S": "save_file", + "Ctrl+Shift+S": "save_all", + # 檢查與格式化選單 / Check and format menu + "Ctrl+Shift+Y": "yapf_reformat", + "Ctrl+J": "reformat_json", + "Ctrl+Alt+P": "check_python_format", + # 文字選單 / Text menu + "Alt+W": "word_wrap", + # Python 環境選單 / Python environment menu + "Ctrl+Shift+V": "change_language", + "Ctrl+Shift+U": "pip_upgrade", + "Ctrl+Shift+P": "pip_install", + # 分頁選單 / Tab menu + "Ctrl+Alt+\\": "toggle_split_view", + "Ctrl+Alt+M": "toggle_minimap", +} diff --git a/test/test_shortcut_registry.py b/test/test_shortcut_registry.py new file mode 100644 index 00000000..b2ebdcf1 --- /dev/null +++ b/test/test_shortcut_registry.py @@ -0,0 +1,148 @@ +"""Tests that key sequences are normalised and that nothing claims one twice.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from je_editor.utils.shortcuts.shortcut_registry import ( + WINDOW_SHORTCUTS, ShortcutRegistry, normalise_sequence +) + + +class TestNormalising: + def test_case_is_levelled(self): + assert normalise_sequence("Ctrl+Shift+f") == normalise_sequence("ctrl+shift+F") + + def test_modifier_order_does_not_matter(self): + assert normalise_sequence("Shift+Ctrl+A") == normalise_sequence("Ctrl+Shift+A") + + def test_modifier_aliases_agree(self): + assert normalise_sequence("Control+Alt+S") == normalise_sequence("Ctrl+Option+S") + + def test_a_plain_key_survives(self): + assert normalise_sequence("F7") == "f7" + + def test_plus_can_be_the_key(self): + assert normalise_sequence("Ctrl++") == "ctrl++" + + def test_blank_means_unassigned(self): + assert normalise_sequence(" ") == "" + + def test_surrounding_space_is_ignored(self): + assert normalise_sequence(" Ctrl + G ") == "ctrl+g" + + def test_different_keys_stay_different(self): + assert normalise_sequence("Ctrl+D") != normalise_sequence("Ctrl+Alt+D") + + +class TestRegistry: + def test_an_unused_sequence_is_accepted(self): + registry = ShortcutRegistry() + assert registry.register("Ctrl+Alt+N", "next_occurrence") is None + + def test_the_owner_can_be_looked_up(self): + registry = ShortcutRegistry() + registry.register("Ctrl+Alt+N", "next_occurrence") + assert registry.owner_of("ctrl+alt+n") == "next_occurrence" + + def test_a_second_claim_names_the_first_owner(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + assert registry.register("Ctrl+D", "next_occurrence") == "duplicate_line" + + def test_the_first_owner_keeps_the_sequence(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + registry.register("Ctrl+D", "next_occurrence") + assert registry.owner_of("Ctrl+D") == "duplicate_line" + + def test_registering_the_same_command_twice_is_not_a_clash(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + assert registry.register("Ctrl+D", "duplicate_line") is None + + def test_clashes_are_collected(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + registry.register("Ctrl+D", "next_occurrence") + assert registry.conflicts() == [("ctrl+d", "duplicate_line", "next_occurrence")] + + def test_reserved_sequences_are_already_taken(self): + registry = ShortcutRegistry({"Ctrl+Shift+P": "pip_install"}) + assert registry.register("Ctrl+Shift+P", "play_macro") == "pip_install" + + def test_register_all_reports_only_its_own_clashes(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + registry.register("Ctrl+D", "next_occurrence") + clashes = registry.register_all([("Ctrl+G", "go_to_line"), ("Ctrl+G", "other")]) + assert clashes == [("ctrl+g", "go_to_line", "other")] + + def test_a_blank_sequence_is_ignored(self): + registry = ShortcutRegistry() + assert registry.register("", "nothing") is None + assert registry.commands() == {} + + +class TestWindowShortcutTable: + """ + The reserved table has to describe what the menus and toolbar really set. + + The toolbar side of that check lives in ``test_toolbar_actions``, next to the + fixture that builds a real toolbar. + """ + + def test_no_sequence_is_listed_twice(self): + normalised = [normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS] + assert len(normalised) == len(set(normalised)) + + def test_save_all_moved_off_the_editors_sort_shortcut(self): + assert normalise_sequence("Ctrl+Alt+S") not in { + normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + + +@pytest.fixture() +def editor(qapp, qtbot): + """One editor, with its lifetime handled by qtbot rather than by hand.""" + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + qtbot.addWidget(code_editor) + return code_editor + + +class TestTheEditorHasNoClashes: + """ + Two actions sharing a sequence make Qt run neither, so a clash silently + disables both features. These walk the real actions rather than a list. + """ + + def test_the_registry_recorded_no_conflict(self, editor): + assert editor.shortcut_registry.conflicts() == [] + + def test_no_two_actions_share_a_sequence(self, editor): + sequences = [ + normalise_sequence(action.shortcut().toString()) + for action in editor.actions() + if action.shortcut().toString() + ] + assert len(sequences) == len(set(sequences)) + + def test_no_action_takes_a_menu_sequence(self, editor): + reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + taken = { + normalise_sequence(action.shortcut().toString()) + for action in editor.actions() + if action.shortcut().toString() + } + assert taken & reserved == set() + + def test_duplicate_line_still_owns_control_d(self, editor): + # Ctrl+D is handled in keyPressEvent; no action may shadow it. + assert editor.shortcut_registry.owner_of("Ctrl+D") is None diff --git a/test/test_toolbar_actions.py b/test/test_toolbar_actions.py index 58a37841..c7b0b0fa 100644 --- a/test/test_toolbar_actions.py +++ b/test/test_toolbar_actions.py @@ -5,6 +5,9 @@ from PySide6.QtWidgets import QMainWindow from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import build_toolbar +from je_editor.utils.shortcuts.shortcut_registry import ( + WINDOW_SHORTCUTS, normalise_sequence +) @pytest.fixture() @@ -56,3 +59,12 @@ def test_new_actions_are_on_the_toolbar(self, toolbar_window): toolbar_actions = set(toolbar_window.main_toolbar.actions()) for attribute, _shortcut in self.PICKER_ACTIONS: assert getattr(toolbar_window, attribute) in toolbar_actions + + 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. + reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + for action in toolbar_window.main_toolbar.actions(): + sequence = normalise_sequence(action.shortcut().toString()) + if sequence: + assert sequence in reserved, f"{sequence} is set but not reserved" From f910e3ee0944dad0d6a778eac07a70f197a60d7b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 11:49:54 +0800 Subject: [PATCH 33/36] Check the whole project off the UI thread The Problems panel ran ruff over every file directly in its refresh, so ticking "whole project" froze the window until the walk finished -- seconds on a real tree. The single-file check has always used a worker thread; this gives the project check the same treatment and reports the result through a signal, with the panel showing that a check is running meanwhile. --- .../problems_panel/problems_panel_widget.py | 96 ++++++++++++++++--- .../problems_panel/project_lint_worker.py | 46 +++++++++ je_editor/utils/multi_language/english.py | 1 + .../multi_language/traditional_chinese.py | 1 + test/test_problems_panel.py | 48 +++++++++- 5 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py diff --git a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py index 3f8f2fab..e2b2acbe 100644 --- a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py @@ -17,7 +17,10 @@ QTreeWidgetItem, QVBoxLayout, QWidget ) -from je_editor.code_scan.ruff_lint import apply_fixes, lint_project +from je_editor.code_scan.ruff_lint import apply_fixes +from je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker import ( + ProjectLintWorker +) from je_editor.utils.file.open.open_file import read_file_with_encoding from je_editor.utils.lint.ruff_diagnostics import ( SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING, Diagnostic @@ -65,6 +68,9 @@ def __init__(self, main_window=None) -> None: word = language_wrapper.language_word_dict self._main_window = main_window self._diagnostics: list[Diagnostic] = [] + # 專案檢查在工作執行緒進行,這裡持有進行中的那一個 + # The project check runs on a worker thread; this holds the one in flight + self._project_worker: ProjectLintWorker | None = None self.refresh_button = QPushButton(word.get("problems_panel_refresh")) self.refresh_button.clicked.connect(self.refresh) @@ -112,24 +118,88 @@ def diagnostics(self) -> list[Diagnostic]: def refresh(self) -> None: """ - 重新讀取目前分頁的診斷並重畫清單 - Re-read the current tab's diagnostics and rebuild the list. + 重新讀取診斷並重畫清單 + Re-read the diagnostics and rebuild the list. - 編輯器持續在背景檢查,所以這裡只是取用最新結果。 - The editor keeps checking in the background, so this only picks up its - latest result. + 單一檔案的診斷是編輯器持續在背景產生的,這裡只是取用最新結果;整個專案的 + 檢查則要另外啟動,結果會晚一點才到。 + A single file's diagnostics are produced in the background by the editor, + so those are just picked up; a project-wide check has to be started here + and its result arrives later. """ if self.project_check.isChecked(): - self._diagnostics = lint_project(self._project_root()) + self.start_project_check() + return + self._stop_project_check() + code_edit = current_code_editor(self._main_window) + if code_edit is None: + self._diagnostics = [] else: - code_edit = current_code_editor(self._main_window) - if code_edit is None: - self._diagnostics = [] - else: - code_edit.request_lint() - self._diagnostics = code_edit.lint_manager.diagnostics() + code_edit.request_lint() + self._diagnostics = code_edit.lint_manager.diagnostics() self._render_items() + def start_project_check(self) -> bool: + """ + 在背景檢查整個專案 + Start a project-wide check on a worker thread. + + ruff 走遍整個專案要好幾秒,在 UI 執行緒做會讓視窗整段時間沒有反應。 + Walking a whole project with ruff takes seconds, and doing it on the UI + thread would leave the window unresponsive for all of it. + + :return: 是否啟動了檢查 / whether a check was started + """ + self._stop_project_check() + worker = ProjectLintWorker(self._project_root(), self) + self._project_worker = worker + worker.linted.connect(self._on_project_linted) + # 先放掉參考再刪除,避免之後對已刪除的物件呼叫方法 + # Drop the reference before deleting, so nothing calls a deleted object + worker.finished.connect(self._on_worker_finished) + worker.finished.connect(worker.deleteLater) + self.status_label.setText( + language_wrapper.language_word_dict.get("problems_panel_checking")) + worker.start() + return True + + def _on_project_linted(self, diagnostics: list) -> None: + """ + 套用背景檢查的結果 + Apply the diagnostics a background check produced. + + 只接受目前這個 worker 的結果:換過設定或再按一次重新檢查都會讓舊結果過期。 + Only the current worker's result is accepted, since changing the scope or + rechecking makes an in-flight run stale. + """ + if self.sender() is not self._project_worker: + return + self._diagnostics = list(diagnostics) + self._render_items() + + def _on_worker_finished(self) -> None: + """檢查結束後放掉參考 / Let go of the worker once it has finished.""" + if self.sender() is self._project_worker: + self._project_worker = None + + def _stop_project_check(self) -> None: + """結束仍在執行的專案檢查 / Stop a project check that is still running.""" + worker, self._project_worker = self._project_worker, None + if worker is None: + return + try: + if worker.isRunning(): + worker.blockSignals(True) + worker.wait() + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + return + + def closeEvent(self, event) -> None: + """關閉前先收掉背景檢查 / Stop the background check before closing.""" + self._stop_project_check() + super().closeEvent(event) + def _project_root(self) -> str: """取得要檢查的專案根目錄 / The project root to check.""" working_dir = getattr(self._main_window, "working_dir", None) diff --git a/je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py b/je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py new file mode 100644 index 00000000..10076b44 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py @@ -0,0 +1,46 @@ +""" +在背景檢查整個專案 +Lint a whole project off the UI thread. + +專案檢查會開一個 ruff 子程序走遍所有檔案,大專案要跑上好幾秒。在 UI 執行緒做這件 +事會讓整個視窗在這段時間內完全沒有反應,因此改由工作執行緒執行,完成後再把診斷交 +回主執行緒。單檔檢查早就是這樣做的(見 ``lint_manager``),這裡採用同樣的做法。 +A project check spawns one ruff subprocess that walks every file, which takes +seconds on a large tree. Doing that on the UI thread freezes the whole window +for the duration, so it runs on a worker thread and hands the diagnostics back +when it finishes. The single-file check already works this way (see +``lint_manager``); this follows the same shape. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QThread, Signal + +from je_editor.code_scan.ruff_lint import lint_project + + +class ProjectLintWorker(QThread): + """ + 在背景對一個目錄執行 ruff + Run ruff over a directory without blocking the UI. + """ + + linted = Signal(object) # list[Diagnostic] + + def __init__(self, root: str | Path, parent=None) -> None: + """ + :param root: 要檢查的專案根目錄 / the project root to check + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._root = str(root) + + @property + def root(self) -> str: + """這次檢查的目錄 / The directory being checked.""" + return self._root + + def run(self) -> None: + """執行檢查並回報結果 / Lint and report the result.""" + self.linted.emit(lint_project(self._root)) diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 6690de3a..499cd610 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -419,6 +419,7 @@ "tab_menu_problems_panel_tab_name": "Problems", "problems_panel_refresh": "Recheck", "problems_panel_ready": "Ready", + "problems_panel_checking": "Checking the project...", "problems_panel_clean": "No problems found", "problems_panel_found": "{count} problems", "problems_panel_col_code": "Rule", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 503d0b0f..b8211c7f 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -409,6 +409,7 @@ "tab_menu_problems_panel_tab_name": "問題", "problems_panel_refresh": "重新檢查", "problems_panel_ready": "就緒", + "problems_panel_checking": "檢查專案中……", "problems_panel_clean": "沒有發現問題", "problems_panel_found": "{count} 個問題", "problems_panel_col_code": "規則", diff --git a/test/test_problems_panel.py b/test/test_problems_panel.py index 150ab164..6ae68ae5 100644 --- a/test/test_problems_panel.py +++ b/test/test_problems_panel.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import threading from unittest.mock import MagicMock, patch import pytest @@ -18,6 +19,10 @@ ) +# Long enough that a slow machine still finishes; a hang fails rather than blocks. +WORKER_TIMEOUT_MS = 10_000 + + class TestSeverity: @pytest.mark.parametrize("code,expected", [ ("F401", SEVERITY_ERROR), @@ -149,13 +154,50 @@ def test_the_tree_follows_the_filter(self, panel): class TestProjectScope: - def test_project_mode_lints_the_root(self, panel, tmp_path): + """ + The project check spawns ruff over every file, so it runs on a worker + thread: the panel gets its result through a signal rather than a return + value, and the window stays responsive while it runs. + """ + + def _run_project_check(self, panel, trigger=None): + """Start a project check and wait for it to report back.""" with patch( - "je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget.lint_project", + "je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker.lint_project", return_value=_diagnostics(), ) as mock_lint: + if trigger is None: + panel.project_check.setChecked(True) + else: + trigger() + worker = panel._project_worker + assert worker is not None, "no background check was started" + worker.wait(WORKER_TIMEOUT_MS) + QApplication.processEvents() + return mock_lint + + def test_project_mode_lints_the_root(self, panel, tmp_path): + assert self._run_project_check(panel).called + + def test_the_result_reaches_the_panel(self, panel): + self._run_project_check(panel) + assert len(panel.diagnostics()) == 3 + + def test_the_check_does_not_run_on_the_ui_thread(self, panel): + seen: list[int] = [] + with patch( + "je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker.lint_project", + side_effect=lambda root: seen.append(threading.get_ident()) or [], + ): panel.project_check.setChecked(True) - assert mock_lint.called + worker = panel._project_worker + assert worker is not None + worker.wait(WORKER_TIMEOUT_MS) + assert seen and seen[0] != threading.get_ident() + + def test_rechecking_replaces_the_previous_run(self, panel): + self._run_project_check(panel) + self._run_project_check(panel, trigger=panel.refresh) assert len(panel.diagnostics()) == 3 def test_fixing_without_a_file_does_nothing(self, panel): From 706ffe82f3ecd85beb2b150b898ffff883d899b9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 11:49:54 +0800 Subject: [PATCH 34/36] Send breakpoints to the debugger it starts Breakpoints set in the gutter never reached pdb: the code that sends them existed but nothing called it, so a breakpoint was only a mark on screen and the run went straight through it. Starting the debugger now hands them over while pdb waits at the first line. --- .../under_run_menu/build_debug_menu.py | 25 +++++++++++++++++++ test/test_breakpoints.py | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py b/je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py index f11475ae..dfb299f9 100644 --- a/je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py @@ -67,6 +67,30 @@ def set_debug_menu(ui_we_want_to_set: EditorMain) -> None: ui_we_want_to_set.debug_menu.addAction(ui_we_want_to_set.debug_menu.show_shell_input) +# 把編輯器上設定的中斷點交給剛啟動的除錯器 +# Hand the breakpoints set in the editor to the debugger that just started +def send_breakpoints(widget: EditorWidget) -> int: + """ + 把這個分頁的中斷點送進 pdb + Send this tab's breakpoints into pdb. + + pdb 啟動後會停在第一行等待輸入,因此這時送出的 ``break`` 指令會在程式真正開始 + 跑之前生效。少了這一步,在編輯器邊條設定的中斷點就只是畫面上的標記。 + pdb stops on the first line waiting for input, so the ``break`` commands sent + now take effect before the program really starts. Without this step the + breakpoints set in the editor's gutter are only marks on the screen. + + :param widget: 正在除錯的分頁 / the tab being debugged + :return: 送出的中斷點數量 / how many breakpoints were sent + """ + code_edit = getattr(widget, "code_edit", None) + if code_edit is None: + return 0 + sent = code_edit.send_breakpoints_to_debugger() + jeditor_logger.info(f"build_debug_menu.py send_breakpoints sent: {sent}") + return sent + + # 執行除錯器 (pdb) # Run debugger (pdb) def run_debugger(ui_we_want_to_set: EditorMain) -> None: @@ -91,6 +115,7 @@ def run_debugger(ui_we_want_to_set: EditorMain) -> None: # 綁定除錯器與輸入介面 # Bind debugger and input interface widget.exec_python_debugger = code_exec + send_breakpoints(widget) widget.debugger_input = ProcessInput(widget) widget.debugger_input.show() else: diff --git a/test/test_breakpoints.py b/test/test_breakpoints.py index 270d5f49..20fe3049 100644 --- a/test/test_breakpoints.py +++ b/test/test_breakpoints.py @@ -151,6 +151,31 @@ def test_no_breakpoints_are_sent_without_a_file(self, editor): editor.current_file = None assert editor.send_breakpoints_to_debugger() == 0 + def test_starting_the_debugger_sends_them(self, editor): + # Without this the gutter marks never reach pdb and never stop anything. + from je_editor.pyside_ui.main_ui.menu.run_menu.under_run_menu.build_debug_menu import ( + send_breakpoints + ) + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 2) + editor.toggle_breakpoint() + widget = MagicMock() + widget.code_edit = editor + assert send_breakpoints(widget) == 1 + stdin.write.assert_called_once_with(b"break app.py:3\n") + + def test_a_tab_without_an_editor_sends_nothing(self, editor): + from je_editor.pyside_ui.main_ui.menu.run_menu.under_run_menu.build_debug_menu import ( + send_breakpoints + ) + widget = MagicMock() + widget.code_edit = None + assert send_breakpoints(widget) == 0 + def test_a_broken_pipe_is_reported_rather_than_raised(self, editor): stdin = MagicMock() stdin.write.side_effect = OSError("broken pipe") From d9f149907e4d578825cbca027e1d69832c7e1cb3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 12:11:56 +0800 Subject: [PATCH 35/36] Catch the decode error before its base class UnicodeDecodeError subclasses ValueError, so listing ValueError first made the decode handler unreachable: a non-text blob took the general branch and the reason was never logged. Both readers now try the specific one first. --- je_editor/git_client/file_baseline.py | 8 +++++--- je_editor/git_client/file_staging.py | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/je_editor/git_client/file_baseline.py b/je_editor/git_client/file_baseline.py index 7f5119c4..2ab13a14 100644 --- a/je_editor/git_client/file_baseline.py +++ b/je_editor/git_client/file_baseline.py @@ -61,10 +61,12 @@ def baseline_text(file_path: str | Path) -> str | None: blob = repo.head.commit.tree / relative.as_posix() text = blob.data_stream.read().decode("utf-8") return text.replace("\r\n", "\n").replace("\r", "\n") + except UnicodeDecodeError: + # 必須排在 ValueError 之前,否則永遠輪不到它 + # Must come before ValueError, which is its base class + jeditor_logger.debug("file_baseline: %s is not utf-8 text", file_path) + return None except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): # 新檔案、尚無提交、或路徑不在工作區內 # New file, no commit yet, or outside the tree return None - except UnicodeDecodeError: - jeditor_logger.debug("file_baseline: %s is not utf-8 text", file_path) - return None diff --git a/je_editor/git_client/file_staging.py b/je_editor/git_client/file_staging.py index 21d57926..76aeb454 100644 --- a/je_editor/git_client/file_staging.py +++ b/je_editor/git_client/file_staging.py @@ -53,11 +53,13 @@ def staged_text(file_path: str | Path) -> str | None: entry = repo.index.entries[(relative, 0)] blob = Blob(repo, entry.binsha, entry.mode, relative) text = blob.data_stream.read().decode("utf-8") - except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): - return None except UnicodeDecodeError: + # 必須排在 ValueError 之前,否則永遠輪不到它 + # Must come before ValueError, which is its base class jeditor_logger.debug(f"file_staging: {file_path} is not utf-8 text") return None + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): + return None return text.replace("\r\n", "\n").replace("\r", "\n") From bb62733c68abc60e3c89e052b2456a8d581d6386 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 26 Jul 2026 13:53:50 +0800 Subject: [PATCH 36/36] Stop the debounce timers when an editor closes The suite aborted the interpreter on Python 3.10 roughly half the time, at whichever test next spun an event loop. The message behind it was "QThread: Destroyed while thread is still running", which Qt reports through qFatal -- no traceback, and a crash site far from the cause. Closing an editor stopped the lint, baseline and blame workers but left the debounce timers running. A timer firing after the close started a fresh lint thread, and by then nothing was left to wait for it: the editor was destroyed with that thread still going. The timers now stop first, and EditorWidget closes the editor rather than stopping its managers by hand, so the two paths cannot drift apart. Each background thread also carries its name, so if this ever happens again Qt says which one. Verified on 3.10: 6 full runs with no crash, against 4 in 6 before. --- .../pyside_ui/code/git_diff/blame_manager.py | 4 +++ .../code/git_diff/diff_marker_manager.py | 4 +++ je_editor/pyside_ui/code/lint/lint_manager.py | 4 +++ .../code_edit_plaintext.py | 21 ++++++++++----- .../command_palette/quick_open_dialog.py | 4 +++ .../pyside_ui/main_ui/editor/editor_widget.py | 13 +++++---- .../main_ui/todo_panel/todo_panel_widget.py | 4 +++ test/test_editor_widget.py | 22 +++++++++++++++ test/test_lint_manager.py | 27 +++++++++++++++++++ 9 files changed, 92 insertions(+), 11 deletions(-) diff --git a/je_editor/pyside_ui/code/git_diff/blame_manager.py b/je_editor/pyside_ui/code/git_diff/blame_manager.py index d7863ae2..b02bc984 100644 --- a/je_editor/pyside_ui/code/git_diff/blame_manager.py +++ b/je_editor/pyside_ui/code/git_diff/blame_manager.py @@ -29,6 +29,10 @@ def __init__(self, file_path: str | Path, parent=None) -> None: :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("BlameLoader") self._file_path = file_path def run(self) -> None: diff --git a/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py index 208440e5..a27c2ea7 100644 --- a/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py +++ b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py @@ -39,6 +39,10 @@ def __init__(self, file_path: str | Path, parent=None) -> None: :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("BaselineLoader") self._file_path = file_path def run(self) -> None: diff --git a/je_editor/pyside_ui/code/lint/lint_manager.py b/je_editor/pyside_ui/code/lint/lint_manager.py index 99f0ed8f..3425f530 100644 --- a/je_editor/pyside_ui/code/lint/lint_manager.py +++ b/je_editor/pyside_ui/code/lint/lint_manager.py @@ -31,6 +31,10 @@ def __init__(self, text: str, file_path: str | Path, parent=None) -> None: :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("LintWorker") self._text = text self._file_path = file_path 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 9d907363..492a192e 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 @@ -2807,13 +2807,22 @@ def closeEvent(self, event: QtGui.QCloseEvent) -> None: Stop every background worker before closing. lint、git 基準、blame 與語言伺服器都各自持有執行緒或子程序;編輯器被銷毀 - 時若它們還在跑,Qt 會在之後某個時間點刪掉仍在執行的物件而當掉,而當掉的 - 位置離真正的原因很遠。 + 時若它們還在跑,Qt 會直接讓程序中止(``QThread: Destroyed while thread is + still running``),而中止的位置離真正的原因很遠。 The lint pass, the git baseline, blame and the language server each hold a - thread or a subprocess. If they are still running when the editor is - destroyed, Qt tears down a live object later and crashes far away from the - actual cause. - """ + thread or a subprocess. If one is still running when the editor is + destroyed, Qt aborts the process outright (``QThread: Destroyed while + thread is still running``) somewhere far from the actual cause. + + 計時器要先停:它們是「等輸入停下來再做」的排程,若留著,關閉之後才觸發的 + 那一次會重新開一條執行緒,而那時已經沒有人會去等它結束了。 + The timers go first: they schedule the work that waits for a pause in + typing, and one firing after the close would start a fresh thread that + nothing is left to wait for. + """ + self._lint_timer.stop() + self._diff_timer.stop() + self._complete_timer.stop() self.lint_manager.stop() self.diff_marker_manager.stop() self.blame_manager.stop() diff --git a/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py b/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py index 15417b76..fde1f651 100644 --- a/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py +++ b/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py @@ -39,6 +39,10 @@ def __init__(self, root: str) -> None: :param root: 要索引的專案根目錄 / The project root to index """ super().__init__() + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("FileIndexThread") self._root = root self._stop_requested = False diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index fc41fb2f..b27c963a 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -527,11 +527,14 @@ def close(self) -> bool: self.exec_shell = None self.exec_python_debugger = None - # 停止仍在執行的背景檢查 / Stop background checks still running - self.code_edit.diff_marker_manager.stop() - self.code_edit.lint_manager.stop() - self.code_edit.blame_manager.stop() - self.code_edit.lsp_client.stop() + # 讓編輯器自己收掉背景工作:它的 closeEvent 除了停掉各個管理器,也會停掉 + # 那些「等輸入停下來再做」的計時器,否則關閉之後才觸發的那一次會重新開一 + # 條執行緒,而那時已經沒有人會等它結束 + # Let the editor stop its own background work: its closeEvent stops the + # managers and also the timers that schedule work after a pause in typing, + # one of which would otherwise fire after the close and start a fresh + # thread that nothing is left to wait for + self.code_edit.close() if self.current_file: file_is_open_manager_dict.pop(str(Path(self.current_file)), None) diff --git a/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py b/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py index 95e22d46..4964d7b5 100644 --- a/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py @@ -41,6 +41,10 @@ def __init__(self, root: str) -> None: :param root: 要掃描的專案根目錄 / The project root to scan """ super().__init__() + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("TodoScanThread") self._root = root self._stop_requested = False diff --git a/test/test_editor_widget.py b/test/test_editor_widget.py index 7db45e08..31cb51fd 100644 --- a/test/test_editor_widget.py +++ b/test/test_editor_widget.py @@ -38,6 +38,28 @@ def editor_widget(app): mock_main.tab_widget.deleteLater() +class TestClosingTheTab: + """ + Closing the tab has to stop the editor's scheduled work too. A debounce + timer firing afterwards starts a thread nothing is left to wait for, and Qt + aborts the process when a running QThread is destroyed. + """ + + def test_the_editors_lint_timer_is_stopped(self, editor_widget): + editor_widget.code_edit._lint_timer.start() + editor_widget.close() + assert editor_widget.code_edit._lint_timer.isActive() is False + + def test_the_editors_diff_timer_is_stopped(self, editor_widget): + editor_widget.code_edit._diff_timer.start() + editor_widget.close() + assert editor_widget.code_edit._diff_timer.isActive() is False + + def test_no_lint_worker_is_left_running(self, editor_widget): + editor_widget.close() + assert editor_widget.code_edit.lint_manager._worker is None + + class TestEditorWidgetModifiedTracking: def test_initial_not_modified(self, editor_widget): assert editor_widget._is_modified is False diff --git a/test/test_lint_manager.py b/test/test_lint_manager.py index a6abe1ea..fc1df4c1 100644 --- a/test/test_lint_manager.py +++ b/test/test_lint_manager.py @@ -37,6 +37,33 @@ def editor(app): code_editor.deleteLater() +class TestClosingStopsScheduledWork: + """ + The debounce timers schedule work that spawns threads. One firing after the + close would start a thread nothing is left to wait for, and Qt aborts the + process outright when a running QThread is destroyed -- far from the cause. + """ + + def test_the_lint_timer_is_stopped(self, editor): + editor._lint_timer.start() + editor.close() + assert editor._lint_timer.isActive() is False + + def test_the_diff_timer_is_stopped(self, editor): + editor._diff_timer.start() + editor.close() + assert editor._diff_timer.isActive() is False + + def test_the_completion_timer_is_stopped(self, editor): + editor._complete_timer.start() + editor.close() + assert editor._complete_timer.isActive() is False + + def test_no_lint_worker_is_left_running(self, editor): + editor.close() + assert editor.lint_manager._worker is None + + class TestLintManagerState: def test_starts_empty(self, editor): assert editor.lint_manager.diagnostics() == []