diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 00000000..1a505446 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,3 @@ +sonar.sources=je_editor +sonar.tests=test +sonar.python.version=3.10, 3.11, 3.12 diff --git a/CLAUDE.md b/CLAUDE.md index 8ac431f8..7798b425 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,9 @@ All code must pass SonarQube and Codacy quality gates. Adhere to the following r ## Git & Commit Rules - **Commit messages**: Write in English, concise, imperative mood (e.g., "Add plugin hot-reload support") -- **No AI attribution**: Do not mention any AI tool, assistant, or model name in commit messages or code comments +- **No AI attribution (mandatory)**: Never mention any AI tool, assistant, agent, model name, or vendor in commit messages, commit trailers, branch names, PR titles, PR descriptions, issue text, code comments, or documentation + - No `Co-Authored-By:` trailers referencing an AI tool or model + - No "Generated with ...", "Created by ...", or similar footers in commits or PR bodies + - PR titles and bodies describe **what changed and why** — nothing about how the change was authored - **Branch strategy**: `main` = stable release, `dev` = active development - **Clean commits**: Each commit should be a single logical change; no unrelated changes bundled together diff --git a/README.md b/README.md index e627fbe5..929b87e0 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ JEDITOR achieves up to **1000% faster performance** compared to the original JEd | Category | Features | |---|---| -| **Editor** | Multi-tab editing, syntax highlighting, auto-completion (Jedi), line numbers, current line highlighting, search & replace (regex) | +| **Editor** | Multi-tab editing, syntax highlighting, auto-completion (Jedi), line numbers, current line highlighting, search & replace (regex), code folding, bookmarks, occurrence highlighting, line operations | +| **Navigation** | Command palette, quick open (go to file), go to symbol, document outline, navigation history (back/forward), TODO/FIXME task panel | | **Execution** | Run Python scripts (F5), debug mode (F9), shell commands, virtual environment detection | | **Code Quality** | YAPF formatting, PEP8 checking, Ruff linting, JSON reformatting | | **Git** | Branch management, commit history, side-by-side diff viewer, staging, audit logging | @@ -85,7 +86,7 @@ JEDITOR achieves up to **1000% faster performance** compared to the original JEd | **Plugins** | Custom syntax highlighting, UI translations, run configurations, auto-discovery | | **UI** | Dark/light themes (Qt Material), font customization, dockable panels, system tray, toolbar | | **i18n** | English, Traditional Chinese, extensible via plugins | -| **Files** | Auto-save, multi-encoding support (UTF-8, GBK, Latin-1, etc.), recent files | +| **Files** | Auto-save, multi-encoding support (UTF-8, GBK, Latin-1, etc.), recent files, multi-file session restore | --- @@ -173,8 +174,26 @@ The editor launches in a maximized window with a dark amber theme by default. - **Auto-completion** -- Context-aware code suggestions powered by Jedi. - **Line numbers** -- Displayed alongside the editor with current line highlighting. - **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. +- **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. +- **Case Conversion** (Text menu) -- Uppercase or lowercase the current selection, keeping it selected. +- **Smart Selection** -- Expand the selection outward through word → line → enclosing indented blocks → whole file (`Ctrl+Alt+Right`), and shrink it back (`Ctrl+Alt+Left`). Shrinking only retraces expansions, and a manual selection change resets the history. +- **Increment / Decrement Number** -- Bump the integer under the caret up or down (`Ctrl+Alt+Up` / `Ctrl+Alt+Down`), handling negative signs and growing widths. +- **Rename in File** (`F2`) -- Rename every whole-word occurrence of the identifier under the caret across the file as a single undo step. Word boundaries protect partial matches (renaming `val` never touches `value`). +- **Navigation History** -- Jump back and forward through your cursor-jump history like a browser (`Alt+Left` / `Alt+Right`). A jump records both where you came from and where you went, so "back" returns to where you started. +- **Document Outline** -- A dockable tree of the current file's classes, methods, functions and module variables (parsed with `ast`, so no code runs). Double-click to jump to a definition. - **Variable Inspector** -- Inspect and debug variables during code execution. +### Navigation + +- **Command Palette** (Ctrl+Shift+A) -- Fuzzy-search every menu command by name or menu path and run it without hunting through menus. Matches are ranked by word boundaries, consecutive characters and prefixes, and each row shows the command's own keyboard shortcut. +- **Quick Open / Go to File** (Ctrl+P) -- Fuzzy-search the project tree by file name *or* folder path. Indexing runs in a background thread, skipping VCS, cache, virtualenv and build directories along with binary file types. Typing `>` at the start switches the same picker into command mode. +- **Go to Symbol** (Ctrl+Shift+O) -- Jump to any class, function, method or module-level variable in the current Python file. Symbols are parsed with the standard library `ast` module, so no user code is ever executed, and a file that does not parse simply yields no symbols instead of erroring while you type. +- **TODO Panel** (Tab > Tools, or as a dock) -- Scans the project for `TODO`, `FIXME`, `HACK`, `XXX`, `BUG`, `NOTE` and `OPTIMIZE` comments across Python, C-like, HTML, SQL and other comment styles. Filter by tag and double-click a row to open that file at that line. Tags are only reported when they follow a comment marker, so ordinary strings are not misreported. + ### Code Execution & Debugging - **Run Python scripts** (F5) -- Execute the current file with real-time output streaming. @@ -190,12 +209,18 @@ The editor launches in a maximized window with a dark amber theme by default. - **PEP8 checking** (Ctrl+Alt+P) -- Validate code against PEP8 style guidelines. - **Ruff linting** -- Fast, comprehensive Python linting in a background thread. - **JSON reformatting** (Ctrl+J) -- Pretty-print and validate JSON content. +- **Trim Trailing Whitespace** (Text menu) -- Strip trailing whitespace from every line as one undo step, preserving the caret position. +- **Convert Indentation** (Text menu) -- Convert leading indentation between tabs and spaces (using your indent size). Only leading whitespace is touched, so tabs and spaces inside strings are never altered. +- **Configurable indent width** -- Tab-indent, unindent and Enter auto-indent all honour the configured indent size (`Text > Indent Size`), and the indent width is auto-detected from a file's own content when it is opened. +- **Text transforms** (Text menu) -- Case conversion (upper / lower / swap / title), naming-style conversion (`snake_case` / `camelCase` / `PascalCase` / `kebab-case`), number-base conversion (hex / decimal / binary), and encode/decode helpers (Base64, URL, HTML entities, JSON string escaping). Decoders that fail leave the text untouched. +- **Statistics** (Text menu) -- Line, word and character counts for the whole document or the current selection. ### File Operations - **Create, open, save** files with standard shortcuts (Ctrl+N, Ctrl+O, Ctrl+S). - **Open folders** (Ctrl+K) -- Navigate project directory structures. - **Auto-save** -- Automatic periodic file saving to prevent data loss. +- **Session restore** -- Reopens every file that was open at the last shutdown, not just the last one. Missing, duplicate and already-open files are skipped, the list is capped, and a corrupt or hand-edited settings file can never block startup. Disable by setting `restore_session` to `false` in `.jeditor/user_setting.json`. - **Multi-encoding** -- Seamlessly handle UTF-8, GBK, Latin-1, and other encodings with automatic detection. - **Recent files** -- Quick access to previously opened files. @@ -270,6 +295,25 @@ Plugins are automatically discovered from the `jeditor_plugins/` directory. See | `Ctrl+O` | Open file | | `Ctrl+K` | Open folder | | `Ctrl+S` | Save file | +| `Ctrl+Shift+A` | Command palette | +| `Ctrl+P` | Quick open (go to file) | +| `Ctrl+Shift+O` | Go to symbol | +| `Ctrl+Shift+[` | Toggle fold at cursor | +| `Ctrl+Alt+[` | Fold all | +| `Ctrl+Alt+]` | Unfold all | +| `Ctrl+Alt+K` | Toggle bookmark | +| `Ctrl+Alt+L` | Next bookmark | +| `Ctrl+Alt+J` | Previous bookmark | +| `Alt+Left` | Navigate back | +| `Alt+Right` | Navigate forward | +| `Ctrl+Shift+D` | Delete current line / selection | +| `Ctrl+Alt+S` | Sort selected lines | +| `Ctrl+Shift+J` | Join selected lines | +| `Ctrl+Alt+Right` | Expand selection | +| `Ctrl+Alt+Left` | Shrink selection | +| `Ctrl+Alt+Up` | Increment number under caret | +| `Ctrl+Alt+Down` | Decrement number under caret | +| `F2` | Rename occurrences in file | | `Ctrl+J` | Reformat JSON | | `Ctrl+Shift+Y` | YAPF Python format | | `Ctrl+Alt+P` | PEP8 format checker | @@ -289,8 +333,10 @@ je_editor/ │ ├── browser/ # Embedded web browser │ ├── code/ # Core code editing │ │ ├── auto_save/ # Automatic file saving +│ │ ├── bookmark/ # Bookmark manager (QTextCursor-anchored) │ │ ├── code_format/ # YAPF & PEP8 formatting │ │ ├── code_process/ # Program execution (ExecManager) +│ │ ├── folding/ # Code folding manager │ │ ├── shell_process/ # Shell execution (ShellManager) │ │ ├── syntax/ # Syntax highlighting engine │ │ ├── plaintext_code_edit/ # Plain text editor widget @@ -305,14 +351,17 @@ je_editor/ │ │ └── git_client/ # Branch & commit UI │ └── main_ui/ # Main editor window │ ├── ai_widget/ # AI chat panel +│ ├── command_palette/ # Command palette, quick open, go to symbol │ ├── console_widget/ # Interactive console │ ├── dock/ # Dockable widget management │ ├── editor/ # Tab-based editor │ ├── ipython_widget/ # Jupyter/IPython console │ ├── menu/ # Menu bar system +│ ├── outline_panel/ # Document outline (symbol tree) │ ├── plugin_browser/ # Plugin management UI │ ├── save_settings/ # Settings persistence │ ├── system_tray/ # System tray integration +│ ├── todo_panel/ # TODO/FIXME task panel │ └── toolbar/ # Toolbar actions ├── code_scan/ # Code scanning │ ├── ruff_thread.py # Ruff linter (threaded) @@ -325,13 +374,30 @@ je_editor/ ├── plugins/ # Plugin system │ └── plugin_loader.py # Dynamic plugin loading ├── utils/ # Utilities +│ ├── align/ # Align lines on a delimiter (Qt-free) +│ ├── bookmark/ # Bookmark navigation logic (Qt-free) +│ ├── case_convert/ # Naming-style conversion (Qt-free) +│ ├── code_folding/ # Fold region computation (Qt-free) +│ ├── command_palette/ # Fuzzy matching & ranking (Qt-free) +│ ├── encode_decode/ # Base64/URL/HTML/JSON transforms (Qt-free) │ ├── encodings/ # Encoding detection │ ├── exception/ # Custom exceptions │ ├── file/ # File I/O (open/save) +│ ├── file_scan/ # Shared ignore rules, file indexer, TODO scanner +│ ├── indentation/ # Tab/space conversion + indent detection (Qt-free) │ ├── json_format/ # JSON formatting +│ ├── line_ops/ # Line operation transforms (Qt-free) │ ├── logging/ # Logging setup │ ├── multi_language/ # i18n (English, Traditional Chinese) +│ ├── navigation/ # Cursor jump history (Qt-free) +│ ├── number_ops/ # Number-under-caret adjustment (Qt-free) +│ ├── occurrence/ # Word occurrence finding + whole-word rename (Qt-free) │ ├── redirect_manager/ # Output stream redirection +│ ├── selection/ # Smart selection ranges (Qt-free) +│ ├── session/ # Multi-file session restore (Qt-free) +│ ├── symbols/ # Python symbol extraction + outline tree (ast-based) +│ ├── text_cleanup/ # Trailing whitespace / newline cleanup (Qt-free) +│ ├── text_stats/ # Line/word/char statistics (Qt-free) │ └── venv_check/ # Virtual environment detection ├── __init__.py # Public API ├── __main__.py # CLI entry point diff --git a/je_editor/pyside_ui/code/bookmark/__init__.py b/je_editor/pyside_ui/code/bookmark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/bookmark/bookmark_manager.py b/je_editor/pyside_ui/code/bookmark/bookmark_manager.py new file mode 100644 index 00000000..37ef400c --- /dev/null +++ b/je_editor/pyside_ui/code/bookmark/bookmark_manager.py @@ -0,0 +1,134 @@ +""" +書籤管理器(Qt 整合層) +Bookmark manager (Qt integration layer). + +每個書籤以 ``QTextCursor`` 錨定在該行開頭。當上方插入或刪除文字時, +``QTextCursor`` 會自動跟著移動,因此書籤能正確地跟著程式碼移動而不會漂移。 +Each bookmark is anchored by a ``QTextCursor`` at the line start. A ``QTextCursor`` +moves automatically when text is inserted or removed above it, so bookmarks follow +the code they mark instead of drifting. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtGui import QTextCursor + +from je_editor.utils.bookmark.bookmark_navigation import next_bookmark, prev_bookmark + +if TYPE_CHECKING: + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + + +class BookmarkManager: + """ + 管理單一編輯器的書籤 + Manage the bookmarks of one editor. + """ + + def __init__(self, editor: CodeEditor) -> None: + """ + :param editor: 被管理的程式碼編輯器 / The code editor being managed + """ + self._editor = editor + self._cursors: list[QTextCursor] = [] + + def bookmarked_lines(self) -> list[int]: + """ + 取得目前所有書籤的行號 + Return the current bookmark line numbers. + + 每次即時查詢游標的行號,因此反映文字編輯後的最新位置。 + The line is read from each cursor live, so it reflects edits made since. + + :return: 由小到大排序、無重複的行號(0 起算)/ Sorted, unique 0-based line numbers + """ + return sorted({cursor.blockNumber() for cursor in self._cursors}) + + def is_bookmarked(self, line: int) -> bool: + """ + 判斷某一行是否有書籤 + Return whether a line is bookmarked. + + :param line: 行號(0 起算)/ The line (0-based) + :return: 有書籤時為 ``True`` / ``True`` when bookmarked + """ + return any(cursor.blockNumber() == line for cursor in self._cursors) + + def toggle(self, line: int) -> bool: + """ + 切換某一行的書籤 + Toggle the bookmark on a line. + + :param line: 行號(0 起算)/ The line (0-based) + :return: 切換後該行有書籤時為 ``True`` / ``True`` when the line ends up bookmarked + """ + existing = [cursor for cursor in self._cursors if cursor.blockNumber() == line] + if existing: + self._cursors = [cursor for cursor in self._cursors if cursor.blockNumber() != line] + return False + cursor = self._cursor_at_line(line) + if cursor is None: + return False + self._cursors.append(cursor) + return True + + def toggle_current(self) -> bool: + """ + 切換游標所在行的書籤 + Toggle the bookmark on the line holding the caret. + + :return: 切換後該行有書籤時為 ``True`` / ``True`` when the line ends up bookmarked + """ + return self.toggle(self._editor.textCursor().blockNumber()) + + def clear(self) -> None: + """清除所有書籤 / Remove every bookmark.""" + self._cursors.clear() + + def go_to_next(self, wrap: bool = True) -> int | None: + """ + 跳到下一個書籤 + Jump to the next bookmark. + + :param wrap: 找不到時是否從頭繞回 / Whether to wrap to the first bookmark + :return: 跳到的行號,沒有書籤時回傳 ``None`` / The line jumped to, or ``None`` + """ + target = next_bookmark( + self.bookmarked_lines(), self._editor.textCursor().blockNumber(), wrap) + return self._jump_to(target) + + def go_to_previous(self, wrap: bool = True) -> int | None: + """ + 跳到上一個書籤 + Jump to the previous bookmark. + + :param wrap: 找不到時是否從尾端繞回 / Whether to wrap to the last bookmark + :return: 跳到的行號,沒有書籤時回傳 ``None`` / The line jumped to, or ``None`` + """ + target = prev_bookmark( + self.bookmarked_lines(), self._editor.textCursor().blockNumber(), wrap) + return self._jump_to(target) + + def _cursor_at_line(self, line: int): + """建立錨定在指定行開頭的游標 / Build a cursor anchored at a line's start.""" + block = self._editor.document().findBlockByNumber(line) + if not block.isValid(): + return None + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock) + return cursor + + def _jump_to(self, line: int | None) -> int | None: + """把編輯器游標移到指定行 / Move the editor caret to a line.""" + if line is None: + return None + block = self._editor.document().findBlockByNumber(line) + if not block.isValid(): + return None + cursor = self._editor.textCursor() + cursor.setPosition(block.position()) + self._editor.setTextCursor(cursor) + self._editor.centerCursor() + self._editor.highlight_current_line() + return line diff --git a/je_editor/pyside_ui/code/folding/__init__.py b/je_editor/pyside_ui/code/folding/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/folding/folding_manager.py b/je_editor/pyside_ui/code/folding/folding_manager.py new file mode 100644 index 00000000..181fb31f --- /dev/null +++ b/je_editor/pyside_ui/code/folding/folding_manager.py @@ -0,0 +1,181 @@ +""" +程式碼折疊管理器(Qt 整合層) +Code folding manager (Qt integration layer). + +折疊只切換 ``QTextBlock`` 的可見性,永遠不會修改文字內容,因此存檔與 +``toPlainText()`` 一律取得完整內容,折疊不可能造成資料遺失。 +Folding only toggles ``QTextBlock`` visibility and never edits text, so saving and +``toPlainText()`` always return the full content — folding can never lose data. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from je_editor.utils.code_folding.fold_regions import ( + FoldRegion, compute_fold_regions, region_at_line +) +from je_editor.utils.logging.loggin_instance import jeditor_logger + +if TYPE_CHECKING: + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + + +class FoldingManager: + """ + 管理單一編輯器的折疊狀態 + Manage the fold state of one editor. + + 折疊狀態以「被折疊的標頭行號集合」表示。重新套用時一律先讓所有行可見, + 再隱藏仍然有效的折疊區塊,因此絕不會殘留被錯誤隱藏的行(自我修復)。 + Fold state is the set of folded header line numbers. Re-applying always makes + every line visible first, then hides only still-valid folded regions, so a line + can never be left wrongly hidden (self-healing). + """ + + def __init__(self, editor: CodeEditor) -> None: + """ + :param editor: 被管理的程式碼編輯器 / The code editor being managed + """ + self._editor = editor + self._folded_headers: set[int] = set() + + def compute_regions(self) -> list[FoldRegion]: + """ + 依目前文字計算所有可折疊區塊 + Compute every foldable region for the current text. + + :return: 可折疊區塊清單 / The list of foldable regions + """ + text = self._editor.toPlainText() + return compute_fold_regions(text.split("\n")) + + def foldable_header_lines(self) -> set[int]: + """ + 取得所有可折疊標頭的行號 + Return the header line numbers of every foldable region. + + :return: 標頭行號集合(0 起算)/ Header line numbers (0-based) + """ + return {region.start for region in self.compute_regions()} + + def is_folded(self, line: int) -> bool: + """ + 判斷某個標頭目前是否為折疊狀態 + Return whether a header line is currently folded. + + :param line: 標頭行號(0 起算)/ The header line (0-based) + :return: 折疊時為 ``True`` / ``True`` when folded + """ + return line in self._folded_headers + + def is_any_folded(self) -> bool: + """ + 判斷是否有任何折疊中的區塊 + Return whether any region is currently folded. + + :return: 有折疊時為 ``True`` / ``True`` when at least one region is folded + """ + return bool(self._folded_headers) + + def folded_header_lines(self) -> set[int]: + """ + 取得目前折疊中的標頭行號 + Return the header line numbers that are currently folded. + + :return: 折疊中的標頭行號集合(0 起算)/ Folded header line numbers (0-based) + """ + return set(self._folded_headers) + + def toggle_fold(self, line: int) -> bool: + """ + 切換指定標頭的折疊狀態 + Toggle the fold state of a header line. + + :param line: 標頭行號(0 起算)/ The header line (0-based) + :return: 該行確實是可折疊標頭並完成切換時為 ``True`` + / ``True`` when the line was a foldable header and the state flipped + """ + regions = self.compute_regions() + if region_at_line(regions, line) is None: + return False + if line in self._folded_headers: + self._folded_headers.discard(line) + else: + self._folded_headers.add(line) + self._reapply(regions) + return True + + def fold_all(self) -> None: + """折疊所有可折疊區塊 / Fold every foldable region.""" + regions = self.compute_regions() + self._folded_headers = {region.start for region in regions} + self._reapply(regions) + + def unfold_all(self) -> None: + """展開所有折疊 / Unfold everything.""" + self._folded_headers.clear() + self._reapply(self.compute_regions()) + + def refresh(self) -> None: + """ + 文字變更後重新套用折疊 + Re-apply folds after the text changed. + + 不再對應到有效標頭的折疊會被丟棄,其內容自然恢復可見。 + Folds that no longer match a valid header are dropped and their content + becomes visible again. + """ + regions = self.compute_regions() + valid_headers = {region.start for region in regions} + self._folded_headers &= valid_headers + self._reapply(regions) + + def _reapply(self, regions: list[FoldRegion]) -> None: + """ + 依目前折疊狀態設定每一行的可見性 + Set every line's visibility from the current fold state. + """ + document = self._editor.document() + # 先讓所有行可見,確保不會殘留被錯誤隱藏的行 + # Make every line visible first so no line is left wrongly hidden + block = document.firstBlock() + while block.isValid(): + if not block.isVisible(): + block.setVisible(True) + block = block.next() + + region_by_header = {region.start: region for region in regions} + for header in sorted(self._folded_headers): + region = region_by_header.get(header) + if region is None: + continue + self._hide_region_body(document, region) + + self._relayout() + + def _hide_region_body(self, document, region: FoldRegion) -> None: + """隱藏折疊區塊的內容行 / Hide the body lines of a folded region.""" + for line in region.body_lines: + block = document.findBlockByNumber(line) + if block.isValid(): + block.setVisible(False) + + def _relayout(self) -> None: + """ + 通知編輯器重新計算版面 + Ask the editor to recompute its layout after visibility changed. + + 標記整份文件為 dirty 會讓 ``QPlainTextDocumentLayout`` 重新計算高度與捲軸, + 隱藏的行高度為零而自然收合。 + Marking the whole document dirty makes ``QPlainTextDocumentLayout`` recompute + heights and scrollbars; hidden blocks have zero height and collapse away. + """ + try: + document = self._editor.document() + document.markContentsDirty(0, document.characterCount()) + self._editor.update_line_number_area_width(0) + self._editor.viewport().update() + self._editor.line_number.update() + except RuntimeError as error: + # 編輯器可能在關閉過程中被銷毀 / The editor may be torn down mid-close + jeditor_logger.warning(f"folding_manager.py relayout skipped: {error}") 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 ef5c447b..e86e27c2 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 @@ -12,11 +12,36 @@ ) from PySide6.QtWidgets import QPlainTextEdit, QWidget, QTextEdit, QCompleter, QInputDialog +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.selection.smart_selection_manager import SmartSelectionManager +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 +) +from je_editor.utils.line_ops.line_operations import ( + join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines +) +from je_editor.utils.navigation.location_history import LocationHistory +from je_editor.utils.number_ops.number_ops import adjust_number_at, to_base +from je_editor.utils.occurrence.word_occurrences import ( + 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.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 from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_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 +) +from je_editor.utils.encode_decode.encode_decode import ( + base64_decode, base64_encode, html_escape, html_unescape, + json_string_escape, json_string_unescape, url_decode, url_encode +) from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper # 僅在型別檢查時匯入,避免循環引用 # Only imported for type checking, avoids circular imports @@ -49,6 +74,21 @@ def run(self) -> None: self.finished.emit([]) +# 行號區域中書籤欄與折疊欄的寬度(像素) +# Width in pixels of the bookmark and fold columns inside the gutter +_BOOKMARK_MARKER_WIDTH = 14 +_FOLD_MARKER_WIDTH = 14 + +# 游標移動幾行以上才視為「跳轉」並記入導覽歷史 +# How many lines the caret must move to count as a "jump" recorded in history +_JUMP_THRESHOLD_LINES = 5 + +# 超過此字元數的文件停用出現次數高亮,避免每次游標移動都掃描整份大檔 +# Skip occurrence highlighting past this size, so a large file is not rescanned +# on every caret move +_OCCURRENCE_MAX_CHARS = 100000 + + def venv_check() -> Path: """檢查當前工作目錄下是否有 venv 資料夾 / Check if venv exists in current working directory""" jeditor_logger.info("code_edit_plaintext.py venv check") @@ -158,6 +198,35 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self._bracket_open = set('([{') self.cursorPositionChanged.connect(self._highlight_matching_bracket) + # 程式碼折疊與書籤 / Code folding and bookmarks + self.folding_manager = FoldingManager(self) + self.bookmark_manager = BookmarkManager(self) + # 可折疊標頭快取,捲動重繪時免去重複計算;文字變更時失效 + # Cache of foldable header lines so scroll repaints skip recomputation; + # invalidated whenever the text changes + self._fold_header_cache: Union[set, None] = None + self.textChanged.connect(self._on_text_changed_for_features) + self._register_fold_bookmark_actions() + + # 游標跳轉歷史(上一步/下一步)/ Cursor jump history (back/forward) + self.location_history = LocationHistory() + self._last_recorded_line = 0 + # 執行上一步/下一步時暫停記錄,避免把導覽動作本身記進歷史 + # Suppress recording during back/forward so navigation isn't re-recorded + self._navigating_history = False + self.cursorPositionChanged.connect(self._record_cursor_jump) + self._register_history_actions() + self._register_line_operation_actions() + + # 智慧選取(擴大 / 縮回)/ Smart selection (expand/shrink) + self.smart_selection_manager = SmartSelectionManager(self) + self._register_smart_selection_actions() + self._register_number_actions() + + # 由檔案內容偵測的每檔縮排寬度(None 代表用全域設定) + # Per-file detected indent width (None means use the global setting) + self._indent_size_override: Union[int, None] = None + def reset_highlighter(self) -> None: """重設語法高亮 / Reset syntax highlighter""" jeditor_logger.info("CodeEditor reset_highlighter") @@ -251,6 +320,24 @@ def _on_complete_results(self, names: list) -> None: cursor_rect.setWidth(self.completer.popup().rect().size().width()) self.completer.complete(cursor_rect) + def jump_to_line(self, line: int) -> bool: + """ + 把游標移到指定行並置中顯示 + Move the cursor to a 1-based line number and centre it in the view. + + :param line: 1 起算的行號 / The 1-based line number + :return: 該行存在並完成跳轉時為 ``True`` / ``True`` when the line exists + """ + block = self.document().findBlockByNumber(line - 1) + if not block.isValid(): + return False + cursor = self.textCursor() + cursor.setPosition(block.position()) + self.setTextCursor(cursor) + self.centerCursor() + self.highlight_current_line() + return True + def go_to_line(self) -> None: """跳到指定行數 / Go to a specific line number""" max_line = self.blockCount() @@ -260,13 +347,7 @@ def go_to_line(self) -> None: min=1, max=max_line ) if ok: - block = self.document().findBlockByNumber(line - 1) - if block.isValid(): - cursor = self.textCursor() - cursor.setPosition(block.position()) - self.setTextCursor(cursor) - self.centerCursor() - self.highlight_current_line() + self.jump_to_line(line) def open_search_replace_dialog(self) -> None: """開啟搜尋與取代對話框 / Open Search & Replace dialog""" @@ -310,15 +391,196 @@ def find_back_text(self) -> None: text = self.search_box.search_input.text() self.find(text, QTextDocument.FindFlag.FindBackward) + # ── 程式碼折疊與書籤 / 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) + + def _on_text_changed_for_features(self) -> None: + """ + 文字變更時更新折疊與書籤相關狀態 + Update folding-related state when the text changes. + """ + # 失效可折疊標頭快取,下次繪製時重新計算 + # Invalidate the foldable-header cache so the next paint recomputes it + self._fold_header_cache = 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() + + def _foldable_header_lines(self) -> set: + """取得可折疊標頭行號(快取)/ Foldable header lines (cached).""" + if self._fold_header_cache is None: + self._fold_header_cache = self.folding_manager.foldable_header_lines() + return self._fold_header_cache + + def toggle_fold_at_cursor(self) -> None: + """切換游標所在區塊的折疊 / Toggle folding of the region at the caret.""" + self.folding_manager.toggle_fold(self.textCursor().blockNumber()) + self.line_number.update() + + def fold_all(self) -> None: + """折疊所有區塊 / Fold every region.""" + self.folding_manager.fold_all() + self.line_number.update() + + def unfold_all(self) -> None: + """展開所有區塊 / Unfold every region.""" + self.folding_manager.unfold_all() + self.line_number.update() + + def toggle_bookmark(self) -> None: + """切換游標所在行的書籤 / Toggle the bookmark on the caret line.""" + self.bookmark_manager.toggle_current() + self.line_number.update() + + def next_bookmark(self) -> None: + """跳到下一個書籤 / Jump to the next bookmark.""" + self.bookmark_manager.go_to_next() + + def previous_bookmark(self) -> None: + """跳到上一個書籤 / Jump to the previous bookmark.""" + self.bookmark_manager.go_to_previous() + + # ── 導覽歷史 / Navigation history ───────────────────────────── + + 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) + + def _record_cursor_jump(self) -> None: + """ + 游標大幅移動時記入導覽歷史 + Record a history entry when the caret moves far enough to be a jump. + + 跳轉時同時記錄「跳離的位置」與「跳到的位置」,因此「上一步」會回到跳轉前 + 的位置,而不是更早的某個紀錄。 + A jump records both the line jumped *from* and the line jumped *to*, so + "back" returns to where the jump started rather than some earlier entry. + """ + line = self.textCursor().blockNumber() + if self._navigating_history: + self._last_recorded_line = line + return + if abs(line - self._last_recorded_line) >= _JUMP_THRESHOLD_LINES: + self.location_history.visit(self._last_recorded_line) + self.location_history.visit(line) + self._last_recorded_line = line + + def navigate_back(self) -> bool: + """ + 回到上一個游標位置 + Jump back to the previous cursor location. + + :return: 有可回退的位置並完成跳轉時為 ``True`` / ``True`` when a jump happened + """ + return self._go_to_history_line(self.location_history.back()) + + def navigate_forward(self) -> bool: + """ + 前進到下一個游標位置 + Jump forward to the next cursor location. + + :return: 有可前進的位置並完成跳轉時為 ``True`` / ``True`` when a jump happened + """ + return self._go_to_history_line(self.location_history.forward()) + + def _go_to_history_line(self, line: Union[int, None]) -> bool: + """移動游標到歷史中的行,過程中暫停記錄 / Move to a history line without recording.""" + if line is None: + return False + block = self.document().findBlockByNumber(line) + if not block.isValid(): + return False + self._navigating_history = True + try: + cursor = self.textCursor() + cursor.setPosition(block.position()) + self.setTextCursor(cursor) + self.centerCursor() + self.highlight_current_line() + finally: + self._navigating_history = False + return True + + def gutter_line_at_y(self, y_pos: int) -> int: + """ + 把行號區域的 y 座標對應到區塊行號 + Map a y coordinate in the gutter to a block number. + + :param y_pos: 相對於編輯器視窗的 y 座標 / A y coordinate in the editor viewport + :return: 對應的區塊行號(0 起算),找不到時回傳 -1 + / The 0-based block number, or -1 when none is found + """ + block = self.firstVisibleBlock() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + bottom = top + self.blockBoundingRect(block).height() + while block.isValid(): + if block.isVisible() and top <= y_pos <= bottom: + return block.blockNumber() + block = block.next() + top = bottom + bottom = top + self.blockBoundingRect(block).height() + if top > y_pos: + break + return -1 + + def handle_gutter_click(self, x_pos: int, y_pos: int) -> None: + """ + 處理行號區域的點擊:折疊欄切換折疊、書籤欄切換書籤 + Handle a gutter click: the fold column toggles folding, the bookmark + column toggles a bookmark. + + :param x_pos: 點擊的 x 座標 / The click x coordinate + :param y_pos: 點擊的 y 座標 / The click y coordinate + """ + line = self.gutter_line_at_y(y_pos) + if line < 0: + return + if x_pos <= _BOOKMARK_MARKER_WIDTH: + self.bookmark_manager.toggle(line) + self.line_number.update() + return + if x_pos >= self.line_number.width() - _FOLD_MARKER_WIDTH: + if line in self._foldable_header_lines(): + self.folding_manager.toggle_fold(line) + self.line_number.update() + def line_number_paint(self, event: QtGui.QPaintEvent) -> None: """ - 繪製行號區域 - Paint line number area + 繪製行號區域,包含書籤與折疊標記 + Paint the gutter, including bookmark and fold markers. """ painter = QPainter(self.line_number) # 填滿背景色 painter.fillRect(event.rect(), actually_color_dict.get("line_number_background_color")) + bookmarked = set(self.bookmark_manager.bookmarked_lines()) + fold_headers = self._foldable_header_lines() + folded_headers = self.folding_manager.folded_header_lines() + gutter_width = self.line_number.width() + line_height = self.fontMetrics().height() + # 從第一個可見區塊開始 block = self.firstVisibleBlock() block_number = block.blockNumber() @@ -328,29 +590,63 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: # 逐行繪製行號 while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): - number = str(block_number + 1) painter.setPen(actually_color_dict.get("line_number_color")) painter.drawText( - 0, - top, - self.line_number.width(), - self.fontMetrics().height(), + _BOOKMARK_MARKER_WIDTH, + int(top), + gutter_width - _BOOKMARK_MARKER_WIDTH - _FOLD_MARKER_WIDTH, + line_height, Qt.AlignmentFlag.AlignCenter, - number, + str(block_number + 1), ) + if block_number in bookmarked: + self._paint_bookmark_marker(painter, top, line_height) + if block_number in fold_headers: + self._paint_fold_marker( + painter, top, line_height, gutter_width, + collapsed=block_number in folded_headers) block = block.next() top = bottom bottom = top + self.blockBoundingRect(block).height() block_number += 1 + def _paint_bookmark_marker(self, painter: QPainter, top: float, line_height: int) -> None: + """在行號左側繪製書籤圓點 / Draw the bookmark dot on the gutter's left.""" + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + color = actually_color_dict.get("bookmark_marker_color") + painter.setBrush(color) + painter.setPen(color) + 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_fold_marker( + self, painter: QPainter, top: float, line_height: int, + gutter_width: int, collapsed: bool) -> None: + """在行號右側繪製折疊三角形 / Draw the fold triangle on the gutter's right.""" + painter.save() + painter.setPen(actually_color_dict.get("fold_marker_color")) + marker = "▸" if collapsed else "▾" + painter.drawText( + gutter_width - _FOLD_MARKER_WIDTH, + int(top), + _FOLD_MARKER_WIDTH, + line_height, + Qt.AlignmentFlag.AlignCenter, + marker, + ) + painter.restore() + def line_number_width(self) -> int: """ - 計算行號區域寬度 - Calculate line number area width + 計算行號區域寬度(含書籤與折疊欄) + Calculate gutter width, including the bookmark and fold columns. """ digits = len(str(self.blockCount())) # 根據總行數決定位數 - space = 12 * digits - return space + return 12 * digits + _BOOKMARK_MARKER_WIDTH + _FOLD_MARKER_WIDTH def update_line_number_area_width(self, value: int) -> None: """ @@ -443,8 +739,53 @@ def _highlight_matching_bracket(self) -> None: sel.format = bracket_fmt selections.append(sel) + self._append_occurrence_selections(selections, text, pos) self.setExtraSelections(selections) + def word_occurrences_under_cursor(self, text: str, position: int) -> list[int]: + """ + 取得游標所在字詞在文件中的所有出現位置 + Return every occurrence position of the word under the caret. + + 字詞過大檔案、單一出現或非識別字時回傳空清單,因此不會產生無意義的高亮。 + Returns an empty list for large files, a lone occurrence, or a non-identifier, + so no pointless highlight is produced. + + :param text: 文件內容 / The document text + :param position: 游標字元位置 / The caret character position + :return: 出現位置清單(兩個以上才回傳)/ Occurrence positions (only when 2+) + """ + if len(text) > _OCCURRENCE_MAX_CHARS: + return [] + found = word_at(text, position) + if found is None: + return [] + word, _start, _end = found + positions = find_occurrences(text, word) + # 只有一個出現時不必高亮 / A single occurrence needs no highlight + return positions if len(positions) > 1 else [] + + def _append_occurrence_selections(self, selections: list, text: str, position: int) -> None: + """把游標所在字詞的所有出現位置加入高亮 / Append occurrence highlights.""" + positions = self.word_occurrences_under_cursor(text, position) + if not positions: + return + found = word_at(text, position) + if found is None: + return + word_length = len(found[0]) + occurrence_fmt = QTextCharFormat() + occurrence_fmt.setBackground(actually_color_dict.get("occurrence_highlight_color")) + doc = self.document() + for start in positions: + selection = QTextEdit.ExtraSelection() + cursor = QTextCursor(doc) + cursor.setPosition(start) + cursor.setPosition(start + word_length, QTextCursor.MoveMode.KeepAnchor) + selection.cursor = cursor + selection.format = occurrence_fmt + selections.append(selection) + def _find_matching_bracket(self, text: str, pos: int, char: str) -> Union[int, None]: """ 找到匹配的括號位置 @@ -482,15 +823,464 @@ def jump_to_matching_bracket(self) -> None: def duplicate_line(self) -> None: """ - 複製當前行 (Ctrl+D) - Duplicate current line + 複製當前行或選取內容 (Ctrl+D) + Duplicate the current line, or the selection when there is one. + + 有選取時在選取結尾後插入一份相同內容,並讓游標選住新複本;沒有選取時 + 複製整行。整個動作為單一復原步驟。 + With a selection, a copy is inserted right after it and the caret selects the + new copy; without one, the whole line is duplicated. The whole action is a + single undo step. """ cursor = self.textCursor() + if cursor.hasSelection(): + self._duplicate_selection(cursor) + return + cursor.beginEditBlock() cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock) cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor) line_text = cursor.selectedText() cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock) cursor.insertText("\n" + line_text) + cursor.endEditBlock() + self.setTextCursor(cursor) + + def _duplicate_selection(self, cursor: QTextCursor) -> None: + """在選取結尾後插入一份複本並選住它 / Insert a copy after the selection and select it.""" + selected_text = cursor.selectedText() + end = cursor.selectionEnd() + cursor.beginEditBlock() + cursor.setPosition(end) + cursor.insertText(selected_text) + cursor.endEditBlock() + # 讓游標選住剛插入的複本 / Select the copy that was just inserted + cursor.setPosition(end) + cursor.setPosition(end + len(selected_text), QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(cursor) + + # ── 智慧選取 / Smart selection ─────────────────────────────── + + 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) + + def expand_selection(self) -> None: + """把選取擴大到下一個更大的範圍 / Expand the selection to the next larger range.""" + self.smart_selection_manager.expand() + + def shrink_selection(self) -> None: + """縮回上一個較小的選取範圍 / Shrink back to the previous smaller range.""" + self.smart_selection_manager.shrink() + + # ── 游標處數字加減 / Increment / decrement the number under the caret ── + + 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) + + def rename_word_under_cursor(self) -> bool: + """ + 重新命名游標所在識別字在整個檔案中的所有出現(整字比對,單一復原) + Rename every whole-word occurrence of the identifier under the caret across + the file (word-boundary match, single undo step). + + 這是「文字層級」的重新命名:以字界比對,因此不會動到部分符合的字詞,但字串 + 與註解中剛好同名的字也會一併替換。會彈出對話框詢問新名稱。 + This is a *textual* rename: word boundaries protect partial matches, but a + same-named word inside a string or comment is replaced too. A dialog asks for + the new name. + + :return: 有實際重新命名時為 ``True`` / ``True`` when a rename actually happened + """ + text = self.toPlainText() + found = word_at(text, self.textCursor().position()) + if found is None or not found[0].isidentifier(): + return False + old_word = found[0] + word_dict = language_wrapper.language_word_dict + new_word, accepted = QInputDialog.getText( + self, + word_dict.get("rename_dialog_title"), + word_dict.get("rename_dialog_label").format(word=old_word), + text=old_word, + ) + new_word = new_word.strip() + if not accepted or not new_word or new_word == old_word: + return False + return self._replace_document_text(replace_whole_word(text, old_word, new_word)) + + def adjust_number(self, delta: int) -> bool: + """ + 把游標所在的整數加上 ``delta``(單一復原步驟) + Add ``delta`` to the integer under the caret as one undo step. + + :param delta: 增減量(可為負)/ The amount to add (may be negative) + :return: 游標在數字上並完成調整時為 ``True`` / ``True`` when a number was adjusted + """ + result = adjust_number_at(self.toPlainText(), self.textCursor().position(), delta) + if result is None: + return False + new_text, start, end = result + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + cursor.insertText(new_text) + cursor.endEditBlock() + caret = self.textCursor() + caret.setPosition(start + len(new_text)) + self.setTextCursor(caret) + return True + + # ── 行操作 / Line operations ───────────────────────────────── + + 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) + + def _selected_block_range(self, cursor: QTextCursor) -> tuple[int, int]: + """取得選取(或游標所在)涵蓋的 block 區間 / The block range covered by the selection.""" + if cursor.hasSelection(): + start, end = cursor.selectionStart(), cursor.selectionEnd() + probe = QTextCursor(self.document()) + probe.setPosition(start) + start_block = probe.blockNumber() + probe.setPosition(end) + end_block = probe.blockNumber() + # 選取剛好停在行首時,不把下一行算進來 / Don't include a line the selection only touches at its start + if end_block > start_block and probe.positionInBlock() == 0: + end_block -= 1 + return start_block, end_block + return cursor.blockNumber(), cursor.blockNumber() + + def _replace_block_range(self, start_block: int, end_block: int, new_text: str) -> None: + """以新文字取代指定 block 區間 / Replace a block range with new text as one undo step.""" + document = self.document() + start_cursor = QTextCursor(document.findBlockByNumber(start_block)) + start_cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock) + end_block_obj = document.findBlockByNumber(end_block) + start_cursor.setPosition( + end_block_obj.position() + end_block_obj.length() - 1, + QTextCursor.MoveMode.KeepAnchor) + start_cursor.insertText(new_text) + + def delete_current_line(self) -> None: + """ + 刪除目前行或選取涵蓋的行 (Ctrl+Shift+D) + Delete the current line, or every line the selection touches. + + 一併移除該行的換行:中間的行連同其後的換行刪除;刪到最後一行時,改為連同 + 其前的換行刪除,避免留下多餘空行。 + The line's newline goes too: an interior line takes the newline after it, + while deleting through the last line takes the newline before it instead, + so no stray blank line is left behind. + """ + cursor = self.textCursor() + start_block, end_block = self._selected_block_range(cursor) + document = self.document() + first_block = document.findBlockByNumber(start_block) + last_block = document.findBlockByNumber(end_block) + next_block = last_block.next() + + delete_cursor = QTextCursor(document) + if next_block.isValid(): + delete_cursor.setPosition(first_block.position()) + delete_cursor.setPosition(next_block.position(), QTextCursor.MoveMode.KeepAnchor) + else: + # 最後一行:把前一行的換行也吃掉 / Last line: also consume the preceding newline + start_position = first_block.position() + if start_position > 0: + start_position -= 1 + delete_cursor.setPosition(start_position) + delete_cursor.setPosition( + last_block.position() + last_block.length() - 1, + QTextCursor.MoveMode.KeepAnchor) + delete_cursor.removeSelectedText() + self.highlight_current_line() + + def _transform_selected_lines(self, transform) -> bool: + """ + 對選取涵蓋的行套用一個 list[str] → list[str] 的轉換 + Apply a ``list[str] -> list[str]`` transform to the lines the selection spans. + + 少於兩行時不做任何事(排序、去重、反轉在單行都沒有意義)。 + Does nothing with fewer than two lines (sorting, dedup and reversing are all + meaningless for a single line). + + :param transform: 行轉換函式 / The line transform + :return: 有套用時為 ``True`` / ``True`` when the transform was applied + """ + cursor = self.textCursor() + start_block, end_block = self._selected_block_range(cursor) + if end_block <= start_block: + return False + lines = self._block_texts(start_block, end_block) + self._replace_block_range(start_block, end_block, "\n".join(transform(lines))) + return True + + def sort_selected_lines(self) -> None: + """ + 排序選取的行 (Ctrl+Alt+S) + Sort the selected lines alphabetically. + """ + self._transform_selected_lines(sort_lines) + + def remove_duplicate_selected_lines(self) -> None: + """ + 移除選取範圍內的重複行,保留首次出現順序 + Remove duplicate lines within the selection, keeping first-seen order. + """ + self._transform_selected_lines(unique_lines) + + def reverse_selected_lines(self) -> None: + """ + 反轉選取範圍內的行順序 + Reverse the order of the selected lines. + """ + self._transform_selected_lines(reverse_lines) + + def natural_sort_selected_lines(self) -> None: + """ + 以自然順序排序選取的行(item2 在 item10 之前) + Sort the selected lines naturally (item2 before item10). + """ + self._transform_selected_lines(natural_sort) + + def remove_blank_selected_lines(self) -> None: + """ + 移除選取範圍內的空白行 + Remove blank lines within the selection. + """ + self._transform_selected_lines(remove_blank_lines) + + def align_selected_lines(self) -> None: + """ + 依使用者輸入的分隔符對齊選取的行 + Align the selected lines on a delimiter the user types. + + 彈出對話框詢問分隔符(預設 ``=``),把每行第一個該分隔符對齊到同一欄。 + A dialog asks for the delimiter (default ``=``) and aligns each line's first + occurrence of it into the same column. + """ + word_dict = language_wrapper.language_word_dict + delimiter, accepted = QInputDialog.getText( + self, + word_dict.get("align_dialog_title"), + word_dict.get("align_dialog_label"), + text="=", + ) + if not accepted or delimiter == "": + return + self._transform_selected_lines(lambda lines: align_by_delimiter(lines, delimiter)) + + def _transform_selection_text(self, transform) -> bool: + """ + 對選取的文字套用一個 str → str 的轉換並保留選取 + Apply a ``str -> str`` transform to the selected text, keeping it selected. + + :param transform: 文字轉換函式 / The text transform + :return: 有選取並套用時為 ``True`` / ``True`` when there was a selection to transform + """ + cursor = self.textCursor() + if not cursor.hasSelection(): + return False + start = cursor.selectionStart() + new_text = transform(cursor.selectedText()) + # 轉換回傳 None(例如解碼失敗)時保持原樣不動 / A None result (e.g. failed decode) is a no-op + if new_text is None: + return False + cursor.beginEditBlock() + cursor.insertText(new_text) + cursor.endEditBlock() + cursor.setPosition(start) + cursor.setPosition(start + len(new_text), QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(cursor) + return True + + def uppercase_selection(self) -> None: + """把選取文字轉為大寫 / Convert the selected text to uppercase.""" + self._transform_selection_text(str.upper) + + def lowercase_selection(self) -> None: + """把選取文字轉為小寫 / Convert the selected text to lowercase.""" + self._transform_selection_text(str.lower) + + def base64_encode_selection(self) -> None: + """把選取文字做 Base64 編碼 / Base64-encode the selected text.""" + self._transform_selection_text(base64_encode) + + def base64_decode_selection(self) -> None: + """把選取文字做 Base64 解碼(失敗則不動)/ Base64-decode the selection (no-op on failure).""" + self._transform_selection_text(base64_decode) + + def url_encode_selection(self) -> None: + """把選取文字做 URL 編碼 / URL-encode the selected text.""" + self._transform_selection_text(url_encode) + + def url_decode_selection(self) -> None: + """把選取文字做 URL 解碼 / URL-decode the selected text.""" + self._transform_selection_text(url_decode) + + def html_escape_selection(self) -> None: + """把選取文字做 HTML 轉義 / HTML-escape the selected text.""" + self._transform_selection_text(html_escape) + + def html_unescape_selection(self) -> None: + """把選取文字做 HTML 還原 / HTML-unescape the selected text.""" + self._transform_selection_text(html_unescape) + + def json_escape_selection(self) -> None: + """把選取文字轉成 JSON 字串字面值 / Escape the selection into a JSON string literal.""" + self._transform_selection_text(json_string_escape) + + def json_unescape_selection(self) -> None: + """把選取的 JSON 字串還原(失敗則不動)/ Unescape a JSON string (no-op on failure).""" + self._transform_selection_text(json_string_unescape) + + def swapcase_selection(self) -> None: + """把選取文字大小寫互換 / Swap the case of the selected text.""" + self._transform_selection_text(str.swapcase) + + def titlecase_selection(self) -> None: + """把選取文字轉為標題大小寫 / Convert the selected text to title case.""" + self._transform_selection_text(str.title) + + def to_snake_case_selection(self) -> None: + """把選取的識別字轉為 snake_case / Convert the selection to snake_case.""" + self._transform_selection_text(to_snake_case) + + def to_camel_case_selection(self) -> None: + """把選取的識別字轉為 camelCase / Convert the selection to camelCase.""" + self._transform_selection_text(to_camel_case) + + def to_pascal_case_selection(self) -> None: + """把選取的識別字轉為 PascalCase / Convert the selection to PascalCase.""" + self._transform_selection_text(to_pascal_case) + + def to_kebab_case_selection(self) -> None: + """把選取的識別字轉為 kebab-case / Convert the selection to kebab-case.""" + self._transform_selection_text(to_kebab_case) + + def number_to_hex_selection(self) -> None: + """把選取的整數轉為十六進位(失敗則不動)/ Convert the selected integer to hex.""" + self._transform_selection_text(lambda text: to_base(text, 16)) + + def number_to_decimal_selection(self) -> None: + """把選取的整數轉為十進位(失敗則不動)/ Convert the selected integer to decimal.""" + self._transform_selection_text(lambda text: to_base(text, 10)) + + def number_to_binary_selection(self) -> None: + """把選取的整數轉為二進位(失敗則不動)/ Convert the selected integer to binary.""" + self._transform_selection_text(lambda text: to_base(text, 2)) + + def join_selected_lines(self) -> None: + """ + 把選取的行併成一行 (Ctrl+Shift+J) + Join the selected lines into a single line. + """ + cursor = self.textCursor() + start_block, end_block = self._selected_block_range(cursor) + if end_block <= start_block: + return + lines = self._block_texts(start_block, end_block) + self._replace_block_range(start_block, end_block, join_lines(lines)) + + def _block_texts(self, start_block: int, end_block: int) -> list[str]: + """取得指定 block 區間的每一行文字 / The text of each block in the range.""" + document = self.document() + return [ + document.findBlockByNumber(number).text() + for number in range(start_block, end_block + 1) + ] + + def _replace_document_text(self, new_text: str) -> bool: + """ + 以新內容取代整份文件(單一復原步驟,並還原游標) + Replace the whole document as one undo step, restoring the caret. + + :param new_text: 新的完整內容 / The new full content + :return: 有變動時為 ``True`` / ``True`` when the content changed + """ + if new_text == self.toPlainText(): + return False + cursor = self.textCursor() + line = cursor.blockNumber() + column = cursor.positionInBlock() + + edit_cursor = self.textCursor() + edit_cursor.beginEditBlock() + edit_cursor.select(QTextCursor.SelectionType.Document) + edit_cursor.insertText(new_text) + edit_cursor.endEditBlock() + + self._restore_caret(line, column) + return True + + def convert_indentation_to_spaces(self, tab_size: int = 4) -> bool: + """ + 把整份文件開頭縮排的 Tab 轉成空白 / Convert leading tabs to spaces document-wide. + + :param tab_size: 每個 Tab 對應的空白數 / Spaces per tab + :return: 有變動時為 ``True`` / ``True`` when the content changed + """ + return self._replace_document_text( + convert_leading_tabs_to_spaces(self.toPlainText(), tab_size)) + + def convert_indentation_to_tabs(self, tab_size: int = 4) -> bool: + """ + 把整份文件開頭縮排的空白轉成 Tab / Convert leading spaces to tabs document-wide. + + :param tab_size: 每個 Tab 對應的空白數 / Spaces per tab + :return: 有變動時為 ``True`` / ``True`` when the content changed + """ + return self._replace_document_text( + convert_leading_spaces_to_tabs(self.toPlainText(), tab_size)) + + def trim_trailing_whitespace_document(self) -> bool: + """ + 移除整份文件每行結尾的空白(單一復原步驟) + Strip trailing whitespace across the whole document as one undo step. + + 內容沒有變動時不做任何事,也不會產生多餘的復原步驟;有變動時盡量把游標 + 還原到原本的行與欄(欄位超過新行長度時夾到行尾)。 + Does nothing when the content is unchanged (no stray undo step); otherwise + restores the caret to its original line and column, clamped to the new line + length. + + :return: 有實際修改時為 ``True`` / ``True`` when the text actually changed + """ + return self._replace_document_text(trim_trailing_whitespace(self.toPlainText())) + + def _restore_caret(self, line: int, column: int) -> None: + """把游標還原到指定行與欄,欄位夾到行尾 / Restore the caret, clamping the column.""" + block = self.document().findBlockByNumber(line) + if not block.isValid(): + return + cursor = self.textCursor() + cursor.setPosition(block.position() + min(column, len(block.text()))) self.setTextCursor(cursor) def _toggle_comment_block_range(self, cursor: QTextCursor) -> tuple[int, int]: @@ -638,9 +1428,58 @@ def _leading_space_count(text: str, limit: int = 4) -> int: break return spaces + def indent_size(self) -> int: + """ + 取得目前生效的縮排空白數 + Return the indent width in spaces currently in effect. + + 優先使用本編輯器由檔案內容偵測到的縮排(per-editor override),沒有時退回 + ``user_setting_dict['indent_size']``(預設 4)。因此 Tab 縮排、取消縮排與 + Enter 自動縮排都遵循同一個值。 + Prefers this editor's per-file detected indent (an override); otherwise falls + back to ``user_setting_dict['indent_size']`` (default 4). Tab-indent, unindent + and Enter auto-indent all follow the same value. + + :return: 縮排空白數(1 到 16)/ The indent width in spaces (1 to 16) + """ + override = getattr(self, "_indent_size_override", None) + if isinstance(override, int) and 1 <= override <= 16: + return override + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + size = user_setting_dict.get("indent_size", 4) + if not isinstance(size, int) or size < 1: + return 4 + return min(size, 16) + + def apply_detected_indentation(self) -> int | None: + """ + 依目前內容偵測縮排寬度並套用到本編輯器 + Detect the indent width from the current content and apply it to this editor. + + 偵測到以空白縮排時,設定 per-editor 覆寫值並更新 Tab 顯示寬度;以 Tab 縮排 + 或無法判斷時清除覆寫,改用全域設定。永不修改文字內容。 + On space indentation, sets the per-editor override and updates the visual tab + width; on tab indentation or when undecidable, clears the override to fall + back to the global setting. It never modifies the text. + + :return: 套用的縮排空白數,未套用時回傳 ``None`` + / The applied indent width, or ``None`` when not applied + """ + text = self.toPlainText() + if detect_indentation_uses_tabs(text): + self._indent_size_override = None + return None + width = detect_indent_width(text) + self._indent_size_override = width if width and 1 <= width <= 16 else None + if self._indent_size_override is not None: + self.setTabStopDistance( + QtGui.QFontMetricsF(self.font()).horizontalAdvance( + " " * self._indent_size_override)) + return self._indent_size_override + def _unindent_current_block(self, cursor: QTextCursor) -> None: - """移除當前行開頭最多 4 個空白 / Remove up to 4 leading spaces on the current line.""" - spaces = self._leading_space_count(cursor.block().text(), limit=4) + """移除當前行開頭最多一個縮排單位的空白 / Remove up to one indent unit of leading spaces.""" + spaces = self._leading_space_count(cursor.block().text(), limit=self.indent_size()) if spaces == 0: return cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock) @@ -658,11 +1497,12 @@ def _indent_selection(self, indent: bool = True) -> None: cursor.setPosition(end) end_block = cursor.blockNumber() + indent_unit = " " * self.indent_size() cursor.setPosition(start) for _ in range(end_block - start_block + 1): cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock) if indent: - cursor.insertText(" ") + cursor.insertText(indent_unit) else: self._unindent_current_block(cursor) cursor.movePosition(QTextCursor.MoveOperation.NextBlock) @@ -748,7 +1588,7 @@ def _handle_enter_autoindent(self, event: QKeyEvent) -> None: else: break if line.rstrip().endswith(":"): - indent += " " + indent += " " * self.indent_size() super().keyPressEvent(event) if indent: self.textCursor().insertText(indent) @@ -845,3 +1685,11 @@ def paintEvent(self, event: QtGui.QPaintEvent) -> None: Delegate painting to CodeEditor.line_number_paint """ self.editor.line_number_paint(event) + + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + """ + 點擊行號區域時切換折疊或書籤 + Toggle folding or a bookmark when the gutter is clicked. + """ + position = event.position() + self.editor.handle_gutter_click(int(position.x()), int(position.y())) diff --git a/je_editor/pyside_ui/code/selection/__init__.py b/je_editor/pyside_ui/code/selection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/selection/smart_selection_manager.py b/je_editor/pyside_ui/code/selection/smart_selection_manager.py new file mode 100644 index 00000000..71ae41ae --- /dev/null +++ b/je_editor/pyside_ui/code/selection/smart_selection_manager.py @@ -0,0 +1,87 @@ +""" +智慧選取管理器(Qt 整合層) +Smart selection manager (Qt integration layer). + +負責把純邏輯算出的範圍套用到編輯器,並維護一個「縮回」用的堆疊。 +Applies the ranges computed by the pure logic to the editor and keeps a stack so +selections can be shrunk back to their previous size. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtGui import QTextCursor + +from je_editor.utils.selection.smart_selection import expand_selection + +if TYPE_CHECKING: + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + + +class SmartSelectionManager: + """ + 管理單一編輯器的智慧選取擴大 / 縮回 + Manage smart selection expand/shrink for one editor. + + 使用者手動改變選取時堆疊會失效,因此「縮回」永遠只會回到由「擴大」建立的範圍。 + The stack is invalidated when the user changes the selection manually, so shrink + only ever returns to ranges that expand itself created. + """ + + def __init__(self, editor: CodeEditor) -> None: + """ + :param editor: 被管理的程式碼編輯器 / The code editor being managed + """ + self._editor = editor + self._stack: list[tuple[int, int]] = [] + self._last_applied: tuple[int, int] | None = None + + def _current_range(self) -> tuple[int, int]: + """目前選取的字元範圍 / The current selection's character range.""" + cursor = self._editor.textCursor() + return cursor.selectionStart(), cursor.selectionEnd() + + def _sync_stack_with_selection(self, current: tuple[int, int]) -> None: + """選取被使用者改動時清空堆疊 / Clear the stack when the user changed the selection.""" + if self._last_applied is not None and current != self._last_applied: + self._stack.clear() + + def expand(self) -> bool: + """ + 把選取擴大到下一個更大的範圍 + Expand the selection to the next larger range. + + :return: 有擴大時為 ``True`` / ``True`` when the selection grew + """ + text = self._editor.toPlainText() + start, end = self._current_range() + self._sync_stack_with_selection((start, end)) + new_range = expand_selection(text, start, end) + if new_range is None: + return False + self._stack.append((start, end)) + self._apply(new_range) + return True + + def shrink(self) -> bool: + """ + 縮回上一個較小的範圍 + Shrink back to the previous smaller range. + + :return: 有縮回時為 ``True`` / ``True`` when the selection shrank + """ + current = self._current_range() + self._sync_stack_with_selection(current) + if not self._stack: + return False + self._apply(self._stack.pop()) + return True + + def _apply(self, selection_range: tuple[int, int]) -> None: + """把字元範圍套用成編輯器選取 / Apply a character range as the editor selection.""" + start, end = selection_range + cursor = self._editor.textCursor() + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + self._editor.setTextCursor(cursor) + self._last_applied = selection_range diff --git a/je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py b/je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py index 5308da63..f5b3c70c 100644 --- a/je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py +++ b/je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py @@ -51,6 +51,10 @@ def _load_file_into_widget(widget: EditorWidget, file_path: str) -> bool: return False widget.current_file = file_path widget.code_edit.setPlainText(result[1]) + try: + widget.code_edit.apply_detected_indentation() + except Exception as detect_error: + jeditor_logger.warning(f"Indent detection failed: {detect_error}") if widget.code_save_thread is None: init_new_auto_save_thread(widget.current_file, widget) else: diff --git a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py index bcc1950f..2329bde1 100644 --- a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py +++ b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py @@ -14,25 +14,15 @@ QFileDialog, QPlainTextEdit, QMessageBox, QWidget ) +from je_editor.utils.file_scan.ignore_rules import ( + IGNORED_DIRECTORY_NAMES as _SKIP_DIRS, + is_binary_file as _is_binary, +) from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper if TYPE_CHECKING: from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget -# 二進位嗅探用的 null byte 門檻 / Binary sniff threshold -_BINARY_SNIFF_BYTES = 4096 -# 搜尋時略過的資料夾 / Folders to skip during search -_SKIP_DIRS = {".git", ".hg", ".svn", "__pycache__", "node_modules", ".venv", "venv", ".tox", ".mypy_cache"} - - -def _is_binary(path: Path) -> bool: - """快速判斷檔案是否為二進位 / Quick check if file is binary""" - try: - with open(path, "rb") as f: - return b"\x00" in f.read(_BINARY_SNIFF_BYTES) - except Exception: - return True - class _SearchWorker(QThread): """ diff --git a/je_editor/pyside_ui/main_ui/command_palette/__init__.py b/je_editor/pyside_ui/main_ui/command_palette/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/main_ui/command_palette/command_palette_dialog.py b/je_editor/pyside_ui/main_ui/command_palette/command_palette_dialog.py new file mode 100644 index 00000000..32ab02ff --- /dev/null +++ b/je_editor/pyside_ui/main_ui/command_palette/command_palette_dialog.py @@ -0,0 +1,193 @@ +""" +指令面板:以模糊搜尋找到並執行任何選單指令 +Command palette: fuzzy-search and run any menu command. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import ( + QDialog, QLineEdit, QListWidget, QListWidgetItem, QVBoxLayout, QWidget +) + +from je_editor.pyside_ui.main_ui.command_palette.menu_command_collector import ( + collect_menu_commands +) +from je_editor.utils.command_palette.fuzzy_matcher import CommandEntry, rank_commands +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + +if TYPE_CHECKING: + from je_editor.pyside_ui.main_ui.main_editor import EditorMain + +# 對話框尺寸 / Dialog size +DIALOG_WIDTH = 640 +DIALOG_HEIGHT = 420 +# 清單顯示筆數上限 / Maximum rows rendered in the result list +RESULT_LIMIT = 60 +# 對話框相對於父視窗頂端的偏移 / Vertical offset from the parent window top +TOP_OFFSET = 120 + + +class CommandPaletteDialog(QDialog): + """ + 指令面板對話框 + The command palette dialog. + + 輸入文字即時過濾指令,Enter 執行選取的指令,Esc 關閉。 + Typing filters commands live, Enter runs the selected one, Esc closes. + """ + + def __init__( + self, parent: QWidget, commands: list[CommandEntry], + title: str | None = None, placeholder: str | None = None) -> None: + """ + :param parent: 父視窗 / The parent window + :param commands: 可執行的指令清單 / The runnable commands + :param title: 視窗標題,``None`` 時使用指令面板的預設標題 + / Window title; ``None`` uses the command palette default + :param placeholder: 輸入框提示文字,``None`` 時使用預設提示 + / Search box hint; ``None`` uses the default + """ + super().__init__(parent) + self._commands = commands + self._visible_commands: list[CommandEntry] = [] + + self.setWindowTitle( + title or language_wrapper.language_word_dict.get("command_palette_title")) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.setModal(True) + self.resize(DIALOG_WIDTH, DIALOG_HEIGHT) + + self.search_input = QLineEdit(self) + self.search_input.setPlaceholderText( + placeholder or language_wrapper.language_word_dict.get( + "command_palette_placeholder")) + self.search_input.textChanged.connect(self._refresh_results) + self.search_input.installEventFilter(self) + + self.result_list = QListWidget(self) + self.result_list.itemActivated.connect(self._run_item) + self.result_list.itemDoubleClicked.connect(self._run_item) + + layout = QVBoxLayout(self) + layout.addWidget(self.search_input) + layout.addWidget(self.result_list) + self.setLayout(layout) + + self._refresh_results("") + self.search_input.setFocus() + + def eventFilter(self, watched, event) -> bool: + """ + 讓上下鍵在輸入框中也能移動清單選取 + Route arrow keys from the search box to the result list. + """ + if watched is self.search_input and isinstance(event, QKeyEvent) \ + and event.type() == QKeyEvent.Type.KeyPress: + if event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down): + self._move_selection(1 if event.key() == Qt.Key.Key_Down else -1) + return True + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + self._run_current() + return True + return super().eventFilter(watched, event) + + def set_commands(self, commands: list[CommandEntry]) -> None: + """ + 替換候選清單並重新套用目前的查詢字串 + Replace the candidate list and re-apply the current query. + + 供背景索引完成後把結果送進已開啟的面板。 + Lets a background indexer feed results into an already open picker. + + :param commands: 新的候選清單 / The new candidate list + """ + self._commands = commands + self._refresh_results(self.search_input.text()) + + def _move_selection(self, step: int) -> None: + """移動清單選取位置 / Move the selection within the result list.""" + count = self.result_list.count() + if count == 0: + return + row = (self.result_list.currentRow() + step) % count + self.result_list.setCurrentRow(row) + + def _refresh_results(self, query: str) -> None: + """依查詢字串重新產生清單 / Rebuild the result list for the query.""" + self._visible_commands = rank_commands(query, self._commands, RESULT_LIMIT) + self.result_list.clear() + for command in self._visible_commands: + label = command.path or command.title + if command.shortcut: + label = f"{label}\t({command.shortcut})" + self.result_list.addItem(QListWidgetItem(label)) + if self.result_list.count() > 0: + self.result_list.setCurrentRow(0) + + def _run_item(self, item: QListWidgetItem) -> None: + """執行清單項目對應的指令 / Run the command behind a list row.""" + self._run_command_at(self.result_list.row(item)) + + def _run_current(self) -> None: + """執行目前選取的指令 / Run the currently selected command.""" + self._run_command_at(self.result_list.currentRow()) + + def _run_command_at(self, row: int) -> None: + """ + 關閉面板後再觸發指令 + Close the palette first, then trigger the command. + + 先關閉面板,指令自己彈出的對話框才不會被這個 modal 面板擋住。 + Closing first keeps a dialog opened by the command from being blocked + by this modal palette. + """ + if row < 0 or row >= len(self._visible_commands): + return + command = self._visible_commands[row] + runner = _payload_runner(command.payload) + jeditor_logger.info(f"command_palette_dialog.py run command: {command.path}") + self.accept() + if runner is not None: + QTimer.singleShot(0, runner) + + +def _payload_runner(payload) -> object | None: + """ + 把項目的 payload 轉成可呼叫的觸發函式 + Turn an entry payload into a callable trigger. + + 支援 ``QAction``(選單指令)與一般的可呼叫物件(例如開檔函式)。 + Supports a ``QAction`` (menu command) and any plain callable (e.g. an opener). + + :param payload: 項目攜帶的物件 / The object carried by the entry + :return: 可呼叫的觸發函式,無法觸發時回傳 ``None`` + / A callable trigger, or ``None`` when the payload cannot be run + """ + if payload is None: + return None + if callable(payload): + return payload + trigger = getattr(payload, "trigger", None) + return trigger if callable(trigger) else None + + +def open_command_palette(main_window: EditorMain) -> CommandPaletteDialog | None: + """ + 建立並顯示指令面板 + Build and show the command palette. + + :param main_window: 主編輯器視窗 / The main editor window + :return: 已顯示的對話框,沒有任何指令時回傳 ``None`` + / The shown dialog, or ``None`` when no command was collected + """ + jeditor_logger.info("command_palette_dialog.py open_command_palette") + commands = collect_menu_commands(getattr(main_window, "menu", None)) + if not commands: + return None + dialog = CommandPaletteDialog(main_window, commands) + dialog.show() + return dialog diff --git a/je_editor/pyside_ui/main_ui/command_palette/go_to_symbol_dialog.py b/je_editor/pyside_ui/main_ui/command_palette/go_to_symbol_dialog.py new file mode 100644 index 00000000..1b1f7cb4 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/command_palette/go_to_symbol_dialog.py @@ -0,0 +1,108 @@ +""" +前往符號:在目前檔案中以模糊搜尋跳到類別、函式或變數 +Go to symbol: fuzzy-search the current file and jump to a class, function or variable. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from je_editor.pyside_ui.main_ui.command_palette.command_palette_dialog import ( + CommandPaletteDialog +) +from je_editor.utils.command_palette.fuzzy_matcher import CommandEntry +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper +from je_editor.utils.symbols.python_symbols import SymbolInfo, extract_python_symbols + +if TYPE_CHECKING: + from je_editor.pyside_ui.main_ui.main_editor import EditorMain + + +def build_symbol_entries(symbols: list[SymbolInfo], code_edit=None) -> list[CommandEntry]: + """ + 把符號轉成可放進模糊搜尋清單的項目 + Turn symbols into entries the fuzzy picker can rank. + + :param symbols: 萃取出的符號 / The extracted symbols + :param code_edit: 要跳轉的編輯器元件,``None`` 時項目沒有觸發動作 + / The editor to jump in; ``None`` leaves entries without a trigger + :return: 對應的項目清單 / The matching entries + """ + return [ + CommandEntry( + title=symbol.name, + path=f"{symbol.kind} {symbol.qualified_name} :{symbol.line}", + shortcut="", + payload=make_symbol_jumper(code_edit, symbol.line), + ) + for symbol in symbols + ] + + +def make_symbol_jumper(code_edit, line: int): + """ + 建立跳到指定行的觸發函式 + Build a trigger that jumps the editor to a line. + + 閉包只捕捉編輯器與行號,不捕捉對話框;對話框設定了 ``WA_DeleteOnClose``, + 觸發時它已經被銷毀。 + The closure captures only the editor and line, never the dialog: it sets + ``WA_DeleteOnClose`` and is already gone when the trigger fires. + + :param code_edit: 要跳轉的編輯器元件 / The editor widget to move + :param line: 1 起算的行號 / The 1-based line number + :return: 可直接呼叫的觸發函式 / A callable trigger + """ + + def jump() -> None: + if code_edit is not None and hasattr(code_edit, "jump_to_line"): + code_edit.jump_to_line(line) + + return jump + + +def current_code_edit(main_window: EditorMain): + """ + 取得目前分頁的程式碼編輯器 + Return the code editor of the current tab. + + :param main_window: 主編輯器視窗 / The main editor window + :return: 程式碼編輯器,目前分頁不是編輯器時回傳 ``None`` + / The code editor, or ``None`` when the current tab is not an editor + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + widget = main_window.tab_widget.currentWidget() + if isinstance(widget, EditorWidget): + return widget.code_edit + return None + + +def open_go_to_symbol(main_window: EditorMain) -> CommandPaletteDialog | None: + """ + 建立並顯示前往符號面板 + Build and show the go-to-symbol picker. + + 目前分頁不是程式碼編輯器,或檔案沒有可用符號時不會開啟面板。 + The picker stays closed when the current tab is not a code editor or when the + file yields no symbols. + + :param main_window: 主編輯器視窗 / The main editor window + :return: 已顯示的對話框,沒有符號時回傳 ``None`` + / The shown dialog, or ``None`` when there is nothing to show + """ + code_edit = current_code_edit(main_window) + if code_edit is None: + return None + symbols = extract_python_symbols(code_edit.toPlainText()) + jeditor_logger.info(f"go_to_symbol_dialog.py found {len(symbols)} symbols") + if not symbols: + return None + word = language_wrapper.language_word_dict + dialog = CommandPaletteDialog( + main_window, + build_symbol_entries(symbols, code_edit), + title=word.get("go_to_symbol_title"), + placeholder=word.get("go_to_symbol_placeholder"), + ) + dialog.show() + return dialog diff --git a/je_editor/pyside_ui/main_ui/command_palette/menu_command_collector.py b/je_editor/pyside_ui/main_ui/command_palette/menu_command_collector.py new file mode 100644 index 00000000..bbfd75a6 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/command_palette/menu_command_collector.py @@ -0,0 +1,101 @@ +""" +從選單列蒐集可執行指令,提供給指令面板使用 +Collect runnable commands from the menu bar for the command palette. +""" +from __future__ import annotations + +from PySide6.QtWidgets import QMenu, QMenuBar + +from je_editor.utils.command_palette.fuzzy_matcher import CommandEntry +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 選單巢狀深度上限,避免自我參照的選單造成無限遞迴 +# Depth cap so a self-referencing menu cannot recurse forever +MAX_MENU_DEPTH = 8 +# 蒐集指令的數量上限,避免字型選單之類的大型選單拖慢面板 +# Cap on collected commands so huge menus (e.g. the font list) stay responsive +MAX_COMMANDS = 4000 +# 選單路徑的分隔字串 / Separator used when building the menu path +PATH_SEPARATOR = " > " + + +def clean_action_text(text: str) -> str: + """ + 移除 Qt 助記符號 ``&`` 並修剪空白 + Strip Qt mnemonic ``&`` markers and surrounding whitespace. + + :param text: 原始的動作文字 / The raw action text + :return: 可直接顯示的文字 / Text ready for display + """ + return text.replace("&&", "\0").replace("&", "").replace("\0", "&").strip() + + +def collect_menu_commands(menu_bar: QMenuBar | None) -> list[CommandEntry]: + """ + 走訪整個選單列,蒐集所有可觸發的動作 + Walk the whole menu bar and collect every triggerable action. + + 子選單本身不會成為指令,只會成為子項目路徑的前綴。 + Submenus never become commands themselves; they only prefix child paths. + + :param menu_bar: 要走訪的選單列,``None`` 時回傳空清單 + / The menu bar to walk; ``None`` yields an empty list + :return: 蒐集到的指令清單 / The collected commands + """ + if menu_bar is None: + return [] + commands: list[CommandEntry] = [] + seen_menus: set[int] = set() + for action in menu_bar.actions(): + submenu = action.menu() + if submenu is None: + _append_action(commands, action, "") + continue + _collect_from_menu(submenu, clean_action_text(action.text()), commands, seen_menus, 1) + jeditor_logger.info(f"menu_command_collector.py collect_menu_commands count: {len(commands)}") + return commands + + +def _collect_from_menu( + menu: QMenu, prefix: str, commands: list[CommandEntry], + seen_menus: set[int], depth: int) -> None: + """遞迴蒐集單一選單下的動作 / Recursively collect actions under one menu.""" + if depth > MAX_MENU_DEPTH or len(commands) >= MAX_COMMANDS: + return + # 同一個 QMenu 可能被掛在多個位置;只走訪一次避免重複與循環 + # The same QMenu can be attached in several places; visit it once only + menu_id = id(menu) + if menu_id in seen_menus: + return + seen_menus.add(menu_id) + + for action in menu.actions(): + if action.isSeparator(): + continue + title = clean_action_text(action.text()) + if not title: + continue + submenu = action.menu() + if submenu is not None: + _collect_from_menu( + submenu, f"{prefix}{PATH_SEPARATOR}{title}" if prefix else title, + commands, seen_menus, depth + 1) + continue + _append_action(commands, action, prefix) + if len(commands) >= MAX_COMMANDS: + return + + +def _append_action(commands: list[CommandEntry], action, prefix: str) -> None: + """把單一動作轉成指令項目 / Turn a single action into a command entry.""" + title = clean_action_text(action.text()) + if not title or not action.isEnabled(): + return + commands.append( + CommandEntry( + title=title, + path=f"{prefix}{PATH_SEPARATOR}{title}" if prefix else title, + shortcut=action.shortcut().toString(), + payload=action, + ) + ) 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 new file mode 100644 index 00000000..15417b76 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py @@ -0,0 +1,201 @@ +""" +快速開啟:以模糊搜尋在專案中找到並開啟檔案 +Quick open: fuzzy-search the project tree and open a file. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from PySide6.QtCore import QThread, Signal + +from je_editor.pyside_ui.main_ui.command_palette.command_palette_dialog import ( + CommandPaletteDialog +) +from je_editor.utils.command_palette.fuzzy_matcher import CommandEntry +from je_editor.utils.file_scan.file_indexer import build_file_entries, index_project_files +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + +if TYPE_CHECKING: + from je_editor.pyside_ui.main_ui.main_editor import EditorMain + +# 切換成指令模式的前綴,與 VS Code 的 Ctrl+P 行為一致 +# Prefix that switches to command mode, matching VS Code's Ctrl+P behaviour +COMMAND_MODE_PREFIX = ">" + + +class FileIndexThread(QThread): + """ + 背景索引專案檔案,避免大型專案在走訪時卡住 UI + Index project files in the background so a large tree never blocks the UI. + """ + + indexed = Signal(list) # list[str] of project-relative paths + + def __init__(self, root: str) -> None: + """ + :param root: 要索引的專案根目錄 / The project root to index + """ + super().__init__() + self._root = root + self._stop_requested = False + + def stop(self) -> None: + """要求提前結束索引 / Ask the walk to finish early.""" + self._stop_requested = True + + def run(self) -> None: + """執行索引並送出結果 / Run the index and emit the result.""" + try: + paths = index_project_files(self._root, should_stop=lambda: self._stop_requested) + except OSError as error: + jeditor_logger.error(f"quick_open_dialog.py index failed: {error!r}") + paths = [] + self.indexed.emit(paths) + + +class QuickOpenDialog(CommandPaletteDialog): + """ + 快速開啟對話框 + The quick open dialog. + + 索引在背景進行,完成後結果會直接補進清單;輸入 ``>`` 可切換到指令模式。 + Indexing runs in the background and streams into the list when done; typing + ``>`` switches the picker into command mode. + """ + + # 類別層級預設值:基底類別的 __init__ 會先呼叫 _refresh_results, + # 那時子類別的實例屬性還不存在。使用不可變的空 tuple 避免共用可變狀態。 + # Class-level defaults: the base __init__ calls _refresh_results before the + # subclass attributes exist. Immutable empty tuples avoid shared mutable state. + _in_command_mode = False + _file_entries: tuple = () + _command_entries: tuple = () + + def __init__( + self, parent, root: str, command_entries: list[CommandEntry], + main_window=None) -> None: + """ + :param parent: Qt 父視窗 / The Qt parent widget + :param root: 專案根目錄 / The project root being indexed + :param command_entries: 指令模式使用的選單指令 / Menu commands for command mode + :param main_window: 用來開檔的主視窗,``None`` 時沿用 ``parent`` + / The window used to open files; ``None`` reuses ``parent`` + """ + word = language_wrapper.language_word_dict + super().__init__( + parent, [], + title=word.get("quick_open_title"), + placeholder=word.get("quick_open_placeholder"), + ) + self._root = root + self._main_window = main_window if main_window is not None else parent + self._file_entries = [] + self._command_entries = command_entries + self._in_command_mode = False + + self._index_thread = FileIndexThread(root) + self._index_thread.indexed.connect(self._on_indexed) + self._index_thread.start() + + def _on_indexed(self, relative_paths: list) -> None: + """索引完成後建立項目並套用 / Build entries once indexing finishes.""" + jeditor_logger.info(f"quick_open_dialog.py indexed {len(relative_paths)} files") + entries = build_file_entries(relative_paths) + for entry in entries: + entry.payload = make_file_opener( + self._main_window, Path(self._root) / entry.path) + self._file_entries = entries + if not self._in_command_mode: + self.set_commands(entries) + + def _refresh_results(self, query: str) -> None: + """ + 依查詢字串切換檔案 / 指令模式後再過濾 + Switch between file and command mode, then filter. + """ + wants_command_mode = query.startswith(COMMAND_MODE_PREFIX) + if wants_command_mode != self._in_command_mode: + self._in_command_mode = wants_command_mode + self._commands = list( + self._command_entries if wants_command_mode else self._file_entries) + if wants_command_mode: + query = query[len(COMMAND_MODE_PREFIX):] + super()._refresh_results(query) + + def closeEvent(self, event) -> None: + """ + 關閉前停掉索引執行緒 + Stop the index thread before the dialog goes away. + + 先擋掉信號再等待,避免執行緒在物件已銷毀後才送出結果。 + Signals are blocked before waiting so a late result cannot reach a dead + widget, and waiting keeps the QThread from being destroyed while running. + """ + thread = getattr(self, "_index_thread", None) + if thread is not None and thread.isRunning(): + thread.blockSignals(True) + thread.stop() + thread.wait() + super().closeEvent(event) + + +def make_file_opener(main_window, full_path: Path): + """ + 建立開啟指定檔案的觸發函式 + Build a trigger that opens one file in a new editor tab. + + 刻意在模組層級建立閉包,只捕捉主視窗與路徑而不捕捉對話框; + 對話框設定了 ``WA_DeleteOnClose``,觸發時它已經被銷毀。 + The closure lives at module level and captures only the window and path, never + the dialog: the dialog sets ``WA_DeleteOnClose`` and is already gone when the + trigger fires. + + :param main_window: 用來開檔的主視窗 / The window used to open the file + :param full_path: 要開啟的完整路徑 / The absolute path to open + :return: 可直接呼叫的觸發函式 / A callable trigger + """ + + def open_file() -> None: + if main_window is not None and hasattr(main_window, "go_to_new_tab"): + main_window.go_to_new_tab(full_path) + + return open_file + + +def resolve_project_root(main_window: EditorMain) -> str: + """ + 取得要索引的專案根目錄 + Resolve the project root that should be indexed. + + 以主視窗的工作目錄為主,未設定時退回目前的工作目錄。 + Prefers the main window's working directory, falling back to the process cwd. + + :param main_window: 主編輯器視窗 / The main editor window + :return: 專案根目錄路徑 / The project root path + """ + working_dir = getattr(main_window, "working_dir", None) + if working_dir and Path(working_dir).is_dir(): + return str(working_dir) + return os.getcwd() + + +def open_quick_open(main_window: EditorMain) -> QuickOpenDialog: + """ + 建立並顯示快速開啟面板 + Build and show the quick open picker. + + :param main_window: 主編輯器視窗 / The main editor window + :return: 已顯示的對話框 / The shown dialog + """ + from je_editor.pyside_ui.main_ui.command_palette.menu_command_collector import ( + collect_menu_commands + ) + root = resolve_project_root(main_window) + jeditor_logger.info(f"quick_open_dialog.py open_quick_open root: {root}") + dialog = QuickOpenDialog( + main_window, root, collect_menu_commands(getattr(main_window, "menu", None))) + dialog.show() + return dialog diff --git a/je_editor/pyside_ui/main_ui/console_widget/console_gui.py b/je_editor/pyside_ui/main_ui/console_widget/console_gui.py index ca9e7c25..816c34a7 100644 --- a/je_editor/pyside_ui/main_ui/console_widget/console_gui.py +++ b/je_editor/pyside_ui/main_ui/console_widget/console_gui.py @@ -168,3 +168,11 @@ def on_finished(self, code: int, status: int) -> None: "#888" ) self.proc.system.emit(self.language_word_dict_get("dynamic_console_ready")) + + def closeEvent(self, event: QEvent) -> None: + """ + 關閉前結束 shell,避免留下孤兒子程序 + Shut the shell down on close so no orphaned child process is left behind. + """ + self.proc.shutdown() + super().closeEvent(event) diff --git a/je_editor/pyside_ui/main_ui/console_widget/qprocess_adapter.py b/je_editor/pyside_ui/main_ui/console_widget/qprocess_adapter.py index fd097c95..9930ee52 100644 --- a/je_editor/pyside_ui/main_ui/console_widget/qprocess_adapter.py +++ b/je_editor/pyside_ui/main_ui/console_widget/qprocess_adapter.py @@ -2,6 +2,11 @@ from PySide6.QtCore import QObject, QProcess, Signal, QTimer +# Windows 啟動 shell 後切換 UTF-8 code page 的延遲 / Delay before switching to UTF-8 on Windows +UTF8_CODEPAGE_DELAY_MS = 500 +# terminate 之後改用 kill 的等待時間 / How long to wait after terminate before killing +KILL_DELAY_MS = 1000 + class ConsoleProcessAdapter(QObject): """ @@ -42,8 +47,18 @@ def start_shell(self, shell: str = "auto") -> None: self.proc.start(program, args) # 啟動子程序 / Start process # Windows 特殊處理:設定 UTF-8 編碼 / Windows-specific: set UTF-8 encoding + # 以 self 當作 context 物件,這個介面卡被銷毀時 Qt 會自動丟棄尚未觸發的呼叫; + # 沒有 context 的 singleShot 會在物件死後才觸發,存取到已刪除的 QProcess。 + # Passing self as the context object lets Qt drop the pending call when this + # adapter dies; a context-less singleShot fires after death and touches a + # already-deleted QProcess. if os.name == "nt": - QTimer.singleShot(500, lambda: self.send_command("chcp 65001")) + QTimer.singleShot(UTF8_CODEPAGE_DELAY_MS, self, self._enable_utf8_codepage) + + # 切換到 UTF-8 code page / Switch the shell to the UTF-8 code page + def _enable_utf8_codepage(self) -> None: + if self.is_running(): + self.send_command("chcp 65001") # 傳送指令到 shell / Send command to shell def send_command(self, cmd: str) -> None: @@ -58,7 +73,28 @@ def stop(self) -> None: return self.proc.terminate() # 嘗試正常結束 / Try graceful termination # 如果 1 秒後仍在執行,強制 kill / Force kill if still running after 1s - QTimer.singleShot(1000, lambda: self.is_running() and self.proc.kill()) + QTimer.singleShot(KILL_DELAY_MS, self, self._kill_if_still_running) + + # 強制結束仍在執行的 shell / Force kill a shell that ignored terminate + def _kill_if_still_running(self) -> None: + if self.is_running(): + self.proc.kill() + + def shutdown(self, wait_ms: int = KILL_DELAY_MS) -> None: + """ + 同步關閉 shell,確保 QProcess 不會在子程序仍執行時被銷毀 + Shut the shell down synchronously so the QProcess is never destroyed + while its child process is still running. + + :param wait_ms: 每個階段的等待毫秒數 / Milliseconds waited at each stage + """ + if not self.is_running(): + return + self.proc.terminate() + if self.proc.waitForFinished(wait_ms): + return + self.proc.kill() + self.proc.waitForFinished(wait_ms) # 判斷是否正在執行 / Check if process is running def is_running(self) -> bool: 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 fa5db569..4f3461fa 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -250,6 +250,12 @@ def open_an_file(self, path: Path) -> bool: file, file_content = result self.code_edit.setPlainText(file_content) + # 依內容偵測縮排寬度;失敗不可影響開檔 / Detect indent; must not break opening + try: + self.code_edit.apply_detected_indentation() + except Exception as detect_error: + jeditor_logger.warning(f"Indent detection failed: {detect_error}") + # 更新目前檔案資訊 / Update current file info self.current_file = file self.code_edit.current_file = file @@ -439,6 +445,10 @@ def close(self) -> bool: self.code_save_thread.still_run = False self.code_save_thread = None + # 關閉內嵌終端機的互動式 shell / Shut down the embedded console's interactive shell + if self.console_widget is not None: + self.console_widget.close() + # 停止所有正在執行的子程序 / Stop all running subprocesses for mgr in (self.exec_program, self.exec_shell, self.exec_python_debugger): if mgr is not None: diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 614c19a4..1431eed2 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -37,6 +37,11 @@ ) 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.open_files_session import ( + SESSION_SETTING_KEY, + collect_open_files, + restorable_files, +) 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.redirect_manager.redirect_manager_class import redirect_manager_instance @@ -54,6 +59,10 @@ class EditorMain(QMainWindow, QtStyleTools): 繼承 QMainWindow 與 QtStyleTools """ + # 類別層級預設值:擴充模式的子類別(如 PyBreeze)也一定讀得到 + # Class-level default so subclasses in extend mode (e.g. PyBreeze) always see it + _session_restored = False + def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, extend: bool = False) -> None: # 初始化時記錄 log # Log initialization @@ -136,6 +145,7 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, self.tab_widget.tabCloseRequested.connect(self.close_tab) self.tab_widget.currentChanged.connect(self._on_tab_changed) self._prev_editor_widget = None # 追蹤前一個分頁 / Track previous tab for signal disconnect + self._session_restored = False # 分頁只還原一次 / Restore tabs only once per window # 計時器會在後面初始化並連接 redirect # Timer will be initialized later with redirect connection @@ -346,11 +356,58 @@ def startup_setting(self) -> None: if not last_file_loaded: last_file_loaded = self._try_restore_last_file(widget) + self._restore_open_files_session() + app = QApplication.instance() if app is not None: self.apply_stylesheet(app, user_setting_dict.get("ui_style", "dark_amber.xml")) update_actually_color_dict() + def _open_file_paths(self) -> list[str]: + """取得所有分頁目前開啟的檔案 / Every tab's currently open file.""" + return [ + self.tab_widget.widget(index).current_file + for index in range(self.tab_widget.count()) + if isinstance(self.tab_widget.widget(index), EditorWidget) + ] + + def _restore_open_files_session(self) -> None: + """ + 還原上次關閉時開啟的分頁 + Reopen the tabs that were open at the last shutdown. + + 只在每個視窗執行一次:``startup_setting`` 也會在開啟資料夾時被呼叫, + 若不設旗標,使用者關掉的分頁會在開啟資料夾後又冒出來。 + Runs once per window: ``startup_setting`` is also called when opening a + folder, and without this flag a tab the user closed would come back. + + 整段以 try/except 包住:損毀或被手動編輯的設定檔絕不能擋住編輯器啟動。 + The whole step is guarded: a corrupt or hand-edited settings file must + never stop the editor from starting. + """ + if self._session_restored or not user_setting_dict.get("restore_session", True): + return + self._session_restored = True + try: + to_restore = restorable_files( + user_setting_dict.get(SESSION_SETTING_KEY), + already_open=[path for path in self._open_file_paths() if path], + ) + for file_path in to_restore: + self.go_to_new_tab(Path(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. + """ + try: + user_setting_dict[SESSION_SETTING_KEY] = collect_open_files(self._open_file_paths()) + except Exception as error: + jeditor_logger.warning(f"Saving the open-file session failed: {error}") + def go_to_new_tab(self, file_path: Path) -> None: """ 開啟新分頁並載入檔案 @@ -442,6 +499,7 @@ def _periodic_save_settings(self) -> None: Periodically save user settings to prevent data loss """ try: + self._save_open_files_session() write_user_setting() write_user_color_setting() except Exception as e: @@ -455,6 +513,9 @@ def closeEvent(self, event: QCloseEvent) -> None: jeditor_logger.info("EditorMain closeEvent") if hasattr(self, '_settings_save_timer'): self._settings_save_timer.stop() + # 必須在關閉分頁前記錄,否則分頁已消失就抓不到開啟中的檔案 + # Must run before the tabs close, or the open files are already gone + self._save_open_files_session() # 關閉所有編輯器分頁(停止自動儲存和檔案監控) # Close all editor tabs (stop auto-save and file watchers) for i in range(self.tab_widget.count() - 1, -1, -1): 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 d41fdca3..83ca0983 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 @@ -19,6 +19,8 @@ from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock 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.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 from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper # 多語系支援 / Multi-language wrapper @@ -128,6 +130,22 @@ def set_dock_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.dock_git_menu.addAction(ui_we_want_to_set.dock_menu.new_code_diff_viewer) + # === TODO Panel Dock === + ui_we_want_to_set.dock_menu.new_todo_panel = QAction( + language_wrapper.language_word_dict.get("tab_menu_todo_panel_tab_name")) + ui_we_want_to_set.dock_menu.new_todo_panel.triggered.connect( + lambda: add_dock_widget(ui_we_want_to_set, "todo_panel") + ) + ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_todo_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")) + ui_we_want_to_set.dock_menu.new_outline_panel.triggered.connect( + lambda: add_dock_widget(ui_we_want_to_set, "outline_panel") + ) + ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_outline_panel) + def _make_editor_dock(ui_we_want_to_set: EditorMain, dock_widget: "DestroyDock") -> bool: """建立 Editor Dock;取消選檔則回傳 False / Build editor dock, False if user cancels.""" @@ -158,6 +176,10 @@ def _dock_builders(ui_we_want_to_set: EditorMain) -> dict: "variable_inspector": ("tab_menu_variable_inspector_tab_name", VariableInspector), "console_widget": ("tab_menu_console_widget_tab_name", ConsoleWidget), "code_diff_viewer": ("tab_code_diff_viewer_tab_name", DiffViewerWidget), + "todo_panel": ("tab_menu_todo_panel_tab_name", + lambda: TodoPanelWidget(ui_we_want_to_set)), + "outline_panel": ("tab_menu_outline_panel_tab_name", + lambda: OutlinePanelWidget(ui_we_want_to_set)), } diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_tools_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_tools_menu.py index 0f162105..267dc0e5 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_tools_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_tools_menu.py @@ -8,6 +8,8 @@ from je_editor.pyside_ui.code.variable_inspector.inspector_gui import VariableInspector from je_editor.pyside_ui.main_ui.ai_widget.chat_ui import ChatUI 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.todo_panel.todo_panel_widget import TodoPanelWidget from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper @@ -57,6 +59,26 @@ def set_tab_tools_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.tab_menu.tools_menu.addAction(ui_we_want_to_set.tab_menu.tools_menu.add_chat_ui_action) + # === 待辦事項分頁 === + # === TODO Panel Tab === + ui_we_want_to_set.tab_menu.tools_menu.add_todo_panel_action = QAction( + language_wrapper.language_word_dict.get("tab_menu_todo_panel_tab_name")) + ui_we_want_to_set.tab_menu.tools_menu.add_todo_panel_action.triggered.connect( + lambda: add_todo_panel_tab(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.tools_menu.addAction( + ui_we_want_to_set.tab_menu.tools_menu.add_todo_panel_action) + + # === 大綱分頁 === + # === Outline Panel Tab === + ui_we_want_to_set.tab_menu.tools_menu.add_outline_panel_action = QAction( + language_wrapper.language_word_dict.get("tab_menu_outline_panel_tab_name")) + ui_we_want_to_set.tab_menu.tools_menu.add_outline_panel_action.triggered.connect( + lambda: add_outline_panel_tab(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.tools_menu.addAction( + ui_we_want_to_set.tab_menu.tools_menu.add_outline_panel_action) + def add_ipython_tab(ui_we_want_to_set: EditorMain) -> None: # 紀錄日誌:新增 IPython 分頁 @@ -105,3 +127,29 @@ def add_chat_ui_tab(ui_we_want_to_set: EditorMain) -> None: f"{language_wrapper.language_word_dict.get('tab_menu_chat_ui_tab_name')} " f"{ui_we_want_to_set.tab_widget.count()}" ) + + +def add_todo_panel_tab(ui_we_want_to_set: EditorMain) -> None: + # 紀錄日誌:新增待辦事項分頁 + # Log: add a TODO panel tab + jeditor_logger.info(f"build_tab_menu.py add todo panel tab ui_we_want_to_set: {ui_we_want_to_set}") + # 在主編輯器中新增待辦事項分頁 + # Add a TODO panel tab into the main editor + ui_we_want_to_set.tab_widget.addTab( + TodoPanelWidget(ui_we_want_to_set), # 建立待辦事項元件 / Create TODO panel widget + f"{language_wrapper.language_word_dict.get('tab_menu_todo_panel_tab_name')} " + f"{ui_we_want_to_set.tab_widget.count()}" + ) + + +def add_outline_panel_tab(ui_we_want_to_set: EditorMain) -> None: + # 紀錄日誌:新增大綱分頁 + # Log: add an outline panel tab + jeditor_logger.info(f"build_tab_menu.py add outline panel tab ui_we_want_to_set: {ui_we_want_to_set}") + # 在主編輯器中新增大綱分頁 + # Add an outline panel tab into the main editor + ui_we_want_to_set.tab_widget.addTab( + OutlinePanelWidget(ui_we_want_to_set), # 建立大綱元件 / Create outline panel widget + f"{language_wrapper.language_word_dict.get('tab_menu_outline_panel_tab_name')} " + f"{ui_we_want_to_set.tab_widget.count()}" + ) diff --git a/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py b/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py index f8fb99b7..60d33bf2 100644 --- a/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/text_menu/build_text_menu.py @@ -3,11 +3,12 @@ from typing import TYPE_CHECKING from PySide6.QtGui import QAction -from PySide6.QtWidgets import QPlainTextEdit +from PySide6.QtWidgets import QMessageBox, QPlainTextEdit from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.text_stats.text_statistics import TextStatistics, text_statistics # 啟用未來註解功能,允許型別提示使用字串前向參照 # Enable future annotations, allowing forward references in type hints @@ -94,6 +95,233 @@ def set_text_menu(ui_we_want_to_set: EditorMain) -> None: lambda checked=False, s=size: set_indent_size(ui_we_want_to_set, s)) indent_menu.addAction(indent_action) + ui_we_want_to_set.text_menu.addSeparator() + + # === 移除行尾空白 (Trim Trailing Whitespace) === + trim_action = QAction( + language_wrapper.language_word_dict.get("text_menu_trim_trailing_whitespace"), + ui_we_want_to_set) + trim_action.triggered.connect(lambda: trim_trailing_whitespace(ui_we_want_to_set)) + ui_we_want_to_set.text_menu.addAction(trim_action) + + # === 縮排轉換 (Convert Indentation) === + tabs_to_spaces_action = QAction( + language_wrapper.language_word_dict.get("text_menu_indent_tabs_to_spaces"), + ui_we_want_to_set) + tabs_to_spaces_action.triggered.connect( + lambda: _convert_indentation(ui_we_want_to_set, to_spaces=True)) + ui_we_want_to_set.text_menu.addAction(tabs_to_spaces_action) + + spaces_to_tabs_action = QAction( + language_wrapper.language_word_dict.get("text_menu_indent_spaces_to_tabs"), + ui_we_want_to_set) + spaces_to_tabs_action.triggered.connect( + lambda: _convert_indentation(ui_we_want_to_set, to_spaces=False)) + ui_we_want_to_set.text_menu.addAction(spaces_to_tabs_action) + + ui_we_want_to_set.text_menu.addSeparator() + + # === 選取行操作 (Selected-line operations) === + remove_duplicates_action = QAction( + language_wrapper.language_word_dict.get("text_menu_remove_duplicate_lines"), + ui_we_want_to_set) + remove_duplicates_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "remove_duplicate_selected_lines")) + ui_we_want_to_set.text_menu.addAction(remove_duplicates_action) + + reverse_lines_action = QAction( + language_wrapper.language_word_dict.get("text_menu_reverse_lines"), + ui_we_want_to_set) + reverse_lines_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "reverse_selected_lines")) + ui_we_want_to_set.text_menu.addAction(reverse_lines_action) + + natural_sort_action = QAction( + language_wrapper.language_word_dict.get("text_menu_natural_sort"), + ui_we_want_to_set) + natural_sort_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "natural_sort_selected_lines")) + ui_we_want_to_set.text_menu.addAction(natural_sort_action) + + remove_blank_action = QAction( + language_wrapper.language_word_dict.get("text_menu_remove_blank_lines"), + ui_we_want_to_set) + remove_blank_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "remove_blank_selected_lines")) + ui_we_want_to_set.text_menu.addAction(remove_blank_action) + + align_action = QAction( + language_wrapper.language_word_dict.get("text_menu_align_by_delimiter"), + ui_we_want_to_set) + align_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "align_selected_lines")) + ui_we_want_to_set.text_menu.addAction(align_action) + + ui_we_want_to_set.text_menu.addSeparator() + + # === 大小寫轉換 (Case conversion) === + uppercase_action = QAction( + language_wrapper.language_word_dict.get("text_menu_uppercase"), ui_we_want_to_set) + uppercase_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "uppercase_selection")) + ui_we_want_to_set.text_menu.addAction(uppercase_action) + + lowercase_action = QAction( + language_wrapper.language_word_dict.get("text_menu_lowercase"), ui_we_want_to_set) + lowercase_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "lowercase_selection")) + ui_we_want_to_set.text_menu.addAction(lowercase_action) + + swapcase_action = QAction( + language_wrapper.language_word_dict.get("text_menu_swapcase"), ui_we_want_to_set) + swapcase_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "swapcase_selection")) + ui_we_want_to_set.text_menu.addAction(swapcase_action) + + titlecase_action = QAction( + language_wrapper.language_word_dict.get("text_menu_titlecase"), ui_we_want_to_set) + titlecase_action.triggered.connect( + lambda: _run_on_editor(ui_we_want_to_set, "titlecase_selection")) + ui_we_want_to_set.text_menu.addAction(titlecase_action) + + # === 命名風格子選單 (Naming-style submenu) === + naming_menu = ui_we_want_to_set.text_menu.addMenu( + language_wrapper.language_word_dict.get("text_menu_naming_menu")) + for label_key, method_name in ( + ("text_menu_snake_case", "to_snake_case_selection"), + ("text_menu_camel_case", "to_camel_case_selection"), + ("text_menu_pascal_case", "to_pascal_case_selection"), + ("text_menu_kebab_case", "to_kebab_case_selection"), + ): + action = QAction(language_wrapper.language_word_dict.get(label_key), naming_menu) + action.triggered.connect( + lambda checked=False, name=method_name: _run_on_editor(ui_we_want_to_set, name)) + naming_menu.addAction(action) + + # === 數字進位子選單 (Number-base submenu) === + base_menu = ui_we_want_to_set.text_menu.addMenu( + language_wrapper.language_word_dict.get("text_menu_number_base_menu")) + for label_key, method_name in ( + ("text_menu_number_hex", "number_to_hex_selection"), + ("text_menu_number_decimal", "number_to_decimal_selection"), + ("text_menu_number_binary", "number_to_binary_selection"), + ): + action = QAction(language_wrapper.language_word_dict.get(label_key), base_menu) + action.triggered.connect( + lambda checked=False, name=method_name: _run_on_editor(ui_we_want_to_set, name)) + base_menu.addAction(action) + + # === 編碼/解碼子選單 (Encode/Decode submenu) === + encode_menu = ui_we_want_to_set.text_menu.addMenu( + language_wrapper.language_word_dict.get("text_menu_encode_decode_menu")) + for label_key, method_name in ( + ("text_menu_base64_encode", "base64_encode_selection"), + ("text_menu_base64_decode", "base64_decode_selection"), + ("text_menu_url_encode", "url_encode_selection"), + ("text_menu_url_decode", "url_decode_selection"), + ("text_menu_html_escape", "html_escape_selection"), + ("text_menu_html_unescape", "html_unescape_selection"), + ("text_menu_json_escape", "json_escape_selection"), + ("text_menu_json_unescape", "json_unescape_selection"), + ): + action = QAction(language_wrapper.language_word_dict.get(label_key), encode_menu) + action.triggered.connect( + lambda checked=False, name=method_name: _run_on_editor(ui_we_want_to_set, name)) + encode_menu.addAction(action) + + ui_we_want_to_set.text_menu.addSeparator() + + # === 文字統計 (Statistics) === + statistics_action = QAction( + language_wrapper.language_word_dict.get("text_menu_statistics"), ui_we_want_to_set) + statistics_action.triggered.connect(lambda: show_text_statistics(ui_we_want_to_set)) + ui_we_want_to_set.text_menu.addAction(statistics_action) + + +def _current_editor(ui_we_want_to_set: EditorMain): + """取得目前分頁的 EditorWidget / Return the current tab's EditorWidget, or None.""" + widget = ui_we_want_to_set.tab_widget.currentWidget() + return widget if isinstance(widget, EditorWidget) else None + + +def _run_on_editor(ui_we_want_to_set: EditorMain, method_name: str) -> None: + """ + 對目前分頁編輯器的 code_edit 呼叫指定方法 + Call a named method on the current editor's code_edit, if there is one. + """ + jeditor_logger.info(f"build_text_menu.py run_on_editor method: {method_name}") + widget = _current_editor(ui_we_want_to_set) + if widget is not None: + getattr(widget.code_edit, method_name)() + + +def format_statistics(stats: TextStatistics, scope_label: str) -> str: + """ + 把統計數據組成可顯示的多行字串 + Format statistics into a displayable multi-line string. + + :param stats: 統計數據 / The statistics + :param scope_label: 範圍描述(整份文件或選取)/ A label for the scope (document or selection) + :return: 可直接顯示的字串 / A ready-to-show string + """ + word = language_wrapper.language_word_dict + return ( + f"{scope_label}\n" + f"{word.get('text_menu_statistics_lines')}: {stats.lines}\n" + f"{word.get('text_menu_statistics_words')}: {stats.words}\n" + f"{word.get('text_menu_statistics_chars')}: {stats.characters}\n" + f"{word.get('text_menu_statistics_chars_no_spaces')}: {stats.characters_no_spaces}" + ) + + +def show_text_statistics(ui_we_want_to_set: EditorMain) -> None: + """ + 顯示目前編輯器(選取或整份文件)的文字統計 + Show text statistics for the current editor (selection, or the whole document). + """ + jeditor_logger.info("build_text_menu.py show_text_statistics") + widget = _current_editor(ui_we_want_to_set) + if widget is None: + return + cursor = widget.code_edit.textCursor() + word = language_wrapper.language_word_dict + if cursor.hasSelection(): + text = cursor.selectedText().replace("
", "\n") + scope_label = word.get("text_menu_statistics_scope_selection") + else: + text = widget.code_edit.toPlainText() + scope_label = word.get("text_menu_statistics_scope_document") + message = format_statistics(text_statistics(text), scope_label) + QMessageBox.information( + ui_we_want_to_set, word.get("text_menu_statistics"), message) + + +def trim_trailing_whitespace(ui_we_want_to_set: EditorMain) -> None: + """ + 對目前分頁的編輯器移除每行行尾空白 + Strip trailing whitespace on the current tab's editor. + """ + jeditor_logger.info("build_text_menu.py trim_trailing_whitespace") + widget = _current_editor(ui_we_want_to_set) + if widget is not None: + widget.code_edit.trim_trailing_whitespace_document() + + +def _convert_indentation(ui_we_want_to_set: EditorMain, to_spaces: bool) -> None: + """ + 轉換目前分頁編輯器的縮排(Tab 與空白互轉) + Convert the current editor's indentation between tabs and spaces. + """ + jeditor_logger.info(f"build_text_menu.py convert_indentation to_spaces: {to_spaces}") + widget = _current_editor(ui_we_want_to_set) + if widget is None: + return + indent_size = user_setting_dict.get("indent_size", 4) + if to_spaces: + widget.code_edit.convert_indentation_to_spaces(indent_size) + else: + widget.code_edit.convert_indentation_to_tabs(indent_size) + def toggle_word_wrap(ui_we_want_to_set: EditorMain, enabled: bool) -> None: """ diff --git a/je_editor/pyside_ui/main_ui/outline_panel/__init__.py b/je_editor/pyside_ui/main_ui/outline_panel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py b/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py new file mode 100644 index 00000000..5011ce72 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/outline_panel/outline_panel_widget.py @@ -0,0 +1,145 @@ +""" +大綱面板:顯示目前檔案的類別、函式與變數結構 +Outline panel: show the class / function / variable structure of the current file. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +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.symbols.outline_tree import OutlineNode, build_outline_tree +from je_editor.utils.symbols.python_symbols import SymbolInfo, extract_python_symbols + +# 樹狀項目中儲存符號行號的資料角色 / Item data role holding a symbol's line number +_LINE_ROLE = Qt.ItemDataRole.UserRole + + +class OutlinePanelWidget(QWidget): + """ + 大綱面板 + The outline panel. + + 重新整理時解析目前分頁的 Python 檔案並建立符號樹,雙擊項目可跳到定義處。 + Refreshing parses the current tab's Python file into a symbol tree; double-click + jumps to the definition. + """ + + def __init__(self, main_window=None) -> None: + """ + :param main_window: 用來找到目前編輯器的主視窗 / The window used to find the editor + """ + super().__init__() + word = language_wrapper.language_word_dict + self._main_window = main_window + + self.refresh_button = QPushButton(word.get("outline_panel_refresh")) + self.refresh_button.clicked.connect(self.refresh) + + self.status_label = QLabel(word.get("outline_panel_ready")) + + self.tree = QTreeWidget() + self.tree.setHeaderLabels([ + word.get("outline_panel_col_symbol"), + word.get("outline_panel_col_kind"), + word.get("outline_panel_col_line"), + ]) + self.tree.itemDoubleClicked.connect(self._on_item_activated) + self.tree.itemActivated.connect(self._on_item_activated) + + controls = QHBoxLayout() + controls.addWidget(self.refresh_button) + controls.addWidget(self.status_label) + controls.addStretch() + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(self.tree) + self.setLayout(layout) + + self.refresh() + + def current_code_edit(self): + """ + 取得目前分頁的程式碼編輯器 + Return the code editor of the current tab, or ``None``. + """ + 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 None + widget = tab_widget.currentWidget() + if isinstance(widget, EditorWidget): + return widget.code_edit + return None + + def refresh(self) -> None: + """ + 重新解析目前檔案並重建大綱 + Re-parse the current file and rebuild the outline. + """ + code_edit = self.current_code_edit() + self.tree.clear() + if code_edit is None: + self.status_label.setText( + language_wrapper.language_word_dict.get("outline_panel_no_editor")) + return + symbols = extract_python_symbols(code_edit.toPlainText()) + roots = build_outline_tree(symbols) + for node in roots: + self.tree.addTopLevelItem(self._build_item(node)) + self.tree.expandAll() + jeditor_logger.info(f"outline_panel_widget.py built outline of {len(symbols)} symbols") + self.status_label.setText( + language_wrapper.language_word_dict.get("outline_panel_found").format(count=len(symbols))) + + def _build_item(self, node: OutlineNode) -> QTreeWidgetItem: + """把大綱節點轉成樹狀項目 / Turn an outline node into a tree item.""" + symbol = node.symbol + item = QTreeWidgetItem([symbol.name, symbol.kind, str(symbol.line)]) + item.setData(0, _LINE_ROLE, symbol.line) + for child in node.children: + item.addChild(self._build_item(child)) + return item + + def _on_item_activated(self, item: QTreeWidgetItem, _column: int = 0) -> None: + """雙擊項目時跳到符號所在行 / Jump to the symbol line on activation.""" + line = item.data(0, _LINE_ROLE) + if line is None: + return + self.jump_to_symbol_line(int(line)) + + def jump_to_symbol_line(self, line: int) -> bool: + """ + 在目前編輯器跳到指定的符號行 + Jump to a symbol line in the current editor. + + :param line: 1 起算的行號 / The 1-based line number + :return: 成功跳轉時為 ``True`` / ``True`` when the jump happened + """ + code_edit = self.current_code_edit() + if code_edit is None or not hasattr(code_edit, "jump_to_line"): + return False + return code_edit.jump_to_line(line) + + +def build_symbol_items(symbols: list[SymbolInfo]) -> list[QTreeWidgetItem]: + """ + 把符號清單建成樹狀項目(供測試與重用) + Build tree items from a symbol list (for reuse and testing). + + :param symbols: 要顯示的符號 / The symbols to display + :return: 根層級的樹狀項目 / The top-level tree items + """ + def _to_item(node: OutlineNode) -> QTreeWidgetItem: + symbol = node.symbol + item = QTreeWidgetItem([symbol.name, symbol.kind, str(symbol.line)]) + item.setData(0, _LINE_ROLE, symbol.line) + for child in node.children: + item.addChild(_to_item(child)) + return item + + return [_to_item(node) for node in build_outline_tree(symbols)] 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 c8acb1b2..d4cb2a44 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 @@ -36,6 +36,9 @@ def update_actually_color_dict() -> None: "normal_output_color": _to_qcolor("normal_output_color", [255, 255, 255]), "error_output_color": _to_qcolor("error_output_color", [255, 0, 0]), "warning_output_color": _to_qcolor("warning_output_color", [204, 204, 0]), + "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]), } ) @@ -48,7 +51,10 @@ def update_actually_color_dict() -> None: "current_line_color": [148, 148, 184], "normal_output_color": [255, 255, 255], "error_output_color": [255, 0, 0], - "warning_output_color": [204, 204, 0] + "warning_output_color": [204, 204, 0], + "bookmark_marker_color": [66, 165, 245], + "fold_marker_color": [120, 120, 120], + "occurrence_highlight_color": [80, 90, 60] } # 實際使用的顏色字典 (以 QColor 表示) diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py index c88f22b4..0e6756c6 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py @@ -29,6 +29,8 @@ "max_line_of_output": 200000, # 最大輸出行數限制 / Max lines of output "recent_files": [], # 最近開啟的檔案清單 / Recent files list "indent_size": 4, # 縮排空格數 / Indent size (spaces) + "open_files": [], # 上次關閉時開啟的分頁 / Tabs open at last shutdown + "restore_session": True, # 啟動時還原分頁 / Restore tabs on startup } diff --git a/je_editor/pyside_ui/main_ui/todo_panel/__init__.py b/je_editor/pyside_ui/main_ui/todo_panel/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..95e22d46 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py @@ -0,0 +1,236 @@ +""" +待辦事項面板:列出專案中所有 TODO / FIXME 註解 +TODO panel: list every TODO / FIXME comment in the project. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtWidgets import ( + QComboBox, QHBoxLayout, QLabel, QPushButton, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget +) + +from je_editor.utils.file_scan.todo_scanner import DEFAULT_TAGS, TodoItem, scan_project_todos +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + +# 「全部標籤」的篩選值 / Filter value meaning "every tag" +ALL_TAGS_FILTER = "*" +# 樹狀清單欄位索引 / Column indexes in the tree +COLUMN_TAG = 0 +COLUMN_MESSAGE = 1 +COLUMN_FILE = 2 +COLUMN_LINE = 3 +# 訊息欄的預設寬度 / Default width of the message column +MESSAGE_COLUMN_WIDTH = 420 + + +class TodoScanThread(QThread): + """ + 背景掃描專案中的 TODO 註解 + Scan the project for TODO comments in the background. + """ + + scanned = Signal(list) # list[TodoItem] + + def __init__(self, root: str) -> None: + """ + :param root: 要掃描的專案根目錄 / The project root to scan + """ + super().__init__() + self._root = root + self._stop_requested = False + + def stop(self) -> None: + """要求提前結束掃描 / Ask the scan to finish early.""" + self._stop_requested = True + + def run(self) -> None: + """執行掃描並送出結果 / Run the scan and emit the result.""" + try: + items = scan_project_todos( + self._root, should_stop=lambda: self._stop_requested) + except OSError as error: + jeditor_logger.error(f"todo_panel_widget.py scan failed: {error!r}") + items = [] + self.scanned.emit(items) + + +class TodoPanelWidget(QWidget): + """ + 待辦事項面板 + The TODO panel. + + 掃描在背景執行緒中進行,雙擊項目會在編輯器中開啟該行。 + Scanning runs in a worker thread; double-clicking a row opens that line. + """ + + def __init__(self, main_window=None, root: str | None = None) -> None: + """ + :param main_window: 用來開檔的主視窗 / The window used to open files + :param root: 專案根目錄,``None`` 時自動判斷 / The project root; ``None`` auto-detects + """ + super().__init__() + word = language_wrapper.language_word_dict + self._main_window = main_window + self._root = root if root is not None else resolve_todo_root(main_window) + self._items: list[TodoItem] = [] + self._scan_thread: TodoScanThread | None = None + + self.refresh_button = QPushButton(word.get("todo_panel_refresh")) + self.refresh_button.clicked.connect(self.start_scan) + + self.tag_filter = QComboBox() + self.tag_filter.addItem(word.get("todo_panel_all_tags"), ALL_TAGS_FILTER) + for tag in DEFAULT_TAGS: + self.tag_filter.addItem(tag, tag) + self.tag_filter.currentIndexChanged.connect(self._render_items) + + self.status_label = QLabel(word.get("todo_panel_ready")) + + self.result_tree = QTreeWidget() + self.result_tree.setColumnCount(4) + self.result_tree.setHeaderLabels([ + word.get("todo_panel_col_tag"), + word.get("todo_panel_col_message"), + word.get("todo_panel_col_file"), + word.get("todo_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.tag_filter) + controls.addWidget(self.status_label) + controls.addStretch() + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(self.result_tree) + self.setLayout(layout) + + self.start_scan() + + def start_scan(self) -> None: + """ + 啟動一次背景掃描 + Kick off one background scan. + + 掃描進行中重複觸發會被忽略,避免覆寫仍在執行的 QThread。 + A re-entrant trigger is ignored so a still-running QThread is never dropped. + """ + if self._scan_thread is not None and self._scan_thread.isRunning(): + return + self.refresh_button.setEnabled(False) + self.status_label.setText(language_wrapper.language_word_dict.get("todo_panel_scanning")) + self._scan_thread = TodoScanThread(self._root) + self._scan_thread.scanned.connect(self._on_scanned) + self._scan_thread.start() + + def visible_items(self) -> list[TodoItem]: + """ + 取得目前篩選條件下的項目 + Return the items matching the current tag filter. + + :return: 篩選後的項目 / The filtered items + """ + selected = self.tag_filter.currentData() + if selected in (None, ALL_TAGS_FILTER): + return list(self._items) + return [item for item in self._items if item.tag == selected] + + def _on_scanned(self, items: list) -> None: + """掃描完成後更新畫面 / Update the view once the scan finishes.""" + jeditor_logger.info(f"todo_panel_widget.py scanned {len(items)} todo items") + self._items = items + self.refresh_button.setEnabled(True) + self._render_items() + + def _render_items(self) -> None: + """依篩選條件重建清單 / Rebuild the tree for the current filter.""" + visible = self.visible_items() + self.result_tree.clear() + for item in visible: + row = QTreeWidgetItem([item.tag, item.message, item.path, str(item.line)]) + row.setData(COLUMN_TAG, Qt.ItemDataRole.UserRole, item) + self.result_tree.addTopLevelItem(row) + self.status_label.setText( + language_wrapper.language_word_dict.get("todo_panel_found").format(count=len(visible))) + + def _open_item(self, row: QTreeWidgetItem, _column: int) -> None: + """在編輯器中開啟被雙擊的項目 / Open the double-clicked item in the editor.""" + item = row.data(COLUMN_TAG, Qt.ItemDataRole.UserRole) + if item is None: + return + open_todo_item(self._main_window, self._root, item) + + def closeEvent(self, event) -> None: + """ + 關閉前停掉掃描執行緒 + Stop the scan thread before the panel goes away. + + 先擋掉信號再等待,避免執行緒在物件已銷毀後才送出結果。 + Signals are blocked before waiting so a late result cannot reach a dead + widget, and waiting keeps the QThread from being destroyed while running. + """ + thread = self._scan_thread + if thread is not None and thread.isRunning(): + thread.blockSignals(True) + thread.stop() + thread.wait() + super().closeEvent(event) + + +def resolve_todo_root(main_window) -> str: + """ + 取得要掃描的專案根目錄 + Resolve the project root that should be scanned. + + :param main_window: 主編輯器視窗,可為 ``None`` / The main window, may be ``None`` + :return: 專案根目錄路徑 / The project root path + """ + working_dir = getattr(main_window, "working_dir", None) + if working_dir and Path(working_dir).is_dir(): + return str(working_dir) + return os.getcwd() + + +def open_todo_item(main_window, root: str, item: TodoItem) -> bool: + """ + 在編輯器中開啟一筆待辦事項所在的行 + Open the line a TODO item points at. + + :param main_window: 用來開檔的主視窗 / The window used to open files + :param root: 專案根目錄 / The project root + :param item: 要開啟的項目 / The item to open + :return: 成功要求開檔時為 ``True`` / ``True`` when the open was requested + """ + if main_window is None or not hasattr(main_window, "go_to_new_tab"): + return False + main_window.go_to_new_tab(Path(root) / item.path) + jump_to_item_line(main_window, item.line) + return True + + +def jump_to_item_line(main_window, line: int) -> bool: + """ + 把目前分頁的游標移到指定行 + Move the current tab's cursor to a line. + + :param main_window: 主編輯器視窗 / The main editor window + :param line: 1 起算的行號 / The 1-based line number + :return: 成功跳轉時為 ``True`` / ``True`` when the jump happened + """ + 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/pyside_ui/main_ui/toolbar/toolbar_builder.py b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py index ecf9b0f7..9d49d214 100644 --- a/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py +++ b/je_editor/pyside_ui/main_ui/toolbar/toolbar_builder.py @@ -114,6 +114,33 @@ def build_toolbar(main_window: EditorMain) -> None: act_search.triggered.connect(lambda: _open_search(main_window)) toolbar.addAction(act_search) + # ── Command palette ─────────────────────────────────────── + # 使用 Ctrl+Shift+A(JetBrains 的 Find Action);Ctrl+Shift+P 已被 pip 安裝佔用 + # Uses Ctrl+Shift+A (JetBrains "Find Action"); Ctrl+Shift+P is taken by pip install + act_palette = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogDetailedView), + lang("toolbar_command_palette"), main_window) + act_palette.setToolTip(lang("toolbar_command_palette")) + act_palette.setShortcut("Ctrl+Shift+A") + act_palette.triggered.connect(lambda: _open_command_palette(main_window)) + toolbar.addAction(act_palette) + main_window.command_palette_action = act_palette + + act_quick_open = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogListView), + lang("toolbar_quick_open"), main_window) + act_quick_open.setToolTip(lang("toolbar_quick_open")) + act_quick_open.setShortcut("Ctrl+P") + act_quick_open.triggered.connect(lambda: _open_quick_open(main_window)) + toolbar.addAction(act_quick_open) + main_window.quick_open_action = act_quick_open + + act_go_to_symbol = QAction(_icon(main_window, QStyle.StandardPixmap.SP_FileDialogInfoView), + lang("toolbar_go_to_symbol"), main_window) + act_go_to_symbol.setToolTip(lang("toolbar_go_to_symbol")) + act_go_to_symbol.setShortcut("Ctrl+Shift+O") + act_go_to_symbol.triggered.connect(lambda: _open_go_to_symbol(main_window)) + toolbar.addAction(act_go_to_symbol) + main_window.go_to_symbol_action = act_go_to_symbol + # 初始載入 git 分支 / Initial git branch load _git_refresh_branches(main_window) @@ -178,6 +205,26 @@ def _open_search(main_window: EditorMain) -> None: widget.code_edit.open_search_replace_dialog() +def _open_command_palette(main_window: EditorMain) -> None: + """開啟指令面板 / Open the command palette""" + from je_editor.pyside_ui.main_ui.command_palette.command_palette_dialog import ( + open_command_palette + ) + open_command_palette(main_window) + + +def _open_quick_open(main_window: EditorMain) -> None: + """開啟快速開啟檔案面板 / Open the quick open file picker""" + from je_editor.pyside_ui.main_ui.command_palette.quick_open_dialog import open_quick_open + open_quick_open(main_window) + + +def _open_go_to_symbol(main_window: EditorMain) -> None: + """開啟前往符號面板 / Open the go-to-symbol picker""" + from je_editor.pyside_ui.main_ui.command_palette.go_to_symbol_dialog import open_go_to_symbol + open_go_to_symbol(main_window) + + class _GitBranchWorker(QObject): """背景取得 Git 分支清單 / Fetch git branches in background""" finished = Signal(list, str) # (branch_names, current_branch_or_sha) diff --git a/je_editor/utils/align/__init__.py b/je_editor/utils/align/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/align/align.py b/je_editor/utils/align/align.py new file mode 100644 index 00000000..7afd54b5 --- /dev/null +++ b/je_editor/utils/align/align.py @@ -0,0 +1,48 @@ +""" +依分隔符對齊多行(純邏輯,不含 Qt) +Align lines on a delimiter (pure logic, no Qt imports). + +把每行第一個分隔符對齊到同一欄,常用於對齊一組 ``=`` 指派或 ``:`` 對應。 +Aligns each line's first delimiter to the same column, handy for a group of ``=`` +assignments or ``:`` mappings. +""" +from __future__ import annotations + + +def align_by_delimiter(lines: list[str], delimiter: str) -> list[str]: + """ + 把每行的第一個分隔符對齊 + Align the first occurrence of ``delimiter`` across the lines. + + 不含分隔符的行保持原樣。分隔符前後各保留一個空白,分隔符前的內容以空白補齊到 + 最長的一行,讓所有分隔符落在同一欄。 + Lines without the delimiter are left unchanged. One space is kept on each side of + the delimiter, and the content before it is padded to the longest line so every + delimiter lands in the same column. + + :param lines: 要對齊的行 / The lines to align + :param delimiter: 對齊用的分隔符 / The delimiter to align on + :return: 對齊後的新清單 / A new list with delimiters aligned + """ + if not delimiter: + return list(lines) + + positions = [line.find(delimiter) for line in lines] + widths = [ + len(line[:index].rstrip()) + for line, index in zip(lines, positions) + if index >= 0 + ] + if not widths: + return list(lines) + target = max(widths) + + aligned: list[str] = [] + for line, index in zip(lines, positions): + if index < 0: + aligned.append(line) + continue + before = line[:index].rstrip() + after = line[index + len(delimiter):].lstrip() + aligned.append(f"{before.ljust(target)} {delimiter} {after}".rstrip()) + return aligned diff --git a/je_editor/utils/bookmark/__init__.py b/je_editor/utils/bookmark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/bookmark/bookmark_navigation.py b/je_editor/utils/bookmark/bookmark_navigation.py new file mode 100644 index 00000000..e7387dda --- /dev/null +++ b/je_editor/utils/bookmark/bookmark_navigation.py @@ -0,0 +1,79 @@ +""" +書籤導覽邏輯(純邏輯,不含 Qt) +Bookmark navigation logic (pure logic, no Qt imports). + +只處理「從目前行往下 / 往上找到哪個書籤」,實際移動游標留給 UI 層。 +Only decides which bookmark comes next / previous from the current line; the UI +layer performs the actual cursor move. +""" +from __future__ import annotations + +from typing import Iterable + + +def normalise_lines(lines: Iterable[int]) -> list[int]: + """ + 去除重複並排序書籤行號 + Deduplicate and sort bookmark line numbers. + + :param lines: 書籤行號 / Bookmark line numbers + :return: 由小到大排序、無重複的行號 / Sorted, unique line numbers + """ + return sorted(set(lines)) + + +def next_bookmark(lines: Iterable[int], current: int, wrap: bool = True) -> int | None: + """ + 找到目前行之後的下一個書籤 + Find the first bookmark after the current line. + + :param lines: 書籤行號 / Bookmark line numbers + :param current: 目前所在行 / The current line + :param wrap: 找不到時是否從頭繞回 / Whether to wrap to the first bookmark + :return: 下一個書籤行,沒有書籤時回傳 ``None`` + / The next bookmark line, or ``None`` when there are no bookmarks + """ + ordered = normalise_lines(lines) + if not ordered: + return None + for line in ordered: + if line > current: + return line + return ordered[0] if wrap else None + + +def prev_bookmark(lines: Iterable[int], current: int, wrap: bool = True) -> int | None: + """ + 找到目前行之前的上一個書籤 + Find the last bookmark before the current line. + + :param lines: 書籤行號 / Bookmark line numbers + :param current: 目前所在行 / The current line + :param wrap: 找不到時是否從尾端繞回 / Whether to wrap to the last bookmark + :return: 上一個書籤行,沒有書籤時回傳 ``None`` + / The previous bookmark line, or ``None`` when there are no bookmarks + """ + ordered = normalise_lines(lines) + if not ordered: + return None + for line in reversed(ordered): + if line < current: + return line + return ordered[-1] if wrap else None + + +def toggle_line(lines: Iterable[int], line: int) -> list[int]: + """ + 切換某一行的書籤狀態 + Toggle the bookmark state of one line. + + :param lines: 現有書籤行號 / Existing bookmark line numbers + :param line: 要切換的行 / The line to toggle + :return: 切換後排序的行號清單 / The resulting sorted line numbers + """ + current = set(normalise_lines(lines)) + if line in current: + current.discard(line) + else: + current.add(line) + return sorted(current) diff --git a/je_editor/utils/case_convert/__init__.py b/je_editor/utils/case_convert/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/case_convert/case_convert.py b/je_editor/utils/case_convert/case_convert.py new file mode 100644 index 00000000..070d5965 --- /dev/null +++ b/je_editor/utils/case_convert/case_convert.py @@ -0,0 +1,63 @@ +""" +識別字命名風格轉換(純邏輯,不含 Qt) +Identifier naming-style conversion (pure logic, no Qt imports). + +支援 snake_case、camelCase、PascalCase 與 kebab-case 互轉。先把輸入切成一個個 +「詞」,再依目標風格重新組合。 +Converts between snake_case, camelCase, PascalCase and kebab-case. The input is +first split into words, then recombined in the target style. +""" +from __future__ import annotations + +import re + +# 分隔符號:底線、連字號、空白 / Separators: underscore, hyphen, whitespace +_SEPARATOR_RE = re.compile(r"[_\-\s]+") +# 小寫或數字之後接大寫:詞界 / lower/digit followed by upper: a word boundary +_LOWER_UPPER_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") +# 連續大寫之後接「大寫+小寫」:縮寫與後續詞的界線(HTTPServer -> HTTP Server) +# A run of capitals before an upper+lower: acronym/word boundary (HTTPServer -> HTTP Server) +_ACRONYM_RE = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])") + + +def split_words(text: str) -> list[str]: + """ + 把識別字切成一個個詞 + Split an identifier into its component words. + + 同時處理底線、連字號、空白,以及 camelCase / PascalCase 的大小寫邊界, + 並保留縮寫(例如 ``parseHTTPResponse`` → ``parse``, ``HTTP``, ``Response``)。 + Handles underscores, hyphens, whitespace and camelCase / PascalCase boundaries, + keeping acronyms intact (e.g. ``parseHTTPResponse`` -> ``parse``, ``HTTP``, + ``Response``). + + :param text: 要拆解的識別字 / The identifier to split + :return: 詞的清單(皆為非空字串)/ The list of words (all non-empty) + """ + spaced = _SEPARATOR_RE.sub(" ", text) + spaced = _ACRONYM_RE.sub(" ", spaced) + spaced = _LOWER_UPPER_RE.sub(" ", spaced) + return [word for word in spaced.split(" ") if word] + + +def to_snake_case(text: str) -> str: + """轉為 ``snake_case`` / Convert to ``snake_case``.""" + return "_".join(word.lower() for word in split_words(text)) + + +def to_kebab_case(text: str) -> str: + """轉為 ``kebab-case`` / Convert to ``kebab-case``.""" + return "-".join(word.lower() for word in split_words(text)) + + +def to_camel_case(text: str) -> str: + """轉為 ``camelCase`` / Convert to ``camelCase``.""" + words = split_words(text) + if not words: + return "" + return words[0].lower() + "".join(word.capitalize() for word in words[1:]) + + +def to_pascal_case(text: str) -> str: + """轉為 ``PascalCase`` / Convert to ``PascalCase``.""" + return "".join(word.capitalize() for word in split_words(text)) diff --git a/je_editor/utils/code_folding/__init__.py b/je_editor/utils/code_folding/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/code_folding/fold_regions.py b/je_editor/utils/code_folding/fold_regions.py new file mode 100644 index 00000000..8486d792 --- /dev/null +++ b/je_editor/utils/code_folding/fold_regions.py @@ -0,0 +1,123 @@ +""" +以縮排計算可折疊區塊(純邏輯,不含 Qt) +Compute foldable regions from indentation (pure logic, no Qt imports). + +折疊只依據縮排,因此對 Python 以及任何用縮排表達巢狀的檔案都適用, +且完全不需要執行或匯入使用者的程式碼。 +Folding is purely indentation-based, so it works for Python and any file that +expresses nesting through indentation, without importing or running user code. +""" +from __future__ import annotations + +from dataclasses import dataclass + +# 展開 Tab 時使用的寬度;只影響相對縮排比較,不影響顯示 +# Tab expansion width; only affects relative indent comparison, not display +TAB_WIDTH = 8 +# 掃描行數上限,避免病態的深層巢狀檔案造成過久的計算 +# Line cap so a pathologically deep file cannot make the scan run too long +MAX_SCAN_LINES = 50000 + + +@dataclass(frozen=True) +class FoldRegion: + """ + 一個可折疊區塊 + One foldable region. + + :param start: 標頭行(0 起算),折疊時保持可見 / The header line (0-based), stays visible + :param end: 區塊最後一行(0 起算,含)/ The last line of the region (0-based, inclusive) + :param indent: 標頭行的縮排欄位數 / The header line's indent column count + """ + + start: int + end: int + indent: int + + @property + def body_lines(self) -> range: + """折疊時會被隱藏的行 / The lines hidden when this region is folded.""" + return range(self.start + 1, self.end + 1) + + +def line_indent(line: str) -> int | None: + """ + 計算一行的縮排欄位數 + Return a line's indent as a column count. + + :param line: 原始文字行 / The raw text line + :return: 縮排欄位數;整行空白時回傳 ``None`` + / The indent column count, or ``None`` when the line is blank + """ + if not line.strip(): + return None + expanded = line.expandtabs(TAB_WIDTH) + return len(expanded) - len(expanded.lstrip()) + + +def compute_fold_regions(lines: list[str]) -> list[FoldRegion]: + """ + 從文字行計算所有可折疊區塊 + Compute every foldable region from a list of text lines. + + 一行是折疊標頭,當其後(略過空白行)存在縮排更深的行。區塊延伸到縮排掉回 + 標頭層級之前的最後一個非空白行,尾端的空白行不計入。 + A line is a fold header when a more-indented line follows it (skipping blanks). + The region runs to the last non-blank line before the indent falls back to the + header's level; trailing blank lines are excluded. + + :param lines: 檔案的文字行 / The file's text lines + :return: 依標頭行排序的區塊清單 / Regions ordered by header line + """ + count = min(len(lines), MAX_SCAN_LINES) + indents = [line_indent(lines[index]) for index in range(count)] + regions: list[FoldRegion] = [] + for header in range(count): + header_indent = indents[header] + if header_indent is None: + continue + end = _region_end(indents, header, header_indent, count) + if end > header: + regions.append(FoldRegion(start=header, end=end, indent=header_indent)) + return regions + + +def _region_end( + indents: list[int | None], header: int, header_indent: int, count: int) -> int: + """ + 找出區塊的最後一行 + Find the last line belonging to the region starting at ``header``. + + :return: 區塊最後一個非空白行的索引;沒有內容時回傳 ``header`` + / Index of the region's last non-blank line, or ``header`` when empty + """ + end = header + cursor = header + 1 + while cursor < count: + indent = indents[cursor] + if indent is None: + # 空白行暫時略過,是否計入由後續是否還有內容決定 + # A blank line is tentatively skipped; inclusion depends on what follows + cursor += 1 + continue + if indent > header_indent: + end = cursor + cursor += 1 + continue + break + return end + + +def region_at_line(regions: list[FoldRegion], line: int) -> FoldRegion | None: + """ + 取得以指定行為標頭的區塊 + Return the region whose header is ``line``, if any. + + :param regions: :func:`compute_fold_regions` 的結果 / Regions from :func:`compute_fold_regions` + :param line: 標頭行(0 起算)/ The header line (0-based) + :return: 對應的區塊,或 ``None`` / The matching region, or ``None`` + """ + for region in regions: + if region.start == line: + return region + return None diff --git a/je_editor/utils/command_palette/__init__.py b/je_editor/utils/command_palette/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/command_palette/fuzzy_matcher.py b/je_editor/utils/command_palette/fuzzy_matcher.py new file mode 100644 index 00000000..5f8210b1 --- /dev/null +++ b/je_editor/utils/command_palette/fuzzy_matcher.py @@ -0,0 +1,174 @@ +""" +指令面板的模糊比對邏輯(純邏輯,不含 Qt) +Fuzzy matching logic for the command palette (pure logic, no Qt imports). + +保持與 Qt 無關讓排序規則可以在無頭環境下單元測試。 +Keeping this Qt-free lets the ranking rules be unit tested headlessly. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +# 連續命中的加分 / Bonus when a query char immediately follows the previous match +SEQUENTIAL_BONUS = 15 +# 命中單字開頭的加分 / Bonus when a match lands on a word boundary +WORD_BOUNDARY_BONUS = 10 +# 大小寫完全相同的加分 / Bonus when the case matches exactly +CASE_BONUS = 2 +# 候選字串開頭未命中的每字元扣分 / Penalty per leading unmatched char +LEADING_PENALTY = -3 +# 開頭扣分的下限 / Floor for the leading penalty +MAX_LEADING_PENALTY = -9 +# 候選字串多餘長度的每字元扣分 / Penalty per surplus candidate char +LENGTH_PENALTY = -1 +# 長度扣分的下限 / Floor for the length penalty +MAX_LENGTH_PENALTY = -20 +# 直接包含整段查詢的加分 / Bonus when the whole query appears as a substring +SUBSTRING_BONUS = 25 +# 候選字串以查詢開頭的加分 / Bonus when the candidate starts with the query +PREFIX_BONUS = 20 +# 比對命令標題時的權重(標題比完整路徑更重要) +# Weight applied to the title score (a title match beats a path match) +TITLE_WEIGHT = 2 +# 預設回傳筆數上限 / Default cap on returned results +DEFAULT_RESULT_LIMIT = 50 + +# 視為單字分隔的字元 / Characters treated as word separators +WORD_SEPARATORS = frozenset(" \t_-.:/\\>()[]{}") + + +@dataclass +class CommandEntry: + """ + 指令面板中的一筆可執行指令 + One runnable entry shown in the command palette. + + :param title: 指令顯示名稱 / Human readable command name + :param path: 指令在選單中的完整路徑,例如 "File > Open File" + / Full menu path, e.g. "File > Open File" + :param shortcut: 對應的快捷鍵文字,可為空 / Shortcut text, may be empty + :param payload: 觸發指令所需的物件(UI 層使用),純邏輯不會碰 + / Object used by the UI layer to trigger the command; untouched here + """ + + title: str + path: str = "" + shortcut: str = "" + payload: Any = field(default=None, compare=False, repr=False) + + @property + def search_text(self) -> str: + """回傳用於比對的完整文字 / Return the full text used for matching.""" + return self.path or self.title + + +def fuzzy_score(query: str, candidate: str) -> int | None: + """ + 計算查詢字串對候選字串的模糊比對分數 + Score ``query`` against ``candidate`` using a greedy subsequence match. + + :param query: 使用者輸入的查詢字串 / The user supplied query + :param candidate: 被比對的候選字串 / The candidate string + :return: 分數(越高越相符),完全不相符時回傳 ``None`` + / A score where higher is better, or ``None`` when there is no match + """ + if not query: + return 0 + if not candidate: + return None + + lowered_query = query.lower() + lowered_candidate = candidate.lower() + + score = 0 + search_from = 0 + previous_index = -1 + first_index = -1 + + for query_index, query_char in enumerate(lowered_query): + found = lowered_candidate.find(query_char, search_from) + if found < 0: + return None + if first_index < 0: + first_index = found + score += _match_bonus(query, candidate, query_index, found, previous_index) + previous_index = found + search_from = found + 1 + + score += max(MAX_LEADING_PENALTY, LEADING_PENALTY * first_index) + score += max(MAX_LENGTH_PENALTY, LENGTH_PENALTY * (len(candidate) - len(query))) + if lowered_query in lowered_candidate: + score += SUBSTRING_BONUS + if lowered_candidate.startswith(lowered_query): + score += PREFIX_BONUS + return score + + +def _match_bonus( + query: str, candidate: str, query_index: int, found: int, previous_index: int) -> int: + """單一字元命中的加分 / Bonus earned by a single character match.""" + bonus = 0 + if previous_index >= 0 and found == previous_index + 1: + bonus += SEQUENTIAL_BONUS + if found == 0 or candidate[found - 1] in WORD_SEPARATORS: + bonus += WORD_BOUNDARY_BONUS + if candidate[found] == query[query_index]: + bonus += CASE_BONUS + return bonus + + +def score_command(query: str, command: CommandEntry) -> int | None: + """ + 計算查詢字串對單一指令的分數 + Score ``query`` against a single :class:`CommandEntry`. + + 標題的分數權重較高,但仍允許只比對到選單路徑的指令出現。 + The title is weighted higher, while a path-only match still qualifies. + + :param query: 使用者輸入的查詢字串 / The user supplied query + :param command: 要評分的指令 / The command being scored + :return: 分數,不相符時回傳 ``None`` / The score, or ``None`` when unmatched + """ + title_score = fuzzy_score(query, command.title) + path_score = fuzzy_score(query, command.search_text) + if title_score is None and path_score is None: + return None + if title_score is None: + return path_score + if path_score is None: + return title_score * TITLE_WEIGHT + return max(title_score * TITLE_WEIGHT, path_score) + + +def rank_commands( + query: str, commands: Iterable[CommandEntry], + limit: int = DEFAULT_RESULT_LIMIT) -> list[CommandEntry]: + """ + 依模糊比對分數排序指令,最相符的排在前面 + Rank ``commands`` by fuzzy score, best match first. + + 空查詢會依原本順序回傳前 ``limit`` 筆,讓面板一開啟就有內容。 + An empty query returns the first ``limit`` commands in their original order + so the palette is never blank when it opens. + + :param query: 使用者輸入的查詢字串 / The user supplied query + :param commands: 候選指令 / Candidate commands + :param limit: 回傳筆數上限,非正數代表不限制 / Result cap; non-positive means no cap + :return: 排序後的指令清單 / The ranked command list + """ + command_list = list(commands) + if not query.strip(): + return command_list[:limit] if limit > 0 else command_list + + scored: list[tuple[int, int, CommandEntry]] = [] + for index, command in enumerate(command_list): + score = score_command(query, command) + if score is not None: + scored.append((score, index, command)) + + # 以分數遞減排序,分數相同時保留原本順序(index 遞增)確保結果穩定 + # Sort by descending score, falling back to the original order for stability + scored.sort(key=lambda item: (-item[0], item[1])) + ranked = [command for _score, _index, command in scored] + return ranked[:limit] if limit > 0 else ranked diff --git a/je_editor/utils/encode_decode/__init__.py b/je_editor/utils/encode_decode/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/encode_decode/encode_decode.py b/je_editor/utils/encode_decode/encode_decode.py new file mode 100644 index 00000000..828626b1 --- /dev/null +++ b/je_editor/utils/encode_decode/encode_decode.py @@ -0,0 +1,123 @@ +""" +文字編碼/解碼工具(純邏輯,不含 Qt) +Text encode/decode helpers (pure logic, no Qt imports). + +解碼失敗時一律回傳 ``None``,讓 UI 層可以安全地「不做任何改動」而非拋出例外。 +Decoders return ``None`` on failure, so the UI layer can safely leave the text +unchanged instead of raising. +""" +from __future__ import annotations + +import base64 +import html +import json +from urllib.parse import quote, unquote + +# base64 / URL 一律以 UTF-8 處理位元組 / Bytes are handled as UTF-8 throughout +_ENCODING = "utf-8" + + +def base64_encode(text: str) -> str: + """ + 將文字以 Base64 編碼 + Encode text as Base64. + + :param text: 原始文字 / The original text + :return: Base64 字串 / The Base64 string + """ + return base64.b64encode(text.encode(_ENCODING)).decode("ascii") + + +def base64_decode(text: str) -> str | None: + """ + 將 Base64 字串解碼回文字 + Decode a Base64 string back into text. + + 輸入不是合法的 Base64、或解出來不是有效 UTF-8 時回傳 ``None``。 + Returns ``None`` when the input is not valid Base64 or does not decode as UTF-8. + + :param text: Base64 字串 / The Base64 string + :return: 解碼後的文字,失敗時回傳 ``None`` / The decoded text, or ``None`` on failure + """ + try: + decoded = base64.b64decode(text.strip(), validate=True) + return decoded.decode(_ENCODING) + # binascii.Error 與 UnicodeDecodeError 都是 ValueError 的子類別 + # binascii.Error and UnicodeDecodeError both derive from ValueError + except ValueError: + return None + + +def url_encode(text: str) -> str: + """ + 將文字做 URL 百分比編碼 + Percent-encode text for use in a URL. + + :param text: 原始文字 / The original text + :return: URL 編碼後的字串 / The percent-encoded string + """ + return quote(text, safe="") + + +def url_decode(text: str) -> str: + """ + 將 URL 百分比編碼還原為文字 + Decode percent-encoded text back to plain text. + + :param text: URL 編碼字串 / The percent-encoded string + :return: 還原後的文字 / The decoded text + """ + return unquote(text) + + +def html_escape(text: str) -> str: + """ + 將文字中的 HTML 特殊字元轉為實體 + Escape HTML-special characters into entities. + + :param text: 原始文字 / The original text + :return: 轉義後的文字 / The escaped text + """ + return html.escape(text) + + +def html_unescape(text: str) -> str: + """ + 將 HTML 實體還原為原始字元 + Unescape HTML entities back into characters. + + :param text: 含實體的文字 / Text containing entities + :return: 還原後的文字 / The unescaped text + """ + return html.unescape(text) + + +def json_string_escape(text: str) -> str: + """ + 把文字轉成合法的 JSON 字串字面值(含引號) + Turn text into a valid JSON string literal (including the surrounding quotes). + + :param text: 原始文字 / The original text + :return: JSON 字串字面值,例如 ``"a\\nb"`` / A JSON string literal, e.g. ``"a\\nb"`` + """ + return json.dumps(text) + + +def json_string_unescape(text: str) -> str | None: + """ + 把 JSON 字串字面值還原為原始文字 + Turn a JSON string literal back into its raw text. + + 輸入不是合法的 JSON 字串(例如缺少引號或跳脫錯誤)時回傳 ``None``。 + Returns ``None`` when the input is not a valid JSON string (missing quotes, + bad escapes, or not a string). + + :param text: JSON 字串字面值 / A JSON string literal + :return: 還原後的文字,失敗時回傳 ``None`` / The raw text, or ``None`` on failure + """ + try: + parsed = json.loads(text.strip()) + # json.JSONDecodeError 是 ValueError 的子類別 / derives from ValueError + except ValueError: + return None + return parsed if isinstance(parsed, str) else None diff --git a/je_editor/utils/file_scan/__init__.py b/je_editor/utils/file_scan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/file_scan/file_indexer.py b/je_editor/utils/file_scan/file_indexer.py new file mode 100644 index 00000000..a2ce4516 --- /dev/null +++ b/je_editor/utils/file_scan/file_indexer.py @@ -0,0 +1,106 @@ +""" +專案檔案索引(純邏輯,不含 Qt) +Project file indexing for quick open (pure logic, no Qt imports). +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Callable, Iterable + +from je_editor.utils.command_palette.fuzzy_matcher import CommandEntry +from je_editor.utils.file_scan.ignore_rules import is_ignored_directory, is_ignored_file + +# 索引檔案數量上限,避免超大專案吃光記憶體 / Cap so a huge tree cannot exhaust memory +DEFAULT_FILE_LIMIT = 20000 +# 走訪的資料夾深度上限 / Maximum directory depth walked +MAX_INDEX_DEPTH = 24 + + +def index_project_files( + root: str | Path, + limit: int = DEFAULT_FILE_LIMIT, + should_stop: Callable[[], bool] | None = None) -> list[str]: + """ + 走訪專案資料夾,回傳可編輯檔案的相對路徑 + Walk a project directory and return relative paths of editable files. + + 路徑一律使用 ``/`` 分隔,讓不同平台上的比對結果一致。 + Paths always use ``/`` separators so matching behaves the same on every platform. + + :param root: 專案根目錄 / The project root directory + :param limit: 回傳檔案數量上限 / Maximum number of files returned + :param should_stop: 回傳 ``True`` 時提前中止走訪,供背景執行緒取消用 + / Called between steps; returning ``True`` aborts the walk for cancellation + :return: 排序後的相對路徑清單 / Sorted relative paths + """ + root_path = Path(root) + if not root_path.is_dir(): + return [] + + found: list[str] = [] + # symlink 不跟隨,避免循環連結造成無限走訪 + # Symlinks are not followed so a link loop cannot spin forever + for current_dir, sub_dirs, file_names in os.walk(root_path, topdown=True, followlinks=False): + if should_stop is not None and should_stop(): + break + if _depth_of(root_path, current_dir) >= MAX_INDEX_DEPTH: + sub_dirs.clear() + continue + # 就地修改 sub_dirs 才能讓 os.walk 真的不進入被忽略的資料夾 + # Mutating sub_dirs in place is what actually prunes the walk + sub_dirs[:] = [name for name in sub_dirs if not is_ignored_directory(name)] + if _collect_files(root_path, current_dir, file_names, found, limit): + break + + found.sort() + return found + + +def _depth_of(root_path: Path, current_dir: str) -> int: + """計算相對於根目錄的深度 / Depth of a directory relative to the root.""" + try: + relative = Path(current_dir).relative_to(root_path) + except ValueError: + return MAX_INDEX_DEPTH + return len(relative.parts) + + +def _collect_files( + root_path: Path, current_dir: str, file_names: list[str], + found: list[str], limit: int) -> bool: + """ + 收集單一資料夾中的檔案,達到上限時回傳 ``True`` + Collect files from one directory; return ``True`` once the limit is reached. + """ + for file_name in file_names: + if is_ignored_file(file_name): + continue + full_path = Path(current_dir) / file_name + try: + relative = full_path.relative_to(root_path) + except ValueError: + continue + found.append(relative.as_posix()) + if len(found) >= limit: + return True + return False + + +def build_file_entries(relative_paths: Iterable[str]) -> list[CommandEntry]: + """ + 把相對路徑轉成可放進模糊搜尋清單的項目 + Turn relative paths into entries the fuzzy picker can rank. + + 標題是檔名、路徑是完整相對路徑,因此打檔名或打資料夾名都找得到。 + The title is the file name and the path is the full relative path, so typing + either a file name or a folder name finds the file. + + :param relative_paths: 專案相對路徑 / Project-relative paths + :return: 對應的項目清單,``payload`` 由 UI 層填入 + / The matching entries; the UI layer fills in ``payload`` + """ + return [ + CommandEntry(title=Path(relative).name, path=relative) + for relative in relative_paths + ] diff --git a/je_editor/utils/file_scan/ignore_rules.py b/je_editor/utils/file_scan/ignore_rules.py new file mode 100644 index 00000000..3395f7e2 --- /dev/null +++ b/je_editor/utils/file_scan/ignore_rules.py @@ -0,0 +1,73 @@ +""" +掃描專案時共用的忽略規則 +Shared ignore rules used when scanning a project tree. + +搜尋、快速開啟等功能共用同一份規則,避免各自維護不同的清單。 +Search, quick open and friends share one rule set instead of each keeping +its own drifting copy. +""" +from __future__ import annotations + +from pathlib import Path + +# 掃描時完全跳過的資料夾名稱 / Directory names skipped entirely while scanning +IGNORED_DIRECTORY_NAMES = frozenset({ + ".git", ".hg", ".svn", + "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".hypothesis", + ".venv", "venv", ".tox", ".nox", + "node_modules", ".idea", ".vscode", + "dist", "build", ".eggs", +}) + +# 不會被當成可編輯檔案的副檔名 / Suffixes never treated as editable files +IGNORED_FILE_SUFFIXES = frozenset({ + ".pyc", ".pyo", ".pyd", ".so", ".dll", ".dylib", ".exe", ".bin", ".o", ".a", + ".zip", ".gz", ".tar", ".7z", ".rar", ".whl", ".jar", ".class", + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svgz", + ".pdf", ".mp3", ".mp4", ".avi", ".mov", ".wav", + ".db", ".sqlite", ".sqlite3", ".lock", +}) + +# 二進位嗅探讀取的位元組數 / Bytes read when sniffing for binary content +BINARY_SNIFF_BYTES = 4096 + + +def is_ignored_directory(name: str) -> bool: + """ + 判斷資料夾是否應該跳過 + Return whether a directory should be skipped while scanning. + + :param name: 資料夾名稱(非完整路徑)/ The directory name, not a full path + :return: 應該跳過時為 ``True`` / ``True`` when the directory is ignored + """ + return name in IGNORED_DIRECTORY_NAMES + + +def is_ignored_file(name: str) -> bool: + """ + 判斷檔案是否應該跳過 + Return whether a file should be skipped while scanning. + + :param name: 檔案名稱或路徑 / The file name or path + :return: 應該跳過時為 ``True`` / ``True`` when the file is ignored + """ + return Path(name).suffix.lower() in IGNORED_FILE_SUFFIXES + + +def is_binary_file(path: str | Path, sniff_bytes: int = BINARY_SNIFF_BYTES) -> bool: + """ + 以開頭是否含有 null byte 快速判斷檔案是否為二進位 + Quickly decide whether a file is binary by sniffing for a null byte. + + 無法讀取的檔案一律視為二進位,讓呼叫端安全地略過。 + Unreadable files are reported as binary so callers safely skip them. + + :param path: 要檢查的檔案路徑 / The file path to check + :param sniff_bytes: 要讀取的位元組數 / How many bytes to read + :return: 是二進位或無法讀取時為 ``True`` / ``True`` when binary or unreadable + """ + try: + with open(path, "rb") as file_to_sniff: + return b"\x00" in file_to_sniff.read(sniff_bytes) + except OSError: + return True diff --git a/je_editor/utils/file_scan/todo_scanner.py b/je_editor/utils/file_scan/todo_scanner.py new file mode 100644 index 00000000..2981a79b --- /dev/null +++ b/je_editor/utils/file_scan/todo_scanner.py @@ -0,0 +1,138 @@ +""" +掃描專案中的 TODO / FIXME 註解(純邏輯,不含 Qt) +Scan a project for TODO / FIXME comments (pure logic, no Qt imports). +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Sequence + +from je_editor.utils.file_scan.file_indexer import index_project_files +from je_editor.utils.file_scan.ignore_rules import is_binary_file + +# 預設掃描的標籤,取自業界常見的註解慣例 +# Default tags, taken from the widely used comment conventions +DEFAULT_TAGS: tuple[str, ...] = ("TODO", "FIXME", "HACK", "XXX", "BUG", "NOTE", "OPTIMIZE") + +# 註解起始符號,涵蓋 Python、C 系列、HTML、SQL、Lisp、LaTeX +# Comment openers covering Python, C-like, HTML, SQL, Lisp and LaTeX +COMMENT_MARKERS = r"(?:#|//|/\*|\*|\n")[0].message == "add alt text" + + def test_plain_string_is_not_a_todo(self): + # Without a comment marker this is ordinary code, not a task. + assert _scan('message = "TODO items are tracked elsewhere"\n') == [] + + def test_word_inside_identifier_is_not_matched(self): + assert _scan("# TODOLIST = []\n") == [] + + def test_reports_every_line(self): + found = _scan(""" + # TODO: first + code = 1 + # FIXME: second + """) + assert [item.tag for item in found] == ["TODO", "FIXME"] + + def test_line_numbers_are_one_based(self): + found = _scan("code = 1\n# TODO: second line\n") + assert found[0].line == 2 + + def test_relative_path_is_kept(self): + assert _scan("# TODO: x\n", "pkg/mod.py")[0].path == "pkg/mod.py" + + def test_empty_message_is_allowed(self): + assert _scan("# TODO\n")[0].message == "" + + def test_every_default_tag_is_detected(self): + for tag in DEFAULT_TAGS: + assert _scan(f"# {tag}: something\n")[0].tag == tag + + +class TestScanProjectTodos: + """Project-wide scanning, pruning and cancellation.""" + + @staticmethod + def _project(tmp_path): + (tmp_path / "main.py").write_text("# TODO: main work\n", encoding="utf-8") + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "helper.py").write_text("# FIXME: helper bug\n", encoding="utf-8") + (tmp_path / "__pycache__").mkdir() + (tmp_path / "__pycache__" / "cached.py").write_text("# TODO: ignored\n", encoding="utf-8") + (tmp_path / "blob.bin").write_bytes(b"# TODO: binary\x00") + return tmp_path + + def test_finds_todos_across_files(self, tmp_path): + found = scan_project_todos(self._project(tmp_path)) + assert {item.tag for item in found} == {"TODO", "FIXME"} + + def test_ignored_directories_are_skipped(self, tmp_path): + found = scan_project_todos(self._project(tmp_path)) + assert not any("__pycache__" in item.path for item in found) + + def test_binary_files_are_skipped(self, tmp_path): + found = scan_project_todos(self._project(tmp_path)) + assert not any(item.path.endswith(".bin") for item in found) + + def test_missing_root_returns_empty(self, tmp_path): + assert scan_project_todos(tmp_path / "nope") == [] + + def test_should_stop_aborts_the_scan(self, tmp_path): + assert scan_project_todos(self._project(tmp_path), should_stop=lambda: True) == [] + + def test_limit_caps_the_result(self, tmp_path): + target = tmp_path / "many.py" + target.write_text("\n".join(f"# TODO: item {i}" for i in range(20)), encoding="utf-8") + assert len(scan_project_todos(tmp_path, limit=5)) == 5 + + def test_custom_tags_only(self, tmp_path): + (tmp_path / "main.py").write_text("# TODO: a\n# FIXME: b\n", encoding="utf-8") + found = scan_project_todos(tmp_path, tags=("FIXME",)) + assert [item.tag for item in found] == ["FIXME"] + + +class _FakeCodeEdit: + def __init__(self): + self.jumped: list[int] = [] + + def jump_to_line(self, line: int) -> bool: + self.jumped.append(line) + return True + + +class _FakeMainWindow: + def __init__(self, working_dir=None): + self.working_dir = working_dir + self.opened: list = [] + + def go_to_new_tab(self, file_path) -> None: + self.opened.append(file_path) + + +class TestResolveTodoRoot: + """Root resolution mirrors quick open's behaviour.""" + + def test_uses_working_dir_when_valid(self, tmp_path): + assert resolve_todo_root(_FakeMainWindow(str(tmp_path))) == str(tmp_path) + + def test_falls_back_without_working_dir(self): + import os + assert resolve_todo_root(_FakeMainWindow(None)) == os.getcwd() + + def test_falls_back_without_a_window(self): + import os + assert resolve_todo_root(None) == os.getcwd() + + +class TestOpenTodoItem: + """Opening an item must degrade gracefully without a real window.""" + + @staticmethod + def _item() -> TodoItem: + return TodoItem(tag="TODO", message="x", path="pkg/mod.py", line=9) + + def test_no_window_returns_false(self, tmp_path): + assert open_todo_item(None, str(tmp_path), self._item()) is False + + def test_window_without_tab_api_returns_false(self, tmp_path): + assert open_todo_item(object(), str(tmp_path), self._item()) is False + + def test_opens_the_expected_path(self, tmp_path): + window = _FakeMainWindow() + assert open_todo_item(window, str(tmp_path), self._item()) is True + assert window.opened[0].name == "mod.py" + + def test_jump_without_tab_widget_returns_false(self): + assert jump_to_item_line(_FakeMainWindow(), 5) is False + + +@pytest.mark.usefixtures("qapp") +class TestTodoPanelWidget: + """Scanning, filtering and teardown in the panel.""" + + @staticmethod + def _project(tmp_path): + (tmp_path / "main.py").write_text("# TODO: main work\n", encoding="utf-8") + (tmp_path / "other.py").write_text("# FIXME: other bug\n", encoding="utf-8") + return tmp_path + + def _panel(self, qtbot, tmp_path) -> TodoPanelWidget: + panel = TodoPanelWidget(main_window=None, root=str(self._project(tmp_path))) + qtbot.addWidget(panel) + qtbot.waitUntil(lambda: panel.result_tree.topLevelItemCount() > 0, timeout=10000) + return panel + + def test_scan_populates_the_tree(self, qtbot, tmp_path): + panel = self._panel(qtbot, tmp_path) + assert panel.result_tree.topLevelItemCount() == 2 + panel.close() + + def test_all_tags_filter_shows_everything(self, qtbot, tmp_path): + panel = self._panel(qtbot, tmp_path) + assert panel.tag_filter.currentData() == ALL_TAGS_FILTER + assert len(panel.visible_items()) == 2 + panel.close() + + def test_tag_filter_narrows_the_list(self, qtbot, tmp_path): + panel = self._panel(qtbot, tmp_path) + panel.tag_filter.setCurrentIndex(panel.tag_filter.findData("FIXME")) + assert [item.tag for item in panel.visible_items()] == ["FIXME"] + panel.close() + + def test_tag_filter_updates_the_tree(self, qtbot, tmp_path): + panel = self._panel(qtbot, tmp_path) + panel.tag_filter.setCurrentIndex(panel.tag_filter.findData("FIXME")) + assert panel.result_tree.topLevelItemCount() == 1 + panel.close() + + def test_rescan_while_running_is_ignored(self, qtbot, tmp_path): + panel = self._panel(qtbot, tmp_path) + panel.start_scan() + first_thread = panel._scan_thread + panel.start_scan() + assert panel._scan_thread is first_thread + qtbot.waitUntil(lambda: not first_thread.isRunning(), timeout=10000) + panel.close() + + def test_closing_stops_the_scan_thread(self, qtbot, tmp_path): + panel = self._panel(qtbot, tmp_path) + panel.close() + assert not panel._scan_thread.isRunning() + + def test_closing_immediately_is_safe(self, tmp_path): + panel = TodoPanelWidget(main_window=None, root=str(self._project(tmp_path))) + panel.close() + assert not panel._scan_thread.isRunning() + + def test_empty_project_reports_no_items(self, qtbot, tmp_path): + panel = TodoPanelWidget(main_window=None, root=str(tmp_path)) + qtbot.addWidget(panel) + qtbot.waitUntil(lambda: not panel._scan_thread.isRunning(), timeout=10000) + assert panel.visible_items() == [] + panel.close() diff --git a/test/test_toolbar_actions.py b/test/test_toolbar_actions.py new file mode 100644 index 00000000..58a37841 --- /dev/null +++ b/test/test_toolbar_actions.py @@ -0,0 +1,58 @@ +"""Tests that the toolbar wires up its actions with working labels and shortcuts.""" +from __future__ import annotations + +import pytest +from PySide6.QtWidgets import QMainWindow + +from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import build_toolbar + + +@pytest.fixture() +def toolbar_window(qapp, qtbot): + """Build the real toolbar on a bare main window.""" + window = QMainWindow() + qtbot.addWidget(window) + build_toolbar(window) + yield window + + +@pytest.mark.usefixtures("qapp") +class TestToolbarActions: + """Every toolbar entry must reach a real callback with a translated label.""" + + # (attribute on the main window, expected shortcut) + PICKER_ACTIONS = ( + ("command_palette_action", "Ctrl+Shift+A"), + ("quick_open_action", "Ctrl+P"), + ("go_to_symbol_action", "Ctrl+Shift+O"), + ) + + def test_toolbar_is_attached(self, toolbar_window): + assert toolbar_window.main_toolbar is not None + + @pytest.mark.parametrize("attribute,shortcut", PICKER_ACTIONS) + def test_picker_action_exists(self, toolbar_window, attribute, shortcut): + assert getattr(toolbar_window, attribute, None) is not None + + @pytest.mark.parametrize("attribute,shortcut", PICKER_ACTIONS) + def test_picker_action_has_expected_shortcut(self, toolbar_window, attribute, shortcut): + action = getattr(toolbar_window, attribute) + assert action.shortcut().toString() == shortcut + + @pytest.mark.parametrize("attribute,shortcut", PICKER_ACTIONS) + def test_picker_action_label_is_translated(self, toolbar_window, attribute, shortcut): + # An empty label means the language dictionary is missing the key. + assert getattr(toolbar_window, attribute).text().strip() + + def test_picker_shortcuts_do_not_collide(self, toolbar_window): + shortcuts = [ + action.shortcut().toString() + for action in toolbar_window.main_toolbar.actions() + if action.shortcut().toString() + ] + assert len(shortcuts) == len(set(shortcuts)) + + 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 diff --git a/test/test_word_occurrences.py b/test/test_word_occurrences.py new file mode 100644 index 00000000..08225192 --- /dev/null +++ b/test/test_word_occurrences.py @@ -0,0 +1,88 @@ +"""Tests for word-under-cursor detection and occurrence finding.""" +from __future__ import annotations + +from je_editor.utils.occurrence.word_occurrences import ( + find_occurrences, + is_highlightable_word, + word_at, +) + + +class TestWordAt: + def test_inside_word(self): + assert word_at("foo bar", 1) == ("foo", 0, 3) + + def test_at_word_start(self): + assert word_at("foo bar", 4) == ("bar", 4, 7) + + def test_just_past_word_end(self): + # Caret right after "foo" still resolves to "foo". + assert word_at("foo bar", 3) == ("foo", 0, 3) + + def test_on_whitespace_returns_none(self): + assert word_at("foo bar", 4) is None + + def test_identifier_with_underscore(self): + assert word_at("my_var = 1", 3) == ("my_var", 0, 6) + + def test_out_of_range_returns_none(self): + assert word_at("foo", 99) is None + + def test_negative_returns_none(self): + assert word_at("foo", -1) is None + + def test_empty_text(self): + assert word_at("", 0) is None + + def test_number_is_part_of_identifier(self): + assert word_at("var2 = 1", 0) == ("var2", 0, 4) + + +class TestIsHighlightableWord: + def test_normal_identifier(self): + assert is_highlightable_word("value") + + def test_single_char_rejected(self): + assert not is_highlightable_word("x") + + def test_keyword_rejected(self): + assert not is_highlightable_word("def") + assert not is_highlightable_word("class") + + def test_non_identifier_rejected(self): + assert not is_highlightable_word("a-b") + assert not is_highlightable_word("123") + + def test_empty_rejected(self): + assert not is_highlightable_word("") + + +class TestFindOccurrences: + def test_finds_all_positions(self): + text = "value = value + other_value" + # 'value' at 0 and 8, but not inside 'other_value' + assert find_occurrences(text, "value") == [0, 8] + + def test_whole_word_only(self): + assert find_occurrences("values valuer value", "value") == [14] + + def test_no_match(self): + assert find_occurrences("abc def", "xyz") == [] + + def test_keyword_not_matched(self): + assert find_occurrences("def foo(): def bar():", "def") == [] + + def test_short_word_not_matched(self): + assert find_occurrences("x = x + x", "x") == [] + + def test_special_regex_chars_are_escaped(self): + # A word that is not a valid identifier is rejected before regex use. + assert find_occurrences("a.b a.b", "a.b") == [] + + def test_multiline_text(self): + text = "total = 1\nprint(total)\ntotal += 2" + assert find_occurrences(text, "total") == [0, 16, 23] + + def test_underscored_identifier(self): + text = "my_var = my_var + 1" + assert find_occurrences(text, "my_var") == [0, 9]