diff --git a/.coveragerc b/.coveragerc index e2f9790c..27664856 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,12 +1,18 @@ # Coverage is measured on Windows and consumed by the SonarQube scanner running # on Linux, so the report must not carry machine-specific paths. relative_files -# makes coverage emit repo-root-relative names (pybreeze/utils/... rather than an -# absolute plus a package-relative filename), which is what the scanner -# resolves against. +# makes the report's the relative "pybreeze" rather than an absolute +# path, and the scanner resolves the package-relative filenames against it. +# +# patch = subprocess starts coverage in every Python child a test starts (it +# needs coverage 7.10+; pytest-cov 7 no longer measures children itself), so +# the tests that build the real main window in a child interpreter +# (test/test_utils/started_window.py) count. Each child writes its own data +# file, which pytest-cov combines before reporting. [run] relative_files = True source = pybreeze branch = True +patch = subprocess [report] exclude_also = diff --git a/CLAUDE.md b/CLAUDE.md index 0fdfe2f2..d01cbc6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,11 @@ pybreeze/ │ ├── gui_thread_gc.py # Garbage collected on a GUI-thread timer, never on a worker │ ├── plain_text.py # as_text: server/file text shown in message boxes as text, not markup │ ├── exact_text.py # exact_text: a text box read as typed (toPlainText changes U+00A0, U+2028) +│ ├── terminal_view.py # Terminal output in a text view: colours, the pty size, a lone \r rewinding the line +│ ├── fixed_pitch.py # use_fixed_pitch_font: the system's fixed-pitch font, whatever the theme names +│ ├── run_shortcut.py # press_on_ctrl_enter: Ctrl+Enter in a panel presses its main button │ ├── error_text.py # error_text: a tool's English error (exception_tags) in the IDE language +│ ├── code_result_logs.py # Only warnings and errors from loggers reach the editor's Code Result panel │ ├── closing.py # may_close / AskingDock: tabs and docks with unsaved work are asked first │ ├── dialog/ # prthinker settings dialog │ └── syntax/ # Automation keyword highlighting definitions @@ -34,6 +38,7 @@ pybreeze/ │ │ ├── process_executor_utils.py # build_process / start_process / run_dir_files_* │ │ ├── file_runner_process.py # FileRunnerProcess — plugin run configs (any language) │ │ ├── queue_pump.py # Shared pipe reader + per-tick queue drain +│ │ ├── run_notice.py # run_notice: a run window's own [Error]/[Run]/… lines, translated │ │ ├── api_testka/ auto_control/ web_runner/ load_density/ │ │ ├── file_automation/ mail_thunder/ # Each delegates to build_process with its package name │ │ ├── test_pioneer/ # python -m test_pioneer -e via start_module_process @@ -50,6 +55,7 @@ pybreeze/ ├── exception/ # ITEException hierarchy ├── logging/ file_process/ app_dirs.py / subprocess_util.py ├── terminal_text.py # Escape sequences and controls stripped from terminal output (SSH, run window) + ├── terminal_style.py # SGR colours and emphasis read into a TextStyle (SSH terminal) └── manager/package_manager/ # PackageManager — holds syntax_check_list ``` @@ -73,7 +79,7 @@ pybreeze/ - `unit-tests` job: GitHub Actions on Windows, Python 3.10–3.14 — install deps → pytest `test/test_utils/` → `start_automation_test` → `extend_automation_test` - `sonarcloud` job: CI-based SonarQube Cloud analysis (`sonar-project.properties`), `needs: unit-tests` so it can consume the `coverage-xml` artifact that leg uploads. Automatic Analysis is off and must stay off — the two modes are mutually exclusive and the scanner refuses to run alongside it - SonarCloud's plan for this organization exposes results for `main` and for pull requests only. An analysis pushed for another branch succeeds but its results read back 403, so `dev.yml` scans on pull requests only; `stable.yml` also scans pushes to `main`. Do not "fix" this by scanning every `dev` push — the numbers are not readable -- Coverage comes from the 3.12 matrix leg (`pytest --cov`), configured by `.coveragerc`. `relative_files = True` is required: the report is produced on Windows and consumed by a Linux scanner, so it must not carry machine-specific paths +- Coverage comes from the 3.12 matrix leg (`pytest --cov`), configured by `.coveragerc`. `relative_files = True` is required: the report is produced on Windows and consumed by a Linux scanner, so it must not carry machine-specific paths. `patch = subprocess` is required too: pytest-cov 7 no longer measures child processes, and without it nothing the tests run in a child interpreter (the real main window, `started_window.py`) counts. coverage traces only the threads Python starts, so `test/test_utils/conftest.py` gives every `QThread` subclass a `run` that installs its tracer on Qt's thread; without it no `QThread.run` counts as covered ## Development @@ -96,6 +102,10 @@ ruff check pybreeze/ # before committing non-trivia - Custom exceptions inherit from `ITEException`; log via `pybreeze_logger` (lazy `%s` formatting, never `print()`) - Plugin API: `register_programming_language()` / `register_natural_language()` from `je_editor.plugins` - A QAction built for a menu must be kept alive: store it on the main window or give it the menu as its parent. A menu does not own the actions added to it, so one held only by a local variable is deleted when the builder returns and its entry disappears +- A context menu or dialog built on each use with a parent (`QMenu(self)`, `SomeDialog(self)`) is deleted once `exec()` returns (`deleteLater()`, or `WA_DeleteOnClose` for a message box): its parent keeps it otherwise, one more per use +- A process the IDE starts gets `child_environment()` or `utf8_subprocess_env()` (`utils/subprocess_util.py`) as its `env`, never `os.environ` as it is: a variable the IDE sets for itself alone has the value `IDE_ONLY` and stays out (`LOCUST_SKIP_MONKEY_PATCH`, which a load test must not inherit) +- Import `je_auto_control` only where it is used, never at the top of a module the IDE loads as it starts: it makes the process system DPI aware as it imports, which keeps Qt from making the IDE per-monitor aware. The automation packages' GUIs and the SSH client (paramiko) are likewise imported by the entry that opens them, which keeps almost two seconds off the start; `test_startup_imports.py` fails when one of them is imported as the IDE starts +- An instance attribute of a Qt class never takes the name of a member of its Qt base (`self.actions`, `self.thread`, `self.layout`, …): it hides the method from everything that calls it on the widget. `test_no_qt_member_shadowing.py` fails on one - Delete unused code immediately — no dead imports, unreachable branches, commented-out blocks, or `_old_` prefixes - Follow PEP 8 and standard Pythonic practice; `ruff` is the arbiter @@ -167,3 +177,4 @@ Workspace rule shared by every repository under `D:\Codes` (full text: `D:\Codes - Commit messages: short imperative sentence ("Update stable version", "Fix github actions") - **No AI attribution (mandatory)** — never mention any AI tool, assistant, agent, model or vendor in commit messages, trailers, branch names, PR titles or bodies, issues, code comments or documentation. No `Co-Authored-By` referencing an AI, no "Generated with …" footers. PR text describes *what changed and why*, never how it was authored. - PR target: `dev` for development work, `main` for stable releases +- **SonarCloud / Codacy findings.** When a PR or commit fails a SonarCloud or Codacy check, look the findings up through their APIs instead of guessing. The keys are in environment variables: `SonarCloudToken` (SonarCloud, e.g. `curl -s -u "$SonarCloudToken:" "https://sonarcloud.io/api/issues/search?componentKeys=&pullRequest=&resolved=false"`) and `CODACY_PROJECT_TOKEN` (a Codacy project token, valid only for its own project: any other repository answers "Bad credentials", so for a public repository query `https://app.codacy.com/api/v3/analysis/organizations/gh//repositories//pull-requests//issues?status=new` without a key). **Never reveal a key or any personal credential while doing so**: refer to the variables by name only, never echo or print their values, and never put them in files, commit messages, PR or issue text, logs, or any output that leaves the machine. diff --git a/PLUGIN_GUIDE.md b/PLUGIN_GUIDE.md index 6c53e1fd..41e62f65 100644 --- a/PLUGIN_GUIDE.md +++ b/PLUGIN_GUIDE.md @@ -1,4 +1,4 @@ -# PyBreeze Plugin Guide / 插件開發指南 +# PyBreeze Plugin Guide / 外掛開發指南 PyBreeze is built on JEditor and uses its plugin system unchanged: the `je_editor.plugins` API (`register_programming_language`, `register_natural_language`, `PLUGIN_RUN_CONFIG`), the @@ -9,6 +9,9 @@ for both editors, kept in the JEditor repository: Ready-made plugins (C, C++, Go, Java and Rust highlighting with run support, a French UI translation) live in [IDE_Plugins](https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins). +JEditor now colours those five languages itself, and a registered language's keywords are used +only for a suffix it does not colour, so from these plugins their run configurations are what +takes effect. PyBreeze reads one run-config key JEditor does not: `"encoding"`, the encoding the program writes its output in (`"cp950"`, `"utf-8"`, or `"locale"` for the machine's own). Without it the output is @@ -17,14 +20,15 @@ page, so a Java run config on a non-English Windows wants `"encoding": "locale"` --- -PyBreeze 建立在 JEditor 之上,原封不動地使用它的插件系統:`je_editor.plugins` API +PyBreeze 建立在 JEditor 之上,原封不動地使用它的外掛系統:`je_editor.plugins` API (`register_programming_language`、`register_natural_language`、`PLUGIN_RUN_CONFIG`)、 -`jeditor_plugins/` 目錄,以及 *插件 → Plugin Browser* 選單。所以兩個編輯器共用一份指南,放在 JEditor repo: +`jeditor_plugins/` 目錄,以及 *外掛 → 外掛瀏覽器* 選單。所以兩個編輯器共用一份指南,放在 JEditor repo: **→ [JEditor `PLUGIN_GUIDE.md`](https://github.com/Integration-Automation/JEDITOR/blob/main/PLUGIN_GUIDE.md)** -現成的插件(C、C++、Go、Java、Rust 語法高亮與執行設定,以及法文介面翻譯)放在 +現成的外掛(C、C++、Go、Java、Rust 語法高亮與執行設定,以及法文介面翻譯)放在 [IDE_Plugins](https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins)。 +JEditor 現在自己就會為這五種語言上色,而註冊的語言關鍵字只用在它不上色的副檔名,所以這些外掛實際生效的是執行設定。 PyBreeze 多讀一個 JEditor 不讀的執行設定欄位:`"encoding"`,程式輸出用的編碼(`"cp950"`、`"utf-8"`, 或 `"locale"` 表示這台電腦自己的)。沒寫就用 IDE 的編碼讀,預設 UTF-8。Java 與中文化的編譯器輸出的是 diff --git a/README.md b/README.md index 6bee0d70..6836487e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PyBreeze: The Automation-First IDE -[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![Python 3.10–3.14](https://img.shields.io/badge/python-3.10--3.14-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![PySide6](https://img.shields.io/badge/GUI-PySide6-green.svg)](https://doc.qt.io/qtforpython/) [![Documentation](https://readthedocs.org/projects/pybreeze/badge/?version=latest)](https://pybreeze.readthedocs.io/en/latest/index.html) @@ -11,7 +11,7 @@ ![PyBreeze main window](images/main_window.png) -*The main window: automation keywords highlighted in an APITestka action file, project tree on the left, run/format/debug/terminal panes below.* +*The main window: an APITestka action file open in the editor, project tree on the left, run/format/debug/terminal panes below.* --- @@ -72,17 +72,19 @@ Each module gets the same menu shape: **Run** (single script, batch directory, w ### IDE core -- **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml` and `.yaml`, on top of JEditor's language support -- **Code editor** — built on [JEditor](https://github.com/Integration-Automation/JEDITOR): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane -- **Script execution** — single or batch, each run in a window of its own with a Stop button; action files are passed by path, and a script from the tab in front that is too long for a Windows command line (~32 KB) goes through a temporary file +- **Automation keyword sets** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml` and `.yaml`, on top of JEditor's language support. JEditor does not colour them yet: it highlights those files with its own rules for the suffix, which leave registered keywords out +- **Code editor** — built on [JEditor](https://github.com/Integration-Automation/JEDITOR): tabs, project tree, format checker, debugger, terminal and a git client pane +- **Script execution** — single or batch, each run in a window of its own with a Stop button (Run ▸ Stop All Program stops them all); action files are passed by path, and a script from the tab in front that is too long for a Windows command line (~32 KB) goes through a temporary file - **Report generation** — HTML / JSON / XML after a run, with optional email delivery -- **Integrated JupyterLab** — launches as a tab, installing JupyterLab into the project venv if it is missing -- **Virtual environment awareness** — `venv/` and `.venv/` are detected and used automatically; without one, scripts run on the interpreter the IDE runs on +- **Integrated JupyterLab** — launches as a tab, in the same interpreter a run would use, installing JupyterLab there if it is missing +- **Virtual environment awareness** — a run uses the interpreter chosen under **Python Env**; with none chosen, a `venv/` or `.venv/` in the working folder is detected and used, and without one, the interpreter the IDE runs on --- ## Built-in Tools +In a tool with one main button, Ctrl+Enter anywhere in it presses that button: its text boxes take Enter as a new line. In Query ↔ JSON and the URL parser/builder, which convert both ways, it goes the way the input reads: from JSON when the input is a JSON object. + ### cURL Import — a copied request becomes a runnable script Paste a `curl` command from your browser's dev tools and pick a target. The parser handles method, URL, headers, bodies, basic auth, `-G` query parameters, `-F` multipart fields (uploads become `files=open(...)`), the `--json` shortcut, `-d @file` bodies and multi-line continuations. Repeated `-H` values are combined the way HTTP combines them (`; ` for cookies, `, ` otherwise) instead of the last one silently winning. Nothing is ever executed — it is pure parsing. @@ -117,7 +119,7 @@ Reports names sent more than once, `Set-Cookie` entries missing `Secure` / `Http ![Text Diff](images/tool_diff.png) -Compare two payloads — an expected vs. actual API response, say — and get a unified diff plus a one-line added/removed summary. +Compare two payloads — an expected vs. actual API response, say — and get a unified diff, its added and removed lines in the theme's colours, plus a one-line added/removed summary. ### The everyday utilities @@ -149,23 +151,25 @@ A WYSIWYG `QGraphicsScene` editor: rectangle, rounded, ellipse and diamond nodes ![SSH client](images/ssh_client.png) -Password or private-key authentication (the key file picked with Browse, starting in `~/.ssh`), an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Every SFTP request runs in the background, so a stalled link never freezes the IDE. An upload asks before it replaces a file on the server, and a transfer can be cancelled from the tree's menu. Both directions write to a temporary file first, so a dropped link leaves the old copy whole. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. +Password or private-key authentication (the key file picked with Browse, starting in `~/.ssh`: an RSA, Ed25519 or ECDSA key in OpenSSH or PEM format, PKCS#8 included; a PuTTY `.ppk` key is exported from PuTTYgen as an OpenSSH key first, as the error message says; with key authentication the password field reads Passphrase and takes the key's passphrase), an interactive shell with keepalive that shows ANSI colours, in a fixed-pitch font, whose width and height the shell is told as the view is resized (Up and Down bring back earlier commands, Enter on an empty line reaches the shell, `clear` and `reset` wipe the view, and **Interrupt**, or Ctrl+C in the command line with nothing selected, stops what runs in it; the view shows output line by line, so programs that draw on the whole screen by moving the cursor, such as `vim` or `htop`, come out garbled), and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download (F2 renames and Delete deletes the entry in focus, as in the project tree). Every SFTP request runs in the background, so a stalled link never freezes the IDE. An upload asks before it replaces a file on the server, and a transfer can be cancelled from the tree's menu. Both directions write to a temporary file first, so a dropped link leaves the old copy whole. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. ### And also -- **File Tree Context Menu** — right-click to create, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager (a file shown selected in Explorer and Finder). Renaming or deleting a file open in an editor tab keeps the tab in sync. +- **File Tree Context Menu** — right-click to create, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager (a file shown selected in Explorer and Finder). F2 renames and Delete deletes the item in focus while the tree has the focus. A delete asks first, with No as the default, and moves the item to the trash (the Recycle Bin on Windows); where there is none, as on some network drives, it asks again before deleting for good. Renaming or deleting a file open in an editor tab keeps the tab in sync. - **Package Manager** — install automation modules and build tools from the menu, output in a run window. -- **Integrated Documentation** — each module's docs and GitHub page open as in-IDE browser tabs. +- **Integrated Documentation** — each module's docs and GitHub page open as in-IDE browser tabs (TestPioneer's documentation is its GitHub README). --- ## AI-Assisted Development +As in the tools, Ctrl+Enter in AI Code Review, CoT Code Review or Skill Send presses its send button. + ### AI Code Review ![AI code review client](images/ai_code_review.png) -*Shown in the pre-send state.* Send a selection to an LLM endpoint, then accept or reject the suggestion — the tally is kept in `~/.pybreeze/response_stats.txt`. The URL is SSRF-validated, the connection goes only to the address that was checked, redirects are not followed, and the response body is size-capped before it reaches the panel. +*Shown in the pre-send state.* Send a selection to an LLM endpoint (as the form field `code` in the body of a POST, the default, or a PUT; GET and DELETE send the URL alone), then accept or reject the suggestion — the tally is kept in `~/.pybreeze/response_stats.txt`. The URL is SSRF-validated, the connection goes only to the address that was checked, redirects are not followed, and the response body is size-capped before it reaches the panel. An endpoint on this machine or on a private network, such as a local model server, is therefore refused; CoT Code Review and Skill Send check their endpoint URL the same way. ### Chain-of-Thought Code Review (prthinker) @@ -181,7 +185,7 @@ One settings form holds the inference backend (`remote`, `local`, OpenAI-compati Create and manage the multi-step review chain: first summary → first code review → a judge of that review → linter → code smell detector → step-by-step analysis → total summary → a judge of the summary. Each step quotes the answers it needs from the steps before it. Files are watched, so an external edit shows up immediately. -Run the chain from **Tools → AI → CoT Code Review** (a tab, or a dock from the Dock menu): paste the code, give the endpoint URL, and each step's answer appears in the selector as it arrives. +Run the chain from **Tools → AI → CoT Code Review** (a tab, or a dock from the Dock menu): paste the code, give the endpoint URL, and each step's answer appears in the selector as it arrives. Each step is a POST of the JSON `{"prompt": "..."}`, and the response body, as text, is that step's answer. ### Skill Prompt Editor & Skill Send @@ -189,7 +193,7 @@ Run the chain from **Tools → AI → CoT Code Review** (a tab, or a dock from t |---|---| | ![Skill prompt editor](images/skill_prompt_editor.png) | ![Skill send](images/skills_send.png) | -Define reusable skill prompts (code explanation, code review), then pick one, edit it if needed, and send it to an LLM endpoint from a dedicated tab or dock. *Both shown in the pre-send state — no endpoint was contacted for these screenshots.* +Define reusable skill prompts (code explanation, code review), then pick one, edit it if needed, and send it to an LLM endpoint from a dedicated tab or dock: a POST of the JSON `{"code": "..."}` holding the prompt, whose response body is shown as it is. *Both shown in the pre-send state — no endpoint was contacted for these screenshots.* --- @@ -197,10 +201,10 @@ Define reusable skill prompts (code explanation, code review), then pick one, ed PyBreeze inherits JEditor's plugin architecture, auto-discovered from a `jeditor_plugins/` directory in the working directory. A plugin can register: -- **Syntax highlighting** — keyword sets and rules for any language +- **Syntax highlighting** — keyword sets and rules for a file suffix JEditor does not colour itself (for `.c`, `.cpp`, `.go`, `.java`, `.js`, `.json`, `.rs`, `.sh`, `.sql`, `.toml`, `.ts`, `.yaml` and the other suffixes it colours, its own rules are used) - **UI translations** — new interface languages - **Run configurations** — "Run with…" for interpreted (`go run main.go`) and compiled (`gcc main.c -o main` then run) languages, executed through PyBreeze's `FileRunnerProcess` with the compiled artifact cleaned up afterwards -- **Plugin Browser** — browse and install plugins from remote repositories inside the IDE +- **Plugin Browser** — browse and install plugins from remote repositories inside the IDE, from **Plugins → Plugin Browser**, which is there before any plugin is installed; an installed plugin loads at the next start Loaded plugins appear under their own **Plugins** menu with an About entry and one run action, labelled with the suffixes it runs. [PLUGIN_GUIDE.md](PLUGIN_GUIDE.md) covers what PyBreeze adds and links JEditor's guide, which has the full API and worked examples (C, C++, Go, Java, Rust, and a French translation). @@ -211,7 +215,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 708 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Menus, dialogs, the reasons a tool refuses its input and the run window's own notices (`[Error] …`, `[Run] …`) all follow the chosen language. Both dictionaries carry the same 760 keys, and a test enforces that parity so a new string can never land in one language only. The Language menu also lists JEditor's Japanese and Simplified Chinese: picked, JEditor's own menus change and PyBreeze's strings stay in English. Further languages can be added via translation plugins. --- @@ -321,13 +325,13 @@ python exe/start_pybreeze.py # from the exe directory ```python from pybreeze import start_editor -start_editor() # default dark_amber theme -start_editor(theme="dark_teal.xml") # any qt_material theme +start_editor() # the theme picked from UI Style (dark_amber until one is) +start_editor(theme="dark_teal.xml") # any qt_material theme; it becomes the picked one ``` Once launched: -1. **Write** an automation script in the editor — automation keywords highlight as you type +1. **Write** an automation script in the editor 2. **Run** it from the `Automation` menu, picking the target module 3. **Watch** the output stream into the run window 4. **Generate** an HTML / JSON / XML report @@ -344,7 +348,7 @@ Once launched: | **WebRunner** | Browser driver integration, element location and interaction, web test scripting, reports | | **LoadDensity** | Concurrent request simulation, performance metrics, stress scenario management, reports | | **MailThunder** | SMTP sending, HTML report delivery, attachments, environment-variable configuration | -| **TestPioneer** | YAML test definitions, template generation, structured execution | +| **TestPioneer** | YAML test definitions, template generation, structured execution; `Install ▸ Automation ▸ Install TestPioneer` installs or upgrades it (0.1.34 and later read a YAML file as UTF-8 whatever the system locale) | | **File Automation** | Automated file and directory operations, batch processing | | **prthinker** | Chain-of-thought code review of a file or a Pull Request; settings in `~/.pybreeze/prthinker_setting.json`; installed from its own source folder via `Install ▸ Automation ▸ Install prthinker` (needs Python 3.12+) | @@ -383,10 +387,12 @@ PyBreeze/ │ └── utils/ # curl/HAR parsing, headers, JWT, hashing, │ # URL validation, logging, exceptions, … ├── exe/ # Standalone launcher & build configs -├── docs/ # Sphinx documentation source +├── docs/ # Sphinx documentation source; updates/ is the change log ├── test/ # Unit tests (test_utils) + startup tests ├── images/ # Screenshots +├── architecture.md # Architecture overview: layers, flows, cross-project contracts ├── architecture_explore.md # Module-by-module architecture notes +├── progress.md # Work still to do ├── PLUGIN_GUIDE.md # Plugin development documentation ├── pyproject.toml # Package configuration (stable) ├── dev.toml # Package configuration (dev channel) diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index b45e056a..769d395d 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -1,135 +1,239 @@ # PyBreeze:自动化优先的 IDE -[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![Python 3.10–3.14](https://img.shields.io/badge/python-3.10--3.14-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../LICENSE) [![PySide6](https://img.shields.io/badge/GUI-PySide6-green.svg)](https://doc.qt.io/qtforpython/) +[![Documentation](https://readthedocs.org/projects/pybreeze/badge/?version=latest)](https://pybreeze.readthedocs.io/en/latest/index.html) [English](../README.md) | [繁體中文](README_zh-TW.md) -![主界面](../images/main_gui.png) +**PyBreeze** 是一款专为自动化工程师打造的 Python IDE。Web、API、GUI 和负载测试都在同一个窗口里,旁边还有自动化工作真正需要的日常 HTTP 工具——不用到处找插件,也不用费力考究环境。 -**PyBreeze** 是一款专为自动化工程师打造的 Python IDE。它将 Web、API、GUI 和负载测试自动化整合到单一统一环境中——无需寻找插件、无需复杂的环境配置,打开即可开始自动化。 +![PyBreeze 主窗口](../images/main_window.png) + +*主窗口:编辑器打开着一个 APITestka 动作文件,左侧是项目树,下方是运行/格式检查/调试/终端面板。* --- ## 目录 -- [功能特色](#功能特色) - - [四维自动化](#四维自动化) - - [IDE 核心功能](#ide-核心功能) - - [内置工具](#内置工具) - - [AI 辅助开发](#ai-辅助开发) - - [插件系统](#插件系统) - - [多语言界面](#多语言界面) -- [架构设计](#架构设计) -- [安装方式](#安装方式) +- [截图导览](#截图导览) +- [四维自动化](#四维自动化) +- [内置工具](#内置工具) +- [AI 辅助开发](#ai-辅助开发) +- [插件系统](#插件系统) +- [多语言界面](#多语言界面) +- [架构](#架构) +- [安装](#安装) - [快速开始](#快速开始) - [集成自动化模块](#集成自动化模块) - [项目结构](#项目结构) - [依赖项](#依赖项) +- [测试与 CI](#测试与-ci) - [目标用户](#目标用户) - [许可证](#许可证) --- -## 功能特色 +## 截图导览 + +IDE 的大部分功能都在三个菜单里。**Automation** 运行你的脚本,**Tools** 打开各种工具标签页,**Install** 安装各个模块。 + +| Automation | Tools | Install | +|---|---|---| +| ![Automation 菜单](../images/menu_automation.png) | ![Tools 菜单](../images/menu_tools.png) | ![Install 菜单](../images/menu_install.png) | -### 四维自动化 +每次自动化运行都在独立的子进程中进行。输出会流回运行窗口,编辑器始终保持响应——stdout 以普通颜色显示,stderr 以红色显示,最后是进程的退出码: + +![运行输出窗口](../images/run_output_window.png) + +*一次真实的运行:通过 IDE 的文件运行器调用 PyBreeze 自带的 curl 解析器,生成一个 pytest 测试。* + +--- + +## 四维自动化 PyBreeze 开箱即用,涵盖自动化测试的完整范围: -| 维度 | 模块 | 说明 | +| 维度 | 模块 | 功能 | |---|---|---| -| **Web 自动化** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | 浏览器交互模拟与测试,深度集成浏览器驱动与元素定位器 | -| **API 自动化** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful API 开发与测试,内置请求构建器、响应分析器、Mock 服务器及断言验证 | -| **GUI 自动化** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | 桌面应用程序自动化,支持图像识别、坐标定位、键盘鼠标控制及动作录制 | -| **负载与压力测试** | [LoadDensity](https://github.com/Integration-Automation/LoadDensity) | 高并发性能测试引擎,用于监控系统在极端压力下的稳定性 | +| **API** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful 测试,包含请求构建器、响应分析器、Mock 服务器与断言 | +| **Web** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | 由浏览器驱动的交互与测试,集成驱动程序与元素定位器 | +| **GUI** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | 桌面自动化:图像识别、坐标、键盘/鼠标控制与录制 | +| **Load** | [LoadDensity](https://github.com/Integration-Automation/LoadDensity) | 高并发性能测试,检验系统在压力下的稳定性 | + +此外还有: + +- **文件自动化** — 通过 [automation-file](https://github.com/Integration-Automation/FileAutomation) 进行文件与目录操作 +- **邮件自动化** — 通过 [MailThunder](https://github.com/Integration-Automation/MailThunder) 投递报告 +- **测试框架** — 通过 [TestPioneer](https://github.com/Integration-Automation/TestPioneer) 进行 YAML 驱动的执行 + +每个模块的菜单结构都相同:**Run**(单个脚本、整个目录批量运行,可选择是否以邮件发送报告)、**Help**(文档与 GitHub 以 IDE 内的浏览器标签页打开)、**Project**(生成模板目录),有原生 GUI 的模块还会提供一个 GUI 标签页。 + +### IDE 核心 + +- **自动化关键字集** — 在 JEditor 的语言支持之上,为 `.json` 注册了 `AT_*` / GUI / Web / Load 关键字集,为 `.yml` 和 `.yaml` 注册了 TestPioneer 的结构定义。JEditor 目前还不会为它们着色:它用自己针对这些扩展名的规则高亮,不会用到注册的关键字 +- **代码编辑器** — 基于 [JEditor](https://github.com/Integration-Automation/JEDITOR) 构建:标签页、项目树、格式检查、调试器、终端以及 git 客户端面板 +- **脚本执行** — 单个或批量运行,每次运行都有自己的窗口和 Stop 按钮(Run ▸ Stop All Program 会全部停止);动作文件以路径传入,而前台标签页中的脚本若超出 Windows 命令行的长度上限(约 32 KB),会改经临时文件传递 +- **报告生成** — 运行后生成 HTML / JSON / XML 报告,可选以邮件发送 +- **集成 JupyterLab** — 以标签页方式启动,使用与运行脚本相同的解释器;那里没有 JupyterLab 时会自动安装 +- **虚拟环境感知** — 运行时使用在 **Python Env** 中选择的解释器;没有选择时,自动检测并使用工作文件夹中的 `venv/` 或 `.venv/`,都没有时则以 IDE 本身所用的解释器运行 + +--- + +## 内置工具 + +只有一个主要按钮的工具,在工具里任何地方按 Ctrl+Enter 就等于按下那个按钮:文本框里的 Enter 是换行。Query ↔ JSON 与 URL 解析/构建器可以双向转换,Ctrl+Enter 会根据输入决定方向:输入是 JSON 对象时从 JSON 转出。 + +### cURL 导入——复制的请求变成可运行的脚本 + +从浏览器开发者工具粘贴一条 `curl` 命令,选择目标格式。解析器能处理方法、URL、请求头、请求体、basic auth、`-G` 查询参数、`-F` multipart 字段(上传会变成 `files=open(...)`)、`--json` 简写、`-d @file` 请求体以及多行续行。重复的 `-H` 值会按照 HTTP 的方式合并(cookie 用 `; `,其他用 `, `),而不是悄悄只保留最后一个。它从不执行任何东西——纯粹只做解析。 + +| 目标:pytest | 目标:APITestka JSON 动作 | +|---|---| +| ![cURL 导入为 pytest](../images/tool_curl_import.png) | ![cURL 导入为 APITestka 动作](../images/tool_curl_import_action.png) | + +目标格式:Python `requests`、可直接运行的 **pytest** 测试、**APITestka**(Python,或可由 `execute_files` 直接运行的 `[["AT_test_api_method", {...}]]` 动作列表),以及 **LoadDensity** 的 Locust 负载测试。可以复制输出、直接在编辑器标签页中打开,或以正确的扩展名保存。只需一次点击,还能把解析出的 URL 交给 URL 解析器/构建器,或把请求头交给请求头分析器。 + +### HAR 导入——整个会话变成测试套件 + +"Copy as cURL" 只能抓一个请求;**Save all as HAR** 能抓下整个会话。打开导出的文件,每个记录下来的调用都会列出方法、路径、状态码与媒体类型,页面装饰资源(CSS、图片、字体)默认会被过滤掉。 + +![HAR 导入](../images/tool_har_import.png) + +选择想要的请求——或者直接全选列出的项目——用与 cURL 导入器相同的目标格式生成一个脚本。重复的端点会得到编号的测试名称,不会有测试悄悄覆盖另一个;HTTP/2 伪请求头会被去掉,与记录的 cookie 列表重复的 `Cookie` 请求头也会被移除,让每个值只发送一次(同名的 cookie 无法放进字典,则改以请求头发送)。无法变成请求的条目(例如 URL 或方法格式错误)会被跳过,其余照常加载。只选一个请求时,生成的结果与 cURL 导入器完全相同。HAR 就是 JSON,因此只需要标准库,而且从不会替你重放任何请求。 + +### 响应检查器——粘贴一个响应,读懂其中的一切 + +![响应检查器](../images/tool_response_inspector.png) + +状态码会在 HTTP 参考表中查找,请求头会被解析,JSON 响应体会被格式化,文本中任何位置的 JWT(例如 `Authorization: Bearer` 请求头)都会被解码,其中的时间戳声明以 UTC 显示。每项发现都能在对应的工具标签页中打开,并已预先填好。 + +### HTTP 请求头分析器——一组请求头实际上在说什么 + +![请求头分析器](../images/tool_header_analyzer.png) + +会报告:重复发送的名称、缺少 `Secure` / `HttpOnly` / `SameSite` 的 `Set-Cookie`、通配符 CORS(以及浏览器会直接拒绝的"通配符加凭据"组合)、短到撑不过一次重启的 HSTS `max-age`、CSP 的 `unsafe-inline` / `unsafe-eval`、产品标识、已弃用的请求头,以及——针对响应——缺少的安全请求头。携带凭据的请求头**只报告名称**;它们的值绝不会进入报告。 + +### 文本比较 + +![文本比较](../images/tool_diff.png) + +比较两份数据——例如预期与实际的 API 响应——得到 unified diff(新增与删除的行以主题的颜色标示),以及一行新增/删除的摘要。 + +### 日常小工具 + +每个都是一个标签页或停靠面板,底部都有相同的一排按钮:复制/在编辑器中打开/保存到文件。 + +![JWT 解码器、正则表达式测试器、HTTP 状态码参考、JSON 格式化](../images/tools_montage_a.png) + +- **JWT 解码器** — header 与 payload 以格式化的 JSON 显示,`exp` / `iat` / `nbf` / `auth_time` 以易读的 UTC 显示。只做查看:从不验证签名,也从不信任令牌。 +- **正则表达式测试器** — 支持 `IGNORECASE` / `MULTILINE` / `DOTALL` / `VERBOSE`,列出每个匹配及其偏移量、编号分组与命名分组。在模式框中按 Enter 即可运行。无效的模式会显示友好的错误信息,而不会崩溃。 +- **HTTP 状态码参考** — 按状态码前缀或关键字搜索完整的状态码表(来自标准库,因此始终保持最新)。 +- **JSON 格式化** — 格式化或压缩,输入不是 JSON 时给出清楚的验证错误。 + +![时间戳转换器、哈希生成器、查询字符串/JSON、URL 构建器](../images/tools_montage_b.png) + +- **时间戳转换器** — 输入 Unix 时间戳(秒、毫秒、微秒或纳秒,自动识别)或 ISO-8601 日期时间(支持 `Z`、`+08`、`+0800` 或 `+08:00`,任意位数的小数,基本或扩展格式),输出所有 UTC 表示形式。结果确定,与本地时区无关。 +- **哈希生成器** — 一次算出 SHA-256、SHA-512、SHA-1 与 MD5(MD5/SHA-1 以 `usedforsecurity=False` 提供,只为了互操作,绝不用于安全判断)。 +- **Query ⇄ JSON** — `application/x-www-form-urlencoded` 转为格式化的 JSON,也能转回去;重复的键会变成数组,反之亦然。 +- **URL 解析器/构建器** — 把 scheme、host、port、path、query、fragment 与凭据拆成可编辑的 JSON 对象,也能再组回 URL。会自动为 IPv6 字面量加上方括号,并重新编码查询参数。 + +### 图表编辑器——不离开 IDE 就能画架构图 + +![导入 Mermaid 流程图后的图表编辑器](../images/diagram_editor.png) + +*把一段 Mermaid `flowchart` 粘贴到导入器中,自动完成布局。* + +一个基于 `QGraphicsScene` 的所见即所得编辑器:矩形、圆角矩形、椭圆与菱形节点,带边标签的贝塞尔连线,自由文本与图片。Mermaid `flowchart` / `graph` 导入采用 Sugiyama 式布局(分层、减少交叉、跨轴对齐)。可保存和打开 `.diagram.json`,导出为 PNG 或 SVG,支持撤销/重做、对齐、分布、网格、吸附与缩放。从 URL 获取的图片会经过 SSRF 验证并有大小上限。 + +### SSH 客户端——终端与远程文件树并排 + +![SSH 客户端](../images/ssh_client.png) + +支持密码或私钥认证(用 Browse 选择密钥文件,从 `~/.ssh` 开始:OpenSSH 或 PEM 格式的 RSA、Ed25519、ECDSA 密钥,PKCS#8 也可以;PuTTY 的 `.ppk` 密钥需先在 PuTTYgen 导出为 OpenSSH 密钥,错误信息会说明如何操作;勾选密钥认证时,密码栏会改为“密语”,填入私钥的密语),带 keepalive、会显示 ANSI 颜色的交互式 shell,以等宽字体显示,窗口大小改变时会把新的宽度和高度告诉 shell(上下方向键调出之前发送的命令,空行按 Enter 也会发送到 shell,`clear` 与 `reset` 会清空画面,**Interrupt** 按钮、或在未选中文字的命令行中按 Ctrl+C,可停止 shell 中正在运行的程序;界面逐行显示输出,所以 `vim`、`htop` 这类移动光标绘制整个屏幕的程序会显示错乱),以及按需加载的 SFTP 文件树,支持创建文件夹/重命名/删除/上传/下载(和项目文件树一样,F2 重命名、Delete 删除当前项目)。每个 SFTP 请求都在后台运行,因此连接卡住时也不会冻结 IDE。上传前若会覆盖服务器上的文件会先询问,传输也可以从文件树的菜单中取消。两个方向都先写入临时文件,因此连接中断时,旧的副本仍完整保留。未知的主机密钥**不会**被自动接受:首次连接时会显示 SHA256 指纹请你确认(首次使用即信任),并保存到 `~/.pybreeze/ssh_known_hosts`。 -此外还包含: +### 其他 -- **文件自动化** — 通过 [automation-file](https://github.com/Integration-Automation/FileAutomation) 模块实现自动化文件与目录操作 -- **邮件自动化** — 通过 [MailThunder](https://github.com/Integration-Automation/MailThunder) 实现自动化邮件发送(例如测试报告投递) -- **测试框架** — 通过 [TestPioneer](https://github.com/Integration-Automation/TestPioneer) 实现结构化 YAML 驱动的测试执行 +- **文件树右键菜单** — 右键即可创建、重命名、删除、复制绝对或相对路径,或在系统的文件管理器中显示该项目(在 Explorer 与 Finder 中会选中该文件)。焦点在文件树时,F2 重命名、Delete 删除当前项目。删除前会先询问,默认是“否”,删除的项目会移到回收站(Windows 的回收站);没有回收站的地方(例如某些网络驱动器),会再询问一次才永久删除。重命名或删除一个已在编辑器标签页中打开的文件时,标签页会保持同步。 +- **包管理器** — 从菜单安装自动化模块与构建工具,输出显示在运行窗口中。 +- **集成文档** — 每个模块的文档与 GitHub 页面都以 IDE 内的浏览器标签页打开(TestPioneer 的文档就是它 GitHub 上的 README)。 -### IDE 核心功能 +--- + +## AI 辅助开发 -PyBreeze 不仅仅是一个代码编辑器——它是自动化生命周期的指挥中心: +和工具一样,在 AI 代码审查、CoT 代码审查或 Skill Send 中按 Ctrl+Enter,就等于按下发送按钮。 -- **语法高亮** — 内置 Python 语法高亮,针对自动化库(APITestka、AutoControl、WebRunner、LoadDensity 等)提供深度关键字识别。可通过插件添加自定义语法规则。 -- **代码编辑器** — 基于 [JEditor](https://github.com/Integration-Automation/JEDITOR) 构建,提供完整的编辑器功能,包含标签页管理、文件树导航与项目工作区支持。 -- **脚本执行** — 直接在 IDE 中执行自动化脚本,并实时显示输出。支持单脚本与多脚本批量执行。 -- **报告生成** — 自动化模块可在测试执行后生成 HTML、JSON 和 XML 报告,并支持可选的电子邮件投递。 -- **集成 JupyterLab** — 在 PyBreeze 中直接以标签页方式启动 JupyterLab,进行交互式笔记本开发。若未安装 JupyterLab 将自动安装。 -- **虚拟环境感知** — 自动检测并使用项目的虚拟环境(`.venv` 或 `venv`)。 +### AI 代码审查 -### 内置工具 +![AI 代码审查客户端](../images/ai_code_review.png) -- **SSH 客户端** — 完整的 SSH 终端客户端,支持: - - 密码与私钥认证 - - 交互式命令执行 - - 远程文件树查看器,支持 CRUD 操作(创建文件夹、重命名、删除、上传、下载) - - 交互式 TOFU host key 验证,已确认的密钥会持久化到 `~/.pybreeze/ssh_known_hosts` -- **架构图编辑器** — 内置的 WYSIWYG 架构图编辑器: - - 矩形、圆角矩形、椭圆、菱形节点、连线、自由文本 - - 从本地文件或 URL 插入图片(URL 下载会做 SSRF 验证并有大小上限) - - 支持 Mermaid `flowchart` / `graph` 导入 - - 保存/打开为 `.diagram.json`,导出为 PNG 或 SVG - - Undo/redo、对齐、分布、Grid、Snap、Zoom 控制 -- **文件树右键菜单** — 在项目文件树中对任何文件或文件夹右键,可创建文件/文件夹、重命名、删除、复制绝对或相对路径、在系统文件管理器中打开。重命名或删除当前已在编辑器标签页中打开的文件时,标签页会同步更新。 -- **包管理器** — 直接从 IDE 菜单安装自动化模块和构建工具,无需离开编辑器。 -- **集成文档** — 从菜单栏快速访问每个自动化模块的文档和 GitHub 页面。 +*图中为发送前的状态。* 把选中的代码发送到 LLM 端点(以 POST(默认)或 PUT 发送,代码放在请求体的表单字段 `code` 中;GET 与 DELETE 只发送 URL),然后接受或拒绝建议——统计记录保存在 `~/.pybreeze/response_stats.txt`。URL 会经过 SSRF 验证,连接只会连到经过检查的地址,不跟随重定向,响应体在到达面板之前会被限制大小。因此本机或私有网络上的端点(例如在本机运行的模型服务器)会被拒绝;CoT Code Review 与 Skill Send 也以同样的方式检查端点 URL。 -### AI 辅助开发 +### 思维链代码审查(prthinker) -- **AI 代码审查** — 将代码发送到 LLM API 端点进行自动化代码审查。可直接在 IDE 中接受或拒绝建议。 -- **思维链代码审查(prthinker)** — 以 [prthinker](https://github.com/JE-Chen/Code-Review-Framework-Combining-Large-Language-Models-and-Chain-of-Thought-Reasoning) 的审查流程审查正在编辑的文件,或审查一个 Pull Request,输出实时流进运行窗口。一张设置表填完推理后端(审查服务器、OpenAI 兼容端点、Anthropic 或本机模型)、代码托管平台与仓库;密钥与令牌以环境变量交给审查,不会出现在命令行上——那是任务管理器看得到的地方。 -- **CoT(思维链)提示词编辑器** — 创建和管理多步骤 CoT 提示词,用于结构化代码分析,包含: - - 代码审查提示词 - - Code Smell 检测 - - 代码检查分析 - - 逐步分析 - - 摘要生成 -- **Skill 提示词编辑器** — 定义和管理可重复使用的技能型提示词(代码解说、代码审查模板),可发送至 LLM API。 -- **Skill Send GUI** — 在独立的标签页或停靠面板中选择技能提示词模板、按需编辑提示词内容、发送到 LLM API 端点并查看响应。 +对正在编辑的文件或一个 Pull Request 运行 [prthinker](https://github.com/JE-Chen/Code-Review-Framework-Combining-Large-Language-Models-and-Chain-of-Thought-Reasoning) 流程,输出实时流入运行窗口。 -### 插件系统 +![prthinker 设置](../images/prthinker_setting.png) -PyBreeze 支持可扩展的插件架构,用于: +一张设置表就包含推理后端(`remote`、`local`、OpenAI 兼容、Anthropic、Gemini、Cohere、Mistral、`claude-cli`、`codex-cli`)、代码托管平台(GitHub / GitLab / Gitea)与仓库。**密钥与令牌以环境变量交给审查,绝不放在命令行上**——那是进程列表看得到的地方——并且在日志中会被遮蔽。模型名称会交给所选的后端。规则检索(RAG)默认为 `off`,除非设为 `remote`,此时会向 prthinker 服务器的 `/rag` 查询:prthinker 的本地规则索引随它的仓库提供,而不在从仓库安装的包里。审查以 `Python Env` 中选择的解释器运行,因此 PyBreeze 本身可以停留在比 prthinker 所需的 3.12 更旧的 Python 上。 -- **语法高亮** — 通过插件为任何编程语言添加语法高亮 -- **UI 翻译** — 通过翻译插件添加新的界面语言 -- **运行配置** — 为编译型和解释型语言添加"以...运行"支持(C、C++、Go、Java、Rust 等) -- **插件浏览器** — 直接在 IDE 中从远程仓库浏览并安装插件 +### CoT 提示词编辑器 -插件会从 `jeditor_plugins/` 目录自动发现加载。完整文档请参阅 [PLUGIN_GUIDE.md](../PLUGIN_GUIDE.md)。 +![CoT 提示词编辑器](../images/cot_prompt_editor.png) -**内置插件:** C、C++、Go、Java、Rust 语法高亮与运行支持;法语翻译。 +创建和管理多步骤的审查链:第一次摘要 → 第一次代码审查 → 对该审查的评判 → linter → 代码坏味道检测 → 逐步分析 → 总摘要 → 对摘要的评判。每一步都会引用它所需的前面步骤的回答。文件受到监视,因此外部的编辑会立即显示出来。 -### 多语言界面 +从 **Tools → AI → CoT Code Review**(一个标签页,或从 Dock 菜单打开停靠面板)运行这条审查链:粘贴代码,填入端点 URL,每一步的回答一到达就会出现在选择器中。每一步都以 POST 发送 JSON `{"prompt": "..."}`,响应体(按文本读取)就是这一步的回答。 -IDE 界面支持多种语言: +### Skill 提示词编辑器与 Skill Send + +| Skill 提示词编辑器 | Skill Send | +|---|---| +| ![Skill 提示词编辑器](../images/skill_prompt_editor.png) | ![Skill Send](../images/skills_send.png) | -- **English**(英语,默认) -- **繁体中文** -- 可通过插件添加其他语言 +定义可重复使用的技能提示词(代码解说、代码审查),然后选择一个,按需编辑,再从专用的标签页或停靠面板发送到 LLM 端点:以 POST 发送装有提示词的 JSON `{"code": "..."}`,响应体按原样显示。*两张图都是发送前的状态——拍摄这些截图时没有连接任何端点。* --- -## 架构设计 +## 插件系统 + +PyBreeze 继承了 JEditor 的插件架构,会从工作目录下的 `jeditor_plugins/` 目录自动发现插件。插件可以注册: + +- **语法高亮** — JEditor 自身不着色的文件扩展名的关键字集与规则(`.c`、`.cpp`、`.go`、`.java`、`.js`、`.json`、`.rs`、`.sh`、`.sql`、`.toml`、`.ts`、`.yaml` 等 JEditor 会着色的扩展名,使用的是它自己的规则) +- **界面翻译** — 新的界面语言 +- **运行配置** — 为解释型(`go run main.go`)与编译型(`gcc main.c -o main` 后再运行)语言提供"Run with…",通过 PyBreeze 的 `FileRunnerProcess` 执行,编译产物在运行后会被清理 +- **插件浏览器** — 在 IDE 内从远程仓库浏览并安装插件,入口是 **Plugins → Plugin Browser**,尚未安装任何插件时也在;装好的插件在下次启动时加载 + +已加载的插件会出现在它们自己的 **Plugins** 菜单下,包含一个 About 项与一个运行动作,标签上注明它能运行的文件扩展名。[PLUGIN_GUIDE.md](../PLUGIN_GUIDE.md) 介绍 PyBreeze 额外提供的内容,并链接到 JEditor 的指南,那里有完整的 API 与示例(C、C++、Go、Java、Rust,以及一份法语翻译)。 + +--- + +## 多语言界面 + +- **English**(默认) +- **繁體中文**(Traditional Chinese) + +菜单、对话框、工具拒绝输入时给出的原因,以及运行窗口自身的提示(`[Error] …`、`[Run] …`)都会跟随所选语言。两份词典包含同样的 760 个键,并有测试确保两者一致,因此新字符串绝不会只出现在一种语言中。语言菜单还列出 JEditor 的日文与简体中文:选择后 JEditor 自己的菜单会随之改变,PyBreeze 的字符串则保持英文。其他语言可以通过翻译插件添加。 + +--- + +## 架构 ```mermaid flowchart TB UI["PyBreeze UI · PySide6"] - subgraph Editor["JEditor 基础编辑器"] + subgraph Editor["JEditor (Base Editor)"] direction LR - E1["代码编辑器 + 标签页"] - E2["文件树"] - E3["语法高亮"] - E4["插件系统"] + E1["Code Editor + Tabs"] + E2["File Tree"] + E3["Syntax Highlighting"] + E4["Plugin System"] end - subgraph Automation["自动化菜单"] + subgraph Automation["Automation Menu"] direction LR A1["APITestka"] A2["AutoControl"] @@ -140,7 +244,7 @@ flowchart TB A7["TestPioneer"] end - subgraph Executors["子进程执行器 · TaskProcessManager"] + subgraph Executors["Subprocess Executors · TaskProcessManager"] direction LR X1["je_api_testka"] X2["je_auto_control"] @@ -151,21 +255,20 @@ flowchart TB X7["test_pioneer"] end - subgraph Tools["工具"] + subgraph Tools["Tools"] direction LR T1["SSH · paramiko"] - T2["AI 代码审查"] - T3["CoT 提示词编辑器"] - T4["Skill 提示词编辑器"] - T5["Skill Send GUI"] - T6["架构图编辑器"] - T7["JupyterLab"] + T2["AI Code Review"] + T3["Prompt Editors"] + T4["Diagram Editor"] + T5["HTTP Toolbelt"] + T6["JupyterLab"] end - subgraph Install["安装菜单"] + subgraph Install["Install Menu"] direction LR - I1["模块安装器"] - I2["构建工具"] + I1["Module Installers"] + I2["Build Tools"] end UI --> Editor @@ -182,41 +285,13 @@ flowchart TB A7 --> X7 ``` -PyBreeze 采用模块化架构: - -``` -PyBreeze UI (PySide6) -├── JEditor(基础编辑器引擎) -│ ├── 代码编辑器与标签页 -│ ├── 文件树导航 -│ ├── 语法高亮引擎 -│ └── 插件系统 -├── 自动化菜单 -│ ├── APITestka ──→ APITestka 执行器 ──→ je_api_testka -│ ├── AutoControl ──→ AutoControl 执行器 ──→ je_auto_control -│ ├── WebRunner ──→ WebRunner 执行器 ──→ je_web_runner -│ ├── LoadDensity ──→ LoadDensity 执行器 ──→ je_load_density -│ ├── FileAutomation ──→ FileAutomation 执行器 ──→ automation-file -│ ├── MailThunder ──→ MailThunder 执行器 ──→ je-mail-thunder -│ └── TestPioneer ──→ TestPioneer 执行器 ──→ test_pioneer -├── 工具 -│ ├── SSH 客户端(paramiko) -│ ├── AI 代码审查客户端 -│ ├── CoT 提示词编辑器 -│ ├── Skill 提示词编辑器 -│ ├── Skill Send GUI -│ ├── 架构图编辑器(WYSIWYG、Mermaid 导入、PNG/SVG 导出) -│ └── JupyterLab 集成 -└── 安装菜单 - ├── 自动化模块安装器 - └── 构建工具安装器 -``` +**编辑器进程从不运行你的脚本。** 每个自动化模块都以 `python -m ` 的方式、用项目的解释器、以 `shell=False` 启动。两个守护线程把 stdout 与 stderr 读入线程安全的队列;一个 100 ms 的 `QTimer` 以有上限的批次把它们送到 UI 线程。脚本崩溃、卡住或无限打印,都不会拖垮 IDE。 -每个自动化模块都通过 `PythonTaskProcessManager` 在独立的子进程中执行,提供进程隔离,防止崩溃影响 IDE。 +逐个模块的代码导览请参阅 [architecture_explore.md](../architecture_explore.md)。 --- -## 安装方式 +## 安装 ### 从 PyPI 安装 @@ -234,103 +309,48 @@ pip install -r requirements.txt ### 系统要求 -- **Python**:3.10 或更高版本 +- **Python**:3.10 – 3.14 - **操作系统**:Windows、macOS、Linux -- **GUI 框架**:PySide6 6.11.2(自动安装) +- **GUI**:PySide6 6.11.2(自动安装) --- ## 快速开始 -### 通过命令行运行 - ```bash -python -m pybreeze +python -m pybreeze # 命令行 +python exe/start_pybreeze.py # 从 exe 目录运行 ``` -### 通过 Python 脚本运行 - ```python from pybreeze import start_editor -start_editor() -``` - -### 从 exe 目录运行 - -```bash -python exe/start_pybreeze.py +start_editor() # 在 UI Style 中选定的主题(尚未选择时为 dark_amber) +start_editor(theme="dark_teal.xml") # 任意 qt_material 主题;它会成为选定的主题 ``` -启动后,您可以: +启动后: -1. **编写自动化脚本** — 在编辑器中享有语法感知的自动补全 -2. **执行脚本** — 通过 `自动化` 菜单,选择目标模块(APITestka、WebRunner 等) -3. **查看结果** — 在集成式输出面板中查看 -4. **生成报告** — 支持 HTML/JSON/XML 格式 -5. **发送报告** — 使用 MailThunder 集成功能通过电子邮件发送 +1. **编写** — 在编辑器中编写自动化脚本 +2. **运行** — 从 `Automation` 菜单运行,选择目标模块 +3. **查看** — 输出实时流入运行窗口 +4. **生成** — 生成 HTML / JSON / XML 报告 +5. **发送** — 通过 MailThunder 集成以电子邮件发送报告 --- ## 集成自动化模块 -### APITestka — API 测试 - -- HTTP 方法测试(GET、POST、PUT、DELETE 等) -- 通过 httpx 支持异步 HTTP -- 使用 Flask 创建 Mock 服务器 -- 报告生成(HTML、JSON、XML) -- 基于调度器的事件触发 -- Socket 服务器支持 - -### AutoControl — GUI 自动化 - -- 鼠标控制(点击、拖拽、滚动、位置追踪) -- 键盘模拟(输入、快捷键、按键按下/释放) -- 图像识别与定位点击 -- 屏幕截图 -- 动作录制与回放 -- Shell 命令执行 -- 进程管理 - -### WebRunner — Web 自动化 - -- 浏览器驱动集成 -- 元素定位与交互 -- 基于 Web 的测试脚本 -- 报告生成 - -### LoadDensity — 负载测试 - -- 并发请求模拟 -- 性能指标收集 -- 压力测试场景管理 -- 报告生成 - -### MailThunder — 邮件自动化 - -- SMTP 邮件发送 -- HTML 报告投递 -- 附件支持 -- 基于环境变量的配置 - -### TestPioneer — 测试框架 - -- 基于 YAML 的测试定义 -- 模板生成 -- 结构化测试执行 - -### File Automation — 文件自动化 - -- 自动化文件与目录操作 -- 批量文件处理 - -### prthinker — 思维链代码审查 - -- 审查正在编辑的文件,或审查 GitHub / GitLab / Gitea 上的 Pull Request -- 推理后端可选:审查服务器、OpenAI 兼容端点、Anthropic,或本机模型 -- 设置存放在用户目录的 `~/.pybreeze/prthinker_setting.json`;密钥以环境变量交给审查 -- 从它自己的源码文件夹安装(**安装 ▸ 自动化 ▸ 安装 prthinker**),需要 Python 3.12 以上 +| 模块 | 功能 | +|---|---| +| **APITestka** | HTTP 方法、通过 httpx 的异步请求、Flask Mock 服务器、HTML/JSON/XML 报告、调度器触发、socket 服务器、JSON-schema 与 JSONPath 断言、SLA 检查、录制回放 cassette | +| **AutoControl** | 鼠标(点击、拖拽、滚动、位置)、键盘(输入、快捷键、按下/释放)、图像识别与定位点击、截图、录制与回放、shell 与进程控制 | +| **WebRunner** | 浏览器驱动集成、元素定位与交互、Web 测试脚本、报告 | +| **LoadDensity** | 并发请求模拟、性能指标、压力场景管理、报告 | +| **MailThunder** | SMTP 发送、HTML 报告投递、附件、环境变量配置 | +| **TestPioneer** | YAML 测试定义、模板生成、结构化执行;`Install ▸ Automation ▸ Install TestPioneer` 可安装或升级(0.1.34 起无论系统区域设置,都以 UTF-8 读取 YAML 文件) | +| **File Automation** | 自动化文件与目录操作、批量处理 | +| **prthinker** | 对文件或 Pull Request 进行思维链代码审查;设置保存在 `~/.pybreeze/prthinker_setting.json`;通过 `Install ▸ Automation ▸ Install prthinker` 从它自己的源码文件夹安装(需要 Python 3.12+) | --- @@ -339,39 +359,44 @@ python exe/start_pybreeze.py ``` PyBreeze/ ├── pybreeze/ -│ ├── __init__.py # 公开 API(start_editor、插件 re-export) -│ ├── __main__.py # 入口点(python -m pybreeze) +│ ├── __init__.py # 公开 API(start_editor、插件 re-export) +│ ├── __main__.py # 入口点(python -m pybreeze) │ ├── extend/ -│ │ ├── mail_thunder_extend/ # 测试后邮件报告发送 -│ │ ├── process_executor/ # 各自动化模块的子进程管理器 -│ │ │ ├── api_testka/ -│ │ │ ├── auto_control/ -│ │ │ ├── file_automation/ -│ │ │ ├── load_density/ -│ │ │ ├── mail_thunder/ -│ │ │ ├── test_pioneer/ -│ │ │ └── web_runner/ -│ │ └── process_executor/python_task_process_manager.py -│ ├── extend_multi_language/ # 内置翻译(英语、繁体中文) +│ │ ├── process_executor/ # 子进程隔离层 +│ │ │ ├── python_task_process_manager.py # TaskProcessManager(核心) +│ │ │ ├── process_executor_utils.py # build_process / start_process +│ │ │ ├── file_runner_process.py # 插件运行配置(任意语言) +│ │ │ ├── queue_pump.py # 共用的管道读取器 + QTimer 排空 +│ │ │ ├── api_testka/ auto_control/ web_runner/ +│ │ │ ├── load_density/ file_automation/ mail_thunder/ +│ │ │ ├── test_pioneer/ prthinker/ +│ │ ├── mail_thunder_extend/ # 测试后邮件报告钩子 +│ │ └── prthinker_extend/ # prthinker 设置与参数组装 +│ ├── extend_multi_language/ # 内置多语言(英语、繁体中文) │ ├── pybreeze_ui/ -│ │ ├── editor_main/ # 主窗口(扩展 JEditor)+ 文件树右键菜单 -│ │ ├── connect_gui/ssh/ # SSH 客户端组件(TOFU host key 验证) -│ │ ├── diagram_editor/ # WYSIWYG 架构图编辑器 -│ │ ├── extend_ai_gui/ # AI 代码审查与提示词编辑器 -│ │ ├── jupyter_lab_gui/ # JupyterLab 集成 -│ │ ├── menu/ # 菜单栏构建 -│ │ ├── syntax/ # 自动化关键字定义 -│ │ └── show_code_window/ # 代码显示组件 -│ └── utils/ # 日志、异常处理、文件处理、包管理 -├── exe/ # 独立启动器与构建配置 -├── docs/ # Sphinx 文档源码 -├── test/ # 单元测试 -├── images/ # 截图 -├── architecture_diagram/ # 架构图 -├── PLUGIN_GUIDE.md # 插件开发文档 -├── pyproject.toml # 包配置 -├── requirements.txt # 运行时依赖项 -└── dev_requirements.txt # 开发依赖项 +│ │ ├── editor_main/ # 主窗口 + 文件树右键菜单 +│ │ ├── menu/ # Automation / Install / Tools / Plugins 菜单 +│ │ ├── tools_gui/ # cURL、HAR、JWT、diff、regex …… 工具标签页 +│ │ ├── diagram_editor/ # 所见即所得图表编辑器 +│ │ ├── extend_ai_gui/ # CoT 审查、提示词编辑器、skill send +│ │ ├── connect_gui/ # SSH 终端 + SFTP 文件树、AI 审查客户端 +│ │ ├── jupyter_lab_gui/ # JupyterLab 标签页 +│ │ ├── show_code_window/ # CodeWindow(运行输出) +│ │ ├── dialog/ # prthinker 设置对话框 +│ │ └── syntax/ # 自动化关键字定义 +│ └── utils/ # curl/HAR 解析、请求头、JWT、哈希、 +│ # URL 验证、日志、异常 …… +├── exe/ # 独立启动器与构建配置 +├── docs/ # Sphinx 文档源码;updates/ 是更新记录 +├── test/ # 单元测试(test_utils)+ 启动测试 +├── images/ # 截图 +├── architecture.md # 架构总览:分层、主要流程、跨项目约定 +├── architecture_explore.md # 逐个模块的架构说明 +├── progress.md # 尚未完成的工作 +├── PLUGIN_GUIDE.md # 插件开发文档 +├── pyproject.toml # 包配置(稳定版) +├── dev.toml # 包配置(开发通道) +└── requirements.txt # 运行时依赖项 ``` --- @@ -382,35 +407,51 @@ PyBreeze/ | 包 | 用途 | |---|---| -| `PySide6` (6.11.2) | GUI 框架(Qt for Python)| +| `PySide6` (6.11.2) | GUI 框架(Qt for Python) | | `je-editor` | 基础代码编辑器引擎 | | `je_api_testka` | API 测试自动化 | -| `je_auto_control` | GUI/桌面自动化 | +| `je_auto_control` | GUI/桌面自动化 | | `je_web_runner` | Web 浏览器自动化 | | `je_load_density` | 负载与压力测试 | | `je-mail-thunder` | 邮件自动化 | | `automation-file` | 文件操作自动化 | | `test_pioneer` | 基于 YAML 的测试框架 | | `paramiko` | SSH 客户端支持 | -| `jupyterlab` | 集成式笔记本环境 | +| `jupyterlab` | 集成的笔记本环境 | ### 开发 -`build`、`twine`、`sphinx`、`sphinx-rtd-theme`、`auto-py-to-exe` +`build`、`twine`、`sphinx`、`sphinx-rtd-theme`、`auto-py-to-exe`、`pytest`、`pytest-cov`、`hypothesis`、`ruff` + +--- + +## 测试与 CI + +```bash +python -m pip install -r dev_requirements.txt +python -m pytest test/test_utils/ -v --tb=short +``` + +- **单元测试** — `test/test_utils/`,覆盖纯逻辑层(curl 与 HAR 解析、请求头分析、SSRF 验证、JWT、哈希、时间戳、差异比较),加上通过 `QT_QPA_PLATFORM=offscreen` 进行的无界面 Qt 组件测试,以及针对解析器的 Hypothesis 属性测试 +- **启动测试** — `test/unit_test/start_automation/` 以调试模式启动 IDE,验证它能正常启动并干净地退出 +- **CI** — GitHub Actions 在 Windows 上跑 Python 3.10 – 3.14,每次 push 与 PR 都会运行,另有每晚一次的运行 +- **静态分析** — SonarCloud、Codacy 与 Bandit --- ## 目标用户 -- **Python 开发者** — 一个轻量、专用的环境,用于构建自动化脚本,无需承受重量级通用 IDE 的负担 -- **SDET(测试开发工程师)** — 需要在同一工具中同时维护 Web、API 和性能测试的专业人士 -- **自动化初学者** — 一个友好的 IDE,通过零配置环境降低 Python 自动化的入门门槛 -- **DevOps 团队** — 一个在 CI/CD 流水线中快速构建和调试集成测试套件的平台 +- **Python 开发者** — 一个轻量、专用的自动化脚本环境,没有通用 IDE 的负担 +- **SDET(测试开发工程师)** — 用一个工具同时维护 Web、API 与性能测试 +- **自动化初学者** — 零配置的环境设置,每个模块都有菜单 +- **DevOps 团队** — 构建和调试将要进入 CI/CD 的集成测试套件的地方 --- ## 许可证 -本项目采用 MIT 许可证——详情请参阅 [LICENSE](../LICENSE) 文件。 +MIT — 详见 [LICENSE](../LICENSE)。Copyright (c) 2022 JE-Chen + +--- -Copyright (c) 2022 JE-Chen +截图均取自 Windows 11 上实际的 PyBreeze 组件,使用默认的 `dark_amber` 主题;各工具中的示例数据都是经真实代码路径处理的真实输入。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 08a57468..318c2972 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -1,135 +1,239 @@ # PyBreeze:自動化優先的 IDE -[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![Python 3.10–3.14](https://img.shields.io/badge/python-3.10--3.14-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../LICENSE) [![PySide6](https://img.shields.io/badge/GUI-PySide6-green.svg)](https://doc.qt.io/qtforpython/) +[![Documentation](https://readthedocs.org/projects/pybreeze/badge/?version=latest)](https://pybreeze.readthedocs.io/en/latest/index.html) [English](../README.md) | [简体中文](README_zh-CN.md) -![主介面](../images/main_gui.png) +**PyBreeze** 是一款專為自動化工程師打造的 Python IDE。Web、API、GUI 與負載測試都在同一個視窗裡,旁邊還有自動化工作實際需要的日常 HTTP 工具——不必四處找外掛,也不必挖掘環境設定。 -**PyBreeze** 是一款專為自動化工程師打造的 Python IDE。它將 Web、API、GUI 和負載測試自動化整合到單一統一環境中——無需尋找插件、無需複雜的環境設定,開啟即可開始自動化。 +![PyBreeze 主視窗](../images/main_window.png) + +*主視窗:編輯器開著一個 APITestka 動作檔,左側是專案樹,下方是執行/格式檢查/除錯/終端機面板。* --- ## 目錄 -- [功能特色](#功能特色) - - [四維自動化](#四維自動化) - - [IDE 核心功能](#ide-核心功能) - - [內建工具](#內建工具) - - [AI 輔助開發](#ai-輔助開發) - - [插件系統](#插件系統) - - [多語言介面](#多語言介面) -- [架構設計](#架構設計) -- [安裝方式](#安裝方式) +- [截圖導覽](#截圖導覽) +- [四維自動化](#四維自動化) +- [內建工具](#內建工具) +- [AI 輔助開發](#ai-輔助開發) +- [外掛系統](#外掛系統) +- [多語言介面](#多語言介面) +- [架構](#架構) +- [安裝](#安裝) - [快速開始](#快速開始) -- [整合自動化模組](#整合自動化模組) +- [整合的自動化模組](#整合的自動化模組) - [專案結構](#專案結構) -- [依賴項目](#依賴項目) +- [相依套件](#相依套件) +- [測試與 CI](#測試與-ci) - [目標使用者](#目標使用者) - [授權條款](#授權條款) --- -## 功能特色 +## 截圖導覽 + +IDE 的大部分功能集中在三個選單(繁體中文介面顯示為括號內的名稱)。**Automation**(自動化)執行你的腳本,**Tools**(工具)開啟各種工具分頁,**Install**(安裝)下載安裝各個模組。 + +| Automation | Tools | Install | +|---|---|---| +| ![Automation 選單](../images/menu_automation.png) | ![Tools 選單](../images/menu_tools.png) | ![Install 選單](../images/menu_install.png) | -### 四維自動化 +每次自動化執行都在獨立的子行程中進行。輸出會即時傳回執行視窗,編輯器同時保持可操作——stdout 以一般顏色顯示,stderr 以紅色顯示,最後附上行程的結束代碼: + +![執行輸出視窗](../images/run_output_window.png) + +*一次實際的執行:透過 IDE 的檔案執行器呼叫 PyBreeze 自己的 curl 解析器,產生一支 pytest 測試。* + +--- + +## 四維自動化 PyBreeze 開箱即用,涵蓋自動化測試的完整範疇: -| 維度 | 模組 | 說明 | +| 維度 | 模組 | 功能 | |---|---|---| -| **Web 自動化** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | 瀏覽器互動模擬與測試,深度整合瀏覽器驅動與元素定位器 | -| **API 自動化** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful API 開發與測試,內建請求建構器、回應分析器、Mock 伺服器及斷言驗證 | -| **GUI 自動化** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | 桌面應用程式自動化,支援圖像辨識、座標定位、鍵盤滑鼠控制及動作錄製 | -| **負載與壓力測試** | [LoadDensity](https://github.com/Integration-Automation/LoadDensity) | 高併發效能測試引擎,用於監控系統在極端壓力下的穩定性 | +| **API** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful 測試,內建請求建構器、回應分析器、Mock 伺服器與斷言 | +| **Web** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | 以瀏覽器驅動的互動與測試,整合驅動程式與元素定位器 | +| **GUI** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | 桌面自動化,支援圖像辨識、座標、鍵盤/滑鼠控制與錄製 | +| **Load** | [LoadDensity](https://github.com/Integration-Automation/LoadDensity) | 高併發效能測試,檢驗系統在壓力下的穩定性 | + +此外還有: + +- **檔案自動化** — 透過 [automation-file](https://github.com/Integration-Automation/FileAutomation) 進行檔案與目錄操作 +- **郵件自動化** — 透過 [MailThunder](https://github.com/Integration-Automation/MailThunder) 寄送報告 +- **測試框架** — 透過 [TestPioneer](https://github.com/Integration-Automation/TestPioneer) 以 YAML 驅動執行 + +每個模組的選單結構都一樣:**Run**(單一腳本、整個目錄批次執行,可選擇是否寄出報告郵件)、**Help**(文件與 GitHub 頁面以 IDE 內的瀏覽器分頁開啟)、**Project**(建立範本目錄),以及在有提供時的原生 GUI 分頁。 + +### IDE 核心 + +- **自動化關鍵字集** — 在 JEditor 的語言支援之上,`AT_*`/GUI/Web/Load 關鍵字集註冊給 `.json`,TestPioneer 的結構描述註冊給 `.yml` 與 `.yaml`。JEditor 目前還不會為它們上色:它用自己針對這些副檔名的規則高亮,不會用到註冊的關鍵字 +- **程式碼編輯器** — 以 [JEditor](https://github.com/Integration-Automation/JEDITOR) 為基礎:分頁、專案樹、格式檢查、除錯器、終端機與 git 用戶端面板 +- **腳本執行** — 單一或批次執行,每次執行都有自己的視窗與 Stop 按鈕(Run ▸ Stop All Program 會全部停止);動作檔以路徑傳入,而目前分頁中的腳本若超過 Windows 命令列長度上限(約 32 KB),會改用暫存檔傳遞 +- **報告產生** — 執行後產生 HTML/JSON/XML 報告,並可選擇以電子郵件寄送 +- **整合 JupyterLab** — 以分頁方式啟動,使用與執行腳本相同的直譯器;那裡沒有 JupyterLab 時會自動安裝 +- **虛擬環境感知** — 執行時使用在 **Python Env** 選擇的直譯器;沒有選擇時,自動偵測並使用工作資料夾中的 `venv/` 或 `.venv/`,都沒有時則以 IDE 本身使用的直譯器執行 + +--- + +## 內建工具 + +只有一個主要按鈕的工具,在工具裡任何地方按 Ctrl+Enter 就等於按下那顆按鈕:文字框裡的 Enter 是換行。Query ↔ JSON 與 URL 解析/組建器可以雙向轉換,Ctrl+Enter 會依輸入決定方向:輸入是 JSON 物件時從 JSON 轉出。 + +### cURL 匯入 — 複製下來的請求變成可執行的腳本 + +從瀏覽器開發者工具貼上一段 `curl` 指令,再選擇輸出目標。解析器能處理方法、URL、標頭、本文、Basic 驗證、`-G` 查詢參數、`-F` multipart 欄位(上傳檔案會變成 `files=open(...)`)、`--json` 簡寫、`-d @file` 本文,以及多行接續。重複的 `-H` 值會照 HTTP 的方式合併(cookie 用 `; `,其他用 `, `),而不是默默只留下最後一個。過程中不會執行任何東西——純粹是解析。 + +| 目標:pytest | 目標:APITestka JSON 動作 | +|---|---| +| ![cURL 匯入為 pytest](../images/tool_curl_import.png) | ![cURL 匯入為 APITestka 動作](../images/tool_curl_import_action.png) | + +輸出目標:Python `requests`、可直接執行的 **pytest** 測試、**APITestka**(Python,或可由 `execute_files` 直接執行的 `[["AT_test_api_method", {...}]]` 動作清單),以及 **LoadDensity** 的 Locust 負載測試。輸出可以複製、直接開到編輯器分頁,或以正確的副檔名儲存。只要按一下,也能把解析出的 URL 交給 URL 解析/建構器,或把標頭交給標頭分析器。 + +### HAR 匯入 — 整段工作階段變成測試套件 + +「Copy as cURL」只抓一個請求;**Save all as HAR** 則抓下整段工作階段。開啟匯出檔後,每個記錄下來的呼叫都會列出方法、路徑、狀態碼與媒體類型,頁面裝飾類資源(CSS、圖片、字型)預設會被濾掉。 + +![HAR 匯入](../images/tool_har_import.png) + +選擇需要的項目——或直接取用全部列出的項目——就能用與 cURL 匯入相同的輸出目標產生一支腳本。重複的端點會得到編號過的測試名稱,不會有測試默默蓋掉另一個;HTTP/2 虛擬標頭會被移除,與記錄中 cookie 清單重複的 `Cookie` 標頭也會拿掉,讓每個值只送出一次(同名的 cookie 無法放進字典,改以標頭送出)。無法變成請求的項目,例如 URL 或方法格式錯誤的項目,會被略過,其餘照常載入。只選一個請求時,產生的結果與 cURL 匯入完全相同。HAR 是 JSON,因此只需要標準函式庫,而且不會替你重播任何請求。 + +### Response Inspector — 貼上回應,讀出裡面的一切 + +![Response Inspector](../images/tool_response_inspector.png) + +狀態碼會到 HTTP 參考表中查詢,標頭會被解析,JSON 本文會格式化顯示,文字中任何位置的 JWT(例如 `Authorization: Bearer` 標頭)都會被解碼,時間戳記類的宣告以 UTC 顯示。每項發現都能在對應的工具分頁中開啟,並預先填好內容。 + +### HTTP 標頭分析器 — 一段標頭實際在說什麼 + +![標頭分析器](../images/tool_header_analyzer.png) + +會回報:送出不只一次的名稱、缺少 `Secure`/`HttpOnly`/`SameSite` 的 `Set-Cookie` 項目、萬用字元 CORS(以及瀏覽器會直接拒絕的「萬用字元加憑證」組合)、短到撐不過重新啟動的 HSTS `max-age`、CSP 的 `unsafe-inline`/`unsafe-eval`、產品版本標語、已淘汰的標頭,以及——針對回應——缺少的安全標頭。攜帶憑證的標頭**只回報名稱**;它們的值絕不會進入報告。 + +### 文字比對 + +![文字比對](../images/tool_diff.png) + +比較兩段內容——例如預期與實際的 API 回應——得到 unified diff(新增與刪除的行以主題的顏色標示),以及一行新增/刪除摘要。 + +### 日常小工具 + +每個都是分頁或停駐面板,底部都有同樣的一排:複製/在編輯器開啟/儲存成檔案。 + +![JWT 解碼器、正規表示式測試器、HTTP 狀態碼參考、JSON 格式化](../images/tools_montage_a.png) + +- **JWT 解碼器** — 標頭與酬載以格式化的 JSON 顯示,`exp`/`iat`/`nbf`/`auth_time` 轉成易讀的 UTC。只做檢視:絕不驗證簽章,也絕不信任權杖。 +- **正規表示式測試器** — 支援 `IGNORECASE`/`MULTILINE`/`DOTALL`/`VERBOSE`,列出每個比對結果的位移、編號群組與具名群組。在樣式欄按 Enter 即執行。無效的樣式會顯示友善的錯誤訊息,不會當掉。 +- **HTTP 狀態碼參考** — 以代碼前綴或關鍵字搜尋完整的狀態碼表(資料來自標準函式庫,因此會保持最新)。 +- **JSON 格式化** — 格式化或壓縮,輸入不是 JSON 時會給出清楚的驗證錯誤。 + +![時間戳記轉換器、雜湊產生器、Query/JSON、URL 建構器](../images/tools_montage_b.png) + +- **時間戳記轉換器** — 輸入 Unix epoch(秒、毫秒、微秒或奈秒,自動判斷)或 ISO-8601 日期時間(可帶 `Z`、`+08`、`+0800` 或 `+08:00`,小數位數不限,基本或延伸格式皆可),輸出所有 UTC 表示法。結果固定,不受本機時區影響。 +- **雜湊產生器** — 同時計算 SHA-256、SHA-512、SHA-1 與 MD5(MD5/SHA-1 以 `usedforsecurity=False` 提供互通用途,絕不用於安全判斷)。 +- **Query ⇄ JSON** — `application/x-www-form-urlencoded` 轉成格式化的 JSON,也能轉回來;重複的鍵會變成陣列,反之亦然。 +- **URL 解析器/建構器** — 把 scheme、主機、連接埠、路徑、查詢、片段與憑證拆成可編輯的 JSON 物件,也能組回 URL。會自動為 IPv6 位址加上方括號,並重新編碼查詢參數。 + +### 圖表編輯器 — 不離開 IDE 就能畫架構圖 + +![匯入 Mermaid 流程圖後的圖表編輯器](../images/diagram_editor.png) + +*把 Mermaid `flowchart` 貼進匯入器後自動排版的結果。* + +以 `QGraphicsScene` 打造的所見即所得編輯器:矩形、圓角矩形、橢圓與菱形節點,可加上連線標籤的貝茲曲線連線,還有自由文字與圖片。Mermaid `flowchart`/`graph` 匯入會執行 Sugiyama 式排版(分層、減少交叉、跨軸對齊)。可儲存與開啟 `.diagram.json`,匯出為 PNG 或 SVG,並支援復原/重做、對齊、均分、格線、貼齊與縮放。從 URL 下載的圖片會經過 SSRF 驗證並有大小上限。 + +### SSH 用戶端 — 終端機與遠端檔案樹並排 + +![SSH 用戶端](../images/ssh_client.png) + +支援密碼或私鑰驗證(金鑰檔以 Browse 挑選,從 `~/.ssh` 開始:OpenSSH 或 PEM 格式的 RSA、Ed25519、ECDSA 金鑰,PKCS#8 也可以;PuTTY 的 `.ppk` 金鑰要先在 PuTTYgen 匯出成 OpenSSH 金鑰,錯誤訊息會說明怎麼做;勾選金鑰驗證時,密碼欄會改成「密語」,填入私鑰的密語),具備 keepalive、會顯示 ANSI 顏色的互動式 shell,以等寬字型顯示,視窗大小改變時會把新的寬度與高度告訴 shell(上下方向鍵叫回先前送出的指令,空白的一行按 Enter 也會送到 shell,`clear` 與 `reset` 會清空畫面,**Interrupt**(中斷)按鈕、或在沒有選取文字的指令列按 Ctrl+C,可停止 shell 中正在執行的程式;畫面是一行一行顯示輸出,所以 `vim`、`htop` 這類移動游標畫滿整個畫面的程式會顯示錯亂),以及延遲載入的 SFTP 檔案樹,可建立資料夾/重新命名/刪除/上傳/下載(和專案檔案樹一樣,F2 重新命名、Delete 刪除目前的項目)。每個 SFTP 請求都在背景執行,連線卡住也不會讓 IDE 凍結。上傳時若要取代伺服器上的檔案會先詢問,傳輸也能從檔案樹的選單取消。上下傳都會先寫入暫存檔,所以連線中斷時舊的檔案仍完整無缺。未知的主機金鑰**不會**自動接受:第一次連線時會顯示 SHA256 指紋供確認(首次使用即信任),並保存到 `~/.pybreeze/ssh_known_hosts`。 -此外還包含: +### 其他 -- **檔案自動化** — 透過 [automation-file](https://github.com/Integration-Automation/FileAutomation) 模組實現自動化檔案與目錄操作 -- **郵件自動化** — 透過 [MailThunder](https://github.com/Integration-Automation/MailThunder) 實現自動化郵件寄送(例如測試報告傳遞) -- **測試框架** — 透過 [TestPioneer](https://github.com/Integration-Automation/TestPioneer) 實現結構化 YAML 驅動的測試執行 +- **檔案樹右鍵選單** — 按右鍵即可建立、重新命名、刪除、複製絕對或相對路徑,或在系統的檔案管理員中顯示該項目(在 Explorer 與 Finder 中會選取該檔案)。焦點在檔案樹時,F2 重新命名、Delete 刪除目前的項目。刪除前會先詢問,預設是「否」,刪除的項目會移到回收筒(Windows 的資源回收筒);沒有回收筒的地方(例如某些網路磁碟),會再問一次才永久刪除。重新命名或刪除已在編輯器分頁中開啟的檔案時,分頁會同步更新。 +- **套件管理員** — 從選單安裝自動化模組與建置工具,輸出顯示在執行視窗中。 +- **整合文件** — 每個模組的文件與 GitHub 頁面都以 IDE 內的瀏覽器分頁開啟(TestPioneer 的文件就是它 GitHub 上的 README)。 -### IDE 核心功能 +--- + +## AI 輔助開發 -PyBreeze 不僅僅是一個程式碼編輯器——它是自動化生命週期的指揮中心: +和工具一樣,在 AI 程式碼審查、CoT 程式碼審查或 Skill Send 裡按 Ctrl+Enter,就等於按下送出鈕。 -- **語法高亮** — 內建 Python 語法高亮,針對自動化函式庫(APITestka、AutoControl、WebRunner、LoadDensity 等)提供深度關鍵字識別。可透過插件新增自訂語法規則。 -- **程式碼編輯器** — 基於 [JEditor](https://github.com/Integration-Automation/JEDITOR) 構建,提供完整的編輯器功能,包含分頁管理、檔案樹瀏覽與專案工作區支援。 -- **腳本執行** — 直接在 IDE 中執行自動化腳本,並即時顯示輸出。支援單一腳本與多腳本批次執行。 -- **報告生成** — 自動化模組可在測試執行後生成 HTML、JSON 和 XML 報告,並支援可選的電子郵件傳遞。 -- **整合 JupyterLab** — 在 PyBreeze 中直接以分頁方式啟動 JupyterLab,進行互動式筆記本開發。若未安裝 JupyterLab 將自動安裝。 -- **虛擬環境感知** — 自動偵測並使用專案的虛擬環境(`.venv` 或 `venv`)。 +### AI 程式碼審查 -### 內建工具 +![AI 程式碼審查用戶端](../images/ai_code_review.png) -- **SSH 用戶端** — 完整的 SSH 終端用戶端,支援: - - 密碼與私鑰驗證 - - 互動式指令執行 - - 遠端檔案樹檢視器,支援 CRUD 操作(建立資料夾、重新命名、刪除、上傳、下載) - - 互動式 TOFU host key 驗證,已確認的金鑰會持久化到 `~/.pybreeze/ssh_known_hosts` -- **架構圖編輯器** — 內建的 WYSIWYG 架構圖編輯器: - - 矩形、圓角矩形、橢圓、菱形節點、連線、自由文字 - - 從本地檔案或 URL 插入圖片(URL 下載會做 SSRF 驗證並有大小上限) - - 支援 Mermaid `flowchart` / `graph` 匯入 - - 儲存/開啟為 `.diagram.json`,匯出為 PNG 或 SVG - - Undo/redo、對齊、分佈、Grid、Snap、Zoom 控制 -- **檔案樹右鍵選單** — 在專案檔案樹中對任何檔案或資料夾按右鍵,可建立檔案/資料夾、重新命名、刪除、複製絕對或相對路徑、在系統檔案管理器中開啟。重新命名或刪除目前已開在編輯器分頁中的檔案時,分頁會同步更新。 -- **套件管理器** — 直接從 IDE 選單安裝自動化模組和建構工具,無需離開編輯器。 -- **整合文件** — 從選單列快速存取每個自動化模組的文件和 GitHub 頁面。 +*畫面為送出前的狀態。* 把選取的程式碼送到 LLM 端點(以 POST(預設)或 PUT 送出,程式碼放在本文的表單欄位 `code`;GET 與 DELETE 只送出 URL),再接受或拒絕建議——統計會記錄在 `~/.pybreeze/response_stats.txt`。URL 會經過 SSRF 驗證,連線只會連到檢查過的位址,不跟隨重新導向,回應本文在送進面板前也有大小上限。因此本機或私有網路上的端點(例如在本機跑的模型伺服器)會被拒絕;CoT Code Review 與 Skill Send 也用同樣的方式檢查端點 URL。 -### AI 輔助開發 +### 思維鏈程式碼審查(prthinker) -- **AI 程式碼審查** — 將程式碼傳送到 LLM API 端點進行自動化程式碼審查。可直接在 IDE 中接受或拒絕建議。 -- **思維鏈程式碼審查(prthinker)** — 以 [prthinker](https://github.com/JE-Chen/Code-Review-Framework-Combining-Large-Language-Models-and-Chain-of-Thought-Reasoning) 的審查流程審查正在編輯的檔案,或審查一個 Pull Request,輸出即時流進執行視窗。一張設定表填完推論後端(審查伺服器、OpenAI 相容端點、Anthropic 或本機模型)、程式碼託管平台與儲存庫;金鑰與權杖以環境變數交給審查,不會出現在命令列上——那是工作管理員看得到的地方。 -- **CoT(思維鏈)提示詞編輯器** — 建立和管理多步驟 CoT 提示詞,用於結構化程式碼分析,包含: - - 程式碼審查提示詞 - - Code Smell 偵測 - - 程式碼檢查分析 - - 逐步分析 - - 摘要生成 -- **Skill 提示詞編輯器** — 定義和管理可重複使用的技能型提示詞(程式碼解說、程式碼審查範本),可傳送至 LLM API。 -- **Skill Send GUI** — 在獨立的分頁或停靠面板中選擇技能提示詞範本、視需要編輯提示詞內容、傳送到 LLM API 端點並檢視回應。 +對正在編輯的檔案或一個 Pull Request 執行 [prthinker](https://github.com/JE-Chen/Code-Review-Framework-Combining-Large-Language-Models-and-Chain-of-Thought-Reasoning) 審查流程,輸出即時傳進執行視窗。 -### 插件系統 +![prthinker 設定](../images/prthinker_setting.png) -PyBreeze 支援可擴展的插件架構,用於: +一張設定表就包含推論後端(`remote`、`local`、OpenAI 相容、Anthropic、Gemini、Cohere、Mistral、`claude-cli`、`codex-cli`)、程式碼託管平台(GitHub/GitLab/Gitea)與儲存庫。**金鑰與權杖以環境變數交給審查,絕不放在命令列上**——那裡會被行程清單看到——而且在日誌中會被遮蔽。模型名稱會交給所選的後端。規則檢索(RAG)預設為 `off`,設為 `remote` 時會向 prthinker 伺服器的 `/rag` 查詢:prthinker 的本機規則索引隨它的儲存庫提供,不在由它安裝的套件裡。審查以 `Python Env` 選定的直譯器執行,因此 PyBreeze 本身可以停留在比 prthinker 所需的 3.12 更舊的 Python 上。 -- **語法高亮** — 透過插件為任何程式語言新增語法高亮 -- **UI 翻譯** — 透過翻譯插件新增新的介面語言 -- **執行設定** — 為編譯式和直譯式語言新增「以...執行」支援(C、C++、Go、Java、Rust 等) -- **插件瀏覽器** — 直接在 IDE 中從遠端儲存庫瀏覽並安裝插件 +### CoT 提示詞編輯器 -插件會從 `jeditor_plugins/` 目錄自動探索載入。完整文件請參閱 [PLUGIN_GUIDE.md](../PLUGIN_GUIDE.md)。 +![CoT 提示詞編輯器](../images/cot_prompt_editor.png) -**內建插件:** C、C++、Go、Java、Rust 語法高亮與執行支援;法文翻譯。 +建立與管理多步驟的審查鏈:初次摘要 → 初次程式碼審查 → 評審該次審查 → linter → 程式碼異味偵測 → 逐步分析 → 總結 → 評審總結。每一步都會引用它所需的前面步驟的答案。檔案受到監看,所以外部的修改會立即反映出來。 -### 多語言介面 +從 **Tools → AI → CoT Code Review** 執行這條審查鏈(以分頁開啟,或從 Dock 選單以停駐面板開啟):貼上程式碼、填入端點 URL,每一步的答案一到就會出現在選擇器中。每一步都以 POST 送出 JSON `{"prompt": "..."}`,回應本文(以文字讀取)就是那一步的答案。 -IDE 介面支援多種語言: +### Skill 提示詞編輯器與 Skill Send + +| Skill 提示詞編輯器 | Skill Send | +|---|---| +| ![Skill 提示詞編輯器](../images/skill_prompt_editor.png) | ![Skill Send](../images/skills_send.png) | + +定義可重複使用的 skill 提示詞(程式碼解說、程式碼審查),再從專用的分頁或停駐面板選一個、視需要編輯,然後送到 LLM 端點:以 POST 送出裝著提示詞的 JSON `{"code": "..."}`,回應本文照原樣顯示。*兩者都是送出前的狀態——拍攝這些截圖時沒有連線到任何端點。* + +--- + +## 外掛系統 + +PyBreeze 沿用 JEditor 的外掛架構,會自動從工作目錄中的 `jeditor_plugins/` 目錄探索外掛。外掛可以註冊: + +- **語法高亮** — JEditor 自己不上色的副檔名的關鍵字集與規則(`.c`、`.cpp`、`.go`、`.java`、`.js`、`.json`、`.rs`、`.sh`、`.sql`、`.toml`、`.ts`、`.yaml` 等 JEditor 會上色的副檔名,用的是它自己的規則) +- **介面翻譯** — 新的介面語言 +- **執行設定** — 為直譯式(`go run main.go`)與編譯式(`gcc main.c -o main` 後執行)語言提供「Run with…」,透過 PyBreeze 的 `FileRunnerProcess` 執行,並在結束後清掉編譯產物 +- **外掛瀏覽器** — 在 IDE 內瀏覽並安裝遠端儲存庫中的外掛,入口是 **Plugins → Plugin Browser**,還沒裝任何外掛時也在;裝好的外掛在下次啟動時載入 + +已載入的外掛會出現在它們專屬的 **Plugins**(外掛)選單下,附一個 About 項目和一個執行動作,動作名稱標示它能執行的副檔名。[PLUGIN_GUIDE.md](../PLUGIN_GUIDE.md) 說明 PyBreeze 額外提供的部分,並連到 JEditor 的指南,那裡有完整的 API 與實作範例(C、C++、Go、Java、Rust,以及法文翻譯)。 + +--- + +## 多語言介面 - **English**(英文,預設) -- **繁體中文** -- 可透過插件新增其他語言 +- **繁體中文**(Traditional Chinese) + +選單、對話框、工具拒絕輸入時說明的原因,以及執行視窗自己的訊息(`[錯誤] …`、`[執行] …`)都會跟著所選的語言顯示。兩份字典都有相同的 760 個鍵,並有測試強制兩者一致,因此新字串不可能只出現在其中一種語言。語言選單另外列出 JEditor 的日文與簡體中文:選了之後 JEditor 自己的選單會改變,PyBreeze 的字串則維持英文。其他語言可透過翻譯外掛加入。 --- -## 架構設計 +## 架構 ```mermaid flowchart TB UI["PyBreeze UI · PySide6"] - subgraph Editor["JEditor 基礎編輯器"] + subgraph Editor["JEditor (Base Editor)"] direction LR - E1["程式碼編輯器 + 分頁"] - E2["檔案樹"] - E3["語法高亮"] - E4["插件系統"] + E1["Code Editor + Tabs"] + E2["File Tree"] + E3["Syntax Highlighting"] + E4["Plugin System"] end - subgraph Automation["自動化選單"] + subgraph Automation["Automation Menu"] direction LR A1["APITestka"] A2["AutoControl"] @@ -140,7 +244,7 @@ flowchart TB A7["TestPioneer"] end - subgraph Executors["子行程執行器 · TaskProcessManager"] + subgraph Executors["Subprocess Executors · TaskProcessManager"] direction LR X1["je_api_testka"] X2["je_auto_control"] @@ -151,21 +255,20 @@ flowchart TB X7["test_pioneer"] end - subgraph Tools["工具"] + subgraph Tools["Tools"] direction LR T1["SSH · paramiko"] - T2["AI 程式碼審查"] - T3["CoT 提示詞編輯器"] - T4["Skill 提示詞編輯器"] - T5["Skill Send GUI"] - T6["架構圖編輯器"] - T7["JupyterLab"] + T2["AI Code Review"] + T3["Prompt Editors"] + T4["Diagram Editor"] + T5["HTTP Toolbelt"] + T6["JupyterLab"] end - subgraph Install["安裝選單"] + subgraph Install["Install Menu"] direction LR - I1["模組安裝器"] - I2["建構工具"] + I1["Module Installers"] + I2["Build Tools"] end UI --> Editor @@ -182,41 +285,13 @@ flowchart TB A7 --> X7 ``` -PyBreeze 採用模組化架構: - -``` -PyBreeze UI (PySide6) -├── JEditor(基礎編輯器引擎) -│ ├── 程式碼編輯器與分頁 -│ ├── 檔案樹瀏覽 -│ ├── 語法高亮引擎 -│ └── 插件系統 -├── 自動化選單 -│ ├── APITestka ──→ APITestka 執行器 ──→ je_api_testka -│ ├── AutoControl ──→ AutoControl 執行器 ──→ je_auto_control -│ ├── WebRunner ──→ WebRunner 執行器 ──→ je_web_runner -│ ├── LoadDensity ──→ LoadDensity 執行器 ──→ je_load_density -│ ├── FileAutomation ──→ FileAutomation 執行器 ──→ automation-file -│ ├── MailThunder ──→ MailThunder 執行器 ──→ je-mail-thunder -│ └── TestPioneer ──→ TestPioneer 執行器 ──→ test_pioneer -├── 工具 -│ ├── SSH 用戶端(paramiko) -│ ├── AI 程式碼審查用戶端 -│ ├── CoT 提示詞編輯器 -│ ├── Skill 提示詞編輯器 -│ ├── Skill Send GUI -│ ├── 架構圖編輯器(WYSIWYG、Mermaid 匯入、PNG/SVG 匯出) -│ └── JupyterLab 整合 -└── 安裝選單 - ├── 自動化模組安裝器 - └── 建構工具安裝器 -``` +**編輯器行程從不執行你的腳本。** 每個自動化模組都以 `python -m ` 在專案的直譯器中啟動,並使用 `shell=False`。兩條常駐執行緒把 stdout 與 stderr 讀進執行緒安全的佇列;一個 100 ms 的 `QTimer` 以有上限的批次把它們取出,送到 UI 執行緒。腳本當掉、卡住或陷入無限輸出迴圈,都不會連帶拖垮 IDE。 -每個自動化模組都透過 `PythonTaskProcessManager` 在獨立的子行程中執行,提供行程隔離,防止崩潰影響 IDE。 +逐一介紹各模組的程式碼導覽,請參閱 [architecture_explore.md](../architecture_explore.md)。 --- -## 安裝方式 +## 安裝 ### 從 PyPI 安裝 @@ -234,103 +309,48 @@ pip install -r requirements.txt ### 系統需求 -- **Python**:3.10 或更高版本 +- **Python**:3.10 – 3.14 - **作業系統**:Windows、macOS、Linux -- **GUI 框架**:PySide6 6.11.2(自動安裝) +- **GUI**:PySide6 6.11.2(自動安裝) --- ## 快速開始 -### 透過命令列執行 - ```bash -python -m pybreeze +python -m pybreeze # 命令列 +python exe/start_pybreeze.py # 從 exe 目錄 ``` -### 透過 Python 腳本執行 - ```python from pybreeze import start_editor -start_editor() -``` - -### 從 exe 目錄執行 - -```bash -python exe/start_pybreeze.py +start_editor() # 從 UI Style 選的主題(還沒選過時是 dark_amber) +start_editor(theme="dark_teal.xml") # 任何 qt_material 主題;它會成為選定的主題 ``` -啟動後,您可以: +啟動後: -1. **撰寫自動化腳本** — 在編輯器中享有語法感知的自動補全 -2. **執行腳本** — 透過 `自動化` 選單,選擇目標模組(APITestka、WebRunner 等) -3. **檢視結果** — 在整合式輸出面板中查看 -4. **生成報告** — 支援 HTML/JSON/XML 格式 -5. **寄送報告** — 使用 MailThunder 整合功能透過電子郵件發送 +1. **撰寫** — 在編輯器中撰寫自動化腳本 +2. **執行** — 從 `Automation` 選單執行,選擇目標模組 +3. **觀看** — 輸出即時傳進執行視窗 +4. **產生** — HTML/JSON/XML 報告 +5. **寄送** — 透過 MailThunder 整合以電子郵件寄出 --- -## 整合自動化模組 - -### APITestka — API 測試 +## 整合的自動化模組 -- HTTP 方法測試(GET、POST、PUT、DELETE 等) -- 透過 httpx 支援非同步 HTTP -- 使用 Flask 建立 Mock 伺服器 -- 報告生成(HTML、JSON、XML) -- 基於排程器的事件觸發 -- Socket 伺服器支援 - -### AutoControl — GUI 自動化 - -- 滑鼠控制(點擊、拖曳、滾動、位置追蹤) -- 鍵盤模擬(輸入、快捷鍵、按鍵按下/釋放) -- 圖像辨識與定位點擊 -- 螢幕截圖 -- 動作錄製與重播 -- Shell 指令執行 -- 行程管理 - -### WebRunner — Web 自動化 - -- 瀏覽器驅動整合 -- 元素定位與互動 -- 基於 Web 的測試腳本 -- 報告生成 - -### LoadDensity — 負載測試 - -- 併發請求模擬 -- 效能指標收集 -- 壓力測試情境管理 -- 報告生成 - -### MailThunder — 郵件自動化 - -- SMTP 郵件寄送 -- HTML 報告傳遞 -- 附件支援 -- 基於環境變數的設定 - -### TestPioneer — 測試框架 - -- 基於 YAML 的測試定義 -- 範本生成 -- 結構化測試執行 - -### File Automation — 檔案自動化 - -- 自動化檔案與目錄操作 -- 批次檔案處理 - -### prthinker — 思維鏈程式碼審查 - -- 審查正在編輯的檔案,或審查 GitHub / GitLab / Gitea 上的 Pull Request -- 推論後端可選:審查伺服器、OpenAI 相容端點、Anthropic,或本機模型 -- 設定存放在使用者目錄的 `~/.pybreeze/prthinker_setting.json`;金鑰以環境變數交給審查 -- 從它自己的原始碼資料夾安裝(**安裝 ▸ 自動化 ▸ 安裝 prthinker**),需要 Python 3.12 以上 +| 模組 | 功能 | +|---|---| +| **APITestka** | HTTP 方法、透過 httpx 的非同步請求、Flask Mock 伺服器、HTML/JSON/XML 報告、排程觸發、socket 伺服器、JSON-schema 與 JSONPath 斷言、SLA 檢查、錄製重播 cassette | +| **AutoControl** | 滑鼠(點擊、拖曳、捲動、位置)、鍵盤(輸入、快捷鍵、按下/放開)、圖像辨識與定位點擊、螢幕截圖、錄製與播放、shell 與行程控制 | +| **WebRunner** | 瀏覽器驅動程式整合、元素定位與互動、Web 測試腳本、報告 | +| **LoadDensity** | 併發請求模擬、效能指標、壓力情境管理、報告 | +| **MailThunder** | SMTP 寄信、HTML 報告寄送、附件、以環境變數設定 | +| **TestPioneer** | YAML 測試定義、範本產生、結構化執行;`Install ▸ Automation ▸ Install TestPioneer` 可安裝或升級(0.1.34 起不論系統語系,都以 UTF-8 讀取 YAML 檔) | +| **File Automation** | 自動化檔案與目錄操作、批次處理 | +| **prthinker** | 對檔案或 Pull Request 進行思維鏈程式碼審查;設定存於 `~/.pybreeze/prthinker_setting.json`;透過 `Install ▸ Automation ▸ Install prthinker` 從它自己的原始碼資料夾安裝(需要 Python 3.12 以上) | --- @@ -339,78 +359,99 @@ python exe/start_pybreeze.py ``` PyBreeze/ ├── pybreeze/ -│ ├── __init__.py # 公開 API(start_editor、插件 re-export) -│ ├── __main__.py # 進入點(python -m pybreeze) +│ ├── __init__.py # 公開 API(start_editor、外掛 re-export) +│ ├── __main__.py # 進入點(python -m pybreeze) │ ├── extend/ -│ │ ├── mail_thunder_extend/ # 測試後郵件報告寄送 -│ │ ├── process_executor/ # 各自動化模組的子行程管理器 -│ │ │ ├── api_testka/ -│ │ │ ├── auto_control/ -│ │ │ ├── file_automation/ -│ │ │ ├── load_density/ -│ │ │ ├── mail_thunder/ -│ │ │ ├── test_pioneer/ -│ │ │ └── web_runner/ -│ │ └── process_executor/python_task_process_manager.py -│ ├── extend_multi_language/ # 內建翻譯(英文、繁體中文) +│ │ ├── process_executor/ # 子行程隔離層 +│ │ │ ├── python_task_process_manager.py # TaskProcessManager(核心) +│ │ │ ├── process_executor_utils.py # build_process / start_process +│ │ │ ├── file_runner_process.py # 外掛執行設定(任何語言) +│ │ │ ├── queue_pump.py # 共用的管線讀取器 + QTimer 取出 +│ │ │ ├── api_testka/ auto_control/ web_runner/ +│ │ │ ├── load_density/ file_automation/ mail_thunder/ +│ │ │ ├── test_pioneer/ prthinker/ +│ │ ├── mail_thunder_extend/ # 測試後郵件報告掛鉤 +│ │ └── prthinker_extend/ # prthinker 設定與參數組裝 +│ ├── extend_multi_language/ # 內建多語言(英文、繁體中文) │ ├── pybreeze_ui/ -│ │ ├── editor_main/ # 主視窗(擴展 JEditor)+ 檔案樹右鍵選單 -│ │ ├── connect_gui/ssh/ # SSH 用戶端元件(TOFU host key 驗證) -│ │ ├── diagram_editor/ # WYSIWYG 架構圖編輯器 -│ │ ├── extend_ai_gui/ # AI 程式碼審查與提示詞編輯器 -│ │ ├── jupyter_lab_gui/ # JupyterLab 整合 -│ │ ├── menu/ # 選單列建構 -│ │ ├── syntax/ # 自動化關鍵字定義 -│ │ └── show_code_window/ # 程式碼顯示元件 -│ └── utils/ # 日誌、例外處理、檔案處理、套件管理 -├── exe/ # 獨立啟動器與建構設定 -├── docs/ # Sphinx 文件原始碼 -├── test/ # 單元測試 -├── images/ # 截圖 -├── architecture_diagram/ # 架構圖 -├── PLUGIN_GUIDE.md # 插件開發文件 -├── pyproject.toml # 套件設定 -├── requirements.txt # 執行階段依賴項 -└── dev_requirements.txt # 開發依賴項 +│ │ ├── editor_main/ # 主視窗 + 檔案樹右鍵選單 +│ │ ├── menu/ # Automation / Install / Tools / 外掛選單 +│ │ ├── tools_gui/ # cURL、HAR、JWT、diff、regex 等工具分頁 +│ │ ├── diagram_editor/ # 所見即所得圖表編輯器 +│ │ ├── extend_ai_gui/ # CoT 審查、提示詞編輯器、skill send +│ │ ├── connect_gui/ # SSH 終端機 + SFTP 檔案樹、AI 審查用戶端 +│ │ ├── jupyter_lab_gui/ # JupyterLab 分頁 +│ │ ├── show_code_window/ # CodeWindow(執行輸出) +│ │ ├── dialog/ # prthinker 設定對話框 +│ │ └── syntax/ # 自動化關鍵字定義 +│ └── utils/ # curl/HAR 解析、標頭、JWT、雜湊、 +│ # URL 驗證、日誌、例外…… +├── exe/ # 獨立啟動器與建置設定 +├── docs/ # Sphinx 文件原始碼;updates/ 是更新紀錄 +├── test/ # 單元測試(test_utils)+ 啟動測試 +├── images/ # 截圖 +├── architecture.md # 架構總覽:分層、主要流程、跨專案約定 +├── architecture_explore.md # 逐模組的架構筆記 +├── progress.md # 尚未完成的工作 +├── PLUGIN_GUIDE.md # 外掛開發文件 +├── pyproject.toml # 套件設定(穩定版) +├── dev.toml # 套件設定(開發通道) +└── requirements.txt # 執行階段相依套件 ``` --- -## 依賴項目 +## 相依套件 ### 執行階段 | 套件 | 用途 | |---|---| -| `PySide6` (6.11.2) | GUI 框架(Qt for Python)| +| `PySide6` (6.11.2) | GUI 框架(Qt for Python) | | `je-editor` | 基礎程式碼編輯器引擎 | | `je_api_testka` | API 測試自動化 | -| `je_auto_control` | GUI/桌面自動化 | +| `je_auto_control` | GUI/桌面自動化 | | `je_web_runner` | Web 瀏覽器自動化 | | `je_load_density` | 負載與壓力測試 | | `je-mail-thunder` | 郵件自動化 | | `automation-file` | 檔案操作自動化 | -| `test_pioneer` | 基於 YAML 的測試框架 | +| `test_pioneer` | 以 YAML 為基礎的測試框架 | | `paramiko` | SSH 用戶端支援 | -| `jupyterlab` | 整合式筆記本環境 | +| `jupyterlab` | 整合的筆記本環境 | ### 開發 -`build`、`twine`、`sphinx`、`sphinx-rtd-theme`、`auto-py-to-exe` +`build`、`twine`、`sphinx`、`sphinx-rtd-theme`、`auto-py-to-exe`、`pytest`、`pytest-cov`、`hypothesis`、`ruff` + +--- + +## 測試與 CI + +```bash +python -m pip install -r dev_requirements.txt +python -m pytest test/test_utils/ -v --tb=short +``` + +- **單元測試** — `test/test_utils/`,涵蓋純邏輯層(curl 與 HAR 解析、標頭分析、SSRF 驗證、JWT、雜湊、時間戳記、比對),加上透過 `QT_QPA_PLATFORM=offscreen` 的無視窗 Qt 元件測試,以及針對各解析器的 Hypothesis 性質測試 +- **啟動測試** — `test/unit_test/start_automation/` 以 debug 模式啟動 IDE,確認它能正常開啟並乾淨地結束 +- **CI** — 在 Windows 上以 GitHub Actions 跑 Python 3.10 – 3.14,每次 push 與 PR 都會執行,另外每晚執行一次 +- **靜態分析** — SonarCloud、Codacy 與 Bandit --- ## 目標使用者 -- **Python 開發者** — 一個輕量、專用的環境,用於構建自動化腳本,無需承受重量級通用 IDE 的負擔 -- **SDET(測試開發工程師)** — 需要在同一工具中同時維護 Web、API 和效能測試的專業人士 -- **自動化初學者** — 一個友善的 IDE,透過零設定環境降低 Python 自動化的入門門檻 -- **DevOps 團隊** — 一個在 CI/CD 流水線中快速構建和除錯整合測試套件的平台 +- **Python 開發者** — 一個輕量、專用的自動化腳本環境,沒有通用 IDE 的額外負擔 +- **SDET(測試開發工程師)** — 用同一個工具並行維護 Web、API 與效能測試 +- **自動化初學者** — 零設定的環境建置,每個模組都有選單 +- **DevOps 團隊** — 建置與除錯要送進 CI/CD 的整合測試套件的地方 --- ## 授權條款 -本專案採用 MIT 授權條款——詳情請參閱 [LICENSE](../LICENSE) 檔案。 +MIT — 請參閱 [LICENSE](../LICENSE)。Copyright (c) 2022 JE-Chen + +--- -Copyright (c) 2022 JE-Chen +截圖是在 Windows 11 上以預設的 `dark_amber` 主題,從實際的 PyBreeze 元件渲染而來;每個工具中的範例資料都是由實際程式路徑處理過的真實輸入。 diff --git a/architecture.md b/architecture.md index 007211b4..25b0afa5 100644 --- a/architecture.md +++ b/architecture.md @@ -37,7 +37,7 @@ their output reaches the UI through Queue + QTimer. | `pybreeze/extend/process_executor/` | Subprocess isolation layer: `TaskProcessManager`, `process_executor_utils.py`, `FileRunnerProcess`, `queue_pump.py`, one sub-package per automation package, plus `test_pioneer/` and `prthinker/` | | `pybreeze/extend/mail_thunder_extend/`, `prthinker_extend/` | Post-test email hook; prthinker settings and argument assembly (pure logic) | | `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | -| `pybreeze/utils/` | Pure logic, no Qt or JEditor (`test_utils_has_no_qt.py` guards it): request parsing and codegen, HTTP tools, `network/` SSRF validation, pinned connections and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py`, `terminal_text.py` (terminal escapes stripped for the SSH terminal and the run window) | +| `pybreeze/utils/` | Pure logic, no Qt or JEditor (`test_utils_has_no_qt.py` guards it): request parsing and codegen, HTTP tools, `network/` SSRF validation, pinned connections and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py`, `terminal_text.py` (terminal escapes stripped for the SSH terminal and the run window), `terminal_style.py` (SGR colours read for the SSH terminal) | | `test/test_utils/` | Unit tests (pure logic and headless widgets). `test/unit_test/start_automation/` holds the launch tests | | `pyproject.toml`, `dev.toml` | Stable packaging (CI bumps and publishes it) and the unpublished dev packaging (keep its dependencies identical) | | `.github/workflows/` | `dev.yml`, `stable.yml` (unit tests on a Windows matrix, then SonarCloud) | @@ -53,7 +53,8 @@ The layers are presentation (`pybreeze_ui/`), then execution (`extend/`), then f `multiprocessing.freeze_support()`: in the packaged executable the regex tester runs patterns in a spawned process, which re-runs the executable. From source it runs them in a plain worker script (`python -I -S -c`), so a launch script without the guard is safe. -- **Programmatic**: `pybreeze.start_editor(debug_mode=False, theme="dark_amber.xml", **kwargs)`. +- **Programmatic**: `pybreeze.start_editor(debug_mode=False, theme=None, **kwargs)`. A `theme` replaces + the one picked from UI Style (JEditor's saved `ui_style`) and is saved as it; `None` keeps the saved one. `debug_mode=True` adds an auto-close timer, which CI uses. - **Main window**: `PyBreezeMainWindow` exposes `tab_widget`, `current_run_code_window` and `python_compiler`. @@ -72,16 +73,20 @@ The layers are presentation (`pybreeze_ui/`), then execution (`extend/`), then f **Startup** ``` -python -m pybreeze → start_editor() → QApplication → PyBreezeMainWindow() +python -m pybreeze → start_editor() → QApplication → open_main_window() → PyBreezeMainWindow() → update_language_dict() [before JEditor picks the startup language] → EditorMain.__init__(extend=True) [JEditor builds the editor, loads jeditor_plugins/] → drop JEditor Help menu → add_menu_to_menubar() → syntax_extend_package() → EDITOR_EXTEND_TAB tabs → setup_file_tree_context_menu() - → apply_stylesheet(theme) → showMaximized() → startup_setting() → exec() → os._exit() + [EditorMain.__init__ has applied the saved settings and UI Style theme: startup_setting()] + → a theme given to start_editor(): saved as ui_style, startup_setting() again + (none given: only the window's own style sheet is set again) + → showMaximized() → exec() → os._exit() ``` -Before PySide6 is imported, `main_ui.py` sets `LOCUST_SKIP_MONKEY_PATCH=1` to keep LoadDensity's -gevent patching away from Qt. +Before PySide6 is imported, `main_ui.py` sets `LOCUST_SKIP_MONKEY_PATCH` (to `IDE_ONLY`, unless the user +set it) to keep locust's gevent patching away from Qt. The processes the IDE starts get +`subprocess_util.child_environment()`, which leaves it out: a load test needs the patching. **Run an automation script** @@ -105,7 +110,9 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() ## 5. Extension points - **Custom tabs**: add entries to `EDITOR_EXTEND_TAB` (`pybreeze_ui/editor_main/main_ui.py`) before - `start_editor()`. + `start_editor()`, or in a file plugin's `register()`, which runs before the tabs are added. A widget + with a `may_close()` is asked before its tab, its dock or the IDE closes (`pybreeze_ui/closing.py`); + one whose constructor raises costs only its own tab. - **File plugins**: `jeditor_plugins/` in the working directory, loaded by JEditor (`je_editor/plugins/plugin_loader.py`). `PLUGIN_RUN_CONFIG` entries appear in the Run with… and Plugins menus and execute via `FileRunnerProcess`. The plugin browser tab reuses JEditor's @@ -120,9 +127,12 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - add keywords in `pybreeze_ui/syntax/syntax_keyword.py`. - **New tool tab or dock**: a widget in `pybreeze_ui/tools_gui/`, its logic in `pybreeze/utils/`, and rows in `_WIDGET_FACTORIES` / `_TAB_ACTIONS` / `_DOCK_ACTIONS` / `_DOCK_TITLES` - (`pybreeze_ui/menu/tools/tools_menu.py`). + (`pybreeze_ui/menu/tools/tools_menu.py`). Like the other tools, a box that holds code calls + `fixed_pitch.use_fixed_pitch_font()`, and the main button gets Ctrl+Enter through + `run_shortcut.press_on_ctrl_enter()` (`act_on_ctrl_enter()` when the input decides the action). - **UI strings**: add keys to both `extend_multi_language/extend_english.py` and - `extend_traditional_chinese.py`. `test/test_utils/test_language_parity.py` enforces parity. + `extend_traditional_chinese.py`. `test/test_utils/test_language_parity.py` enforces parity, and the + key count in the READMEs and `architecture_explore.md` must follow (`test_the_readmes_count_the_keys_there_are`). ## 6. Cross-project boundaries @@ -143,14 +153,23 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() | `choose_file_get_save_file_path` | `pyside_ui.dialog.file_dialog.save_file_dialog` | `menu/plugin_menu/build_run_with_menu.py` | | `write_file_with_encoding` | `utils.file.save.save_file` | `menu/plugin_menu/build_run_with_menu.py` | | `DEFAULT_ENCODING`, `LINE_ENDING_LF` | `utils.encodings.text_codec` | `menu/plugin_menu/build_run_with_menu.py` | - | `actually_color_dict` | `pyside_ui.main_ui.save_settings.user_color_setting_file` | `show_code_window/code_window.py`, `automation_menu/auto_control_menu/build_autocontrol_menu.py` | + | `actually_color_dict` | `pyside_ui.main_ui.save_settings.user_color_setting_file` | `show_code_window/code_window.py`, `automation_menu/auto_control_menu/build_autocontrol_menu.py`, `tools_gui/diff_gui.py` (the diff's line colours: `diff_added_marker_color`, `diff_removed_marker_color`, `syntax_keyword_color`, `blame_annotation_color`) | + | `RedirectStdErr` | `utils.redirect_manager.redirect_manager_class` | `code_result_logs.py` (the handler `EditorMain` hooks onto every logger to show records in Code Result; PyBreeze raises its level to `WARNING`) | + | `user_setting_dict` | `pyside_ui.main_ui.save_settings.user_setting_file` | `editor_main/main_ui.py` (`open_main_window()` makes a `theme` given to `start_editor()` the saved `ui_style`, which `EditorMain.startup_setting()` applies over any theme applied before it) | PyBreeze also relies on `EditorWidget`'s `current_file`, `code_edit`, `file_encoding`, `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, on its private `_file_watcher`, `_ignore_next_change`, `_is_modified` and `_on_text_changed()` (a rename puts the unsaved mark back after `rename_self_tab()` clears it), on `EditorMain.close_tab(index)` (overridden to ask a tab's `may_close()` first, and to delete a closed tool tab, which its `removeTab` keeps) and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and - `start_language_server()` (a rename moves the tab the way `open_an_file` does), and on `language_wrapper`'s + `start_language_server()` (a rename moves the tab the way `open_an_file` does), on `EditorMain`'s + `run_menu.stop_all_program_action` (Stop All Program also stops every run window's run), on + `EditorMain.__init__` calling `startup_setting()` (the window is built with the saved settings and + theme; `open_main_window()` applies them again only for a theme given to `start_editor()`), its + `dock_menu` and its AI submenu `dock_ai_menu` (PyBreeze's AI docks join it; without one they get an + AI submenu of their own, `menu/tools/tools_menu.py`), on the syntax highlighter + taking a theme colour key (`warning_output_color`, `diff_modified_marker_color`, in both JEditor's dark and light + sets) for a registered keyword's colour (`syntax/syntax_extend.py`), and on `language_wrapper`'s `choose_language_dict` serving English and Traditional Chinese from the exported dict objects themselves. Having je_editor export the names in the table is workspace X-17. It merges its strings by mutating JEditor's `english_word_dict` and `traditional_chinese_word_dict` diff --git a/architecture_explore.md b/architecture_explore.md index 858bd70d..de09c276 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,6 +1,6 @@ # PyBreeze 架構探勘 / Architecture Exploration -> 掃描範圍:`pybreeze/`(201 個 `.py`、約 22,200 行,不含空行與註解約 17,300 行)+ `test/`、`exe/`、`docs/`、CI 設定 +> 掃描範圍:`pybreeze/`(209 個 `.py`、約 24,200 行,不含空行與註解約 18,900 行)+ `test/`、`exe/`、`docs/`、CI 設定 > 對應版本:`pyproject.toml` 1.0.21(stable)/`dev.toml` 1.0.14(dev),分支 `dev` --- @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 708 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 735 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -62,25 +62,26 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ## 3. 啟動流程 -`pybreeze/pybreeze_ui/editor_main/main_ui.py:189` 的 `start_editor()`: +`pybreeze/pybreeze_ui/editor_main/main_ui.py:216` 的 `start_editor()`(第 2 到 4 步在 `open_main_window()`,它回傳視窗給 `start_editor()` 握到程式結束): 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 708 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` + - `update_language_dict()` 併入 PyBreeze 的 760 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/`,也以 `startup_setting()` 套上存下的設定與 UI Style 主題 + - `show_only_warnings_in_code_result()`(`pybreeze_ui/code_result_logs.py`)— JEditor 剛把一個 `RedirectStdErr` 掛到當下每個 logger 上、收到的顯示在 Code Result;自動化套件 import 時把 root 設成 DEBUG,所以開檔就有 gitpython 的除錯訊息以紅字出現。把這個 handler 的門檻調到 WARNING,logger 本身的層級不動 - 刪掉 JEditor 原本的 Help 選單 - - 設定標題、Windows AppUserModelID、圖示 + - 設定標題、Windows AppUserModelID、圖示(`pybreeze_icon.ico`,在 `main_ui.py` 旁邊、以 package data 隨套件發佈:`pyproject.toml` / `dev.toml` 的 `[tool.setuptools.package-data]`,執行檔建置用 `datas` 帶進去,所以不論從哪個資料夾啟動都有圖示) - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` / `.yaml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器(`rename_self_tab()` 會清掉「未儲存」標記,有未存的編輯就用 `_on_text_changed()` 放回去,否則改名後五秒內關分頁會直接丟掉編輯);Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開。「在檔案總管中顯示」由 `reveal_command()` 組指令:Windows 用 Explorer 的 `/select,`、macOS 用 `open -R` 把檔案選起來,其他平台 `xdg-open` 只能開資料夾;啟動失敗(例如沒有 `xdg-open`)經 `_perform_file_op()` 跳警告 + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單,以及焦點在樹上時的 F2(重新命名)與 Delete(刪除,先問、預設否)快捷鍵(`_attach_keys()`,`WidgetShortcut`,走選單的同一組動作)。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器(`rename_self_tab()` 會清掉「未儲存」標記,有未存的編輯就用 `_on_text_changed()` 放回去,否則改名後五秒內關分頁會直接丟掉編輯);Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除先移到系統的回收筒(`_move_to_trash()`:`QFile.moveToTrash`,Windows 的資源回收筒、macOS 與 freedesktop 的垃圾桶);沒有回收筒可用時再問一次(預設否)才永久刪除,資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀);符號連結與 junction 只刪連結本身,不進回收筒。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開。「在檔案總管中顯示」由 `reveal_command()` 組指令:Windows 用 Explorer 的 `/select,`、macOS 用 `open -R` 把檔案選起來,其他平台 `xdg-open` 只能開資料夾;啟動失敗(例如沒有 `xdg-open`)經 `_perform_file_op()` 跳警告 - `close_tab()` 覆寫 JEditor 的:分頁有 `may_close()` 就先問(提示詞編輯器、架構圖編輯器有未存的變更時會問);關掉的工具分頁 `deleteLater()`(JEditor 的 `removeTab` 不刪 widget,關過的工具分頁會留到 IDE 結束),JEditor 自己的編輯器分頁不動,關閉 IDE 時也先問過每個分頁與 dock,有一個說不就取消關閉 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) -3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) -4. `showMaximized()` → `startup_setting()` → `app.exec()` +3. `start_editor(theme=...)` 給了主題時(`_apply_given_theme()`):寫進 `user_setting_dict["ui_style"]` 成為選定的主題,再跑一次 `startup_setting()` 套上;它失敗時記 log,仍用 qt_material 的 `apply_stylesheet()` 套上這個主題。沒給主題就不再套:建構子已經套過 JEditor 存下的 `ui_style`(UI Style 選的,沒選過是 `dark_amber.xml`),而套一次主題要將近一秒;只把視窗自己的字型 style sheet 再設一次(`startup_setting()` 先設它、後套主題,不再設一次的話工具列比給了主題時高 4 px) +4. `showMaximized()` → `app.exec()` 5. 離開時以 `os._exit(ret)` 硬退出(避開 Qt 拆解殘留執行緒) -模組層級有一個副作用:`main_ui.py:8` 在匯入 PySide6 之前就設定 `LOCUST_SKIP_MONKEY_PATCH=1`,避免 LoadDensity 的 gevent monkey patch 破壞 Qt。 +模組層級有一個副作用:`main_ui.py` 在匯入 PySide6 之前就設定 `LOCUST_SKIP_MONKEY_PATCH`(值是 `subprocess_util.IDE_ONLY`;使用者自己設過就沿用),避免 locust 一 import 就對整個行程做的 gevent monkey patch 破壞 Qt(Load Density GUI、JEditor 行程內的 IPython console 都可能 import 它)。IDE 啟動的行程拿到的是 `child_environment()`,不帶這個值:負載測試要靠 patch 才能讓使用者同時跑,帶著它時 HttpUser 一個接一個跑,3 秒的測試跑了三分多鐘。 --- @@ -101,9 +102,11 @@ Template Method 定義的子行程生命週期: 三種啟動介面: -- `start_test_process(package, exec_str)` — 腳本內容直接走 `--execute_str`(Windows 上先 `json.dumps` 逃逸);Windows 上命令列超過 30,000 字元(上限 32,767)時改寫進暫存的 `pybreeze_run_*.json`、走 `--execute_file`(JSON 以全跳脫的 ASCII 寫回,套件用哪種編碼讀都一樣),執行結束或啟動失敗就刪掉 +- `start_test_process(package, exec_str, subject="")` — 腳本內容直接走 `--execute_str`(Windows 上先 `json.dumps` 逃逸);Windows 上命令列超過 30,000 字元(上限 32,767)時改寫進暫存的 `pybreeze_run_*.json`、走 `--execute_file`(JSON 以全跳脫的 ASCII 寫回,套件用哪種編碼讀都一樣),執行結束或啟動失敗就刪掉 - `start_test_process_file(package, file_path)` — 走 `--execute_file`,避開 Windows ~32K 命令列上限 -- `start_module_process(package, arguments, environment)` — 通用形式;**祕密(API key、token)走 environment 不走命令列**,工作管理員看不到 +- `start_module_process(package, arguments, environment, subject="")` — 通用形式;**祕密(API key、token)走 environment 不走命令列**,工作管理員看不到 + +執行視窗的標題是 `套件 - 檔名`(`subject`:編輯器分頁的檔名、`--execute_file` 的檔案、TestPioneer 的 YAML),沒有檔案時只有套件名;一次執行整個資料夾時,每個檔案一個視窗,這樣才分得出來 ### 4.2 `process_executor_utils.py` — 工廠函式 @@ -157,9 +160,10 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停。超過 `buffer_size` 的長行分段讀進來:用 incremental decoder 解碼(被切斷的多位元組字元接到下一段),段尾的 `\r` 留到下一段(`\r\n` 被切開時不會變成兩個換行);不認得的 encoding 退回 UTF-8 並記 warning - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 - `output_queue()` — 每條管線的 queue 最多 `MAX_QUEUED_MESSAGES`(10,000)則;滿了 reader 就等(每 0.2 秒看一次 `keep_reading`),子行程寫管線也跟著等,跑得跟視窗顯示一樣快,像終端機;以前不設上限,印個不停的腳本會一直吃記憶體,按 Stop 後再一口氣全倒進視窗 -- `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 -- 執行視窗上方有「停止」按鈕:執行器在子行程跑起來後呼叫 `CodeWindow.run_started()` 打開它,`run_ended()` 關掉它,按下去走 `stop_runner()`(`stop_tree` 停掉子行程和它開的所有行程);關掉執行視窗不會停止執行。 -- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 等控制字串、`ESC ( B` 這類 nF 與其他兩位元組 escape)先拿掉、backspace 套用到前一個字元、tab、換行、`\r` 以外的控制字元丟掉(與 SSH terminal 共用 `utils/terminal_text.strip_terminal_controls()`;讀取切斷在 escape 中間時,reader 把尾巴留給下一段,`queue_pump` 的 `split_incomplete_escape()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`_insert_rewinding()`;結尾的 `\r` 記在 `_rewind_pending`,等下一段來才套用:接著是 `\n` 就是換行,否則回捲,跑完的進度條不會被清掉);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 +- `ReaderGrace` / `any_alive()` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 +- 執行器寫進執行視窗、說明這次執行本身的訊息(`[Error] Command not found: …`、`[Compile]`、`[Run]`、`[Stopped]`、`[Mail] …`、`Task exit with code …`、行程仍握著輸出的註明,共 15 種)一律經 `run_notice.run_notice(名稱, **欄位)`:取語言字典的 `run_window_<名稱>` 填入欄位,字典沒有時(腳本、測試在 `update_language_dict()` 之前啟動執行器)退回 PyBreeze 的英文;`test_run_notice.py` 擋掉在執行器裡直接寫 `"[Error] …"` 字串 +- 執行視窗上方有「停止」按鈕:執行器在子行程跑起來後呼叫 `CodeWindow.run_started()` 打開它,`run_ended()` 關掉它,按下去走 `stop_runner()`(`stop_tree` 停掉子行程和它開的所有行程);關掉執行視窗不會停止執行。Run > Stop All Program(JEditor 的 `run_menu.stop_all_program_action`,本來只停 JEditor 自己選單開的程式)另接到 `PyBreezeMainWindow.stop_all_runs()`,對每個執行視窗呼叫 `stop_runner()`,視窗與輸出留著。 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 等控制字串、`ESC ( B` 這類 nF 與其他兩位元組 escape)先拿掉、backspace 套用到前一個字元、tab、換行、`\r` 以外的控制字元丟掉(與 SSH terminal 共用 `utils/terminal_text.strip_terminal_controls()`;讀取切斷在 escape 中間時,reader 把尾巴留給下一段,`queue_pump` 的 `split_incomplete_escape()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`pybreeze_ui/terminal_view.insert_rewinding()`;結尾的 `\r` 記在 `_rewind_pending`,等下一段來才套用:接著是 `\n` 就是換行,否則回捲,跑完的進度條不會被清掉);輸出用等寬字型(`fixed_pitch.use_fixed_pitch_font()`:有 Consolas 用 Consolas,否則系統的等寬字型,Windows 上是 Courier New,與 SSH terminal 共用);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -169,44 +173,44 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.1 `automation_menu_factory.py` — 選單工廠 -`build_automation_menu(ui, spec)` 依一份 `AutomationMenu` 描述組出標準自動化子選單:`Run` 子選單(`RunAction` 列表)/ `Help`(`HelpLink` 列表,文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁,每一段各由一個小函式建(`_add_run_menu` 等),沒有項目的段落不建。三個描述都是 frozen dataclass。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份 `AutomationMenu(...)`。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣;它的停止錄製不論前面是哪個分頁都會停,把動作以 AutoControl 執行器讀的 JSON 插在編輯分頁的游標處(沒有編輯分頁就放剪貼簿),沒錄到東西就告知。 +`build_automation_menu(ui, spec)` 依一份 `AutomationMenu` 描述組出標準自動化子選單:`Run` 子選單(`RunAction` 列表)/ `Help`(`HelpLink` 列表,文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁(`gui_widget_factory`,選到才呼叫,套件可以到那時才 import 它的 GUI),每一段各由一個小函式建(`_add_run_menu` 等),沒有項目的段落不建。三個描述都是 frozen dataclass。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份 `AutomationMenu(...)`。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣;它的停止錄製不論前面是哪個分頁都會停,把動作以 AutoControl 執行器讀的 JSON 插在編輯分頁的游標處(沒有編輯分頁就放剪貼簿),沒錄到東西就告知。`je_auto_control` 一 import 就把行程設成 system DPI aware,所以這個模組只在用到時才 import 它(`_auto_control()`、`_autocontrol_gui()`):跟著選單在應用程式建立前 import,Qt 就設不成 per-monitor v2(每次啟動都警告 `SetProcessDpiAwarenessContext() failed`),在縮放比例跟主螢幕不同的螢幕上,Windows 把整個 IDE 當點陣圖拉伸。`test_startup_imports.py` 守著這點,也守著三個 GUI 與 SSH 用到時才 import:跟著選單一起 import 時,光是 import 主視窗模組就要 6.45 秒(中位數),現在 4.65 秒。 `safe_create_project(ui, import_name)` 回傳延遲 import 的 closure:專案建在 IDE 開著的資料夾(`working_dir`,沒開就用行程的工作目錄);套件的資料夾(`create_project_dir` 的 `parent_name` 預設值)已存在時先問(預設否),因為各套件一律覆寫範本檔;模組沒裝、寫入失敗都跳警告並記 log,成功時說出建在哪裡。 | 選單 | 文件 | GUI 分頁 | |---|---|---| -| APITestka | apitestka.readthedocs.io | `APITestkaWidget` | -| AutoControl | autocontrol.readthedocs.io | `AutoControlGUIWidget` | +| APITestka | apitestka.readthedocs.io | `APITestkaWidget`(開分頁時才 import) | +| AutoControl | autocontrol.readthedocs.io | `AutoControlGUIWidget`(開分頁時才 import) | | WebRunner | webrunner.readthedocs.io | — | -| LoadDensity | loaddensity.readthedocs.io | `LoadDensityWidget` | +| LoadDensity | loaddensity.readthedocs.io | `LoadDensityWidget`(開分頁時才 import:套件會帶進 locust 與 gevent) | | FileAutomation | fileautomation.readthedocs.io | — | | MailThunder | mailthunder.readthedocs.io | — | ### 5.2 非工廠的兩個選單 -- **`test_pioneer_menu/`** — 建範本目錄(寫在 IDE 的工作目錄,已有範本先問是否取代,寫入失敗跳警告)+ `QFileDialog` 選 `.yml` / `.yaml`(副檔名清單與語法高亮共用 `syntax_keyword.TEST_PIONEER_SUFFIXES`;會驗副檔名,選錯跳 `QMessageBox`) +- **`test_pioneer_menu/`** — 建範本目錄(寫在 IDE 的工作目錄,已有範本先問是否取代,寫入失敗跳警告)+ `QFileDialog` 選 `.yml` / `.yaml`(副檔名清單與語法高亮共用 `syntax_keyword.TEST_PIONEER_SUFFIXES`;會驗副檔名,選錯跳 `QMessageBox`)+ Help 子選單(`add_help_menu`,只有 GitHub:它的 readthedocs 網站沒有建出來) - **`prthinker_menu/`** — 審查目前檔案(先照 Run with... 的方式存檔:`save_current_file_for_run()`)/ 審查 PR(`QInputDialog` 問編號,範圍 1–1,000,000)/ 設定對話框 / Help ### 5.3 `tools/tools_menu.py` — 表格驅動的工具註冊 這是全專案設計最乾淨的一塊。三張表把 20 個工具的「建構」「分頁開啟」「dock 開啟」完全解耦: -- `_WIDGET_FACTORIES: dict[str, Callable]` — widget key → 建構 lambda +- `_WIDGET_FACTORIES: dict[str, Callable]` — widget key → 建構 lambda;SSH 的經 `_ssh_widget()`,第一次開才 import(paramiko 與 cryptography 約佔啟動的六分之一秒) - `_TAB_ACTIONS: tuple[...]` — (widget key, 主視窗屬性, 選單屬性, action 語言鍵, 分頁標籤鍵) -- `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock(`closing.AskingDock`:關 dock 前先問 widget 的 `may_close()`,有未存變更的提示詞與架構圖編輯器不會被 dock 的關閉鈕直接丟掉) +- `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock(`closing.AskingDock`:關 dock 前先問 widget 的 `may_close()`,有未存變更的提示詞與架構圖編輯器不會被 dock 的關閉鈕直接丟掉);AI 類的 dock 放進 JEditor Dock 選單原有的 AI 子選單(`dock_ai_menu`),沒有才自己建一個 `_register_action()` 有一段關鍵註解:QAction 必須 `setattr` 掛回主視窗,否則 Qt 不持有它、被 GC 後選單項就失效。另一種做法是建構時把選單當 parent(自動化選單工廠、插件選單用這種)。`test_started_menus.py` 在子行程啟動真的 IDE、GC 後走訪整條選單列,任何子選單變空就失敗(JEditor 的兩個字型選單除外:offscreen 平台沒有字型)。 ### 5.4 插件選單 -- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 一個 Run 動作,多個副檔名時一併列在標籤裡,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口。插件是第三方程式:不是 dict 的 metadata 或 run config 略過並記 log,名稱經 `plugin_text()` 轉成文字(`addMenu(None)` 會讓 Qt access violation),每個插件的選單各自建、失敗只少它自己那一項 +- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 一個 Run 動作,多個副檔名時一併列在標籤裡,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口,沒有任何插件時選單也照建、只有這一項(第一個插件就是從它裝的)。插件是第三方程式:不是 dict 的 metadata 或 run config 略過並記 log,名稱經 `plugin_text()` 轉成文字(`addMenu(None)` 會讓 Qt access violation),每個插件的選單各自建、失敗只少它自己那一項 - **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。`run_config_suffixes()` 把插件登記的副檔名正規化成 `Path.suffix` 的樣子(小寫、一個前導點;JEditor 原樣保存,`.R`、`r` 以前永遠比對不上)。`run_current_file_with()` 先經 `save_current_file_for_run()` 存檔(已有檔名的分頁照 JEditor 自己存檔的方式寫:`write_file_with_encoding()` 用分頁的編碼與行尾,寫成功後才 `mark_ignore_next_file_change()` 與 `mark_saved()`;存檔失敗跳警告、不執行;沒檔名的走 JEditor 的另存新檔),再驗副檔名、交給 `FileRunnerProcess`。Plugins 選單的 Run 動作也走這一條 ### 5.5 安裝選單 `install_utils.install_packages()` 用 `build_task_process()` 開一個執行視窗,`start_module_process("pip", ["install", "-U", *packages])`:參數清單、不經 shell(以前借 JEditor 的 `ShellManager`,它用 `shell=True` 交給 `cmd.exe`,使用者選的資料夾名稱裡有 `&` 就會把指令切開)。多個套件一次 pip(建置工具以前是三個 pip 同時對同一個環境跑)。pip 用 IDE 選定的直譯器,沒選時照一般執行的退路。`install_package()` 是單一套件的寫法 -- `automation_menu/` — 七個自動化套件的一鍵安裝。**prthinker 例外**:不在 PyPI 上,第一次會問來源資料夾、記進設定,之後裝 `[runner]` +- `automation_menu/` — 八個自動化套件的一鍵安裝(PyPI 上的七個列在 `PYPI_PACKAGES`)。**prthinker 例外**:不在 PyPI 上,第一次會問來源資料夾、記進設定,之後裝 `[runner]` - `tools_menu/` — 安裝 setuptools / build / wheel --- @@ -226,7 +230,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | | `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`:從原始碼執行時是 `python -I -S -c` 跑一段只用標準函式庫的固定腳本,工作用 JSON 從 stdin 進、結果從 stdout 出,5 秒後 kill;打包版沒有直譯器可用,仍是 multiprocessing spawn),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停。spawn 會重新匯入啟動 IDE 的腳本,README 那種沒有 `__main__` 防護的腳本會每跑一次就再開一個 IDE。還在跑的 worker 記在 `_RUNNING`,分頁關閉時 `stop_running_workers()` 結束它(IDE 以 `os._exit` 結束,子行程不會跟著走);執行中不能存檔,列到 `MAX_MATCHES` 上限時會註明可能還有更多 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | -| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 的統計與 diff 共用同一次比對(`_TrimmedMatcher`:先把相同的開頭結尾放一邊再比對中間,長而重複的文字改一行就是一行;放一邊有時反而比對得更差(`b b a b a` 對 `b a c b`),所以有放一邊時 `_closest_match` 也照原樣比一次,取改動行數少的;autojunk 照 difflib 的預設,關掉的話重複的文字比對時間隨行數平方成長;diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | +| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 的統計與 diff 共用同一次比對(`_TrimmedMatcher`:先把相同的開頭結尾放一邊再比對中間,長而重複的文字改一行就是一行;放一邊有時反而比對得更差(`b b a b a` 對 `b a c b`),所以有放一邊、且兩段合計不超過 2,000 行時,`_closest_match` 也照原樣比一次,取改動行數少的(更大的文字再比一次會讓等待加倍);autojunk 照 difflib 的預設,關掉的話重複的文字比對時間隨行數平方成長;diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來。輸出由 `UnifiedDiffHighlighter` 依行首上色(`diff_line_colour()`:`@@`、`+`、`-`、`\ No newline` 各用 JEditor 的主題色,深色淺色各一組;只有前兩行算 `---`/`+++` 標頭) | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT;`curl -i` 印出的多段回應(`100 Continue`、proxy 的 `Connection established`、`-L` 的轉址)取最後一段;只有一行又沒有狀態列就當 body | @@ -234,22 +238,23 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 兩個橫向共用機制 - **`tool_tabs.open_tool_tab()`** — 工具之間互相「轉交」:Response Inspector 把狀態碼丟給 HTTP Status、headers 丟給 Header Analyzer、JWT 丟給 JWT Decoder、JSON body 丟給 JSON Format;curl 匯入把 URL 丟給 URL Builder。開新分頁並自動聚焦。 +- **`pybreeze_ui/run_shortcut.press_on_ctrl_enter()`**(不在 `tools_gui/` 裡)— 只有一個主要動作的工具(cURL、Diff、Hash、Header、JSON Format、JWT、Regex、Response)在工具裡任何地方按 Ctrl+Enter 就等於按那顆鈕(文字框裡的 Enter 是換行);shortcut 是工具的子物件、`WidgetWithChildrenShortcut`,焦點不在工具裡時不會搶走編輯器的按鍵,按鈕停用時(還在跑)不會觸發;AI Code Review、CoT Code Review 與 Skill Send 的送出鈕也用它;雙向的 Query ↔ JSON 與 URL Builder 用 `act_on_ctrl_enter()` 接到 `convert_as_pasted()`,輸入是 JSON 物件就往另一個方向轉 - **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上,輸出也經 `exact_text()` 讀(不讓 U+00A0、U+2028 被改掉);副檔名與檔名可傳 callable 動態決定。存檔經 `replace_text()` 整檔替換,失敗(唯讀資料夾、被鎖住的檔案、磁碟滿)時原檔不動、會跳警告,說出檔名與原因。Qt 的文字元件留不住貼上的 CR,換行一律讀成 LF --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,963 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(4,055 行,最大子系統) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (678) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (888) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點與圖片之上/之下;`to_dict()` 給每個節點與圖片記下它在兩者之間由下往上的位置(`stack`),載入後 `_restore_stacking()` 照這個順序重新加回場景(同 z 值時後加的在上面),圖片也存 `z`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | -| `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`DiagramScene.add_image()`(`diagram_scene.py`)把圖放進 scene 的 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | -| `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | +| `diagram_editor_widget.py` (695) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | +| `diagram_scene.py` (915) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點與圖片之上/之下;`to_dict()` 給每個節點與圖片記下它在兩者之間由下往上的位置(`stack`),載入後 `_restore_stacking()` 照這個順序重新加回場景(同 z 值時後加的在上面),圖片也存 `z`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | +| `diagram_items.py` (962) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`DiagramScene.add_image()`(`diagram_scene.py`)把圖放進 scene 的 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | +| `diagram_mermaid_parser.py` (629) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。箭頭的 `|label|` 由 `_arrow_label()` 用兩個相鄰部分不共用字元的樣式讀(引號標籤一個、一般標籤一個,取最左邊的),線性時間:原本合成一個、標籤兩側可有空白的樣式在未閉合的標籤上是立方時間回溯。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (453) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單;每記一步 undo(場景的 `recorded`)就重新整理(在畫布上拖把手改大小時選取沒變);寬、高各自只改自己那一邊;數字欄位不追鍵盤、輸入完才套用 | -| `diagram_view.py` (195) | `QGraphicsView`:滾輪與按鈕縮放都經 `_step_zoom()`(有上下界,界外時仍可往界內走;橫向滾輪不縮放),`fit()` 把「符合視窗」夾在上下界內、中鍵平移、`drawBackground` 畫格線 | +| `diagram_view.py` (228) | `QGraphicsView`:滾輪與按鈕縮放都經 `_step_zoom()`(有上下界,界外時仍可往界內走;橫向滾輪不縮放),`fit()` 把「符合視窗」夾在上下界內、中鍵或右鍵拖曳平移(右鍵拖過畫布就不開右鍵選單,`contextMenuEvent`;放開沒送到它時下一次移動就停止平移)、`drawBackground` 畫格線 | | `diagram_commands.py` (48) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態;有 `merge_key` 的連續步驟(同一個項目的同一個屬性:大小、字級、線寬)合併成一步(`id()` / `mergeWith`) | -| `diagram_net_utils.py` (112) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗(驗過就關掉轉址回應,urllib 不會把轉址的內容整個讀完)、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、每次等資料 15 秒 timeout,整個下載另有 `overall_deadline(DOWNLOAD_DEADLINE_SECONDS)` 120 秒上限(慢慢送位元組的伺服器原本可以一直卡住下載執行緒) | +| `diagram_net_utils.py` (117) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗(驗過就關掉轉址回應,urllib 不會把轉址的內容整個讀完)、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、每次等資料 15 秒 timeout,整個下載另有 `overall_deadline(DOWNLOAD_DEADLINE_SECONDS)` 120 秒上限(慢慢送位元組的伺服器原本可以一直卡住下載執行緒) | `diagram_net_utils` 是 CLAUDE.md 指定的網路安全參考實作,其他 HTTP 呼叫端則統一走 `utils/network/url_validation.py` 驗證、經 `utils/network/public_http.py` 的 `public_session()` 送出。 @@ -264,7 +269,7 @@ extend_ai_gui/ ├── code_review/ │ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 -│ └── cot_code_review_gui.py UI(工具 → AI 的分頁與 dock);URL 只由 worker 驗證,UI 執行緒不查 DNS;每次送出先清掉上一輪的回覆;關閉時請審查停在目前這一步,交給 let_run_out(),不等 +│ └── cot_code_review_gui.py UI(工具 → AI 的分頁與 dock);URL 只由 worker 驗證,UI 執行緒不查 DNS;沒貼程式碼就不送(整條鏈八個請求都會白跑);每次送出先清掉上一輪的回覆;關閉時請審查停在目前這一步,交給 let_run_out(),不等 ├── prompt_edit_gui/ │ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新,watcher 以編輯器為 parent、關閉時停止監看;有未存編輯時,外部改動、「重新載入」和切換模板都先問;還沒有檔案的模板只用 placeholder 說明,存檔不會把說明寫進去) │ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 @@ -293,17 +298,17 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(2,035 行) +### `ssh/`(2,517 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`;任一欄按 Enter 就按下連線;金鑰欄旁的「瀏覽...」從 `~/.ssh` 開檔案對話框,選了檔就勾起金鑰驗證) | -| `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (722) + `sftp_session.py` (473) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,傳輸中右鍵選單多一項「取消傳輸」(`SftpTransferThread.cancel()`:paramiko `get` / `put` 的進度回呼每塊都檢查,丟 `TransferCancelled`,暫存檔照失敗處理刪掉、要被取代的檔案不動,發 `cancelled`);傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時;pty 開成 terminal 看得到的大小,`terminal_view.terminal_size()`:以等寬字型算出完整看得到的欄與列,至少 20 × 5)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成 `TerminalOutput`:是否先清畫面(`clear`、`reset`:`split_at_screen_clear()`,只留最後一次清除之後的文字,清除前設的顏色延續、`ESC c` 則重設;widget 先 `clear()` 畫面並忘掉待套用的 `\r`),以及一段段帶樣式的文字(SGR 顏色與粗體等由 `utils/terminal_style.split_styled()` 讀出,跨次讀取延續、`reset()` 清掉;畫面用 `terminal_view.style_format()` 轉成 `QTextCharFormat`,一段結尾的單獨 `\r` 記在 `_rewind_pending` 給下一段套用):UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;空白的一行也送出(只送換行,用來接受提示的預設值);沒連線時,空白的一行不跳「尚未連線」提示;「中斷」按鈕與指令列的 Ctrl+C(沒有選取文字時;有選取就照常複製,`eventFilter`)送出 `\x03`(`send_interrupt()`),停止 shell 中正在跑的程式,指令列打到一半的字留著;上下鍵走過送出過的指令(`CommandHistory`:最多 500 行,連續重複只記一次,往下走過最新一行就還回原本打到一半的字);輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),單獨的 `\r` 回到行首、由後面的文字取代這一行(進度條;與執行視窗共用 `terminal_view.insert_rewinding()`),輸出用等寬字型(`fixed_pitch.use_fixed_pitch_font()`,同執行視窗,欄位對齊的輸出才對得齊),terminal 的 viewport 改變大小、換算成字元數有變時送 `resize_pty`(`_follow_view_size()`,經 `eventFilter`;shell 開好時再對一次,連線中改了大小也跟上;伺服器拒絕時記 log,下次再試),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_file_viewer_widget.py` (756) + `sftp_session.py` (473) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事;焦點在樹上時 F2 重新命名、Delete 刪除目前項目,走選單同一組動作,`_act_on()` 把斷線的錯誤變成對話框)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,傳輸中右鍵選單多一項「取消傳輸」(`SftpTransferThread.cancel()`:paramiko `get` / `put` 的進度回呼每塊都檢查,丟 `TransferCancelled`,暫存檔照失敗處理刪掉、要被取代的檔案不動,發 `cancelled`);傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | -| `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | +| `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;paramiko 不讀的 PKCS#8(`BEGIN PRIVATE KEY`/`BEGIN ENCRYPTED PRIVATE KEY`)由 `cryptography` 讀進來、在記憶體裡轉成 OpenSSH 格式再交給 paramiko;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA),或是要先匯出成 OpenSSH 格式的 PuTTY `.ppk` 金鑰 | ### `url/ai_code_review_gui.py` @@ -311,6 +316,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - 請求走 `ReviewRequestThread(QThread)`,只有 `answered` / `failed` 兩個 signal 碰 UI(和 `SkillsSendGUI` 同一套);送出中再按不會重送;`closeEvent` 不等它,交給 `thread_keeper.let_run_out()`:斷開它和面板的連線、留著參考直到它結束(等它會讓 IDE 凍住最長一個讀取逾時) - `urls.txt` 只存 URL 的 SHA-256 指紋(`url_fingerprint()`):API URL 可能帶權杖,依 CLAUDE.md 要當憑證看待;舊版留下的明文檔會在下次送出時改寫成指紋 +- 方法預設 POST(`DEFAULT_METHOD`);POST/PUT(`METHODS_WITH_A_BODY`)把程式碼照貼上的樣子放進本文的表單欄位 `code`,程式碼是空的就不送、在面板說明;GET/DELETE 只送 URL - 非 2xx(含不跟隨的轉址)走 `failed`,狀態碼寫進面板,不會只留空白 - 接受/拒絕只在收到回答(`answered`)後可按,每個回答只能評一次;Send 按鈕由執行緒的 `finished` 恢復,請求不論怎麼結束都回得來 @@ -318,7 +324,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 10. `pybreeze_ui/jupyter_lab_gui/` -- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `choose_python()`(IDE 選定的直譯器優先,其次 venv 的,最後 IDE 自己的)→ `is_jupyter_installed()`(問直譯器 `find_spec('jupyterlab')`,不問 pip;缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴。失敗時 `error_occurred` 送的是原因(例外訊息,最多 2,000 字),traceback 只進 log;已經 `stop()`(分頁關了)的失敗不算失敗,只記 debug +- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `choose_python()`(IDE 選定的直譯器優先,否則和執行一樣走 `default_interpreter()`:工作目錄的 `venv`/`.venv`,再來 IDE 自己的;打包版找 PATH,找不到的 `JEditorExecException` 在分頁上顯示原因)→ `is_jupyter_installed()`(問直譯器 `find_spec('jupyterlab')`,不問 pip;缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴。失敗時 `error_occurred` 送的是原因(例外訊息,最多 2,000 字),traceback 只進 log;已經 `stop()`(分頁關了)的失敗不算失敗,只記 debug - `jupyter_lab_widget.py` — 設 `WA_DeleteOnClose`:分頁關閉就刪掉(`close_tab` 只移除分頁、不刪 widget,網頁檢視與它的 Chromium renderer 會一直留到 IDE 結束)。收到 URL 後用 `QWebEngineView.setUrl()` 載入;失敗時在狀態列顯示「初始化失敗:原因」(純文字、可選取、自動換行);`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server);還在安裝或啟動的 launcher 交給 `let_run_out()`,不在 UI 執行緒等(安裝可能要好幾分鐘),也不用 `blockSignals`(那會連 `finished` 一起擋掉,keeper 永遠放不掉它)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。`--ServerApp.port_retries=0`:port 被占就直接結束(走「提早結束」的回報),不讓它默默換 port。**不設 `allow_origin`**:loopback 擋不住瀏覽器,開放來源的話使用者逛到的任何網頁都能操作這個沒有 token 的 server。 @@ -327,9 +333,9 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 11. `pybreeze_ui/syntax/` -- `syntax_keyword.py`(625 行)— 七份關鍵字清單,彙整成 `package_keyword_list`: +- `syntax_keyword.py`(629 行)— 七份關鍵字清單,彙整成 `package_keyword_list`: `je_auto_control` / `je_load_density` / `je_api_testka` / `je_web_runner` / `automation_file` / `mail_thunder` / `test_pioneer` -- `syntax_extend.py` — 把前六個註冊到 `.json`(黃色 `#FFFF00`),`test_pioneer` 註冊到 `TEST_PIONEER_SUFFIXES` 的每個副檔名(`.yml`、`.yaml`,橘色 `#FF9900`),然後重置當前編輯器的 highlighter +- `syntax_extend.py` — 把前六個註冊到 `.json`(黃色,JEditor 主題色鍵 `warning_output_color`),`test_pioneer` 註冊到 `TEST_PIONEER_SUFFIXES` 的每個副檔名(`.yml`、`.yaml`,橘色,`diff_modified_marker_color`);顏色給的是主題色的鍵而不是固定顏色,JEditor 的 highlighter 每次建立時查 `actually_color_dict`,所以深色與淺色主題各用各的一組,然後重置當前編輯器的 highlighter `PackageManager.syntax_check_list` 決定要註冊哪些;用 `package_keyword_list.get(pkg, [])` 取值,套件沒有關鍵字清單時註冊空集合而不是炸掉。 @@ -340,16 +346,17 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | 套件 | 內容 | |---|---| | `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置,建立時為 `0700`(`DATA_DIR_MODE`);`pybreeze_data_path()` 只給路徑、不建立 | -| `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)、`split_unfinished_end()`(再加上它前面或最後的 `\r`)、`take_leading_backspaces()`(一段開頭的 backspace 留給畫面,擦掉前一段已經顯示的字,不越過行首)。SSH terminal 與執行視窗共用 | -| `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | +| `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)、`split_unfinished_end()`(再加上它前面或最後的 `\r`)、`take_leading_backspaces()`(一段開頭的 backspace 留給畫面,擦掉前一段已經顯示的字,不越過行首)、`split_at_screen_clear()`(最後一個清除整個畫面的序列:`ESC [ 2J`、`ESC [ 3J`、`ESC c`,前後切開;`ESC [ J` 只清游標以下,shell 重畫提示字元時會送,不算)。SSH terminal 與執行視窗共用 | +| `terminal_style.py` | SGR(`ESC [ … m`)讀成 `TextStyle`(frozen dataclass:前景、背景、粗體、斜體、底線、反白):`apply_sgr()`(16 色與亮色、`38;5;n` 256 色、`38;2;r;g;b` 24 位元色、各開關與 39/49 預設、0 重設;看不懂或格式錯的參數不改任何東西,超過 5 位數的參數略過,`int()` 不收超過 4300 位數)、`split_styled()`(文字在 SGR 處切段,每段帶它的樣式,其餘 escape 留給 `strip_terminal_controls()`)、`colour_rgb(colour, on_dark=)`(前 16 色用 VS Code 終端機的預設值,深色與淺色主題各一組,`terminal_view.style_format()` 依 view 背景的亮度挑;256 色的色塊、灰階與 24 位元色照 xterm)。只有 SSH terminal 用:執行視窗的程式寫到 pipe,不會上色 | +| `subprocess_util.py` | `child_environment()`(`os.environ` 去掉值為 `IDE_ONLY` 的變數:IDE 只給自己設的)、`utf8_subprocess_env()`(以它為底再釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數;`error_templates.py` 把名稱以 `_error` 結尾的常數變成語言字典的 `error_text_<名稱>`(英文字典直接取常數本身) | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`_NoRedirectSession`:不跟也不準備轉址,3xx 原封不讀地回來;`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣),和其他 `-d` 片段照命令列的順序接起來(`data_file_positions`);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別(`body_kind()`:JSON 物件要能原樣送回才走 `json=`,重複的 key、float 裝不下的數字、`NaN`、巢狀超過 100 層都照原字串送);`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | -| `har_import/` | `har_parser.py`(354) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,含半個字元(JSON 的 `\ud800`)的也跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | -| `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | +| `curl_import/` | `curl_parser.py`(612) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣),和其他 `-d` 片段照命令列的順序接起來(`data_file_positions`);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別(`body_kind()`:JSON 物件要能原樣送回才走 `json=`,重複的 key、float 裝不下的數字、`NaN`、巢狀超過 100 層都照原字串送);`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | +| `har_import/` | `har_parser.py`(368) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,含半個字元(JSON 的 `\ud800`)的也跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | +| `header_tools/` | `header_analyzer.py`(335) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN` 與同一物件重複的 key(`load_json_verbatim()`,用 `json_process.unique_pairs`),Query 轉 JSON 遇到不是 UTF-8 的 percent-escape 報錯而不換成 U+FFFD,`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | | `file_process/read_capped.py` | `read_text_capped(path, encoding, max_bytes=None)`:先看大小,超過 `MAX_OPEN_BYTES`(100 MB)丟 `FileTooLargeError`(`OSError`,`strerror` 說明大小與上限),HAR 分頁與架構圖編輯器開檔都經它,不在 UI 執行緒讀好幾 GB 的檔案 | @@ -362,7 +369,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 708 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 760 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -455,10 +462,10 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2251 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 150 個 `test_*.py`、2714 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 -- **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) +- **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。`patch = subprocess` 也是必要的:pytest-cov 7 不再量測子行程,沒有它,測試在子直譯器裡建出的真主視窗(`started_window.py`)一行都不算。目前整體語句 96%、連分支 95%(`tools_gui` 99%、`utils/` 98%、`dialog` 100%;`menu` 97%、`connect_gui` 96%、`diagram_editor` 96%、`extend_ai_gui` 95%、`extend/` 94%、`jupyter_lab_gui` 92%;最低的是 `editor_main` 90%)。coverage 只追蹤 Python 自己開的執行緒,`test/test_utils/conftest.py` 讓每個 `QThread` 子類別的 `run` 在 Qt 的執行緒上裝上 coverage 的 tracer,否則沒有一個 `QThread.run` 算得到 - **靜態分析** SonarCloud(`sonar-project.properties`,CI-based analysis;Automatic Analysis 已關閉且必須維持關閉,兩種模式互斥)+ Codacy(`.codacy.yml`)+ Bandit(`pyproject.toml` 中排除 test、skip B101/B404) - **SonarCloud 方案限制** 該組織的方案只開放 `main` 與 PR 的分析結果。非 main 分支的分析送得出去、CE 任務也會成功,但結果讀回來是 403(組織內每個專案都只有 `main` 一條分支)。因此 `dev.yml` 只在 PR 時掃描,`stable.yml` 另外掃 push to `main` diff --git a/dev.toml b/dev.toml index b6d349fb..6494cb0e 100644 --- a/dev.toml +++ b/dev.toml @@ -43,3 +43,7 @@ content-type = "text/markdown" [tool.setuptools.packages] find = { namespaces = false } + +# The main window's icon, loaded from beside main_ui.py +[tool.setuptools.package-data] +"pybreeze.pybreeze_ui.editor_main" = ["pybreeze_icon.ico"] diff --git a/docs/source/Eng/ai_tools.rst b/docs/source/Eng/ai_tools.rst index 7eb26c1a..92077ae7 100644 --- a/docs/source/Eng/ai_tools.rst +++ b/docs/source/Eng/ai_tools.rst @@ -1,155 +1,180 @@ AI Tools ======== -PyBreeze integrates several AI-powered tools for code review, prompt engineering, -and LLM interaction. All AI tools are accessible from the **Tools** menu and can -be opened as either tabs or dock widgets. +PyBreeze has five tools for AI-assisted code review and prompt work. Each opens as a +tab from **Tools > AI** or as a dock from **Dock > AI**. The code review of a file or a +pull request by prthinker is in the **Automation** menu instead (see +:doc:`menu_automation`). + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Tool + - What it does + * - **AI Code Review** + - Sends code to an endpoint in one request; accept or reject the answer. + * - **CoT Code Review** + - Runs the eight-step Chain-of-Thought (CoT) review: one request per step. + * - **CoT Prompt Editor** + - Edits the eight CoT prompts. + * - **Skill Prompt Editor** + - Edits the two skill prompts (code review, code explanation). + * - **Skill Send** + - Sends one skill prompt, with your code in it, and shows the answer. + +In AI Code Review, CoT Code Review and Skill Send, **Ctrl+Enter** anywhere in the panel +presses its send button. The request runs in the background, and the send button stays +greyed out until it ends. -AI Code-Review Client ---------------------- +.. note:: + + The endpoint URL must be a public ``http`` / ``https`` address. It is checked before + anything is sent, and the connection goes only to the address checked: an endpoint on + this machine or on a private network, such as a local model server, is refused. + Redirects are not followed, the answer is capped at 16 MB, and a request that runs + past five minutes is stopped. Only AI Code Review records the URLs it used, and only as + fingerprints (see below), since an API URL can carry a token. -**Menu:** Tools > AI Code-Review Tab / AI Code-Review Dock +AI Code Review +-------------- -A client for sending code to an AI API endpoint for automated code review. +**Menu:** Tools > AI > AI Code Review Tab / Dock > AI > AI Code Review Dock Interface Layout ^^^^^^^^^^^^^^^^ -- **URL Input** -- Enter the API endpoint URL -- **Method Selector** -- Choose HTTP method (GET, POST, PUT, DELETE) -- **Code Input** (left panel) -- Paste or write code to be reviewed -- **Response Display** (right panel, read-only) -- Shows the AI review response -- **Send Request** button -- Sends the code to the API endpoint - -Features -^^^^^^^^ - -- Tracks accept/reject statistics for AI responses -- Records which endpoints have been used in ``.pybreeze/urls.txt``, as fingerprints: - an API URL can carry a token, so the URL itself is never written to disk -- Stores response statistics in ``.pybreeze/response_stats.txt`` +- **URL** -- the endpoint URL +- **Method** -- ``GET``, ``POST`` (the default), ``PUT`` or ``DELETE`` +- **Code to Send** (left) -- the code to review +- **Response** (right, read-only) -- the answer, or why there is none +- **Send Request** -- sends the request +- **Accept Response** / **Reject Response** -- your verdict on the answer Usage ^^^^^ -1. Enter your AI API endpoint URL in the URL input field -2. Select the HTTP method (typically POST) -3. Paste the code you want reviewed in the left panel -4. Click **Send Request** -5. Review the AI's response in the right panel +1. Enter the endpoint URL and choose the method. ``POST`` and ``PUT`` send the code as + the form field ``code`` in the body; ``GET`` and ``DELETE`` send the URL alone. +2. Paste the code to review on the left (``POST`` and ``PUT`` need some). +3. Click **Send Request**. The response panel first says whether this URL has been used + before, then shows the answer when it arrives. An answer that is not a success (an + HTTP error, or a redirect, which is not followed) is shown as an error with its status. +4. Click **Accept Response** or **Reject Response**. They are enabled once an answer has + arrived, and take one verdict per answer. -CoT Code Review GUI --------------------- +Files +^^^^^ + +- ``~/.pybreeze/response_stats.txt`` -- the running totals of accepted and rejected + answers, shared by every AI Code Review panel +- ``~/.pybreeze/urls.txt`` -- the URLs used, as SHA-256 fingerprints: the URL itself is + never written to disk -**Menu:** Tools > AI Code-Review Tab / Dock +CoT Code Review +--------------- -An advanced code review tool using Chain-of-Thought (CoT) prompting for -more structured and detailed reviews. +**Menu:** Tools > AI > CoT Code Review Tab / Dock > AI > CoT Code Review Dock Interface Layout ^^^^^^^^^^^^^^^^ -- **API URL Input** -- Enter the API endpoint URL -- **Code Area** -- Paste code for review -- **Response Selector** (ComboBox) -- Browse through multiple review responses -- **Response Viewer** (read-only) -- Displays the selected review response -- **Send Button** -- Sends code for review +- **API URL** -- the endpoint URL +- **Code to Review** -- the code the review is about +- **Response Area** -- **Step** selector (each step whose answer has arrived) beside the + selected step's answer (read-only) +- **Start Sending** -- runs the review -Features -^^^^^^^^ +How a review runs +^^^^^^^^^^^^^^^^^ -- Supports reviewing multiple files at once via ``SenderThread`` -- Background threading prevents UI freezing during API calls -- Multiple responses can be stored and browsed +The eight steps run in this order, because each may quote the answers of the steps +before it: + +1. ``first_summary_prompt.md`` -- a first summary of the code +2. ``first_code_review.md`` -- a first review +3. ``judge_single_review.md`` -- a judge of that review +4. ``linter.md`` -- lint findings +5. ``code_smell_detector.md`` -- code smells +6. ``step_by_step_analysis.md`` -- each lint finding and code smell walked through +7. ``total_summary.md`` -- the summary of everything above +8. ``judge.md`` -- a judge of the summary + +Each step's prompt, wrapped in the global review rules, is sent as a ``POST`` of the JSON +``{"prompt": "..."}``, and the response body, as text, is that step's answer. It appears +under **Step** as it arrives and is shown at once. A step that fails shows why, and the +steps after it do not quote the failure. **Start Sending** clears the previous run's +answers, and closing the panel stops the review after the request in flight. + +The prompts are the CoT Prompt Editor's: an edited prompt is used in place of the +built-in one. CoT Prompt Editor ----------------- -**Menu:** Tools > CoT Prompt Editor Tab / CoT Prompt Editor Dock - -A template-based editor for creating and managing Chain-of-Thought prompt templates. +**Menu:** Tools > AI > CoT Prompt Editor Tab / Dock > AI > CoT Prompt Editor Dock Interface Layout ^^^^^^^^^^^^^^^^ -- **File Selector** (ComboBox) -- Select from available prompt template files -- **Edit Panel** (QTextEdit) -- Edit the selected prompt template -- **Create** button -- Creates a new prompt template file -- **Save** button -- Saves changes to the current template -- **Reload** button -- Reloads the template from disk +- **Edit File Content** -- the text of the prompt chosen below +- The folder the prompt files are kept in, ``~/.pybreeze/prompts/`` +- The prompt selector (one entry per step, as listed above) +- **Reload** -- reads the file again from disk +- **Save** -- writes the text to the file +- **Create File** -- creates the file from the built-in prompt -Features -^^^^^^^^ +How prompts are kept +^^^^^^^^^^^^^^^^^^^^ -- Template-based file management with ``COT_TEMPLATE_RELATION`` mapping -- File system watcher for detecting external changes -- Auto-reloads templates when modified outside the editor -- Pre-configured templates for common CoT review patterns +Every prompt is built in. A file of the same name in ``~/.pybreeze/prompts/`` replaces it +while that file has content, so a review sends what you saved. Until the file exists, the +edit area is empty and says so; **Create File** writes the built-in prompt into it as a +starting point. -Usage -^^^^^ +A prompt's placeholders, such as ``{code_diff}``, are filled in when the review runs. An +edited prompt that names a placeholder the step cannot fill falls back to the built-in +one for that run. -1. Select a template from the dropdown or create a new one -2. Edit the prompt template in the text area -3. Click **Save** to persist your changes -4. The template can then be used in the CoT Code Review GUI +The files are watched: an edit made outside the editor shows up at once. Whenever +showing another text would lose unsaved edits -- choosing another prompt, **Reload**, +**Create File**, an outside change, or closing the tab, the dock or the IDE -- the editor +asks first, with **No** as the default. A file that is not UTF-8 is shown with what cannot +be read replaced, and says so; saving writes it back as UTF-8. Skill Prompt Editor ------------------- -**Menu:** Tools > Skill Prompt Editor Tab / Skill Prompt Editor Dock +**Menu:** Tools > AI > Skill Prompt Editor Tab / Dock > AI > Skill Prompt Editor Dock -Similar to the CoT Prompt Editor, but specialized for skill-based prompt templates -such as code review and code explanation prompts. +The same editor as the CoT Prompt Editor, for the two skill prompts: +``code_review_skill.md`` (a code review) and ``code_explainer_skill.md`` (a code +explanation). Its files are kept in the same folder and work the same way. -Interface Layout -^^^^^^^^^^^^^^^^ - -- **File Selector** (ComboBox) -- Select from available skill prompt templates -- **Edit Panel** (QTextEdit) -- Edit the selected skill prompt -- **Create** button -- Creates a new skill prompt template -- **Save** button -- Saves changes -- **Reload** button -- Reloads from disk - -Pre-built Skill Templates -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- Code Review prompts -- Code Explanation prompts - -Skills Send GUI ---------------- +Skill Send +---------- -**Menu:** Tools > Skill Send GUI Tab / Skill Prompt Dock - -An interface for sending skill-based prompts to an LLM API and viewing responses. +**Menu:** Tools > AI > Skill Send Tab / Dock > AI > Skill Send Dock Interface Layout ^^^^^^^^^^^^^^^^ -- **API URL Input** -- Enter the LLM API endpoint URL -- **Prompt Template Selector** (ComboBox) -- Choose a pre-defined skill prompt template -- **Prompt Text Area** -- Edit or customize the prompt before sending -- **Send Button** -- Sends the prompt to the API (runs in background thread) -- **Response Display** (read-only) -- Shows the LLM response - -Features -^^^^^^^^ - -- Background threading via ``RequestThread`` prevents UI freezing -- Error handling with specific HTTP status code messages -- Prompt templates are loaded from the Skill Prompt Editor's template files +- **LLM API URL** -- the endpoint URL +- **Select Prompt Template** -- the skill prompt to start from +- **Prompt** -- the prompt that is sent, editable +- **Send** -- sends it +- **Response** (read-only) -- the answer, or why there is none Usage ^^^^^ -1. Enter your LLM API endpoint URL -2. Select a prompt template from the dropdown -3. Customize the prompt text if needed (e.g., paste code to review) -4. Click **Send** -5. Wait for the response to appear in the response display area - -.. note:: - - All AI tools require a compatible API endpoint. Configure your API URL - to point to your LLM service (e.g., OpenAI-compatible API, local LLM server, etc.). +1. Enter the endpoint URL. +2. Choose a template. Its text (the edited file if there is one, otherwise the built-in + prompt) fills **Prompt**. Choosing another template after editing the prompt asks + first. +3. Put your code in place of ``{code_diff}`` in the prompt. The prompt is not sent while + ``{code_diff}`` is still in it. +4. Click **Send**. The prompt goes as a ``POST`` of the JSON ``{"code": "..."}``, and the + response body is shown as it is. A refused request (401, 403) and a server error are + shown as errors; a redirect is not followed and says where it pointed (its scheme and + host only). diff --git a/docs/source/Eng/getting_started.rst b/docs/source/Eng/getting_started.rst index 2ff0e64f..d2a2d432 100644 --- a/docs/source/Eng/getting_started.rst +++ b/docs/source/Eng/getting_started.rst @@ -4,8 +4,9 @@ Getting Started Requirements ------------ -- Python 3.10 or higher +- Python 3.10 to 3.14 - pip (Python package manager) +- Windows, macOS or Linux; PySide6 is installed with PyBreeze Installation ------------ @@ -16,6 +17,19 @@ Install PyBreeze from PyPI: pip install pybreeze +Or from source: + +.. code-block:: bash + + git clone https://github.com/Integration-Automation/PyBreeze.git + cd PyBreeze + pip install -r requirements.txt + +Either way the automation modules (AutoControl, APITestka, WebRunner, LoadDensity, +FileAutomation, MailThunder, TestPioneer), paramiko and JupyterLab are installed with it. +The **Install** menu upgrades them later, into the interpreter runs use (see +:doc:`menu_install`). + Launching PyBreeze ------------------ @@ -39,8 +53,8 @@ Launching PyBreeze from pybreeze import start_editor - # Available themes: dark_amber.xml (default), dark_teal.xml, - # dark_blue.xml, light_blue.xml, etc. + # Any theme the UI Style menu lists: dark_teal.xml, dark_blue.xml, + # light_blue.xml, ... It replaces the theme picked from UI Style. start_editor(theme="dark_teal.xml") Parameters @@ -57,11 +71,27 @@ Parameters * - ``debug_mode`` - bool - ``False`` - - Auto-close after 10 seconds (for CI testing) + - Close by itself after 10 seconds (for start-up tests) * - ``theme`` - - str - - ``"dark_amber.xml"`` - - Qt Material theme name + - str or None + - ``None`` + - Qt Material theme name. It replaces the theme picked from **UI Style** and is kept + as the picked one. ``None`` starts with the picked theme (``dark_amber.xml`` until + one is picked). + +The Working Folder +------------------ + +Start PyBreeze from your project folder. The folder it starts in, or the one opened +later with **File > Open Folder**, is where: + +- the file tree opens; +- a ``venv/`` or ``.venv/`` is looked for, to run scripts with when no interpreter is + chosen under **Python Env**; +- **Create ... Project** and the TestPioneer template are written. + +Plugins are loaded once, at start-up, from ``jeditor_plugins/`` in the folder PyBreeze +starts in (see :doc:`menu_plugins`). First Launch ------------ @@ -71,7 +101,8 @@ When PyBreeze starts, the main window opens maximized with: 1. **Menu Bar** at the top with all available menus 2. **File Tree** on the left side for project navigation 3. **Code Editor** (tabbed) in the center for editing files -4. **Output Panel** at the bottom for execution results +4. **Output panel** below the editor, whose **Code result** tab shows the output of **Run Program** and + **Run On Shell** PyBreeze inherits its core editor functionality from **JEditor** and extends it with automation-specific menus, tools, and integrations. diff --git a/docs/source/Eng/how_to_extend_ui.rst b/docs/source/Eng/how_to_extend_ui.rst index faeb0fdf..4966f671 100644 --- a/docs/source/Eng/how_to_extend_ui.rst +++ b/docs/source/Eng/how_to_extend_ui.rst @@ -51,11 +51,44 @@ How It Works You must register your custom tabs in ``EDITOR_EXTEND_TAB`` **before** calling ``start_editor()``, as the tabs are loaded during window initialization. +A widget whose constructor raises costs only its own tab: the error is logged and the IDE +starts without it. + +Asking Before Closing +--------------------- + +A tab that can hold unsaved work can say so. Give the widget a ``may_close()`` method +returning ``True`` when it may close: closing its tab, or the IDE, asks it first, and +``False`` keeps it open. Ask the user there, as PyBreeze's own prompt and diagram editors +do: + +.. code-block:: python + + from PySide6.QtWidgets import QMessageBox, QTextEdit, QVBoxLayout, QWidget + + + class NotesTab(QWidget): + def __init__(self): + super().__init__() + self.text = QTextEdit() + QVBoxLayout(self).addWidget(self.text) + + def may_close(self) -> bool: + if not self.text.document().isModified(): + return True + reply = QMessageBox.question(self, "Unsaved notes", "Close and lose the notes?") + return reply == QMessageBox.StandardButton.Yes + +A ``may_close()`` that raises counts as a yes (it is logged): it cannot keep the IDE from +closing. + Advanced: Plugin-Based Tabs ---------------------------- You can also add custom tabs via the plugin system by placing a plugin file -in the ``jeditor_plugins/`` directory: +in the ``jeditor_plugins/`` directory (see :doc:`menu_plugins`). Register the tab in the +plugin's ``register()``: plugins load while the main window is being built, before its +tabs are added. .. code-block:: python @@ -63,6 +96,8 @@ in the ``jeditor_plugins/`` directory: from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit from pybreeze import EDITOR_EXTEND_TAB + PLUGIN_NAME = "My Tool" + class MyToolWidget(QWidget): def __init__(self): @@ -73,6 +108,8 @@ in the ``jeditor_plugins/`` directory: layout.addWidget(self.text_edit) - EDITOR_EXTEND_TAB.update({"My Tool": MyToolWidget}) + def register() -> None: + EDITOR_EXTEND_TAB.update({"My Tool": MyToolWidget}) -This plugin will be auto-discovered and loaded when PyBreeze starts. +This plugin will be auto-discovered and loaded when PyBreeze starts from a folder that +has it in ``jeditor_plugins/``. diff --git a/docs/source/Eng/images/ui.png b/docs/source/Eng/images/ui.png index dab23198..13d26169 100644 Binary files a/docs/source/Eng/images/ui.png and b/docs/source/Eng/images/ui.png differ diff --git a/docs/source/Eng/jupyter_lab.rst b/docs/source/Eng/jupyter_lab.rst index dec27253..47d07b2b 100644 --- a/docs/source/Eng/jupyter_lab.rst +++ b/docs/source/Eng/jupyter_lab.rst @@ -7,21 +7,23 @@ Jupyter notebooks directly within the IDE. Opening JupyterLab ------------------- -JupyterLab is available from the tab menu. When opened, it creates a new tab -containing a full JupyterLab interface rendered via Qt's web engine. +Open it from **Tab > Tools Tab > JupyterLab**. It opens a new tab (titled +``JupyterLab ``) containing a full JupyterLab interface rendered via Qt's web engine. First-Time Setup ^^^^^^^^^^^^^^^^ -On first launch, if JupyterLab is not installed, PyBreeze will automatically -install it using pip. A status label shows the initialization progress. +If the interpreter the lab runs in cannot import ``jupyterlab``, PyBreeze first +installs it there with ``pip install -U jupyterlab``. A status label shows the progress. Interface --------- The JupyterLab tab contains: -- **Status Label** -- Shows initialization status ("Starting JupyterLab...", "Ready", etc.) +- **Status Label** -- Shows the startup status ("Initializing...", "Downloading..." while + JupyterLab is installed, "Loading... (Ns / 60s)" while the server starts) and is removed + once the lab loads; if the lab cannot start it reads "JupyterLab init failed: " - **Web Engine View** -- A full JupyterLab interface rendered in a ``QWebEngineView`` The embedded JupyterLab provides all standard Jupyter features: @@ -51,7 +53,10 @@ How It Works Usage Tips ---------- -- JupyterLab runs on a local port; no external network access is needed +- JupyterLab listens on a free port on localhost only; network access is needed only to + install JupyterLab when it is missing - You can open multiple notebooks in JupyterLab's own tab system - Use JupyterLab for data analysis, prototyping, and interactive testing -- The embedded JupyterLab shares the same Python environment as PyBreeze +- JupyterLab and its kernels run in the interpreter a script run uses: the one chosen under + **Python Env > Choose python interpreter**; with none chosen, a ``venv`` or ``.venv`` in + the working folder, else the interpreter PyBreeze itself runs on diff --git a/docs/source/Eng/menu_automation.rst b/docs/source/Eng/menu_automation.rst index 1de781c3..a6030f91 100644 --- a/docs/source/Eng/menu_automation.rst +++ b/docs/source/Eng/menu_automation.rst @@ -7,10 +7,12 @@ and email automation. Each automation module follows a consistent menu structure: -- **RUN** submenu -- Execute scripts (single or multi-file, with or without email reporting) -- **HELP** submenu -- Links to documentation and GitHub repository -- **Project** submenu -- Create new project templates -- **GUI Tab** -- Open an embedded GUI widget (available for some modules) +- **Run** submenu -- Execute scripts (single or multi-file, with or without email reporting) +- **Help** submenu -- Links to documentation and GitHub repository, opened as browser tabs inside the IDE +- **Project** submenu -- Create a new project template in the IDE's working directory +- ** GUI** -- Open the module's GUI as a tab (**APITestka GUI**, **AutoControl GUI**, **LoadDensity GUI**) + +**TestPioneer** and **Code Review (prthinker)** have menus of their own, described below. AutoControl Menu ---------------- @@ -18,7 +20,7 @@ AutoControl Menu **AutoControl** is a GUI automation module for desktop application testing. It can record and replay mouse/keyboard actions. -RUN Submenu +Run Submenu ^^^^^^^^^^^ .. list-table:: @@ -29,14 +31,14 @@ RUN Submenu - Description * - **Run AutoControl Script** - Executes the current editor content as an AutoControl script. - * - **Run AutoControl Script With Send** + * - **Run AutoControl With Send** - Executes the script and sends the results via email (using MailThunder). * - **Run Multi AutoControl Script** - Runs multiple AutoControl scripts from a selected directory. * - **Run Multi AutoControl Script With Send** - Runs multiple scripts and sends the results via email. -HELP Submenu +Help Submenu ^^^^^^^^^^^^ .. list-table:: @@ -59,8 +61,8 @@ Project Submenu * - Menu Item - Description - * - **Create Project** - - Creates a new AutoControl project structure using ``je_auto_control``. + * - **Create AutoControl Project** + - Creates a AutoControl project template (``je_auto_control``) in the IDE's working directory, asking before it replaces one that is already there. Record Submenu ^^^^^^^^^^^^^^ @@ -74,13 +76,15 @@ mouse and keyboard actions for playback. * - Menu Item - Description - * - **Start Record** + * - **Record Start** - Starts recording mouse and keyboard actions. - * - **Stop Record** - - Stops recording and inserts the recorded action data into the code editor. + * - **Record Stop** + - Stops recording and inserts the recorded actions, as the JSON the AutoControl runner + reads, at the cursor of the editor tab in front; with no editor tab in front they are + copied to the clipboard. If nothing was recorded, it says so. -GUI Tab -^^^^^^^ +AutoControl GUI +^^^^^^^^^^^^^^^ Opens an embedded AutoControl GUI widget as a new tab in the editor, providing a visual interface for AutoControl operations. @@ -91,7 +95,7 @@ APITestka Menu **APITestka** is an API testing automation module for sending HTTP requests and validating responses. -RUN Submenu +Run Submenu ^^^^^^^^^^^ .. list-table:: @@ -102,14 +106,14 @@ RUN Submenu - Description * - **Run APITestka Script** - Executes the current editor content as an APITestka script. - * - **Run APITestka Script With Send** + * - **Run APITestka With Send** - Executes the script and sends results via email. * - **Run Multi APITestka Script** - Runs multiple APITestka scripts from a selected directory. * - **Run Multi APITestka Script With Send** - Runs multiple scripts and sends results via email. -HELP Submenu +Help Submenu ^^^^^^^^^^^^ - **Open APITestka Doc** -- Opens https://apitestka.readthedocs.io/ @@ -118,10 +122,10 @@ HELP Submenu Project Submenu ^^^^^^^^^^^^^^^ -- **Create Project** -- Creates a new APITestka project structure using ``je_api_testka``. +- **Create APITestka Project** -- Creates a APITestka project template (``je_api_testka``) in the IDE's working directory, asking before it replaces one that is already there. -GUI Tab -^^^^^^^ +APITestka GUI +^^^^^^^^^^^^^ Opens an embedded APITestka GUI widget as a new tab for visual API testing. @@ -131,7 +135,7 @@ WebRunner Menu **WebRunner** is a web browser automation module for testing web applications using browser drivers (Selenium-based). -RUN Submenu +Run Submenu ^^^^^^^^^^^ .. list-table:: @@ -142,14 +146,14 @@ RUN Submenu - Description * - **Run WebRunner Script** - Executes the current editor content as a WebRunner script. - * - **Run WebRunner Script With Send** + * - **Run WebRunner With Send** - Executes the script and sends results via email. * - **Run Multi WebRunner Script** - Runs multiple WebRunner scripts from a selected directory. * - **Run Multi WebRunner Script With Send** - Runs multiple scripts and sends results via email. -HELP Submenu +Help Submenu ^^^^^^^^^^^^ - **Open WebRunner Doc** -- Opens https://webrunner.readthedocs.io/ @@ -158,7 +162,7 @@ HELP Submenu Project Submenu ^^^^^^^^^^^^^^^ -- **Create Project** -- Creates a new WebRunner project structure using ``je_web_runner``. +- **Create WebRunner Project** -- Creates a WebRunner project template (``je_web_runner``) in the IDE's working directory, asking before it replaces one that is already there. LoadDensity Menu ---------------- @@ -166,7 +170,7 @@ LoadDensity Menu **LoadDensity** is a load/performance testing module that generates concurrent requests to test system capacity. -RUN Submenu +Run Submenu ^^^^^^^^^^^ .. list-table:: @@ -177,14 +181,14 @@ RUN Submenu - Description * - **Run LoadDensity Script** - Executes the current editor content as a LoadDensity script. - * - **Run LoadDensity Script With Send** + * - **Run LoadDensity With Send** - Executes the script and sends results via email. * - **Run Multi LoadDensity Script** - Runs multiple LoadDensity scripts from a selected directory. * - **Run Multi LoadDensity Script With Send** - Runs multiple scripts and sends results via email. -HELP Submenu +Help Submenu ^^^^^^^^^^^^ - **Open LoadDensity Doc** -- Opens https://loaddensity.readthedocs.io/ @@ -193,10 +197,10 @@ HELP Submenu Project Submenu ^^^^^^^^^^^^^^^ -- **Create Project** -- Creates a new LoadDensity project structure using ``je_load_density``. +- **Create LoadDensity Project** -- Creates a LoadDensity project template (``je_load_density``) in the IDE's working directory, asking before it replaces one that is already there. -GUI Tab -^^^^^^^ +LoadDensity GUI +^^^^^^^^^^^^^^^ Opens an embedded LoadDensity GUI widget as a new tab for visual load test configuration. @@ -206,7 +210,7 @@ FileAutomation Menu **FileAutomation** is a file operation automation module for automating file system tasks such as copying, moving, renaming, and processing files. -RUN Submenu +Run Submenu ^^^^^^^^^^^ .. list-table:: @@ -217,14 +221,14 @@ RUN Submenu - Description * - **Run FileAutomation Script** - Executes the current editor content as a FileAutomation script. - * - **Run FileAutomation Script With Send** + * - **Run FileAutomation With Send** - Executes the script and sends results via email. * - **Run Multi FileAutomation Script** - Runs multiple FileAutomation scripts from a selected directory. * - **Run Multi FileAutomation Script With Send** - Runs multiple scripts and sends results via email. -HELP Submenu +Help Submenu ^^^^^^^^^^^^ - **Open FileAutomation Doc** -- Opens https://fileautomation.readthedocs.io/ @@ -233,7 +237,7 @@ HELP Submenu Project Submenu ^^^^^^^^^^^^^^^ -- **Create Project** -- Creates a new FileAutomation project structure using ``automation_file``. +- **Create FileAutomation Project** -- Creates a FileAutomation project template (``automation_file``) in the IDE's working directory, asking before it replaces one that is already there. MailThunder Menu ---------------- @@ -241,7 +245,7 @@ MailThunder Menu **MailThunder** is an email automation module for sending test reports and automated notifications. -RUN Submenu +Run Submenu ^^^^^^^^^^^ .. list-table:: @@ -253,7 +257,7 @@ RUN Submenu * - **Run MailThunder Script** - Executes the current editor content as a MailThunder script. -HELP Submenu +Help Submenu ^^^^^^^^^^^^ - **Open MailThunder Doc** -- Opens https://mailthunder.readthedocs.io/ @@ -262,7 +266,7 @@ HELP Submenu Project Submenu ^^^^^^^^^^^^^^^ -- **Create Project** -- Creates a new MailThunder project structure. +- **Create MailThunder Project** -- Creates a MailThunder project template (``je_mail_thunder``) in the IDE's working directory, asking before it replaces one that is already there. TestPioneer Menu ---------------- @@ -275,11 +279,39 @@ TestPioneer Menu * - Menu Item - Description - * - **Create TestPioneer Yaml Template** - - Generates a YAML template file for defining test cases. - * - **Execute Test Pioneer Yaml** - - Opens a file dialog to select a ``.yml`` file and executes the test + * - **Create TestPioneer YAML Template** + - Creates ``.TestPioneer/.TestPioneer.yml`` in the IDE's working directory, asking + before it replaces one that is already there. + * - **Run TestPioneer YAML** + - Opens a file dialog to select a ``.yml`` or ``.yaml`` file and executes the test definitions within it. + * - **Help > Open TestPioneer GitHub** + - Opens the TestPioneer GitHub repository (its README is its manual) in a browser tab. + +Code Review (prthinker) Menu +---------------------------- + +Runs the prthinker chain-of-thought code review; output streams into a run window. +Install prthinker first with **Install > Automation > Install prthinker (code review)**. + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Menu Item + - Description + * - **Review the current file** + - Saves the file in the editor tab in front, then reviews it. + * - **Review a Pull Request** + - Asks for the pull request number and reviews that pull request of the repository + set in **Settings**. + * - **Settings** + - Opens the prthinker settings: inference backend, model, code host, repository, + keys and tokens. + * - **Help > Open prthinker documentation** + - Opens https://code-review-framework.readthedocs.io/ in a browser tab. + * - **Help > Open prthinker GitHub** + - Opens the prthinker GitHub repository in a browser tab. Script Execution Flow --------------------- @@ -289,7 +321,8 @@ When you run any automation script, the following process occurs: 1. The current code editor content is captured. 2. A subprocess is spawned using the ``TaskProcessManager``. 3. The script runs in an isolated process (preventing crashes from affecting the IDE). -4. A **Code Output Window** opens to display real-time execution output. +4. A run window opens to display real-time execution output, titled with the package + and the file it runs, with a **Stop** button. 5. If "With Send" was selected, results are sent via MailThunder email after execution. .. note:: @@ -302,10 +335,10 @@ Multi-Script Execution When using the "Run Multi" options: -1. A directory selection dialog opens. -2. All matching script files in the selected directory are collected. -3. Each script is executed sequentially in its own subprocess. -4. Results from all scripts are aggregated. +1. A folder selection dialog opens. +2. Every ``.json`` action file in the folder and its subfolders is collected; a folder with none says so. +3. The files run one after another, each in its own subprocess and its own run window. +4. Each run reports on its own (and, with "With Send", mails its own report); stopping one run ends the batch. Report Formats ^^^^^^^^^^^^^^ diff --git a/docs/source/Eng/menu_file_run_text.rst b/docs/source/Eng/menu_file_run_text.rst index b0c71570..e864a087 100644 --- a/docs/source/Eng/menu_file_run_text.rst +++ b/docs/source/Eng/menu_file_run_text.rst @@ -1,8 +1,12 @@ File, Run, Text & Other Base Menus =================================== -These menus are inherited from the JEditor base editor engine and provide -core editing and execution functionality. +These menus come from the JEditor editor engine PyBreeze is built on. Almost every entry +acts on the editor tab in front, and does nothing when the tab in front is not an editor. + +JEditor keeps these settings in ``.jeditor/user_setting.json`` in the working folder, so +each project folder has its own; they are saved every minute and when the IDE closes. An +editor tab that has a file is also saved to it every few seconds. File Menu --------- @@ -13,13 +17,27 @@ File Menu * - Menu Item - Description + * - **New File** + - Asks for a name and a place, and creates an empty file there (it is not opened). * - **Open File** - - Opens a file dialog to select a file. The file content is loaded into the code editor. + - Opens a file into the editor tab in front, replacing what it shows, without asking + about unsaved text there (see :doc:`ui_overview`). + * - **Open Folder** + - Makes a folder the working folder: the file tree shows it, and its settings are + loaded (see :doc:`getting_started`). * - **Save File** - - Saves the current code editor content to the currently opened file. - * - **Encoding** - - Opens a dialog to choose the encoding for the program runner and shell runner - (e.g., UTF-8, ASCII, Big5). + - Opens a **Save As** dialog, starting in the working folder, and writes the tab there. + * - **Recent Files** + - The last files opened; one opens in a new tab. The list is rebuilt at start-up. + * - **Font** / **Font Size** + - The font and size of the whole window: menus, trees and panels. + * - **Encodings** + - The encoding of the tab's file. The file is read again in it when the tab has no + unsaved edits, and the next save writes it. + * - **Line Endings** + - ``LF``, ``CRLF`` or ``CR`` for the next save of the tab's file. + * - **Save All** + - Writes every editor tab that has a file. Run Menu -------- @@ -30,28 +48,41 @@ Run Menu * - Menu Item - Description - * - **Run Program** - - Executes the current code editor content using the program runner (Python interpreter). - * - **Run On Shell** - - Executes the current code editor content using the system shell. + * - **Run Program > Run Program** + - Opens a **Save As** dialog, writes the tab, then runs the file with Python; the + output goes to the tab's **Code result**. One program at a time per tab. + * - **Run Program > Show program input** + - A small window whose line is sent to the running program's standard input. + * - **Run On Shell > Run On Shell** + - Runs the tab's text, as it is, as one shell command (``cmd.exe`` on Windows); the + output goes to **Code result**. + * - **Run On Shell > Show shell input** + - The same input window, for the shell. + * - **Debugger > Run Debugger** + - Opens a **Save As** dialog, then runs the file under ``pdb`` with the breakpoints set + in the editor's gutter, output in the **Debugger** tab and the input window open. + With JEditor 1.0.27 it runs once per editor tab: to debug again, open the file in + another tab. + * - **Debugger > Show debugger input** + - Opens the debugger's input window again. * - **Clean Result** - - Clears the output panel (both program and shell results). - * - **Stop Program** - - Stops the currently running program process. - -Run Help Submenu -^^^^^^^^^^^^^^^^ - -.. list-table:: - :header-rows: 1 - :widths: 30 70 - - * - Menu Item - - Description - * - **Run Help** - - Displays help information about the program runner. - * - **Shell Help** - - Displays help information about the shell runner. + - Empties the tab's **Code result**. + * - **Stop current program** + - Stops the tab's program, shell command and debugger. + * - **Stop All Program** + - Stops every program, shell command, debugger and ``pip`` run started from these + menus, in any tab, and the run in every PyBreeze run window (automation scripts, + installs, **Run with...**); the run windows stay open with their output. + * - **Run Help > Run Help** / **Shell Help** + - Tips: check the interpreter, and match the encoding to the shell's. + * - **Run with...** + - Only when a plugin has registered a run configuration (see :doc:`menu_plugins`). + +Run Program, Run Debugger, the **Python Env** entries and PyBreeze's automation runs use +the interpreter chosen under **Python Env > Choose python interpreter**. With none chosen, +Run Program and Run Debugger use a ``venv/`` in the working folder, else Python on +``PATH``; PyBreeze's automation runs and JupyterLab use a ``venv/`` or ``.venv/`` there, +else the Python PyBreeze runs on. Run On Shell uses no interpreter. Text Menu --------- @@ -62,10 +93,27 @@ Text Menu * - Menu Item - Description - * - **Font** - - Opens a font selection dialog to change the editor's default font. - * - **Font Size** - - Opens a dialog to change the editor's default font size. + * - **Font** / **Font Size** + - The font and size of the editors and their **Code result**, in every editor tab. + * - **Word Wrap** + - Wraps long lines in every editor tab (off again at the next start). + * - **Indent Size** + - 2, 4 or 8 spaces: the tab width and indent unit. A file's own indentation wins. + * - **Trim Trailing Whitespace**, **Convert Indentation to Spaces** / **to Tabs** + - The whole document. + * - **Remove Duplicate Lines**, **Reverse Lines**, **Sort Lines (Natural)**, + **Remove Blank Lines**, **Align by Delimiter...** + - The lines the selection covers, at least two. **Align by Delimiter...** asks for the + delimiter (``=`` by default). + * - **Uppercase Selection**, **Lowercase Selection**, **Swap Case**, **Title Case**, + **Naming Style**, **Number Base**, **Encode / Decode** + - The selected text; nothing happens without a selection, or when it cannot be + converted. **Naming Style** gives ``snake_case``, ``camelCase``, ``PascalCase`` or + ``kebab-case``; **Number Base** hexadecimal, decimal or binary; **Encode / Decode** + Base64, URL, HTML and JSON string escaping both ways. + * - **Statistics** + - Lines, words, characters and characters without spaces, of the selection or the + whole document. Check Code Style Menu --------------------- @@ -77,12 +125,20 @@ Check Code Style Menu * - Menu Item - Description * - **yapf** - - Checks and formats the current Python code using the ``yapf`` code formatter. + - Reformats the whole tab with ``yapf`` (Google style); nothing changes on a syntax + error. * - **Reformat JSON** - - Reformats and validates the current content as JSON, applying proper indentation. - -Venv Menu ---------- + - Rewrites the tab as JSON with a 4-space indent and sorted keys; an error is shown in + **Code result**. + * - **Python format check** + - Runs ``pycodestyle`` on the saved ``.py`` file (unsaved edits are not checked) and + lists what it found in the **Format checker** tab. + * - **Format on Save** + - On or off: ``.py`` files are reformatted with ``yapf`` as **Save File**, **Save All** + and **Run Program** write them. The automatic save does not format. + +Python Env Menu +--------------- .. list-table:: :header-rows: 1 @@ -90,22 +146,40 @@ Venv Menu * - Menu Item - Description - * - **Create Venv** - - Creates a Python virtual environment in the current working directory - using the shell runner. - * - **pip upgrade package** - - Upgrades a specified package using pip within the virtual environment. - * - **pip package** - - Installs a specified package using pip within the virtual environment. - -.. note:: - - PyBreeze automatically detects ``.venv`` or ``venv`` directories in your - project and uses the virtual environment's Python interpreter when available. + * - **Create venv** + - Runs ``python -m venv venv`` in the working folder, with the chosen interpreter or + Python on ``PATH``; the output goes to **Code result**. + * - **pip upgrade package** / **pip package** + - Ask for a package name and run ``pip install`` (``-U`` to upgrade) with the chosen + interpreter, or the one in ``venv/``. They need a ``venv/`` in the working folder. + * - **Choose python interpreter** + - Picks the interpreter file. It is saved, and used as described under **Run Menu** + above; the **Install** menu installs into it too. + +Tab and Dock Menus +------------------ + +**Tab** opens a panel as a tab, **Dock** as a dock (see :doc:`ui_overview`): + +- **Add Editor Tab**, **Add Web Browser Tab**, and a docked editor (**Dock > Editor > + New Dock Editor**, which writes its file back when the dock closes) +- **Console Widget** -- an interactive shell (``cmd``, PowerShell, ``bash`` or ``sh``) +- **Toggle Split View** (the same document twice) and **Toggle Minimap**, for the tab in + front +- **Snippet Editor** -- the snippets in ``.jeditor/snippets.json`` +- **Tools Tab** -- IPython (Jupyter, inside the IDE's own Python), the variable inspector + (empty with JEditor 1.0.27: nothing gives it variables to show), + FrontEngine, ChatUI, the TODO panel, the outline of the current Python file, and + JupyterLab (see :doc:`jupyter_lab`) +- **Git Tab** -- the Git client, the branch tree viewer, the code diff viewer, and the + current file's diff against ``HEAD`` or the staged version +- **Dock > Tools** also has **Problems** (``ruff`` findings) and **Tests** (``pytest`` in + the working folder) UI Style Menu ------------- -Contains a list of available Qt Material themes. Clicking any theme immediately -applies it to the entire application. See :doc:`ui_overview` for the full list -of available themes. +The Qt Material themes (see :doc:`ui_overview`): clicking one applies it at once, and it +is saved for the next start. **Show Indent Guides** and **Show Trailing Whitespace** turn +those marks in the editors on and off, and **Keyboard Shortcuts...** rebinds the editor's +commands. diff --git a/docs/source/Eng/menu_install.rst b/docs/source/Eng/menu_install.rst index c1d141dc..e50489d0 100644 --- a/docs/source/Eng/menu_install.rst +++ b/docs/source/Eng/menu_install.rst @@ -23,10 +23,12 @@ Installs the automation module packages via pip. - Runs ``pip install -U je_load_density`` * - **Install WebRunner** - Runs ``pip install -U je_web_runner`` - * - **Install Automation File** + * - **Install FileAutomation** - Runs ``pip install -U automation_file`` * - **Install MailThunder** - Runs ``pip install -U je_mail_thunder`` + * - **Install TestPioneer** + - Runs ``pip install -U test_pioneer`` * - **Install prthinker (code review)** - prthinker is not on PyPI. The first time, asks for its source folder and remembers it; then runs ``pip install -U [runner]`` @@ -50,4 +52,5 @@ Tools Submenu with no shell in between, so a folder name holding ``&`` or ``|`` reaches pip unchanged. pip runs with the interpreter chosen in the Python environment menu; when none is chosen, with a ``venv`` or ``.venv`` in the working - directory, and otherwise with the Python found on ``PATH``. + directory, and otherwise with the interpreter PyBreeze itself runs on (only a + packaged build looks on ``PATH``). diff --git a/docs/source/Eng/menu_plugins.rst b/docs/source/Eng/menu_plugins.rst index f08a7445..031503cf 100644 --- a/docs/source/Eng/menu_plugins.rst +++ b/docs/source/Eng/menu_plugins.rst @@ -1,78 +1,137 @@ Plugins Menu ============ -PyBreeze supports a plugin system for extending functionality. Plugins are -auto-discovered from the ``jeditor_plugins/`` directory in your working directory. +PyBreeze uses JEditor's plugin system. Plugins are loaded at start-up from the +``jeditor_plugins/`` folder in the working directory (and, for a development checkout of +JEditor, the one beside its package): + +- a ``.py`` file is a plugin, and so is a folder with an ``__init__.py``; +- a folder without ``__init__.py`` only groups plugins, and is searched inside; +- names that start with ``_`` or ``.`` are skipped. + +Each plugin defines a ``register()`` function, called once as it loads. It may also set +``PLUGIN_NAME``, ``PLUGIN_AUTHOR``, ``PLUGIN_VERSION`` and ``PLUGIN_RUN_CONFIG``. Plugin Browser -------------- -Opens the plugin browser interface where you can: +**Plugins > Plugin Browser** opens a tab that lists the plugins in a GitHub repository +(``https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins`` by default; any +``https://github.com/owner/repo`` or ``owner/repo`` can be entered in **Repository URL** +and read with **Fetch Plugins**). Select a plugin to see its details and source, then +**Download & Install** saves it into ``jeditor_plugins/`` in the working directory, asking +before it replaces a plugin of the same name. An installed plugin loads at the next start. -- Browse available plugins -- View plugin details -- Install new plugins +The entry is there before any plugin is installed. Loaded Plugins -------------- -After startup, any plugins found in ``jeditor_plugins/`` are automatically loaded -and listed under the Plugins menu. Each loaded plugin appears as a menu entry. +Below the Plugin Browser, each loaded plugin has an entry: -Run With Menu -------------- +- a plugin without a run configuration (a translation, a syntax plugin): its name, which + shows its name, version and author; +- a plugin with a run configuration: a submenu named after the configuration, with + **About** and **Run with** ** (the suffixes it runs are listed in the label when + there are several). -The **Run With** menu provides options to run the current file using different -compilers and interpreters. This is dynamically built based on available -language support. +Run with... Menu +---------------- -Supported languages include: +The **Run** menu has a **Run with...** submenu once a plugin has registered a run +configuration: one entry per configuration, labelled with its name and suffixes. It runs +the file in the editor tab in front: -- **C** -- Compile and run with gcc/clang -- **C++** -- Compile and run with g++/clang++ -- **Go** -- Run with go run -- **Java** -- Compile and run with javac/java -- **Rust** -- Compile and run with rustc +1. The tab is saved first, in its own encoding and line ending; a tab without a file goes + through **Save As**. A save that fails is reported and nothing runs. +2. A file whose suffix the configuration does not list is refused. +3. The file runs in a run window titled with the configuration's name and the file, with a + **Stop** button: ``compiler [args...] file``, or, for a compiled language, the compiler + first (limited to 60 seconds) and then the program it built. The build goes into a + temporary folder that is removed afterwards. .. note:: - The available "Run With" options depend on which compilers/interpreters are - installed on your system and discoverable via the system PATH. + A run configuration only names the compiler or interpreter; it has to be installed and + on ``PATH``. Otherwise the run window says the command was not found. + +A run configuration is a dictionary: + +.. code-block:: python + + PLUGIN_RUN_CONFIG = { + "name": "Go", # the menu label + "suffixes": (".go",), # the files it runs + "compiler": "go", # the program started + "args": ("run",), # arguments between the compiler and the file + # For a compiled language: + # "compile_then_run": True, "output_flag": "-o", + # PyBreeze only: the encoding the program writes ("locale" is the machine's own) + # "encoding": "locale", + } Creating Plugins ---------------- -For detailed information on creating custom plugins, including syntax highlighting -plugins and UI translation plugins, see the -`Plugin Guide `_ -(the guide lives in the JEditor repository, since PyBreeze uses JEditor's plugin system). +The full plugin API, with worked examples, is JEditor's +`Plugin Guide `_; +PyBreeze's `PLUGIN_GUIDE.md `_ +covers what PyBreeze adds. Ready-made plugins are in +`IDE_Plugins `_. Syntax Highlighting Plugin Example ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Plugins can extend syntax highlighting with custom keywords: +A plugin can colour the keywords of a language, by file suffix: .. code-block:: python - # jeditor_plugins/my_syntax_plugin.py - from je_editor import syntax_word_dict + # jeditor_plugins/lua_syntax.py + from PySide6.QtGui import QColor + from je_editor.plugins import register_programming_language + + PLUGIN_NAME = "Lua syntax" + PLUGIN_VERSION = "1.0" - syntax_word_dict.update({ - "my_keyword": "keyword_format", - "my_function": "function_format", - }) + + def register() -> None: + register_programming_language( + suffix=".lua", + syntax_words={ + "keywords": {"words": ("function", "local", "end", "return"), + "color": QColor(86, 156, 214)}, + }, + syntax_rules={ + "comments": {"rules": (r"--[^\n]*",), "color": QColor(106, 153, 85)}, + }, + ) + +.. note:: + + Registered keywords are used only for a suffix JEditor does not colour itself. For + ``.c``, ``.cpp``, ``.go``, ``.h``, ``.hpp``, ``.java``, ``.js``, ``.json``, ``.rs``, + ``.sh``, ``.sql``, ``.toml``, ``.ts``, ``.yaml`` and ``.yml`` JEditor's own rules apply, + and a plugin's keywords for them are not shown. UI Translation Plugin Example ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Plugins can add new UI translations: +A plugin can add an interface language, listed in the **Language** menu: .. code-block:: python - # jeditor_plugins/my_language_plugin.py - from je_editor import language_wrapper + # jeditor_plugins/french.py + from je_editor.plugins import register_natural_language + + PLUGIN_NAME = "French" + + + def register() -> None: + register_natural_language("French", "Français", { + "file_menu_label": "Fichier", + # ... the translated strings + }) - language_wrapper.language_word_dict.update({ - "application_name": "My Custom Name", - # ... more translations - }) +The keys are those of JEditor's English dictionary and of PyBreeze's +(``pybreeze/extend_multi_language/extend_english.py``). A key the plugin leaves out is +shown in English. diff --git a/docs/source/Eng/menu_tools.rst b/docs/source/Eng/menu_tools.rst index 23445050..5abaa5fa 100644 --- a/docs/source/Eng/menu_tools.rst +++ b/docs/source/Eng/menu_tools.rst @@ -1,111 +1,193 @@ Tools Menu ========== -The **Tools** menu provides access to the SSH client, AI-powered development -tools, and the built-in WYSIWYG architecture-diagram editor. Each tool can be -opened either as a **Tab** (in the main tab widget) or as a **Dock** (a -floating/dockable panel). +The **Tools** menu opens the SSH client, the AI tools, the built-in WYSIWYG +architecture-diagram editor and the HTTP / API utilities, each as a **Tab** in the +main tab widget. Each of them also opens as a **Dock** (a floating or dockable +panel) from the **Dock** menu: + +.. list-table:: + :header-rows: 1 + :widths: 25 40 35 + + * - Tool + - Tab + - Dock + * - SSH client + - **Tools > SSH > SSH Client Tab** + - **Dock > SSH > SSH Client Dock** + * - AI tools + - **Tools > AI >** ** **Tab** + - **Dock > AI >** ** **Dock** + * - Diagram editor + - **Tools > Diagram Editor Tab** + - **Dock > Diagram Editor Dock** + * - HTTP / API utilities + - **Tools >** ** **Tab** + - **Dock >** ** **Dock** SSH --- -SSH Client Tab -^^^^^^^^^^^^^^ - -Opens an SSH client interface as a new tab. See :doc:`ssh_client` for full details. - -SSH Client Dock -^^^^^^^^^^^^^^^ - -Opens the same SSH client as a dockable widget that can be positioned -around the edges of the main window. +**SSH Client Tab** opens an SSH client (a terminal and an SFTP file tree) as a new tab; +**SSH Client Dock** opens the same client as a dockable panel. See :doc:`ssh_client` +for full details. AI Tools -------- -AI Code-Review Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Opens the AI Code Review client, which allows you to send code to an AI API -endpoint for automated code review. See :doc:`ai_tools` for full details. - -CoT Prompt Editor Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The **AI** submenus of **Tools** and **Dock** hold five tools. See :doc:`ai_tools` for full +details. -Opens the Chain-of-Thought (CoT) Prompt Editor for creating and managing -structured prompt templates. See :doc:`ai_tools` for full details. - -Skill Prompt Editor Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. list-table:: + :header-rows: 1 + :widths: 35 65 -Opens the Skill-based Prompt Editor for creating task-specific prompt templates -(e.g., code review prompts, code explanation prompts). See :doc:`ai_tools` for full details. + * - Tool + - Description + * - **AI Code Review** + - Sends code to an LLM endpoint for review, then accept or reject the suggestion. + * - **CoT Prompt Editor** + - Edits the Chain-of-Thought (CoT) review prompt templates. + * - **CoT Code Review** + - Runs the CoT review: each step's prompt goes to the endpoint in turn. + * - **Skill Prompt Editor** + - Edits the task-specific (skill) prompt templates, such as code review or code + explanation. + * - **Skill Send** + - Sends a skill prompt, with your code, to an LLM endpoint and shows the answer. + +HTTP and API Utilities +---------------------- + +Thirteen tools, each opened from **Tools** as a tab or from **Dock** as a dock. None of +them sends a request: they parse, convert and generate text. In a tool with one main +button, **Ctrl+Enter** anywhere in it presses that button (its text boxes take Enter as a +new line); in Query / JSON and the URL parser / builder, which convert both ways, it goes +the way the input reads: from JSON when the input is a JSON object. -Skill Send GUI Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. list-table:: + :header-rows: 1 + :widths: 30 70 -Opens the Skill Prompt Sender interface for sending prompts to an LLM API -and viewing responses. See :doc:`ai_tools` for full details. + * - Tool + - Description + * - **cURL Import** + - Turns a ``curl`` command copied from a browser's dev tools into a Python + ``requests`` script, a pytest test, APITestka (Python or a JSON action list) or a + LoadDensity Locust load test. + * - **HAR Import** + - Lists the requests in a browser's HAR export and turns the ones selected into one + test script, with the same targets as cURL Import. + * - **JWT Decoder** + - Shows a token's header and payload, with its time claims in UTC. The signature is + never verified. + * - **Timestamp Converter** + - Takes a Unix epoch (seconds to nanoseconds) or an ISO-8601 date-time and gives + every representation in UTC. + * - **Hash Generator** + - SHA-256, SHA-512, SHA-1 and MD5 of the text at once. + * - **Query / JSON** + - ``application/x-www-form-urlencoded`` to JSON and back. + * - **URL Parser / Builder** + - A URL as an editable JSON object of its parts, and back again. + * - **Regex Tester** + - Every match with its offsets and groups, with the ``IGNORECASE``, ``MULTILINE``, + ``DOTALL`` and ``VERBOSE`` flags. + * - **HTTP Status Reference** + - The status code table, searched by code or keyword. + * - **Text Diff** + - A unified diff of two texts, with an added / removed summary. + * - **JSON Format** + - Pretty-prints or minifies JSON. + * - **HTTP Header Analyzer** + - Reports repeated headers, cookie flags, CORS, HSTS and CSP weaknesses, and missing + security headers. Headers carrying credentials are reported by name only. + * - **Response Inspector** + - Reads a pasted HTTP response: status, headers, a JSON body and any JWT in it, each + one a click away from its own tool. Diagram Editor -------------- -Diagram Editor Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^ - -Opens the built-in WYSIWYG architecture-diagram editor. Use it to sketch -flowcharts and architecture diagrams directly inside PyBreeze without -switching to an external tool. +**Diagram Editor Tab** / **Diagram Editor Dock** open the built-in WYSIWYG +architecture-diagram editor. Use it to sketch flowcharts and architecture diagrams +directly inside PyBreeze without switching to an external tool. Its keyboard shortcuts +apply only while it has the focus, so as a dock it leaves the code editor's own alone. Drawing tools """"""""""""" -- **Select** -- pick, move, multi-select with rubber-band -- **Rectangle / Rounded Rectangle / Ellipse / Diamond** -- node shapes -- **Connection** -- connect two nodes with a labelled line -- **Text** -- free-floating text annotation -- **Image (file)** -- insert a local image file -- **Image (URL)** -- download and insert an image from a URL (validated - against private/loopback IP ranges and capped at 20 MB to prevent SSRF) +The toolbar's first row: + +- **Select** -- click to select, drag to move, drag on the empty canvas to select with a + rubber band +- **Rect** / **Rounded** / **Ellipse** / **Diamond** -- click the canvas to place a node + of that shape; the tool then goes back to **Select** +- **Connect** -- click the source node, then the target node; a click on the empty canvas + or **Esc** cancels +- **Text** -- click the canvas to place a text node +- **Image** -- insert a local image file +- **URL Image** -- download and insert an image from an ``http`` / ``https`` URL. The + address is checked (private, loopback and other non-public addresses are refused), the + connection goes only to the address checked, and the download is capped at 20 MB and + 120 seconds; it runs in the background, so a slow host does not hold the IDE. + +Double-click a node to edit its text; the edit is one undo step. File operations """"""""""""""" +The second row starts with the file buttons: + .. list-table:: :header-rows: 1 :widths: 25 75 - * - Action + * - Button - Description * - **New** - - Discards the current diagram (with confirmation if non-empty). + - Clears the canvas, asking first when anything is on it. * - **Open** - - Loads a previously saved ``.diagram.json`` file. - * - **Save** / **Save As** (``Ctrl+S`` / ``Ctrl+Shift+S``) - - Saves the diagram as ``.diagram.json``. - * - **Import Mermaid** - - Pastes Mermaid ``flowchart`` / ``graph`` source and converts it to - editable nodes and edges. - * - **Export PNG / SVG** - - Renders the canvas to a raster (PNG) or vector (SVG) image. + - Loads a previously saved ``.diagram.json`` file, asking first when the diagram + has changes that are not saved. A file that is not a diagram changes nothing. + * - **Save** (``Ctrl+S``) + - Saves to the file last opened or saved; the first time, it asks where, as + **Save As** does. + * - **Save As** (``Ctrl+Shift+S``) + - Saves the diagram as a new ``.diagram.json`` file. + * - **Import** + - Pastes Mermaid ``flowchart`` / ``graph`` source and converts it to editable, + automatically laid out nodes and connections. It replaces the canvas as one undo + step. + * - **PNG** / **SVG** + - Exports the canvas to a raster (PNG) or vector (SVG) image. + +Images in a saved diagram come back from where they were: a local path only if it is an +image file on this machine, and a URL through the same checks as **URL Image**. + +Closing the tab, the dock or the IDE with unsaved changes asks first as well. Editing helpers """"""""""""""" -- **Undo / Redo** (``Ctrl+Z`` / ``Ctrl+Y``) -- full undo stack with named - commands (Add, Move, Delete, Import, etc.) -- **Copy / Paste / Duplicate / Select All** -- standard shortcuts plus - ``Ctrl+D`` to duplicate selected items -- **Align** -- align selection by left, right, top, bottom, horizontal - centre, or vertical centre -- **Distribute** -- distribute three or more selected items evenly - horizontally or vertically -- **Grid** -- toggle background grid rendering -- **Snap** -- snap node positions to grid while dragging -- **Property Panel** (right side) -- edit text, colours, line width, - shape, and connection style of the selected item -- **Zoom** -- zoom in/out (``Ctrl+=`` / ``Ctrl+-``), reset to 100% - (``Ctrl+0``), or fit-to-content +- **Undo** / **Redo** (``Ctrl+Z`` / ``Ctrl+Y``) -- every change is one step +- **Delete** (or **Backspace**) removes the selection; ``Ctrl+C`` / ``Ctrl+V`` copy and + paste it, ``Ctrl+D`` duplicates it and ``Ctrl+A`` selects everything +- **Right-click** an item for **Delete**, **Duplicate** (nodes), **Bring to Front** and + **Send to Back**; the empty canvas for **Paste** (once something is copied) and + **Select All** +- **Align** -- **Align Left**, **Align Right**, **Align Top**, **Align Bottom**, + **Center Horizontal** and **Center Vertical** for the selected nodes, and + **Distribute Horizontal** / **Distribute Vertical** for three or more +- **Grid** -- shows the background grid +- **Snap** -- snaps nodes to the grid while dragging +- **Properties** panel (right side) -- the selected node's text, width, height, shape, + fill, border and font size; a connection's label, style (solid, dashed, dotted), + colour and width; an image's caption, width and height, and its source (read only) +- **Zoom** -- the mouse wheel, the **-** and **+** buttons or ``Ctrl+-`` / ``Ctrl+=``; + ``Ctrl+0`` resets to 100% and **Fit** shows the whole diagram. Drag with the right or + middle mouse button to pan. Tab vs. Dock ------------ diff --git a/docs/source/Eng/ssh_client.rst b/docs/source/Eng/ssh_client.rst index 2ae3df14..5cc5f075 100644 --- a/docs/source/Eng/ssh_client.rst +++ b/docs/source/Eng/ssh_client.rst @@ -2,12 +2,13 @@ SSH Client ========== PyBreeze includes a built-in SSH client for connecting to remote servers. -It can be opened from **Tools > SSH Client Tab** or **Tools > SSH Client Dock**. +It can be opened from **Tools > SSH > SSH Client Tab** or **Dock > SSH > SSH Client Dock**. Overview -------- -The SSH client provides three main components arranged in a horizontal splitter: +The SSH client shows the login widget at the top, above a horizontal splitter holding +the file tree and the command widget: 1. **Login Widget** (top) -- Connection settings 2. **File Tree** (left, ~30% width) -- Remote file browser @@ -25,15 +26,24 @@ The login widget provides fields for SSH connection: * - Field - Description * - **Host** - - The hostname or IP address of the remote server. + - The host name or IP address of the remote server. * - **Port** - The SSH port number (default: 22). - * - **Username** - - Your SSH username. - * - **Password / Key** - - Authentication credentials. Supports password-based and key-based authentication. - -After entering your credentials, click **Connect** to establish the SSH session. + * - **User** + - Your SSH user name. + * - **Use key auth** + - Tick for key-based authentication (choosing a key with **Browse...** ticks it). + * - **Key** / **Browse...** + - Path to the private key: an RSA, Ed25519 or ECDSA key in OpenSSH or PEM format, + PKCS#8 included (**Browse...** starts in ``~/.ssh``). A PuTTY ``.ppk`` key must first + be exported from PuTTYgen as an OpenSSH key; the error message says how. + * - **Password** + - The password; with key authentication it reads **Passphrase** and takes the key's + passphrase. + +After entering your credentials, click **Connect** (or press Enter in any field) to establish +the SSH session. An unknown host key is not accepted silently: its SHA256 fingerprint is shown +for confirmation on the first connection and kept in ``~/.pybreeze/ssh_known_hosts``. Remote File Browser ------------------- @@ -53,16 +63,24 @@ Right-click on any file or directory in the file tree to access: - Description * - **Refresh** - Reloads the current directory listing from the remote server. - * - **Create Folder** + * - **Create folder** - Creates a new directory on the remote server. * - **Rename** - - Renames the selected file or directory. + - Renames the selected file or directory (also **F2** while the tree has the focus). * - **Delete** - - Deletes the selected file or directory from the remote server. + - Deletes the selected file or directory from the remote server after a confirmation + (No is the default; also the **Delete** key while the tree has the focus). SFTP removes + only an empty folder. * - **Download** - Downloads the selected file to your local machine. - * - **Upload** - - Uploads a local file to the current remote directory. + * - **Upload to this folder** + - Uploads a local file into the folder right-clicked (or the folder holding the file + right-clicked), asking before it replaces a file on the server. + * - **Cancel the transfer** + - Shown while an upload or download runs; cancels it. + +Every request runs in the background, so a stalled link does not freeze the IDE, and both +directions write to a temporary file first, so a dropped link leaves the old copy whole. SSH Command Terminal -------------------- @@ -70,15 +88,20 @@ SSH Command Terminal The command widget provides an interactive terminal for executing commands on the remote server. -- Type commands and press Enter to execute -- Output is displayed in real-time -- Supports standard shell operations -- Command history is maintained during the session +- Type commands and press Enter to execute; Enter on an empty line reaches the shell too +- Output is displayed in real time, with ANSI colours, in a fixed-pitch font; the shell is + told the terminal's width and height as the view is resized +- **Up** and **Down** bring back earlier commands +- **Interrupt**, or Ctrl+C in the command line with nothing selected, stops what runs in the shell +- ``clear`` and ``reset`` wipe the view +- The view shows output line by line, so programs that draw on the whole screen by moving the + cursor, such as ``vim`` or ``htop``, come out garbled Usage Tips ---------- - Use the **Tab** mode to keep SSH alongside your code editor tabs - Use the **Dock** mode to position the SSH terminal on one side while coding -- The file browser supports drag-and-drop for uploads +- Upload and download from the file tree's right-click menu; F2 renames and Delete deletes + the entry in focus - You can have multiple SSH sessions open simultaneously in separate tabs/docks diff --git a/docs/source/Eng/ui_overview.rst b/docs/source/Eng/ui_overview.rst index e791c581..0ad23c64 100644 --- a/docs/source/Eng/ui_overview.rst +++ b/docs/source/Eng/ui_overview.rst @@ -3,8 +3,8 @@ UI Overview .. image:: images/ui.png -PyBreeze provides a tabbed, dock-based interface built on PySide6 (Qt for Python). -The main window is composed of several key areas. +PyBreeze provides a tabbed, dock-based interface built on PySide6 (Qt for Python) and +the JEditor editor engine. The main window is composed of several key areas. Main Window Layout ------------------ @@ -12,22 +12,28 @@ Main Window Layout Menu Bar ^^^^^^^^ -Located at the top of the window. Contains all menus for file operations, code execution, -automation modules, tool installation, SSH, AI tools, plugins, and more. - -The menu bar includes these top-level menus (from left to right): - -- **File** -- Open, save files, and set encoding -- **Run** -- Execute code, run on shell, stop programs -- **Text** -- Font and font size settings -- **Check Code Style** -- Code formatting tools (yapf, JSON) -- **Venv** -- Virtual environment management -- **UI Style** -- Theme switching -- **Automation** -- All automation module menus (AutoControl, APITestka, WebRunner, etc.) -- **Install** -- Install automation packages and build tools -- **Tools** -- SSH client, AI tools, diagram editor -- **Plugins** -- Plugin browser and loaded plugins -- **Run With** -- Run files with different compilers/interpreters +The menu bar holds these top-level menus, from left to right: + +- **File** -- new, open and save files, open a folder, recent files, font, encoding and + line endings (see :doc:`menu_file_run_text`) +- **Run** -- run the current file with Python, or its text as a shell command, the debugger, + stop a run; + **Run with...** when a plugin has registered a run configuration +- **Text** -- font, word wrap, indentation and text transformations +- **Check Code Style** -- ``yapf``, JSON reformatting, the Python format check and format + on save +- **Python Env** -- create a virtual environment, ``pip``, and choose the interpreter runs + use +- **Tab** -- open editor, browser, console, Git and tool tabs (JupyterLab is under + **Tools Tab**, see :doc:`jupyter_lab`) +- **Dock** -- open the same kinds of panels, and PyBreeze's tools, as docks +- **UI Style** -- the theme, indent guides, trailing whitespace and keyboard shortcuts +- **Language** -- the interface language +- **Automation** -- the automation modules (see :doc:`menu_automation`) +- **Install** -- install automation packages and build tools (see :doc:`menu_install`) +- **Tools** -- the SSH client, AI tools, diagram editor and HTTP / API utilities (see + :doc:`menu_tools`) +- **Plugins** -- the Plugin Browser and the loaded plugins (see :doc:`menu_plugins`) File Tree ^^^^^^^^^ @@ -36,8 +42,19 @@ Located on the left side of the window. Provides a file browser for navigating y directory. You can: - Browse files and folders -- Double-click to open files in the editor +- Click a file to open it in the editor tab in front, in place of what that tab shows + (unsaved text there is lost without a question, see the note below); a file already + open in a tab switches to that tab instead - Right-click any file or folder to access the context menu (see below) +- Press **F2** to rename, or **Delete** to delete, the item in focus while the tree has + the focus + +.. note:: + + With JEditor 1.0.27, opening a file into a tab -- a click in the tree, or **File > Open + File** -- does not ask about the text that tab holds. A tab with a file saves it every + few seconds, but a new tab's text, or the last seconds of typing, is gone. Open a new + tab first (**Tab > Add Editor Tab**) to keep it. File Tree Context Menu """""""""""""""""""""" @@ -52,110 +69,132 @@ Right-clicking the file tree opens a context menu with the following actions: - Description * - **New File** - Prompts for a name and creates an empty file in the clicked directory - (or the directory containing the clicked file). + (or the directory containing the clicked file). A name with a drive, a leading + slash, ``..`` or ``:`` is refused, and so is one that already exists. * - **New Folder** - - Prompts for a name and creates a new directory. + - Prompts for a name and creates a new directory, with the same checks. * - **Rename** - - Renames the selected file or folder. If the file is currently open in - an editor tab, the tab title and the editor's stored path are updated - to match. + - Renames the selected file or folder. Editor tabs open on it, or on a file inside + the folder, follow it to the new name. * - **Delete** - - Deletes the selected file or folder after a confirmation dialog. If - the file is open in an editor tab, the tab is closed first. + - Asks first, with **No** as the default, then moves the item to the trash (the + Recycle Bin on Windows). Where there is no trash, as on some network drives, it + asks again before deleting for good. A link is removed itself, not what it points + to. Editor tabs whose file is gone are closed. * - **Copy Path** - Copies the absolute path of the selected item to the clipboard. * - **Copy Relative Path** - Copies the path relative to the file tree's root directory. - * - **Reveal in Explorer** - - Opens the selected item's containing folder in the platform file - manager (Explorer on Windows, Finder on macOS, ``xdg-open`` on Linux). + * - **Reveal in File Explorer** + - Shows the item in the platform file manager: selected in Explorer on Windows and + in Finder on macOS; on Linux, ``xdg-open`` opens its folder. Code Editor (Tab Widget) ^^^^^^^^^^^^^^^^^^^^^^^^^ The central area uses a tabbed interface. Each opened file gets its own tab. -Additional tool tabs (SSH, AI, JupyterLab, automation GUIs) can also be opened here. +Additional tool tabs (SSH, AI, JupyterLab, the diagram editor, the HTTP / API utilities, +automation GUIs) can also be opened here. Features: -- Syntax highlighting for Python and automation module keywords +- Syntax highlighting for Python and for the languages JEditor colours (C, C++, Go, Java, + JavaScript, JSON, Rust, shell, SQL, TOML, TypeScript, YAML and more). PyBreeze registers + its automation keywords for ``.json``, ``.yml`` and ``.yaml``, but JEditor highlights + those files with its own rules, which leave registered keywords out. - Multiple file editing with tabs -- Extendable with custom tabs via ``EDITOR_EXTEND_TAB`` +- Extendable with custom tabs via ``EDITOR_EXTEND_TAB`` (see :doc:`how_to_extend_ui`) +- A tool tab with unsaved work (a prompt editor, the diagram editor) asks before it + closes, and so does closing the IDE Output Panel ^^^^^^^^^^^^ -Located at the bottom of the window. Displays: +Each editor tab has a panel below it with the tabs **Code result**, **Format checker**, +**Debugger**, **Terminal**, **Variable Inspector** and **Git Client**: -- Program execution output -- Shell command results -- Error messages +- **Code result** shows the output of **Run Program** and **Run On Shell**, errors in the + theme's error colour, and the IDE's log messages (warnings and errors only); +- **Format checker** lists what **Check Code Style > Python format check** found; +- **Debugger** is where **Run Debugger** runs. -Code Output Window -^^^^^^^^^^^^^^^^^^ +Run Window +^^^^^^^^^^ -When running automation scripts, a separate **Code Output Window** opens to display -the execution results. This window: +An automation script, a batch of them, a package install or a **Run with...** run each +opens a window of its own for its output. This window: -- Shows real-time output from subprocess execution -- Is read-only -- Sizes itself based on screen dimensions -- Can be closed independently of the main window +- Is titled with what it runs (the package and the file, for instance) +- Shows the output as it arrives, errors in the error colour, in a fixed-pitch font, and + keeps the last 10,000 lines +- Has a **Stop** button, enabled while the run goes on +- Can be closed while the run goes on: the run continues, and closing the IDE stops it +- Sizes itself to a third of the screen Dock Widgets ^^^^^^^^^^^^ -Several tools can be opened as **dock widgets** instead of tabs, allowing you to -arrange them freely around the main window: +Panels can be opened as **dock widgets** instead of tabs from the **Dock** menu, and +arranged freely around the main window: -- SSH Client Dock -- AI Code-Review Dock -- CoT Prompt Editor Dock -- Skill Prompt Editor Dock -- Skill Send GUI Dock -- Diagram Editor Dock +- **Editor** -- a docked editor, IPython (Jupyter) and the variable inspector (empty for now, + see :doc:`menu_file_run_text`) +- **Git** -- the Git client, branch tree viewer and code diff viewer +- **AI** -- Chat UI, AI Code Review, CoT Prompt Editor, CoT Code Review, Skill Prompt + Editor and Skill Send +- **Tools** -- a browser, FrontEngine, the console, the TODO panel, Problems, Tests and + the outline +- **SSH** -- the SSH client +- the diagram editor and each HTTP / API utility, at the menu's top level -Dock widgets can be dragged, resized, and snapped to any edge of the main window. +Dock widgets can be dragged, resized, floated, stacked and snapped to any edge of the +main window. Theme System ------------ PyBreeze uses `qt_material `_ for theming. -Available themes include: +The **UI Style** menu lists these themes: - ``dark_amber.xml`` (default) -- ``dark_teal.xml`` - ``dark_blue.xml`` - ``dark_cyan.xml`` - ``dark_lightgreen.xml`` - ``dark_pink.xml`` - ``dark_purple.xml`` - ``dark_red.xml`` +- ``dark_teal.xml`` - ``dark_yellow.xml`` - ``light_amber.xml`` - ``light_blue.xml`` - ``light_cyan.xml`` +- ``light_cyan_500.xml`` - ``light_lightgreen.xml`` - ``light_pink.xml`` - ``light_purple.xml`` -- ``light_red.xml`` -- ``light_yellow.xml`` -You can switch themes via the **UI Style** menu in the menu bar, or set the theme -at launch time: +Clicking one applies it to the whole application. The theme can also be set at launch +time: .. code-block:: python + from pybreeze import start_editor + start_editor(theme="dark_teal.xml") +The theme picked from **UI Style** is saved and used at the next start. A theme given to +``start_editor`` replaces it, and is saved in its place. + Multi-Language Support ---------------------- -PyBreeze supports multiple UI languages: +The **Language** menu offers: - **English** (default) -- **Traditional Chinese** (繁體中文) -- Additional languages can be added via plugins +- **繁體中文** (Traditional Chinese) +- **日本語** (Japanese) and **简体中文** (Simplified Chinese), which are JEditor's: picked, + JEditor's own menus change and PyBreeze's strings stay in English +- languages added by translation plugins (see :doc:`menu_plugins`) -All UI strings are managed through a centralized language dictionary that can be -extended with custom translations. +Menus, dialogs, the reasons a tool refuses its input and the run window's own notices +follow the chosen language. diff --git a/docs/source/Zh/ai_tools.rst b/docs/source/Zh/ai_tools.rst index 81b6d346..2d87166b 100644 --- a/docs/source/Zh/ai_tools.rst +++ b/docs/source/Zh/ai_tools.rst @@ -1,153 +1,159 @@ AI 工具 ======= -PyBreeze 整合了多個 AI 驅動的工具,用於程式碼審查、提示詞工程和 LLM 互動。 -所有 AI 工具都可以從 **Tools** 選單存取,並可以作為分頁或停靠面板開啟。 +PyBreeze 有五個 AI 輔助程式碼審查與提示詞的工具,都可以從 **Tools > AI** 以分頁開啟,或從 +**Dock > AI** 以停靠面板開啟。用 prthinker 審查檔案或 Pull Request 則在 **Automation** 選單 +(見 :doc:`menu_automation`)。 + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - 工具 + - 用途 + * - **AI Code Review** + - 以一次請求把程式碼送到端點審查,再接受或拒絕回答。 + * - **CoT Code Review** + - 執行八個步驟的思維鏈(CoT)審查:每個步驟一次請求。 + * - **CoT Prompt Editor** + - 編輯八個 CoT 提示詞。 + * - **Skill Prompt Editor** + - 編輯兩個技能提示詞(程式碼審查、程式碼解釋)。 + * - **Skill Send** + - 送出一個放入你程式碼的技能提示詞,並顯示回答。 + +在 AI Code Review、CoT Code Review 與 Skill Send 中,於面板任何位置按 **Ctrl+Enter** 就會按下送出按鈕。 +請求在背景執行,送出按鈕在請求結束前會維持停用。 -AI 程式碼審查用戶端 --------------------- - -**選單:** Tools > AI Code-Review Tab / AI Code-Review Dock - -用於將程式碼發送到 AI API 端點進行自動程式碼審查的用戶端。 - -介面配置 -^^^^^^^^ +.. note:: -- **URL 輸入框** -- 輸入 API 端點 URL -- **方法選擇器** -- 選擇 HTTP 方法(GET、POST、PUT、DELETE) -- **程式碼輸入區**(左面板)-- 貼上或撰寫要審查的程式碼 -- **回應顯示區**(右面板,唯讀)-- 顯示 AI 審查回應 -- **Send Request** 按鈕 -- 將程式碼發送到 API 端點 + 端點 URL 必須是公開的 ``http`` / ``https`` 位址。送出前會先檢查,而且只連線到檢查過的位址: + 位於本機或私有網路的端點(例如本機的模型伺服器)會被拒絕。不會跟隨重新導向,回答上限為 16 MB, + 超過五分鐘的請求會被中止。只有 AI Code Review 會記錄用過的 URL,而且只記指紋(見下文), + 因為 API URL 可能夾帶權杖。 -功能 -^^^^ +AI Code Review +-------------- -- 追蹤 AI 回應的接受/拒絕統計 -- 在 ``.pybreeze/urls.txt`` 記錄用過哪些端點,存的是指紋:API URL 可能帶著權杖, - 所以不會把 URL 本身寫進磁碟 -- 將回應統計儲存到 ``.pybreeze/response_stats.txt`` +**選單:** Tools > AI > AI Code Review Tab/Dock > AI > AI Code Review Dock -使用方式 +介面佈局 ^^^^^^^^ -1. 在 URL 輸入框中輸入您的 AI API 端點 URL -2. 選擇 HTTP 方法(通常為 POST) -3. 在左面板中貼上要審查的程式碼 -4. 點擊 **Send Request** -5. 在右面板中查看 AI 的回應 +- **URL** -- 端點 URL +- **Method** -- ``GET``、``POST``\ (預設)、``PUT`` 或 ``DELETE`` +- **Code to Send**\ (左側)-- 要審查的程式碼 +- **Response**\ (右側,唯讀)-- 回答,或沒有回答的原因 +- **Send Request** -- 送出請求 +- **Accept Response** / **Reject Response** -- 你對回答的評價 -CoT 程式碼審查 GUI --------------------- - -**選單:** Tools > AI Code-Review Tab / Dock - -使用思維鏈(CoT)提示詞進行更結構化和詳細審查的進階程式碼審查工具。 - -介面配置 +使用方式 ^^^^^^^^ -- **API URL 輸入框** -- 輸入 API 端點 URL -- **程式碼區域** -- 貼上要審查的程式碼 -- **回應選擇器**(ComboBox)-- 瀏覽多個審查回應 -- **回應檢視器**(唯讀)-- 顯示選定的審查回應 -- **Send** 按鈕 -- 發送程式碼進行審查 +1. 輸入端點 URL 並選擇方法。``POST`` 與 ``PUT`` 會把程式碼放在本文的表單欄位 ``code``; + ``GET`` 與 ``DELETE`` 只送 URL。 +2. 在左側貼上要審查的程式碼(``POST`` 與 ``PUT`` 必須有內容)。 +3. 點擊 **Send Request**。回應區先說明這個 URL 是否用過,回答到達後再顯示回答。 + 不是成功的回答(HTTP 錯誤,或不會跟隨的重新導向)會連同狀態碼以錯誤顯示。 +4. 點擊 **Accept Response** 或 **Reject Response**。兩者在回答到達後才能按,每個回答只能評一次。 -功能 +檔案 ^^^^ -- 透過 ``SenderThread`` 支援一次審查多個檔案 -- 背景執行緒防止 API 呼叫時 UI 凍結 -- 可以儲存和瀏覽多個回應 +- ``~/.pybreeze/response_stats.txt`` -- 接受與拒絕的累計次數,所有 AI Code Review 面板共用 +- ``~/.pybreeze/urls.txt`` -- 用過的 URL 的 SHA-256 指紋:URL 本身從不寫入磁碟 -CoT 提示詞編輯器 ------------------ - -**選單:** Tools > CoT Prompt Editor Tab / CoT Prompt Editor Dock +CoT Code Review +--------------- -基於範本的編輯器,用於建立和管理思維鏈提示詞範本。 +**選單:** Tools > AI > CoT Code Review Tab/Dock > AI > CoT Code Review Dock -介面配置 +介面佈局 ^^^^^^^^ -- **檔案選擇器**(ComboBox)-- 從可用的提示詞範本檔案中選擇 -- **編輯面板**(QTextEdit)-- 編輯選定的提示詞範本 -- **Create** 按鈕 -- 建立新的提示詞範本檔案 -- **Save** 按鈕 -- 儲存目前範本的變更 -- **Reload** 按鈕 -- 從磁碟重新載入範本 +- **API URL** -- 端點 URL +- **Code to Review** -- 要審查的程式碼 +- **Response Area** -- **Step** 選單(列出已收到回答的步驟),旁邊顯示所選步驟的回答(唯讀) +- **Start Sending** -- 開始審查 -功能 -^^^^ +審查如何進行 +^^^^^^^^^^^^ -- 透過 ``COT_TEMPLATE_RELATION`` 對應的範本檔案管理 -- 檔案系統監控,偵測外部變更 -- 當範本在編輯器外修改時自動重新載入 -- 預先設定的常用 CoT 審查模式範本 +八個步驟依下列順序執行,因為每一步都可能引用前面步驟的回答: -使用方式 -^^^^^^^^ +1. ``first_summary_prompt.md`` -- 程式碼的初步摘要 +2. ``first_code_review.md`` -- 初步審查 +3. ``judge_single_review.md`` -- 評審這份審查 +4. ``linter.md`` -- lint 發現 +5. ``code_smell_detector.md`` -- 程式碼異味 +6. ``step_by_step_analysis.md`` -- 逐一分析每個 lint 發現與程式碼異味 +7. ``total_summary.md`` -- 以上所有內容的總結 +8. ``judge.md`` -- 評審這份總結 -1. 從下拉選單選擇範本或建立新範本 -2. 在文字區域中編輯提示詞範本 -3. 點擊 **Save** 儲存變更 -4. 該範本可以在 CoT 程式碼審查 GUI 中使用 +每一步的提示詞會包上全域審查規則,以 JSON ``{"prompt": "..."}`` 的 ``POST`` 送出,回應本文(文字) +就是該步驟的回答。回答一到就出現在 **Step** 選單並立即顯示。失敗的步驟會顯示原因,之後的步驟不會引用這個失敗。 +**Start Sending** 會清掉上一次的回答;關閉面板時,審查會在進行中的請求結束後停止。 -Skill 提示詞編輯器 -------------------- +提示詞來自 CoT Prompt Editor:編輯過的提示詞會取代內建的。 -**選單:** Tools > Skill Prompt Editor Tab / Skill Prompt Editor Dock +CoT Prompt Editor +----------------- -與 CoT 提示詞編輯器類似,但專門用於基於技能的提示詞範本, -如程式碼審查和程式碼解釋提示詞。 +**選單:** Tools > AI > CoT Prompt Editor Tab/Dock > AI > CoT Prompt Editor Dock -介面配置 +介面佈局 ^^^^^^^^ -- **檔案選擇器**(ComboBox)-- 從可用的技能提示詞範本中選擇 -- **編輯面板**(QTextEdit)-- 編輯選定的技能提示詞 -- **Create** 按鈕 -- 建立新的技能提示詞範本 -- **Save** 按鈕 -- 儲存變更 -- **Reload** 按鈕 -- 從磁碟重新載入 +- **Edit File Content** -- 下方所選提示詞的內容 +- 提示詞檔案所在的資料夾 ``~/.pybreeze/prompts/`` +- 提示詞選單(每個步驟一項,如上表) +- **Reload** -- 從磁碟重新讀取檔案 +- **Save** -- 把內容寫入檔案 +- **Create File** -- 以內建提示詞建立檔案 -預設技能範本 -^^^^^^^^^^^^ +提示詞如何保存 +^^^^^^^^^^^^^^ -- 程式碼審查提示詞 -- 程式碼解釋提示詞 +每個提示詞都有內建版本。``~/.pybreeze/prompts/`` 中同名的檔案只要有內容就會取代它, +所以審查送出的是你存下的內容。檔案建立之前,編輯區是空的並會註明;**Create File** 會把內建提示詞寫進檔案, +當作起點。 -Skills 傳送 GUI ----------------- +提示詞中的佔位符(例如 ``{code_diff}``)會在審查執行時填入。編輯過的提示詞若用了該步驟填不了的佔位符, +那一次會改用內建提示詞。 -**選單:** Tools > Skill Send GUI Tab / Skill Prompt Dock +檔案會被監看:在編輯器外的修改會立刻出現。只要換上另一份內容會失去沒存的編輯——選另一個提示詞、 +**Reload**、**Create File**、外部修改,或關閉分頁、停靠面板或 IDE——編輯器都會先詢問,預設為 **No**。 +不是 UTF-8 的檔案會把讀不出的部分替換後顯示並告知;存檔時會寫回 UTF-8。 -用於將基於技能的提示詞發送到 LLM API 並查看回應的介面。 +Skill Prompt Editor +------------------- -介面配置 -^^^^^^^^ +**選單:** Tools > AI > Skill Prompt Editor Tab/Dock > AI > Skill Prompt Editor Dock -- **API URL 輸入框** -- 輸入 LLM API 端點 URL -- **提示詞範本選擇器**(ComboBox)-- 選擇預定義的技能提示詞範本 -- **提示詞文字區域** -- 在發送前編輯或自訂提示詞 -- **Send** 按鈕 -- 將提示詞發送到 API(在背景執行緒中執行) -- **回應顯示區**(唯讀)-- 顯示 LLM 回應 +與 CoT Prompt Editor 相同的編輯器,用於兩個技能提示詞:``code_review_skill.md``\ (程式碼審查)與 +``code_explainer_skill.md``\ (程式碼解釋)。檔案放在同一個資料夾,運作方式也相同。 -功能 -^^^^ +Skill Send +---------- -- 透過 ``RequestThread`` 在背景執行緒中執行,防止 UI 凍結 -- 具備特定 HTTP 狀態碼訊息的錯誤處理 -- 從 Skill 提示詞編輯器的範本檔案載入提示詞範本 +**選單:** Tools > AI > Skill Send Tab/Dock > AI > Skill Send Dock -使用方式 +介面佈局 ^^^^^^^^ -1. 輸入您的 LLM API 端點 URL -2. 從下拉選單選擇提示詞範本 -3. 根據需要自訂提示詞文字(例如,貼上要審查的程式碼) -4. 點擊 **Send** -5. 等待回應出現在回應顯示區域 +- **LLM API URL** -- 端點 URL +- **Select Prompt Template** -- 作為起點的技能提示詞 +- **Prompt** -- 要送出的提示詞,可編輯 +- **Send** -- 送出 +- **Response**\ (唯讀)-- 回答,或沒有回答的原因 -.. note:: +使用方式 +^^^^^^^^ - 所有 AI 工具都需要相容的 API 端點。請將您的 API URL 指向您的 LLM 服務 - (例如 OpenAI 相容的 API、本地 LLM 伺服器等)。 +1. 輸入端點 URL。 +2. 選擇範本。它的內容(有編輯過的檔案就用檔案,否則用內建提示詞)會填入 **Prompt**。 + 編輯過提示詞後再選另一個範本時會先詢問。 +3. 把提示詞中的 ``{code_diff}`` 換成你的程式碼。提示詞裡還有 ``{code_diff}`` 時不會送出。 +4. 點擊 **Send**。提示詞以 JSON ``{"code": "..."}`` 的 ``POST`` 送出,回應本文原樣顯示。 + 被拒絕的請求(401、403)與伺服器錯誤會以錯誤顯示;重新導向不會被跟隨,並說明它指向哪裡 + (只列出 scheme 與主機)。 diff --git a/docs/source/Zh/getting_started.rst b/docs/source/Zh/getting_started.rst index 24745833..cbb5d587 100644 --- a/docs/source/Zh/getting_started.rst +++ b/docs/source/Zh/getting_started.rst @@ -4,8 +4,9 @@ 系統需求 -------- -- Python 3.10 或更高版本 +- Python 3.10 到 3.14 - pip(Python 套件管理器) +- Windows、macOS 或 Linux;PySide6 會隨 PyBreeze 一起安裝 安裝 ---- @@ -16,6 +17,18 @@ pip install pybreeze +或從原始碼安裝: + +.. code-block:: bash + + git clone https://github.com/Integration-Automation/PyBreeze.git + cd PyBreeze + pip install -r requirements.txt + +兩種方式都會一併安裝自動化模組(AutoControl、APITestka、WebRunner、LoadDensity、FileAutomation、 +MailThunder、TestPioneer)、paramiko 與 JupyterLab。之後可以從 **Install** 選單把它們升級到執行時使用的直譯器 +(見 :doc:`menu_install`)。 + 啟動 PyBreeze ------------- @@ -39,8 +52,8 @@ from pybreeze import start_editor - # 可用主題:dark_amber.xml(預設)、dark_teal.xml、 - # dark_blue.xml、light_blue.xml 等 + # UI Style 選單列出的任何主題:dark_teal.xml、 + # dark_blue.xml、light_blue.xml……它會取代從 UI Style 選的主題。 start_editor(theme="dark_teal.xml") 參數說明 @@ -57,11 +70,23 @@ * - ``debug_mode`` - bool - ``False`` - - 10 秒後自動關閉(用於 CI 測試) + - 10 秒後自行關閉(用於啟動測試) * - ``theme`` - - str - - ``"dark_amber.xml"`` - - Qt Material 主題名稱 + - str 或 None + - ``None`` + - Qt Material 主題名稱。它會取代從 **UI Style** 選的主題,並成為選定的主題。``None`` 表示用選定的主題 + (還沒選過時是 ``dark_amber.xml``)。 + +工作資料夾 +---------- + +請從你的專案資料夾啟動 PyBreeze。它啟動時所在的資料夾,或之後以 **File > Open Folder** 開啟的資料夾,就是: + +- 檔案樹開啟的位置; +- 尋找 ``venv/`` 或 ``.venv/`` 的位置,在 **Python Env** 沒有選直譯器時用它執行腳本; +- **Create ... Project** 與 TestPioneer 範本寫入的位置。 + +外掛只在啟動時載入一次,來源是 PyBreeze 啟動時所在資料夾中的 ``jeditor_plugins/``\ (見 :doc:`menu_plugins`)。 首次啟動 -------- @@ -70,7 +95,7 @@ PyBreeze 啟動後,主視窗會以最大化方式開啟,包含: 1. **選單列** -- 位於頂部,包含所有可用選單 2. **檔案樹** -- 位於左側,用於專案導覽 -3. **程式碼編輯器**(分頁式)-- 位於中央,用於編輯檔案 -4. **輸出面板** -- 位於底部,顯示執行結果 +3. **程式碼編輯器**\ (分頁式)-- 位於中央,用於編輯檔案 +4. **輸出面板** -- 位於編輯器下方,其中的 **Code result** 分頁顯示 **Run Program** 與 **Run On Shell** 的輸出 PyBreeze 繼承了 **JEditor** 的核心編輯器功能,並擴充了自動化專用的選單、工具和整合功能。 diff --git a/docs/source/Zh/how_to_extend_ui.rst b/docs/source/Zh/how_to_extend_ui.rst index 92a76b93..4a044128 100644 --- a/docs/source/Zh/how_to_extend_ui.rst +++ b/docs/source/Zh/how_to_extend_ui.rst @@ -42,7 +42,7 @@ PyBreeze 支援使用 ``EDITOR_EXTEND_TAB`` 字典擴充 UI 自訂分頁。 -------- 1. 從 ``pybreeze`` 匯入 ``EDITOR_EXTEND_TAB``。 -2. 建立繼承 ``QWidget``(或任何 QWidget 子類別)的類別。 +2. 建立繼承 ``QWidget``\ (或任何 QWidget 子類別)的類別。 3. 將您的元件類別加入 ``EDITOR_EXTEND_TAB`` 字典,以顯示名稱作為鍵值。 4. 呼叫 ``start_editor()`` -- 您的分頁將與預設分頁一起顯示。 @@ -51,10 +51,39 @@ PyBreeze 支援使用 ``EDITOR_EXTEND_TAB`` 字典擴充 UI 自訂分頁。 您必須在呼叫 ``start_editor()`` **之前** 在 ``EDITOR_EXTEND_TAB`` 中註冊自訂分頁, 因為分頁在視窗初始化時載入。 +建構子拋出例外的元件只會少掉它自己的分頁:錯誤會記入日誌,IDE 照常啟動。 + +關閉前先詢問 +------------ + +可能有未儲存內容的分頁可以表明這一點。為元件加上 ``may_close()`` 方法,可以關閉時回傳 ``True``: +關閉它的分頁或 IDE 時會先問它,回傳 ``False`` 就保持開啟。在這裡詢問使用者,就像 PyBreeze +自己的提示詞編輯器與架構圖編輯器一樣: + +.. code-block:: python + + from PySide6.QtWidgets import QMessageBox, QTextEdit, QVBoxLayout, QWidget + + + class NotesTab(QWidget): + def __init__(self): + super().__init__() + self.text = QTextEdit() + QVBoxLayout(self).addWidget(self.text) + + def may_close(self) -> bool: + if not self.text.document().isModified(): + return True + reply = QMessageBox.question(self, "筆記未儲存", "要關閉並捨棄筆記嗎?") + return reply == QMessageBox.StandardButton.Yes + +拋出例外的 ``may_close()`` 視為同意(會記入日誌):它不能讓 IDE 關不掉。 + 進階:基於外掛的分頁 --------------------- -您也可以透過外掛系統新增自訂分頁,將外掛檔案放在 ``jeditor_plugins/`` 目錄中: +您也可以透過外掛系統新增自訂分頁,將外掛檔案放在 ``jeditor_plugins/`` 目錄中(見 :doc:`menu_plugins`)。 +在外掛的 ``register()`` 裡註冊分頁:外掛在主視窗建立的過程中載入,早於分頁加入。 .. code-block:: python @@ -62,6 +91,8 @@ PyBreeze 支援使用 ``EDITOR_EXTEND_TAB`` 字典擴充 UI 自訂分頁。 from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit from pybreeze import EDITOR_EXTEND_TAB + PLUGIN_NAME = "我的工具" + class MyToolWidget(QWidget): def __init__(self): @@ -72,6 +103,7 @@ PyBreeze 支援使用 ``EDITOR_EXTEND_TAB`` 字典擴充 UI 自訂分頁。 layout.addWidget(self.text_edit) - EDITOR_EXTEND_TAB.update({"我的工具": MyToolWidget}) + def register() -> None: + EDITOR_EXTEND_TAB.update({"我的工具": MyToolWidget}) -此外掛會在 PyBreeze 啟動時自動探索並載入。 +PyBreeze 從 ``jeditor_plugins/`` 中有這個外掛的資料夾啟動時,會自動探索並載入它。 diff --git a/docs/source/Zh/images/ui.png b/docs/source/Zh/images/ui.png index dab23198..13d26169 100644 Binary files a/docs/source/Zh/images/ui.png and b/docs/source/Zh/images/ui.png differ diff --git a/docs/source/Zh/jupyter_lab.rst b/docs/source/Zh/jupyter_lab.rst index 4a725d3d..6a54f063 100644 --- a/docs/source/Zh/jupyter_lab.rst +++ b/docs/source/Zh/jupyter_lab.rst @@ -6,21 +6,22 @@ PyBreeze 包含嵌入式 JupyterLab 環境,讓您可以直接在 IDE 中使用 開啟 JupyterLab ---------------- -JupyterLab 可從分頁選單中使用。開啟時,它會建立一個新分頁, +從 **Tab > Tools Tab > JupyterLab** 開啟。它會開一個新分頁(標題為 ``JupyterLab ``), 透過 Qt 的 Web 引擎呈現完整的 JupyterLab 介面。 首次設定 ^^^^^^^^ -首次啟動時,如果 JupyterLab 尚未安裝,PyBreeze 會自動使用 pip 安裝。 -狀態標籤會顯示初始化進度。 +若 lab 所用的直譯器無法匯入 ``jupyterlab``,PyBreeze 會先以 ``pip install -U jupyterlab`` +安裝到該直譯器。狀態標籤會顯示進度。 介面 ---- JupyterLab 分頁包含: -- **狀態標籤** -- 顯示初始化狀態(「Starting JupyterLab...」、「Ready」等) +- **狀態標籤** -- 顯示啟動狀態(「Initializing...」、安裝時的「Downloading...」、伺服器啟動時的 + 「Loading... (Ns / 60s)」),lab 載入後即移除;無法啟動時顯示「JupyterLab init failed: <原因>」 - **Web 引擎檢視** -- 在 ``QWebEngineView`` 中呈現的完整 JupyterLab 介面 嵌入式 JupyterLab 提供所有標準 Jupyter 功能: @@ -50,7 +51,8 @@ JupyterLab 分頁包含: 使用提示 -------- -- JupyterLab 在本地連接埠上執行;不需要外部網路存取 +- JupyterLab 只在 localhost 的空閒連接埠上執行;只有需要安裝 JupyterLab 時才需要網路 - 您可以在 JupyterLab 自己的分頁系統中開啟多個筆記本 - 使用 JupyterLab 進行資料分析、原型開發和互動式測試 -- 嵌入式 JupyterLab 與 PyBreeze 共用相同的 Python 環境 +- JupyterLab 與其 kernel 在執行腳本所用的直譯器中執行:**Python Env > Choose python interpreter** + 選定的直譯器;沒有選的話,用工作資料夾中的 ``venv`` 或 ``.venv``,再沒有就用 PyBreeze 自己的直譯器 diff --git a/docs/source/Zh/menu_automation.rst b/docs/source/Zh/menu_automation.rst index 2027a179..9832e1be 100644 --- a/docs/source/Zh/menu_automation.rst +++ b/docs/source/Zh/menu_automation.rst @@ -6,10 +6,12 @@ Automation 選單 每個自動化模組都遵循一致的選單結構: -- **RUN** 子選單 -- 執行腳本(單檔或多檔,可選擇是否以郵件發送結果) -- **HELP** 子選單 -- 連結到文件和 GitHub 儲存庫 -- **Project** 子選單 -- 建立新的專案範本 -- **GUI Tab** -- 開啟嵌入式 GUI 元件(部分模組可用) +- **Run** 子選單 -- 執行腳本(單檔或多檔,可選擇是否以郵件發送結果) +- **Help** 子選單 -- 在 IDE 內的瀏覽器分頁開啟文件和 GitHub 儲存庫 +- **Project** 子選單 -- 在 IDE 的工作目錄建立新的專案範本 +- **<模組> GUI** -- 以分頁開啟該模組的 GUI(**APITestka GUI**、**AutoControl GUI**、**LoadDensity GUI**) + +**TestPioneer** 與 **Code Review (prthinker)** 的選單另有結構,見下文。 AutoControl 選單 ----------------- @@ -17,7 +19,7 @@ AutoControl 選單 **AutoControl** 是桌面應用程式測試的 GUI 自動化模組, 可以錄製和重播滑鼠/鍵盤操作。 -RUN 子選單 +Run 子選單 ^^^^^^^^^^ .. list-table:: @@ -28,14 +30,14 @@ RUN 子選單 - 說明 * - **Run AutoControl Script** - 將目前編輯器內容作為 AutoControl 腳本執行。 - * - **Run AutoControl Script With Send** + * - **Run AutoControl With Send** - 執行腳本並透過郵件發送結果(使用 MailThunder)。 * - **Run Multi AutoControl Script** - 從選定的目錄執行多個 AutoControl 腳本。 * - **Run Multi AutoControl Script With Send** - 執行多個腳本並透過郵件發送結果。 -HELP 子選單 +Help 子選單 ^^^^^^^^^^^ .. list-table:: @@ -58,8 +60,8 @@ Project 子選單 * - 選單項目 - 說明 - * - **Create Project** - - 使用 ``je_auto_control`` 建立新的 AutoControl 專案結構。 + * - **Create AutoControl Project** + - 在 IDE 的工作目錄建立 AutoControl 專案範本(``je_auto_control``),已存在時會先詢問是否取代。 Record 子選單 ^^^^^^^^^^^^^ @@ -72,13 +74,14 @@ Record 子選單是 **AutoControl 獨有** 的功能,允許您錄製滑鼠和 * - 選單項目 - 說明 - * - **Start Record** + * - **Record Start** - 開始錄製滑鼠和鍵盤操作。 - * - **Stop Record** - - 停止錄製並將錄製的操作資料插入到程式碼編輯器中。 + * - **Record Stop** + - 停止錄製,並把錄到的操作以 AutoControl 執行器讀得懂的 JSON 插入到前景編輯分頁的游標處; + 前景不是編輯分頁時改為複製到剪貼簿。沒有錄到任何東西時會提示。 -GUI Tab -^^^^^^^ +AutoControl GUI +^^^^^^^^^^^^^^^ 在編輯器中開啟嵌入式 AutoControl GUI 元件作為新分頁, 提供 AutoControl 操作的視覺化介面。 @@ -88,7 +91,7 @@ APITestka 選單 **APITestka** 是 API 測試自動化模組,用於發送 HTTP 請求和驗證回應。 -RUN 子選單 +Run 子選單 ^^^^^^^^^^ .. list-table:: @@ -99,14 +102,14 @@ RUN 子選單 - 說明 * - **Run APITestka Script** - 將目前編輯器內容作為 APITestka 腳本執行。 - * - **Run APITestka Script With Send** + * - **Run APITestka With Send** - 執行腳本並透過郵件發送結果。 * - **Run Multi APITestka Script** - 從選定的目錄執行多個 APITestka 腳本。 * - **Run Multi APITestka Script With Send** - 執行多個腳本並透過郵件發送結果。 -HELP 子選單 +Help 子選單 ^^^^^^^^^^^ - **Open APITestka Doc** -- 開啟 https://apitestka.readthedocs.io/ @@ -115,10 +118,10 @@ HELP 子選單 Project 子選單 ^^^^^^^^^^^^^^ -- **Create Project** -- 使用 ``je_api_testka`` 建立新的 APITestka 專案結構。 +- **Create APITestka Project** -- 在 IDE 的工作目錄建立 APITestka 專案範本(``je_api_testka``),已存在時會先詢問是否取代。 -GUI Tab -^^^^^^^ +APITestka GUI +^^^^^^^^^^^^^ 在編輯器中開啟嵌入式 APITestka GUI 元件作為新分頁,用於視覺化 API 測試。 @@ -128,7 +131,7 @@ WebRunner 選單 **WebRunner** 是網頁瀏覽器自動化模組,使用瀏覽器驅動程式(基於 Selenium) 測試網頁應用程式。 -RUN 子選單 +Run 子選單 ^^^^^^^^^^ .. list-table:: @@ -139,14 +142,14 @@ RUN 子選單 - 說明 * - **Run WebRunner Script** - 將目前編輯器內容作為 WebRunner 腳本執行。 - * - **Run WebRunner Script With Send** + * - **Run WebRunner With Send** - 執行腳本並透過郵件發送結果。 * - **Run Multi WebRunner Script** - 從選定的目錄執行多個 WebRunner 腳本。 * - **Run Multi WebRunner Script With Send** - 執行多個腳本並透過郵件發送結果。 -HELP 子選單 +Help 子選單 ^^^^^^^^^^^ - **Open WebRunner Doc** -- 開啟 https://webrunner.readthedocs.io/ @@ -155,14 +158,14 @@ HELP 子選單 Project 子選單 ^^^^^^^^^^^^^^ -- **Create Project** -- 使用 ``je_web_runner`` 建立新的 WebRunner 專案結構。 +- **Create WebRunner Project** -- 在 IDE 的工作目錄建立 WebRunner 專案範本(``je_web_runner``),已存在時會先詢問是否取代。 LoadDensity 選單 ---------------- **LoadDensity** 是負載/效能測試模組,產生並行請求以測試系統容量。 -RUN 子選單 +Run 子選單 ^^^^^^^^^^ .. list-table:: @@ -173,14 +176,14 @@ RUN 子選單 - 說明 * - **Run LoadDensity Script** - 將目前編輯器內容作為 LoadDensity 腳本執行。 - * - **Run LoadDensity Script With Send** + * - **Run LoadDensity With Send** - 執行腳本並透過郵件發送結果。 * - **Run Multi LoadDensity Script** - 從選定的目錄執行多個 LoadDensity 腳本。 * - **Run Multi LoadDensity Script With Send** - 執行多個腳本並透過郵件發送結果。 -HELP 子選單 +Help 子選單 ^^^^^^^^^^^ - **Open LoadDensity Doc** -- 開啟 https://loaddensity.readthedocs.io/ @@ -189,10 +192,10 @@ HELP 子選單 Project 子選單 ^^^^^^^^^^^^^^ -- **Create Project** -- 使用 ``je_load_density`` 建立新的 LoadDensity 專案結構。 +- **Create LoadDensity Project** -- 在 IDE 的工作目錄建立 LoadDensity 專案範本(``je_load_density``),已存在時會先詢問是否取代。 -GUI Tab -^^^^^^^ +LoadDensity GUI +^^^^^^^^^^^^^^^ 在編輯器中開啟嵌入式 LoadDensity GUI 元件作為新分頁,用於視覺化負載測試設定。 @@ -202,7 +205,7 @@ FileAutomation 選單 **FileAutomation** 是檔案操作自動化模組,用於自動化檔案系統任務, 如複製、移動、重新命名和處理檔案。 -RUN 子選單 +Run 子選單 ^^^^^^^^^^ .. list-table:: @@ -213,14 +216,14 @@ RUN 子選單 - 說明 * - **Run FileAutomation Script** - 將目前編輯器內容作為 FileAutomation 腳本執行。 - * - **Run FileAutomation Script With Send** + * - **Run FileAutomation With Send** - 執行腳本並透過郵件發送結果。 * - **Run Multi FileAutomation Script** - 從選定的目錄執行多個 FileAutomation 腳本。 * - **Run Multi FileAutomation Script With Send** - 執行多個腳本並透過郵件發送結果。 -HELP 子選單 +Help 子選單 ^^^^^^^^^^^ - **Open FileAutomation Doc** -- 開啟 https://fileautomation.readthedocs.io/ @@ -229,14 +232,14 @@ HELP 子選單 Project 子選單 ^^^^^^^^^^^^^^ -- **Create Project** -- 使用 ``automation_file`` 建立新的 FileAutomation 專案結構。 +- **Create FileAutomation Project** -- 在 IDE 的工作目錄建立 FileAutomation 專案範本(``automation_file``),已存在時會先詢問是否取代。 MailThunder 選單 ----------------- **MailThunder** 是郵件自動化模組,用於發送測試報告和自動化通知。 -RUN 子選單 +Run 子選單 ^^^^^^^^^^ .. list-table:: @@ -248,7 +251,7 @@ RUN 子選單 * - **Run MailThunder Script** - 將目前編輯器內容作為 MailThunder 腳本執行。 -HELP 子選單 +Help 子選單 ^^^^^^^^^^^ - **Open MailThunder Doc** -- 開啟 https://mailthunder.readthedocs.io/ @@ -257,7 +260,7 @@ HELP 子選單 Project 子選單 ^^^^^^^^^^^^^^ -- **Create Project** -- 建立新的 MailThunder 專案結構。 +- **Create MailThunder Project** -- 在 IDE 的工作目錄建立 MailThunder 專案範本(``je_mail_thunder``),已存在時會先詢問是否取代。 TestPioneer 選單 ----------------- @@ -270,10 +273,35 @@ TestPioneer 選單 * - 選單項目 - 說明 - * - **Create TestPioneer Yaml Template** - - 產生用於定義測試案例的 YAML 範本檔案。 - * - **Execute Test Pioneer Yaml** - - 開啟檔案對話框選擇 ``.yml`` 檔案,並執行其中定義的測試。 + * - **Create TestPioneer YAML Template** + - 在 IDE 的工作目錄建立 ``.TestPioneer/.TestPioneer.yml``,已存在時會先詢問是否取代。 + * - **Run TestPioneer YAML** + - 開啟檔案對話框選擇 ``.yml`` 或 ``.yaml`` 檔案,並執行其中定義的測試。 + * - **Help > Open TestPioneer GitHub** + - 在瀏覽器分頁開啟 TestPioneer GitHub 儲存庫(它的 README 就是手冊)。 + +Code Review (prthinker) 選單 +---------------------------- + +執行 prthinker 思維鏈程式碼審查,輸出串流到執行視窗。請先用 +**Install > Automation > Install prthinker (code review)** 安裝 prthinker。 + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - 選單項目 + - 說明 + * - **Review the current file** + - 先儲存前景編輯分頁的檔案,再審查它。 + * - **Review a Pull Request** + - 詢問 Pull Request 編號,審查 **Settings** 中設定之儲存庫的該 Pull Request。 + * - **Settings** + - 開啟 prthinker 設定:推論後端、模型、程式碼託管平台、儲存庫、金鑰與權杖。 + * - **Help > Open prthinker documentation** + - 在瀏覽器分頁開啟 https://code-review-framework.readthedocs.io/ 。 + * - **Help > Open prthinker GitHub** + - 在瀏覽器分頁開啟 prthinker GitHub 儲存庫。 腳本執行流程 ------------ @@ -283,7 +311,7 @@ TestPioneer 選單 1. 擷取目前程式碼編輯器的內容。 2. 使用 ``TaskProcessManager`` 產生子程序。 3. 腳本在獨立的程序中執行(防止崩潰影響 IDE)。 -4. 開啟 **程式碼輸出視窗** 即時顯示執行輸出。 +4. 開啟執行視窗即時顯示執行輸出,標題是套件名稱與它執行的檔案,並有 **Stop** 按鈕。 5. 如果選擇了「With Send」,執行完成後透過 MailThunder 郵件發送結果。 .. note:: @@ -296,10 +324,10 @@ TestPioneer 選單 使用「Run Multi」選項時: -1. 開啟目錄選擇對話框。 -2. 收集選定目錄中所有符合的腳本檔案。 -3. 每個腳本在各自的子程序中依序執行。 -4. 彙整所有腳本的結果。 +1. 開啟資料夾選擇對話框。 +2. 收集該資料夾及其子資料夾中所有 ``.json`` 動作檔;沒有時會提示。 +3. 檔案依序執行,每個都在自己的子程序與自己的執行視窗中。 +4. 每次執行各自回報結果(選「With Send」時各自寄出報告);停止其中一次執行會結束整批。 報告格式 ^^^^^^^^ diff --git a/docs/source/Zh/menu_file_run_text.rst b/docs/source/Zh/menu_file_run_text.rst index 1bbe3dc0..e1d6176e 100644 --- a/docs/source/Zh/menu_file_run_text.rst +++ b/docs/source/Zh/menu_file_run_text.rst @@ -1,7 +1,11 @@ -File、Run、Text 及其他基礎選單 +File、Run、Text 與其他基礎選單 ============================== -這些選單繼承自 JEditor 基礎編輯器引擎,提供核心的編輯和執行功能。 +這些選單來自 PyBreeze 所建構的 JEditor 編輯器引擎。幾乎每個項目都作用在前景的編輯分頁上, +前景不是編輯分頁時什麼都不做。 + +JEditor 把這些設定存在工作資料夾的 ``.jeditor/user_setting.json``,所以每個專案資料夾各有一份; +每分鐘與關閉 IDE 時儲存。已有檔案的編輯分頁也會每隔幾秒自動存回檔案。 File 選單 --------- @@ -12,13 +16,24 @@ File 選單 * - 選單項目 - 說明 + * - **New File** + - 詢問名稱與位置,在那裡建立空檔案(不會開啟它)。 * - **Open File** - - 開啟檔案對話框選擇檔案,將檔案內容載入到程式碼編輯區。 + - 把檔案開進前景的編輯分頁,取代它原本顯示的內容,不會詢問那裡未存的文字(見 :doc:`ui_overview`)。 + * - **Open Folder** + - 把一個資料夾設為工作資料夾:檔案樹顯示它,並載入它的設定(見 :doc:`getting_started`)。 * - **Save File** - - 將目前程式碼編輯區的內容儲存到檔案。 - * - **Encoding** - - 開啟對話框選擇程式運行器和 Shell 運行器的編碼 - (如 UTF-8、ASCII、Big5)。 + - 開啟從工作資料夾開始的 **Save As** 對話框,把分頁寫到選定的位置。 + * - **Recent Files** + - 最近開啟的檔案,點選後在新分頁開啟。清單在啟動時重建。 + * - **Font** / **Font Size** + - 整個視窗的字型與大小:選單、樹狀檢視與面板。 + * - **Encodings** + - 分頁檔案的編碼。分頁沒有未存的編輯時,會以這個編碼重新讀取檔案;下次存檔也用它寫入。 + * - **Line Endings** + - 分頁檔案下次存檔用的 ``LF``、``CRLF`` 或 ``CR``。 + * - **Save All** + - 寫入每個已有檔案的編輯分頁。 Run 選單 -------- @@ -29,28 +44,36 @@ Run 選單 * - 選單項目 - 說明 - * - **Run Program** - - 使用程式運行器(Python 直譯器)執行目前程式碼編輯區的內容。 - * - **Run On Shell** - - 使用系統 Shell 執行目前程式碼編輯區的內容。 + * - **Run Program > Run Program** + - 開啟 **Save As** 對話框寫入分頁,再以 Python 執行檔案;輸出到分頁的 **Code result**。 + 每個分頁一次只能執行一個程式。 + * - **Run Program > Show program input** + - 一個小視窗,輸入的文字會送到執行中程式的標準輸入。 + * - **Run On Shell > Run On Shell** + - 把分頁的文字原樣當成一個 Shell 指令執行(Windows 上是 ``cmd.exe``);輸出到 **Code result**。 + * - **Run On Shell > Show shell input** + - 同樣的輸入視窗,給 Shell 用。 + * - **Debugger > Run Debugger** + - 開啟 **Save As** 對話框後,以 ``pdb`` 執行檔案,並帶入編輯器邊欄設定的中斷點;輸出在 **Debugger** + 分頁,輸入視窗會自動開啟。JEditor 1.0.27 中每個編輯分頁只能執行一次:要再除錯,請在另一個分頁開啟該檔案。 + * - **Debugger > Show debugger input** + - 再次開啟除錯器的輸入視窗。 * - **Clean Result** - - 清除輸出面板(包含程式和 Shell 的結果)。 - * - **Stop Program** - - 停止目前正在執行的程式。 - -Run Help 子選單 -^^^^^^^^^^^^^^^ - -.. list-table:: - :header-rows: 1 - :widths: 30 70 - - * - 選單項目 - - 說明 - * - **Run Help** - - 顯示程式運行器的說明資訊。 - * - **Shell Help** - - 顯示 Shell 運行器的說明資訊。 + - 清空分頁的 **Code result**。 + * - **Stop current program** + - 停止分頁的程式、Shell 指令與除錯器。 + * - **Stop All Program** + - 停止從這些選單啟動、在任何分頁中執行的程式、Shell 指令、除錯器與 ``pip``,以及每個 PyBreeze + 執行視窗中的執行(自動化腳本、安裝、**Run with...**);執行視窗與輸出會留著。 + * - **Run Help > Run Help** / **Shell Help** + - 提示:確認直譯器,並讓編碼與 Shell 的一致。 + * - **Run with...** + - 只在有外掛註冊了執行設定時出現(見 :doc:`menu_plugins`)。 + +Run Program、Run Debugger、**Python Env** 的項目與 PyBreeze 的自動化執行,都使用在 +**Python Env > Choose python interpreter** 選的直譯器。沒有選時,Run Program 與 Run Debugger 用工作資料夾中的 +``venv/``,否則用 ``PATH`` 上的 Python;PyBreeze 的自動化執行與 JupyterLab 用那裡的 ``venv/`` 或 ``.venv/``, +否則用執行 PyBreeze 的那個 Python。Run On Shell 不使用直譯器。 Text 選單 --------- @@ -61,10 +84,24 @@ Text 選單 * - 選單項目 - 說明 - * - **Font** - - 開啟字型選擇對話框,變更編輯器的預設字型。 - * - **Font Size** - - 開啟對話框變更編輯器的預設字型大小。 + * - **Font** / **Font Size** + - 每個編輯分頁中編輯器與 **Code result** 的字型與大小。 + * - **Word Wrap** + - 在每個編輯分頁中自動換行(下次啟動時又會關閉)。 + * - **Indent Size** + - 2、4 或 8 個空白:Tab 寬度與縮排單位。檔案本身的縮排優先。 + * - **Trim Trailing Whitespace**、**Convert Indentation to Spaces** / **to Tabs** + - 整份文件。 + * - **Remove Duplicate Lines**、**Reverse Lines**、**Sort Lines (Natural)**、 + **Remove Blank Lines**、**Align by Delimiter...** + - 選取範圍涵蓋的行,至少兩行。**Align by Delimiter...** 會詢問分隔符號(預設 ``=``)。 + * - **Uppercase Selection**、**Lowercase Selection**、**Swap Case**、**Title Case**、 + **Naming Style**、**Number Base**、**Encode / Decode** + - 選取的文字;沒有選取、或無法轉換時什麼都不做。**Naming Style** 轉成 ``snake_case``、``camelCase``、 + ``PascalCase`` 或 ``kebab-case``;**Number Base** 轉成十六進位、十進位或二進位;**Encode / Decode** + 做 Base64、URL、HTML 與 JSON 字串的雙向跳脫。 + * - **Statistics** + - 選取範圍或整份文件的行數、字數、字元數與不含空白的字元數。 Check Code Style 選單 --------------------- @@ -76,12 +113,17 @@ Check Code Style 選單 * - 選單項目 - 說明 * - **yapf** - - 使用 ``yapf`` 程式碼格式化工具檢查並格式化目前的 Python 程式碼。 + - 以 ``yapf``\ (Google 風格)重新排版整個分頁;有語法錯誤時不會改動。 * - **Reformat JSON** - - 重新格式化並驗證目前內容為 JSON,套用適當的縮排。 + - 把分頁改寫成 4 格縮排、鍵排序過的 JSON;錯誤顯示在 **Code result**。 + * - **Python format check** + - 對存檔後的 ``.py`` 檔執行 ``pycodestyle``\ (不檢查未存的編輯),結果列在 **Format checker** 分頁。 + * - **Format on Save** + - 開關:**Save File**、**Save All** 與 **Run Program** 寫入 ``.py`` 檔時,先以 ``yapf`` 排版。 + 自動存檔不會排版。 -Venv 選單 ---------- +Python Env 選單 +--------------- .. list-table:: :header-rows: 1 @@ -89,20 +131,31 @@ Venv 選單 * - 選單項目 - 說明 - * - **Create Venv** - - 在目前工作目錄中使用 Shell 運行器建立 Python 虛擬環境。 - * - **pip upgrade package** - - 在虛擬環境中使用 pip 升級指定的套件。 - * - **pip package** - - 在虛擬環境中使用 pip 安裝指定的套件。 - -.. note:: - - PyBreeze 會自動偵測專案中的 ``.venv`` 或 ``venv`` 目錄, - 並在可用時使用虛擬環境的 Python 直譯器。 + * - **Create venv** + - 在工作資料夾執行 ``python -m venv venv``,用選定的直譯器或 ``PATH`` 上的 Python;輸出到 **Code result**。 + * - **pip upgrade package** / **pip package** + - 詢問套件名稱後執行 ``pip install``\ (升級時加 ``-U``),用選定的直譯器或 ``venv/`` 裡的那一個。 + 工作資料夾中必須有 ``venv/``。 + * - **Choose python interpreter** + - 選擇直譯器檔案。它會被儲存,並依上方 **Run 選單** 所述使用;**Install** 選單也安裝到它裡面。 + +Tab 與 Dock 選單 +---------------- + +**Tab** 以分頁開啟面板,**Dock** 以停靠面板開啟(見 :doc:`ui_overview`): + +- **Add Editor Tab**、**Add Web Browser Tab**,以及停靠式編輯器(**Dock > Editor > New Dock Editor**, + 關閉停靠面板時寫回它的檔案) +- **Console Widget** -- 互動式 Shell(``cmd``、PowerShell、``bash`` 或 ``sh``) +- **Toggle Split View**\ (同一份文件顯示兩次)與 **Toggle Minimap**,作用在前景分頁 +- **Snippet Editor** -- ``.jeditor/snippets.json`` 中的程式碼片段 +- **Tools Tab** -- IPython(Jupyter,在 IDE 自己的 Python 中)、變數檢視器(JEditor 1.0.27 中是空的:沒有東西提供變數給它)、FrontEngine、ChatUI、TODO 面板、 + 目前 Python 檔的大綱,以及 JupyterLab(見 :doc:`jupyter_lab`) +- **Git Tab** -- Git 用戶端、分支樹檢視器、程式碼差異檢視器,以及目前檔案與 ``HEAD`` 或暫存版本的差異 +- **Dock > Tools** 另有 **Problems**\ (``ruff`` 找到的問題)與 **Tests**\ (在工作資料夾執行 ``pytest``) UI Style 選單 ------------- -包含可用的 Qt Material 主題列表。點擊任何主題即可立即套用到整個應用程式。 -完整的主題列表請參閱 :doc:`ui_overview`。 +Qt Material 主題(見 :doc:`ui_overview`):點選後立即套用,並儲存供下次啟動使用。**Show Indent Guides** 與 +**Show Trailing Whitespace** 開關編輯器中的這兩種標示,**Keyboard Shortcuts...** 可重新綁定編輯器的指令。 diff --git a/docs/source/Zh/menu_install.rst b/docs/source/Zh/menu_install.rst index 45d70526..b530be30 100644 --- a/docs/source/Zh/menu_install.rst +++ b/docs/source/Zh/menu_install.rst @@ -23,10 +23,12 @@ Automation 子選單 - 執行 ``pip install -U je_load_density`` * - **Install WebRunner** - 執行 ``pip install -U je_web_runner`` - * - **Install Automation File** + * - **Install FileAutomation** - 執行 ``pip install -U automation_file`` * - **Install MailThunder** - 執行 ``pip install -U je_mail_thunder`` + * - **Install TestPioneer** + - 執行 ``pip install -U test_pioneer`` * - **Install prthinker (code review)** - prthinker 不在 PyPI 上。第一次會詢問它的原始碼資料夾並記下來, 之後執行 ``pip install -U <資料夾>[runner]`` @@ -48,4 +50,5 @@ Tools 子選單 每次安裝都會開一個自己的執行視窗,在裡面執行 ``python -m pip``,中間不經過 shell, 所以資料夾名稱裡有 ``&`` 或 ``|`` 也會原樣交給 pip。pip 使用 Python 環境選單選定的 - 直譯器;沒有選的話,使用工作目錄下的 ``venv`` 或 ``.venv``,再沒有就用 ``PATH`` 上找到的 Python。 + 直譯器;沒有選的話,使用工作目錄下的 ``venv`` 或 ``.venv``,再沒有就用 PyBreeze 自己執行所用的直譯器 + (只有打包版才會去 ``PATH`` 上找)。 diff --git a/docs/source/Zh/menu_plugins.rst b/docs/source/Zh/menu_plugins.rst index 8b6d4531..2caee6de 100644 --- a/docs/source/Zh/menu_plugins.rst +++ b/docs/source/Zh/menu_plugins.rst @@ -1,76 +1,126 @@ Plugins 選單 ============ -PyBreeze 支援外掛系統以擴充功能。外掛會自動從工作目錄中的 -``jeditor_plugins/`` 目錄探索載入。 +PyBreeze 使用 JEditor 的外掛系統。外掛在啟動時從工作目錄中的 ``jeditor_plugins/`` 資料夾載入 +(若是 JEditor 的開發用 checkout,也會讀它套件旁的那一個): + +- ``.py`` 檔是一個外掛,含 ``__init__.py`` 的資料夾也是; +- 沒有 ``__init__.py`` 的資料夾只用來分組,會往裡面找; +- 名稱以 ``_`` 或 ``.`` 開頭的會略過。 + +每個外掛都定義一個 ``register()`` 函式,載入時呼叫一次。也可以設定 ``PLUGIN_NAME``、``PLUGIN_AUTHOR``、 +``PLUGIN_VERSION`` 與 ``PLUGIN_RUN_CONFIG``。 外掛瀏覽器 ---------- -開啟外掛瀏覽器介面,您可以: +**Plugins > Plugin Browser** 會開一個分頁,列出 GitHub 儲存庫中的外掛(預設是 +``https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins``;也可以在 **Repository URL** 輸入任何 +``https://github.com/owner/repo`` 或 ``owner/repo``,再按 **Fetch Plugins** 讀取)。選一個外掛可看它的 +詳細資訊與原始碼,按 **Download & Install** 會把它存進工作目錄的 ``jeditor_plugins/``,已有同名外掛時會先詢問是否取代。 +裝好的外掛在下次啟動時載入。 -- 瀏覽可用的外掛 -- 查看外掛詳細資訊 -- 安裝新外掛 +還沒裝任何外掛時,這個項目也在。 已載入外掛 ---------- -啟動後,在 ``jeditor_plugins/`` 中找到的任何外掛都會自動載入, -並列在 Plugins 選單下。每個已載入的外掛都會顯示為一個選單項目。 +在 Plugin Browser 下方,每個已載入的外掛各有一個項目: -Run With 選單 -------------- +- 沒有執行設定的外掛(翻譯、語法外掛):它的名稱,點了會顯示名稱、版本與作者; +- 有執行設定的外掛:以執行設定命名的子選單,內有 **About** 與 **Run with** *<名稱>*\ (能執行多種副檔名時, + 標籤會一併列出)。 -**Run With** 選單提供使用不同編譯器和直譯器執行目前檔案的選項。 -此選單根據可用的語言支援動態建立。 +Run with... 選單 +---------------- -支援的語言包括: +只要有外掛註冊了執行設定,**Run** 選單就會出現 **Run with...** 子選單:每個執行設定一項,標籤是它的名稱與副檔名。 +它執行前景編輯分頁中的檔案: -- **C** -- 使用 gcc/clang 編譯並執行 -- **C++** -- 使用 g++/clang++ 編譯並執行 -- **Go** -- 使用 go run 執行 -- **Java** -- 使用 javac/java 編譯並執行 -- **Rust** -- 使用 rustc 編譯並執行 +1. 先存檔,用分頁自己的編碼與行尾;還沒有檔案的分頁會走 **Save As**。存檔失敗會告知,而且不執行。 +2. 副檔名不在執行設定清單中的檔案會被拒絕。 +3. 檔案在執行視窗中執行,標題是執行設定的名稱與檔名,並有 **Stop** 按鈕:``compiler [args...] file``; + 編譯式語言則先編譯(限時 60 秒),再執行編出的程式。編譯產物放在暫存資料夾,執行後刪除。 .. note:: - 可用的「Run With」選項取決於系統上安裝了哪些編譯器/直譯器, - 以及它們是否可透過系統 PATH 找到。 + 執行設定只指定編譯器或直譯器的名稱,它必須已經安裝並在 ``PATH`` 上,否則執行視窗會說找不到指令。 + +執行設定是一個字典: + +.. code-block:: python + + PLUGIN_RUN_CONFIG = { + "name": "Go", # 選單標籤 + "suffixes": (".go",), # 它執行的檔案 + "compiler": "go", # 啟動的程式 + "args": ("run",), # 放在編譯器與檔案之間的參數 + # 編譯式語言: + # "compile_then_run": True, "output_flag": "-o", + # 只有 PyBreeze 讀:程式輸出的編碼("locale" 表示這台電腦自己的) + # "encoding": "locale", + } 建立外掛 -------- -關於建立自訂外掛的詳細資訊,包括語法高亮外掛和 UI 翻譯外掛, -請參閱 `外掛指南 `_ -(指南放在 JEditor repo,因為 PyBreeze 用的是 JEditor 的插件系統)。 +完整的外掛 API 與實作範例在 JEditor 的 +`Plugin Guide `_; +PyBreeze 的 `PLUGIN_GUIDE.md `_ +說明 PyBreeze 額外提供的部分。現成的外掛放在 +`IDE_Plugins `_。 語法高亮外掛範例 ^^^^^^^^^^^^^^^^ -外掛可以使用自訂關鍵字擴充語法高亮: +外掛可以依副檔名為一種語言的關鍵字上色: .. code-block:: python - # jeditor_plugins/my_syntax_plugin.py - from je_editor import syntax_word_dict + # jeditor_plugins/lua_syntax.py + from PySide6.QtGui import QColor + from je_editor.plugins import register_programming_language + + PLUGIN_NAME = "Lua syntax" + PLUGIN_VERSION = "1.0" - syntax_word_dict.update({ - "my_keyword": "keyword_format", - "my_function": "function_format", - }) -UI 翻譯外掛範例 + def register() -> None: + register_programming_language( + suffix=".lua", + syntax_words={ + "keywords": {"words": ("function", "local", "end", "return"), + "color": QColor(86, 156, 214)}, + }, + syntax_rules={ + "comments": {"rules": (r"--[^\n]*",), "color": QColor(106, 153, 85)}, + }, + ) + +.. note:: + + 註冊的關鍵字只用在 JEditor 自己不上色的副檔名。``.c``、``.cpp``、``.go``、``.h``、``.hpp``、``.java``、 + ``.js``、``.json``、``.rs``、``.sh``、``.sql``、``.toml``、``.ts``、``.yaml`` 與 ``.yml`` + 用的是 JEditor 自己的規則,外掛為它們註冊的關鍵字不會顯示。 + +介面翻譯外掛範例 ^^^^^^^^^^^^^^^^ -外掛可以新增新的 UI 翻譯: +外掛可以新增一種介面語言,列在 **Language** 選單中: .. code-block:: python - # jeditor_plugins/my_language_plugin.py - from je_editor import language_wrapper + # jeditor_plugins/french.py + from je_editor.plugins import register_natural_language + + PLUGIN_NAME = "French" + + + def register() -> None: + register_natural_language("French", "Français", { + "file_menu_label": "Fichier", + # ... 其他翻譯字串 + }) - language_wrapper.language_word_dict.update({ - "application_name": "我的自訂名稱", - # ... 更多翻譯 - }) +鍵與 JEditor 的英文字典和 PyBreeze 的(``pybreeze/extend_multi_language/extend_english.py``)相同。 +外掛沒有提供的鍵會以英文顯示。 diff --git a/docs/source/Zh/menu_tools.rst b/docs/source/Zh/menu_tools.rst index 969e8f50..2e639479 100644 --- a/docs/source/Zh/menu_tools.rst +++ b/docs/source/Zh/menu_tools.rst @@ -1,102 +1,170 @@ Tools 選單 ========== -**Tools** 選單提供 SSH 用戶端、AI 開發工具,以及內建的 WYSIWYG 架構圖編輯器。 -每個工具都可以作為 **分頁**(在主分頁元件中)或 **停靠面板**(浮動/可停靠面板)開啟。 +**Tools** 選單以 **分頁**\ (在主分頁元件中)開啟 SSH 用戶端、AI 工具、內建的 WYSIWYG 架構圖編輯器, +以及 HTTP / API 小工具。每一個也都能從 **Dock** 選單以 **停靠面板**\ (浮動/可停靠面板)開啟: + +.. list-table:: + :header-rows: 1 + :widths: 25 40 35 + + * - 工具 + - 分頁 + - 停靠面板 + * - SSH 用戶端 + - **Tools > SSH > SSH Client Tab** + - **Dock > SSH > SSH Client Dock** + * - AI 工具 + - **Tools > AI >** *<工具>* **Tab** + - **Dock > AI >** *<工具>* **Dock** + * - 架構圖編輯器 + - **Tools > Diagram Editor Tab** + - **Dock > Diagram Editor Dock** + * - HTTP / API 小工具 + - **Tools >** *<工具>* **Tab** + - **Dock >** *<工具>* **Dock** SSH --- -SSH Client Tab -^^^^^^^^^^^^^^ - -以新分頁的形式開啟 SSH 用戶端介面。詳細資訊請參閱 :doc:`ssh_client`。 - -SSH Client Dock -^^^^^^^^^^^^^^^ - -以可停靠面板的形式開啟相同的 SSH 用戶端,可以定位在主視窗的邊緣。 +**SSH Client Tab** 以新分頁開啟 SSH 用戶端(終端機與 SFTP 檔案樹);**SSH Client Dock** +以可停靠面板開啟同一個用戶端。詳細資訊請參閱 :doc:`ssh_client`。 AI 工具 ------- -AI Code-Review Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^^ +**Tools** 與 **Dock** 的 **AI** 子選單各有五個工具。詳細資訊請參閱 :doc:`ai_tools`。 -開啟 AI 程式碼審查用戶端,允許您將程式碼發送到 AI API 端點進行自動程式碼審查。 -詳細資訊請參閱 :doc:`ai_tools`。 - -CoT Prompt Editor Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -開啟思維鏈(CoT)提示詞編輯器,用於建立和管理結構化的提示詞範本。 -詳細資訊請參閱 :doc:`ai_tools`。 +.. list-table:: + :header-rows: 1 + :widths: 35 65 -Skill Prompt Editor Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + * - 工具 + - 說明 + * - **AI Code Review** + - 把程式碼送到 LLM 端點審查,再接受或拒絕它的建議。 + * - **CoT Prompt Editor** + - 編輯思維鏈(CoT)審查的提示詞範本。 + * - **CoT Code Review** + - 執行 CoT 審查:每一步的提示詞依序送到端點。 + * - **Skill Prompt Editor** + - 編輯特定任務(技能)的提示詞範本,例如程式碼審查或程式碼解釋。 + * - **Skill Send** + - 把技能提示詞連同你的程式碼送到 LLM 端點,並顯示回答。 + +HTTP 與 API 小工具 +------------------ -開啟基於技能的提示詞編輯器,用於建立特定任務的提示詞範本 -(如程式碼審查提示詞、程式碼解釋提示詞)。詳細資訊請參閱 :doc:`ai_tools`。 +共十三個工具,都可以從 **Tools** 以分頁、或從 **Dock** 以停靠面板開啟。它們都不會送出請求, +只做解析、轉換與產生文字。在只有一個主要按鈕的工具裡,於任何位置按 **Ctrl+Enter** 就會按下那個按鈕 +(文字框裡的 Enter 是換行);Query / JSON 與 URL 解析/組建器可以雙向轉換,Ctrl+Enter 依輸入的內容決定方向: +輸入是 JSON 物件時從 JSON 轉回。 -Skill Send GUI Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. list-table:: + :header-rows: 1 + :widths: 30 70 -開啟技能提示詞傳送介面,用於將提示詞發送到 LLM API 並查看回應。 -詳細資訊請參閱 :doc:`ai_tools`。 + * - 工具 + - 說明 + * - **cURL Import** + - 把從瀏覽器開發者工具複製的 ``curl`` 指令轉成 Python ``requests`` 腳本、pytest 測試、 + APITestka(Python 或 JSON 動作清單),或 LoadDensity 的 Locust 負載測試。 + * - **HAR Import** + - 列出瀏覽器 HAR 匯出檔裡的請求,把選取的請求轉成一份測試腳本,目標格式與 cURL Import 相同。 + * - **JWT Decoder** + - 顯示權杖的 header 與 payload,時間欄位以 UTC 呈現。不會驗證簽章。 + * - **Timestamp Converter** + - 輸入 Unix epoch(秒到奈秒)或 ISO-8601 日期時間,輸出所有 UTC 表示法。 + * - **Hash Generator** + - 同時算出文字的 SHA-256、SHA-512、SHA-1 與 MD5。 + * - **Query / JSON** + - ``application/x-www-form-urlencoded`` 與 JSON 互轉。 + * - **URL Parser / Builder** + - 把 URL 拆成可編輯的 JSON 物件,也能組回 URL。 + * - **Regex Tester** + - 列出每個符合項目的位置與群組,可用 ``IGNORECASE``、``MULTILINE``、``DOTALL``、 + ``VERBOSE`` 旗標。 + * - **HTTP Status Reference** + - 以狀態碼或關鍵字搜尋狀態碼表。 + * - **Text Diff** + - 兩段文字的 unified diff,附新增/刪除行數摘要。 + * - **JSON Format** + - 美化或壓縮 JSON。 + * - **HTTP Header Analyzer** + - 找出重複的標頭、Cookie 旗標、CORS、HSTS 與 CSP 的弱點,以及缺少的安全標頭。 + 帶有憑證的標頭只列出名稱。 + * - **Response Inspector** + - 解讀貼上的 HTTP 回應:狀態碼、標頭、JSON 本文與其中的 JWT,每一項都能一鍵在對應的工具中開啟。 架構圖編輯器 ------------ -Diagram Editor Tab / Dock -^^^^^^^^^^^^^^^^^^^^^^^^^ - -開啟內建的 WYSIWYG 架構圖編輯器,可直接在 PyBreeze 中繪製流程圖與架構圖, -不需要切換到外部工具。 +**Diagram Editor Tab** / **Diagram Editor Dock** 開啟內建的 WYSIWYG 架構圖編輯器, +可直接在 PyBreeze 中繪製流程圖與架構圖,不需要切換到外部工具。它的快捷鍵只在它取得焦點時作用, +所以作為停靠面板時不會搶走程式碼編輯器自己的快捷鍵。 繪圖工具 """""""" -- **選取(Select)** -- 點選、搬移、橡皮筋多選 -- **矩形 / 圓角矩形 / 橢圓 / 菱形** -- 節點形狀 -- **連線(Connection)** -- 將兩個節點以可加標籤的連線串接 -- **文字(Text)** -- 浮動文字註記 -- **圖片(檔案)** -- 插入本地圖片檔 -- **圖片(URL)** -- 從 URL 下載並插入圖片(會驗證 - 目標 IP 不在 private/loopback 範圍,並限制 20 MB 上限以防止 SSRF) +工具列的第一列: + +- **Select** -- 點選以選取、拖曳以搬移,在空白畫布上拖曳可用橡皮筋框選 +- **Rect** / **Rounded** / **Ellipse** / **Diamond** -- 點擊畫布放置該形狀的節點,之後工具回到 **Select** +- **Connect** -- 先點來源節點,再點目標節點;點空白畫布或按 **Esc** 取消 +- **Text** -- 點擊畫布放置文字節點 +- **Image** -- 插入本機圖片檔 +- **URL Image** -- 從 ``http`` / ``https`` URL 下載並插入圖片。會檢查位址(拒絕私有、loopback + 等非公開位址),只連線到檢查過的位址,下載上限為 20 MB 與 120 秒;在背景執行,主機回應慢也不會卡住 IDE。 + +雙擊節點可編輯它的文字;一次編輯是一個復原步驟。 檔案操作 """""""" +第二列開頭是檔案按鈕: + .. list-table:: :header-rows: 1 :widths: 25 75 - * - 操作 + * - 按鈕 - 說明 * - **New** - - 清除目前圖形(畫布非空時會跳出確認)。 + - 清空畫布;畫布上有東西時會先詢問。 * - **Open** - - 載入先前儲存的 ``.diagram.json`` 檔案。 - * - **Save / Save As** (``Ctrl+S`` / ``Ctrl+Shift+S``) - - 將圖形儲存為 ``.diagram.json``。 - * - **Import Mermaid** - - 貼上 Mermaid ``flowchart`` / ``graph`` 原始碼,轉換為可編輯的節點與邊。 - * - **Export PNG / SVG** + - 載入先前儲存的 ``.diagram.json`` 檔案;目前的架構圖有沒存的變更時會先詢問。 + 不是架構圖的檔案不會改動任何東西。 + * - **Save**\ (``Ctrl+S``) + - 儲存到上次開啟或儲存的檔案;第一次儲存時會像 **Save As** 一樣詢問位置。 + * - **Save As**\ (``Ctrl+Shift+S``) + - 另存為新的 ``.diagram.json`` 檔案。 + * - **Import** + - 貼上 Mermaid ``flowchart`` / ``graph`` 原始碼,轉換為自動排版、可編輯的節點與連線。 + 它會取代整個畫布,並算作一個復原步驟。 + * - **PNG** / **SVG** - 將畫布輸出為點陣圖(PNG)或向量圖(SVG)。 +已儲存架構圖裡的圖片會從原處載回:本機路徑只接受這台電腦上的圖片檔,URL 則經過與 **URL Image** 相同的檢查。 + +有沒存的變更時,關閉分頁、停靠面板或 IDE 也都會先詢問。 + 編輯輔助 """""""" -- **Undo / Redo** (``Ctrl+Z`` / ``Ctrl+Y``) -- 完整的 undo stack,含具名指令 - (新增、搬移、刪除、Import 等) -- **Copy / Paste / Duplicate / Select All** -- 標準快捷鍵;``Ctrl+D`` 可複製選取項目 -- **對齊(Align)** -- 將選取項目以左、右、上、下、水平置中、垂直置中對齊 -- **分佈(Distribute)** -- 三個以上選取項目可水平或垂直平均分佈 -- **Grid** -- 切換背景格線 +- **Undo** / **Redo**\ (``Ctrl+Z`` / ``Ctrl+Y``)-- 每個變更都是一個步驟 +- **Delete**\ (或 **Backspace**)刪除選取項目;``Ctrl+C`` / ``Ctrl+V`` 複製與貼上, + ``Ctrl+D`` 複製一份,``Ctrl+A`` 全選 +- 在項目上 **按右鍵** 有 **Delete**、**Duplicate**\ (節點)、**Bring to Front**、**Send to Back**; + 在空白畫布上有 **Paste**\ (複製過東西之後)與 **Select All** +- **Align** -- 對選取的節點做 **Align Left**、**Align Right**、**Align Top**、**Align Bottom**、 + **Center Horizontal**、**Center Vertical**;選取三個以上時可 **Distribute Horizontal** / + **Distribute Vertical** +- **Grid** -- 顯示背景格線 - **Snap** -- 拖曳節點時對齊格線 -- **屬性面板(右側)** -- 編輯選取項目的文字、顏色、線寬、形狀、連線樣式 -- **Zoom** -- 放大/縮小 (``Ctrl+=`` / ``Ctrl+-``)、重設 100% - (``Ctrl+0``)、Fit-to-content +- **Properties** 面板(右側)-- 選取節點的文字、寬、高、形狀、填色、框線與字級;連線的標籤、 + 樣式(實線、虛線、點線)、顏色與寬度;圖片的說明、寬、高,以及它的來源(唯讀) +- **縮放** -- 滑鼠滾輪、**-** 與 **+** 按鈕,或 ``Ctrl+-`` / ``Ctrl+=``;``Ctrl+0`` 回到 100%, + **Fit** 顯示整張圖。按住滑鼠右鍵或中鍵拖曳可平移。 分頁 vs. 停靠面板 ------------------ diff --git a/docs/source/Zh/ssh_client.rst b/docs/source/Zh/ssh_client.rst index cf6880cf..0c421f18 100644 --- a/docs/source/Zh/ssh_client.rst +++ b/docs/source/Zh/ssh_client.rst @@ -2,16 +2,16 @@ SSH 用戶端 ========== PyBreeze 內建 SSH 用戶端,用於連接遠端伺服器。 -可以從 **Tools > SSH Client Tab** 或 **Tools > SSH Client Dock** 開啟。 +可以從 **Tools > SSH > SSH Client Tab** 或 **Dock > SSH > SSH Client Dock** 開啟。 總覽 ---- -SSH 用戶端提供三個主要元件,以水平分割器排列: +SSH 用戶端頂部是登入元件,下方的水平分割器放著檔案樹與命令元件: -1. **登入元件**(頂部)-- 連線設定 -2. **檔案樹**(左側,約 30% 寬度)-- 遠端檔案瀏覽器 -3. **命令元件**(右側,約 70% 寬度)-- 互動式 SSH 終端機 +1. **登入元件**\ (頂部)-- 連線設定 +2. **檔案樹**\ (左側,約 30% 寬度)-- 遠端檔案瀏覽器 +3. **命令元件**\ (右側,約 70% 寬度)-- 互動式 SSH 終端機 登入元件 -------- @@ -28,12 +28,19 @@ SSH 用戶端提供三個主要元件,以水平分割器排列: - 遠端伺服器的主機名稱或 IP 位址。 * - **Port** - SSH 連接埠號(預設:22)。 - * - **Username** + * - **User** - 您的 SSH 使用者名稱。 - * - **Password / Key** - - 驗證憑證。支援密碼驗證和金鑰驗證。 - -輸入憑證後,點擊 **Connect** 建立 SSH 連線。 + * - **Use key auth** + - 勾選以使用金鑰驗證(用 **Browse...** 選了金鑰也會勾選)。 + * - **Key** / **Browse...** + - 私鑰路徑:OpenSSH 或 PEM 格式的 RSA、Ed25519、ECDSA 金鑰,PKCS#8 也可以 + (**Browse...** 從 ``~/.ssh`` 開始)。PuTTY 的 ``.ppk`` 金鑰要先在 PuTTYgen 匯出成 + OpenSSH 金鑰,錯誤訊息會說明怎麼做。 + * - **Password** + - 密碼;使用金鑰驗證時改為 **Passphrase**,填金鑰的密語。 + +輸入憑證後,點擊 **Connect**\ (或在任一欄位按 Enter)建立 SSH 連線。未知的主機金鑰不會被默默接受: +第一次連線時會顯示它的 SHA256 指紋供確認,並保存在 ``~/.pybreeze/ssh_known_hosts``。 遠端檔案瀏覽器 -------------- @@ -53,31 +60,38 @@ SSH 用戶端提供三個主要元件,以水平分割器排列: - 說明 * - **Refresh** - 從遠端伺服器重新載入目前的目錄列表。 - * - **Create Folder** + * - **Create folder** - 在遠端伺服器上建立新目錄。 * - **Rename** - - 重新命名選定的檔案或目錄。 + - 重新命名選定的檔案或目錄(焦點在檔案樹時也可按 **F2**)。 * - **Delete** - - 從遠端伺服器刪除選定的檔案或目錄。 + - 確認後從遠端伺服器刪除選定的檔案或目錄(預設為「否」;焦點在檔案樹時也可按 + **Delete** 鍵)。SFTP 只能刪除空資料夾。 * - **Download** - 將選定的檔案下載到本機。 - * - **Upload** - - 將本機檔案上傳到目前的遠端目錄。 + * - **Upload to this folder** + - 將本機檔案上傳到按右鍵的資料夾(或按右鍵檔案所在的資料夾),要取代伺服器上的檔案前會先詢問。 + * - **Cancel the transfer** + - 上傳或下載進行中時出現,用來取消傳輸。 + +每個請求都在背景執行,連線卡住也不會讓 IDE 凍結;上下傳都會先寫入暫存檔,連線中斷時舊的檔案仍完整無缺。 SSH 命令終端機 -------------- 命令元件提供互動式終端機,用於在遠端伺服器上執行命令。 -- 輸入命令並按 Enter 執行 -- 即時顯示輸出 -- 支援標準 Shell 操作 -- 在連線期間維護命令歷史 +- 輸入命令並按 Enter 執行;空白的一行按 Enter 也會送到 shell +- 即時顯示輸出,顯示 ANSI 顏色、使用等寬字型;視窗大小改變時會把新的寬度與高度告訴 shell +- **上**、**下** 方向鍵叫回先前送出的命令 +- **Interrupt**,或在沒有選取文字的命令列按 Ctrl+C,可停止 shell 中正在執行的程式 +- ``clear`` 與 ``reset`` 會清空畫面 +- 畫面是一行一行顯示輸出,所以 ``vim``、``htop`` 這類移動游標畫滿整個畫面的程式會顯示錯亂 使用提示 -------- - 使用 **分頁** 模式將 SSH 與程式碼編輯器分頁並列 - 使用 **停靠面板** 模式將 SSH 終端機定位在一側,同時編輯程式碼 -- 檔案瀏覽器支援拖放上傳 +- 從檔案樹的右鍵選單上傳與下載;焦點在檔案樹時,F2 重新命名、Delete 刪除目前的項目 - 您可以在不同的分頁/停靠面板中同時開啟多個 SSH 連線 diff --git a/docs/source/Zh/ui_overview.rst b/docs/source/Zh/ui_overview.rst index 57725502..67a5076c 100644 --- a/docs/source/Zh/ui_overview.rst +++ b/docs/source/Zh/ui_overview.rst @@ -3,31 +3,33 @@ UI 總覽 .. image:: images/ui.png -PyBreeze 提供基於分頁和停靠面板的介面,使用 PySide6(Qt for Python)建構。 +PyBreeze 提供基於分頁和停靠面板的介面,使用 PySide6(Qt for Python)與 JEditor 編輯器引擎建構。 主視窗由以下幾個主要區域組成。 +本指南的選單與按鈕名稱以英文介面為準。在 **Language** 選單選「繁體中文」後,介面會顯示對應的中文名稱, +例如 **Tools** 是「工具」、**Dock** 是「區域」、**Automation** 是「自動化」。 + 主視窗佈局 ---------- 選單列 ^^^^^^ -位於視窗頂部,包含所有檔案操作、程式碼執行、自動化模組、工具安裝、 -SSH、AI 工具、外掛等選單。 - 選單列包含以下頂層選單(由左至右): -- **File** -- 開啟、儲存檔案,設定編碼 -- **Run** -- 執行程式碼、在 Shell 中執行、停止程式 -- **Text** -- 字型和字型大小設定 -- **Check Code Style** -- 程式碼格式化工具(yapf、JSON) -- **Venv** -- 虛擬環境管理 -- **UI Style** -- 主題切換 -- **Automation** -- 所有自動化模組選單(AutoControl、APITestka、WebRunner 等) -- **Install** -- 安裝自動化套件和建置工具 -- **Tools** -- SSH 用戶端、AI 工具、架構圖編輯器 -- **Plugins** -- 外掛瀏覽器和已載入外掛 -- **Run With** -- 使用不同編譯器/直譯器執行檔案 +- **File** -- 新增、開啟與儲存檔案,開啟資料夾、最近的檔案、字型、編碼與行尾(見 :doc:`menu_file_run_text`) +- **Run** -- 以 Python 執行目前的檔案,或把它的文字當成 Shell 指令執行、除錯器、停止執行;有外掛註冊執行設定時另有 **Run with...** +- **Text** -- 字型、自動換行、縮排與文字轉換 +- **Check Code Style** -- ``yapf``、JSON 重新排版、Python 格式檢查與存檔時自動格式化 +- **Python Env** -- 建立虛擬環境、``pip``,以及選擇執行時使用的直譯器 +- **Tab** -- 開啟編輯器、瀏覽器、主控台、Git 與工具分頁(JupyterLab 在 **Tools Tab** 下,見 :doc:`jupyter_lab`) +- **Dock** -- 以停靠面板開啟同類的面板,以及 PyBreeze 的工具 +- **UI Style** -- 主題、縮排參考線、行尾空白與鍵盤快捷鍵 +- **Language** -- 介面語言 +- **Automation** -- 自動化模組(見 :doc:`menu_automation`) +- **Install** -- 安裝自動化套件與建置工具(見 :doc:`menu_install`) +- **Tools** -- SSH 用戶端、AI 工具、架構圖編輯器與 HTTP / API 小工具(見 :doc:`menu_tools`) +- **Plugins** -- 外掛瀏覽器與已載入的外掛(見 :doc:`menu_plugins`) 檔案樹 ^^^^^^ @@ -35,8 +37,16 @@ SSH、AI 工具、外掛等選單。 位於視窗左側,提供檔案瀏覽器用於導覽專案目錄。您可以: - 瀏覽檔案和資料夾 -- 雙擊在編輯器中開啟檔案 +- 單擊檔案會把它開進前景的編輯分頁,取代該分頁顯示的內容(那裡未存的文字會直接消失,不會詢問,見下方說明); + 已在某個分頁開啟的檔案則會切換到那個分頁 - 右鍵任何檔案或資料夾以開啟功能選單(詳見下方) +- 檔案樹有焦點時,按 **F2** 重新命名、按 **Delete** 刪除焦點所在的項目 + +.. note:: + + 在 JEditor 1.0.27 中,把檔案開進分頁——在檔案樹中單擊,或 **File > Open File**——不會詢問該分頁原本的文字。 + 有檔案的分頁每隔幾秒會自動存檔,但新分頁的文字、或最後幾秒打的字會消失。要保留它們,請先開新分頁 + (**Tab > Add Editor Tab**)。 檔案樹右鍵選單 """""""""""""" @@ -50,106 +60,116 @@ SSH、AI 工具、外掛等選單。 * - 動作 - 說明 * - **New File** - - 跳出輸入框詢問檔名,在按右鍵的目錄下(或被點擊檔案所在目錄) - 建立空檔案。 + - 跳出輸入框詢問檔名,在按右鍵的目錄下(或被點擊檔案所在目錄)建立空檔案。 + 含磁碟機代號、開頭斜線、``..`` 或 ``:`` 的名稱會被拒絕,已存在的名稱也是。 * - **New Folder** - - 跳出輸入框詢問資料夾名稱,建立新目錄。 + - 跳出輸入框詢問資料夾名稱,建立新目錄,檢查方式相同。 * - **Rename** - - 重新命名選取的檔案或資料夾。若該檔案正以分頁形式開啟在編輯器中, - 分頁標題與編輯器內部的路徑會同步更新。 + - 重新命名選取的檔案或資料夾。開在這個檔案、或資料夾內檔案上的編輯器分頁會跟著改到新名稱。 * - **Delete** - - 顯示確認對話框後刪除選取的檔案或資料夾。若該檔案正以分頁開啟, - 會先關閉分頁。 + - 先詢問(預設為 **No**),再把項目移到垃圾桶(Windows 的資源回收筒)。沒有垃圾桶的地方 + (例如某些網路磁碟)會再問一次,才永久刪除。連結只刪除連結本身,不動它指向的東西。 + 檔案已不存在的編輯器分頁會關閉。 * - **Copy Path** - 將選取項目的絕對路徑複製到剪貼簿。 * - **Copy Relative Path** - - 複製相對於檔案樹根目錄的相對路徑。 - * - **Reveal in Explorer** - - 在系統檔案管理器中開啟選取項目所在資料夾(Windows 的檔案總管、 - macOS 的 Finder、Linux 的 ``xdg-open``)。 + - 複製相對於檔案樹根目錄的路徑。 + * - **Reveal in File Explorer** + - 在作業系統的檔案管理員中顯示這個項目:Windows 的檔案總管與 macOS 的 Finder 會選取它; + Linux 則以 ``xdg-open`` 開啟它所在的資料夾。 程式碼編輯器(分頁元件) ^^^^^^^^^^^^^^^^^^^^^^^^ -中央區域使用分頁介面。每個開啟的檔案都有自己的分頁。 -額外的工具分頁(SSH、AI、JupyterLab、自動化 GUI)也可以在此開啟。 +中央區域使用分頁介面,每個開啟的檔案都有自己的分頁。其他工具分頁(SSH、AI、JupyterLab、 +架構圖編輯器、HTTP / API 小工具、自動化 GUI)也可以在這裡開啟。 功能: -- Python 和自動化模組關鍵字的語法高亮 -- 多檔案分頁編輯 -- 可透過 ``EDITOR_EXTEND_TAB`` 擴充自訂分頁 +- Python 與 JEditor 會上色的語言(C、C++、Go、Java、JavaScript、JSON、Rust、Shell、SQL、TOML、 + TypeScript、YAML 等)的語法高亮。PyBreeze 為 ``.json``、``.yml``、``.yaml`` 註冊了自動化關鍵字, + 但 JEditor 用自己的規則為這些檔案上色,不包含註冊的關鍵字。 +- 多分頁編輯多個檔案 +- 可透過 ``EDITOR_EXTEND_TAB`` 擴充自訂分頁(見 :doc:`how_to_extend_ui`) +- 有未儲存內容的工具分頁(提示詞編輯器、架構圖編輯器)關閉前會先詢問,關閉 IDE 時也是 輸出面板 ^^^^^^^^ -位於視窗底部,顯示: +每個編輯器分頁下方都有一個面板,包含 **Code result**、**Format checker**、**Debugger**、**Terminal**、 +**Variable Inspector** 與 **Git Client** 這幾個分頁: -- 程式執行輸出 -- Shell 命令結果 -- 錯誤訊息 +- **Code result** 顯示 **Run Program** 與 **Run On Shell** 的輸出,錯誤以主題的錯誤色呈現, + 也會顯示 IDE 的日誌訊息(只有警告與錯誤); +- **Format checker** 列出 **Check Code Style > Python format check** 找到的問題; +- **Debugger** 是 **Run Debugger** 執行的地方。 -程式碼輸出視窗 -^^^^^^^^^^^^^^ +執行視窗 +^^^^^^^^ -執行自動化腳本時,會開啟獨立的 **程式碼輸出視窗** 顯示執行結果。此視窗: +自動化腳本、一批腳本、套件安裝或 **Run with...** 的每一次執行,都會開一個自己的視窗顯示輸出。這個視窗: -- 即時顯示子程序的執行輸出 -- 唯讀模式 -- 根據螢幕大小自動調整尺寸 -- 可獨立於主視窗關閉 +- 標題說明它執行的東西(例如套件與檔案) +- 輸出一到就顯示,錯誤以錯誤色、等寬字型呈現,並保留最後 10,000 行 +- 有 **Stop** 按鈕,執行期間可以按 +- 執行中也可以關閉視窗:執行會繼續,關閉 IDE 時才會停止 +- 大小為螢幕的三分之一 停靠面板 ^^^^^^^^ -以下工具可以作為 **停靠面板** 開啟(而非分頁),讓您自由排列: +面板可以從 **Dock** 選單以 **停靠面板** 開啟,而不是分頁,並可在主視窗周圍自由排列: -- SSH 用戶端停靠面板 -- AI 程式碼審查停靠面板 -- CoT 提示詞編輯器停靠面板 -- Skill 提示詞編輯器停靠面板 -- Skill 傳送 GUI 停靠面板 -- 架構圖編輯器停靠面板 +- **Editor** -- 停靠式編輯器、IPython(Jupyter)與變數檢視器(目前是空的,見 :doc:`menu_file_run_text`) +- **Git** -- Git 用戶端、分支樹檢視器與程式碼差異檢視器 +- **AI** -- Chat UI、AI Code Review、CoT Prompt Editor、CoT Code Review、Skill Prompt Editor 與 Skill Send +- **Tools** -- 瀏覽器、FrontEngine、主控台、TODO 面板、Problems、Tests 與大綱 +- **SSH** -- SSH 用戶端 +- 架構圖編輯器與每個 HTTP / API 小工具,在選單的最上層 -停靠面板可以拖曳、調整大小,並固定到主視窗的任何邊緣。 +停靠面板可以拖曳、調整大小、浮動、堆疊,並停靠到主視窗的任何邊緣。 主題系統 -------- -PyBreeze 使用 `qt_material `_ 進行主題設定。 -可用主題包括: +PyBreeze 使用 `qt_material `_ 提供主題。 +**UI Style** 選單列出這些主題: -- ``dark_amber.xml``(預設) -- ``dark_teal.xml`` +- ``dark_amber.xml``\ (預設) - ``dark_blue.xml`` - ``dark_cyan.xml`` - ``dark_lightgreen.xml`` - ``dark_pink.xml`` - ``dark_purple.xml`` - ``dark_red.xml`` +- ``dark_teal.xml`` - ``dark_yellow.xml`` - ``light_amber.xml`` - ``light_blue.xml`` - ``light_cyan.xml`` +- ``light_cyan_500.xml`` - ``light_lightgreen.xml`` - ``light_pink.xml`` - ``light_purple.xml`` -- ``light_red.xml`` -- ``light_yellow.xml`` -您可以透過選單列中的 **UI Style** 選單切換主題,或在啟動時設定: +點擊任一主題會立即套用到整個應用程式。也可以在啟動時指定主題: .. code-block:: python + from pybreeze import start_editor + start_editor(theme="dark_teal.xml") +從 **UI Style** 選的主題會被儲存,下次啟動時使用。傳給 ``start_editor`` 的主題會取代它,並改存成這一個。 + 多語言支援 ---------- -PyBreeze 支援多種 UI 語言: +**Language** 選單提供: -- **English**(預設) +- **English**\ (預設) - **繁體中文** -- 可透過外掛新增其他語言 +- **日本語** 與 **简体中文**,這兩個是 JEditor 的:選了之後 JEditor 自己的選單會改變,PyBreeze 的字串則維持英文 +- 翻譯外掛新增的語言(見 :doc:`menu_plugins`) -所有 UI 字串透過集中式語言字典管理,可擴充自訂翻譯。 +選單、對話框、工具拒絕輸入時說明的原因,以及執行視窗自己的訊息,都會跟著所選的語言顯示。 diff --git a/docs/source/conf.py b/docs/source/conf.py index 711e2c16..713d23a9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -26,8 +26,6 @@ extensions = [] -templates_path = ["_templates"] - language = "en" exclude_patterns = [] @@ -36,4 +34,19 @@ html_theme = "sphinx_rtd_theme" -html_static_path = ["_static"] +# -- Options for LaTeX (PDF) output ------------------------------------------- + +# Half of the guide is Traditional Chinese, which pdflatex cannot set: the PDF +# Read the Docs built had every Chinese character missing. xelatex sets it with +# xeCJK and the Noto CJK TC fonts, which Read the Docs' build image has +# (fonts-noto-cjk); ctex's own fonts leave out some Traditional characters. +latex_engine = "xelatex" +latex_use_xindy = False +latex_elements = { + "preamble": "\n".join(( + r"\usepackage{xeCJK}", + r"\setCJKmainfont{Noto Serif CJK TC}", + r"\setCJKsansfont{Noto Sans CJK TC}", + r"\setCJKmonofont{Noto Sans Mono CJK TC}", + )), +} diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 66e6d9f7..cb37150d 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -640,3 +640,449 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: `test/test_utils/test_regex_tester.py` passes on the 3.10 matrix leg; normal patterns still compile, escaped `\(` and `[(]` are not counted. - **Files**: `pybreeze/utils/regex_tools/regex_tester.py` - **Evidence**: crash trace `sre_parse.py _parse_sub/_parse` on Python 3.10.11; ruff clean. + +## U-20260924-271 · 2026-09-24 · A run window's own notices in the IDE language · #fix #i18n #run-window + +- **What**: what the executors write into a run window about the run itself was English whatever the IDE spoke: `[Error] Command not found: gcc`, `[Compile] ...`, `[Compile failed] exit code 1`, `[Run] ...`, `[Stopped]`, `[Error] Timed out after 120s`, `[Error] No Python interpreter found: ...`, `[Error] APITestka runs the script in the editor tab in front; ...`, `[Mail] The test report was not sent: ...` and four more, thirteen f-string literals in `file_runner_process.py`, `process_executor_utils.py` and `python_task_process_manager.py`. +- **Fix**: `extend/process_executor/run_notice.py`: `run_notice(notice, /, **fields)` takes `run_window_` from the language dictionary (13 entries in each language; the English reads as before) and fills in the fields; the notice name is positional-only, so `[Run] {name}` can have a field called `name`. When the IDE's dictionary lacks the entry (an executor started before `update_language_dict()`, as in a script or a test) PyBreeze's English is used. +- **Tests**: `test_run_notice.py` (4): a notice in Traditional Chinese; English when the dictionary lacks it; every notice the executors name is in both dictionaries; no executor writes an `"[Error] ..."`-style literal. The run-window, compile-then-run and mail-notice tests pass unchanged. +- **Result / numbers**: 721 keys in each language. 2255 tests in 125 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/run_notice.py` (new), `pybreeze/extend/process_executor/file_runner_process.py`, `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_run_notice.py` (new), `CLAUDE.md`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: the reason a report mail was not sent comes from `mail_thunder_extend` and is still English. + +## U-20260924-272 · 2026-09-24 · Refactor: the reasons a report mail was not sent come from exception_tags · #refactor #i18n #mail + +- **What**: `send_after_test` answers why a run's report was not mailed with eight English literals (`no mail user is set`, `the mail server login failed`, `sending failed (SMTPException)`, `the run wrote no default_name.html`, ...), which the run window shows after `[Mail] The test report was not sent:` (U-20260924-271). They are `mail_*_error` and `report_*_error` constants in `exception_tags` now, with the same text. +- **Behaviour**: none visible; the Traditional Chinese entries are in the dictionary for the next step. +- **Tests**: `test_error_text.py` finds each of the eight again from a message made from it (+8); the mail-notice tests pass unchanged. +- **Result / numbers**: 729 keys in each language. 2263 tests in 125 files. `ruff check` clean. +- **Files**: `pybreeze/utils/exception/exception_tags.py`, `pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` +- **Open items**: showing them in the IDE language (next entry). + +## U-20260924-273 · 2026-09-24 · Why a report mail was not sent, in the IDE language · #fix #i18n #mail + +- **What**: after U-20260924-271 the run window's mail notice was translated, but the reason after it was not: `[郵件] 沒有寄出測試報告:no mail user is set`. +- **Fix**: `_MailNotice.tell` passes `send_after_test`'s answer through `error_text()`, which finds it among the constants U-20260924-272 made; a reason from anywhere else is shown as it is. +- **Tests**: `test_run_notice.py` +1 (no mail user, in Traditional Chinese: `[郵件] 沒有寄出測試報告:沒有設定郵件使用者`); the English mail-notice tests pass unchanged. +- **Result / numbers**: 2264 tests in 125 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `test/test_utils/test_run_notice.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-274 · 2026-09-24 · The exit-code line and the held-output note in the IDE language too · #fix #i18n #run-window + +- **What**: U-20260924-271 translated the run window's `[Error]`/`[Run]`-style notices but left two others English: `Task exit with code 0`, which ends every Python run, and the note that a process the run started still holds its output (`OUTPUT_STILL_HELD_NOTE` in `queue_pump.py`). +- **Fix**: both are `run_notice("exit_code", code=...)` and `run_notice("output_still_held")` (`run_window_exit_code`: `執行結束,結束代碼 {code}`); the constant is gone from `queue_pump.py`. The English reads as before. +- **Tests**: `test_run_notice.py` +1 (both in Traditional Chinese); the run-output, install-menu and code-window tests, which wait for `Task exit with code`, pass unchanged. +- **Result / numbers**: 731 keys in each language. 2265 tests in 125 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/queue_pump.py`, `pybreeze/extend/process_executor/file_runner_process.py`, `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_run_notice.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-275 · 2026-09-24 · The Traditional and Simplified Chinese READMEs follow README.md again · #docs #readme + +- **What**: `README/README_zh-TW.md` and `README/README_zh-CN.md` still had an old layout: no screenshot tour, the built-in tools in one list instead of a section each (cURL, HAR, Response Inspector, Header Analyzer, Diff, the everyday utilities, Diagram Editor, SSH), no Testing & CI section, and none of what README.md has said since about the run window's Stop button, SFTP transfer cancelling, the key file browser, the regex tab's Enter, the translated tool errors and run-window notices or the key count. CLAUDE.md now requires README.md and every translation to be aligned in structure and content. +- **Change**: both are translations of README.md as it stands, section by section: the same headings in the same order (16 second-level, 19 third-level), tables, lists, code blocks, screenshots and captions; relative links and images carry `../`; the tables of contents point at the translated headings. zh-TW uses Taiwan usage and names the menus as the Traditional Chinese interface shows them (自動化, 工具, 安裝) beside the English in the screenshots; code, commands, file names and the Mermaid example stay as in README.md. +- **Checks**: every relative link and image in both files resolves; all three give the same key count (731); no mainland term in zh-TW. +- **Files**: `README/README_zh-TW.md`, `README/README_zh-CN.md` +- **Open items**: none. + +## U-20260924-276 · 2026-09-24 · Check the trimmed diff against the plain one only on small texts · #fix #diff #perf + +- **What**: U-20260924-264 made `compare_texts` match a second time, plainly, whenever an unchanged head or tail had been set aside, and keep the diff that changes fewer lines. On the Diff tab's own large case (20,000 lines on each side, every tenth changed) that took the comparison from 6.4 s to 16.6 s for the same 2,000-line diff, and `test_diff_gui.py`'s large-comparison test ran out of time under coverage. Found by a coverage run. +- **Fix**: the plain match is made only when both texts together have at most `_PLAIN_MATCH_MAX_LINES` (2,000) lines, where it takes milliseconds; the case trimming gets wrong is a small, ambiguous one (`b b a b a` against `b a c b`). The large case is back to about 7 s. +- **Tests**: `test_text_diff.py` +2 (a 21-line text with a common head is matched plainly too; a 3,002-line one is not), and the property that the diff is never longer than difflib's, on its small texts, still passes. +- **Result / numbers**: 2267 tests in 125 files. `ruff check` clean. +- **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-277 · 2026-09-24 · Tests for the diagram editor's align and distribute · #test #diagram + +- **What**: a coverage run (87% over `pybreeze/`) showed the diagram scene's eight align and distribute operations (`align_left` ... `distribute_v`) run by no test. +- **Change**: `test_diagram_align.py` (11): each edge lines up where the outermost node's edge was; centres line up on their average; distributing keeps the outer two in place and leaves equal gaps; one undo takes a whole alignment back and leaves nothing more to undo; with too few nodes selected nothing moves and no undo step is recorded. They pass on the code as it is. +- **Result / numbers**: 2278 tests in 126 files. +- **Files**: `test/test_utils/test_diagram_align.py` (new), `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-278 · 2026-09-24 · Tests for the property panel on a connection and an image · #test #diagram + +- **What**: the same coverage run showed the diagram property panel's connection and image handlers (label, style, colour, width, caption, size) run by no test; only the node side had tests. +- **Change**: `test_diagram_property_panel.py` (5): selecting a connection or an image fills the panel and records no undo step; a connection's style, colour and label follow the panel, one undo step each; steps on its width are one undo step, and undoing it restores the width; an image's width edit keeps its height, and its caption follows. They pass on the code as it is. +- **Result / numbers**: 2283 tests in 127 files. +- **Files**: `test/test_utils/test_diagram_property_panel.py` (new), `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-279 · 2026-09-24 · A new diagram node's text in the IDE language · #fix #i18n #diagram + +- **What**: a node added with a shape tool said `Node`, and one added with the Text tool `Text`, whatever the IDE spoke. Found reading the diagram scene's untested lines from the coverage run. +- **Fix**: `_add_shape_node` and `_add_text_node` take the text from `diagram_editor_new_node_text` (`Node` / `節點`) and `diagram_editor_new_text_text` (`Text` / `文字`). A saved diagram's text is unchanged. +- **Tests**: `test_diagram_editing.py` +2 (a shape node and a text node, in Traditional Chinese). +- **Result / numbers**: 733 keys in each language. 2285 tests in 127 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_diagram_editing.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-280 · 2026-09-24 · curl -b '' reads no cookie file · #fix #curl + +- **What**: `curl -b ''` only switches curl's cookie engine on and reads no file (curl's manual: an empty file name as the only cookie input starts the engine with no cookies). The cURL import took it as a cookie file named `""`: the generated Python scripts said `# curl read cookies from "" (-b)`, and the APITestka JSON action refused the command ("cannot ... read ... cookies from" a file). Found by a Hypothesis run over generated curl commands and every template target. +- **Fix**: `_apply_cookie` records a `-b` value with no `=` as a cookie file only when it is not empty. +- **Tests**: `test_curl_import.py` +1 (no file, no cookie, no note), `test_curl_request_fidelity.py` +1 (the JSON action is made). The first failed on the old code. The ad-hoc fuzz (20000 commands, 21 flags, all 5 targets parse or compile, only the JSON action may refuse) passes. +- **Result / numbers**: 2287 tests in 127 files. Full suite at 35aaecd: 2270 passed, 15 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `test/test_utils/test_curl_import.py`, `test/test_utils/test_curl_request_fidelity.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-281 · 2026-09-24 · architecture_explore.md line counts re-measured · #docs + +- **What**: three line counts `architecture_explore.md` quotes had fallen behind the code: `diagram_editor/` 3,963 (now 3,985), `connect_gui/ssh/` 2,035 (now 2,230, after the SFTP work) and `syntax/syntax_keyword.py` 625 (now 629, after `.yaml` highlighting). +- **Fix**: re-measured with `wc -l` and corrected. No other count in the file quotes a line total. +- **Files**: `architecture_explore.md` +- **Open items**: none. + +## U-20260924-282 · 2026-09-24 · A right-drag on the diagram canvas pans without opening the menu · #fix #diagram + +- **What**: the diagram canvas pans with the middle or the right button (`DiagramView.mousePressEvent`), and the scene has a right-click menu. Windows opens a context menu as the right button is released, so every right-drag pan ended with the canvas menu open. Where the menu opens on the press (Linux, macOS), the menu took the release, `_panning` stayed on, and the canvas went on scrolling with the mouse and no button held. Found reading the view's untested lines; reproduced headless by sending the mouse through the window, where Qt makes the context-menu event. +- **Fix**: `DiagramView` remembers the pan button. A right-drag that moved at least the system drag distance swallows the one mouse-triggered context menu that follows it (`contextMenuEvent`). A right click that did not move and the keyboard's menu key still open it. A move with the pan button no longer held stops the pan (`_stop_panning`). +- **Tests**: new `test/test_utils/test_diagram_view_pan.py` (6): a right-drag pans and opens no menu; a right click still opens it; a right click after a right-drag opens it; a middle-drag pans; the menu key after a right-drag opens it; a pan whose release went to a menu opened on the press stops on the next move. The trigger is set per test (`QStyleHints.setContextMenuTrigger`). Four failed on the old code. +- **Docs**: `architecture_explore.md`'s `diagram_view.py` row describes the right-drag. The per-file `(N)` line counts in its tables were re-measured too: ten were behind, which U-20260924-281 missed (it checked only the `N 行` form). `diagram_editor/` is now 4,018 lines. +- **Result / numbers**: 2293 tests in 128 files. The diagram tests (148) pass. `ruff check` and C901 clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_view.py`, `test/test_utils/test_diagram_view_pan.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-283 · 2026-09-24 · architecture_explore.md coverage figures re-measured · #docs #test + +- **What**: `architecture_explore.md` §18 still said coverage was 60% overall, with `diagram_editor` 45%, `process_executor` 39% and `connect_gui` 28%. Those figures predate most of the tests written since. +- **Measured**: `pytest test/test_utils --cov=pybreeze` with `.coveragerc` (branch coverage) at 96f287e in a clean worktree. Total 89%. `utils/`, `tools_gui` and `dialog` 97-100%, `extend/` 93%, `connect_gui` 89%, `diagram_editor` 85%, `jupyter_lab_gui` 82%, `menu` 81%, `editor_main` 71%. The main window mostly runs in the startup tests' child processes, which `.coveragerc` does not measure. +- **Same run**: 3 child-IDE tests failed (`test_started_menus.py::test_a_broken_extend_tab_costs_only_itself`, `test_startup_language.py` Traditional Chinese and Japanese): a 120 s timeout, and `OpenBLAS error: Memory allocation still failed` with 560 MB of 32 GB free while other projects' processes held the memory. The plain suite at 35aaecd passed all of them (2270 passed, 15 skipped). +- **Files**: `architecture_explore.md` +- **Open items**: none. + +## U-20260924-284 · 2026-09-24 · Taiwan terms in the Traditional Chinese interface · #fix #i18n + +- **What**: PyBreeze's own Traditional Chinese entries used Mainland terms. The 22 automation Run entries said 運行 (「運行 APITestka 腳本」…), while the run window said 執行. The plugin menu and plugin browser (9 entries PyBreeze defines over JEditor's) said 插件, the three SSH status lines 終端, and the diagram property panel 字體大小. Found by a term sweep over the dictionary. +- **Fix**: 運行 → 執行, 插件 → 外掛, 終端 → 終端機, 字體 → 字型, in `extend_traditional_chinese.py` (35 entries). `README/README_zh-TW.md` follows the interface: 外掛系統, 外掛瀏覽器, and the **Plugins**(外掛)menu named like the other menus. `README.md` and `README_zh-CN.md` do not name these words (the Simplified Chinese README keeps 插件, the Mainland term), so they are unchanged. +- **Tests**: `test_language_parity.py` +1 (`test_traditional_chinese_uses_taiwan_terms`: ten Mainland terms, and 終端 not followed by 機). It failed on the old dictionary. +- **Result / numbers**: 2294 tests in 128 files. The language, run-notice and error-text tests (102) pass. +- **Files**: `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `README/README_zh-TW.md`, `architecture_explore.md`, `progress.md` +- **Open items**: #108 — JEditor's own entries (its Run menu's 運行, its font menus' 字體) still show inside PyBreeze; they change in JEditor. + +## U-20260924-285 · 2026-09-24 · Enter on an empty SSH command line sends Enter · #fix #ssh + +- **What**: the SSH tab's command line sent nothing when Enter was pressed with nothing typed (`SSHCommandWidget.send_command` returned on an empty line). A shell prompt that waits for Enter alone could not be answered: a default like `[Y/n]`, "Press Enter to continue", a pager. +- **Fix**: with a session, an empty line is sent as a bare newline. Without one, an empty line still does nothing, and a typed line still says to connect first. +- **Tests**: `test_ssh_terminal_output.py` +2 (an empty line sends `\n`; without a session it shows no dialog). The first failed on the old code. +- **Result / numbers**: 2296 tests in 128 files. The SSH tests (106) pass. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-286 · 2026-09-24 · Interrupt what runs in the SSH shell · #feature #ssh #i18n + +- **What**: the SSH tab had no way to send Ctrl+C. The command line sends whole lines, so a `ping`, `tail -f` or `top` started there ran until the session was disconnected. +- **Change**: an **Interrupt** button (中斷) beside Send, and Ctrl+C in the command line when no text is selected, send `\x03` (`SSHCommandWidget.send_interrupt`), which the remote terminal turns into SIGINT. With text selected, Ctrl+C copies as before (`eventFilter`). What is typed in the command line stays. Without a session it does nothing. `send_command` and `send_interrupt` share `_send`, which reports a failed send in the terminal. +- **Tests**: `test_ssh_terminal_output.py` +4 (the button sends `\x03` and keeps the line; Ctrl+C in the command line interrupts; Ctrl+C on a selection copies and sends nothing; the button without a session does nothing). +- **Docs**: the SSH paragraph of `README.md`, `README/README_zh-TW.md` and `README/README_zh-CN.md` names Interrupt, Ctrl+C and Enter on an empty line. Key count 733 → 735 there and in `architecture_explore.md`, whose SSH row describes the interrupt. +- **Result / numbers**: 735 keys in each language. 2300 tests in 128 files. The SSH, language and started-menu tests (123) pass. Full suite at 2576d92 (before this change): 2279 passed, 15 skipped; both startup tests exit 0. `ruff check` and C901 clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_terminal_output.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-287 · 2026-09-24 · Command history on the SSH command line · #feature #ssh + +- **What**: the SSH tab's command line forgot each line once it was sent. Up and Down did nothing, so running a command again, or fixing a typo in one, meant typing it again. +- **Change**: `CommandHistory` (in `ssh_command_widget.py`, no Qt) remembers the lines sent: up to 500, and a line sent twice in a row only once. In the command line, Up and Down walk through them. Walking down past the newest gives back what was being typed. Sending goes back to a new line. The keys are read in `SSHCommandWidget._command_line_key`, which the command line's event filter calls. Ctrl+C is handled there too. +- **Tests**: `test_ssh_terminal_output.py` +7 (six on `CommandHistory`: back and forth, the draft, nothing sent, empty and repeated lines, the limit, sending resets the walk; one on Up and Down in the widget). +- **Docs**: the SSH paragraph of `README.md`, `README/README_zh-TW.md` and `README/README_zh-CN.md`, and `architecture_explore.md`'s SSH row. +- **Result / numbers**: 2307 tests in 128 files. The SSH terminal tests (37) pass. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ssh_terminal_output.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-288 · 2026-09-24 · Refactor: the run window's line rewinding in a shared module · #refactor + +- **What**: `_insert_rewinding` (a lone `\r` goes back to the line's start, as in a terminal) lived in `show_code_window/code_window.py`. The SSH terminal needs the same thing next. +- **Change**: moved as it is to `pybreeze/pybreeze_ui/terminal_view.py` as `insert_rewinding`. The run window imports it. No behaviour changes. +- **Tests**: the run-window and terminal tests (89) pass unchanged. +- **Docs**: `CLAUDE.md` architecture tree, `architecture_explore.md` (the run window's row names the new place). +- **Files**: `pybreeze/pybreeze_ui/terminal_view.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `CLAUDE.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-289 · 2026-09-24 · A progress bar in the SSH terminal redraws its line · #fix #ssh + +- **What**: in the SSH terminal a lone `\r` became a line break. A progress bar that redraws its line (pip, wget, curl, apt) added a line per step and pushed real output out of the 10,000-line scrollback. The run window already rewound the line. Found comparing the two views, which share `utils/terminal_text`. +- **Fix**: `SSHCommandWidget._insert_output` writes through `terminal_view.insert_rewinding` (moved there by U-20260924-288), so a lone `\r` goes back to the line's start and what follows replaces it. `TerminalDecoder` already holds a `\r` that ends a read until the next read shows whether `\n` follows. +- **Tests**: `test_ssh_terminal_output.py` +2 (a bar over several reads; a bar with `\x1b[K` in one read). Both failed on the old code. +- **Result / numbers**: 2309 tests in 128 files. The SSH tests (119) pass. Full suite at 531ea9d: 2292 passed, 15 skipped; both startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-290 · 2026-09-24 · Terminal output in a fixed-pitch font · #fix #ssh #ui + +- **What**: the SSH terminal and the run window showed output in the interface's proportional font. Output laid out in columns (`ls -l`, `df`, `ps`, a table a script prints) came out ragged: on Windows an `i` was about 3 px wide and a `W` 12 px. +- **Fix**: `terminal_view.use_terminal_font(view)` gives a view the system's fixed-pitch font (`QFontDatabase.SystemFont.FixedFont`, Courier New on Windows) at the size the view had. Both views call it. +- **Tests**: new `test_terminal_font.py` (3): the helper keeps the size; the SSH terminal and the run window use the fixed font. The two view tests failed on the old code. +- **Result / numbers**: 2312 tests in 129 files. `ruff check` clean. +- **Docs**: `CLAUDE.md` tree line for `terminal_view.py`, `architecture_explore.md` (both views, test count). +- **Files**: `pybreeze/pybreeze_ui/terminal_view.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_terminal_font.py`, `CLAUDE.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-291 · 2026-09-24 · The SSH shell's pty follows the terminal's size · #feature #ssh + +- **What**: the SSH shell's pty was opened at 120 x 32 and never changed. Programs laid their output out for that size whatever the view showed: `ls` spread its columns over 120 characters in a view showing 70 (a horizontal scroll to read them), a progress bar sized itself to 120. +- **Change**: `terminal_view.terminal_size(view)` gives the columns and rows a view shows whole, in its (fixed-pitch, U-20260924-290) font, at least 20 x 5. The shell opens at the view's size (`open_shell_channel(client, size)`); a resize of the view's viewport sends `resize_pty` when the size in characters changes (`_follow_view_size`, through the widget's `eventFilter`), and once more when the shell comes up, for a resize while it was connecting. A resize the server refuses (a dropped link) is logged and tried again on the next one. +- **Tests**: `test_ssh_terminal_output.py` +5 (the pty follows the view; a resize within a column sends nothing; nothing without a session; a refused resize is not raised; the shell opens at the given size), `test_terminal_font.py` +2 (`terminal_size` grows with the view; a squeezed view gets the minimum). The follow test failed on the old code. `TestTheConnectMessage` now gives `_start_shell` a channel fake instead of a bare `object()`. +- **Result / numbers**: 2319 tests in 129 files. The terminal, run-window and SSH tests (167) pass. `ruff check` clean. +- **Docs**: the three READMEs' SSH paragraph, `architecture_explore.md` (SSH row, test count). +- **Files**: `pybreeze/pybreeze_ui/terminal_view.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ssh_terminal_output.py`, `test/test_utils/test_terminal_font.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-292 · 2026-09-24 · Colours in the SSH terminal · #feature #ssh #ui + +- **What**: the SSH terminal removed every escape sequence, colours with the rest. Through a pty, `ls --color`, `git`, `grep --color`, compilers and test runners colour their output, and it came out all one colour. +- **Change**: new `pybreeze/utils/terminal_style.py` (pure): `apply_sgr()` reads an SGR sequence (`ESC [ … m`) into a frozen `TextStyle`: the 16 basic and bright colours, `38;5;n` / `48;5;n` (256 colours), `38;2;r;g;b` (24-bit), bold, italic, underline, inverse and their off switches, 39/49 defaults, 0 or nothing to reset. Unknown or malformed parameters change nothing; a parameter longer than 5 digits is ignored (`int()` refuses past 4300 digits, which a server could send). `split_styled()` cuts text at its SGR sequences, each piece with its style; `colour_rgb()` gives xterm's colours. `terminal_view.style_format()` turns a style into a `QTextCharFormat` (the plain style is an empty format; inverse takes the view's own colours for a default). `TerminalDecoder.feed()` now returns the pieces, the style carried from one read to the next and dropped on `reset()`. `SSHCommandWidget` keeps a lone `\r` that ends a piece for the next one (`_rewind_pending`, as the run window does), so a bar that changes colour after its rewind still redraws its line. The run window still removes colours: a program writing to a pipe does not colour its output. +- **Tests**: new `test_terminal_style.py` (35: parameters, resets, malformed and huge parameters, the 256 palette, pieces, formats), `test_ssh_terminal_output.py` +6 (colours shown, carried across reads, reset for a new session, a bar recoloured after its rewind in one read and in two, a line ending around a reset). The decoder tests read the pieces' text. An ad-hoc Hypothesis run (7,500 examples: random SGR parameters, text with SGR, rewinds, backspaces and cut escapes, through the parser and the widget) found nothing. +- **Result / numbers**: 2360 tests in 130 files. Full suite at f0858f7: 2304 passed, 15 skipped; both startup tests exit 0; gates clean. `ruff check` clean. +- **Docs**: the three READMEs' SSH paragraph, `CLAUDE.md` tree, `architecture.md` (utils row), `architecture_explore.md` (SSH row, utils table, test count). +- **Files**: `pybreeze/utils/terminal_style.py`, `pybreeze/pybreeze_ui/terminal_view.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_terminal_style.py`, `test/test_utils/test_ssh_terminal_output.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `CLAUDE.md`, `architecture.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-293 · 2026-09-24 · Terminal colours readable on a dark and on a light theme · #fix #ssh #ui + +- **What**: U-20260924-292 showed the 16 basic colours as xterm does. Rendered on the IDE's dark theme, xterm's blue (0, 0, 238), which `ls --color` uses for directories, was all but unreadable; on a light theme xterm's "white" (229, 229, 229) is. +- **Fix**: `terminal_style.colour_rgb(colour, *, on_dark)` takes the first 16 colours from VS Code's terminal defaults, one set for dark themes and one for light (`terminalColorRegistry.ts`); the 256-colour cube, the greys and 24-bit colours are unchanged. `terminal_view.style_format()` picks the set from the view's palette: its Base lightness under 128 is dark. Checked that qt_material's stylesheet reaches a polished view's palette: `dark_amber.xml` gives Base `#31363b` (lightness 54), `light_blue.xml` `#e6e6e6` (230). A screenshot of the SSH widget on the dark theme shows the directory blue readable. +- **Tests**: `test_terminal_style.py`: the `colour_rgb` cases for both backgrounds (5 + 12), the view background picking the set (+2); the red expected in the format and widget tests is VS Code's (205, 49, 49), the same on both. +- **Result / numbers**: 2371 tests in 130 files. The terminal and SSH tests (96) pass. `ruff check` clean. +- **Docs**: `architecture_explore.md` (utils table, test count). +- **Files**: `pybreeze/utils/terminal_style.py`, `pybreeze/pybreeze_ui/terminal_view.py`, `test/test_utils/test_terminal_style.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-294 · 2026-09-24 · The terminal font holds under the IDE's theme · #fix #ssh #ui + +- **What**: U-20260924-290 gave the SSH terminal and the run window a fixed-pitch font with `setFont`. The IDE applies a qt_material theme (`ui_style`, `dark_amber.xml` by default), whose style sheet names a font for every widget (`* { font-family: Roboto }`), and a style sheet's font overrides `setFont`: in the themed IDE both views were still Roboto, proportional. The tests ran without a theme, so they passed. Found rendering the views with `apply_stylesheet(app, "dark_amber.xml")`. +- **Fix**: `use_terminal_font` also sets the family in the view's own style sheet, which outranks the application's; the theme's size (in pixels) stays. A size of -1 (a sheet gave pixels) is no longer passed to `setPointSizeF`. +- **Check**: with `dark_amber.xml` both views are Courier New, fixed pitch, at the theme's 13 px; `terminal_size` counts 106 x 18 for a 900 x 520 SSH tab; a screenshot shows `ls -l` columns aligned. Without a theme: Courier New at 9 pt, as before. +- **Tests**: `test_terminal_font.py` +1: a parent's `* { font-family: "Arial" }` does not take the font back (it came back Arial with `setFont` alone). +- **Result / numbers**: 2372 tests in 130 files. The terminal, run-window and SSH tests (103) pass. `ruff check` clean. +- **Docs**: `architecture_explore.md` (test count). +- **Files**: `pybreeze/pybreeze_ui/terminal_view.py`, `test/test_utils/test_terminal_font.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-295 · 2026-09-24 · Automation keywords readable on a light theme · #fix #ui #jeditor + +- **What**: `syntax_extend_package` registered the automation keywords with fixed colours: pure yellow (255, 255, 0) in `.json` files, orange (255, 153, 0) in TestPioneer `.yml`/`.yaml`. JEditor has a light set of its own colours for the light qt_material styles (`je_editor/utils/theme/theme_colors.py`), but these two stayed fixed, and yellow text on a light editor background has almost no contrast. Found looking for colours the theme cannot reach after U-20260924-294. +- **Fix**: the keywords are registered with JEditor theme colour keys instead: `JSON_KEYWORD_COLOUR = "warning_output_color"` (dark set (204, 204, 0), light set (150, 120, 0)) and `YAML_KEYWORD_COLOUR = "diff_modified_marker_color"` ((255, 167, 38) / (230, 126, 0)). JEditor's highlighter looks a string colour up in `actually_color_dict` each time it is built (je-editor 1.0.27, the lowest PyBreeze accepts; checked in the published wheel), and the Style menu rebuilds the highlighters, so a theme change reaches them. +- **Tests**: `test_syntax_extend.py` +2 (a `.json` and a `.yaml` keyword take the theme's colour on dark_amber and on light_blue, and the two differ; both failed on the fixed colours), `test_jeditor_contract.py` +1 (the highlighter takes a key, and both keys are in the dark and the light set). +- **Result / numbers**: 2375 tests in 130 files. Full suite at d261735: 2356 passed, 15 skipped; both startup tests exit 0. `ruff check` clean. +- **Docs**: `architecture.md` §6 (the reliance on JEditor), `architecture_explore.md` (§11, test count). +- **Files**: `pybreeze/pybreeze_ui/syntax/syntax_extend.py`, `test/test_utils/test_syntax_extend.py`, `test/test_utils/test_jeditor_contract.py`, `architecture.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-296 · 2026-09-24 · The diff tool shows its diff in colour · #feature #ui + +- **What**: the Text Diff tab printed its unified diff in one colour; added and removed lines could be told apart only by their first character. Seen on the dark_amber contact sheet of all 20 tool tabs (the rest read fine under the theme). +- **Change**: `diff_gui.UnifiedDiffHighlighter` (a `QSyntaxHighlighter` on the output) colours a line by its start: `@@` hunks `syntax_keyword_color`, `+` `diff_added_marker_color`, `-` `diff_removed_marker_color`, the `\ No newline at end of file` note `blame_annotation_color`, all JEditor theme colours with a dark and a light set (`actually_color_dict`, already used by the run window). `diff_line_colour(line, number)` decides; the `--- expected` / `+++ actual` header is only the first two lines, since a removed `--x` also starts with `---`. Copy and Save still take the plain text. Highlighting an 18,000-line diff adds 0.14 s to showing it. +- **Check**: rendered on dark_amber and light_blue: readable on both. +- **Tests**: `test_diff_gui.py` +11 (each kind of line, including `---x` and `+++x` past the header; the widget shows `-b` and `+c` in the removed and added colours and context in none), `test_jeditor_contract.py` (the four colour keys are in `actually_color_dict`; `diff_gui.py` listed as a user). +- **Result / numbers**: 2386 tests in 130 files. The diff and contract tests (57) pass. `ruff check` clean. +- **Docs**: the three READMEs' Text Diff section and its screenshot `images/tool_diff.png` (same sample and size, now in colour), `architecture.md` §6 (`actually_color_dict` users), `architecture_explore.md` (diff tool, test count). +- **Files**: `pybreeze/pybreeze_ui/tools_gui/diff_gui.py`, `test/test_utils/test_diff_gui.py`, `test/test_utils/test_jeditor_contract.py`, `images/tool_diff.png`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-297 · 2026-09-24 · README screenshots of the SSH tab, the run window and the diagram editor redone · #docs + +- **What**: the README screenshots date from 2026-08-03 (867acab), and 90 commits have touched the tool tabs, the run window and the diagram editor since. Checked each against a render of the current widget under dark_amber. Stale: `ssh_client.png` (no Browse… for the key file, no Interrupt), `run_output_window.png` (no Stop button, the proportional font, and the generated test's indentation was not visible) and `diagram_editor.png` (no Save As in the toolbar). `tool_diff.png` was redone with U-20260924-296. The tool tabs whose layout did not change (response inspector, header analyzer and the rest) and the Tools menu still match. +- **Change**: the three redone at their old sizes (1180 x 640, 900 x 620, 1180 x 720) with the same content: the SSH tab disconnected; a real run of a script that turns a curl command into a pytest test, through `FileRunnerProcess`; the same diagram, imported from Mermaid and fitted (67 %). Rendered at a scale of 1 (`QT_ENABLE_HIGHDPI_SCALING=0`; the machine is at 125 %) and with Qt's FreeType engine (`-platform windows:fontengine=freetype`), whose greyscale antialiasing matches the old images: ClearType's colour fringes made the grey edge labels look orange. +- **Files**: `images/ssh_client.png`, `images/run_output_window.png`, `images/diagram_editor.png`, `images/tool_diff.png` +- **Open items**: none. + +## U-20260924-298 · 2026-09-24 · Arrow labels in the README's tool screenshots · #docs + +- **What**: in `tool_query_json.png`, `tool_url_builder.png` and the lower half of `tools_montage_b.png`, the conversion buttons read "QUERY ▯ JSON", "URL ▯ JSON": the machine that took them in August had no glyph for the arrow (`Query → JSON` in the word dict). The IDE draws it. Found while checking the README screenshots after U-20260924-297. +- **Change**: the two tabs rendered again with the same input (`status=open&limit=25&tag=api&tag=smoke&q=order%20PB-1001`; `https://ada:secret@api.example.com:8443/v2/orders?status=open&limit=25#top`) and Query → JSON / URL → JSON pressed, at 760 x 460, and pasted over the same two panels of the montage (the timestamp and hash panels above are unchanged). A top-level window came out 762 x 468 whatever size it was given, so each was rendered inside a container widget. +- **Checked, unchanged**: `tool_json_format.png` and its panel in `tools_montage_a.png`: Format still sorts the keys, so they show what the tool shows. No other screenshot has an arrow label (the HAR tab's hint with one is hidden once a file is open). +- **Files**: `images/tool_query_json.png`, `images/tool_url_builder.png`, `images/tools_montage_b.png` +- **Open items**: none. + +## U-20260924-299 · 2026-09-24 · clear and reset wipe the SSH terminal · #fix #ssh + +- **What**: `clear` in the SSH terminal did nothing: it sends `ESC [ H ESC [ 2J ESC [ 3J`, which the terminal removed like any other escape, so the old output stayed and the new prompt went under it. `reset` (`ESC c`) did the same. +- **Fix**: `terminal_text.split_at_screen_clear(text)` finds the last erase of the whole screen (`ESC [ 2J`), of the screen and the scrollback (`ESC [ 3J`) or full reset (`ESC c`) and returns what comes before it, the sequence and what comes after. Erasing below the cursor alone (`ESC [ J`, `0J`, `1J`) is not a clear: shells send it to redraw a prompt. `TerminalDecoder.feed()` now returns a `TerminalOutput` (`clears_screen`, `pieces`): with a clear, only what follows is shown, the colours set before it carry on, and a full reset drops them. `SSHCommandWidget._on_data` clears the view first and forgets a rewind still to be applied. A clear cut between two reads is held like any unfinished escape. +- **Tests**: new `test_ssh_screen_clear.py` (14: the three sequences, the last one counting; erasing less than the screen is not a clear; the decoder's output with a clear, a clear cut between reads, the colour carried over or dropped by a reset; the view wiped, a pending rewind forgotten). The first widget test failed on the old code (the second passes there too: the old rewind happened to overwrite the line). The decoder tests in `test_ssh_terminal_output.py` read the text from `TerminalOutput.pieces`. The ad-hoc Hypothesis run of U-20260924-292, with the clear sequences added to its inputs, found nothing. +- **Result / numbers**: 2400 tests in 131 files. Full suite at 8e17ac1: 2371 passed, 15 skipped; both startup tests exit 0; gates clean. `ruff check` clean. +- **Docs**: the three READMEs' SSH paragraph, `architecture_explore.md` (SSH row, `terminal_text.py`, test count). +- **Files**: `pybreeze/utils/terminal_text.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ssh_screen_clear.py`, `test/test_utils/test_ssh_terminal_output.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-300 · 2026-09-24 · Correction to U-20260924-295: the keyword colours are not shown yet · #docs #jeditor + +- **What**: U-20260924-295 said the automation keywords showed in fixed yellow that could not be read on a light theme. They are not shown at all today: JEditor has built-in rules for `.json`, `.yml` and `.yaml`, so the editor gets its `GenericHighlighter`, which does not read the registered words (progress #103, blocked on JEditor). U-295's test built JEditor's `PythonHighlighter` directly, which is the one that reads them, so it passed without showing what the IDE does. +- **What stands**: the registered colours are theme colour keys, the form JEditor's `GenericHighlighter` uses for its own rules (`_format_for(colour_key)`, `je_editor/pyside_ui/code/syntax/generic_syntax.py:48` in `D:\Codes\JEDITOR` at 4076fab), so when #103 is fixed the keywords follow the theme without a change here. #103 now says the words' colour should be taken as a key. +- **Files**: `progress.md` +- **Open items**: #103 (unchanged, blocked). + +## U-20260924-301 · 2026-09-24 · Build the automation Install menu from a table · #refactor #menu + +- **What** (refactor, no change in behaviour): `build_automation_install_menu` repeated the same six lines for each PyPI package, and kept every action on the main window under an attribute nothing read. Its entries now come from `PYPI_PACKAGES` (label key, package), each action parented to the submenu so it is kept alive, followed by prthinker's. The six one-line `install_` functions are gone; nothing called them. +- **Tests**: new `TestTheAutomationInstallMenu` in `test_install_menu.py` builds the menu and triggers each entry: labels in order and the package each one installs, prthinker last. Written first and passing on the old code. +- **Result / numbers**: 2401 tests in 131 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py`, `test/test_utils/test_install_menu.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-302 · 2026-09-24 · Install TestPioneer from the Install menu · #feature #menu + +- **What**: the Automation menu runs TestPioneer, but Install ▸ Automation had an entry for every other automation module and none for it, so it could not be installed or upgraded from the IDE. An environment with test_pioneer older than 0.1.34 reads a YAML file in the locale's encoding and fails on a non-ASCII character on a cp950 Windows (progress #106); the entry is a way out of that without a terminal. +- **Change**: `Install TestPioneer` (`安裝 TestPioneer`) after MailThunder in `PYPI_PACKAGES`, running `pip install -U test_pioneer` in a run window like the others. +- **Tests**: `TestTheAutomationInstallMenu` expects the entry and its package, after MailThunder, prthinker still last. +- **Result / numbers**: 2401 tests in 131 files. The full suite at c8741d6 (before this and U-20260924-301): 2385 passed, 15 skipped; both startup tests exit 0; gates clean. `ruff check` clean. +- **Docs**: `images/menu_install.png` redone from the real submenu under dark_amber (it was 259 × 312, now 220 × 356 with the extra entry: the new one is rendered, not edited); the three READMEs' TestPioneer row; `architecture_explore.md` §5.5; progress #106 names the entry. +- **Files**: `pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_install_menu.py`, `images/menu_install.png`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md`, `progress.md` +- **Open items**: #106 (unchanged: whether the dependency should require 0.1.34). + +## U-20260924-303 · 2026-09-24 · Make the automation menus' HELP builder public · #refactor #menu + +- **What** (refactor, no change in behaviour): `automation_menu_factory._add_help_menu` is now `add_help_menu`, with a docstring, so a menu not built by `build_automation_menu` (TestPioneer's) can add the same HELP submenu instead of copying it. +- **Tests**: `test_automation_menu_factory.py` (4) passes unchanged. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py` +- **Open items**: none. + +## U-20260924-304 · 2026-09-24 · A HELP submenu for TestPioneer · #feature #menu + +- **What**: every automation menu has a HELP submenu opening the module's documentation and GitHub page in a browser tab, except TestPioneer's, so the README's "each module's docs and GitHub page" was not so for it. +- **Change**: `set_test_pioneer_menu` adds a HELP submenu through `automation_menu_factory.add_help_menu` (U-20260924-303) with one entry, `Open TestPioneer GitHub` (`開啟 TestPioneer GitHub`). No documentation entry: TestPioneer's README links `testpioneer.readthedocs.io`, and every address under it answers 404 today (`/`, `/en/latest/`, `/en/stable/`, the API's project record), so its GitHub README is its manual. That broken link is TestPioneer's to fix (its README and `.readthedocs.yaml`); recorded here, not fixed. The other help links in `pybreeze/` and all the links in the three READMEs answer 200. +- **Tests**: `test_its_help_opens_the_github_page` in `test_testpioneer_menu.py` builds the menu and triggers the HELP entry; it failed before the change (no submenu). +- **Result / numbers**: 2402 tests in 131 files. `ruff check` clean. +- **Docs**: the three READMEs' Integrated Documentation line; `architecture_explore.md` (`test_pioneer_menu/`, test count). +- **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_testpioneer_menu.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: TestPioneer's documentation site (its repository). + +## U-20260924-305 · 2026-09-24 · Taiwan terms for Help and template in the Traditional Chinese IDE · #fix #i18n + +- **What**: the Traditional Chinese IDE called every automation menu's HELP submenu 幫助, and three entries said 模板 where the rest of the dictionary says 範本 (the TestPioneer template entry sat above messages about the same file calling it 範本). +- **Fix**: `help_label` is 說明; `test_pioneer_create_template_label`, `prompt_editor_switch_over_edits` and `prompt_editor_create_over_edits` say 範本. +- **Tests**: `test_traditional_chinese_uses_taiwan_terms` also looks for 幫助 → 說明 and 模板 → 範本; it failed on those four entries before the change. +- **Result / numbers**: 2402 tests in 131 files (one test widened, none added). `test_language_parity.py` passes. +- **Files**: `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py` +- **Open items**: #108 (JEditor's own entries, unchanged). + +## U-20260924-306 · 2026-09-24 · Record the gitpython floor question as progress #109 · #docs #deps #security + +- **What**: je-editor 1.0.28 (on PyPI today) changes nothing but its metadata: its wheel's files are identical to 1.0.27's, and it requires `gitpython>=3.1.59`, which fixes CVE-2026-78676 (a config write turning a quoted value into a live `core.hooksPath`, remote code execution) and CVE-2026-78679 (`TagReference.create` reading local files). 3.1.59 still has CVE-2026-87818 (`--no-index` through the diff API reads any path), fixed in 3.1.60. PyBreeze requires `je-editor>=1.0.27`, so an existing environment keeps whatever gitpython it has; the IDE's git features (JEditor's `git_client`, file baseline and blame) use it. This repo's `.venv` has 3.1.62. +- **Recorded**: progress #109 [DECIDE]: raise the floor to `je-editor>=1.0.28`, declare `gitpython>=3.1.60` here, or both. JEditor's own floor of 3.1.59 (`D:\Codes\JEDITOR\pyproject.toml:18`) is JEditor's to raise; not changed from here. +- **Also checked**: je-mail-thunder 0.0.29 (also today) still gives `SMTPWrapper` no timeout, so #102 stays blocked; je-editor 1.0.28 does not change the generic highlighter or the Traditional Chinese words, so #103 and #108 stay blocked. +- **Files**: `progress.md` +- **Open items**: #109. + +## U-20260924-307 · 2026-09-24 · Redo the README screenshots of the AI tabs · #docs #readme + +- **What**: rendered each AI tab bare at its README image size under dark_amber and compared. `ai_code_review.png` showed Accept and Reject enabled before any response; they are now disabled until one arrives. `cot_prompt_editor.png` and `skill_prompt_editor.png` lacked the line naming the prompt folder ("Prompt files (these override the built-in prompts): ..."). `skills_send.png` matched in content but sat 2 px off the current layout. +- **Done**: all four redone at their old sizes (1080 × 640, 1000 × 620, 1000 × 620, 1000 × 700) with the same content: the same URLs and the same code in the review client, the built-in `first_code_review.md` and `code_review_skill.md` in the editors. The editors ran with `USERPROFILE` set to a throwaway `D:\Users\ada` holding the built-in templates as Create File writes them, so the folder line names no real account; the folder was removed afterwards. The other tool images (cURL, HAR, hash, timestamp, regex, HTTP status, JWT, JSON format, header analyzer, response inspector) match the current tabs and are unchanged. +- **Files**: `images/ai_code_review.png`, `images/cot_prompt_editor.png`, `images/skill_prompt_editor.png`, `images/skills_send.png` +- **Open items**: none. + +## U-20260924-308 · 2026-09-24 · The cURL import button names the chosen target · #fix #tools #readme + +- **What**: the cURL import tab's button said "Convert to Python requests" ("轉換為 Python requests") whatever was chosen under Generate for, so with pytest, APITestka or LoadDensity chosen it named the wrong output; both README screenshots showed it that way. +- **Fix**: `curl_import_convert_button` is `Generate {target}` (`產生 {target}`), filled with the chosen target's label when the tab opens and whenever the choice changes (`CurlImportGUI._name_convert_button`). +- **Tests**: `test_the_button_names_the_chosen_target` in `test_curl_import_gui.py` picks each target and expects its label on the button; it failed on the second target before the change. +- **Result / numbers**: 2403 tests in 131 files. `test_curl_import_gui.py`, `test_language_parity.py` and `test_tools_menu_docks.py` pass (60). `ruff check` clean. +- **Docs**: `images/tool_curl_import.png` and `images/tool_curl_import_action.png` redone at 900 × 720 with the same command and targets (pytest, APITestka JSON action); `architecture_explore.md` test count. The README text does not name the button. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_curl_import_gui.py`, `images/tool_curl_import.png`, `images/tool_curl_import_action.png`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-309 · 2026-09-24 · Keep library debug records out of Code Result · #fix #editor #logging + +- **What**: opening a file in the IDE put gitpython's debug lines into the editor's Code Result panel, in red as if something had failed: `sys.platform='win32', git_executable='git'` twice, and in a git project also `Popen(['git', 'cat-file', '--batch-check'], cwd=...)` and `Popen(['git', 'cat-file', '--batch'], ...)`, each twice. Seen while redoing the README's main window. Two things meet: je_api_testka, je_auto_control, je_web_runner and test_pioneer each set the root logger to DEBUG as they are imported, and `EditorMain.__init__` hooks one `RedirectStdErr` handler onto every logger that exists then (gitpython, urllib3, openai and langchain among them) and shows what it receives in Code Result. PyBreeze's own logger ("Pybreeze", DEBUG) is one of them: JEditor skips a list of names that still says "AutomationIDE". +- **Fix**: new `pybreeze_ui/code_result_logs.py`: `show_only_warnings_in_code_result()` sets the level of every `RedirectStdErr` on a logger to WARNING. `PyBreezeMainWindow.__init__` calls it right after `super().__init__`. Loggers keep their own levels, so the log files and other handlers still get every record; warnings and errors still reach the panel. +- **Tests**: new `test_code_result_logs.py` starts the real window in a child (`started_window.py`) and logs a library debug, a urllib3 debug, a PyBreeze info, a library warning and a PyBreeze error: the first three stayed out of the panel's queue only after the change. `test_jeditor_contract.py` pins `RedirectStdErr` at its module path. Measured in the IDE on a demo git project with a file opened: 6 lines in Code Result before, none after. +- **Result / numbers**: 2405 tests in 132 files. `ruff check` clean. +- **Docs**: `architecture.md` §6 (the new internal JEditor name), `CLAUDE.md` tree, `architecture_explore.md` (startup sequence, test count). +- **Files**: `pybreeze/pybreeze_ui/code_result_logs.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/test_code_result_logs.py`, `test/test_utils/test_jeditor_contract.py`, `architecture.md`, `CLAUDE.md`, `architecture_explore.md` +- **Open items**: upstream, recorded here and not changed from this repository: the four packages setting the root logger's level on import; JEditor's skip list naming "AutomationIDE", and its handler sitting on a logger and on its parent, so a warning from `git.util` shows twice. + +## U-20260924-310 · 2026-09-24 · Redo the README's main window and correct its caption · #docs #readme + +- **What**: `images/main_window.png` (2026-08-03) no longer matched the IDE, and its caption was wrong. It showed JEditor's Help menu, which `PyBreezeMainWindow` removes; the automation keywords of an APITestka action file in colour, which the IDE does not do today (#103: `.json` gets JEditor's generic highlighter); and the project tree of this repository, including a folder named after a development tool. The caption, in all three READMEs, said the keywords are highlighted. +- **Done**: redone at 1600 × 950 under dark_amber from the real `PyBreezeMainWindow`, started in a throwaway git project (`D:\demo\orders_api`, branch `main`) with `USERPROFILE` at a throwaway home and the same action file opened through `open_an_file`; both folders removed afterwards. The caption now says an APITestka action file is open. The tree also shows the four `.log` files the automation packages write into the working directory as they are imported, as a user sees them. +- **Found on the way**: the gitpython lines in Code Result (fixed, U-20260924-309), and a tab opened from the tree showing "*" before anything was typed, recorded as #110 (JEditor). +- **Files**: `images/main_window.png`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `progress.md` +- **Open items**: #103, #110. + +## U-20260924-311 · 2026-09-24 · Remove the images nothing shows · #docs #cleanup + +- **What**: nine files in `images/` were referenced by no file in the repository (outside `docs/updates/`) and by no Markdown file in the workspace: `main_gui.png` (a 1918 × 1030 main window from 2026-04, older than `main_window.png`) and the single-tool pictures `tool_hash.png`, `tool_http_status.png`, `tool_json_format.png`, `tool_jwt_decoder.png`, `tool_query_json.png`, `tool_regex.png`, `tool_timestamp.png` and `tool_url_builder.png`, whose content the README shows through `tools_montage_a.png` and `tools_montage_b.png`. Two of them were redone for nothing in U-20260924-298. +- **Done**: deleted. The README screenshots left are exactly the ones the three READMEs show (20 files). +- **Files**: the nine images +- **Open items**: none. + +## U-20260924-312 · 2026-09-24 · Move the fixed-pitch font helper out of terminal_view · #refactor #ui + +- **What** (refactor, no change in behaviour): `terminal_view.use_terminal_font(view)` is now `fixed_pitch.use_fixed_pitch_font(view)` in the new `pybreeze_ui/fixed_pitch.py`, typed for any `QWidget`. The run window and the SSH terminal call it as before; the tool tabs that show code are to use it next, and a terminal module is not where they should find it. +- **Tests**: `test_terminal_font.py` calls the new name; the 248 tests matching terminal, ssh, code window and run window pass. `ruff check` clean. +- **Docs**: `CLAUDE.md` tree, `architecture_explore.md` (run window, SSH terminal). +- **Files**: `pybreeze/pybreeze_ui/fixed_pitch.py`, `pybreeze/pybreeze_ui/terminal_view.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `test/test_utils/test_terminal_font.py`, `CLAUDE.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-313 · 2026-09-24 · Consolas before Courier New for terminal output · #fix #ui + +- **What**: the run window and the SSH terminal took the system's fixed-pitch font, which Qt gives as Courier New on Windows: thin strokes on the dark theme, and at the size the tool tabs use it lost the underscore (`json_body` read as `json body` in a render of the HAR tab's code). +- **Fix**: `fixed_pitch.fixed_pitch_font()` takes the first installed family of `PREFERRED_FAMILIES` (`Consolas`, what VS Code shows code in on Windows by default: `Consolas, 'Courier New', monospace`) and falls back to the system's fixed-pitch font (Menlo on macOS, DejaVu Sans Mono and the like on Linux); `use_fixed_pitch_font()` uses it. +- **Tests**: `test_terminal_font.py` gains `TestTheFamilyChosen` (Consolas when installed, the system font otherwise; the font database is replaced, since the offscreen platform lists no families) and compares the views with `fixed_pitch_font()`. +- **Result / numbers**: `test_terminal_font.py` passes (8). `ruff check` clean. +- **Docs**: `images/run_output_window.png` redone the same way as in U-20260924-297 (a real run of the same demo script, 900 × 620), now in Consolas; `ssh_client.png` shows no terminal text and is unchanged; `architecture_explore.md` (run window, SSH terminal). +- **Files**: `pybreeze/pybreeze_ui/fixed_pitch.py`, `test/test_utils/test_terminal_font.py`, `images/run_output_window.png`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-314 · 2026-09-24 · Code in the tool tabs in the fixed-pitch font · #feature #tools #readme + +- **What**: the text boxes that hold code showed it in the interface's proportional font, so indentation and the columns of a unified diff did not line up: Text Diff's two inputs and its output, the cURL import's command and generated code, the HAR import's generated code, JSON Format's input and result, and the code to send in AI Code Review. +- **Change**: each of those nine boxes calls `fixed_pitch.use_fixed_pitch_font()` as it is built (Consolas where installed, U-20260924-313). Boxes that hold prose or tables (HTTP status, header analysis, response analysis, hashes, timestamps) keep the interface font. +- **Tests**: new `test_code_boxes_fixed_pitch.py` (9, one per box: the font and the view's own style sheet name the fixed-pitch family); all failed before the change. The cURL, diff, HAR and JSON format widget tests pass (92 with the new file). +- **Result / numbers**: 2416 tests in 133 files (the count after U-20260924-313's two font tests, which that entry did not add to the map). `ruff check` clean. +- **Docs**: redone under dark_amber at their sizes with the same content: `tool_curl_import.png`, `tool_curl_import_action.png`, `tool_har_import.png` (a HAR of the same four requests), `tool_diff.png`, `ai_code_review.png`, and the JSON Format panel of `tools_montage_a.png`; `architecture_explore.md` (test count). +- **Files**: `pybreeze/pybreeze_ui/tools_gui/diff_gui.py`, `pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py`, `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `pybreeze/pybreeze_ui/tools_gui/json_format_gui.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/test_code_boxes_fixed_pitch.py`, the six images, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-315 · 2026-09-24 · Tell apart labels that read the same · #fix #i18n + +- **What**: rendering every tab in Traditional Chinese showed the diagram toolbar with two controls side by side both reading 對齊: the Align menu and the Snap box. Listing every Traditional Chinese string shared by two different English ones found two more slips, in English: the prompt editors' docks were titled "CoT PromptEditor" and "Skill PromptEditor", their tabs "CoT Prompt Editor" and "Skill Prompt Editor". +- **Fix**: `diagram_editor_action_snap` is 貼齊 (the Traditional Chinese README said 吸附 for it, and now says 貼齊 like the toolbar); the two dock titles read like their tabs. The other shared strings are one control each in different places (連線 for the connection tool and the connection property group, 外掛 for the menu and a column). +- **Tests**: `TestLabelsAreTold` in `test_language_parity.py`: the Snap box and the Align menu differ in both languages, and each prompt editor's dock title equals its tab label. Both failed before the change. +- **Result / numbers**: 2418 tests in 133 files. `test_language_parity.py` passes (13). +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `README/README_zh-TW.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-316 · 2026-09-24 · Reword the Traditional Chinese Open in editor tab button · #fix #i18n + +- **What**: every tool tab's button that opens the output in an editor tab read 開成編輯器分頁 in Traditional Chinese, which does not read as a sentence; the buttons beside it say 在 URL 解析/組建器開啟網址, 在分析器開啟標頭. +- **Fix**: `output_actions_open_editor` is 在編輯器分頁開啟, in the same pattern. +- **Tests**: `test_language_parity.py` passes. A wording change; no behaviour to test. +- **Files**: `pybreeze/extend_multi_language/extend_traditional_chinese.py` +- **Open items**: none. + +## U-20260924-317 · 2026-09-24 · Say what the CoT review's step selector is · #fix #ai + +- **What**: the CoT Code Review tab's step selector (which answer is shown) stood alone and empty halfway down the left of the panel before a run, with nothing to say what it was. The box the code goes into showed it in the proportional font, and its Traditional Chinese placeholder said the prompt would be shown there (這裡會顯示要傳送的 Prompt 內容) while the English one asked for the code. +- **Change**: the selector has a "Step:" (步驟:) label above it, sits at the top of its column, and reads "No answers yet" (還沒有回覆) until the first answer. The code box uses `use_fixed_pitch_font()` like the other code boxes (U-20260924-314); its placeholder is "Paste the code to review here" (在這裡貼上要審查的程式碼). +- **Tests**: `test_code_boxes_fixed_pitch.py` covers the CoT code box and `test_the_cot_step_selector_says_what_it_is_before_any_answer` (placeholder and label). The CoT lifecycle and session tests and `test_language_parity.py` pass (42 with them). +- **Result / numbers**: 2420 tests in 133 files. `ruff check` clean. Rendered under dark_amber to check the layout. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_code_boxes_fixed_pitch.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-318 · 2026-09-24 · Say that the SSH terminal is line by line · #docs #ssh #readme + +- **What**: the READMEs listed what the SSH terminal does (colours, the pty size, `clear` and `reset`, Interrupt) but not what it does not: it shows output line by line and drops cursor movement (`strip_terminal_controls` in `utils/terminal_text.py`), so a program that draws on the whole screen by moving the cursor, such as `vim` or `htop`, comes out garbled. +- **Change**: the SSH Client paragraph of `README.md`, `README/README_zh-TW.md` and `README/README_zh-CN.md` says so. +- **Tests**: none; a documentation change. +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md` +- **Open items**: none. + +## U-20260924-319 · 2026-09-24 · Drop the ReEdgeGPT words no menu asks for · #cleanup #i18n + +- **What**: both language dictionaries still defined the five `tools_menu_re_edge_gpt_*` words (ReEdgeGPT, Open ReEdgeGPT Doc, ...) of the Bing GPT menu that `dd6fcf9` removed in March 2025; nothing in PyBreeze or JEditor reads them. +- **Change**: the ten entries and the `# Tools Menu` comment left heading nothing are gone from `extend_english.py` and `extend_traditional_chinese.py`. +- **Tests**: new `test_language_keys_used.py`: every key of PyBreeze's English dictionary appears as a string literal in the package outside the dictionaries, or starts with a prefix a key is built from at run time (`error_text_`, `run_window_`, the SSH file viewer's, the header analyzer's) or that JEditor's plugin browser reads (`plugin_browser_`). It failed on the five keys before the change. `test_language_parity.py` passes (13). +- **Result / numbers**: 2421 tests in 134 files. The full suite at `5a5ec56` passed (2405 passed, 15 skipped). `ruff check` clean. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_keys_used.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-320 · 2026-09-24 · Help, not HELP, in the automation menus · #fix #i18n + +- **What**: every automation menu (APITestka, AutoControl, ..., TestPioneer, prthinker) has a submenu of documentation links titled "HELP" in English, in capitals, beside JEditor's own "Help" menu and the title-case entries around it. Traditional Chinese already read 說明. +- **Fix**: `help_label` is "Help". +- **Tests**: `test_no_label_is_in_capitals` in `test_language_parity.py` (`TestLabelsAreTold`): no English `*_label` has a word of four or more capitals other than HTTP, JSON, MIME and SFTP. It failed on `help_label` before the change. The language parity, install menu and automation menu factory tests pass (25). +- **Result / numbers**: 2422 tests in 134 files. `ruff check` clean. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/2026-09-c.md b/docs/updates/2026-09-c.md new file mode 100644 index 00000000..178ca052 --- /dev/null +++ b/docs/updates/2026-09-c.md @@ -0,0 +1,936 @@ +# 2026-09 update log, continued again + +Continues [2026-09-b.md](2026-09-b.md), which passed the batch size limit. Index and query commands: [README.md](README.md). New entries go at the end. + +--- + +## U-20260925-01 · 2026-09-25 · Start the 2026-09-c batch · #docs + +- **What**: `2026-09-b.md` had reached 1088 lines, past the batch size limit of about 800 (Batch rules, item 2), and the batch table counted 118 entries in it where it holds 120, each with its index row. +- **Change**: new entries go into `2026-09-c.md`, listed in the batch table; the table's count for `2026-09-b.md` is 120. +- **Files**: `docs/updates/2026-09-c.md`, `docs/updates/README.md` +- **Open items**: none. + +## U-20260925-02 · 2026-09-25 · Full-width punctuation in the Traditional Chinese strings · #fix #i18n + +- **What**: 42 Traditional Chinese labels end in a full-width colon (:), but twelve strings used half-width punctuation next to Chinese text: the Skill Send and CoT Code Review labels (`LLM API URL:`, `Prompt:`, `回傳結果:`, `選擇 Prompt 範本:`, `API URL:`), their error lines (`錯誤: `, `發生例外: `), the prompt editors' missing-file note `(檔案 {filename} 不存在)`, the SSH login placeholders (`主機 (例如: 192.168.0.10)`, `私鑰路徑 (.pem/.ppk)`) and the run-with suffix warning. +- **Fix**: they use : and (). File-dialog filters keep `(*.txt)`: Qt reads the patterns between those parentheses. +- **Tests**: `test_traditional_chinese_uses_full_width_punctuation` in `test_language_parity.py`: no half-width colon or opening parenthesis after a Chinese character, no colon at the end or before a `{placeholder}`, and no half-width parentheses around Chinese text, except a filter's `(*`. Run against the previous dictionary it names the twelve keys; `{host}:{port}` passes. `test_language_parity.py` passes (15). +- **Result / numbers**: 2423 tests in 134 files. `ruff check` clean. Coverage at `c9a1ecd`, measured by the full suite (2406 passed, 15 skipped): 90% of statements. +- **Files**: `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-03 · 2026-09-25 · Free the project and SFTP trees' right-click menus · #fix #ui #ssh + +- **What**: the project tree's and the SFTP tree's right-click menus were built with a parent (`QMenu(tree_view)`, `QMenu(self)`) on every right-click and never deleted, so each click left one more menu, with its actions, as a child of the tree until the tree closed. +- **Fix**: `_show_context_menu` (`editor_main/file_tree_context_menu.py`) and `SSHFileTreeManager.on_context_menu` (`connect_gui/ssh/ssh_file_viewer_widget.py`) call `menu.deleteLater()` once `exec()` returns. The rule is in `CLAUDE.md` › Conventions, beside the one on keeping a menu's actions alive. The diagram scene's menu has no parent and goes with its last reference; the diagram toolbar's Align menu is built once. +- **Tests**: new `test_context_menus_are_freed.py` (2): three dismissed menus on each tree leave no `QMenu` child once deferred deletes run; both failed before the fix (3 left). Setting `exec` on PySide's `QMenu` class did not reach the call, so the test replaces the modules' `QMenu` with a subclass. The file tree and SFTP tree action tests pass (87 with the new file). +- **Result / numbers**: 2425 tests in 135 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_context_menus_are_freed.py`, `CLAUDE.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-04 · 2026-09-25 · The project tree's menu opens where it was asked for · #fix #ui + +- **What**: the project tree's right-click menu opened at the mouse pointer (`QCursor.pos()`), not at the position Qt passes with `customContextMenuRequested`. With the mouse that is the same place; with the keyboard's Menu key (or Shift+F10) Qt asks for the menu at the current item, and it appeared wherever the pointer happened to be, possibly on another screen. The SFTP tree already maps the position it is given. +- **Fix**: `_show_context_menu` (`editor_main/file_tree_context_menu.py`) opens the menu at `tree_view.viewport().mapToGlobal(pos)`; the `QCursor` import went with it. +- **Tests**: `test_the_project_tree_menu_opens_where_it_was_asked_for` in `test_context_menus_are_freed.py` records where the menu is shown; it failed before the fix (the pointer's (10, 10) against the item's (8, 30)). The file tree tests pass (62 with it). +- **Result / numbers**: 2426 tests in 135 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_context_menus_are_freed.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-05 · 2026-09-25 · Free the diagram editor's Mermaid import dialog · #fix #diagram + +- **What**: Import Mermaid built its paste dialog as a child of the diagram editor (`MermaidImportDialog(self)`) on every import and never deleted it, so each import, converted or cancelled, left the dialog and the text pasted into it in memory until the editor closed. The same kind of leak as the tree menus (U-20260925-03); the message boxes built elsewhere already set `WA_DeleteOnClose`. +- **Fix**: `_import_mermaid` (`diagram_editor/diagram_editor_widget.py`) reads the answer and the text, then deletes the dialog (`deleteLater()`). The `CLAUDE.md` › Conventions rule now covers a dialog built on each use as well as a context menu. +- **Tests**: new `TestMermaidImport` in `test_diagram_editor_widget.py` (4), the first tests of the import: the pasted flowchart becomes the diagram, a cancelled import leaves the canvas alone, and three imports, converted or cancelled, leave no dialog behind. The last two (one per answer) failed before the fix (3 left). The diagram editor widget tests pass (15). +- **Result / numbers**: 2430 tests in 135 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `CLAUDE.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-06 · 2026-09-25 · The Mermaid paste box in the fixed-pitch font · #fix #diagram #ui + +- **What**: the Import Mermaid dialog's paste box holds flowchart code, indented, but showed it in the interface's proportional font; the code boxes of the tool tabs use the fixed-pitch one (U-20260924-314). +- **Change**: `MermaidImportDialog` calls `use_fixed_pitch_font()` on its box. +- **Tests**: `test_code_boxes_fixed_pitch.py` covers the Mermaid box (the font and the box's own style sheet name the fixed-pitch family); it failed before the change. With the diagram editor widget tests, 27 pass. +- **Result / numbers**: 2431 tests in 135 files. `ruff check` clean. The README's diagram image shows the laid-out diagram, not the dialog, and is unchanged. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_code_boxes_fixed_pitch.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-07 · 2026-09-25 · SSH and SFTP log in with a PKCS#8 private key · #feature #ssh + +- **What**: a private key in PKCS#8 format (`-----BEGIN PRIVATE KEY-----`, or `-----BEGIN ENCRYPTED PRIVATE KEY-----` with a passphrase), which `openssl genpkey` and `ssh-keygen -m PKCS8` write, was refused as an unsupported key by the SSH terminal and the SFTP tree, whatever its type: paramiko's key classes read only OpenSSH's format and the traditional PEM one (`BEGIN RSA PRIVATE KEY`, `BEGIN EC PRIVATE KEY`). Measured with RSA, Ed25519 and ECDSA keys, plain and encrypted, on paramiko 4.0.0 (this repo's `.venv`) and 5.0.0: all six refused by both. +- **Change**: `load_private_key` (`connect_gui/ssh/ssh_key_loader.py`), when no key class takes the file and it starts with a PKCS#8 header, has `cryptography` read it (with the passphrase if the file is encrypted) and write it again in OpenSSH's format, in memory only, for paramiko's key classes. `unloadable_key_reason` tells a missing or wrong passphrase for an encrypted PKCS#8 file, which paramiko does not recognise as encrypted, from an unsupported key. A key type paramiko cannot use (DSA) is still unsupported. +- **Tests**: new `TestPkcs8Keys` in `test_ssh_security.py` (10): each of the three types, plain and encrypted, loads as the same key (its public key matches); a passphrase typed for a plain key is ignored, as paramiko does; a missing and a wrong passphrase are named; a DSA key, plain or encrypted, is unsupported. Eight of them failed against the previous loader; the two DSA ones pass either way. The SSH security, login form, session sharing and reentrancy tests pass (52). +- **Result / numbers**: 2441 tests in 135 files. `ruff check` clean; `C901` clean. +- **Docs**: the SSH Client paragraph of the three READMEs names the key formats; `architecture_explore.md` (`ssh_key_loader.py`, test count). +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py`, `test/test_utils/test_ssh_security.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Evidence**: [paramiko issue #1015](https://github.com/paramiko/paramiko/issues/1015) (PKCS#8 support, open). +- **Open items**: none. + +## U-20260925-08 · 2026-09-25 · Say what to do with a PuTTY key instead of offering it · #fix #ssh #i18n #readme + +- **What**: the SSH login form's key field read "Private key path (.pem/.ppk)", but neither paramiko nor cryptography reads a PuTTY `.ppk` key: one chosen there was refused as "Unsupported or invalid private key.", with nothing about converting it. +- **Fix**: `unloadable_key_reason` (`connect_gui/ssh/ssh_key_loader.py`) recognises a file starting `PuTTY-User-Key-File-` and gives `PUTTY_KEY` (`ssh_key_error_putty_key`): load it in PuTTYgen, Conversions > Export OpenSSH key, and pick the exported file (Traditional Chinese: 請在 PuTTYgen 載入它,選 Conversions > Export OpenSSH key 匯出…). The placeholder reads "Private key path (OpenSSH or PEM)" (私鑰路徑(OpenSSH 或 PEM)). The file-header check shared with the PKCS#8 path of U-20260925-07 is `_key_file_data`. +- **Tests**: new `TestPuttyKeys` in `test_ssh_security.py` (3): a `.ppk` file, with or without a passphrase typed, does not load and is reported as a PuTTY key; neither language's placeholder offers `.ppk`. `test_each_reason_has_a_message` covers the new reason. All four failed before the change. With the language parity and keys-used tests and the login form tests, 50 pass. +- **Result / numbers**: 2444 tests in 135 files. `ruff check` clean. The full suite at `d5c266a` passed (2416 passed, 15 skipped). +- **Docs**: the SSH Client paragraph of the three READMEs says a `.ppk` key is exported as OpenSSH first; `images/ssh_client.png` redone at its 1180×640 under dark_amber with the new placeholder; `architecture_explore.md` (`ssh_key_loader.py`, test count). +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_security.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `images/ssh_client.png`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-09 · 2026-09-25 · No private address in the SSH host placeholder · #fix #ssh #i18n + +- **What**: the SSH login form's host field suggested `Host (e.g., 192.168.0.10)` (主機(例如:192.168.0.10)), a hardcoded private address, against the project's rule of no hardcoded IPs or hostnames outside documented loopback; it was the only one in the package besides the CGNAT range `url_validation.py` checks against. +- **Fix**: the placeholder reads "Host name or IP address" (主機名稱或 IP 位址). +- **Tests**: `test_no_string_holds_an_ip_address` in `test_language_parity.py` (`TestLabelsAreTold`): no English or Traditional Chinese string holds a dotted IPv4 address other than loopback (the AI panels' placeholders name `127.0.0.1`). It failed on the two placeholders before the change. The language parity and SSH login form tests pass (23). +- **Result / numbers**: 2445 tests in 135 files. `ruff check` clean. +- **Docs**: `images/ssh_client.png` redone at 1180×640 under dark_amber with the new placeholder; `architecture_explore.md` (test count). +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `images/ssh_client.png`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-10 · 2026-09-25 · JWT, Query and URL tools in the fixed-pitch font · #fix #tools #ui + +- **What**: U-20260924-314 put the code boxes of the tool tabs in the fixed-pitch font but left out three tools whose boxes hold the same kind of text: the JWT decoder (a token in, its header and payload as indented JSON out), Query ↔ JSON and the URL parser / builder (a query string or URL in, JSON out, and back). Their JSON lost its indentation's alignment in the proportional font. +- **Change**: `JwtDecoderGUI`, `QueryJsonGUI` and `UrlBuilderGUI` call `use_fixed_pitch_font()` on their input and output boxes. The tools that show prose or a list (HTTP status, header and response analysis, hashes, timestamps, the regex matches) keep the interface font, as before. +- **Tests**: `test_code_boxes_fixed_pitch.py` covers the six boxes; all failed before the change. With the three tools' widget tests, 113 pass. +- **Result / numbers**: 2451 tests in 135 files. `ruff check` clean. +- **Docs**: the JWT panel of `images/tools_montage_a.png` and the Query and URL panels of `images/tools_montage_b.png` redone under dark_amber at their sizes with the same content (the JWT rebuilt from the one shown, byte for byte). +- **Files**: `pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py`, `pybreeze/pybreeze_ui/tools_gui/query_json_gui.py`, `pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py`, `test/test_utils/test_code_boxes_fixed_pitch.py`, `images/tools_montage_a.png`, `images/tools_montage_b.png`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-11 · 2026-09-25 · AI panels no longer suggest an endpoint they refuse · #fix #ai #readme + +- **What**: the endpoint field of CoT Code Review and Skill Send suggested `http://127.0.0.1:5000/api` (請輸入要傳送的 API URL,例如 http://127.0.0.1:5000/api), and the README's AI Code Review and Skill Send screenshots showed `http://127.0.0.1:8000/...` typed in; but every AI panel sends through `validate_url`, which refuses loopback and private addresses ("Access to non-public address 127.0.0.1 is blocked."). Following the example could only fail. The README did not say that a local endpoint is refused. +- **Fix**: both placeholders read "The API URL to send to, e.g. https://llm.example.com/api" (要傳送到的 API URL,例如 https://llm.example.com/api). The AI Code Review paragraph of the three READMEs says an endpoint on this machine or a private network, such as a local model server, is refused, and that CoT Code Review and Skill Send check theirs the same way. Whether they should let the user allow one is the owner's decision: progress #111. +- **Tests**: `test_an_example_url_is_one_the_ide_would_send_to` in `test_language_parity.py` (`TestLabelsAreTold`) runs every URL in either language's strings through `validate_url`, names resolved without the network (an address literal as itself, `localhost` as loopback, any other name as a public address). It failed on the two placeholders before the change; the cURL import's `https://api.example.com/...` passes. With the CoT session and keys-used tests, 28 pass. +- **Result / numbers**: 2452 tests in 135 files. `ruff check` clean. +- **Docs**: `images/ai_code_review.png` and `images/skills_send.png` redone at their sizes under dark_amber, from the demo home, with `https://llm.example.com/...` typed in; `progress.md` #111 [DECIDE]; `architecture_explore.md` (test count). +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `images/ai_code_review.png`, `images/skills_send.png`, `progress.md`, `architecture_explore.md` +- **Open items**: `progress.md` #111. + +## U-20260925-12 · 2026-09-25 · AI Code Review sends the code by default · #fix #ai #readme + +- **What**: AI Code Review's Method box started on GET, and only POST and PUT carry the code (`METHODS_WITH_A_BODY`: the form field `code` in the body). Pasting code and pressing Send Request as the panel opened sent the URL alone; the endpoint never saw the code, and nothing said so. The README did not say how the code is sent. +- **Fix**: a new panel starts on POST (`DEFAULT_METHOD` in `connect_gui/url/ai_code_review_gui.py`); the list and its order are unchanged. The AI Code Review paragraph of the three READMEs says the code goes as the form field `code` in the body of a POST (the default) or a PUT, and that GET and DELETE send the URL alone. +- **Tests**: `test_a_new_panel_sends_the_code` in `test_ai_code_review_client.py`: a new panel's method is one that carries the code. It failed before the fix (`'GET' in ('POST', 'PUT')`). With the fixed-pitch tests, 48 pass. +- **Result / numbers**: 2453 tests in 135 files. `ruff check` clean. +- **Docs**: `images/ai_code_review.png` redone (Method: POST); the three READMEs; `architecture_explore.md` (test count). +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/test_ai_code_review_client.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `images/ai_code_review.png`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-13 · 2026-09-25 · Say what CoT Code Review and Skill Send send · #docs #ai #readme + +- **What**: the READMEs told the user to give CoT Code Review and Skill Send an endpoint URL, but not what reaches it, and the two differ from each other and from AI Code Review (U-20260925-12): CoT Code Review POSTs the JSON `{"prompt": ...}` once per step and takes the response body, as text, as that step's answer; Skill Send POSTs the JSON `{"code": ...}` holding the prompt and shows the response body as it is. Anyone writing the endpoint had to read the code. +- **Change**: the CoT Prompt Editor and Skill Send paragraphs of the three READMEs say so. The formats are unchanged: renaming Skill Send's `code` key would break the endpoints already written for it. +- **Tests**: new `test_ai_request_formats.py` (5) pins what the READMEs now promise: AI Code Review sends the form field `code` with POST and PUT and the URL alone with GET and DELETE; Skill Send posts `{"code": }`. CoT's `{"prompt": ...}` is already checked in `test_cot_session_reuse.py`. They pass as the code stands; they are there so the documented formats do not drift. +- **Result / numbers**: 2458 tests in 136 files. `ruff check` clean. +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `test/test_utils/test_ai_request_formats.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-14 · 2026-09-25 · AI Code Review and CoT Code Review send no empty code, and the code as pasted · #fix #ai #i18n + +- **What**: with an empty code box, CoT Code Review still ran its whole chain, eight requests to the endpoint, each asking for a review of no code; AI Code Review sent an empty `code` field with POST or PUT. Neither said anything. AI Code Review also sent the code stripped, so a selection taken from inside a function lost the indentation of its first line only, and arrived inconsistently indented. +- **Fix**: CoT Code Review's `start_sending` warns "Paste the code to review first." (請先貼上要審查的程式碼。, `cot_gui_error_no_code`) and starts nothing when the box holds only whitespace; AI Code Review's `send_request` says the same in its response panel (`ai_code_review_gui_message_paste_code`) for a method that carries the code, while GET and DELETE, which send the URL alone, need none. AI Code Review sends the code as pasted (`exact_text`), as CoT Code Review already did. +- **Tests**: `TestCoTWithoutCode` in `test_ai_gui_lifecycle.py`; `test_no_code_is_not_sent_in_a_body`, `test_a_method_without_a_body_needs_no_code` and `test_the_code_goes_as_pasted` in `test_ai_code_review_client.py`. The three about the fixed behaviour failed before it. Two existing tests that sent from an empty box (`_sending_gui` in `test_cot_session_reuse.py`, `test_however_the_request_ended`) now paste code first, as a user would. With the AI panel, language and request-format tests, 121 pass. +- **Result / numbers**: 2462 tests in 136 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ai_code_review_client.py`, `test/test_utils/test_ai_gui_lifecycle.py`, `test/test_utils/test_cot_session_reuse.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-15 · 2026-09-25 · The architecture map on what the AI review panels send · #docs #ai + +- **What**: U-20260925-12 and U-20260925-14 changed what AI Code Review and CoT Code Review send but updated only the test count in `architecture_explore.md`, which the project's rules ask to keep current in the same change. +- **Change**: its `url/ai_code_review_gui.py` section says the method starts on POST (`DEFAULT_METHOD`), that POST and PUT (`METHODS_WITH_A_BODY`) carry the code as pasted in the form field `code` and send nothing when it is empty, and that GET and DELETE send the URL alone; the `cot_code_review_gui.py` line says an empty box sends nothing. +- **Files**: `architecture_explore.md` +- **Open items**: none. + +## U-20260925-16 · 2026-09-25 · One wording for the JWT hand-over, and a status button that says what it does · #fix #tools #i18n + +- **What**: the same hand-over to the JWT decoder read "Open token in JWT decoder" (在 JWT 解碼器開啟權杖) on Header Analyzer and "Open JWT in decoder" (在解碼器開啟 JWT) on Response Inspector. Response Inspector's status button read "Open status in reference" (在參考開啟狀態碼), naming no tab: the HTTP Status tab looks the code up. +- **Fix**: Header Analyzer's button reads "Open JWT in decoder" (在解碼器開啟 JWT), like Response Inspector's; the status button reads "Look up the status code" (查詢狀態碼). +- **Tests**: `test_the_same_action_reads_the_same_on_every_tab` and `test_a_hand_over_button_does_not_name_a_tab_that_is_not_there` in `test_language_parity.py` (`TestLabelsAreTold`); both failed before the change. With the header analyzer and response inspector tests, 114 pass. The full suite at `237d60d` passed (2443 passed, 15 skipped). +- **Result / numbers**: 2464 tests in 136 files. `ruff check` clean. +- **Docs**: `images/tool_header_analyzer.png` and `images/tool_response_inspector.png` redone at 900×720 under dark_amber with the same input, rebuilt from the images; rendered inside a container like the other images redone since U-20260924-313, so their content sits 2 pixels further up and left than in the old top-level grabs. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `images/tool_header_analyzer.png`, `images/tool_response_inspector.png`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-17 · 2026-09-25 · HTTP status classes in the IDE's language · #fix #tools #i18n + +- **What**: the HTTP Status tab and Response Inspector's status section tag each status with its class, and showed it in English whatever the IDE language: `404 Not Found [Client Error]` in the Traditional Chinese IDE, beside Chinese labels and buttons. +- **Fix**: `http_status_gui.status_heading()` writes the heading line for both tabs, the class looked up in the word dictionary under `http_status_category_` (`category_key()`; 資訊, 成功, 重新導向, 用戶端錯誤, 伺服器錯誤, 未知), falling back to the English class. The pure-logic `utils/http_reference/status_codes.py` keeps its English classes. The reason phrase and the description stay as `http.HTTPStatus` words them. +- **Tests**: new `TestTheCategoryInTheIdeLanguage` in `test_http_status_gui.py` (3): in the Traditional Chinese IDE the reference and Response Inspector show 用戶端錯誤 and 伺服器錯誤; every class, and an unregistered code's Unknown, has words in both languages. The first two failed before the change. `test_language_keys_used.py` knows the key prefix. With the HTTP status, response inspector and language tests, 69 pass. +- **Result / numbers**: 2467 tests in 136 files. `ruff check` clean. English output is unchanged, so the README images are too. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/http_status_gui.py`, `pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_http_status_gui.py`, `test/test_utils/test_language_keys_used.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-18 · 2026-09-25 · Regex and timestamp results worded in the IDE's language · #fix #tools #i18n + +- **What**: running every tool once in the Traditional Chinese IDE and reading its output found two lines put together in code: the regex tester wrote `group 1: 'GET'`, the English word, for each numbered group, and the timestamp converter `Epoch(秒): 1754208900`, a half-width colon after the Chinese label (U-20260925-02 moved the dictionary to full-width punctuation, but this colon was not in it). The rest (hashes, the diff and its summary, the JWT, header and response reports, the URL parts, cURL) read in Chinese or are code; the bilingual headings (`== 內容 Payload ==`) keep the JWT and HTTP terms on purpose. +- **Fix**: the line formats are dictionary entries: `regex_group_line` (group {index}: {value} / 群組 {index}:{value}), `regex_named_group_line` ({name}: {value} / {name}:{value}) and `timestamp_result_line` ({label}: {value} / {label}:{value}). English output is unchanged. +- **Tests**: new `test_tool_output_wording.py` (4): the regex groups and the timestamp lines in each language, exactly. The two Chinese ones failed before the change. With the regex, timestamp and language tests, 43 pass. +- **Result / numbers**: 2471 tests in 137 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_tool_output_wording.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-19 · 2026-09-25 · One space after the SSH terminal's [Error] · #fix #ssh #i18n + +- **What**: the SSH terminal writes a failed connection as its `[Error]` label, a space and the reason (`ssh_command_widget.py`), and the English label was `"[Error] "` with a space of its own, so English showed `[Error] ` with two; the Traditional Chinese `[錯誤]` had none. It was the only dictionary value with a space at either end. +- **Fix**: the English label is `[Error]`. +- **Tests**: `test_no_value_starts_or_ends_with_a_space` in `test_language_parity.py`: no value in either language starts or ends with a space, since the code adds its own separators. It failed on this key before the fix. With the SSH terminal output and login form tests, 77 pass. +- **Result / numbers**: 2472 tests in 137 files. `ruff check` clean. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-20 · 2026-09-25 · One AI submenu in the Dock menu · #fix #menu #jeditor + +- **What**: listing every menu entry of the started IDE showed the Dock menu with two submenus titled "AI" side by side: JEditor's (Chat UI) and one `extend_dock_menu` added for PyBreeze's five AI docks, which also replaced JEditor's `dock_ai_menu` attribute with its own. +- **Fix**: `extend_dock_menu` (`menu/tools/tools_menu.py`) adds the AI docks to JEditor's `dock_ai_menu` and builds an AI submenu of its own only when JEditor has none. `architecture.md` §6 lists `dock_menu` and `dock_ai_menu` among what PyBreeze relies on in `EditorMain`. +- **Tests**: `test_no_menu_has_two_entries_of_the_same_name` in `test_started_menus.py` walks every menu of the started IDE and fails on two entries of one name in the same menu; before the fix it found exactly `Dock > AI`. New `test_dock_menu.py` (2): the AI docks join an existing AI submenu, and get their own when there is none; the first failed before the fix. With `test_unsaved_on_close.py`, 15 pass. +- **Result / numbers**: 2475 tests in 138 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/tools/tools_menu.py`, `test/test_utils/test_started_menus.py`, `test/test_utils/test_dock_menu.py`, `architecture.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-21 · 2026-09-25 · Menu names spelled one way, and Tools entries that say Tab or Dock · #fix #menu #i18n #readme + +- **What**: listing every menu entry of the started IDE found names spelled two ways and entries that did not say what they open: the Automation menu wrote "Autocontrol" (Run Autocontrol Script, Open Autocontrol Doc, ...) where Install and the GUI entry wrote "AutoControl", as the README does; TestPioneer's entries read "Create TestPioneer Yaml template" and "Execute Test Pioneer Yaml" (Execute where every other menu says Run, and "Yaml"); Tools ▸ AI read "AI Code-Review Tab", "CoT Prompt Editor", "CoT Code Review Tab", "Skill Prompt Editor", "Skill Send GUI", where every other Tools entry ends in Tab; the Skill Send dock's entry read "Skill Prompt Dock" and its title "Skill Send GUI" (Traditional Chinese: Skill Prompt 傳送停駐窗格, Skill 提示詞傳送 GUI). +- **Fix**: AutoControl throughout, in both languages; "Create TestPioneer YAML Template" / "Run TestPioneer YAML" (建立 TestPioneer YAML 範本 / 執行 TestPioneer YAML) and "Please choose a YAML file"; "AI Code Review"; every Tools ▸ AI entry ends in Tab (分頁); Skill Send's tab and dock are "Skill Send" (Skill 提示詞傳送), its menu entries "Skill Send Tab" and "Skill Send Dock" (Skill 提示詞傳送分頁/停駐窗格). Skill Send's Tools entry had used its tab title as its label; it has a key of its own, `extend_tools_menu_skill_prompt_send_tab_action`. +- **Tests**: in `test_language_parity.py` (`TestLabelsAreTold`), `test_every_tools_entry_says_whether_it_opens_a_tab_or_a_dock` (every `_TAB_ACTIONS` entry ends in Tab / 分頁, every `_DOCK_ACTIONS` one in Dock / 停駐窗格), `test_a_dock_is_titled_like_the_tab_of_the_same_tool` and `test_a_name_is_spelled_one_way`; all three failed before the change. `test_no_label_is_in_capitals` counts YAML among the acronyms. With the menu and TestPioneer tests, 51 pass. +- **Result / numbers**: 2478 tests in 138 files. `ruff check` clean. +- **Docs**: `images/menu_automation.png` redone from the started IDE under dark_amber (AutoControl); `architecture_explore.md` (test count). +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `pybreeze/pybreeze_ui/menu/tools/tools_menu.py`, `test/test_utils/test_language_parity.py`, `test/test_utils/test_dock_menu.py`, `images/menu_automation.png`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-22 · 2026-09-25 · Install FileAutomation, as the menu and the README call it · #fix #menu #i18n + +- **What**: the menu dump of the started IDE in Traditional Chinese (after U-20260925-21's English one) showed Install ▸ Automation offering "Install Automation File" (安裝 Automation File) for the package the Automation menu and the README call FileAutomation. It also showed JEditor's Dock menu named 區域 (新編輯器區域, 新瀏覽器區域) while PyBreeze's entries in it say 停駐窗格, and JEditor's Editor, Git and Tools submenus untranslated: JEditor's own strings. +- **Fix**: the entry reads "Install FileAutomation" (安裝 FileAutomation). The JEditor wording joins `progress.md` #108 (JEditor's words, with line numbers at bb6bc94). +- **Tests**: `test_a_name_is_spelled_one_way` also refuses "Automation File"; it failed on this key before the fix. With the install menu tests, 30 pass. +- **Result / numbers**: 2478 tests in 138 files. `ruff check` clean. +- **Docs**: `images/menu_install.png` redone at its 220×356 under dark_amber; `progress.md` #108. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `images/menu_install.png`, `progress.md` +- **Open items**: `progress.md` #108. + +## U-20260925-23 · 2026-09-25 · Deleting and discarding ask with No as the default · #fix #ui #ssh #diagram + +- **What**: three of the nine yes-or-no questions named no default button: deleting a file or folder on the SFTP server, deleting one in the project tree, and discarding the diagram for a new one. Given Yes and No and no default, Qt makes Yes the default, so Enter, or a key press meant for the window behind, deleted the file for good (no recycle bin on either side) or dropped the diagram. The other six already defaulted to No. +- **Fix**: `SSHFileTreeManager.action_delete`, the project tree's `_action_delete` and `DiagramEditorWidget._new_diagram` pass `QMessageBox.StandardButton.No` as the default. +- **Tests**: new `test_questions_default_to_no.py` (2) reads every `QMessageBox.question` call in the package and fails on one without a default button (positional or `defaultButton=`), and checks it sees them all. It named exactly the three before the fix. The file tree, SFTP tree, diagram editor and message box tests pass (102). +- **Result / numbers**: 2480 tests in 139 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_questions_default_to_no.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-24 · 2026-09-25 · Refactor: the diagram image suffix allowlist public, its comment back on it · #refactor #diagram + +- **What**: the allowlist of image suffixes a saved diagram may reference on disk (`diagram_scene._VALID_IMAGE_SUFFIXES`) is needed by the editor's Add Image dialog too (U-20260925-25), and its comment, which says why it is a security boundary, sat above `_PASTE_OFFSET` instead, split from it when that constant was added. +- **Change**: renamed `IMAGE_SUFFIXES`; the comment is back above it. No behaviour changes. +- **Tests**: the diagram image and serialization tests pass (73). +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py` +- **Open items**: none. + +## U-20260925-25 · 2026-09-25 · The diagram editor's file dialogs in the IDE's language, offering every image it keeps · #fix #diagram #i18n + +- **What**: the diagram editor's five file dialogs (Open, Save As, Export PNG, Export SVG, Add Image from file) passed English filter literals, "Diagram JSON (*.diagram.json);;All Files (*)", "PNG Image (*.png)" and so on, in every IDE language; the other tools' dialogs take theirs from the dictionary. Add Image's filter listed seven suffixes, one fewer than the eight a saved diagram may reference (`.ico` was missing), so an `.ico` could be picked only through All Files. +- **Fix**: the filters come from `diagram_editor_filter_diagram`, `_png`, `_svg`, `_images` and `_all` (架構圖 JSON, PNG 圖片, SVG 圖片, 圖片, 所有檔案, with the patterns in ASCII parentheses, which Qt reads); `_image_filter()` builds Add Image's from `diagram_scene.IMAGE_SUFFIXES` (U-20260925-24), so the two cannot drift apart. +- **Tests**: new `TestFileDialogFilters` in `test_diagram_editor_widget.py` (2): in the Traditional Chinese IDE no filter holds English words and Open and Save offer 所有檔案; the image filter holds every suffix of `IMAGE_SUFFIXES`. Both failed before the fix. `test_traditional_chinese_uses_full_width_punctuation` lets a filter's `({patterns})` keep its ASCII parentheses, as it did `(*.txt)`, and still names the twelve strings U-20260925-02 fixed when run on the old dictionary. With the diagram image and language tests, 56 pass. +- **Result / numbers**: 2482 tests in 139 files. `ruff check` clean. The full suite at `d7d5d51` passed (2463 passed, 15 skipped). +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_diagram_editor_widget.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-26 · 2026-09-25 · 提示詞 throughout the Traditional Chinese IDE, and a CoT review window named as its tab · #fix #ai #i18n + +- **What**: the Traditional Chinese strings called a prompt 提示詞 in twelve places (the prompt editors' tabs, docks and menu entries) and Prompt or prompt in seven, sometimes on one screen: the CoT Prompt Editor tab (CoT 提示詞編輯器) is labelled "Prompt 檔案位置(會覆寫內建 prompt):" inside; Skill Send read "選擇 Prompt 範本:" and "Prompt:". The CoT Code Review panel's window title, shown when it stands alone, was "Prompt Sender UI" (Prompt 傳送介面), which names neither the tool nor its tab. +- **Fix**: the seven say 提示詞 (提示詞檔案位置(會覆寫內建提示詞):, 選擇提示詞範本:, 提示詞:, 請輸入 API URL 和提示詞, and the two messages); the window title is "CoT Code Review" (CoT 程式碼審查), its tab's name. +- **Tests**: `test_traditional_chinese_says_prompt_one_way` in `test_language_parity.py`: no Traditional Chinese string holds the word prompt. It failed on the seven before the change. With the AI panel, CoT and Skill Send tests, 90 pass. +- **Result / numbers**: 2483 tests in 139 files. `ruff check` clean. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-27 · 2026-09-25 · A run window names the file it runs · #fix #executor #ui + +- **What**: every automation run window was titled with the package alone (`je_api_testka`), so running a folder, which opens one window per file, gave a row of windows nothing told apart; so did two runs of different tabs. The plugin run windows already read `Run - main.go`. +- **Change**: `TaskProcessManager` titles its window `package - subject` (`_spawn_and_pump`): `start_test_process_file` uses the file's name, `start_test_process` and `start_module_process` take an optional `subject`; `build_process` passes the name of the file the tab in front has open, and TestPioneer's run passes its YAML. A run with no file (a script given outright, pip, prthinker) keeps the package alone. +- **Tests**: new `test_run_window_title.py` (4), running `python -m this`: a file run, a tab's script and a module run with a subject name the file; a script from nowhere names the package. The three with a file failed before the change. `test_run_output.py`'s TestPioneer recorder takes and checks the subject. With the build, install, prthinker, closing, TestPioneer menu and compile-then-run tests, 77 pass. +- **Result / numbers**: 2487 tests in 140 files. `ruff check` clean; `C901` clean. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py`, `test/test_utils/test_run_window_title.py`, `test/test_utils/test_run_output.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-28 · 2026-09-25 · The READMEs count the dictionary keys there are, and a test keeps them to it · #docs #i18n #readme + +- **What**: the three READMEs said both language dictionaries carry the same 735 keys; they hold 753. The number was kept by hand and had already drifted before this batch's own additions and removals. +- **Change**: 753 in `README.md`, `README/README_zh-TW.md` and `README/README_zh-CN.md`. +- **Tests**: `test_the_readmes_count_the_keys_there_are` in `test_language_parity.py` reads the count from each README and fails when it is not the size of the two dictionaries, so a key added or removed without the READMEs fails the suite. It failed on 735 before the change. `test_language_parity.py` passes (25). +- **Result / numbers**: 2488 tests in 140 files. `ruff check` clean. +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-29 · 2026-09-25 · Re-measure the counts the architecture map quotes · #docs + +- **What**: after U-20260925-28, the counts `architecture_explore.md` quotes were measured again, as the project rules ask. Four had drifted: the package was 201 `.py` files and about 22,200 lines (17,300 without blank lines and comments), the diagram editor 4,018 lines, `ssh/` 2,230 lines, and each language dictionary 735 keys. +- **Result / numbers**: 208 `.py` files, 24,003 lines, 19,404 without blank lines and comments (tokenized; docstrings count as code); `diagram_editor/` 4,034 lines; `connect_gui/ssh/` 2,467 lines; 753 keys. Unchanged and confirmed: 13 tool widgets in `tools_gui/`, 20 widget factories, 18 `utils/` subpackages, 17 `ITEException` classes, `syntax_keyword.py` 629 lines. +- **Tests**: `test_the_readmes_count_the_keys_there_are` checks the map's key count too. `test_language_parity.py` passes (25). +- **Files**: `architecture_explore.md`, `test/test_utils/test_language_parity.py` +- **Open items**: none. + +## U-20260925-30 · 2026-09-25 · The SSH login form's secret field says Passphrase for a key · #fix #ssh #i18n #readme + +- **What**: with Use key auth ticked, the SSH login form's Password field holds the private key's passphrase (`load_private_key` reads it so), but it still read "Password", and a key that needed one was answered with "enter it in the password field". +- **Change**: `LoginWidget._name_the_secret`, run as the box is ticked or unticked, labels the field Passphrase (密語) with the placeholder "The key's passphrase, if it has one" (私鑰的密語(沒有就留空)), and Password again when it is unticked. The missing-passphrase message says the Passphrase field (密語欄). The README's screenshot, taken unticked, is unchanged. +- **Tests**: `TestTheSecretField` in `test_ssh_login_form.py` (3): Password at first, Passphrase with key authentication, Password again when unticked; the last two failed before the change. `test_the_readmes_count_the_keys_there_are` (U-20260925-28) failed on the two new keys until the counts were updated: its first catch. With the language, SSH security and session-sharing tests, 68 pass. +- **Result / numbers**: 2491 tests in 140 files; 755 keys in each dictionary. `ruff check` clean. +- **Docs**: the SSH Client paragraph of the three READMEs; the key count in them and in `architecture_explore.md`. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_login_form.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-31 · 2026-09-25 · Ctrl+Enter runs a tool · #feature #tools #readme + +- **What**: the tool tabs had no key to run them. Their inputs are multi-line text boxes, where Enter is a new line, so pasting a curl command, a response or two texts to diff and then running the tool took the mouse; only the regex pattern and the timestamp fields, single lines, ran on Enter. +- **Change**: new `tools_gui/run_shortcut.py`: `press_on_ctrl_enter(tool, button)` gives the tool two shortcuts, Ctrl+Return and Ctrl+Enter (the keypad's), that click the button, and names the keys in its tooltip. They are the tool's children with `WidgetWithChildrenShortcut`, so they apply only while the focus is in that tool (a docked tool does not take the keys from the editor, and JEditor binds neither), and a disabled button, a run still going, is not pressed. Wired into the eight tools with one main button: cURL Import (Convert), Text Diff (Compare), Hash, Header Analyzer, JSON Format (Format), JWT Decoder, Regex (Find matches) and Response Inspector. Query ↔ JSON and URL Parser / Builder have two directions and HAR Import two buttons, so they have none. +- **Tests**: new `test_tool_run_shortcut.py` (10): each of the eight tools has the two shortcuts, scoped to it, pressing its button and named in the tooltip; a disabled button is not pressed; a real Ctrl+Enter typed in Hash's text box computes the digests. All failed before the change. +- **Result / numbers**: 2501 tests in 141 files. `ruff check` clean. The full suite at `79d8382` passed (2473 passed, 15 skipped). +- **Docs**: the Built-in Tools section of the three READMEs; `architecture_explore.md` (§6's shared mechanisms, file and test counts). +- **Files**: `pybreeze/pybreeze_ui/tools_gui/run_shortcut.py`, the eight tools' `*_gui.py` in `pybreeze/pybreeze_ui/tools_gui/`, `test/test_utils/test_tool_run_shortcut.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-32 · 2026-09-25 · Refactor: the Ctrl+Enter helper moves up to pybreeze_ui · #refactor #ui + +- **What**: `press_on_ctrl_enter` (U-20260925-31) is about to serve the AI panels too (U-20260925-33), which are not tools, so it moves from `pybreeze_ui/tools_gui/run_shortcut.py` to `pybreeze_ui/run_shortcut.py`, beside `fixed_pitch.py`, another helper several kinds of panel share. +- **Change**: `git mv` and the eight tools' imports; `CLAUDE.md`'s tree lists the module; `architecture_explore.md` §6 counts two shared mechanisms in `tools_gui/` again and says where the helper lives. No behaviour changes. +- **Tests**: `test_tool_run_shortcut.py` passes (10). +- **Files**: `pybreeze/pybreeze_ui/run_shortcut.py` (moved), the eight tools' `*_gui.py`, `CLAUDE.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-33 · 2026-09-25 · Ctrl+Enter sends from the AI panels · #feature #ai #readme + +- **What**: AI Code Review, CoT Code Review and Skill Send each have one send button and a multi-line code or prompt box, where Enter is a new line; sending took the mouse, where the tools now take Ctrl+Enter (U-20260925-31). +- **Change**: each panel calls `press_on_ctrl_enter(self, self.send_button)` (moved to `pybreeze_ui/run_shortcut.py` in U-20260925-32): Ctrl+Enter while the focus is in the panel presses Send, and not while a request is in flight (the button is disabled then). +- **Tests**: `test_ctrl_enter_sends_from_an_ai_panel` in `test_tool_run_shortcut.py` (3, one per panel, with the send slot disconnected so nothing goes out); all failed before the change. With the AI panel, lifecycle and closed-panel tests, 88 pass. +- **Result / numbers**: 2504 tests in 141 files. `ruff check` clean. +- **Docs**: the AI-Assisted Development section of the three READMEs; `architecture_explore.md` (the helper's users, test count). +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py`, `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `test/test_utils/test_tool_run_shortcut.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-34 · 2026-09-25 · The Regex tab's Traditional Chinese title · #fix #tools #i18n + +- **What**: in Traditional Chinese, Tools ▸ 正規表示式測試器分頁 opened a tab, and a dock, titled "Regex": the only tool tab left in English but Query / JSON, a pair of technical terms that reads the same in both languages. +- **Fix**: the tab and dock title is 正規表示式. +- **Tests**: `test_a_tab_is_titled_as_its_menu_entry_names_it` in `test_language_parity.py` (`TestLabelsAreTold`): each tool's tab title appears in its Tools menu entry, in both languages; it failed only on this one. With the regex tests, 38 pass. +- **Result / numbers**: 2505 tests in 141 files. `ruff check` clean. +- **Files**: `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-35 · 2026-09-25 · F2 and Delete in the project tree · #feature #ui #readme + +- **What**: the project tree renamed and deleted only through its right-click menu; F2 and Delete, the keys a file manager uses, did nothing, and JEditor's tree handles neither. +- **Change**: `_attach_keys` (`editor_main/file_tree_context_menu.py`), run when the menu is attached, gives the tree F2 (rename) and Delete (delete) shortcuts that act on the entry in focus through the menu's own `_action_rename` and `_action_delete`. They are `WidgetShortcut`s: only while the tree itself has the focus, so Delete in the editor beside it stays the editor's. Delete asks first, No being the default (U-20260925-23). The menu's Rename and Delete entries show the keys. +- **Tests**: new `TestTheKeys` in `test_file_tree_context_menu.py` (5): each key has one tree-scoped shortcut acting on the current entry, a real F2 or Delete typed in a shown tree reaches the action (the view's own key handling, which starts an edit on F2, does not take it), and attaching twice adds the keys once. The first four failed before the change. With the context menu and rename tests, 67 pass. +- **Result / numbers**: 2510 tests in 141 files. `ruff check` clean; `C901` clean. +- **Docs**: the File Tree Context Menu line of the three READMEs; `architecture_explore.md`. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-36 · 2026-09-25 · Refactor: the SFTP tree's menu action runner and entry lookup · #refactor #ssh + +- **What**: the SFTP tree's keys (U-20260925-37) need what `on_context_menu` did inline: find the entry a row stands for (the "..." or loading row has none, so its folder) and run an action on it, reporting a failed SFTP request instead of raising it. +- **Change**: `SSHFileTreeManager._entry_of(item)` and `_act_on(handler, item)` (`connect_gui/ssh/ssh_file_viewer_widget.py`); `on_context_menu` uses both. No behaviour changes. +- **Tests**: the SFTP tree action and cancel tests, the menu-freeing and closed-panel tests pass (63). The full suite at `d96be79` passed (2490 passed, 15 skipped), and both startup tests exit 0. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` +- **Open items**: none. + +## U-20260925-37 · 2026-09-25 · F2 and Delete in the SFTP tree · #feature #ssh #readme + +- **What**: the SFTP tree, like the project tree before U-20260925-35, renamed and deleted only through its right-click menu. +- **Change**: `SSHFileTreeManager` gives its tree F2 and Delete shortcuts (`WidgetShortcut`, so only while the tree has the focus) connected to the bound methods `_rename_current` and `_delete_current`, which run the menu's `action_rename` and `action_delete` on the entry in focus through `_act_on` (U-20260925-36): a dropped session is reported in a dialog, not raised. Delete asks first, No being the default. The menu's Rename and Delete entries show the keys (`_ENTRY_KEYS`, one map for both). +- **Tests**: new `TestTheKeys` in `test_sftp_tree_actions.py` (3): each key has one tree-scoped shortcut acting on the current entry, and a request failing on a dropped session is reported. All failed before the change. With the menu-freeing, closed-panel and cancel tests, 66 pass; the closed-panel test confirms the bound slots keep nothing alive. +- **Result / numbers**: 2513 tests in 141 files. `ruff check` clean; `C901` clean. +- **Docs**: the SSH Client paragraph of the three READMEs; `architecture_explore.md` (the SFTP tree, and the re-measured line counts it quotes: `ssh_file_viewer_widget.py` 756, `ssh/` 2,508, `diagram_editor_widget.py` 695). +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-38 · 2026-09-25 · The project tree deletes to the trash · #feature #ui #readme + +- **What**: Delete in the project tree, from its menu or with the Delete key (U-20260925-35), removed the file or folder for good: a whole project folder was one Enter away from gone, with no Recycle Bin to take it back from. File managers, and editors such as VS Code, move to the trash by default. +- **Change**: `_action_delete` (`editor_main/file_tree_context_menu.py`), once confirmed, calls `_remove`: it moves the entry to the system's trash with `QFile.moveToTrash` (`_move_to_trash`; the Recycle Bin on Windows, the macOS and freedesktop trash elsewhere), and only where there is none (some network drives, a desktop without one) asks again, No being the default, before deleting for good (`remove_folder()` or `unlink()`). A symbolic link or junction is still removed itself, never moved, so what it points to stays. The confirmation reads "Move '{name}' to the trash?" (要把 '{name}' 移到回收筒嗎?) and the second question "'{name}' cannot be moved to the trash here. Delete it for good?". Measured here: `QFile.moveToTrash` moves a file and a folder to `C:\$Recycle.Bin`. +- **Tests**: new `TestDeletingToTheTrash` in `test_file_tree_context_menu.py` (5): a file and a folder go to the trash; with no trash the second question comes, defaulting to No, and Yes deletes for good while No keeps the file; the real `_move_to_trash` asks `QFile.moveToTrash`. An autouse fixture gives every test a trash of its own, so the suite does not fill the machine's Recycle Bin; the test of a failing delete runs where there is no trash, the only place a delete can fail now. With the rename, language, message box and default-button tests, 100 pass; `test_the_readmes_count_the_keys_there_are` caught the new key. +- **Result / numbers**: 2518 tests in 141 files; 756 keys in each dictionary. `ruff check` clean. +- **Docs**: the File Tree Context Menu line of the three READMEs and their key count; `architecture_explore.md`. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_file_tree_context_menu.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-39 · 2026-09-25 · Tests that reach every branch of the SSRF check · #test #security #network + +- **What**: coverage of `utils/network/url_validation.py` showed branches of the SSRF check the suite never ran, among them its last line of defence: the comparison of the host urllib3 reads with the one `urlparse` reads (every differential URL in `test_url_parser_differential.py` stops earlier, at the character check), the `urlparse`-only parse failure, the fallback for a name IDNA cannot encode, a name that resolves to nothing, the de-duplication of repeated addresses, and the Teredo and NAT64 extraction in `_embedded_ipv4`. The last two never run on the Python versions CI covers, because the standard library already classes Teredo (`2001::/32`) as private and NAT64 (`64:ff9b::/96`) as reserved; they are there should a version class them otherwise, as 3.12 and 3.13 reclassified other ranges. +- **Change**: tests only. `test_url_parser_differential.py`: `http://[fe80::1%25eth0]/` passes the character check and is refused by the comparison (urllib3 decodes the zone ID, `urlparse` does not), with the ambiguous-host message; a `urlparse` failure becomes the unparsable-URL message without quoting the URL's token; `_as_ascii` leaves a name IDNA cannot encode as it is. `test_url_validation.py`: a name with no address is refused; repeated addresses come once, in the resolver's order; `_embedded_ipv4` finds 169.254.169.254 inside its IPv4-mapped, 6to4, Teredo and NAT64 wrappers and nothing inside a plain IPv6 address. Found while searching for URLs the two parsers read differently: none without a backslash, whitespace or a control character but the zone ID one; four others fail urllib3's parser first. +- **Result / numbers**: `url_validation.py` at 100% of lines and branches with these and `test_public_http.py` (69 pass). 2528 tests in 141 files. `ruff check` clean. +- **Files**: `test/test_utils/test_url_parser_differential.py`, `test/test_utils/test_url_validation.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-40 · 2026-09-25 · Tests that reach every branch of the pinned connections and capped reads · #test #security #network + +- **What**: after U-20260925-39, the other two modules every outbound request goes through still had behaviour no test ran. `public_http.py`: when every checked address fails, the last failure is raised (the requests and the urllib path); HTTPS through a proxy is left to the proxy (urllib); and the overall deadline's promises: a block that returns after the time is up still raises `ReadTimeout`, a socket watched after the time is up is shut at once, a timer firing after the request ended shuts nothing, and there is nothing to watch without a socket. `http_client.py`: empty chunks are skipped, a read error the watchdog did not cause is raised (the response still closed), a cancelled watchdog does nothing when its time comes, and a connection it cannot shut down is logged, not raised on the timer's thread. +- **Change**: tests only, in `test_public_http.py` (7: two in `TestEveryCheckedAddressIsTried`, one in `TestUrllibOpener`, four in the new `TestTheDeadlineItself`) and `test_http_client.py` (4). The proxy test sees the opener send `CONNECT rebind.test:443` to a local proxy. +- **Result / numbers**: `public_http.py`, `http_client.py` and `url_validation.py` all at 100% of lines and branches (159 pass across their tests). 2539 tests in 141 files. `ruff check` clean. +- **Files**: `test/test_utils/test_public_http.py`, `test/test_utils/test_http_client.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-41 · 2026-09-25 · Record cryptography with the undeclared direct imports in progress #53 · #docs #deps #security + +- **What**: a check of 2026's cryptography advisories found that PyBreeze imports `cryptography` directly (`connect_gui/ssh/ssh_key_loader.py`: `serialization`, `UnsupportedAlgorithm`, `CryptographyDeprecationWarning`; used more since U-20260925-07 reads PKCS#8 keys with it), yet declares it nowhere: it arrives through paramiko, as `requests` and `urllib3` arrive through the automation packages (progress #53). This repo's `.venv` has 46.0.6; 46.0.7 fixes CVE-2026-39892, a buffer over-read when a non-contiguous buffer (such as `buf[::-1]`) is passed to an API taking a buffer, like `Hash.update()`. The key loader passes whole `bytes`, so its calls are not affected; CVE-2026-34073 (X.509 name constraints) and CVE-2026-69247 to 69249 (PKCS#7, name constraints, path building) concern APIs PyBreeze does not call. +- **Change**: `progress.md` #53 names `cryptography` beside `requests` and `urllib3`, and the question now includes `cryptography>=46.0.7`. Declaring or pinning it stays the owner's decision, as for the other two. +- **Files**: `progress.md` +- **Evidence**: [oss-sec: cryptography 46.0.7, CVE-2026-39892](https://seclists.org/oss-sec/2026/q2/51); [CVE-2026-34073](https://www.sentinelone.com/vulnerability-database/cve-2026-34073/). +- **Open items**: `progress.md` #53. + +## U-20260925-42 · 2026-09-25 · Every undeclared direct import in progress #53 · #docs #deps + +- **What**: after U-20260925-41, every top-level import in `pybreeze/` was compared with `requirements.txt`. Besides `requests`, `urllib3` and `cryptography`, one more third-party package is imported but declared nowhere: `qt_material`, whose `apply_stylesheet` `start_editor` calls to style the window (`editor_main/main_ui.py`); it arrives only through je-editor, so a je-editor that dropped it would stop the IDE starting. `idna` (`utils/network/url_validation.py`) is imported with a fallback, and `shiboken6` is installed with PySide6. +- **Change**: `progress.md` #53 names `qt_material` and asks about declaring `qt-material` too. +- **Files**: `progress.md` +- **Open items**: `progress.md` #53. + +## U-20260925-43 · 2026-09-25 · Tests of the diagram editor's image download itself · #test #security #diagram + +- **What**: coverage of `diagram_editor/diagram_net_utils.py` showed that the suite never ran `safe_download_image`'s own checks: the scene's tests stand in for the whole function, and `test_public_http.py` reaches it only for a rebinding name. Untested were refusing an unsafe URL as an `ImageDownloadError` (the canvas catches that; an `UnsafeURLError` would escape), refusing a `text/*` page posing as an image, the declared-size cap, the cap on a body whose length is absent or understated, and a malformed or negative `Content-Length`. +- **Change**: tests only: new `test_image_download.py` (9) against a local HTTP server, with 127.0.0.1 allowed to stand in for a public server and every other private address still refused. An image comes back whole; a `text/html` answer is refused naming its type; a declared length over the cap is refused, and so is a longer body with no length (the cap set to 100 bytes for the test); an unsafe URL is refused as an `ImageDownloadError` with nothing requested; a redirect to 169.254.169.254 is not followed, while one on the same public host is; a `Content-Length` of `abc` or `-5` leaves the bounded read to decide. +- **Result / numbers**: `diagram_net_utils.py` at 98% of branches: the one left is `redirect_request` returning `None`, which CPython's handler never does (it raises instead). 2548 tests in 142 files. `ruff check` clean. +- **Files**: `test/test_utils/test_image_download.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-44 · 2026-09-25 · Tests that stopping a run stops what it started · #test #executor + +- **What**: `subprocess_util.stop_tree`, which the run window's Stop and the IDE's closing use to end a run with every process it started (`go run`, `cargo run` and a web run start the real program as a grandchild), had no test of its own: nothing checked that a grandchild ends, that an ended process is left alone, or that the child is still terminated when the tree kill fails (`taskkill` missing, `killpg` refused). Coverage of the full suite at `5de7821` (2524 passed, 15 skipped; 91% of statements) showed its fallback lines unrun. +- **Change**: tests only: `TestStoppingATree` in `test_subprocess_util.py` (3). A launcher starts a grandchild that writes a heartbeat file every 50 ms; after `stop_tree` the heartbeat stops (on Windows `os.kill(pid, 0)` would itself terminate the process, so the heartbeat is the portable check; the grandchild also stops itself after 30 s, whatever happens). Run with `stop_tree` replaced by terminating only the launcher, that test fails. An ended process is neither killed nor terminated; with the tree kill refused, the child alone is terminated. +- **Result / numbers**: `subprocess_util.py` covered but for its two POSIX-only lines, which the Windows CI cannot reach. 2551 tests in 142 files. `ruff check` clean. +- **Files**: `test/test_utils/test_subprocess_util.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-45 · 2026-09-25 · Tests of the SSH host-key store's last line and a closed panel's question · #test #ssh #security + +- **What**: two behaviours of `connect_gui/ssh/ssh_host_key_policy.py` had no test: trusting a host when `~/.pybreeze/ssh_known_hosts` does not end in a newline (a file edited by hand, say) must not glue the new key onto the last host's line, spoiling both; and a question asked for a panel that was closed before it came is a No, with nobody asked on its behalf. +- **Change**: tests only, in `test_ssh_host_key_policy.py` (3): `test_a_file_without_a_last_newline_keeps_its_last_host` (both hosts still load with their keys), and `TestThePanelThatAsked`: the question carries the panel that asked, and a panel collected before the question makes the connect fail as a No without asking. +- **Result / numbers**: the policy's lines 125 and 151-152, the only ones the full suite did not run, now run. 2554 tests in 142 files. `ruff check` clean. +- **Files**: `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-46 · 2026-09-25 · Coverage counts what QThread workers run · #test #ci + +- **What**: coverage.py traces the threads Python starts; a `QThread`'s `run` executes on a thread Qt starts, so every one of the package's twelve `run` methods, and everything they call on their thread, read as never executed, though the tests run them (the SFTP listings, transfers and requests, the regex worker, the diff, the review requests). The SonarCloud numbers, taken from the same measurement, undercounted the same way. coverage.py's own issue for it (#686) has no fix. +- **Change**: `test/test_utils/conftest.py` sets `QThread.__init_subclass__` so that every subclass defined after it (the package's, imported by the tests) gets a `run` that first installs, on its own thread, the tracer `threading` hands the threads Python starts (`threading.gettrace()`). Without coverage there is no tracer, and it does nothing; where coverage uses `sys.monitoring`, which sees every thread, likewise. `CLAUDE.md` › Branching & CI says so beside the coverage settings. +- **Tests**: the full suite passes with it (2539 passed, 15 skipped). The four SFTP test files alone measure `sftp_session.py` at 80% where they measured 61%. +- **Result / numbers**: over the whole suite, 730 of 10,546 statements unrun where 782 were (91% either way, rounded), and 247 partial branches where 253 were. +- **Files**: `test/test_utils/conftest.py`, `CLAUDE.md` +- **Evidence**: [coverage.py issue #686, Coverage missing for QThreads](https://github.com/nedbat/coveragepy/issues/686). +- **Open items**: none. + +## U-20260925-47 · 2026-09-25 · Re-measure the coverage the architecture map quotes · #docs #test + +- **What**: `architecture_explore.md` §18 gave coverage as 89% overall with per-package figures from an older run, and did not say how `QThread` workers are counted (U-20260925-46). +- **Result / numbers**: measured from the full suite at `7f14bc0` plus the `conftest.py` hook (2539 passed, 15 skipped): statements 93.1% (730 of 10,546 unrun), 91% with branches. By package, statements: `pybreeze_ui/dialog` 100%, `tools_gui` 99.3%, `utils/` 98.1%, `extend_ai_gui` 95.0%, `extend/` 93.9%, `connect_gui` 93.8%, `diagram_editor` 89.7%, `menu` 85.2%, `jupyter_lab_gui` 80.2%, `editor_main` 72.2% (the main window runs mostly in the child-process startup tests, which are not measured; so does `code_result_logs.py`, at 44%). +- **Change**: §18's coverage line gives these figures and says how the `QThread` workers come to be counted. +- **Files**: `architecture_explore.md` +- **Open items**: none. + +## U-20260925-48 · 2026-09-25 · Tests of a saved diagram's local images · #test #diagram #security + +- **What**: a `.diagram.json` is untrusted data, and the project's rules let the editor read an image path from it only after checking the extension against an allowlist and that the path is on this machine and a file (`DiagramScene._try_load_image_source`). The refusals of a UNC path had a test (`test_diagram_serialization.py`), but the full suite never ran the load that passes the checks, nor a refused extension or a missing file. +- **Change**: tests only: `TestAnImageOnThisMachine` in `test_diagram_images.py` (3): a PNG next to the test is shown; the same PNG named `.txt` is not read, the extension being checked before the file is touched; a missing file leaves the image empty. None of them is fetched as a URL. +- **Result / numbers**: 2557 tests in 142 files. `ruff check` clean. +- **Files**: `test/test_utils/test_diagram_images.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-49 · 2026-09-25 · Refactor: Ctrl+Enter can call any action · #refactor #ui + +- **What**: `press_on_ctrl_enter` (U-20260925-31) could only press a button; the two tools with a button for each direction (U-20260925-50) need Ctrl+Enter to call a method that picks the direction. +- **Change**: `pybreeze_ui/run_shortcut.py` has `act_on_ctrl_enter(tool, action)`, which gives the tool its two shortcuts calling *action*, and `press_on_ctrl_enter` is that with the button's `click` plus the tooltip. The docstring says to connect a bound method or a button's `click`, as the project's conventions ask. No behaviour changes. +- **Tests**: `test_tool_run_shortcut.py` and `test_closed_panels_are_freed.py` pass (41). +- **Files**: `pybreeze/pybreeze_ui/run_shortcut.py` +- **Open items**: none. + +## U-20260925-50 · 2026-09-25 · Ctrl+Enter in the two-way tools goes the way the input reads · #feature #tools #readme + +- **What**: U-20260925-31 left Query ↔ JSON and the URL parser/builder without Ctrl+Enter, since each has a button for each direction. The input already says which is meant: a JSON object is converted from JSON, anything else to it. +- **Change**: both tools connect Ctrl+Enter (`act_on_ctrl_enter`, U-20260925-49) to a bound `convert_as_pasted`, which clicks the from-JSON button when the input, leading blanks aside, starts with `{`, and the to-JSON button otherwise; a click, so a disabled button is left alone. The buttons' tooltips say when Ctrl+Enter picks them ("Ctrl+Enter, when the input is a JSON object" / "... is not a JSON object"; Ctrl+Enter(輸入是 JSON 物件時)). +- **Tests**: `test_ctrl_enter_goes_the_way_the_input_reads` in `test_tool_run_shortcut.py` (4: each tool, with text and with a JSON object); all failed before the change. With the two tools', language and closed-panel tests, 89 pass; `test_the_readmes_count_the_keys_there_are` caught the two new keys again. +- **Result / numbers**: 2561 tests in 142 files; 758 keys in each dictionary. `ruff check` clean. +- **Docs**: the Built-in Tools paragraph of the three READMEs and their key count; `architecture_explore.md`. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/query_json_gui.py`, `pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_tool_run_shortcut.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-51 · 2026-09-25 · The READMEs no longer promise keyword highlighting · #docs #readme #jeditor + +- **What**: the READMEs' feature list headed a line "Automation-aware syntax highlighting", and the Quick Start's first step said "automation keywords highlight as you type". They do not: PyBreeze registers its keyword sets for `.json`, `.yml` and `.yaml`, but JEditor highlights those suffixes with its own rules, which leave registered keywords out (`progress.md` #103, blocked on JEditor). U-20260924-300 corrected the main window's caption for the same reason; these two lines were missed. +- **Change**: in the three READMEs the feature line is "Automation keyword sets" and says JEditor does not colour them yet and why; the Quick Start's first step no longer mentions highlighting. They go back when #103 is fixed. +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md` +- **Open items**: `progress.md` #103. + +## U-20260925-52 · 2026-09-25 · Record the build configs left under the old name as progress #112 · #docs #decision + +- **What**: checking the README's Quick Start against the repository led to `exe/`. The Windows auto-py-to-exe settings build PyBreeze (`exe/start_pybreeze.py`, `exe/pybreeze_icon.ico`, since 987252e), but the Linux settings (`exe/auto_py_to_exe_setting_linux.json`) and the InstallForge project (`exe/automation_ide_setup_config.ifp`) still build AutomationIDE: its name and version 1.0.9, its repository as website and licence link, a script `exe/start_automation_editor.py` and an icon `exe/je_driver_icon.ico` that the repository no longer has, and paths on another build machine (`C:/CodeWorkspace/Python/AutomationIDE`). +- **Change**: `progress.md` #112 [DECIDE]: bring the two over to PyBreeze as the Windows settings were, or delete them if they are no longer used. Their paths are a build machine's, so the owner has to say which. +- **Files**: `progress.md` +- **Open items**: `progress.md` #112. + +## U-20260925-53 · 2026-09-25 · The README's project tree names the root documents · #docs #readme + +- **What**: the READMEs' Project Structure tree listed `architecture_explore.md` and `PLUGIN_GUIDE.md` but not `architecture.md`, the short architecture overview every repository in the workspace keeps (layers, main flows, the cross-project contracts in §6), nor `progress.md`, and described `docs/` as Sphinx source only, though `docs/updates/` holds the change log. +- **Change**: the tree in the three READMEs lists `architecture.md` and `progress.md` and says `docs/updates/` is the change log. Everything else it names was checked to exist. +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md` +- **Open items**: none. + +## U-20260925-54 · 2026-09-25 · Which interpreter a run uses, told right in the README and a docstring · #docs #readme #executor + +- **What**: the READMEs' "Virtual environment awareness" line said a `venv/` or `.venv/` is used automatically and, without one, the IDE's own interpreter, leaving out that an interpreter chosen under Python Env comes before both (`renew_path`). `build_task_process`'s docstring said the fallback after the venv is `PATH`, which only a packaged build does: from source the IDE's own interpreter is used (`default_interpreter`), `PATH`'s `python3` on Windows often being the Store's stub. `architecture_explore.md` already told it right. +- **Change**: the line in the three READMEs gives the order: the Python Env choice, then a `venv/` or `.venv/` in the working folder, then the IDE's interpreter; the docstring names `default_interpreter` and says only a packaged build looks on `PATH`. +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `pybreeze/extend/process_executor/process_executor_utils.py` +- **Open items**: none. + +## U-20260925-55 · 2026-09-25 · The README says which Python JupyterLab starts in · #docs #readme + +- **What**: the READMEs said the JupyterLab tab installs JupyterLab "into the project venv" if it is missing. The tab picks its interpreter as a run does (`choose_python`: the one chosen under Python Env, else a `venv`/`.venv` in the working folder, else the IDE's own), so with no venv it installs into the IDE's interpreter or the chosen one. +- **Change**: the line in the three READMEs says it starts in the interpreter a run would use and installs JupyterLab there if missing (U-20260925-54 gives that order). +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md` +- **Open items**: none. + +## U-20260925-56 · 2026-09-25 · The README says what the other languages in the menu do · #docs #readme #i18n + +- **What**: the READMEs' Multi-Language UI section listed English and Traditional Chinese, but the Language menu also offers JEditor's 日本語 and 简体中文. Picked, JEditor's own menus change and PyBreeze's strings stay in English (JEditor serves those languages from a copy merged with English; `test_each_registered_language_resolves_every_key` keeps every key resolving). Nothing said so, and the Simplified Chinese README's readers are the likeliest to pick 简体中文. +- **Change**: the section in the three READMEs says so. +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md` +- **Open items**: none. + +## U-20260925-57 · 2026-09-25 · 外掛, not 插件, in the plugin guide · #docs #i18n + +- **What**: the Traditional Chinese half of `PLUGIN_GUIDE.md` called plugins 插件, the Mainland term, four times, and sent the reader to a "*插件 → Plugin Browser*" menu; in the Traditional Chinese IDE that menu is 外掛 and its entry 外掛瀏覽器 (PyBreeze's own words over JEditor's, held by `test_traditional_chinese_uses_taiwan_terms`). The Traditional Chinese README was checked for the same terms and has none (its 終端機, 用戶端 and 媒體類型 are the Taiwan ones). +- **Change**: 外掛 throughout the guide, and the menu named *外掛 → 外掛瀏覽器*. +- **Files**: `PLUGIN_GUIDE.md` +- **Open items**: none. + +## U-20260925-58 · 2026-09-25 · The Sphinx docs name the menu entries as they now read · #docs #i18n + +- **What**: the Sphinx documentation (`docs/source/Eng` and `docs/source/Zh`, published on Read the Docs) quoted the menu labels U-20260925-20 to -22 and U-20260925-11 renamed: "AI Code-Review", "Skill Send GUI Tab / Skill Prompt Dock", the automation menus' "HELP" submenu, "Create TestPioneer Yaml Template", "Execute Test Pioneer Yaml", "Install Automation File". The Chinese pages also used 運行 (運行器) and 插件 in their own prose, where the Traditional Chinese IDE says 執行 and 外掛. +- **Change**: the labels as the IDE shows them (AI Code Review, Skill Send Tab / Skill Send Dock, Help, Create TestPioneer YAML Template, Run TestPioneer YAML, Install FileAutomation) in both languages' pages, and 執行器, 外掛 in the Chinese prose. Section underlines stay long enough: no title grew. +- **Files**: `docs/source/Eng/ai_tools.rst`, `menu_automation.rst`, `menu_install.rst`, `menu_tools.rst`, `ui_overview.rst`; `docs/source/Zh/ai_tools.rst`, `menu_automation.rst`, `menu_file_run_text.rst`, `menu_install.rst`, `menu_plugins.rst`, `menu_tools.rst` +- **Open items**: none; a page-by-page comparison with the menus follows. + +## U-20260925-59 · 2026-09-25 · The Sphinx docs show today's main window · #docs + +- **What**: the UI Overview page of the Sphinx docs, in both languages, showed `images/ui.png`: a 2023 capture of the IDE as "Automation Editor", with a menu bar from before the Tools, AI, Dock, Plugins and Help menus, and a project tree listing that machine's files (`crede…`, `token…` JSON among them). +- **Change**: both pages' `ui.png` is the README's main window (`images/main_window.png`, redone in U-20260924-310 from a demo project), 1600×950. +- **Files**: `docs/source/Eng/images/ui.png`, `docs/source/Zh/images/ui.png` +- **Open items**: none. + +## U-20260925-60 · 2026-09-25 · architecture.md's recipe for a new tool follows today's conventions · #docs + +- **What**: `architecture.md`'s extension points told how to add a tool tab or dock (a widget, its logic in `utils/`, the rows in `tools_menu.py`) but not what every tool now does: code boxes in the fixed-pitch font (U-20260924-314, U-20260925-10) and Ctrl+Enter on the main button (U-20260925-31, -50); and for UI strings, that the key count quoted in the READMEs and `architecture_explore.md` is checked (U-20260925-28, which has caught two changes since). +- **Change**: the New tool tab or dock point names `use_fixed_pitch_font()` and `press_on_ctrl_enter()` / `act_on_ctrl_enter()`; the UI strings point names the key-count test. +- **Files**: `architecture.md` +- **Open items**: none. + +## U-20260925-61 · 2026-09-25 · Bold and code in the Chinese Sphinx pages render, and the docs build without warnings · #fix #docs #i18n + +- **What**: building the Sphinx docs gave 20 warnings. 19 were in the Chinese pages: reStructuredText ends `**strong**` or ``literal`` only before whitespace or certain punctuation (not a Chinese character, nor an opening bracket such as () and starts one only after them, and Chinese runs on without spaces, so `**程式碼輸入區**(左面板)` and five other pages' markup were published with the asterisks showing (the built page read `程式碼輸入區**(左面板)`). The last was `conf.py`'s `html_static_path = ["_static"]`, a folder that does not exist, as `templates_path`'s `_templates` does not either. +- **Fix**: an escaped space (backslash, space), which renders as nothing, between the markup and the Chinese next to it, in `Zh/ai_tools.rst`, `getting_started.rst`, `how_to_extend_ui.rst`, `menu_tools.rst`, `ssh_client.rst` and `ui_overview.rst`; the two settings are gone from `conf.py`. `sphinx-build -E` now reports no warning, and the page shows `程式碼輸入區`. +- **Tests**: new `test_docs_markup.py` (26: one per page, and that there are pages) parses each `.rst` with docutils and fails on an inline-markup warning; the six Chinese pages failed before the fix. It skips where docutils is missing; CI installs it with Sphinx from `dev_requirements.txt`. +- **Result / numbers**: 2587 tests in 143 files. `ruff check` clean. +- **Files**: the six `docs/source/Zh/*.rst` pages, `docs/source/conf.py`, `test/test_utils/test_docs_markup.py`, `architecture_explore.md` +- **Evidence**: [Docutils: reStructuredText inline markup recognition rules](https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#inline-markup-recognition-rules). +- **Open items**: none. + +## U-20260925-62 · 2026-09-25 · JupyterLab runs in the interpreter a run uses · #fix #jupyter + +- **What**: with no interpreter chosen under Python Env, a run used a `venv`/`.venv` in the working folder, else the IDE's own (`default_interpreter`), but the JupyterLab tab first asked whether the IDE itself ran in a virtual environment (`get_venv_python`) and used that one if so. An IDE started from a venv of its own therefore ran notebooks there while runs of the same project used the project's `.venv`, so the notebooks did not see the project's packages. `default_interpreter`'s docstring even said the tab chose as it did. Found by the Sphinx docs audit, whose JupyterLab page states the tab's order; U-20260925-55 had written the README's line as if the two already agreed. +- **Fix**: `choose_python` (`jupyter_lab_gui/jupyter_lab_thread.py`) returns the interpreter chosen in the IDE, else `default_interpreter()`, as a run does. That can raise `JEditorExecException` in a packaged build with no Python, which the launcher thread now catches and shows as the tab's failure reason instead of dying with nothing shown. `get_venv_python`, unused after this, is removed, with the `os` and `sys` imports it alone needed. +- **Tests**: in `test_jupyter_ready.py`, `test_the_lab_runs_where_a_run_would` (the IDE in a venv, the project with a `.venv`: the lab gets the project's, as `default_interpreter` gives; it got the IDE's before) and `test_a_packaged_build_with_no_python_says_so` are new; the tests that stood in for `get_venv_python` stand in for `default_interpreter`. The JupyterLab tests pass (27). +- **Result / numbers**: 2589 tests in 143 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, `test/test_utils/test_jupyter_ready.py`, `test/test_utils/test_jupyter_lifecycle.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260925-63 · 2026-09-25 · The Sphinx JupyterLab pages told right · #docs #jupyter + +- **What**: a page-by-page audit of the Sphinx docs against the running IDE's menus and the code (the rename pass U-20260925-58 had fixed only labels) found the JupyterLab page, in both languages, saying: open it "from the tab menu" (it is Tab > Tools Tab > JupyterLab); it installs JupyterLab "on first launch" (whenever the lab's interpreter cannot import it); a status label reading "Starting JupyterLab...", "Ready" (neither string exists: "Initializing...", "Downloading...", "Loading... (Ns / 60s)", removed once the lab loads, or "JupyterLab init failed: "); "no external network access is needed" (the install needs it); and it "shares the same Python environment as PyBreeze" (it runs where a script run does, since U-20260925-62). +- **Change**: both pages say what the code does, with the tab's title `JupyterLab `. +- **Files**: `docs/source/Eng/jupyter_lab.rst`, `docs/source/Zh/jupyter_lab.rst` +- **Open items**: none. + +## U-20260925-64 · 2026-09-25 · The Sphinx SSH pages told right · #docs #ssh + +- **What**: the audit found the SSH Client page, in both languages, with a menu path that is not there (Tools > SSH Client Dock; it is Dock > SSH > SSH Client Dock, and the tab Tools > SSH > SSH Client Tab), three components "in a horizontal splitter" (the login widget sits above it), login fields named Username and "Password / Key" (User; Use key auth, Key with Browse..., and Password, which reads Passphrase under key authentication), menu entries named Create Folder and Upload (Create folder, Upload to this folder), no Cancel the transfer, and a file browser that "supports drag-and-drop for uploads" (nothing in the SSH code handles a drag or a drop). +- **Change**: both pages give the real paths, layout, fields and entries, and what the README says of the client (the key formats and the PuTTY note, trust on first use, F2 and Delete, Delete asking with No as the default and SFTP removing only an empty folder, background requests and temporary files, colours, the pty size, history, Interrupt, `clear`, and the line-by-line view). +- **Files**: `docs/source/Eng/ssh_client.rst`, `docs/source/Zh/ssh_client.rst` +- **Open items**: none. + +## U-20260925-65 · 2026-09-25 · The Sphinx Install pages list TestPioneer and the right fallback interpreter · #docs + +- **What**: the audit found the Install Menu page, in both languages, without the Install TestPioneer entry (U-20260924-302), and saying pip runs, with no interpreter chosen and no venv, with the Python on `PATH`; it runs with the IDE's own interpreter, and only a packaged build looks on `PATH` (`default_interpreter`, as U-20260925-54 corrected in the README). +- **Change**: both pages list Install TestPioneer (`pip install -U test_pioneer`) after MailThunder, as the menu does, and give the right fallback. +- **Files**: `docs/source/Eng/menu_install.rst`, `docs/source/Zh/menu_install.rst` +- **Open items**: none. + +## U-20260925-66 · 2026-09-25 · The Sphinx Automation pages told right · #docs #menu + +- **What**: the audit found the Automation Menu page, in both languages, naming entries that are not there: a "RUN" submenu (Run), "Run X Script With Send" (Run X With Send, for AutoControl, APITestka, WebRunner, LoadDensity and FileAutomation), a "Create Project" entry (Create X Project), "Start Record" / "Stop Record" (Record Start / Record Stop) and a "GUI Tab" (APITestka GUI, AutoControl GUI, LoadDensity GUI). It left out TestPioneer's Help submenu, where its template goes, that its file dialog takes `.yaml` too, and the whole Code Review (prthinker) menu. It said a multi-script run gathers "all matching script files" and "aggregates" their results; it collects every `.json` action file in the folder and its subfolders, runs them one after another each in a run window of its own, each reporting (and with Send, mailing) on its own, and stopping one ends the batch. +- **Change**: both pages use the menu's labels, say where a project or template is created and that it asks before replacing one, say what Record Stop does with no editor tab in front or nothing recorded, add TestPioneer's Help and the prthinker menu (its four entries and help links, and that prthinker is installed from the Install menu), describe the multi-script run as it is, and the run window's title and Stop button. +- **Files**: `docs/source/Eng/menu_automation.rst`, `docs/source/Zh/menu_automation.rst` +- **Open items**: none. + +## U-20260925-67 · 2026-09-25 · The diagram editor's unsaved-edits question in one place · #refactor #diagram + +- **What**: refactor, no change in behaviour. The question the diagram editor asks before unsaved changes are lost lived inside `may_close`; opening another diagram needs it too (the next entry). +- **Change**: `DiagramEditorWidget._may_discard_edits(question_key, fallback)` asks it, as the prompt editor's method of the same name does; `may_close` calls it with `diagram_editor_close_over_edits`. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py` +- **Checks**: `test_unsaved_on_close.py` and `test_diagram_editor_widget.py` pass unchanged (27); `ruff` clean. +- **Open items**: none. + +## U-20260925-68 · 2026-09-25 · Opening another diagram asks before unsaved changes go · #fix #diagram + +- **What**: the diagram editor's **Open** replaced the canvas and cleared the undo history with no question, so a diagram with unsaved changes was lost and could not be brought back by Undo. Closing the tab or the IDE already asked (U-20260923-164), and New asks before it discards a diagram; Open was the one way round it. Found while checking the Sphinx Tools page against the editor. +- **Change**: `_open_diagram` asks `diagram_editor_open_over_edits` ("The diagram has changes that are not saved. Open another and lose them?", No by default) through `_may_discard_edits` before the file dialog, only when the undo stack is not clean: a diagram as last saved or opened opens another unasked. The key is in both dictionaries (759 keys: the three READMEs and `architecture_explore.md` say so), and the Tools page, in both languages, says Open asks. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_unsaved_on_close.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md`, `docs/source/Eng/menu_tools.rst`, `docs/source/Zh/menu_tools.rst` +- **Checks**: three new tests in `test_unsaved_on_close.py`: No leaves the canvas, its path and its undo history alone without opening the file dialog (failed before the change: nothing was asked), Yes opens the other diagram, and a saved diagram opens another unasked. With the language parity, README key-count and diagram editor tests: all pass. `test/test_utils` collects 2592 tests; `ruff` clean. +- **Open items**: none. + +## U-20260925-69 · 2026-09-25 · The Sphinx Tools pages told right · #docs #diagram + +- **What**: the audit found the Tools Menu page, in both languages, saying every tool opens as a Tab or a Dock from the Tools menu (the docks are in the Dock menu: Dock > SSH, Dock > AI, and the diagram editor and the utilities at its top level), without CoT Code Review among the AI tools, without the thirteen HTTP / API utilities (cURL Import to Response Inspector) at all, and with diagram editor labels that are not on its toolbar (Rectangle, Rounded Rectangle, Connection, Image (file), Image (URL), Import Mermaid, Export PNG / SVG; the buttons read Rect, Rounded, Connect, Image, URL Image, Import, PNG and SVG). It left out how a connection is drawn and cancelled, the right-click menus, Delete, Esc, the wheel zoom and the right- or middle-drag pan, that Save goes back to the file last opened or saved, that Import replaces the canvas as one undo step, what the property panel edits for an image, how a saved diagram's images come back, and that closing with unsaved changes asks. +- **Change**: both pages open with a table of where each tool's tab and dock are, list the five AI tools and the thirteen utilities (with the Ctrl+Enter rule), and describe the diagram editor from its two toolbar rows with the labels it shows. The Chinese page keeps the English labels, as the other Chinese pages do. +- **Files**: `docs/source/Eng/menu_tools.rst`, `docs/source/Zh/menu_tools.rst` +- **Checks**: `test_docs_markup.py` passes (26); the Sphinx build gives no warning, and neither built page shows a stray `**` or double backquote. +- **Open items**: none. + +## U-20260925-70 · 2026-09-25 · CoT Code Review labels its code box as the code · #fix #ai + +- **What**: in CoT Code Review the box the code to review is pasted into was labelled "Prompt Area" (Chinese: 傳送資料區域, "area of data to send"), while its placeholder said "Paste the code to review here". The prompts are the eight templates of the CoT Prompt Editor; this box holds the code each of them quotes, so the label sent people looking for a prompt to type. Found while checking the Sphinx AI Tools page against the panel. +- **Change**: the label reads "Code to Review" / 要審查的程式碼 (the key `cot_gui_label_prompt_area` is kept, so a translation plugin that sets it still does). +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py` (its comment), `test/test_utils/test_ai_gui_lifecycle.py`, `architecture_explore.md` (test count) +- **Checks**: `TestCoTLabels` finds the new label in both languages and no label with "Prompt" or 傳送資料 (the English case failed with the old label); `test_ai_gui_lifecycle.py` and `test_language_parity.py` pass (37). `test/test_utils` collects 2594 tests; `ruff` clean. +- **Open items**: none. + +## U-20260925-71 · 2026-09-25 · The Sphinx AI Tools pages told right · #docs #ai + +- **What**: the audit found the AI Tools page, in both languages, with menu paths that are not there (Tools > AI Code Review Tab, and for CoT Code Review the AI Code Review entry; the entries are under Tools > AI and Dock > AI), labels the panels do not show (URL Input, Method Selector, Code Area, Send Button, Response Selector; they read URL, Method, Code to Send, API URL, Code to Review, Step, Start Sending, LLM API URL, Select Prompt Template), no Accept / Reject buttons, the data files at `.pybreeze/` in the working folder (they are under `~/.pybreeze/`), CoT Code Review "reviewing multiple files at once" (it reviews the pasted code in eight steps, one request each), a Create button (Create File) and an editor that "auto-reloads" over unsaved edits (it asks), and a note pointing at a local LLM server, which the URL check refuses. +- **Change**: both pages list the five tools with their real paths and labels, say what each request sends (`code` form field, `{"prompt": ...}`, `{"code": ...}`), the eight CoT steps in order and why, where prompts are kept and which wins, when the editors ask, that Skill Send will not send while `{code_diff}` is in the prompt, the files AI Code Review keeps, and what the URL check allows (public addresses only, no redirects, 16 MB, five minutes). +- **Files**: `docs/source/Eng/ai_tools.rst`, `docs/source/Zh/ai_tools.rst` +- **Checks**: `test_docs_markup.py` passes (26); the Sphinx build gives no warning, and neither built page shows a stray `**` or double backquote. +- **Open items**: none (whether a local model server should be allowed is `progress.md` #111). + +## U-20260925-72 · 2026-09-25 · The Plugin Browser is there before any plugin is · #fix #plugins + +- **What**: `set_plugin_menu` returned before building the Plugins menu when no plugin was loaded, so on a fresh install there was no Plugins menu and no Plugin Browser, the tool the README offers for installing plugins. The first plugin had to be copied into `jeditor_plugins/` by hand before the browser could be reached. JEditor's own Plugins menu always has the browser. Found while checking the Sphinx Plugins page against the menus. +- **Change**: the Plugins menu is always built with its Plugin Browser entry; the separator and one entry per plugin follow when plugins are loaded. The three READMEs say where the browser is, that it is there before any plugin is installed, and that an installed plugin loads at the next start (the browser says so as it installs). +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `test/test_utils/test_plugin_menu.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` (§5.4 and the test count) +- **Checks**: `test_no_plugins_means_no_menu`, which recorded the old behaviour, became `test_with_no_plugins_the_menu_still_offers_the_plugin_browser`; `test_the_plugin_browser_opens_as_a_tab` triggers the entry. Both failed before the change. `test_plugin_menu.py`, `test_answered_boxes_are_deleted.py` and `test_jeditor_contract.py` pass (87). A menu dump of the IDE started in a folder with no `jeditor_plugins/` ends with Plugins > Plugin Browser. `test/test_utils` collects 2595 tests; `ruff` clean. +- **Open items**: none. + +## U-20260925-73 · 2026-09-25 · The Sphinx Plugins and extension pages told right · #docs #plugins + +- **What**: the audit found the Plugins Menu page, in both languages, with a Run With list of five languages it claimed are supported (the Run with... submenu lists whatever run configurations plugins registered, and exists only when one did), a syntax example that imports `syntax_word_dict` from `je_editor` (there is no such name) with no `register()` (JEditor warns and counts such a plugin as skipped), and a translation example that overwrote the current dictionary instead of registering a language. How to Extend the UI registered its plugin tab at import rather than in `register()`, and said nothing about `may_close()`. Checking them against JEditor 1.0.27 also showed that a registered language's keywords are used only for a suffix JEditor does not colour itself: the README called syntax plugins good "for any language", and PLUGIN_GUIDE offered C, C++, Go, Java and Rust highlighting plugins whose highlighting JEditor's own rules now replace (the cause of `progress.md` #103). +- **Change**: the Plugins page, in both languages, says how plugins are found and loaded, what the Plugin Browser does (repository, install folder, asking before replacing, loaded at the next start), what each loaded plugin's entry holds, how Run with... saves, checks and runs a file, what a run configuration holds (with PyBreeze's `encoding`), and gives a syntax example for `.lua` and a translation example through `register_natural_language`, both inside `register()`, with the suffixes JEditor colours itself. How to Extend the UI registers the plugin tab in `register()` and describes `may_close()`. The three READMEs and PLUGIN_GUIDE (both languages) state the highlighting limit. `progress.md` #113 records that a translation plugin's display name is not used by JEditor's Language menu. +- **Files**: `docs/source/Eng/menu_plugins.rst`, `docs/source/Zh/menu_plugins.rst`, `docs/source/Eng/how_to_extend_ui.rst`, `docs/source/Zh/how_to_extend_ui.rst`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `PLUGIN_GUIDE.md`, `progress.md` +- **Checks**: the three plugin examples, copied from the built page into a `jeditor_plugins/` folder, load in the IDE: the Plugins menu lists French and Lua syntax, the Language menu lists French, the tab example adds a My Tool tab, and the Lua example colours `local`, `function`, `return` and `end` and the comment. `test_docs_markup.py` passes (26); the Sphinx build gives no warning, and no built page shows a stray `**` or double backquote. +- **Open items**: `progress.md` #113. + +## U-20260925-74 · 2026-09-25 · The main window opens from its own function · #refactor #startup + +- **What**: refactor, no change in behaviour. `start_editor()` built the window, styled it, showed it and applied the saved settings, then ran the application and ended in `os._exit()`, so none of those steps could be tested without ending the test run; the theme fix in the next entry needs a test there. +- **Change**: `open_main_window(app, debug_mode, theme, **kwargs)` in `main_ui.py` does those steps and returns the window, which `start_editor()` holds until the application ends. `architecture.md` §4's start-up flow and `architecture_explore.md` §3 name it (and §3's translation count, 735, is now the 759 there are). +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `architecture.md`, `architecture_explore.md` +- **Checks**: `test_gui_thread_gc.py`, `test_package_facade.py` and `test_jeditor_contract.py` pass (51); both start-up tests (`start_automation_test`, `extend_automation_test`) exit 0; `ruff` clean. +- **Open items**: none. + +## U-20260925-75 · 2026-09-25 · start_editor's theme is the one the IDE shows · #fix #startup + +- **What**: `start_editor(theme="dark_teal.xml")`, as the README, the Sphinx pages and `architecture.md` show it, never changed the theme. `start_editor` applied the argument, then JEditor's `startup_setting()` applied the saved `ui_style` over it: the theme picked from UI Style, and `dark_amber.xml` when none had been, so even a first launch ended in dark amber. Found while checking the Sphinx pages' UI Style section against JEditor 1.0.27. +- **Change**: `open_main_window()` writes a given theme into JEditor's `user_setting_dict["ui_style"]` before the settings are applied, so it is the one shown and is saved as the picked theme, as picking it from UI Style does. `theme` now defaults to `None`, which keeps the picked theme; the first `apply_stylesheet` uses the picked theme too, so a saved light theme no longer shows dark for a moment before it. `user_setting_dict` joins the JEditor internals `test_jeditor_contract.py` pins, with a test that `startup_setting()` still reads `ui_style`, and `architecture.md` §6's table. The three READMEs' quick start, `architecture.md` §3, `architecture_explore.md` §3 and the Sphinx Getting Started and UI Overview pages (both languages) say what the argument does. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/test_start_theme.py` (new), `test/test_utils/started_window.py` (`saved_settings` and `build`), `test/test_utils/test_jeditor_contract.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture.md`, `architecture_explore.md`, `docs/source/Eng/getting_started.rst`, `docs/source/Zh/getting_started.rst`, `docs/source/Eng/ui_overview.rst`, `docs/source/Zh/ui_overview.rst` +- **Checks**: `test_start_theme.py` starts the real window in a child and reads the theme qt_material applied: a given theme is shown and saved, and replaces a saved one (both failed before the change, showing the saved theme); with none given, the saved one, or `dark_amber.xml`, is shown. The contract, start-up language, facade and GC tests pass (81); both start-up tests exit 0; the Sphinx build gives no warning. `test/test_utils` collects 2601 tests in 144 files; `ruff` clean. +- **Open items**: none. + +## U-20260925-76 · 2026-09-25 · The Sphinx overview, getting-started and base-menu pages told right · #docs + +- **What**: the audit found the UI Overview page, in both languages, listing menus that are not there (Venv, a top-level Run With) and leaving out Python Env, Tab, Dock and Language; a Delete that deletes (it moves to the trash, after asking with No as the default); Reveal in Explorer (Reveal in File Explorer, which selects the file on Windows and macOS); automation keywords said to be highlighted (`progress.md` #103); an output panel and a "Code Output Window" described as neither is; a dock list of six; and a theme list with `light_red` and `light_yellow`, which the UI Style menu does not offer, and without `light_cyan_500`, which it does. The File, Run, Text page had File > Encoding as a dialog (it is a submenu of codecs), a Stop Program entry (Stop current program, Stop All Program), Font and Font Size as dialogs, a Venv menu (Python Env) without Choose python interpreter, and a venv note that did not say which runs look for `venv/` or `.venv/`. Getting Started said Python "3.10 or higher" (3.10 to 3.14 are the versions tested) and called the output panel by another name. A subagent read JEditor 1.0.27 for what each base-menu entry does; the claims used here were checked against the code, and the debugger's was reproduced. +- **Change**: UI Overview lists the thirteen menus in order, the file tree's keys and menu as they behave, the output panel's six tabs, the run window, the Dock menu's groups, the sixteen themes and the four languages, and tells Chinese readers the guide uses the English labels (Tools is 工具, Dock is 區域). The File, Run, Text page gives every entry of File, Run, Text, Check Code Style, Python Env, Tab, Dock and UI Style what it does, which interpreter each run uses, where settings are kept, and that Save File, Run Program and Run Debugger always ask where to save and Run Debugger runs once per tab with JEditor 1.0.27 (`progress.md` #114, #115, recorded with this entry). Getting Started gives the tested Python versions, installing from source, that the automation modules come with PyBreeze, and what the working folder decides. +- **Files**: `docs/source/Eng/ui_overview.rst`, `docs/source/Zh/ui_overview.rst`, `docs/source/Eng/menu_file_run_text.rst`, `docs/source/Zh/menu_file_run_text.rst`, `docs/source/Eng/getting_started.rst`, `docs/source/Zh/getting_started.rst`, `progress.md` +- **Checks**: `test_docs_markup.py` passes (26); the Sphinx build gives no warning, and no built page shows a stray `**` or double backquote. The Run Debugger limit was reproduced in the real window: after `pdb` quit (exit 0) a second Run Debugger in the tab was refused. +- **Open items**: `progress.md` #114, #115. + +## U-20260925-77 · 2026-09-25 · Stop All Program stops PyBreeze's runs too · #fix #run + +- **What**: Run > Stop All Program is JEditor's: it stops the programs, shell commands, debuggers and pip runs its own menus started. An automation script, a batch, a package install or a Run with... run shows in one of PyBreeze's run windows, and went on after it; only each window's Stop button, or closing the IDE, stopped them. Found through the subagent's reading of JEditor's base menus for the Sphinx File, Run, Text page. +- **Change**: `PyBreezeMainWindow.stop_all_runs()` calls `stop_runner()` on every run window, each guarded so one that fails is logged and the others still stop, and is connected to JEditor's `run_menu.stop_all_program_action` as the window is built. The windows stay open with their output. The three READMEs (Script execution), the Run page (both languages), `architecture_explore.md` and `architecture.md` §6 (the action PyBreeze relies on) say so, and `test_jeditor_contract.py` checks the action is still built. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/test_stop_all_runs.py` (new), `test/test_utils/test_jeditor_contract.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture.md`, `architecture_explore.md`, `docs/source/Eng/menu_file_run_text.rst`, `docs/source/Zh/menu_file_run_text.rst` +- **Checks**: `test_stop_all_runs.py` starts the real window in a child, adds two run windows and one whose stop raises, and triggers Stop All Program: both are stopped and all three stay (failed before the change: none was stopped). The contract, IDE-closing and docs-markup tests pass (71); the Sphinx build gives no warning. `test/test_utils` collects 2603 tests in 145 files; `ruff` clean. +- **Open items**: none. + +## U-20260925-78 · 2026-09-25 · The Sphinx pages say what a tree click does to a tab's text · #docs + +- **What**: the UI Overview page (U-20260925-76 kept the line) said a double-click in the file tree opens a file. It is a single click, and it opens the file into the editor tab in front, replacing what that tab holds without asking, as File > Open File does (JEditor's `open_an_file`). Reproduced on je_editor 1.0.27 in the real window: an untitled tab's text was replaced and nothing was asked. A tab with a file loses only the last seconds of typing, since it is saved every 5 s. +- **Change**: UI Overview (both languages) says a click opens the file into the tab in front, and a file open in another tab switches to it; a note says unsaved text there is not asked about, and to open a new tab first to keep it. The File menu's Open File row says the same. `progress.md` #116 records it for JEditor. +- **Files**: `docs/source/Eng/ui_overview.rst`, `docs/source/Zh/ui_overview.rst`, `docs/source/Eng/menu_file_run_text.rst`, `docs/source/Zh/menu_file_run_text.rst`, `progress.md` +- **Checks**: `test_docs_markup.py` passes (26); the Sphinx build gives no warning. +- **Open items**: `progress.md` #116. + +## U-20260925-79 · 2026-09-25 · Why a key did not load, decided in one place · #refactor #ssh + +- **What**: refactor, no change in behaviour. `unloadable_key_reason` had a cognitive complexity of 17, over the gate of 15 in CLAUDE.md (measured with the `cognitive_complexity` package over every function in `pybreeze/`; it was the only one over), because the choice between no passphrase, a wrong one and an unsupported key was written out twice, for an encrypted PKCS#8 file and for a key a paramiko class asks a passphrase for. +- **Change**: `_asks_for_passphrase(key_path)` tries the key classes, and `_encrypted_key_reason(key_path, password)` makes the choice once; `unloadable_key_reason` scores 3, and no function in the module more than 7. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py` +- **Checks**: `test_ssh_security.py` (which calls `unloadable_key_reason` with real RSA, ECDSA, Ed25519, PKCS#8, PuTTY and DSA key files) and `test_ssh_login_form.py` pass unchanged (37), and `test_ssh_security.py` also with paramiko 5.0.0 (27); `ruff` clean. The other quality gates were checked at the same time: `ruff`'s complexity, argument, branch and nesting rules at the CLAUDE.md limits, no function over 60 lines of code, no file over 1000 lines. +- **Open items**: none. + +## U-20260925-80 · 2026-09-25 · The variable inspector is not offered as a feature · #docs + +- **What**: the three READMEs listed a variable inspector among the editor's features. JEditor's Variable Inspector (Tab > Tools Tab, Dock > Editor, and a tab of each editor's bottom panel) shows the namespace it is given, and nothing gives it one: it is always empty on je_editor 1.0.27. Found checking a subagent's reading of JEditor's base menus. +- **Change**: the READMEs' Code editor bullet no longer names it; the Sphinx File, Run, Text and UI Overview pages (both languages) say it is empty; `progress.md` #117 records it for JEditor. +- **Files**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `docs/source/Eng/menu_file_run_text.rst`, `docs/source/Zh/menu_file_run_text.rst`, `docs/source/Eng/ui_overview.rst`, `docs/source/Zh/ui_overview.rst`, `progress.md` +- **Checks**: no code in je_editor 1.0.27 sets `VariableModel.namespace`; `test_docs_markup.py` passes (26) and the Sphinx build gives no warning. +- **Open items**: `progress.md` #117. + +## U-20260925-81 · 2026-09-25 · architecture_explore.md's line counts re-measured · #docs + +- **What**: CLAUDE.md asks for the line counts `architecture_explore.md` quotes to be re-measured when the code they count changes. Today's changes to the diagram editor and the SSH key loader left two of them behind, and the header's count of lines without blanks or comments was 19,400 against 18,876 measured. +- **Change**: the header says about 24,200 lines, about 18,900 without blank lines and `#` comments (209 files, unchanged); the diagram editor section 4,042 lines (was 4,034); the `ssh/` section 2,516 (was 2,508). `syntax_keyword.py`'s 629 is still right. +- **Files**: `architecture_explore.md` +- **Checks**: counted with `wc -l` and a line filter over `pybreeze/**/*.py`. +- **Open items**: none. + +## U-20260925-82 · 2026-09-25 · A plugin's About box speaks the IDE language · #fix #i18n #plugins + +- **What**: the About box of a loaded plugin (Plugins > the plugin, or its submenu's About) said "Version:" and "Author:" in English whatever the IDE spoke; it was the one piece of UI text in `pybreeze/` built outside the dictionaries (a search for literal English in labels, buttons, titles and message texts found no other). +- **Change**: the text comes from the key `plugin_about_text` (the name, then `Version: {version}` and `Author: {author}` on lines of their own; 版本 and 作者 in Traditional Chinese), still shown as plain text. The dictionaries have 760 keys each; the three READMEs and `architecture_explore.md` say so. +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_plugin_menu.py`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture_explore.md` +- **Checks**: `test_the_about_dialog_speaks_the_ide_language` (failed before the change: the text was English in the Chinese IDE); the plugin menu, language parity, message box, keys-used and answered-box tests pass (77), and the README key-count tests. `test/test_utils` collects 2604 tests; `ruff` clean. +- **Open items**: none. + +## U-20260925-83 · 2026-09-25 · A damaged saved theme stops JEditor's window: recorded · #investigation + +- **What**: reviewing the theme change (U-20260925-75), I checked what a settings file with a `ui_style` that is not a theme name does. qt_material logs a warning for a name it does not know and raises `TypeError` for a number or `null`. With `{"ui_style": 42}` saved, the IDE did not start, before or after that change: JEditor's `EditorMain.__init__` calls `startup_setting()` itself, unguarded, and the error leaves the window's constructor before PyBreeze runs any code of its own. A guard in `open_main_window()` was tried and dropped, since it cannot be reached. +- **Change**: `progress.md` #118 records it for JEditor. No code change. +- **Files**: `progress.md` +- **Checks**: reproduced with a script that saves `{"ui_style": 42}` in a scratch working folder and builds the window: `TypeError` from `qt_material.get_theme` inside `EditorMain.__init__` (`main_editor.py:245`). +- **Open items**: `progress.md` #118. + +## U-20260925-84 · 2026-09-25 · A run whose window was closed can be stopped from the Run menu · #test #run + +- **What**: `progress.md` #2 said a run whose window had been closed could be stopped only by a task manager or by closing the IDE. A window closed while its run goes on stays in the main window's list (`CodeWindow.closeEvent`), so Stop All Program, which since U-20260925-77 stops every run window's run, reaches it too; nothing tested that. +- **Change**: `test_a_run_whose_window_was_closed_can_still_be_stopped` opens a real run window with `open_run_window`, gives it a runner whose process is still running, closes the window and triggers Stop All Program: the window was kept and its run stopped. #2 now says Stop All Program or closing the IDE stops such a run; whether closing one window should stop its run is still the owner's decision. +- **Files**: `test/test_utils/test_stop_all_runs.py`, `progress.md`, `architecture_explore.md` (test count) +- **Checks**: `test_stop_all_runs.py` passes (2); `test/test_utils` collects 2605 tests; `ruff` clean. +- **Open items**: `progress.md` #2 (the decision). + +## U-20260925-85 · 2026-09-25 · The PDF of the guide keeps its Chinese half · #fix #docs + +- **What**: Read the Docs builds the guide as HTML, an ePub and a PDF (`.readthedocs.yaml`). The PDF went through Sphinx's default `pdflatex`, which cannot set CJK, and the build still reported success: the downloaded PDF (52 pages) had no Chinese character at all, so the Traditional Chinese half was headings and code with the words missing (the tab example read `EDITOR_EXTEND_TAB.update({"": MyToolWidget})`). The ePub, built from HTML, was whole. Read the Docs adds CJK defaults only to a project whose language is Chinese; this one is English. +- **Change**: `docs/source/conf.py` sets `latex_engine = "xelatex"`, `latex_use_xindy = False`, and a preamble loading `xeCJK` with Noto Serif / Sans / Sans Mono CJK TC, which Read the Docs' ubuntu-22.04 image has (`texlive-full`, `fonts-noto-cjk-extra`, per its Dockerfile). ctex's own default fonts were not used: Read the Docs issue #6319 reports them missing Traditional characters such as 佈 and 換. +- **Files**: `docs/source/conf.py` +- **Checks**: built locally with TinyTeX 2026.09 (xelatex, xeCJK, FreeFont) and the three Noto CJK TC fonts: 64 pages, 10,371 Chinese characters in the text, no missing-glyph warning, 佈 and 換 present. That TeX Live 2026 stops at Sphinx 9.0.4's booktabs table rules (`TeX capacity exceeded` at `\sphinxmidrule`, with or without xeCJK), so the local check used `-D latex_table_style=standard`; Read the Docs' image has TeX Live 2021, where the default table style built before this change. The HTML build gives no warning; the Read the Docs PDF is to be checked after the next build of `latest`. +- **Open items**: none. + +## U-20260925-86 · 2026-09-25 · An arrow's label is read in linear time · #fix #diagram + +- **What**: SonarCloud's open issues on `main` (174: 19 in `pybreeze/`, 155 in tests) include S8786 on the Mermaid importer's arrow-label pattern, `\|\s*("[^"]*"|[^|]*)\s*\|`. Its spaces could be matched by the `\s*` on either side or by the label itself, and on a label with no closing bar that backtracked in cubic time: 800 spaces took 0.5 s, 3,000 about 28 s. The importer itself only hands `_parse_arrow` tokens whose label is closed, which match at once, so a pasted diagram could not reach it; any other caller could. +- **Change**: `_arrow_label()` reads the label with two patterns whose neighbouring parts share no character, one for a quoted label (which may hold a bar) and one for a plain one, and takes the leftmost match, the quoted one when both start at the same bar, as the single pattern's alternation did. 200,000 spaces take 4 ms. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `test/test_utils/test_mermaid_parser.py`, `architecture_explore.md` (the parser's note, its line count and the diagram editor's, the test count) +- **Checks**: `TestArrowLabels`: thirteen tokens with the labels the old pattern gave (recorded before the change), a Hypothesis property with the old pattern as the oracle on tokens of up to 14 characters from bar, quote, space, tab and a letter (200,000 random tokens also compared by hand: no difference), and the time of two 3,000-space unclosed labels (failed before the change: 28 s). The Mermaid parser and diagram editor tests pass (109); `test/test_utils` collects 2620 tests; `ruff` clean. +- **Open items**: the other SonarCloud issues on `main`, next. + +## U-20260925-87 · 2026-09-25 · SonarCloud's open issues in pybreeze/ dealt with · #refactor #quality + +- **What**: refactor, no change in behaviour. After the arrow-label fix (U-20260925-86), eighteen of SonarCloud's open issues on `main` still applied to `pybreeze/` on `dev` (checked line by line against `origin/main`). +- **Change**: S7632 (3): a comma in a `# noqa: CODE — reason` comment is read as starting another rule code, so the three reasons with commas are reworded, and `test_no_noqa_reason_has_a_comma` keeps new ones out. S5713 (2): `ConnectTimeoutError` alone (urllib3's `NewConnectionError` is one) and `(ImportError, ValueError)` (a `UnicodeError` is a `ValueError`), with comments saying so. S5655 (2): `_number`'s fallback is a type variable, so `None` is a fallback its signature allows. S7504 (2): the SFTP viewer's teardown loops iterate over tuple copies, with the reason. S1192 (2): the facade's module paths and the dictionaries' repeated "Result:" / 「結果:」 are constants. S2589: Run with...'s Save As branch is `_save_as()`, where Sonar no longer assumes the tab still has no file after the dialog. S2583: the float check in `request_body` is two checks with the same message. S5843: the incomplete-escape pattern in `terminal_text` is composed from three named parts (the compiled pattern is byte-for-byte the old one). False positives marked `# NOSONAR — ` as `diagram_scene.py` does: S2068 on two Chinese passphrase messages, S5332 on mounting the checked adapter for `http://`, S1313 on the CGNAT range that is refused. The 155 issues in `test/` (composite asserts, `@pytest.fixture()` parentheses and the like) are not touched here. +- **Files**: `pybreeze/__init__.py`, `pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `pybreeze/utils/curl_import/request_body.py`, `pybreeze/utils/network/public_http.py`, `pybreeze/utils/network/url_validation.py`, `pybreeze/utils/terminal_text.py`, `test/test_utils/test_no_blind_except.py`, `architecture_explore.md` (line and test counts) +- **Checks**: the tests of every module touched pass (1273, 1 skipped); `test/test_utils` collects 2621 tests; `ruff` clean. Whether SonarCloud agrees shows on its next analysis of `main`. +- **Open items**: the test-code issues, if wanted. + +## U-20260925-88 · 2026-09-25 · Fixture decorators without empty parentheses · #refactor #test + +- **What**: refactor of test code, no change in behaviour. SonarCloud reported 59 decorators written with empty parentheses (S9083) on `main`; `dev` has 70, all `@pytest.fixture()`, which pytest treats exactly as `@pytest.fixture`. +- **Change**: the 70 are `@pytest.fixture` (a sed over the 51 files that had one; fixtures with arguments are untouched). +- **Files**: 51 files in `test/test_utils/` +- **Checks**: `test/test_utils` still collects 2621 tests; a sample of files using these fixtures passes (173). The other test-code issues (composite asserts, S9073) are left: splitting 78 asserts by hand is churn for a clearer failure message only. +- **Open items**: none. + +## U-20260925-89 · 2026-09-25 · A node and an image resize through one function · #refactor #diagram + +- **What**: refactor, no change in behaviour. SonarCloud's only duplication in the project (0.1 %) was the resize arithmetic `DiagramNode._apply_resize` and `DiagramImage._apply_resize` each spelled out (16 and 14 lines), differing only in the minimum height; CLAUDE.md asks for a helper once a block of six lines or more repeats. +- **Change**: `resized_geometry(role, delta, orig_rect, orig_pos, min_w, min_h)` in `diagram_items.py` gives the new position and size; the node calls it with `_MIN_NODE_W` / `_MIN_NODE_H` (40 x 20) and the image with `_MIN_IMAGE_SIDE` (40 x 40), the constants the constructors already used. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_editing.py`, `architecture_explore.md` (line and test counts) +- **Checks**: `TestResizeFromAHandle` (fourteen drags of a node and an image, every side, both clamps, the far side staying put) was written first and passed before the change as after it; the diagram editing, editor, property panel and image tests pass (79). `test/test_utils` collects 2635 tests; `ruff` clean. +- **Open items**: none. + +## U-20260925-90 · 2026-09-25 · The diagram canvas's zoom and grid are tested · #test #diagram + +- **What**: SonarCloud's coverage by file put `diagram_view.py` at 47.7 % on `main` (71 % from the diagram tests run locally): nothing tested Ctrl+0's `set_zoom`, the grid's properties, or the background it draws when Grid is on. +- **Change**: `test_diagram_view_zoom_grid.py`: `set_zoom` sets (not multiplies) the scale, keeps it within 10 %–500 % and reports the percentage; the grid is off until turned on and a cell is never under 5; the background drawn into an image is plain without the grid, and with it has a major line every fifth cell and minor lines on multiples of the cell size, left of the origin too (Python's modulo of a negative coordinate). Fit's clamping was already tested in `test_diagram_editing.py`. +- **Files**: `test/test_utils/test_diagram_view_zoom_grid.py` (new), `architecture_explore.md` (test count) +- **Checks**: the nine tests pass; the diagram tests now cover 97 % of `diagram_view.py` (the rest: a grid-size repaint while the grid is on, and one branch each of `fit` and `mousePressEvent`). `test/test_utils` collects 2644 tests in 146 files. +- **Open items**: none. + +## U-20260925-91 · 2026-09-25 · The diagram canvas's clicks and keys are tested · #test #diagram + +- **What**: `diagram_scene.py` had 68.8 % coverage on `main` (83 % from the diagram tests locally). Untested were drawing a connection by clicking, cancelling one, the Delete / Backspace / Esc keys, deleting a selection, Select All and the main path of vertical distribution. +- **Change**: `test_diagram_scene_actions.py`: a click on the source and one on the target make one connection, one undo step, with the dashed line gone and the tool back to Select; a click on the empty canvas, on the source itself or Esc cancels it; Delete and Backspace take a node and its connections in one undo step, a connection alone leaves its nodes, and nothing selected leaves no undo step; while a node's text is being edited, Delete edits the text instead of removing the node; Select All takes nodes, connections and images but not a connection being drawn; vertical distribution leaves equal gaps between the outer two and moves nothing sideways. +- **Files**: `test/test_utils/test_diagram_scene_actions.py` (new), `architecture_explore.md` (test count) +- **Checks**: the eleven tests pass as the code stands (no behaviour needed changing); the diagram tests now cover 91 % of `diagram_scene.py`. `test/test_utils` collects 2655 tests in 147 files. +- **Open items**: none. + +## U-20260925-92 · 2026-09-25 · The property panel's node edits and colour button are tested · #test #diagram + +- **What**: `diagram_property_panel.py` had 68.9 % coverage on `main`. A connection's and an image's edits were tested; a node's text, shape, fill and border edits were not, nor the colour button's dialog, nor an edit arriving with nothing selected. +- **Change**: in `test_diagram_property_panel.py`: a node's text, shape and both colours follow the panel, four undo steps; leaving the text box unchanged records nothing (its `editingFinished` comes with every focus change, and a step would mark a saved diagram as changed); an edit with nothing selected changes nothing; the colour button shows and passes on a picked colour, and a cancelled dialog changes nothing. +- **Files**: `test/test_utils/test_diagram_property_panel.py`, `architecture_explore.md` (test count) +- **Checks**: the file's ten tests pass as the code stands. `test/test_utils` collects 2660 tests. +- **Open items**: none. + +## U-20260925-93 · 2026-09-25 · The prthinker menu is tested beyond reviewing a file · #test #prthinker + +- **What**: `build_prthinker_menu.py` had 42.6 % coverage on `main` (43 % locally): only Review the current file was tested. Review a Pull Request, Settings and the menu itself were not. +- **Change**: in `test_prthinker_menu_review.py`: the pull request number asked for (from 1 to 1,000,000, starting at 1) is the one reviewed, cancelling the question reviews nothing, and without a repository set the user is told so; the menu's entries read Review the current file, Review a Pull Request, Settings and Help, and Settings, the documentation and GitHub open what they should; Settings opens as a dialog of the main window, deleted on close. +- **Files**: `test/test_utils/test_prthinker_menu_review.py`, `architecture_explore.md` (test count) +- **Checks**: the file's eight tests pass as the code stands. `test/test_utils` collects 2665 tests. +- **Open items**: none. + +## U-20260925-94 · 2026-09-25 · Every Tools tab entry is opened in a test · #test #menu + +- **What**: `test_tools_menu_docks.py` opened every tool as a dock, and checked that each tool has a tab entry, but no test triggered the tab entries: a factory that failed, or a label key with no text, would have shown only in the running IDE (the Tools menu is built at start-up, which the unit tests reach only in child processes that coverage does not follow). +- **Change**: `test_every_tools_tab_entry_opens_its_widget_under_its_label` builds the Tools menu on a stand-in main window with the home folder in a scratch directory, and triggers all twenty tab entries: each sits in its submenu, reads its dictionary text, and opens its widget in a tab under its label. +- **Files**: `test/test_utils/test_tools_menu_docks.py`, `architecture_explore.md` (test count) +- **Checks**: the file's seventeen tests pass as the code stands. `test/test_utils` collects 2666 tests. +- **Open items**: none. + +## U-20260925-95 · 2026-09-25 · architecture.md's custom-tab extension point is complete · #docs + +- **What**: `architecture.md` §5 said custom tabs are added to `EDITOR_EXTEND_TAB` before `start_editor()`, which the Sphinx How to Extend the UI page (U-20260925-73) says more fully: a file plugin can register one in `register()`, a widget with `may_close()` is asked before it closes, and a constructor that raises costs only its own tab. +- **Change**: §5's Custom tabs entry says all three, pointing at `pybreeze_ui/closing.py`. +- **Files**: `architecture.md` +- **Checks**: checked against `main_ui.py` (`_add_extend_tabs`, `close_tab`, `_tool_tabs_may_close`) and `closing.py`. +- **Open items**: none. + +## U-20260925-96 · 2026-09-25 · The SFTP tree's download and upload dialogs are tested · #test #ssh + +- **What**: the SFTP tree's transfers were tested from `_start_transfer` on (replacing, cancelling, temporary files, freeing), but not the actions in front of them: Download, Upload and New Folder with no place chosen had no test (86 % coverage of `ssh_file_viewer_widget.py` locally). +- **Change**: in `test_sftp_tree_actions.py`: a file downloads to the path the save dialog gives, which offered the file's own name; a cancelled save or a folder downloads nothing (a folder is told so); an upload goes into the folder chosen, or into the folder of the file chosen, and refreshes that folder once it is done; a cancelled choice uploads nothing; New Folder with nothing chosen says so and creates nothing. +- **Files**: `test/test_utils/test_sftp_tree_actions.py`, `architecture_explore.md` (test count) +- **Checks**: the seven tests pass as the code stands. `test/test_utils` collects 2673 tests. +- **Open items**: none. + +## U-20260925-97 · 2026-09-25 · The SSH terminal's key authentication and input checks are tested · #test #ssh + +- **What**: that every connect refuses SHA-1 was tested for the terminal's password login and the file tree, not for the terminal's key login (`_connect_with_key`), the path CLAUDE.md's SSH rule covers just the same. Also untested: the terminal's checks before it connects, and what an encrypted key with no passphrase says. +- **Change**: in `test_ssh_reentrancy.py`: key authentication passes `SHA1_ALGORITHMS` and the loaded key, and no password; a key needing a passphrase, with none given, connects nothing and the terminal shows the passphrase message; an empty host or user, or a key file that is not there, is refused with a warning before any connect starts. +- **Files**: `test/test_utils/test_ssh_reentrancy.py`, `architecture_explore.md` (test count) +- **Checks**: the file's 21 tests pass as the code stands. `test/test_utils` collects 2678 tests. +- **Open items**: none. + +## U-20260925-98 · 2026-09-25 · Installing JupyterLab for the tab is tested · #test #jupyter + +- **What**: the JupyterLab tab installs JupyterLab into the interpreter it runs in when that interpreter has none (U-20260925-62 made it the interpreter a run uses); that path had no test (`jupyter_lab_thread.py` 84 %, `jupyter_lab_widget.py` 75 % locally), nor the tab's switch from its status line to the lab. +- **Change**: in `test_jupyter_ready.py`: a missing JupyterLab is installed with `python -m pip install jupyterlab -U` into the interpreter the lab runs in, with the five-minute limit, the tab says it is downloading, and the server then starts on the port found and is announced at `http://localhost:/lab`; a failed install shows pip's reason and starts no server; a tab closed during the install gets no server and no error; once the lab loads, the status line is gone and a late status or error does nothing. +- **Files**: `test/test_utils/test_jupyter_ready.py`, `architecture_explore.md` (test count) +- **Checks**: the four tests pass as the code stands. `test/test_utils` collects 2682 tests. +- **Open items**: none. + +## U-20260925-99 · 2026-09-25 · The JupyterLab tab test no longer crashes the test run on exit · #test #fix #jupyter + +- **What**: after U-20260925-98 the whole `test/test_utils` run reported 2667 passed and 15 skipped but exited with 139, which fails the CI job. `test_jupyter_ready.py` alone reproduced it every time, down to `TestTheTab`: its `load_lab()` had Chromium load a page, and the view (closed and given to `deleteLater()`, which no event loop carried out) was still alive when the interpreter ended. +- **Change**: the test records the URL the tab gives its web view instead of loading it, checks that the view is shown, and carries out the tab's delete (`sendPostedEvents(tab, DeferredDelete)`) before it ends. The widget is unchanged: a script that loads the lab in a tab, closes it the way the IDE does and quits the event loop exits with 0 (twice), while the test's old steps exit with 139 (twice). +- **Files**: `test/test_utils/test_jupyter_ready.py` +- **Checks**: `test_jupyter_ready.py` with `test_jupyter_lifecycle.py` exits with 0 three times out of three (27 passed each). +- **Open items**: none. + +## U-20260925-100 · 2026-09-25 · Coverage counts the tests that start the real main window in a child · #ci #test #coverage + +- **What**: SonarCloud put `main_ui.py` at 31.5 % and `build_menubar.py` at 51.4 %, though tests build the whole main window, menus included: they do it in a child interpreter (`test/test_utils/started_window.py`), and CI installs pytest-cov unpinned, whose 7.0 dropped the measurement of child processes. +- **Change**: `.coveragerc` sets `patch = subprocess` (coverage 7.10+, which pytest-cov 7 requires): every Python child a test starts measures itself into a data file of its own, and pytest-cov combines them before the XML report. The comment on `relative_files` is corrected too: the report carries the relative `pybreeze` and package-relative file names, not repo-root-relative ones. `CLAUDE.md` (Branching & CI) and `architecture_explore.md` §18 say why both settings are required. +- **Files**: `.coveragerc`, `CLAUDE.md`, `architecture_explore.md` +- **Checks**: the start-theme and Stop All tests with and without the setting: `main_ui.py` 0 % → 69 %, `build_menubar.py` 0 % → 100 %; without `COVERAGE_FILE` set, the children (working directory a temporary folder) still write where the parent combines. The XML report has the same file names and `` as before. The full run with it (Python 3.11, coverage 7.14.3, pytest-cov 7.1.0): 2667 passed, 15 skipped, exit 0, in 13 minutes; statements 96.4 %, with branches 94.9 %, `editor_main` 90 %, `menu` 97 %; `architecture_explore.md` quoted 93 %, 91 %, 72 % and 85 %, measured earlier without the children, and now quotes these. +- **Open items**: none. + +## U-20260925-101 · 2026-09-25 · The IDE is per-monitor DPI aware again: AutoControl is imported when used · #fix #startup #autocontrol + +- **What**: every start printed `SetProcessDpiAwarenessContext() failed: Access is denied` and the IDE ran system DPI aware instead of Qt 6's per-monitor v2, so Windows stretched it as a bitmap on a screen scaled differently from the main one. `je_auto_control/windows/screen/win32_screen.py` calls `SetProcessDPIAware()` as it imports, and `build_autocontrol_menu.py` imported the package (and its GUI) at the top, so it ran as the menus were imported, before `start_editor` created the application. Checked with the Windows platform plugin and no window: Qt alone gives per-monitor v2; `je_auto_control` or the main-window module imported first gives system aware and the warning. +- **Change**: `AutomationMenu.gui_widget_class` is now `gui_widget_factory`, a callable run each time the GUI entry is chosen (a class still fits). The AutoControl menu imports the package in `_auto_control()` and its GUI in `_autocontrol_gui()`; Record and Stop Record go through the first. The other two GUI entries are unchanged for now. +- **Tests**: `test_startup_imports.py` (new) starts the real main window in a child and fails if `je_auto_control` was imported or (on Windows) the process is DPI aware before Qt sets it; both failed before the change (awareness 1, package loaded). `test_autocontrol_record.py` patches the package itself, and gains tests that Record starts AutoControl's recording and that the GUI entry opens AutoControl's GUI in a tab. +- **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, `.../auto_control_menu/build_autocontrol_menu.py`, `.../load_density_menu/build_load_density_menu.py`, `.../api_testka_menu/build_api_testka_menu.py`, `test/test_utils/test_startup_imports.py` (new), `test/test_utils/test_autocontrol_record.py`, `test/test_utils/test_automation_menu_factory.py`, `CLAUDE.md` (Conventions), `architecture_explore.md` (§5.1, test count) +- **Checks**: the same check with the main-window module imported first now gives per-monitor v2 and no warning. The menu, record and startup-import tests pass (12). `test/test_utils` collects 2686 tests in 148 files. +- **Open items**: none. + +## U-20260925-102 · 2026-09-25 · The IDE starts about 1.8 s sooner: the automation GUIs and SSH load when opened · #perf #startup + +- **What**: `-X importtime` on the main-window module put the menu bar at 1.2–1.4 s of 4.3–5.0 s of imports, nearly all of it three packages no start needs: the Load Density GUI (`je_load_density`, which brings locust and gevent, about 0.45–0.53 s), AutoControl (0.33–0.38 s, loaded as it is used since U-20260925-101), the APITestka GUI (0.13–0.15 s), and the SSH client's paramiko and cryptography through the Tools menu (0.16–0.19 s). The rest of the start is JEditor's own imports (about 3 s). +- **Change**: the Load Density and APITestka menus pass `_load_density_gui()` / `_api_testka_gui()` as their `gui_widget_factory`, and the Tools menu's SSH entry goes through `_ssh_widget()`: each imports its widget when the entry is chosen. Nothing else changes; locust still starts without gevent's patching, since `main_ui.py` sets `LOCUST_SKIP_MONKEY_PATCH` for the whole process. +- **Tests**: `test_startup_imports.py` also fails when `je_load_density`, `locust`, `je_api_testka` or `paramiko` is loaded once the real main window is built (it failed on all four before the change). `test_automation_menu_factory.py` checks that the Load Density and APITestka GUI entries still open their GUI in a tab, through a stand-in module in `sys.modules`, so the test process never imports locust. The Tools-tab test already opens the SSH client. +- **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py`, `.../api_testka_menu/build_api_testka_menu.py`, `pybreeze/pybreeze_ui/menu/tools/tools_menu.py`, `test/test_utils/test_startup_imports.py`, `test/test_utils/test_automation_menu_factory.py`, `CLAUDE.md` (Conventions), `architecture_explore.md` (§5.1, the Tools registry, test count) +- **Checks**: importing the main-window module, before (706f21d) and after, alternated six times each: median 6.45 s → 4.65 s, fastest 6.11 s → 4.42 s; the menu bar's share is now about 0.2 s. The startup-import, factory, AutoControl record and Tools-tab tests pass (31). `test/test_utils` collects 2688 tests in 148 files. +- **Open items**: none. + +## U-20260925-103 · 2026-09-25 · Load tests the IDE starts are patched with gevent again · #fix #load-density #subprocess + +- **What**: `main_ui.py` sets `LOCUST_SKIP_MONKEY_PATCH` so that locust, which patches the whole process with gevent as it imports, cannot do it to the IDE (the Load Density GUI imports it there, and so can a user in JEditor's in-process IPython console). The variable went on to every process the IDE started, through `utf8_subprocess_env()` (the automation menus, Run with..., installs) and the JupyterLab server, whose kernels inherit it. Unpatched, locust's HttpUser blocks the whole process on each request: measured with 10 users against a local server answering in 0.3 s, a 3 s run made 98 requests patched, and with the variable ran one user at a time for over three minutes (607 requests) with `ConnectionAbortedError`s. FastHttpUser, je_load_density's default, is unaffected (96 and 92); its `http_user`, one of the templates its projects are created with, and any HttpUser script are. +- **Change**: `subprocess_util.IDE_ONLY` is the value of a variable the IDE sets for itself alone, and `child_environment()` is `os.environ` without every variable that has it; `utf8_subprocess_env()` starts from it, and the JupyterLab server is started with it. `main_ui.py` sets `LOCUST_SKIP_MONKEY_PATCH` to the value the user gave it, or `IDE_ONLY`. JEditor's Run Program, Run Debugger and shell start their processes with the IDE's environment as it is, which PyBreeze cannot change: recorded as `progress.md` #120. +- **Tests**: `test_subprocess_util.py` (a variable with the value is left out, a real child runs without it, one the user set is passed on); `test_startup_imports.py` (in the real main window, started with the variable unset: the IDE has it, a child's environment does not; it failed before, the child got `1`); `test_jupyter_lifecycle.py` (the server's environment leaves it out). +- **Files**: `pybreeze/utils/subprocess_util.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, the three test files, `CLAUDE.md` (Conventions), `architecture.md` (§4), `architecture_explore.md` (main window, `subprocess_util.py`, test count), `progress.md` (#120) +- **Checks**: from a process that had imported the IDE, the same HttpUser run started with `utf8_subprocess_env()` had gevent's patching and made 100 requests in 4.6 s. The subprocess, startup, JupyterLab and prthinker-contract tests pass (44, 14 skipped: no prthinker here). `test/test_utils` collects 2693 tests in 148 files. +- **Open items**: `progress.md` #120. + +## U-20260925-104 · 2026-09-25 · Each entry of the project tree's right-click menu is tested · #test #file-tree + +- **What**: the coverage run of U-20260925-100 left `file_tree_context_menu.py` at 87 %, the lowest of the larger files; untested was the part of `_show_context_menu` that turns the entry chosen into its action, which blocks in `QMenu.exec()`. +- **Change**: `TestTheMenuEntries` in `test_file_tree_context_menu.py` opens the menu with a `QMenu` whose `exec()` picks the entry by its text, over an item and over empty space: New File, New Folder, Rename, Delete, Copy Path, Copy Relative Path and Reveal each call their own action with the item (Rename and Delete with the window too; the two copies differ only in `relative`), and over empty space only the two New entries of the seven can be chosen and nothing runs. +- **Files**: `test/test_utils/test_file_tree_context_menu.py`, `architecture_explore.md` (test count) +- **Checks**: the eight tests pass as the code stands; the file's 74 tests pass. `test/test_utils` collects 2701 tests in 148 files. +- **Open items**: none. + +## U-20260925-105 · 2026-09-25 · The main window shows PyBreeze's icon wherever the IDE starts from · #fix #packaging #ui + +- **What**: `PyBreezeMainWindow` read its icon from `pybreeze_icon.ico` in the working folder, and the only copy was `exe/pybreeze_icon.ico`, which is not part of the package: `python -m pybreeze`, an installed package, and the built executable (its own folder has no copy; the build only embedded it in the `.exe` file) all showed the window without it. +- **Change**: the icon moves to `pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico` and `main_ui.py` loads it from beside itself (`_ICON_PATH`). `pyproject.toml` and `dev.toml` list it as package data (`[tool.setuptools.package-data]`); `exe/auto_py_to_exe_setting.json` takes the executable's icon from there and bundles it for the window (`datas`, to the same place under the package). `progress.md` #112 names the new path. +- **Tests**: `test_window_icon.py` (new): the real main window, started in an empty temporary folder, has an icon; the icon is a file beside `main_ui.py` with an `.ico` header. Both failed before the change. +- **Files**: `pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico` (moved from `exe/`), `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `pyproject.toml`, `dev.toml`, `exe/auto_py_to_exe_setting.json`, `test/test_utils/test_window_icon.py` (new), `architecture_explore.md` (§3 start, test count), `progress.md` (#112) +- **Checks**: `python -m build` (setuptools from `pyproject.toml`) makes a wheel with `pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico` in it, and an sdist with it too. The whole executable was not rebuilt here; a PyInstaller 6 build (Python 3.14) of a stand-in script importing `pybreeze.pybreeze_ui.editor_main`, with the same `datas` entry, found the icon beside the frozen module (`_internal/pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico`), where `_ICON_PATH` looks. `test/test_utils` collects 2703 tests in 149 files. +- **Open items**: none. + +## U-20260925-106 · 2026-09-25 · The packaged build's regex worker: its timeout and its errors are tested · #test #regex + +- **What**: `regex_tester.py` was at 86 % in the coverage run of U-20260925-100. Untested was most of the path a packaged build takes (`_find_in_spawned_process`, a `spawn` child, since a frozen app has no interpreter to run the worker script with): a pattern stopped for running too long, a child that reports an error, and `_matches_into_pipe`, which runs in the child where coverage does not follow. +- **Change**: `test_regex_tester.py`: a catastrophic pattern in the spawned worker is stopped after its 3 s with the timeout message and leaves no worker registered; a pattern the child cannot compile comes back as a `RegexTesterException`; `_matches_into_pipe`, given a stand-in for its end of the pipe, sends the matches (the same as `find_matches`, `1` and `22`) or an error, and closes it either way. Checked on the way that the child's `find_matches` matches in its own process: it does not take the bounded path again, so a frozen child does not spawn one of its own. +- **Files**: `test/test_utils/test_regex_tester.py`, `architecture_explore.md` (test count) +- **Checks**: the four tests pass as the code stands; the file's 32 tests pass. `test/test_utils` collects 2707 tests in 149 files. +- **Open items**: none. + +## U-20260925-107 · 2026-09-25 · Opening the main window takes about 1 s instead of 3: the theme is applied once · #perf #startup + +- **What**: `open_main_window()` applied the theme to the application three times. JEditor's constructor already runs `startup_setting()` (the saved settings, the UI Style theme among them); after it came qt_material's `apply_stylesheet()` with the same theme (0.72–0.77 s) and, once the window was shown, `startup_setting()` again (0.92–1.24 s), whose file and session restores JEditor guards against running twice, leaving only the theme and fonts done again. Counted in a child: `QApplication.setStyleSheet` ran three times with or without a theme given at launch. +- **Change**: `open_main_window()` builds the window and shows it. Only a theme given to `start_editor()` is applied again (`_apply_given_theme()`: saved as `ui_style`, then `startup_setting()`; if that fails, the error is logged and qt_material's `apply_stylesheet()` still applies the theme). Otherwise the window's own font style sheet is set once more (0.08 s): `startup_setting()` sets it before the theme, and without setting it again the toolbar was 4 px taller (59 px, not 55) than the IDE has always shown it. `DEFAULT_THEME` went with the call that used it. +- **Tests**: `test_start_theme.py`: the theme is applied once when saved, twice when given at launch (it failed before: three times each), and the toolbar is as tall with `dark_amber.xml` saved as with it given (it failed without the style sheet set again: 59 against 55). The existing tests that the given, saved and default themes are the ones shown pass. `test_jeditor_contract.py` pins that `EditorMain.__init__` calls `startup_setting()`, which `architecture.md` §6 now lists among what PyBreeze relies on. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/test_start_theme.py`, `test/test_utils/test_jeditor_contract.py`, `architecture.md` (§4 flow, §6), `architecture_explore.md` (§3 start, test count) +- **Checks**: `open_main_window()` before (378c078) and after, alternated five times each: median 3.32 s → 1.07 s. Images of the started window (offscreen, 1772 × 796) with `light_blue.xml` saved and with nothing saved are identical pixel for pixel before and after. The start and extend startup tests exit 0. `test/test_utils` collects 2711 tests in 149 files. +- **Open items**: none. + +## U-20260925-108 · 2026-09-25 · No tool tab or panel hides a Qt method behind an attribute of the same name · #refactor #quality + +- **What**: a type check of the package (mypy, run for the first time here) reported `button_row` missing on a callable in thirteen tool tabs and `status_update` on one in the JupyterLab tab. They kept their output buttons in `self.actions` and their worker in `self.thread`, which hide `QWidget.actions()` and `QObject.thread()`: anything calling those on the widget (a plugin, a later helper, JEditor collecting a widget's actions) got PyBreeze's object back. Nothing does today, so nothing was broken. A check of every Qt class in the package against the members of its Qt bases found these sixteen and no others. +- **Change** (refactor, no behaviour change): `self.actions` → `self.output_actions` in the thirteen tool tabs; `self.thread` → `self.request_thread` in the CoT review and Skill send panels, `self.launcher` in the JupyterLab tab; the tests that reach them follow. `test_no_qt_member_shadowing.py` (new) fails on an instance attribute of a PyBreeze Qt class named after a member of its Qt base, and `CLAUDE.md` (Conventions) says so. The other mypy reports were checked and left: types it cannot narrow (a line edit or a combo box by branch, optional attributes set before use), and a PySide6 stub that types `QFile.moveToTrash()` as a tuple where it returns a `bool` (checked: `False` for a missing file). +- **Files**: the thirteen `pybreeze/pybreeze_ui/tools_gui/*_gui.py`, `extend_ai_gui/code_review/cot_code_review_gui.py`, `extend_ai_gui/skills/skills_send_gui.py`, `jupyter_lab_gui/jupyter_lab_widget.py`, their tests, `test/test_utils/test_no_qt_member_shadowing.py` (new), `CLAUDE.md`, `architecture_explore.md` (test count) +- **Checks**: the tool tab, AI panel, JupyterLab and Tools-menu tests pass (279); the new test passes, and the same check on the code before the rename listed the sixteen. `test/test_utils` collects 2712 tests in 150 files. +- **Open items**: none. + +## U-20260925-109 · 2026-09-25 · The SSH widgets' optional login widget is typed as optional · #types #ssh + +- **What**: from the same mypy run as U-20260925-108: `SSHCommandWidget` and `SSHFileTreeManager` take `external_login_widget: LoginWidget = None`, a hint that says a login widget is required while `None` (build its own) is the default. +- **Change**: `LoginWidget | None = None` in both. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` +- **Checks**: ruff passes; the SSH re-entrancy and teardown tests pass (43). +- **Open items**: none. + +## U-20260925-110 · 2026-09-25 · Runs from a Chinese folder with Chinese output are tested end to end · #test #encoding + +- **What**: no test ran a real child from a folder with a non-ASCII name, or with output outside ASCII, through the two run paths (an automation package's `TaskProcessManager`, Run with...'s `FileRunnerProcess`); many users here have Chinese in their paths. Checked on the way: the IDE starts (the startup test exits 0) from a working folder named `中文路徑/專案` with the user folder under `中文路徑/家目錄`, Qt reports only the offscreen platform's own warnings as the IDE starts and every Tools tab opens and closes, and no Python warning (encoding ones included, `-X warn_default_encoding`) comes from PyBreeze's code. +- **Change**: `TestTextInAnyLanguage` in `test_run_output.py`: `python -m json.tool --no-ensure-ascii` on `中文資料夾/資料.json`, and a Run with... run of `中文資料夾/腳本.py` printing to stdout and stderr, both with `你好,世界 🙂 naïve` (a character outside the BMP and a Latin accent among them); the run window shows each line as written and the exit line. +- **Files**: `test/test_utils/test_run_output.py`, `architecture_explore.md` (test count) +- **Checks**: the two tests pass as the code stands. `test/test_utils` collects 2714 tests in 150 files. +- **Open items**: none. + +## U-20260925-111 · 2026-09-25 · Python 3.15 checked: PyBreeze cannot be installed there until PySide6 moves; the badge says 3.10–3.14 · #compat #docs + +- **What**: Python 3.15 is due in October 2026 (3.15.0rc2 is out) and CI runs 3.10–3.14. In a 3.15.0rc2 venv (python.org's NuGet build; uv's list stopped at 3.15.0a8) `pip install -r requirements.txt` stops at `PySide6==6.11.2`: every PySide6 6.11 wheel says `Requires-Python <3.15`. PySide's `dev` branch raised the bound to `<3.16` on 2026-08-25 ("Bump Python supported version to 3.15"), and on 2026-08-28 moved QtWebEngine out of `PySide6` into a `PySide6_WebEngine` wheel ("Add new wheel for WebEngine"; `PySide6` becomes Essentials and Addons, and QtPdf gets a wheel too). Neither that release nor `PySide6_WebEngine` is on PyPI yet; je-editor 1.0.28 and frontengine 1.0.81 pin `PySide6==6.11.2` too. The whole source tree compiles on 3.15.0a8 without a warning. The README's badge said Python 3.10+ while its requirements said 3.10 – 3.14. +- **Change**: `progress.md` #121 records what moving to 3.15 takes (the PySide6 release after 6.11, with `PySide6_WebEngine` beside it in `requirements.txt`, `pyproject.toml` and `dev.toml`, since JEditor imports `QtWebEngineWidgets` as the IDE starts and the JupyterLab tab uses a `QWebEngineView`; je-editor and frontengine moving with it; then the CI matrix, classifiers and README) and what blocks it. The badge in `README.md`, `README/README_zh-TW.md` and `README/README_zh-CN.md` reads 3.10–3.14. +- **Files**: `progress.md` (#121), the three READMEs +- **Checks**: shields.io renders the new badge as "python: 3.10-3.14" (HTTP 200). +- **Open items**: `progress.md` #121. diff --git a/docs/updates/README.md b/docs/updates/README.md index cf018570..38842b1c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,167 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260925-111 | 2026-09-25 | Python 3.15 checked: PyBreeze cannot be installed there until PySide6 moves; the badge says 3.10–3.14 | #compat #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-110 | 2026-09-25 | Runs from a Chinese folder with Chinese output are tested end to end | #test #encoding | [2026-09-c](2026-09-c.md) | +| U-20260925-109 | 2026-09-25 | The SSH widgets' optional login widget is typed as optional | #types #ssh | [2026-09-c](2026-09-c.md) | +| U-20260925-108 | 2026-09-25 | No tool tab or panel hides a Qt method behind an attribute of the same name | #refactor #quality | [2026-09-c](2026-09-c.md) | +| U-20260925-107 | 2026-09-25 | Opening the main window takes about 1 s instead of 3: the theme is applied once | #perf #startup | [2026-09-c](2026-09-c.md) | +| U-20260925-106 | 2026-09-25 | The packaged build's regex worker: its timeout and its errors are tested | #test #regex | [2026-09-c](2026-09-c.md) | +| U-20260925-105 | 2026-09-25 | The main window shows PyBreeze's icon wherever the IDE starts from | #fix #packaging #ui | [2026-09-c](2026-09-c.md) | +| U-20260925-104 | 2026-09-25 | Each entry of the project tree's right-click menu is tested | #test #file-tree | [2026-09-c](2026-09-c.md) | +| U-20260925-103 | 2026-09-25 | Load tests the IDE starts are patched with gevent again | #fix #load-density #subprocess | [2026-09-c](2026-09-c.md) | +| U-20260925-102 | 2026-09-25 | The IDE starts about 1.8 s sooner: the automation GUIs and SSH load when opened | #perf #startup | [2026-09-c](2026-09-c.md) | +| U-20260925-101 | 2026-09-25 | The IDE is per-monitor DPI aware again: AutoControl is imported when used | #fix #startup #autocontrol | [2026-09-c](2026-09-c.md) | +| U-20260925-100 | 2026-09-25 | Coverage counts the tests that start the real main window in a child | #ci #test #coverage | [2026-09-c](2026-09-c.md) | +| U-20260925-99 | 2026-09-25 | The JupyterLab tab test no longer crashes the test run on exit | #test #fix #jupyter | [2026-09-c](2026-09-c.md) | +| U-20260925-98 | 2026-09-25 | Installing JupyterLab for the tab is tested | #test #jupyter | [2026-09-c](2026-09-c.md) | +| U-20260925-97 | 2026-09-25 | The SSH terminal's key authentication and input checks are tested | #test #ssh | [2026-09-c](2026-09-c.md) | +| U-20260925-96 | 2026-09-25 | The SFTP tree's download and upload dialogs are tested | #test #ssh | [2026-09-c](2026-09-c.md) | +| U-20260925-95 | 2026-09-25 | architecture.md's custom-tab extension point is complete | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-94 | 2026-09-25 | Every Tools tab entry is opened in a test | #test #menu | [2026-09-c](2026-09-c.md) | +| U-20260925-93 | 2026-09-25 | The prthinker menu is tested beyond reviewing a file | #test #prthinker | [2026-09-c](2026-09-c.md) | +| U-20260925-92 | 2026-09-25 | The property panel's node edits and colour button are tested | #test #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-91 | 2026-09-25 | The diagram canvas's clicks and keys are tested | #test #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-90 | 2026-09-25 | The diagram canvas's zoom and grid are tested | #test #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-89 | 2026-09-25 | A node and an image resize through one function | #refactor #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-88 | 2026-09-25 | Fixture decorators without empty parentheses | #refactor #test | [2026-09-c](2026-09-c.md) | +| U-20260925-87 | 2026-09-25 | SonarCloud's open issues in pybreeze/ dealt with | #refactor #quality | [2026-09-c](2026-09-c.md) | +| U-20260925-86 | 2026-09-25 | An arrow's label is read in linear time | #fix #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-85 | 2026-09-25 | The PDF of the guide keeps its Chinese half | #fix #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-84 | 2026-09-25 | A run whose window was closed can be stopped from the Run menu | #test #run | [2026-09-c](2026-09-c.md) | +| U-20260925-83 | 2026-09-25 | A damaged saved theme stops JEditor's window: recorded | #investigation | [2026-09-c](2026-09-c.md) | +| U-20260925-82 | 2026-09-25 | A plugin's About box speaks the IDE language | #fix #i18n #plugins | [2026-09-c](2026-09-c.md) | +| U-20260925-81 | 2026-09-25 | architecture_explore.md's line counts re-measured | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-80 | 2026-09-25 | The variable inspector is not offered as a feature | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-79 | 2026-09-25 | Why a key did not load, decided in one place | #refactor #ssh | [2026-09-c](2026-09-c.md) | +| U-20260925-78 | 2026-09-25 | The Sphinx pages say what a tree click does to a tab's text | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-77 | 2026-09-25 | Stop All Program stops PyBreeze's runs too | #fix #run | [2026-09-c](2026-09-c.md) | +| U-20260925-76 | 2026-09-25 | The Sphinx overview, getting-started and base-menu pages told right | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-75 | 2026-09-25 | start_editor's theme is the one the IDE shows | #fix #startup | [2026-09-c](2026-09-c.md) | +| U-20260925-74 | 2026-09-25 | The main window opens from its own function | #refactor #startup | [2026-09-c](2026-09-c.md) | +| U-20260925-73 | 2026-09-25 | The Sphinx Plugins and extension pages told right | #docs #plugins | [2026-09-c](2026-09-c.md) | +| U-20260925-72 | 2026-09-25 | The Plugin Browser is there before any plugin is | #fix #plugins | [2026-09-c](2026-09-c.md) | +| U-20260925-71 | 2026-09-25 | The Sphinx AI Tools pages told right | #docs #ai | [2026-09-c](2026-09-c.md) | +| U-20260925-70 | 2026-09-25 | CoT Code Review labels its code box as the code | #fix #ai | [2026-09-c](2026-09-c.md) | +| U-20260925-69 | 2026-09-25 | The Sphinx Tools pages told right | #docs #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-68 | 2026-09-25 | Opening another diagram asks before unsaved changes go | #fix #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-67 | 2026-09-25 | The diagram editor's unsaved-edits question in one place | #refactor #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-66 | 2026-09-25 | The Sphinx Automation pages told right | #docs #menu | [2026-09-c](2026-09-c.md) | +| U-20260925-65 | 2026-09-25 | The Sphinx Install pages list TestPioneer and the right fallback interpreter | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-64 | 2026-09-25 | The Sphinx SSH pages told right | #docs #ssh | [2026-09-c](2026-09-c.md) | +| U-20260925-63 | 2026-09-25 | The Sphinx JupyterLab pages told right | #docs #jupyter | [2026-09-c](2026-09-c.md) | +| U-20260925-62 | 2026-09-25 | JupyterLab runs in the interpreter a run uses | #fix #jupyter | [2026-09-c](2026-09-c.md) | +| U-20260925-61 | 2026-09-25 | Bold and code in the Chinese Sphinx pages render, and the docs build without warnings | #fix #docs #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-60 | 2026-09-25 | architecture.md's recipe for a new tool follows today's conventions | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-59 | 2026-09-25 | The Sphinx docs show today's main window | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-58 | 2026-09-25 | The Sphinx docs name the menu entries as they now read | #docs #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-57 | 2026-09-25 | 外掛, not 插件, in the plugin guide | #docs #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-56 | 2026-09-25 | The README says what the other languages in the menu do | #docs #readme #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-55 | 2026-09-25 | The README says which Python JupyterLab starts in | #docs #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-54 | 2026-09-25 | Which interpreter a run uses, told right in the README and a docstring | #docs #readme #executor | [2026-09-c](2026-09-c.md) | +| U-20260925-53 | 2026-09-25 | The README's project tree names the root documents | #docs #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-52 | 2026-09-25 | Record the build configs left under the old name as progress #112 | #docs #decision | [2026-09-c](2026-09-c.md) | +| U-20260925-51 | 2026-09-25 | The READMEs no longer promise keyword highlighting | #docs #readme #jeditor | [2026-09-c](2026-09-c.md) | +| U-20260925-50 | 2026-09-25 | Ctrl+Enter in the two-way tools goes the way the input reads | #feature #tools #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-49 | 2026-09-25 | Refactor: Ctrl+Enter can call any action | #refactor #ui | [2026-09-c](2026-09-c.md) | +| U-20260925-48 | 2026-09-25 | Tests of a saved diagram's local images | #test #diagram #security | [2026-09-c](2026-09-c.md) | +| U-20260925-47 | 2026-09-25 | Re-measure the coverage the architecture map quotes | #docs #test | [2026-09-c](2026-09-c.md) | +| U-20260925-46 | 2026-09-25 | Coverage counts what QThread workers run | #test #ci | [2026-09-c](2026-09-c.md) | +| U-20260925-45 | 2026-09-25 | Tests of the SSH host-key store's last line and a closed panel's question | #test #ssh #security | [2026-09-c](2026-09-c.md) | +| U-20260925-44 | 2026-09-25 | Tests that stopping a run stops what it started | #test #executor | [2026-09-c](2026-09-c.md) | +| U-20260925-43 | 2026-09-25 | Tests of the diagram editor's image download itself | #test #security #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-42 | 2026-09-25 | Every undeclared direct import in progress #53 | #docs #deps | [2026-09-c](2026-09-c.md) | +| U-20260925-41 | 2026-09-25 | Record cryptography with the undeclared direct imports in progress #53 | #docs #deps #security | [2026-09-c](2026-09-c.md) | +| U-20260925-40 | 2026-09-25 | Tests that reach every branch of the pinned connections and capped reads | #test #security #network | [2026-09-c](2026-09-c.md) | +| U-20260925-39 | 2026-09-25 | Tests that reach every branch of the SSRF check | #test #security #network | [2026-09-c](2026-09-c.md) | +| U-20260925-38 | 2026-09-25 | The project tree deletes to the trash | #feature #ui #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-37 | 2026-09-25 | F2 and Delete in the SFTP tree | #feature #ssh #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-36 | 2026-09-25 | Refactor: the SFTP tree's menu action runner and entry lookup | #refactor #ssh | [2026-09-c](2026-09-c.md) | +| U-20260925-35 | 2026-09-25 | F2 and Delete in the project tree | #feature #ui #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-34 | 2026-09-25 | The Regex tab's Traditional Chinese title | #fix #tools #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-33 | 2026-09-25 | Ctrl+Enter sends from the AI panels | #feature #ai #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-32 | 2026-09-25 | Refactor: the Ctrl+Enter helper moves up to pybreeze_ui | #refactor #ui | [2026-09-c](2026-09-c.md) | +| U-20260925-31 | 2026-09-25 | Ctrl+Enter runs a tool | #feature #tools #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-30 | 2026-09-25 | The SSH login form's secret field says Passphrase for a key | #fix #ssh #i18n #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-29 | 2026-09-25 | Re-measure the counts the architecture map quotes | #docs | [2026-09-c](2026-09-c.md) | +| U-20260925-28 | 2026-09-25 | The READMEs count the dictionary keys there are, and a test keeps them to it | #docs #i18n #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-27 | 2026-09-25 | A run window names the file it runs | #fix #executor #ui | [2026-09-c](2026-09-c.md) | +| U-20260925-26 | 2026-09-25 | 提示詞 throughout the Traditional Chinese IDE, and a CoT review window named as its tab | #fix #ai #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-25 | 2026-09-25 | The diagram editor's file dialogs in the IDE's language, offering every image it keeps | #fix #diagram #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-24 | 2026-09-25 | Refactor: the diagram image suffix allowlist public, its comment back on it | #refactor #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-23 | 2026-09-25 | Deleting and discarding ask with No as the default | #fix #ui #ssh #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-22 | 2026-09-25 | Install FileAutomation, as the menu and the README call it | #fix #menu #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-21 | 2026-09-25 | Menu names spelled one way, and Tools entries that say Tab or Dock | #fix #menu #i18n #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-20 | 2026-09-25 | One AI submenu in the Dock menu | #fix #menu #jeditor | [2026-09-c](2026-09-c.md) | +| U-20260925-19 | 2026-09-25 | One space after the SSH terminal's [Error] | #fix #ssh #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-18 | 2026-09-25 | Regex and timestamp results worded in the IDE's language | #fix #tools #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-17 | 2026-09-25 | HTTP status classes in the IDE's language | #fix #tools #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-16 | 2026-09-25 | One wording for the JWT hand-over, and a status button that says what it does | #fix #tools #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-15 | 2026-09-25 | The architecture map on what the AI review panels send | #docs #ai | [2026-09-c](2026-09-c.md) | +| U-20260925-14 | 2026-09-25 | AI Code Review and CoT Code Review send no empty code, and the code as pasted | #fix #ai #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-13 | 2026-09-25 | Say what CoT Code Review and Skill Send send | #docs #ai #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-12 | 2026-09-25 | AI Code Review sends the code by default | #fix #ai #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-11 | 2026-09-25 | AI panels no longer suggest an endpoint they refuse | #fix #ai #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-10 | 2026-09-25 | JWT, Query and URL tools in the fixed-pitch font | #fix #tools #ui | [2026-09-c](2026-09-c.md) | +| U-20260925-09 | 2026-09-25 | No private address in the SSH host placeholder | #fix #ssh #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-08 | 2026-09-25 | Say what to do with a PuTTY key instead of offering it | #fix #ssh #i18n #readme | [2026-09-c](2026-09-c.md) | +| U-20260925-07 | 2026-09-25 | SSH and SFTP log in with a PKCS#8 private key | #feature #ssh | [2026-09-c](2026-09-c.md) | +| U-20260925-06 | 2026-09-25 | The Mermaid paste box in the fixed-pitch font | #fix #diagram #ui | [2026-09-c](2026-09-c.md) | +| U-20260925-05 | 2026-09-25 | Free the diagram editor's Mermaid import dialog | #fix #diagram | [2026-09-c](2026-09-c.md) | +| U-20260925-04 | 2026-09-25 | The project tree's menu opens where it was asked for | #fix #ui | [2026-09-c](2026-09-c.md) | +| U-20260925-03 | 2026-09-25 | Free the project and SFTP trees' right-click menus | #fix #ui #ssh | [2026-09-c](2026-09-c.md) | +| U-20260925-02 | 2026-09-25 | Full-width punctuation in the Traditional Chinese strings | #fix #i18n | [2026-09-c](2026-09-c.md) | +| U-20260925-01 | 2026-09-25 | Start the 2026-09-c batch | #docs | [2026-09-c](2026-09-c.md) | +| U-20260924-320 | 2026-09-24 | Help, not HELP, in the automation menus | #fix #i18n | [2026-09-b](2026-09-b.md) | +| U-20260924-319 | 2026-09-24 | Drop the ReEdgeGPT words no menu asks for | #cleanup #i18n | [2026-09-b](2026-09-b.md) | +| U-20260924-318 | 2026-09-24 | Say that the SSH terminal is line by line | #docs #ssh #readme | [2026-09-b](2026-09-b.md) | +| U-20260924-317 | 2026-09-24 | Say what the CoT review's step selector is | #fix #ai | [2026-09-b](2026-09-b.md) | +| U-20260924-316 | 2026-09-24 | Reword the Traditional Chinese Open in editor tab button | #fix #i18n | [2026-09-b](2026-09-b.md) | +| U-20260924-315 | 2026-09-24 | Tell apart labels that read the same | #fix #i18n | [2026-09-b](2026-09-b.md) | +| U-20260924-314 | 2026-09-24 | Code in the tool tabs in the fixed-pitch font | #feature #tools #readme | [2026-09-b](2026-09-b.md) | +| U-20260924-313 | 2026-09-24 | Consolas before Courier New for terminal output | #fix #ui | [2026-09-b](2026-09-b.md) | +| U-20260924-312 | 2026-09-24 | Move the fixed-pitch font helper out of terminal_view | #refactor #ui | [2026-09-b](2026-09-b.md) | +| U-20260924-311 | 2026-09-24 | Remove the images nothing shows | #docs #cleanup | [2026-09-b](2026-09-b.md) | +| U-20260924-310 | 2026-09-24 | Redo the README's main window and correct its caption | #docs #readme | [2026-09-b](2026-09-b.md) | +| U-20260924-309 | 2026-09-24 | Keep library debug records out of Code Result | #fix #editor #logging | [2026-09-b](2026-09-b.md) | +| U-20260924-308 | 2026-09-24 | The cURL import button names the chosen target | #fix #tools #readme | [2026-09-b](2026-09-b.md) | +| U-20260924-307 | 2026-09-24 | Redo the README screenshots of the AI tabs | #docs #readme | [2026-09-b](2026-09-b.md) | +| U-20260924-306 | 2026-09-24 | Record the gitpython floor question as progress #109 | #docs #deps #security | [2026-09-b](2026-09-b.md) | +| U-20260924-305 | 2026-09-24 | Taiwan terms for Help and template in the Traditional Chinese IDE | #fix #i18n | [2026-09-b](2026-09-b.md) | +| U-20260924-304 | 2026-09-24 | A HELP submenu for TestPioneer | #feature #menu | [2026-09-b](2026-09-b.md) | +| U-20260924-303 | 2026-09-24 | Make the automation menus' HELP builder public | #refactor #menu | [2026-09-b](2026-09-b.md) | +| U-20260924-302 | 2026-09-24 | Install TestPioneer from the Install menu | #feature #menu | [2026-09-b](2026-09-b.md) | +| U-20260924-301 | 2026-09-24 | Build the automation Install menu from a table | #refactor #menu | [2026-09-b](2026-09-b.md) | +| U-20260924-300 | 2026-09-24 | Correction to U-20260924-295: the keyword colours are not shown yet | #docs #jeditor | [2026-09-b](2026-09-b.md) | +| U-20260924-299 | 2026-09-24 | clear and reset wipe the SSH terminal | #fix #ssh | [2026-09-b](2026-09-b.md) | +| U-20260924-298 | 2026-09-24 | Arrow labels in the README's tool screenshots | #docs | [2026-09-b](2026-09-b.md) | +| U-20260924-297 | 2026-09-24 | README screenshots of the SSH tab, the run window and the diagram editor redone | #docs | [2026-09-b](2026-09-b.md) | +| U-20260924-296 | 2026-09-24 | The diff tool shows its diff in colour | #feature #ui | [2026-09-b](2026-09-b.md) | +| U-20260924-295 | 2026-09-24 | Automation keywords readable on a light theme | #fix #ui #jeditor | [2026-09-b](2026-09-b.md) | +| U-20260924-294 | 2026-09-24 | The terminal font holds under the IDE's theme | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | +| U-20260924-293 | 2026-09-24 | Terminal colours readable on a dark and on a light theme | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | +| U-20260924-292 | 2026-09-24 | Colours in the SSH terminal | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | +| U-20260924-291 | 2026-09-24 | The SSH shell's pty follows the terminal's size | #feature #ssh | [2026-09-b](2026-09-b.md) | +| U-20260924-290 | 2026-09-24 | Terminal output in a fixed-pitch font | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | +| U-20260924-289 | 2026-09-24 | A progress bar in the SSH terminal redraws its line | #fix #ssh | [2026-09-b](2026-09-b.md) | +| U-20260924-288 | 2026-09-24 | Refactor: the run window's line rewinding in a shared module | #refactor | [2026-09-b](2026-09-b.md) | +| U-20260924-287 | 2026-09-24 | Command history on the SSH command line | #feature #ssh | [2026-09-b](2026-09-b.md) | +| U-20260924-286 | 2026-09-24 | Interrupt what runs in the SSH shell | #feature #ssh #i18n | [2026-09-b](2026-09-b.md) | +| U-20260924-285 | 2026-09-24 | Enter on an empty SSH command line sends Enter | #fix #ssh | [2026-09-b](2026-09-b.md) | +| U-20260924-284 | 2026-09-24 | Taiwan terms in the Traditional Chinese interface | #fix #i18n | [2026-09-b](2026-09-b.md) | +| U-20260924-283 | 2026-09-24 | architecture_explore.md coverage figures re-measured | #docs #test | [2026-09-b](2026-09-b.md) | +| U-20260924-282 | 2026-09-24 | A right-drag on the diagram canvas pans without opening the menu | #fix #diagram | [2026-09-b](2026-09-b.md) | +| U-20260924-281 | 2026-09-24 | architecture_explore.md line counts re-measured | #docs | [2026-09-b](2026-09-b.md) | +| U-20260924-280 | 2026-09-24 | curl -b '' reads no cookie file | #fix #curl | [2026-09-b](2026-09-b.md) | +| U-20260924-279 | 2026-09-24 | A new diagram node's text in the IDE language | #fix #i18n #diagram | [2026-09-b](2026-09-b.md) | +| U-20260924-278 | 2026-09-24 | Tests for the property panel on a connection and an image | #test #diagram | [2026-09-b](2026-09-b.md) | +| U-20260924-277 | 2026-09-24 | Tests for the diagram editor's align and distribute | #test #diagram | [2026-09-b](2026-09-b.md) | +| U-20260924-276 | 2026-09-24 | Check the trimmed diff against the plain one only on small texts | #fix #diff #perf | [2026-09-b](2026-09-b.md) | +| U-20260924-275 | 2026-09-24 | The Traditional and Simplified Chinese READMEs follow README.md again | #docs #readme | [2026-09-b](2026-09-b.md) | +| U-20260924-274 | 2026-09-24 | The exit-code line and the held-output note in the IDE language too | #fix #i18n #run-window | [2026-09-b](2026-09-b.md) | +| U-20260924-273 | 2026-09-24 | Why a report mail was not sent, in the IDE language | #fix #i18n #mail | [2026-09-b](2026-09-b.md) | +| U-20260924-272 | 2026-09-24 | Refactor: the reasons a report mail was not sent come from exception_tags | #refactor #i18n #mail | [2026-09-b](2026-09-b.md) | +| U-20260924-271 | 2026-09-24 | A run window's own notices in the IDE language | #fix #i18n #run-window | [2026-09-b](2026-09-b.md) | | U-20260924-270 | 2026-09-24 | Report a deeply nested regex instead of crashing on CPython 3.10 | #fix #regex | [2026-09-b](2026-09-b.md) | | U-20260924-269 | 2026-09-24 | Adopt je_editor 1.0.27 docked-editor contract | #done #cross-project | [2026-09-b](2026-09-b.md) | | U-20260924-268 | 2026-09-24 | A file that is not a diagram, and a refused CoT URL, say why in the IDE language | #fix #i18n #diagram #ai | [2026-09-b](2026-09-b.md) | @@ -352,4 +513,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 68 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 120 | +| [2026-09-c.md](2026-09-c.md) | 2026-09 | 111 | diff --git a/exe/auto_py_to_exe_setting.json b/exe/auto_py_to_exe_setting.json index a320f5d8..5b9b1592 100644 --- a/exe/auto_py_to_exe_setting.json +++ b/exe/auto_py_to_exe_setting.json @@ -19,7 +19,11 @@ }, { "optionDest": "icon_file", - "value": "D:\\Codes/PyBreeze/exe/pybreeze_icon.ico" + "value": "D:\\Codes/PyBreeze/pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico" + }, + { + "optionDest": "datas", + "value": "D:/Codes/PyBreeze/pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico;pybreeze/pybreeze_ui/editor_main" }, { "optionDest": "name", diff --git a/images/ai_code_review.png b/images/ai_code_review.png index ffee085c..7f1c12c9 100644 Binary files a/images/ai_code_review.png and b/images/ai_code_review.png differ diff --git a/images/cot_prompt_editor.png b/images/cot_prompt_editor.png index d9747360..f46a38af 100644 Binary files a/images/cot_prompt_editor.png and b/images/cot_prompt_editor.png differ diff --git a/images/diagram_editor.png b/images/diagram_editor.png index ff24bf0c..af2ddaec 100644 Binary files a/images/diagram_editor.png and b/images/diagram_editor.png differ diff --git a/images/main_gui.png b/images/main_gui.png deleted file mode 100644 index 922aad56..00000000 Binary files a/images/main_gui.png and /dev/null differ diff --git a/images/main_window.png b/images/main_window.png index c8764d35..13d26169 100644 Binary files a/images/main_window.png and b/images/main_window.png differ diff --git a/images/menu_automation.png b/images/menu_automation.png index 8bec583e..91edf86f 100644 Binary files a/images/menu_automation.png and b/images/menu_automation.png differ diff --git a/images/menu_install.png b/images/menu_install.png index 599b62a6..3db56352 100644 Binary files a/images/menu_install.png and b/images/menu_install.png differ diff --git a/images/run_output_window.png b/images/run_output_window.png index 18fed49a..4b54f12d 100644 Binary files a/images/run_output_window.png and b/images/run_output_window.png differ diff --git a/images/skill_prompt_editor.png b/images/skill_prompt_editor.png index ee640c65..8c98a79b 100644 Binary files a/images/skill_prompt_editor.png and b/images/skill_prompt_editor.png differ diff --git a/images/skills_send.png b/images/skills_send.png index 75136f2a..c5389662 100644 Binary files a/images/skills_send.png and b/images/skills_send.png differ diff --git a/images/ssh_client.png b/images/ssh_client.png index f1a82832..09814c60 100644 Binary files a/images/ssh_client.png and b/images/ssh_client.png differ diff --git a/images/tool_curl_import.png b/images/tool_curl_import.png index 0da7990d..393fd36a 100644 Binary files a/images/tool_curl_import.png and b/images/tool_curl_import.png differ diff --git a/images/tool_curl_import_action.png b/images/tool_curl_import_action.png index 203bbb86..644cfd6f 100644 Binary files a/images/tool_curl_import_action.png and b/images/tool_curl_import_action.png differ diff --git a/images/tool_diff.png b/images/tool_diff.png index ebc87b2c..9ec1f2b1 100644 Binary files a/images/tool_diff.png and b/images/tool_diff.png differ diff --git a/images/tool_har_import.png b/images/tool_har_import.png index 663672a1..8692e24d 100644 Binary files a/images/tool_har_import.png and b/images/tool_har_import.png differ diff --git a/images/tool_hash.png b/images/tool_hash.png deleted file mode 100644 index 692a9a4d..00000000 Binary files a/images/tool_hash.png and /dev/null differ diff --git a/images/tool_header_analyzer.png b/images/tool_header_analyzer.png index 7d86e977..72b03575 100644 Binary files a/images/tool_header_analyzer.png and b/images/tool_header_analyzer.png differ diff --git a/images/tool_http_status.png b/images/tool_http_status.png deleted file mode 100644 index 19d3efb3..00000000 Binary files a/images/tool_http_status.png and /dev/null differ diff --git a/images/tool_json_format.png b/images/tool_json_format.png deleted file mode 100644 index 78db8663..00000000 Binary files a/images/tool_json_format.png and /dev/null differ diff --git a/images/tool_jwt_decoder.png b/images/tool_jwt_decoder.png deleted file mode 100644 index 6349b081..00000000 Binary files a/images/tool_jwt_decoder.png and /dev/null differ diff --git a/images/tool_query_json.png b/images/tool_query_json.png deleted file mode 100644 index a78c8b2c..00000000 Binary files a/images/tool_query_json.png and /dev/null differ diff --git a/images/tool_regex.png b/images/tool_regex.png deleted file mode 100644 index 6dbc18ea..00000000 Binary files a/images/tool_regex.png and /dev/null differ diff --git a/images/tool_response_inspector.png b/images/tool_response_inspector.png index affc0c82..6b63fdfe 100644 Binary files a/images/tool_response_inspector.png and b/images/tool_response_inspector.png differ diff --git a/images/tool_timestamp.png b/images/tool_timestamp.png deleted file mode 100644 index 4977a32a..00000000 Binary files a/images/tool_timestamp.png and /dev/null differ diff --git a/images/tool_url_builder.png b/images/tool_url_builder.png deleted file mode 100644 index 90642743..00000000 Binary files a/images/tool_url_builder.png and /dev/null differ diff --git a/images/tools_montage_a.png b/images/tools_montage_a.png index 9417fc4a..5e555fa0 100644 Binary files a/images/tools_montage_a.png and b/images/tools_montage_a.png differ diff --git a/images/tools_montage_b.png b/images/tools_montage_b.png index 91441dbb..fe0580a6 100644 Binary files a/images/tools_montage_b.png and b/images/tools_montage_b.png differ diff --git a/progress.md b/progress.md index 07a0af4f..4a07122e 100644 --- a/progress.md +++ b/progress.md @@ -6,13 +6,27 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open -- **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? +- **#2** [DECIDE] Closing one run window leaves its child running, with no window; only Run > Stop All Program, which stops every run (`PyBreezeMainWindow.stop_all_runs`), or closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). jupyterlab itself is 4.5.6 there, below the fixes for a sanitizer-allowed command-linker button that runs commands on one click (CVE-2026-42557, 4.5.7), an SVG opened in the image viewer keeping a same-origin script context (CVE-2026-73415) and an imported `overrides.json` (CVE-2026-73417), both fixed in 4.5.10 / 4.6.2; with no token, a script running in the page can drive the server. Should the dependencies require `jupyter_server>=2.21.0` and `jupyterlab>=4.5.10`, and should the tab check the installed version and offer to upgrade? -- **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? +- **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). `cryptography` is imported directly too (`connect_gui/ssh/ssh_key_loader.py`, reading PKCS#8 and passphrase-protected keys) and arrives only through paramiko; the `.venv` has 46.0.6, below 46.0.7, which fixes CVE-2026-39892 (a non-contiguous buffer passed to an API that takes one, such as `Hash.update(buf[::-1])`; the key loader passes whole `bytes`). So is `qt_material`, which `start_editor` needs to style the window (`editor_main/main_ui.py`) and which arrives only through je-editor; `idna` (`utils/network/url_validation.py`) is imported with a fallback. Should the dependencies declare `requests`, `urllib3>=2.7.0`, `cryptography>=46.0.7` and `qt-material`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. -- **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. -- **#106** [DECIDE] `test_pioneer` is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), and before 0.1.34 it read a YAML file in the locale's encoding: on a Traditional Chinese Windows (cp950) TestPioneer "Run YAML" of a file with a non-ASCII character, which the editor saves as UTF-8, exits 1 with `UnicodeDecodeError` (`extend/process_executor/test_pioneer/test_pioneer_process_manager.py`). This repo's `.venv` has 0.1.30; 0.1.34 reads it as UTF-8. Should the dependencies require `test_pioneer>=0.1.34`? +- **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix, taking the colour as a theme colour key as its own rules do (PyBreeze registers `warning_output_color` and `diff_modified_marker_color`); the `architecture.md` §6 contract changes with it. +- **#106** [DECIDE] `test_pioneer` is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), and before 0.1.34 it read a YAML file in the locale's encoding: on a Traditional Chinese Windows (cp950) TestPioneer "Run YAML" of a file with a non-ASCII character, which the editor saves as UTF-8, exits 1 with `UnicodeDecodeError` (`extend/process_executor/test_pioneer/test_pioneer_process_manager.py`). This repo's `.venv` has 0.1.30; 0.1.34 reads it as UTF-8. `Install ▸ Automation ▸ Install TestPioneer` upgrades it by hand meanwhile. Should the dependencies require `test_pioneer>=0.1.34`? +- **#108** [BLOCKED] The Traditional Chinese IDE still shows Mainland terms in JEditor's own entries, which PyBreeze does not define: the Run menu (`run_menu_label` 運行, `run_menu_run_program_label` 運行程式, `run_menu_run_on_shell_label` 在終端運行, ...), the font menus (`file_menu_font_label` / `text_menu_label_font` 字體, `font_size` 字體大小) and `editor_code_result` 程式運行結果 (`je_editor/utils/multi_language/traditional_chinese.py:54`, `:96-97` and on, in `D:\Codes\JEDITOR` at e1a8b12). PyBreeze's own entries use 執行, 字型, 外掛 and 終端機 (`test_traditional_chinese_uses_taiwan_terms`), so its menus now sit beside JEditor's 運行. The Dock menu is 區域 (`dock_menu_label`, with 新編輯器區域, 新瀏覽器區域, `:64-68` at bb6bc94) while PyBreeze's entries in it say 停駐窗格, and its Editor, Git and Tools submenus (`:78-80`) are not translated. Needs the words changed in JEditor; overriding them here would copy JEditor's English entries into both of PyBreeze's dictionaries. +- **#109** [DECIDE] `je-editor>=1.0.27` (`requirements.txt`, `pyproject.toml`, `dev.toml`) lets an environment keep a gitpython below 3.1.59, which has CVE-2026-78676 (a config write turns a quoted value into a live `core.hooksPath`: code runs on the next hook) and CVE-2026-78679; the IDE's git features use it (JEditor's `je_editor/git_client/`). je-editor 1.0.28 differs from 1.0.27 only in requiring `gitpython>=3.1.59`, and 3.1.59 still has CVE-2026-87818 (the diff API's `--no-index` reads any path), fixed in 3.1.60. Should the dependencies say `je-editor>=1.0.28`, declare `gitpython>=3.1.60`, or both? JEditor's own floor is its to raise (`D:\Codes\JEDITOR\pyproject.toml:18`). +- **#110** [BLOCKED] A file opened from the project tree shows as unsaved (" *" on its tab, `_is_modified` true) before anything is typed: JEditor's `EditorWidget.open_an_file` fills the editor with `setPlainText`, which fires `_on_text_changed`, and never clears the mark afterwards (`je_editor/pyside_ui/main_ui/editor/editor_widget.py:295` and `:347-358` in `D:\Codes\JEDITOR` at 1bf81e3; plain `EditorMain` does the same). Needs `open_an_file` to clear the mark (`mark_saved()`) once the file is loaded. +- **#111** [DECIDE] AI Code Review, CoT Code Review and Skill Send refuse an endpoint on this machine or on a private network (`validate_url` in `ai_code_review_gui.py:77`, `code_review_thread.py:33`, `skills_send_gui.py:55`; `url_validation._is_blocked_ip`), so a local model server (Ollama, LM Studio, a prthinker server on localhost) cannot be used from them, although CoT Code Review and Skill Send suggested `http://127.0.0.1:5000/api` until U-20260925-11. The Security › Network rule rejects loopback and private addresses for every user-supplied URL. Should these panels let the user allow a loopback (or private) endpoint, for example after a confirmation naming the address, or stay public-only? +- **#112** [DECIDE] Two of the executable build configs still build the IDE under its old name: `exe/auto_py_to_exe_setting_linux.json` (name AutomationIDE, script `exe/start_automation_editor.py` and icon `exe/je_driver_icon.ico`, neither of which exists any more, all under `C:/CodeWorkspace/Python/AutomationIDE`) and the InstallForge project `exe/automation_ide_setup_config.ifp` (program AutomationIDE 1.0.9, the AutomationIDE repository as its website and licence link, the same icon, `...\AutomationIDE\output\AutomationIDE.exe`). The Windows `exe/auto_py_to_exe_setting.json` builds PyBreeze from `exe/start_pybreeze.py` with the icon `pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico`, also bundled as data for the window. Should the two be brought over to PyBreeze the same way (their paths are a build machine's), or deleted if they are no longer used? +- **#113** [BLOCKED] A translation plugin's language is listed in the Language menu under its key, not the name it registers: `register_natural_language(key, display_name, word_dict)` (`je_editor/plugins/__init__.py`) writes the dictionary into `language_wrapper.choose_language_dict` but not `display_names`, so `available_languages()` lists it and `display_name()` gives the key (`je_editor/pyside_ui/main_ui/menu/language_menu/build_language_server.py:47-49`), and the loop over `get_all_natural_languages()` below skips it as already listed. Seen with the Sphinx Plugins page's French example ("French" shown, not "Français") on je_editor 1.0.27. Needs JEditor's `register_natural_language` to call `language_wrapper.register_language(key, word_dict, display_name)`. +- **#114** [BLOCKED] File > Save File, Run Program and Run Debugger open a Save As dialog every time, starting in the working folder with no name filled in, even for a tab that already has a file: each calls `choose_file_get_save_file_path()` (`je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py:96`, `.../run_menu/under_run_menu/build_program_menu.py:95`, `build_debug_menu.py:106`), which always asks, on je_editor 1.0.27. PyBreeze's own Run with... saves in place (`save_current_file_for_run`). Needs JEditor to write a tab with a file where it is, and ask only for one without; the Sphinx File/Run/Text page says what it does now. +- **#115** [BLOCKED] Run Debugger works once per editor tab: when the `pdb` run ends, `ExecManager.full_exit_program()` clears the tab's `exec_program`, not `exec_python_debugger` (`je_editor/pyside_ui/code/code_process/code_exec.py:228`), and nothing else clears it, not Stop current program (`build_run_menu.py:117`) or Stop All, so the next Run Debugger in that tab says a program is still running (`build_debug_menu.py:102`). Reproduced on je_editor 1.0.27 in the real window: after `pdb` quit (exit 0) the slot was still set and a second Run Debugger was refused. Needs the debugger's manager to clear the slot it was stored in. +- **#116** [BLOCKED] Opening a file into an editor tab replaces the text it holds without asking: a click in the project tree (`project_treeview.clicked` -> `treeview_click`) and File > Open File both call `EditorWidget.open_an_file()` (`je_editor/pyside_ui/main_ui/editor/editor_widget.py:262`), which checks only whether the file is open elsewhere, then `setPlainText`s it. A tab with a file loses the last seconds of typing (auto-save runs every 5 s); a new tab loses everything. Reproduced on je_editor 1.0.27 in the real window: an untitled tab's text was replaced, nothing asked. Needs JEditor to ask when the tab has unsaved edits (its `_is_modified`), or open into a new tab; the Sphinx UI Overview says what happens now. +- **#117** [BLOCKED] JEditor's Variable Inspector (Tab > Tools Tab, Dock > Editor, and each editor tab's bottom panel) is always empty: `VariableModel` shows the `namespace` it is given, and nothing gives it one (`je_editor/pyside_ui/code/variable_inspector/inspector_gui.py:17`, built with none at `editor_widget.py:151` and `build_tab_tools_menu.py:103`; no code sets `.namespace`), on je_editor 1.0.27. The README no longer lists it among the editor's features; the Sphinx pages say it is empty. Needs JEditor to feed it from somewhere (the IPython console's namespace, or a paused debugger's), or to drop it. +- **#118** [BLOCKED] A settings file whose `ui_style` is not a string stops the IDE from starting: `EditorMain.__init__` calls `startup_setting()` unguarded (`je_editor/pyside_ui/main_ui/main_editor.py:245`), which hands the value to qt_material's `apply_stylesheet`, and a number raises `TypeError` inside the window's constructor, before PyBreeze can do anything (reproduced on je_editor 1.0.27 with `{"ui_style": 42}` in `.jeditor/user_setting.json`: the window was never built). A theme name that does not exist only logs a warning. Needs JEditor to check the saved style's type, or to guard that call as `_restore_open_files_session` is guarded. +- **#119** [BLOCKED] Most of the IDE's start is JEditor importing two tools nobody has opened yet: `build_dock_menu.py:17,22` and `build_tab_tools_menu.py:9-10` (`je_editor/pyside_ui/main_ui/menu/`) import `ChatUI`, which brings `langchain_openai` and `openai` (2.0–2.5 s), and `IpythonWidget`, which brings IPython (about 1 s), at the top. With `-X importtime` on je_editor 1.0.27, `build_dock_menu` is 3.1–3.7 s of the 4.1–4.9 s it takes to import PyBreeze's main-window module. PyBreeze imports its own heavy GUIs (the automation packages', SSH) when they are first opened. Needs JEditor to import these two in the actions that open them. +- **#120** [BLOCKED] A locust script run with JEditor's Run Program, Run Debugger or shell runs its HttpUser users one at a time: those start their process with the IDE's own environment (`subprocess.Popen` with no `env`, `je_editor/pyside_ui/code/code_process/code_exec.py:99,194`, `.../shell_process/shell_exec.py:95`), which carries `LOCUST_SKIP_MONKEY_PATCH` (PyBreeze sets it so that locust cannot patch the IDE with gevent), and unpatched a 3 s test of 10 users took over three minutes. PyBreeze's own runs (the automation menus, Run with..., installs, the JupyterLab server) leave it out through `child_environment()`, which drops every variable whose value is `subprocess_util.IDE_ONLY`. Needs JEditor to let its host give the environment for the processes it starts, or to drop variables with that value. +- **#121** [BLOCKED] PyBreeze cannot be installed on Python 3.15 (due October 2026): `requirements.txt`, `pyproject.toml` and `dev.toml` pin `PySide6==6.11.2`, whose wheels say `Requires-Python <3.15` (with 3.15.0rc2, pip finds no PySide6 to install). PySide's `dev` branch raised the bound to `<3.16` on 2026-08-25 for its next release, and three days later moved QtWebEngine out of `PySide6` into a wheel of its own, `PySide6_WebEngine` (`PySide6` is then Essentials and Addons only; `create_wheels.py`). Moving the pin to that release therefore also needs `PySide6_WebEngine` beside it, in all three files: JEditor imports `QtWebEngineWidgets` as the IDE starts (its browser) and the JupyterLab tab is a `QWebEngineView`. je-editor and frontengine pin `PySide6==6.11.2` as well and move with it (workspace). Then add 3.15 to the CI matrix, the classifiers and the README's version range. Blocked until that PySide6 release, and `PySide6_WebEngine`, are on PyPI. diff --git a/pybreeze/__init__.py b/pybreeze/__init__.py index 8e682279..d68c7309 100644 --- a/pybreeze/__init__.py +++ b/pybreeze/__init__.py @@ -24,14 +24,17 @@ start_editor, ) +_MAIN_UI = "pybreeze.pybreeze_ui.editor_main.main_ui" +_JEDITOR = "je_editor" + # Where each public name lives; JEditor's plugin API is re-exported for convenience _HOMES = { - "EDITOR_EXTEND_TAB": "pybreeze.pybreeze_ui.editor_main.main_ui", - "PyBreezeMainWindow": "pybreeze.pybreeze_ui.editor_main.main_ui", - "start_editor": "pybreeze.pybreeze_ui.editor_main.main_ui", - "load_external_plugins": "je_editor", - "register_natural_language": "je_editor", - "register_programming_language": "je_editor", + "EDITOR_EXTEND_TAB": _MAIN_UI, + "PyBreezeMainWindow": _MAIN_UI, + "start_editor": _MAIN_UI, + "load_external_plugins": _JEDITOR, + "register_natural_language": _JEDITOR, + "register_programming_language": _JEDITOR, } __all__ = [ diff --git a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py index 36d1c4ec..be2cca7b 100644 --- a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py +++ b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py @@ -4,7 +4,17 @@ import threading from collections.abc import Callable -from pybreeze.utils.exception.exception_tags import send_html_exception_tag +from pybreeze.utils.exception.exception_tags import ( + mail_login_failed_error, + mail_no_user_error, + mail_not_installed_error, + mail_send_failed_error, + mail_settings_unreadable_error, + report_missing_error, + report_not_a_file_error, + report_stale_error, + send_html_exception_tag, +) from pybreeze.utils.exception.exceptions import ITESendHtmlReportException from pybreeze.utils.logging.logger import pybreeze_logger @@ -37,7 +47,7 @@ def send() -> None: outcome = send_report(html_report_path, not_before=not_before) # The last stop on this thread: anything je_mail_thunder raises past # send_report must still reach on_done, or the run window never says - except Exception as error: # noqa: BLE001 — logged, and reported to the run window + except Exception as error: # noqa: BLE001 — logged and reported to the run window pybreeze_logger.error("Sending the report failed: %r", error) outcome = f"sending failed ({type(error).__name__})" if on_done is not None: @@ -64,7 +74,7 @@ def send_report(html_report_path: str | None = None, *, not_before: float | None from je_mail_thunder.utils.exception.exceptions import MailThunderException except ImportError as error: pybreeze_logger.error("Cannot send the report without je_mail_thunder: %r", error) - return "je_mail_thunder is not installed" + return mail_not_installed_error report_path = html_report_path if html_report_path is not None else DEFAULT_REPORT_PATH problem = _report_problem(report_path, not_before) @@ -78,10 +88,10 @@ def send_report(html_report_path: str | None = None, *, not_before: float | None # system, raised out of the mail thread except (OSError, ValueError, MailThunderException) as error: pybreeze_logger.error("The mail settings file could not be read: %r", error) - return "the mail settings file (mail_thunder_content.json) could not be read" + return mail_settings_unreadable_error if user is None: pybreeze_logger.error("Cannot determine mail user for sending report") - return "no mail user is set" + return mail_no_user_error try: with open(report_path, encoding="utf-8") as file: html_string = file.read() @@ -98,12 +108,12 @@ def send_report(html_report_path: str | None = None, *, not_before: float | None mail_thunder_smtp.send_message(message) except ITESendHtmlReportException as error: pybreeze_logger.error("%r %s", error, send_html_exception_tag) - return "the mail server login failed" + return mail_login_failed_error # OSError covers the socket and every smtplib error; ValueError a report # that is not UTF-8 except (OSError, ValueError, MailThunderException) as error: pybreeze_logger.error("Failed to send report: %r", error) - return f"sending failed ({type(error).__name__})" + return mail_send_failed_error.format(kind=type(error).__name__) return None @@ -113,11 +123,11 @@ def _report_problem(report_path: str, not_before: float | None) -> str | None: try: written = os.stat(report_path) except OSError: - return f"the run wrote no {name}" + return report_missing_error.format(name=name) if not os.path.isfile(report_path): - return f"{name} is not a file" + return report_not_a_file_error.format(name=name) if not_before is not None and written.st_mtime < not_before - _MTIME_SLACK_SECONDS: - return f"the run wrote no new {name}; the one there is from an earlier run" + return report_stale_error.format(name=name) return None diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 3a8a6352..d7993386 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -20,7 +20,6 @@ from PySide6.QtCore import QTimer from pybreeze.extend.process_executor.queue_pump import ( - OUTPUT_STILL_HELD_NOTE, ReaderGrace, any_alive, output_queue, @@ -28,6 +27,7 @@ read_stream_into_queue, ) from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow +from pybreeze.extend.process_executor.run_notice import run_notice from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.subprocess_util import ( no_window_creationflags, own_session_options, stop_tree, utf8_subprocess_env, @@ -101,7 +101,7 @@ def run_file(self, run_config: dict, file_path: str) -> None: compiler = run_config.get("compiler") if not isinstance(compiler, str) or not compiler: # A plugin's config is not checked by JEditor when it registers. - self.main_window.append_output("[Error] The run config names no compiler\n", is_error=True) + self.main_window.append_output(run_notice("no_compiler"), is_error=True) return args = run_arguments(run_config) @@ -128,20 +128,20 @@ def _compile_and_run(self, compiler: str, args: list, output_flag: str, file_pat output_name += ".exe" compile_cmd = [compiler] + args + [file_path, output_flag, output_name] - self.main_window.append_output(f"[Compile] {' '.join(compile_cmd)}\n", is_error=False) + self.main_window.append_output(run_notice("compile", command=' '.join(compile_cmd)), is_error=False) def run_if_compiled(exit_code: int) -> None: if self._cancelled: # Stopped: while compiling (reported as a failed compile), or # just after it succeeded (the binary ran anyway) - self.main_window.append_output("[Stopped]\n", is_error=True, own_line=True) + self.main_window.append_output(run_notice("stopped"), is_error=True, own_line=True) self._remove_build_dir(build_dir) return if exit_code != 0: - self.main_window.append_output(f"[Compile failed] exit code {exit_code}\n", is_error=True) + self.main_window.append_output(run_notice("compile_failed", code=exit_code), is_error=True) self._remove_build_dir(build_dir) return - self.main_window.append_output(f"[Run] {output_name}\n", is_error=False) + self.main_window.append_output(run_notice("run", name=output_name), is_error=False) self._start_process([output_name], cleanup_dir=build_dir) self._start_process( @@ -179,7 +179,7 @@ def _start_process(self, command: list[str], cleanup_dir: str | None = None, **own_session_options(), ) except FileNotFoundError: - self.main_window.append_output(f"[Error] Command not found: {command[0]}\n", is_error=True) + self.main_window.append_output(run_notice("command_not_found", command=command[0]), is_error=True) self._remove_build_dir(cleanup_dir) return except OSError as error: @@ -187,7 +187,7 @@ def _start_process(self, command: list[str], cleanup_dir: str | None = None, # by antivirus: this raised out of the menu, or out of the timer # slot after a compile, and the window said nothing. self.main_window.append_output( - f"[Error] Could not start {command[0]}: {error.strerror or error}\n", is_error=True) + run_notice("could_not_start", command=command[0], reason=error.strerror or error), is_error=True) self._remove_build_dir(cleanup_dir) return @@ -242,7 +242,7 @@ def _pull_text(self) -> None: elif self._deadline is not None and time.monotonic() > self._deadline: self._deadline = None self.main_window.append_output( - f"[Error] Timed out after {COMPILE_TIME_LIMIT_SECONDS}s\n", is_error=True) + run_notice("timed_out", seconds=COMPILE_TIME_LIMIT_SECONDS), is_error=True) stop_tree(self.process) def _finish(self) -> None: @@ -261,7 +261,7 @@ def _finish(self) -> None: # Drain remaining output directly (not via _pull_text to avoid recursion) self._drain_queues() if any_alive(*readers): - self.main_window.append_output(OUTPUT_STILL_HELD_NOTE, is_error=False, own_line=True) + self.main_window.append_output(run_notice("output_still_held"), is_error=False, own_line=True) after_exit, self._after_exit = self._after_exit, None if self.process is not None: diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 47bb4a5a..53c4c711 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -4,6 +4,7 @@ import time import weakref from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtCore import QCoreApplication, QObject, QTimer, Signal @@ -14,7 +15,9 @@ from pybreeze.extend.mail_thunder_extend.mail_thunder_setting import DEFAULT_REPORT_PATH, send_after_test from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager from pybreeze.utils.file_process.get_dir_file_list import get_dir_files_as_list +from pybreeze.extend.process_executor.run_notice import run_notice from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.pybreeze_ui.plain_text import as_text if TYPE_CHECKING: @@ -34,13 +37,15 @@ def build_process( reports its own errors in the run window. """ test_format_code = exec_str + subject = "" if test_format_code is None: widget = main_window.tab_widget.currentWidget() if not isinstance(widget, EditorWidget): report_no_script_tab(main_window, package, program_buffer) return test_format_code = widget.code_edit.toPlainText() - start_process(main_window, package, test_format_code, send_mail, program_buffer) + subject = Path(widget.current_file).name if widget.current_file else "" + start_process(main_window, package, test_format_code, send_mail, program_buffer, subject) def report_no_script_tab( @@ -54,7 +59,7 @@ def report_no_script_tab( pybreeze_logger.error("%s run needs an editor tab in front", package) process = build_task_process(main_window, program_buffer=program_buffer) process.main_window.append_output( - f"[Error] {package} runs the script in the editor tab in front; open it and try again\n", + run_notice("needs_editor_tab", package=package), is_error=True, own_line=True) process.main_window.show() @@ -64,12 +69,14 @@ def start_process( package: str, test_format_code: str, send_mail: bool = False, - program_buffer: int = 1024000 + program_buffer: int = 1024000, + subject: str = "", ): process = build_task_process(main_window, send_mail, program_buffer) process.start_test_process( package, exec_str=test_format_code, + subject=subject, ) @@ -204,9 +211,9 @@ class _MailNotice(QObject): def tell(self, reason: str | None) -> None: """Called on the mail thread with ``send_report``'s answer.""" if reason is None: - self.told.emit("[Mail] The test report was sent\n", False) + self.told.emit(run_notice("mail_sent"), False) else: - self.told.emit(f"[Mail] The test report was not sent: {reason}\n", True) + self.told.emit(run_notice("mail_not_sent", reason=error_text(reason)), True) def report_mail_hook(code_window: CodeWindow) -> Callable[[], None]: @@ -247,7 +254,9 @@ def build_task_process( The run window carries the interpreter chosen in the IDE (the Python environment menu, or the saved setting), so the child runs with that interpreter; only when none was chosen does the manager fall back to a - ``venv`` / ``.venv`` in the working directory, then to ``PATH``. The run + ``venv`` / ``.venv`` in the working directory, then to the IDE's own + interpreter (``default_interpreter``; only a packaged build looks on + ``PATH``). The run window holds the manager (``CodeWindow.runner``), so a caller may drop it. """ code_window = open_run_window(main_window) diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 2963c665..902c0148 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -17,7 +17,6 @@ from je_editor.utils.venv_check.check_venv import check_and_choose_venv from pybreeze.extend.process_executor.queue_pump import ( - OUTPUT_STILL_HELD_NOTE, ReaderGrace, any_alive, output_queue, @@ -25,6 +24,7 @@ read_stream_into_queue, ) from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow +from pybreeze.extend.process_executor.run_notice import run_notice from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.subprocess_util import ( no_window_creationflags, own_session_options, stop_tree, utf8_subprocess_env, @@ -110,18 +110,20 @@ def renew_path(self) -> bool: except JEditorExecException as error: pybreeze_logger.error("No Python interpreter found for run: %r", error) self.main_window.append_output( - f"[Error] No Python interpreter found: {error}\n", is_error=True, own_line=True) + run_notice("no_interpreter", error=error), is_error=True, own_line=True) self.main_window.show() return False else: self.compiler_path = self.main_window.python_compiler return True - def start_test_process(self, package: str, exec_str: str): + def start_test_process(self, package: str, exec_str: str, subject: str = ""): """Run *package* on the script *exec_str*, passed on the command line. A script too long for a Windows command line goes as a file instead (``--execute_file``): passed as it was, the run did not start at all. + *subject* -- the name of the file the script came from -- goes in the + window's title beside the package. """ if not self.renew_path(): return @@ -129,7 +131,7 @@ def start_test_process(self, package: str, exec_str: str): args = [str(self.compiler_path), "-m", package, "--execute_str", argument] if sys.platform == "win32" and len(subprocess.list2cmdline(args)) > _MAX_COMMAND_LINE: args[-2:] = ["--execute_file", str(self._write_script_file(exec_str))] - self._spawn_and_pump(package, args) + self._spawn_and_pump(package, args, subject=subject) def _write_script_file(self, script: str) -> Path: """Write *script* to a file of its own for the child to read, and return its path. @@ -167,24 +169,25 @@ def start_test_process_file(self, package: str, file_path: str): "--execute_file", str(file_path), ] - self._spawn_and_pump(package, args) + self._spawn_and_pump(package, args, subject=Path(file_path).name) def start_module_process( - self, package: str, arguments: list, environment: dict | None = None): + self, package: str, arguments: list, environment: dict | None = None, subject: str = ""): """Run ``python -m package`` with *arguments*, adding *environment* if given. The general form behind the two calls above, for a package driven by subcommands and flags rather than by a script to execute. A setting that would be a secret on a command line -- an API key, a forge token -- goes through *environment* instead, where the process list cannot show it. + *subject*, what the run is about (a file), goes in the window's title. """ if not self.renew_path(): return args = [str(self.compiler_path), "-m", package, *[str(one) for one in arguments]] - self._spawn_and_pump(package, args, environment) + self._spawn_and_pump(package, args, environment, subject) def _spawn_and_pump( - self, package: str, args: list, environment: dict | None = None) -> None: + self, package: str, args: list, environment: dict | None = None, subject: str = "") -> None: # Launch user-authored automation script in a child interpreter. # Argument list is validated upstream; shell=False, no user string ever # reaches a shell. nosec B603 — intentional local process execution. @@ -211,7 +214,7 @@ def _spawn_and_pump( pybreeze_logger.error("%s could not start: %r", package, error) self._remove_script_file() self.main_window.append_output( - f"[Error] {package} could not start: {error.strerror or error}\n", + run_notice("package_could_not_start", package=package, reason=error.strerror or error), is_error=True, own_line=True) self.main_window.show() return @@ -228,7 +231,8 @@ def _spawn_and_pump( daemon=True ) self.read_program_error_output_from_thread.start() - self.main_window.setWindowTitle(package) + # With the file: a folder run opens a window per file, all of one package + self.main_window.setWindowTitle(f"{package} - {subject}" if subject else package) self.main_window.show() self.timer = QTimer(self.main_window) self.timer.setInterval(100) @@ -281,11 +285,11 @@ def exit_program(self): self.read_program_error_output_from_thread = None self.drain_and_display_queue() if any_alive(*readers): - self.main_window.append_output(OUTPUT_STILL_HELD_NOTE, own_line=True) + self.main_window.append_output(run_notice("output_still_held"), own_line=True) if self.process is not None: self.process.terminate() self.main_window.append_output( - f"Task exit with code {self.process.returncode}\n", own_line=True) + run_notice("exit_code", code=self.process.returncode), own_line=True) self.process = None self._remove_script_file() if self.task_done_trigger_function is not None: diff --git a/pybreeze/extend/process_executor/queue_pump.py b/pybreeze/extend/process_executor/queue_pump.py index 3ad4b3b9..fdc99074 100644 --- a/pybreeze/extend/process_executor/queue_pump.py +++ b/pybreeze/extend/process_executor/queue_pump.py @@ -40,11 +40,6 @@ # How often a reader waiting on a full queue checks whether to give up _PUT_WAIT_SECONDS = 0.2 -# Written when a process the run started still holds its output at the end -OUTPUT_STILL_HELD_NOTE = ( - "[A process started by this run still holds its output; what it writes from now on is not shown]\n") - - def output_queue() -> Queue: """A queue for one pipe's output, holding at most ``MAX_QUEUED_MESSAGES`` pieces.""" return Queue(maxsize=MAX_QUEUED_MESSAGES) diff --git a/pybreeze/extend/process_executor/run_notice.py b/pybreeze/extend/process_executor/run_notice.py new file mode 100644 index 00000000..a14c2aff --- /dev/null +++ b/pybreeze/extend/process_executor/run_notice.py @@ -0,0 +1,27 @@ +"""What the executors write into a run window about the run itself, in the IDE language. + +A compile, a run starting, a stop, a command that is not there: each is a line +of its own in the window, ``[Error] ...`` or ``[Run] ...``, and they were all +English whatever the IDE spoke. +""" +from __future__ import annotations + +from je_editor import language_wrapper + +from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict + +# The language-dictionary key of a notice is this followed by its name +RUN_NOTICE_KEY_PREFIX = "run_window_" + + +def run_notice(notice: str, /, **fields: object) -> str: + """The notice ``run_window_`` with *fields* filled in, as one line (newline included). + + *notice* is positional-only, so a field may be called anything (``[Run] {name}``). + PyBreeze's English is used when the IDE's dictionary lacks the notice: + an executor started before ``update_language_dict()`` (a script, a test) + still says what happened. + """ + key = RUN_NOTICE_KEY_PREFIX + notice + template = language_wrapper.language_word_dict.get(key) or pybreeze_english_word_dict[key] + return template.format(**fields) + "\n" diff --git a/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py b/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py index b1922de2..8debaca0 100644 --- a/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py +++ b/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from pybreeze.extend.process_executor.process_executor_utils import build_task_process @@ -19,4 +20,4 @@ def init_and_start_test_pioneer_process( than raised out of the menu callback. """ process = build_task_process(ui_we_want_to_set, program_buffer=program_buffer) - process.start_module_process(_PACKAGE, ["-e", file_path]) + process.start_module_process(_PACKAGE, ["-e", file_path], subject=Path(file_path).name) diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 9aa4194a..fe707529 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -9,6 +9,7 @@ _COT_PROMPT_EDITOR = "CoT Prompt Editor" _SKILL_PROMPT_EDITOR = "Skill Prompt Editor" +_RESULT_LABEL = "Result:" # PyBreeze-specific English translations pybreeze_english_word_dict = { @@ -21,7 +22,7 @@ "install_menu_label": "Install", # Normal label "run_label": "Run", - "help_label": "HELP", + "help_label": "Help", "project_label": "Project", "create_project_exists": "{path} already exists. Replace its template files and lose your changes to them?", "create_project_failed": "The project could not be created at {path}: {error}", @@ -40,17 +41,17 @@ "apitestka_github_label": "Open APITestka GitHub", "apitestka_github_tab_label": "APITestka GitHub", "apitestka_create_project_label": "Create APITestka Project", - # Autocontrol Menu - "autocontrol_menu_label": "Autocontrol", - "autocontrol_run_script_label": "Run Autocontrol Script", - "autocontrol_run_script_with_send_label": "Run Autocontrol With Send", - "autocontrol_run_multi_script_label": "Run Multi Autocontrol Script", - "autocontrol_run_multi_script_with_send_label": "Run Multi Autocontrol Script With Send", - "autocontrol_doc_label": "Open Autocontrol Doc", - "autocontrol_doc_tab_label": "Autocontrol Doc", - "autocontrol_github_label": "Open Autocontrol GitHub", - "autocontrol_github_tab_label": "Autocontrol GitHub", - "autocontrol_create_project_label": "Create Autocontrol Project", + # AutoControl Menu + "autocontrol_menu_label": "AutoControl", + "autocontrol_run_script_label": "Run AutoControl Script", + "autocontrol_run_script_with_send_label": "Run AutoControl With Send", + "autocontrol_run_multi_script_label": "Run Multi AutoControl Script", + "autocontrol_run_multi_script_with_send_label": "Run Multi AutoControl Script With Send", + "autocontrol_doc_label": "Open AutoControl Doc", + "autocontrol_doc_tab_label": "AutoControl Doc", + "autocontrol_github_label": "Open AutoControl GitHub", + "autocontrol_github_tab_label": "AutoControl GitHub", + "autocontrol_create_project_label": "Create AutoControl Project", "autocontrol_record_menu_label": "Record", "autocontrol_record_start_label": "Record Start", "autocontrol_record_stop_label": "Record Stop", @@ -102,25 +103,22 @@ "install_menu_apitestka": "Install APITestka", "install_menu_loaddensity": "Install LoadDensity", "install_menu_webrunner": "Install WebRunner", - "install_menu_automation_file": "Install Automation File", + "install_menu_automation_file": "Install FileAutomation", "install_menu_mail_thunder": "Install MailThunder", + "install_menu_test_pioneer": "Install TestPioneer", "install_menu_prthinker": "Install prthinker (code review)", "install_menu_tools_install_menu_label": "Tools", "install_menu_tools_install_build_tools": "Install Build Tools", - # Tools Menu - "tools_menu_re_edge_gpt_label": "ReEdgeGPT", - "tools_menu_re_edge_gpt_doc_label": "Open ReEdgeGPT Doc", - "tools_menu_re_edge_gpt_doc_tab_label": "ReEdgeGPT Doc", - "tools_menu_re_edge_gpt_github_label": "Open ReEdgeGPT GitHub", - "tools_menu_re_edge_gpt_github_tab_label": "ReEdgeGPT GitHub", # Test Pioneer Menu "test_pioneer_label": "TestPioneer", - "test_pioneer_create_template_label": "Create TestPioneer Yaml template", + "test_pioneer_github_label": "Open TestPioneer GitHub", + "test_pioneer_github_tab_label": "TestPioneer GitHub", + "test_pioneer_create_template_label": "Create TestPioneer YAML Template", "test_pioneer_template_exists": "{path} already exists. Replace it with a fresh template and lose its content?", "test_pioneer_template_failed": "The template could not be created at {path}: {error}", "test_pioneer_template_created": "Template created: {path}", - "test_pioneer_run_yaml": "Execute Test Pioneer Yaml", - "test_pioneer_not_choose_yaml": "Please choose a Yaml file", + "test_pioneer_run_yaml": "Run TestPioneer YAML", + "test_pioneer_not_choose_yaml": "Please choose a YAML file", # prthinker code review "prthinker_menu_label": "Code Review (prthinker)", "prthinker_review_current_file_label": "Review the current file", @@ -169,19 +167,25 @@ # SSH command widget "ssh_command_widget_window_title_ssh_command_widget": "SSH Command Widget", "ssh_command_widget_button_label_send_command": "Send", + "ssh_command_widget_button_label_interrupt": "Interrupt", + "ssh_command_widget_tooltip_interrupt": + "Send Ctrl+C to stop what runs in the shell (Ctrl+C in the command line does the same " + "when no text is selected)", "ssh_command_widget_input_placeholder_command_line": "Type command then Enter...", "ssh_command_widget_dialog_title_input_error": "Input error", "ssh_command_widget_dialog_message_input_error_host_user_required": "Host and username are required.", "ssh_command_widget_dialog_title_key_error": "Key error", "ssh_command_widget_dialog_message_key_file_not_exist": "Key file does not exist.", "ssh_command_widget_error_message_unsupported_private_key": "Unsupported or invalid private key.", - "ssh_key_error_passphrase_needed": "The private key is protected by a passphrase: enter it in the password field.", + "ssh_key_error_passphrase_needed": "The private key is protected by a passphrase: enter it in the Passphrase field.", # nosec B105 # NOSONAR S2068 — UI text "ssh_key_error_passphrase_wrong": "The passphrase for the private key is wrong.", + "ssh_key_error_putty_key": "This is a PuTTY key (.ppk), which cannot be used here: load it in PuTTYgen, " + "choose Conversions > Export OpenSSH key, and pick the exported file.", "ssh_command_widget_error_message_key_auth_failed": "Key auth failed", "ssh_command_widget_status_label_connected": "Connected", "ssh_command_widget_status_label_disconnected": "Disconnected", "ssh_command_widget_log_message_connected": "Connected to {host}:{port} as {user}", - "ssh_command_widget_log_message_error": "[Error] ", + "ssh_command_widget_log_message_error": "[Error]", "ssh_command_widget_log_message_channel_closed": "[Channel closed]", "ssh_command_widget_error_message_reader_failed": "Reader error", "ssh_command_widget_log_message_reader_closed": "Reader closed", @@ -235,6 +239,8 @@ "{filename} has edits that are not saved. Close and lose them?", "diagram_editor_close_over_edits": "The diagram has changes that are not saved. Close and lose them?", + "diagram_editor_open_over_edits": + "The diagram has changes that are not saved. Open another and lose them?", "unsaved_close_title": "Unsaved changes", "prompt_editor_reload_over_edits": "{filename} changed on disk. Reload it and lose the edits made here?", @@ -269,10 +275,12 @@ "ssh_login_widget_label_user": "User", "ssh_login_widget_label_key": "Key", "ssh_login_widget_label_password": "Password", - "ssh_login_widget_placeholder_host": "Host (e.g., 192.168.0.10)", + "ssh_login_widget_label_passphrase": "Passphrase", # nosec B105 # NOSONAR S2068 — UI text + "ssh_login_widget_placeholder_host": "Host name or IP address", "ssh_login_widget_placeholder_username": "Username", "ssh_login_widget_placeholder_password": "Password", - "ssh_login_widget_placeholder_private_key": "Private key path (.pem/.ppk)", + "ssh_login_widget_placeholder_passphrase": "The key's passphrase, if it has one", # nosec B105 # NOSONAR S2068 — UI text + "ssh_login_widget_placeholder_private_key": "Private key path (OpenSSH or PEM)", "ssh_login_widget_button_use_key_auth": "Use key auth", "ssh_login_widget_button_connect": "Connect", "ssh_login_widget_button_disconnect": "Disconnect", @@ -280,7 +288,7 @@ "ssh_login_widget_dialog_title_choose_key": "Choose a private key", "ssh_login_widget_status_disconnected": "Disconnected", # AI Code Review GUI - "ai_code_review_gui_window_title": "AI Code-Review Client", + "ai_code_review_gui_window_title": "AI Code Review Client", "ai_code_review_gui_label_url": "URL:", "ai_code_review_gui_label_method": "Method:", "ai_code_review_gui_label_code_to_send": "Code to Send:", @@ -289,6 +297,7 @@ "ai_code_review_gui_button_accept_response": "Accept Response", "ai_code_review_gui_button_reject_response": "Reject Response", "ai_code_review_gui_message_enter_valid_url": "Please enter a valid URL", + "ai_code_review_gui_message_paste_code": "Paste the code to review first.", "ai_code_review_gui_message_url_already_recorded": "This URL is already recorded, still sending request...", "ai_code_review_gui_message_new_url_recorded": "New URL recorded, sending request...", "ai_code_review_gui_message_unsupported_http_method": "Unsupported HTTP method", @@ -330,45 +339,49 @@ "extend_tools_menu_tools_ai_menu": "AI", "extend_tools_menu_ssh_client_tab_action": "SSH Client Tab", "extend_tools_menu_ssh_client_tab_label": "SSH Client", - "extend_tools_menu_ai_code_review_tab_action": "AI Code-Review Tab", - "extend_tools_menu_ai_code_review_tab_label": "AI Code-Review", - "extend_tools_menu_cot_prompt_editor_tab_action": _COT_PROMPT_EDITOR, + "extend_tools_menu_ai_code_review_tab_action": "AI Code Review Tab", + "extend_tools_menu_ai_code_review_tab_label": "AI Code Review", + "extend_tools_menu_cot_prompt_editor_tab_action": _COT_PROMPT_EDITOR + " Tab", "extend_tools_menu_cot_prompt_editor_tab_label": _COT_PROMPT_EDITOR, "extend_tools_menu_cot_code_review_tab_action": "CoT Code Review Tab", "extend_tools_menu_cot_code_review_tab_label": "CoT Code Review", - "extend_tools_menu_skill_prompt_editor_tab_action": _SKILL_PROMPT_EDITOR, + "extend_tools_menu_skill_prompt_editor_tab_action": _SKILL_PROMPT_EDITOR + " Tab", "extend_tools_menu_skill_prompt_editor_tab_label": _SKILL_PROMPT_EDITOR, - "extend_tools_menu_skill_prompt_send_tab_label": "Skill Send GUI", + "extend_tools_menu_skill_prompt_send_tab_label": "Skill Send", + "extend_tools_menu_skill_prompt_send_tab_action": "Skill Send Tab", "extend_tools_menu_dock_ssh_menu": "SSH", "extend_tools_menu_dock_ai_menu": "AI", "extend_tools_menu_ssh_client_dock_action": "SSH Client Dock", - "extend_tools_menu_ai_code_review_dock_action": "AI Code-Review Dock", + "extend_tools_menu_ai_code_review_dock_action": "AI Code Review Dock", "extend_tools_menu_cot_prompt_editor_dock_action": "CoT Prompt Editor Dock", "extend_tools_menu_cot_code_review_dock_action": "CoT Code Review Dock", "extend_tools_menu_cot_code_review_dock_title": "CoT Code Review", "extend_tools_menu_skill_prompt_editor_dock_action": "Skill Prompt Editor Dock", "extend_tools_menu_ssh_client_dock_title": "SSH Client", - "extend_tools_menu_ai_code_review_dock_title": "AI Code-Review", - "extend_tools_menu_cot_prompt_editor_dock_title": "CoT PromptEditor", - "extend_tools_menu_skill_prompt_editor_dock_title": "Skill PromptEditor", - "extend_tools_menu_skill_prompt_send_dock_action": "Skill Prompt Dock", - "extend_tools_menu_skill_prompt_send_dock_title": "Skill Send GUI", + "extend_tools_menu_ai_code_review_dock_title": "AI Code Review", + "extend_tools_menu_cot_prompt_editor_dock_title": "CoT Prompt Editor", + "extend_tools_menu_skill_prompt_editor_dock_title": "Skill Prompt Editor", + "extend_tools_menu_skill_prompt_send_dock_action": "Skill Send Dock", + "extend_tools_menu_skill_prompt_send_dock_title": "Skill Send", # CoT code-review GUI - "cot_gui_window_title": "Prompt Sender UI", + "cot_gui_window_title": "CoT Code Review", "cot_gui_label_api_url": "API URL:", - "cot_gui_placeholder_api_url": "Please enter the API URL to send, e.g. http://127.0.0.1:5000/api", - "cot_gui_placeholder_code_paste_area": "You can put the code to be sent", - "cot_gui_label_prompt_area": "Prompt Area", + "cot_gui_placeholder_api_url": "The API URL to send to, e.g. https://llm.example.com/api", + "cot_gui_placeholder_code_paste_area": "Paste the code to review here", + "cot_gui_label_step": "Step:", + "cot_gui_placeholder_no_answers": "No answers yet", + "cot_gui_label_prompt_area": "Code to Review", "cot_gui_label_response_area": "Response Area", "cot_gui_button_send": "Start Sending", "cot_gui_warning_title": "Warning", "cot_gui_error_no_url": "Please enter the API URL first!", + "cot_gui_error_no_code": "Paste the code to review first.", "cot_gui_error_sending": "Error sending:", # Skills GUI "skills_error_status": "Error: {status_code}\n{text}", "skills_exception": "Exception occurred: {error}", "skills_api_url_label": "LLM API URL:", - "skills_api_url_placeholder": "Enter the API URL to send, e.g. http://127.0.0.1:5000/api", + "skills_api_url_placeholder": "The API URL to send to, e.g. https://llm.example.com/api", "skills_prompt_select_label": "Select Prompt Template:", "skills_prompt_label": "Prompt:", "skills_send_button": "Send", @@ -387,6 +400,7 @@ # Plugin Menu "plugin_menu_label": "Plugins", "plugin_menu_about": "About", + "plugin_about_text": "{name}\nVersion: {version}\nAuthor: {author}", "plugin_menu_run_with": "Run with {name}", # Run with Menu "run_folder_title": "Run a folder", @@ -409,7 +423,8 @@ "file_tree_ctx_already_exists": "'{name}' already exists.", "file_tree_ctx_bad_name": "'{name}' is not a name inside this folder: it may not have a drive, start with a slash, or contain '..' or ':'.", "file_tree_ctx_confirm_delete": "Confirm Delete", - "file_tree_ctx_confirm_delete_message": "Are you sure you want to delete '{name}'?", + "file_tree_ctx_confirm_delete_message": "Move '{name}' to the trash?", + "file_tree_ctx_no_trash": "'{name}' cannot be moved to the trash here. Delete it for good?", # Diagram Editor — Menu "extend_tools_menu_diagram_editor_tab_action": "Diagram Editor Tab", "extend_tools_menu_diagram_editor_tab_label": "Diagram Editor", @@ -423,7 +438,7 @@ # cURL Import — Widget "curl_import_input_label": "Paste a curl command:", "curl_import_input_placeholder": "curl 'https://api.example.com/v1/items' -H 'Accept: application/json'", - "curl_import_convert_button": "Convert to Python requests", + "curl_import_convert_button": "Generate {target}", "curl_import_output_label": "Generated code:", "curl_import_error": "Could not parse the curl command: {error}", "curl_import_empty_hint": "Paste a curl command above, then click convert.", @@ -494,6 +509,7 @@ "timestamp_epoch_seconds_label": "Epoch (seconds)", "timestamp_epoch_millis_label": "Epoch (milliseconds)", "timestamp_iso_label": "ISO-8601 (UTC)", + "timestamp_result_line": "{label}: {value}", "timestamp_error": "Could not convert the value: {error}", "timestamp_empty_hint": "Enter an epoch value or an ISO date-time above.", # Hash Generator — Menu @@ -514,9 +530,11 @@ # Query <-> JSON — Widget "query_json_input_label": "Query string or JSON object:", "query_json_input_placeholder": "a=1&b=2 or {\"a\": \"1\", \"b\": \"2\"}", + "ctrl_enter_when_json": "Ctrl+Enter, when the input is a JSON object", + "ctrl_enter_when_not_json": "Ctrl+Enter, when the input is not a JSON object", "query_json_to_json_button": "Query → JSON", "query_json_to_query_button": "JSON → Query", - "query_json_output_label": "Result:", + "query_json_output_label": _RESULT_LABEL, "query_json_error": "Could not convert: {error}", "query_json_empty_hint": "Enter a query string or a JSON object above.", # URL Parser / Builder — Menu @@ -530,7 +548,7 @@ "https://user@host:8080/path?a=1#frag or {\"scheme\": \"https\", \"host\": \"host\"}", "url_builder_to_json_button": "URL → JSON", "url_builder_to_url_button": "JSON → URL", - "url_builder_output_label": "Result:", + "url_builder_output_label": _RESULT_LABEL, "url_builder_parse_error": "Could not read the URL: {error}", "url_builder_error": "Could not build URL: {error}", "url_builder_empty_hint": "Enter a URL or a JSON object of URL parts above.", @@ -550,6 +568,8 @@ "regex_match_count_capped": "The first {count} match(es); there may be more:", "regex_running": "Running the pattern…", "regex_no_match": "No matches.", + "regex_group_line": "group {index}: {value}", + "regex_named_group_line": "{name}: {value}", "regex_error": "Regex error: {error}", # HTTP Status Reference — Menu "extend_tools_menu_http_status_tab_action": "HTTP Status Reference Tab", @@ -558,6 +578,12 @@ "extend_tools_menu_http_status_dock_title": "HTTP Status", # HTTP Status Reference — Widget "http_status_search_label": "Search by code or keyword:", + "http_status_category_informational": "Informational", + "http_status_category_success": "Success", + "http_status_category_redirection": "Redirection", + "http_status_category_client_error": "Client Error", + "http_status_category_server_error": "Server Error", + "http_status_category_unknown": "Unknown", "http_status_search_placeholder": "404 or not found", "http_status_no_match": "No matching status codes.", # Text Diff — Menu @@ -582,7 +608,7 @@ "json_format_input_placeholder": "{\"a\": 1, \"b\": [2, 3]}", "json_format_format_button": "Format", "json_format_minify_button": "Minify", - "json_format_output_label": "Result:", + "json_format_output_label": _RESULT_LABEL, "json_format_error": "Invalid JSON: {error}", "json_format_empty_hint": "Paste JSON above, then format or minify.", # Response Inspector — Menu @@ -596,7 +622,7 @@ "response_analyze_button": "Analyze", "response_output_label": "Analysis:", "response_open_jwt_button": "Open JWT in decoder", - "response_open_status_button": "Open status in reference", + "response_open_status_button": "Look up the status code", "response_open_headers_button": "Open headers in analyzer", "response_open_body_button": "Open body in JSON format", "response_empty_hint": "Paste a response above, then click analyze.", @@ -623,7 +649,7 @@ "header_analyzer_empty_hint": "Paste a header block above, then click analyze.", "header_analyzer_level_warning": "WARNING", "header_analyzer_level_info": "INFO", - "header_analyzer_open_jwt_button": "Open token in JWT decoder", + "header_analyzer_open_jwt_button": "Open JWT in decoder", # HTTP Header Analyzer — Findings ({header} is the header, {detail} its value) "header_finding_duplicate_header": "{header}: sent {detail} times; the receiver joins the values into one.", @@ -667,6 +693,8 @@ "diagram_editor_tool_diamond": "Diamond", "diagram_editor_tool_connection": "Connect", "diagram_editor_tool_text": "Text", + "diagram_editor_new_node_text": "Node", + "diagram_editor_new_text_text": "Text", # Diagram Editor — Actions "diagram_editor_action_new": "New", "diagram_editor_action_open": "Open", @@ -694,6 +722,11 @@ "diagram_editor_confirm_title": "Confirm", "diagram_editor_confirm_new": "Discard current diagram?", "diagram_editor_dialog_open": "Open Diagram", + "diagram_editor_filter_diagram": "Diagram JSON (*.diagram.json)", + "diagram_editor_filter_png": "PNG Image (*.png)", + "diagram_editor_filter_svg": "SVG Image (*.svg)", + "diagram_editor_filter_images": "Images ({patterns})", + "diagram_editor_filter_all": "All Files (*)", "diagram_editor_dialog_save": "Save Diagram", "diagram_editor_dialog_export_png": "Export PNG", "diagram_editor_dialog_export_svg": "Export SVG", @@ -771,6 +804,23 @@ "plugin_browser_status_downloading": "Downloading {name}...", "plugin_browser_status_installed": "Installed: {path}", "plugin_browser_restart_hint": "Plugin downloaded to:\n{path}\n\nPlease restart the editor to activate.", + # A run window's own notices about the run (extend/process_executor/run_notice.py) + "run_window_no_compiler": "[Error] The run config names no compiler", + "run_window_compile": "[Compile] {command}", + "run_window_stopped": "[Stopped]", + "run_window_compile_failed": "[Compile failed] exit code {code}", + "run_window_run": "[Run] {name}", + "run_window_command_not_found": "[Error] Command not found: {command}", + "run_window_could_not_start": "[Error] Could not start {command}: {reason}", + "run_window_timed_out": "[Error] Timed out after {seconds}s", + "run_window_needs_editor_tab": "[Error] {package} runs the script in the editor tab in front; open it and try again", + "run_window_mail_sent": "[Mail] The test report was sent", + "run_window_mail_not_sent": "[Mail] The test report was not sent: {reason}", + "run_window_no_interpreter": "[Error] No Python interpreter found: {error}", + "run_window_package_could_not_start": "[Error] {package} could not start: {reason}", + "run_window_exit_code": "Task exit with code {code}", + "run_window_output_still_held": + "[A process started by this run still holds its output; what it writes from now on is not shown]", } # Why a tool refused its input (pybreeze_ui/error_text.py): in English, the # constants the tools raise diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index d4a96197..c9049ca5 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -4,6 +4,7 @@ _COT_PROMPT_EDITOR = "CoT 提示詞編輯器" _SKILL_PROMPT_EDITOR = "Skill 提示詞編輯器" +_RESULT_LABEL = "結果:" # PyBreeze-specific Traditional Chinese translations pybreeze_traditional_chinese_word_dict = { @@ -15,8 +16,8 @@ "automation_menu_label": "自動化", "install_menu_label": "安裝", # Normal label - "run_label": "運行", - "help_label": "幫助", + "run_label": "執行", + "help_label": "說明", "project_label": "專案", "create_project_exists": "{path} 已經存在。要用新的範本檔取代,並捨棄你對它們的修改嗎?", "create_project_failed": "無法在 {path} 建立專案:{error}", @@ -26,26 +27,26 @@ "tab_menu_jupyterlab_tab_name": "JupyterLab", # APITestka Menu "apitestka_menu_label": "APITestka", - "apitestka_run_script_label": "運行 APITestka 腳本", - "apitestka_run_script_with_send_label": "運行 APITestka 腳本並寄信", - "apitestka_run_multi_script_label": "運行多個 APITestka 腳本", - "apitestka_run_multi_script_with_send_label": "運行多個 APITestka 腳本並寄信", + "apitestka_run_script_label": "執行 APITestka 腳本", + "apitestka_run_script_with_send_label": "執行 APITestka 腳本並寄信", + "apitestka_run_multi_script_label": "執行多個 APITestka 腳本", + "apitestka_run_multi_script_with_send_label": "執行多個 APITestka 腳本並寄信", "apitestka_doc_label": "開啟 APITestka 文件", "apitestka_doc_tab_label": "APITestka 文件", "apitestka_github_label": "開啟 APITestka GitHub", "apitestka_github_tab_label": "APITestka GitHub", "apitestka_create_project_label": "建立 APITestka 專案", - # Autocontrol Menu - "autocontrol_menu_label": "Autocontrol", - "autocontrol_run_script_label": "運行 Autocontrol 腳本", - "autocontrol_run_script_with_send_label": "運行 Autocontrol 腳本並寄信", - "autocontrol_run_multi_script_label": "運行多個 Autocontrol 腳本", - "autocontrol_run_multi_script_with_send_label": "運行多個 Autocontrol 腳本並寄信", - "autocontrol_doc_label": "開啟 Autocontrol 文件", - "autocontrol_doc_tab_label": "Autocontrol 文件", - "autocontrol_github_label": "開啟 Autocontrol GitHub", - "autocontrol_github_tab_label": "Autocontrol GitHub", - "autocontrol_create_project_label": "建立 Autocontrol 專案", + # AutoControl Menu + "autocontrol_menu_label": "AutoControl", + "autocontrol_run_script_label": "執行 AutoControl 腳本", + "autocontrol_run_script_with_send_label": "執行 AutoControl 腳本並寄信", + "autocontrol_run_multi_script_label": "執行多個 AutoControl 腳本", + "autocontrol_run_multi_script_with_send_label": "執行多個 AutoControl 腳本並寄信", + "autocontrol_doc_label": "開啟 AutoControl 文件", + "autocontrol_doc_tab_label": "AutoControl 文件", + "autocontrol_github_label": "開啟 AutoControl GitHub", + "autocontrol_github_tab_label": "AutoControl GitHub", + "autocontrol_create_project_label": "建立 AutoControl 專案", "autocontrol_record_menu_label": "錄製", "autocontrol_record_start_label": "開始錄製", "autocontrol_record_stop_label": "停止錄製", @@ -53,10 +54,10 @@ "autocontrol_record_copied": "錄製結果已複製到剪貼簿:目前最前面的不是編輯分頁,沒有地方可以插入。", # File Automation Menu "file_automation_menu_label": "FileAutomation", - "file_automation_run_script_label": "運行 FileAutomation 腳本", - "file_automation_run_script_with_send_label": "運行 FileAutomation 腳本並寄信", - "file_automation_run_multi_script_label": "運行多個 FileAutomation 腳本", - "file_automation_run_multi_script_with_send_label": "運行多個 FileAutomation 腳本並寄信", + "file_automation_run_script_label": "執行 FileAutomation 腳本", + "file_automation_run_script_with_send_label": "執行 FileAutomation 腳本並寄信", + "file_automation_run_multi_script_label": "執行多個 FileAutomation 腳本", + "file_automation_run_multi_script_with_send_label": "執行多個 FileAutomation 腳本並寄信", "file_automation_doc_label": "開啟 FileAutomation 文件", "file_automation_doc_tab_label": "FileAutomation 文件", "file_automation_github_label": "開啟 FileAutomation GitHub", @@ -64,10 +65,10 @@ "file_automation_create_project_label": "建立 FileAutomation 專案", # Load Density Menu "load_density_menu_label": "LoadDensity", - "load_density_run_script_label": "運行 LoadDensity 腳本", - "load_density_run_script_with_send_label": "運行 LoadDensity 腳本並寄信", - "load_density_run_multi_script_label": "運行多個 LoadDensity 腳本", - "load_density_run_multi_script_with_send_label": "運行多個 LoadDensity 腳本並寄信", + "load_density_run_script_label": "執行 LoadDensity 腳本", + "load_density_run_script_with_send_label": "執行 LoadDensity 腳本並寄信", + "load_density_run_multi_script_label": "執行多個 LoadDensity 腳本", + "load_density_run_multi_script_with_send_label": "執行多個 LoadDensity 腳本並寄信", "load_density_doc_label": "開啟 LoadDensity 文件", "load_density_doc_tab_label": "LoadDensity 文件", "load_density_github_label": "開啟 LoadDensity GitHub", @@ -75,7 +76,7 @@ "load_density_create_project_label": "建立 LoadDensity 專案", # Mail Thunder Menu "mail_thunder_menu_label": "MailThunder", - "mail_thunder_run_script_label": "運行 MailThunder 腳本", + "mail_thunder_run_script_label": "執行 MailThunder 腳本", "mail_thunder_doc_label": "開啟 MailThunder 文件", "mail_thunder_doc_tab_label": "MailThunder 文件", "mail_thunder_github_label": "開啟 MailThunder GitHub", @@ -83,10 +84,10 @@ "mail_thunder_create_project_label": "建立 MailThunder 專案", # Webrunner Menu "web_runner_menu_label": "WebRunner", - "web_runner_run_script_label": "運行 WebRunner 腳本", - "web_runner_run_script_with_send_label": "運行 WebRunner 腳本並寄信", - "web_runner_run_multi_script_label": "運行多個 WebRunner 腳本", - "web_runner_run_multi_script_with_send_label": "運行多個 WebRunner 腳本並寄信", + "web_runner_run_script_label": "執行 WebRunner 腳本", + "web_runner_run_script_with_send_label": "執行 WebRunner 腳本並寄信", + "web_runner_run_multi_script_label": "執行多個 WebRunner 腳本", + "web_runner_run_multi_script_with_send_label": "執行多個 WebRunner 腳本並寄信", "web_runner_doc_label": "開啟 WebRunner 文件", "web_runner_doc_tab_label": "WebRunner 文件", "web_runner_github_label": "開啟 WebRunner GitHub", @@ -97,25 +98,22 @@ "install_menu_apitestka": "安裝 APITestka", "install_menu_loaddensity": "安裝 LoadDensity", "install_menu_webrunner": "安裝 WebRunner", - "install_menu_automation_file": "安裝 Automation File", + "install_menu_automation_file": "安裝 FileAutomation", "install_menu_mail_thunder": "安裝 MailThunder", + "install_menu_test_pioneer": "安裝 TestPioneer", "install_menu_prthinker": "安裝 prthinker(程式碼審查)", "install_menu_tools_install_menu_label": "工具", "install_menu_tools_install_build_tools": "安裝 Build Tools", - # Tools Menu - "tools_menu_re_edge_gpt_label": "ReEdgeGPT", - "tools_menu_re_edge_gpt_doc_label": "開啟 ReEdgeGPT 文件", - "tools_menu_re_edge_gpt_doc_tab_label": "ReEdgeGPT 文件", - "tools_menu_re_edge_gpt_github_label": "開啟 ReEdgeGPT GitHub", - "tools_menu_re_edge_gpt_github_tab_label": "ReEdgeGPT GitHub", # Test Pioneer Menu "test_pioneer_label": "TestPioneer", - "test_pioneer_create_template_label": "建立 TestPioneer Yaml 模板", + "test_pioneer_github_label": "開啟 TestPioneer GitHub", + "test_pioneer_github_tab_label": "TestPioneer GitHub", + "test_pioneer_create_template_label": "建立 TestPioneer YAML 範本", "test_pioneer_template_exists": "{path} 已經存在。要用新的範本取代,並捨棄它目前的內容嗎?", "test_pioneer_template_failed": "無法在 {path} 建立範本:{error}", "test_pioneer_template_created": "已建立範本:{path}", - "test_pioneer_run_yaml": "執行 Test Pioneer Yaml", - "test_pioneer_not_choose_yaml": "請選擇 Yaml 檔案", + "test_pioneer_run_yaml": "執行 TestPioneer YAML", + "test_pioneer_not_choose_yaml": "請選擇 YAML 檔案", # prthinker code review "prthinker_menu_label": "程式碼審查(prthinker)", "prthinker_review_current_file_label": "審查目前的檔案", @@ -149,7 +147,7 @@ "prthinker_setting_bad_extra_arguments": "額外參數無法解讀:有引號沒有關上。請補上或刪掉後再存檔。", "prthinker_setting_save_failed": "設定無法存到 {path},原本的設定保持不變。", # Prompt 編輯器 —— 編輯過的 prompt 會覆寫內建版本 - "prompt_editor_stored_at_label": "Prompt 檔案位置(會覆寫內建 prompt):", + "prompt_editor_stored_at_label": "提示詞檔案位置(會覆寫內建提示詞):", "prthinker_choose_source_path_label": "選擇 prthinker 原始碼資料夾", "prthinker_need_source_path_message": "prthinker 是從原始碼安裝的。請選擇含有 pyproject.toml 的資料夾," @@ -164,14 +162,18 @@ # SSH command widget "ssh_command_widget_window_title_ssh_command_widget": "SSH 指令介面", "ssh_command_widget_button_label_send_command": "送出", + "ssh_command_widget_button_label_interrupt": "中斷", + "ssh_command_widget_tooltip_interrupt": "送出 Ctrl+C,停止 shell 中正在執行的程式(指令列沒有選取文字時按 Ctrl+C 也一樣)", "ssh_command_widget_input_placeholder_command_line": "輸入指令後按 Enter...", "ssh_command_widget_dialog_title_input_error": "輸入錯誤", "ssh_command_widget_dialog_message_input_error_host_user_required": "必須輸入主機與使用者名稱。", "ssh_command_widget_dialog_title_key_error": "金鑰錯誤", "ssh_command_widget_dialog_message_key_file_not_exist": "金鑰檔案不存在。", "ssh_command_widget_error_message_unsupported_private_key": "不支援或無效的私鑰。", - "ssh_key_error_passphrase_needed": "這把私鑰有密語保護:請在密碼欄輸入密語。", - "ssh_key_error_passphrase_wrong": "私鑰的密語不對。", + "ssh_key_error_passphrase_needed": "這把私鑰有密語保護:請在密語欄輸入。", # NOSONAR S2068 — a message, not a credential + "ssh_key_error_passphrase_wrong": "私鑰的密語不對。", # NOSONAR S2068 — a message, not a credential + "ssh_key_error_putty_key": "這是 PuTTY 金鑰(.ppk),這裡無法使用:請在 PuTTYgen 載入它," + "選 Conversions > Export OpenSSH key 匯出,再選擇匯出的檔案。", "ssh_command_widget_error_message_key_auth_failed": "金鑰驗證失敗", "ssh_command_widget_status_label_connected": "已連線", "ssh_command_widget_status_label_disconnected": "已斷線", @@ -217,23 +219,25 @@ "prompt_editor_not_utf8": "{filename} 不是 UTF-8 文字檔。讀不出來的字元已經換掉後顯示;存檔時會改存成 UTF-8。", "prompt_editor_switch_over_edits": - "{filename} 還有沒存的編輯。要切換模板並捨棄它們嗎?", + "{filename} 還有沒存的編輯。要切換範本並捨棄它們嗎?", "prompt_editor_reload_button_over_edits": "{filename} 還有沒存的編輯。要從磁碟重新載入並捨棄它們嗎?", "prompt_editor_create_over_edits": - "要用內建模板建立 {filename} 嗎?這裡輸入的內容會被取代。", + "要用內建範本建立 {filename} 嗎?這裡輸入的內容會被取代。", "prompt_editor_unreadable": "{filename} 讀不出來:{error}", "prompt_editor_close_over_edits": "{filename} 還有沒存的編輯。要關閉並捨棄它們嗎?", "diagram_editor_close_over_edits": "架構圖還有沒存的變更。要關閉並捨棄它們嗎?", + "diagram_editor_open_over_edits": + "架構圖還有沒存的變更。要開啟另一個並捨棄它們嗎?", "unsaved_close_title": "尚未儲存的變更", "prompt_editor_reload_over_edits": "{filename} 在磁碟上被改過了。要重新載入並捨棄這裡還沒存的編輯嗎?", - "ssh_state_shell_and_files": "已連線:終端與檔案", - "ssh_state_shell_only": "已連線:只有終端(檔案樹沒連上)", - "ssh_state_files_only": "已連線:只有檔案(終端沒連上)", + "ssh_state_shell_and_files": "已連線:終端機與檔案", + "ssh_state_shell_only": "已連線:只有終端機(檔案樹沒連上)", + "ssh_state_files_only": "已連線:只有檔案(終端機沒連上)", "ssh_state_neither": "未連線", "ssh_file_viewer_message_session_busy": "SFTP 連線正在傳輸檔案或列出目錄,請等它完成後再試。", "ssh_file_viewer_dialog_title_transfer_running": "已有傳輸進行中", @@ -261,10 +265,12 @@ "ssh_login_widget_label_user": "使用者", "ssh_login_widget_label_key": "金鑰", "ssh_login_widget_label_password": "密碼", - "ssh_login_widget_placeholder_host": "主機 (例如: 192.168.0.10)", + "ssh_login_widget_label_passphrase": "密語", # nosec B105 # NOSONAR S2068 — UI text + "ssh_login_widget_placeholder_host": "主機名稱或 IP 位址", "ssh_login_widget_placeholder_username": "使用者名稱", "ssh_login_widget_placeholder_password": "密碼", - "ssh_login_widget_placeholder_private_key": "私鑰路徑 (.pem/.ppk)", + "ssh_login_widget_placeholder_passphrase": "私鑰的密語(沒有就留空)", # nosec B105 # NOSONAR S2068 — UI text + "ssh_login_widget_placeholder_private_key": "私鑰路徑(OpenSSH 或 PEM)", "ssh_login_widget_button_use_key_auth": "使用金鑰驗證", "ssh_login_widget_button_connect": "連線", "ssh_login_widget_button_disconnect": "斷線", @@ -281,6 +287,7 @@ "ai_code_review_gui_button_accept_response": "接受回應", "ai_code_review_gui_button_reject_response": "拒絕回應", "ai_code_review_gui_message_enter_valid_url": "請輸入有效的網址", + "ai_code_review_gui_message_paste_code": "請先貼上要審查的程式碼。", "ai_code_review_gui_message_url_already_recorded": "此網址已被紀錄,仍然送出請求...", "ai_code_review_gui_message_new_url_recorded": "新網址已紀錄,正在送出請求...", "ai_code_review_gui_message_unsupported_http_method": "不支援的 HTTP 方法", @@ -301,7 +308,7 @@ "cot_prompt_editor_msgbox_file_created": "檔案 {filename} 已建立", "cot_prompt_editor_msgbox_file_saved": "檔案 {filename} 已儲存", "cot_prompt_editor_msgbox_no_file_selected": "尚未選擇檔案", - "cot_prompt_editor_file_not_exist": "(檔案 {filename} 不存在)", + "cot_prompt_editor_file_not_exist": "(檔案 {filename} 不存在)", # Skill Prompt Editor "skill_prompt_editor_window_title": "技能提示編輯器", "skill_prompt_editor_groupbox_edit_file_content": "編輯檔案內容", @@ -315,7 +322,7 @@ "skill_prompt_editor_msgbox_file_created": "檔案 {filename} 已建立", "skill_prompt_editor_msgbox_file_saved": "檔案 {filename} 已儲存", "skill_prompt_editor_msgbox_no_file_selected": "未選擇檔案", - "skill_prompt_editor_file_not_exist": "(檔案 {filename} 不存在)", + "skill_prompt_editor_file_not_exist": "(檔案 {filename} 不存在)", # Extend Menu "extend_tools_menu_tools_menu": "工具", "extend_tools_menu_tools_ssh_menu": "SSH", @@ -324,13 +331,14 @@ "extend_tools_menu_ssh_client_tab_label": "SSH 用戶端", "extend_tools_menu_ai_code_review_tab_action": "AI 程式碼審查分頁", "extend_tools_menu_ai_code_review_tab_label": "AI 程式碼審查", - "extend_tools_menu_cot_prompt_editor_tab_action": _COT_PROMPT_EDITOR, + "extend_tools_menu_cot_prompt_editor_tab_action": _COT_PROMPT_EDITOR + "分頁", "extend_tools_menu_cot_prompt_editor_tab_label": _COT_PROMPT_EDITOR, "extend_tools_menu_cot_code_review_tab_action": "CoT 程式碼審查分頁", "extend_tools_menu_cot_code_review_tab_label": "CoT 程式碼審查", - "extend_tools_menu_skill_prompt_editor_tab_action": _SKILL_PROMPT_EDITOR, + "extend_tools_menu_skill_prompt_editor_tab_action": _SKILL_PROMPT_EDITOR + "分頁", "extend_tools_menu_skill_prompt_editor_tab_label": _SKILL_PROMPT_EDITOR, - "extend_tools_menu_skill_prompt_send_tab_label": "Skill 提示詞傳送 GUI", + "extend_tools_menu_skill_prompt_send_tab_label": "Skill 提示詞傳送", + "extend_tools_menu_skill_prompt_send_tab_action": "Skill 提示詞傳送分頁", "extend_tools_menu_dock_ssh_menu": "SSH", "extend_tools_menu_dock_ai_menu": "AI", "extend_tools_menu_ssh_client_dock_action": "SSH 用戶端停駐窗格", @@ -343,32 +351,35 @@ "extend_tools_menu_ai_code_review_dock_title": "AI 程式碼審查", "extend_tools_menu_cot_prompt_editor_dock_title": _COT_PROMPT_EDITOR, "extend_tools_menu_skill_prompt_editor_dock_title": _SKILL_PROMPT_EDITOR, - "extend_tools_menu_skill_prompt_send_dock_action": "Skill Prompt 傳送停駐窗格", - "extend_tools_menu_skill_prompt_send_dock_title": "Skill 提示詞傳送 GUI", + "extend_tools_menu_skill_prompt_send_dock_action": "Skill 提示詞傳送停駐窗格", + "extend_tools_menu_skill_prompt_send_dock_title": "Skill 提示詞傳送", # CoT code-review GUI - "cot_gui_window_title": "Prompt 傳送介面", - "cot_gui_label_api_url": "API URL:", - "cot_gui_placeholder_api_url": "請輸入要傳送的 API URL,例如 http://127.0.0.1:5000/api", - "cot_gui_placeholder_code_paste_area": "這裡會顯示要傳送的 Prompt 內容", - "cot_gui_label_prompt_area": "傳送資料區域", + "cot_gui_window_title": "CoT 程式碼審查", + "cot_gui_label_api_url": "API URL:", + "cot_gui_placeholder_api_url": "要傳送到的 API URL,例如 https://llm.example.com/api", + "cot_gui_placeholder_code_paste_area": "在這裡貼上要審查的程式碼", + "cot_gui_label_step": "步驟:", + "cot_gui_placeholder_no_answers": "還沒有回覆", + "cot_gui_label_prompt_area": "要審查的程式碼", "cot_gui_label_response_area": "回傳區域", "cot_gui_button_send": "開始傳送", "cot_gui_warning_title": "警告", "cot_gui_error_no_url": "請先輸入 API URL!", + "cot_gui_error_no_code": "請先貼上要審查的程式碼。", "cot_gui_error_sending": "傳送失敗:", # Skills GUI - "skills_error_status": "錯誤: {status_code}\n{text}", - "skills_exception": "發生例外: {error}", - "skills_api_url_label": "LLM API URL:", - "skills_api_url_placeholder": "請輸入要傳送的 API URL,例如 http://127.0.0.1:5000/api", - "skills_prompt_select_label": "選擇 Prompt 範本:", - "skills_prompt_label": "Prompt:", + "skills_error_status": "錯誤:{status_code}\n{text}", + "skills_exception": "發生例外:{error}", + "skills_api_url_label": "LLM API URL:", + "skills_api_url_placeholder": "要傳送到的 API URL,例如 https://llm.example.com/api", + "skills_prompt_select_label": "選擇提示詞範本:", + "skills_prompt_label": "提示詞:", "skills_send_button": "傳送", - "skills_response_label": "回傳結果:", - "skills_missing_input": "請輸入 API URL 和 Prompt", + "skills_response_label": "回傳結果:", + "skills_missing_input": "請輸入 API URL 和提示詞", "skills_generating": "產生中...", - "skills_switch_over_edits": "Prompt 已經修改過。要換成 {name},並捨棄這些修改嗎?", - "skills_code_missing": "送出前,請把程式碼放在 prompt 裡 {code_diff} 的位置。", + "skills_switch_over_edits": "提示詞已經修改過。要換成 {name},並捨棄這些修改嗎?", + "skills_code_missing": "送出前,請把程式碼放在提示詞裡 {code_diff} 的位置。", # JupyterLab GUI "jupyterlab_init": "初始化中...", "jupyterlab_downloading": "下載中...", @@ -389,7 +400,7 @@ # cURL 匯入 — 介面 "curl_import_input_label": "貼上 curl 指令:", "curl_import_input_placeholder": "curl 'https://api.example.com/v1/items' -H 'Accept: application/json'", - "curl_import_convert_button": "轉換為 Python requests", + "curl_import_convert_button": "產生 {target}", "curl_import_output_label": "產生的程式碼:", "curl_import_error": "無法解析 curl 指令:{error}", "curl_import_empty_hint": "請先在上方貼上 curl 指令,再按轉換。", @@ -421,9 +432,9 @@ "har_import_generate_error": "無法產生腳本:{error}", "har_import_not_utf8": "不是 UTF-8 文字檔", "har_import_read_error": "無法開啟檔案:{error}", - # 共用輸出動作(複製 / 開成分頁 / 存檔) + # 共用輸出動作(複製 / 在編輯器分頁開啟 / 存檔) "output_actions_copy": "複製", - "output_actions_open_editor": "開成編輯器分頁", + "output_actions_open_editor": "在編輯器分頁開啟", "output_actions_save": "存成檔案...", "output_actions_editor_tab_label": "產生內容", "output_actions_save_failed_title": "沒有存檔", @@ -460,6 +471,7 @@ "timestamp_epoch_seconds_label": "Epoch(秒)", "timestamp_epoch_millis_label": "Epoch(毫秒)", "timestamp_iso_label": "ISO-8601(UTC)", + "timestamp_result_line": "{label}:{value}", "timestamp_error": "無法轉換此值:{error}", "timestamp_empty_hint": "請在上方輸入 epoch 值或 ISO 日期時間。", # 雜湊產生器 — 選單 @@ -480,9 +492,11 @@ # Query <-> JSON — 介面 "query_json_input_label": "查詢字串或 JSON 物件:", "query_json_input_placeholder": "a=1&b=2 或 {\"a\": \"1\", \"b\": \"2\"}", + "ctrl_enter_when_json": "Ctrl+Enter(輸入是 JSON 物件時)", + "ctrl_enter_when_not_json": "Ctrl+Enter(輸入不是 JSON 物件時)", "query_json_to_json_button": "Query → JSON", "query_json_to_query_button": "JSON → Query", - "query_json_output_label": "結果:", + "query_json_output_label": _RESULT_LABEL, "query_json_error": "無法轉換:{error}", "query_json_empty_hint": "請在上方輸入查詢字串或 JSON 物件。", # URL 解析/組建器 — 選單 @@ -496,15 +510,15 @@ "https://user@host:8080/path?a=1#frag 或 {\"scheme\": \"https\", \"host\": \"host\"}", "url_builder_to_json_button": "URL → JSON", "url_builder_to_url_button": "JSON → URL", - "url_builder_output_label": "結果:", + "url_builder_output_label": _RESULT_LABEL, "url_builder_parse_error": "無法解析這個 URL:{error}", "url_builder_error": "無法組建 URL:{error}", "url_builder_empty_hint": "請在上方輸入 URL 或描述 URL 各部分的 JSON 物件。", # 正規表示式測試器 — 選單 "extend_tools_menu_regex_tab_action": "正規表示式測試器分頁", - "extend_tools_menu_regex_tab_label": "Regex", + "extend_tools_menu_regex_tab_label": "正規表示式", "extend_tools_menu_regex_dock_action": "正規表示式測試器停駐窗格", - "extend_tools_menu_regex_dock_title": "Regex", + "extend_tools_menu_regex_dock_title": "正規表示式", # 正規表示式測試器 — 介面 "regex_pattern_label": "樣式:", "regex_pattern_placeholder": r"(\d{4})-(\d{2})-(\d{2})", @@ -516,6 +530,8 @@ "regex_match_count_capped": "前 {count} 個符合,可能還有更多:", "regex_running": "正在執行這個 pattern…", "regex_no_match": "沒有符合項。", + "regex_group_line": "群組 {index}:{value}", + "regex_named_group_line": "{name}:{value}", "regex_error": "正規表示式錯誤:{error}", # HTTP 狀態碼參考 — 選單 "extend_tools_menu_http_status_tab_action": "HTTP 狀態碼參考分頁", @@ -524,6 +540,12 @@ "extend_tools_menu_http_status_dock_title": "HTTP 狀態碼", # HTTP 狀態碼參考 — 介面 "http_status_search_label": "以代碼或關鍵字搜尋:", + "http_status_category_informational": "資訊", + "http_status_category_success": "成功", + "http_status_category_redirection": "重新導向", + "http_status_category_client_error": "用戶端錯誤", + "http_status_category_server_error": "伺服器錯誤", + "http_status_category_unknown": "未知", "http_status_search_placeholder": "404 或 not found", "http_status_no_match": "沒有符合的狀態碼。", # 文字差異 — 選單 @@ -548,7 +570,7 @@ "json_format_input_placeholder": "{\"a\": 1, \"b\": [2, 3]}", "json_format_format_button": "格式化", "json_format_minify_button": "壓縮", - "json_format_output_label": "結果:", + "json_format_output_label": _RESULT_LABEL, "json_format_error": "無效的 JSON:{error}", "json_format_empty_hint": "請在上方貼上 JSON,再按格式化或壓縮。", # 回應檢視器 — 選單 @@ -562,7 +584,7 @@ "response_analyze_button": "分析", "response_output_label": "分析結果:", "response_open_jwt_button": "在解碼器開啟 JWT", - "response_open_status_button": "在參考開啟狀態碼", + "response_open_status_button": "查詢狀態碼", "response_open_headers_button": "在分析器開啟標頭", "response_open_body_button": "在 JSON 格式化開啟內容", "response_empty_hint": "請先在上方貼上回應,再按分析。", @@ -589,7 +611,7 @@ "header_analyzer_empty_hint": "請先在上方貼上標頭,再按分析。", "header_analyzer_level_warning": "警告", "header_analyzer_level_info": "資訊", - "header_analyzer_open_jwt_button": "在 JWT 解碼器開啟權杖", + "header_analyzer_open_jwt_button": "在解碼器開啟 JWT", # HTTP 標頭分析器 — 發現({header} 為標頭名稱,{detail} 為其值) "header_finding_duplicate_header": "{header}:送出 {detail} 次,接收端會把這些值合併成一個。", @@ -633,6 +655,8 @@ "diagram_editor_tool_diamond": "菱形", "diagram_editor_tool_connection": "連線", "diagram_editor_tool_text": "文字", + "diagram_editor_new_node_text": "節點", + "diagram_editor_new_text_text": "文字", # Diagram Editor — 動作 "diagram_editor_action_new": "新增", "diagram_editor_action_open": "開啟", @@ -645,7 +669,7 @@ "diagram_editor_action_undo": "復原", "diagram_editor_action_redo": "重做", "diagram_editor_action_grid": "格線", - "diagram_editor_action_snap": "對齊", + "diagram_editor_action_snap": "貼齊", # Diagram Editor — 對齊 "diagram_editor_align_menu": "對齊", "diagram_editor_align_left": "靠左對齊", @@ -660,6 +684,11 @@ "diagram_editor_confirm_title": "確認", "diagram_editor_confirm_new": "是否捨棄目前的架構圖?", "diagram_editor_dialog_open": "開啟架構圖", + "diagram_editor_filter_diagram": "架構圖 JSON (*.diagram.json)", + "diagram_editor_filter_png": "PNG 圖片 (*.png)", + "diagram_editor_filter_svg": "SVG 圖片 (*.svg)", + "diagram_editor_filter_images": "圖片 ({patterns})", + "diagram_editor_filter_all": "所有檔案 (*)", "diagram_editor_dialog_save": "儲存架構圖", "diagram_editor_dialog_export_png": "匯出 PNG", "diagram_editor_dialog_export_svg": "匯出 SVG", @@ -676,7 +705,7 @@ "diagram_editor_prop_shape": "形狀", "diagram_editor_prop_fill_color": "填充", "diagram_editor_prop_border_color": "邊框", - "diagram_editor_prop_font_size": "字體大小", + "diagram_editor_prop_font_size": "字型大小", "diagram_editor_prop_label": "標籤", "diagram_editor_prop_line_style": "樣式", "diagram_editor_prop_line_color": "顏色", @@ -733,36 +762,38 @@ "file_tree_ctx_already_exists": "'{name}' 已存在。", "file_tree_ctx_bad_name": "「{name}」不是這個資料夾裡的名稱:不能有磁碟代號、不能以斜線開頭,也不能含有「..」或「:」。", "file_tree_ctx_confirm_delete": "確認刪除", - "file_tree_ctx_confirm_delete_message": "確定要刪除 '{name}' 嗎?", + "file_tree_ctx_confirm_delete_message": "要把 '{name}' 移到回收筒嗎?", + "file_tree_ctx_no_trash": "'{name}' 在這裡無法移到回收筒。要永久刪除嗎?", # Plugin Menu - "plugin_menu_label": "插件", + "plugin_menu_label": "外掛", "plugin_menu_about": "關於", + "plugin_about_text": "{name}\n版本:{version}\n作者:{author}", "plugin_menu_run_with": "以 {name} 執行", # Run with Menu "run_folder_title": "執行資料夾", "run_folder_no_action_files": "{folder} 裡沒有動作 JSON 檔,所以沒有執行任何東西。", "run_with_menu_label": "以...執行", "run_with_save_failed": "{file} 無法存檔,所以沒有執行:{error}", - "run_with_suffix_mismatch": "目前的檔案 ({suffix}) 與預期的副檔名不符: {expected}", + "run_with_suffix_mismatch": "目前的檔案({suffix})與預期的副檔名不符:{expected}", # Plugin Browser - "plugin_browser_tab_name": "插件瀏覽器", + "plugin_browser_tab_name": "外掛瀏覽器", "plugin_browser_repo_label": "儲存庫 URL:", - "plugin_browser_fetch_btn": "取得插件", - "plugin_browser_col_name": "插件", + "plugin_browser_fetch_btn": "取得外掛", + "plugin_browser_col_name": "外掛", "plugin_browser_col_path": "路徑", "plugin_browser_col_size": "大小", - "plugin_browser_select_hint": "選擇插件以檢視詳細資訊", + "plugin_browser_select_hint": "選擇外掛以檢視詳細資訊", "plugin_browser_download_btn": "下載並安裝", "plugin_browser_loading_source": "正在載入原始碼...", - "plugin_browser_overwrite_title": "插件已存在", + "plugin_browser_overwrite_title": "外掛已存在", "plugin_browser_overwrite_msg": "{name} 已存在,是否覆蓋?", "plugin_browser_invalid_url": "無效的儲存庫 URL", "plugin_browser_status_ready": "就緒", - "plugin_browser_status_fetching": "正在取得插件列表...", - "plugin_browser_status_loaded": "已載入 {count} 個插件", + "plugin_browser_status_fetching": "正在取得外掛列表...", + "plugin_browser_status_loaded": "已載入 {count} 個外掛", "plugin_browser_status_downloading": "正在下載 {name}...", "plugin_browser_status_installed": "已安裝:{path}", - "plugin_browser_restart_hint": "插件已下載至:\n{path}\n\n請重新啟動編輯器以啟用。", + "plugin_browser_restart_hint": "外掛已下載至:\n{path}\n\n請重新啟動編輯器以啟用。", # 工具拒絕輸入的原因(pybreeze_ui/error_text.py):exception_tags 各常數的翻譯 "error_text_cant_reformat_json_error": "無法重新格式化 JSON:型別正確嗎?", "error_text_wrong_json_data_error": "無法解析 JSON", @@ -827,6 +858,30 @@ "error_text_server_error_error": "伺服器錯誤:{body}", "error_text_diagram_not_an_object_error": "架構圖檔案應該是一個物件,而不是 {kind}", "error_text_diagram_section_not_a_list_error": "架構圖的 '{section}' 應該是清單,而不是 {kind}", + "error_text_mail_not_installed_error": "沒有安裝 je_mail_thunder", + "error_text_mail_settings_unreadable_error": "無法讀取郵件設定檔(mail_thunder_content.json)", + "error_text_mail_no_user_error": "沒有設定郵件使用者", + "error_text_mail_login_failed_error": "郵件伺服器登入失敗", + "error_text_mail_send_failed_error": "寄送失敗({kind})", + "error_text_report_missing_error": "這次執行沒有寫出 {name}", + "error_text_report_not_a_file_error": "{name} 不是檔案", + "error_text_report_stale_error": "這次執行沒有寫出新的 {name};現有的是之前執行留下的", + # 執行視窗自己對這次執行的說明(extend/process_executor/run_notice.py) + "run_window_no_compiler": "[錯誤] 這個執行設定沒有指定編譯器", + "run_window_compile": "[編譯] {command}", + "run_window_stopped": "[已停止]", + "run_window_compile_failed": "[編譯失敗] 結束代碼 {code}", + "run_window_run": "[執行] {name}", + "run_window_command_not_found": "[錯誤] 找不到指令:{command}", + "run_window_could_not_start": "[錯誤] 無法啟動 {command}:{reason}", + "run_window_timed_out": "[錯誤] 超過 {seconds} 秒仍未完成", + "run_window_needs_editor_tab": "[錯誤] {package} 執行的是最前面那個編輯分頁裡的腳本;請先開啟腳本再試一次", + "run_window_mail_sent": "[郵件] 已寄出測試報告", + "run_window_mail_not_sent": "[郵件] 沒有寄出測試報告:{reason}", + "run_window_no_interpreter": "[錯誤] 找不到 Python 直譯器:{error}", + "run_window_package_could_not_start": "[錯誤] 無法啟動 {package}:{reason}", + "run_window_exit_code": "執行結束,結束代碼 {code}", + "run_window_output_still_held": "[這次執行啟動的某個行程仍握著輸出;它之後寫出的內容不會顯示]", } diff --git a/pybreeze/pybreeze_ui/code_result_logs.py b/pybreeze/pybreeze_ui/code_result_logs.py new file mode 100644 index 00000000..e0bf25dc --- /dev/null +++ b/pybreeze/pybreeze_ui/code_result_logs.py @@ -0,0 +1,29 @@ +"""Only warnings and errors from loggers in the editor's Code Result panel. + +JEditor shows log records in the panel, in red, through a handler it hooks onto +every logger that exists when its window is built. The automation packages set +the root logger to DEBUG as they are imported, so every library's debug records +went there: opening a file filled it with gitpython's ``Popen([...])`` lines, +and PyBreeze's own debug and info records went there too. +""" +from __future__ import annotations + +import logging + +from je_editor.utils.redirect_manager.redirect_manager_class import RedirectStdErr + + +def show_only_warnings_in_code_result() -> None: + """Let JEditor's Code Result handler take only warnings and worse, on every logger it is on. + + Call it once the editor window is built: JEditor hooks the handler then. It + changes the handler's level only; loggers keep theirs, so their files and + other handlers still get every record. + """ + loggers = [logging.root, *( + logger for logger in list(logging.root.manager.loggerDict.values()) + if isinstance(logger, logging.Logger))] + for logger in loggers: + for handler in logger.handlers: + if isinstance(handler, RedirectStdErr): + handler.setLevel(logging.WARNING) diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py index f3bc5a8b..40921855 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -3,10 +3,11 @@ import codecs import os import weakref +from dataclasses import dataclass import paramiko -from PySide6.QtCore import QThread, Signal -from PySide6.QtGui import QTextCursor +from PySide6.QtCore import QEvent, QThread, Qt, Signal +from PySide6.QtGui import QTextCharFormat, QTextCursor from PySide6.QtWidgets import ( QWidget, QLineEdit, QPushButton, QPlainTextEdit, QHBoxLayout, QVBoxLayout, @@ -22,10 +23,15 @@ ) from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key, unloadable_key_reason from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font +from pybreeze.pybreeze_ui.terminal_view import insert_rewinding, style_format, terminal_size from pybreeze.pybreeze_ui.thread_keeper import if_alive, let_run_out from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.terminal_text import split_unfinished_end, strip_terminal_controls, take_leading_backspaces +from pybreeze.utils.terminal_style import PLAIN, TextStyle, split_styled +from pybreeze.utils.terminal_text import ( + FULL_RESET, split_at_screen_clear, split_unfinished_end, strip_terminal_controls, take_leading_backspaces, +) # What closing a channel or a client can raise on a connection already broken CLOSE_ERRORS = (OSError, EOFError, paramiko.SSHException) @@ -37,27 +43,53 @@ class TerminalDecoder: A read ends wherever the channel's buffer did, so it can stop inside a multi-byte UTF-8 character or inside an escape sequence. Decoding each read on its own showed the character as replacement marks and the escape's tail - as text; this carries the unfinished part over to the next read. + as text; this carries the unfinished part over to the next read. The + colours and emphasis SGR sequences set carry over too. """ def __init__(self) -> None: self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") self._pending = "" + self._style = PLAIN def reset(self) -> None: """Forget anything carried over, for a new session.""" self._decoder.reset() self._pending = "" + self._style = PLAIN - def feed(self, data: bytes) -> str: - """Return the text *data* completes, escape sequences removed. + def feed(self, data: bytes) -> TerminalOutput: + """Return what *data* completes: whether it clears the screen, and the text after. - Backspaces it starts with are kept, for the view to take back what an - earlier read showed; any others are applied here. + The text comes in pieces with the style each is shown in. Escape + sequences are removed. Backspaces a piece starts with are kept, for the + view to take back what it already showed; any others are applied here. """ text, self._pending = split_unfinished_end(self._pending + self._decoder.decode(data)) - backspaces, text = take_leading_backspaces(text) - return "\x08" * backspaces + strip_terminal_controls(text) + cleared = split_at_screen_clear(text) + if cleared is not None: + before, sequence, text = cleared + # What it wipes is not shown, but the colours it set carry on, + # unless a full reset drops them + _, self._style = split_styled(before, self._style) + if sequence == FULL_RESET: + self._style = PLAIN + pieces, self._style = split_styled(text, self._style) + return TerminalOutput(cleared is not None, [(style, _shown(piece)) for style, piece in pieces]) + + +@dataclass(frozen=True) +class TerminalOutput: + """What one read shows: whether the screen is wiped first (``clear``, ``reset``), then its text.""" + + clears_screen: bool + pieces: list[tuple[TextStyle, str]] + + +def _shown(text: str) -> str: + """*text* as the view shows it, the backspaces it starts with kept.""" + backspaces, text = take_leading_backspaces(text) + return "\x08" * backspaces + strip_terminal_controls(text) # Bound the terminal scrollback so an endless stream (``tail -f``, ``yes``) @@ -70,6 +102,51 @@ def feed(self, data: bytes) -> str: SSH_KEEPALIVE_SECONDS = 30 +# What Ctrl+C sends in a terminal (ETX): the shell's line discipline turns it into SIGINT +INTERRUPT = b"\x03" + +# Lines the command line remembers for Up and Down; the oldest go first +HISTORY_LIMIT = 500 + + +class CommandHistory: + """Lines sent from the command line, for Up and Down to bring back, as a shell does. + + Walking up from a line being typed keeps it: walking down past the newest + line gives it back. A line sent twice in a row is remembered once. + """ + + def __init__(self, limit: int = HISTORY_LIMIT) -> None: + self._lines: list[str] = [] + self._limit = limit + self._index = 0 # len(self._lines): at the line being typed + self._draft = "" + + def add(self, line: str) -> None: + """Remember *line* (not an empty one) and go back to a new line.""" + if line and (not self._lines or self._lines[-1] != line): + self._lines.append(line) + del self._lines[:-self._limit] + self._index = len(self._lines) + self._draft = "" + + def older(self, current: str) -> str | None: + """The line before the one shown, or ``None`` at the oldest. *current* is what is typed.""" + if self._index == 0: + return None + if self._index == len(self._lines): + self._draft = current + self._index -= 1 + return self._lines[self._index] + + def newer(self) -> str | None: + """The line after the one shown, the draft after the newest, or ``None`` at the draft.""" + if self._index >= len(self._lines): + return None + self._index += 1 + return self._draft if self._index == len(self._lines) else self._lines[self._index] + + # Longest a command waits for the server to take it (a full SSH window), on the UI thread SEND_TIMEOUT_SECONDS = 5 @@ -90,15 +167,17 @@ def send_all(channel: paramiko.Channel, data: bytes) -> None: channel.settimeout(0.0) -def open_shell_channel(client: paramiko.SSHClient) -> paramiko.Channel: +def open_shell_channel(client: paramiko.SSHClient, size: tuple[int, int]) -> paramiko.Channel: """Open an interactive shell on *client*'s connection. Waits on the network: not the UI thread. - The channel comes back non-blocking: its reader polls it. + Its pty is *size* (columns, rows). The channel comes back non-blocking: + its reader polls it. """ transport = client.get_transport() if transport is not None: transport.set_keepalive(SSH_KEEPALIVE_SECONDS) - channel = client.invoke_shell(term="xterm", width=120, height=32) + columns, rows = size + channel = client.invoke_shell(term="xterm", width=columns, height=rows) channel.settimeout(0.0) return channel @@ -157,7 +236,7 @@ class SSHCommandWidget(QWidget): # Emitted when the session comes up or goes down, for the tab's status label state_changed = Signal() - def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: bool = True): + def __init__(self, external_login_widget: LoginWidget | None = None, add_login_widget: bool = True): super().__init__() self.word_dict = language_wrapper.language_word_dict self.setWindowTitle( @@ -173,6 +252,12 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: self._decoder = TerminalDecoder() # The connect in progress, if any / 正在進行的連線 self._connecting: SshConnectThread | None = None + # Lines sent, for Up and Down / 送出過的指令 + self._history = CommandHistory() + # The size the shell's pty was last given / pty 目前的大小 + self._pty_size: tuple[int, int] | None = None + # The output so far ended on a lone \r the next piece applies / 待套用的 \r + self._rewind_pending = False host_key_asker() # built here, on the UI thread, for a connect to ask through if self.add_login_widget: @@ -190,6 +275,9 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: self.command_input_edit = QLineEdit() self.command_send_button = QPushButton( self.word_dict.get("ssh_command_widget_button_label_send_command")) + self.interrupt_button = QPushButton( + self.word_dict.get("ssh_command_widget_button_label_interrupt")) + self.interrupt_button.setToolTip(self.word_dict.get("ssh_command_widget_tooltip_interrupt")) self._setup_ui() self._bind_events() @@ -197,6 +285,7 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: def _setup_ui(self): self.terminal.setReadOnly(True) self.terminal.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + use_fixed_pitch_font(self.terminal) self.command_input_edit.setPlaceholderText( self.word_dict.get("ssh_command_widget_input_placeholder_command_line") ) @@ -207,6 +296,7 @@ def _setup_ui(self): command_input_bar = QHBoxLayout() command_input_bar.addWidget(self.command_input_edit) command_input_bar.addWidget(self.command_send_button) + command_input_bar.addWidget(self.interrupt_button) main_widget = QVBoxLayout() main_widget.addWidget(self.login_widget) # 插入登入介面 @@ -223,6 +313,58 @@ def _bind_events(self): # 綁定其他按鈕 self.command_send_button.clicked.connect(self.send_command) self.command_input_edit.returnPressed.connect(self.send_command) + self.interrupt_button.clicked.connect(self.send_interrupt) + self.command_input_edit.installEventFilter(self) + self._terminal_viewport = self.terminal.viewport() + self._terminal_viewport.installEventFilter(self) + + def eventFilter(self, watched, event) -> bool: + """Keys the command line gives to the shell rather than to its own text; the view's size.""" + if watched is self._terminal_viewport and event.type() == QEvent.Type.Resize: + self._follow_view_size() + elif (watched is self.command_input_edit and event.type() == QEvent.Type.KeyPress + and self._command_line_key(event.key(), event.modifiers())): + return True + return super().eventFilter(watched, event) + + def _command_line_key(self, key: int, modifiers) -> bool: + """Act on *key* in the command line; return whether it was taken. + + Ctrl+C interrupts the shell, unless it copies a selection. Up and Down + walk through the lines sent before. + """ + if (key == Qt.Key.Key_C and modifiers == Qt.KeyboardModifier.ControlModifier + and not self.command_input_edit.hasSelectedText()): + self.send_interrupt() + return True + if modifiers & ~Qt.KeyboardModifier.KeypadModifier: + return False + if key == Qt.Key.Key_Up: + line = self._history.older(self.command_input_edit.text()) + elif key == Qt.Key.Key_Down: + line = self._history.newer() + else: + return False + if line is not None: + self.command_input_edit.setText(line) + return True + + def _follow_view_size(self) -> None: + """Give the shell's pty the size the view shows, as a terminal window does on a resize. + + It had 120 columns whatever the view's width, so ``ls`` laid its + columns out for a width the view did not have. + """ + size = terminal_size(self.terminal) + if not self._has_shell() or size == self._pty_size: + return + columns, rows = size + try: + self.shell_channel.resize_pty(width=columns, height=rows) + except (OSError, paramiko.SSHException) as error: + pybreeze_logger.debug("SSH pty resize: %r", error) + return + self._pty_size = size def append_text(self, text: str): """Add a notice of our own, starting on a line of its own.""" @@ -230,8 +372,8 @@ def append_text(self, text: str): end.movePosition(QTextCursor.MoveOperation.End) self._insert_output(text if end.atBlockStart() else "\n" + text) - def _insert_output(self, text: str) -> None: - """Add *text* where the output ends, without starting a new line. + def _insert_output(self, text: str, text_format: QTextCharFormat | None = None) -> None: + """Add *text* where the output ends, without starting a new line, in *text_format*. ``appendPlainText`` starts a new paragraph on every call, so each read from the shell began on a line of its own, wherever the read happened @@ -242,12 +384,17 @@ def _insert_output(self, text: str) -> None: end = QTextCursor(self.terminal.document()) end.movePosition(QTextCursor.MoveOperation.End) backspaces, text = take_leading_backspaces(text) - if backspaces: + if self._rewind_pending: + # A lone \r before a colour change ("50%\r" "\x1b[32m60%") applies here + text = "\r" + text + elif backspaces: # They take back what an earlier read showed, never past the line's start end.movePosition(QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.KeepAnchor, min(backspaces, end.positionInBlock())) end.removeSelectedText() - end.insertText(text) + # A lone \r redraws the line (a progress bar); the decoder holds one + # that ends a read until the next shows whether "\n" follows + self._rewind_pending = insert_rewinding(end, text, text_format or QTextCharFormat()) if following: scroll_bar.setValue(scroll_bar.maximum()) @@ -280,6 +427,7 @@ def connect_ssh(self): # connected would otherwise leak the old SSH client and orphan its # reader thread (which keeps appending to the terminal). self._cleanup() + size = self._pty_size = terminal_size(self.terminal) client = paramiko.SSHClient() self.ssh_client = client apply_host_key_policy(client, self) @@ -293,7 +441,7 @@ def connect() -> None: client.connect( hostname=host, port=port, username=user, password=password, timeout=10, disabled_algorithms=SHA1_ALGORITHMS) - opened["channel"] = open_shell_channel(client) + opened["channel"] = open_shell_channel(client, size) # The connect and the shell's channel are made off the UI thread: an # unreachable host used to hold the IDE for the connect, banner and auth # timeouts together, and a server gone quiet after auth held it while @@ -345,10 +493,12 @@ def _start_shell(self, channel: paramiko.Channel, host: str, port: int, user: st """Show *channel*'s output from now on. UI thread; nothing here waits on the network.""" self.shell_channel = channel self._decoder.reset() + self._rewind_pending = False self.reader_thread = SSHReaderThread(self.shell_channel) self.reader_thread.data_received.connect(self._on_data) self.reader_thread.closed.connect(self._on_closed) self.reader_thread.start() + self._follow_view_size() # resized while it was connecting self.login_widget.status_label.setText( self.word_dict.get("ssh_command_widget_status_label_connected")) # An IPv6 address in brackets, or its port reads as one more group @@ -357,7 +507,14 @@ def _start_shell(self, channel: paramiko.Channel, host: str, port: int, user: st host=shown_host, port=port, user=user) + "\n") def _on_data(self, data: bytes): - self._insert_output(self._decoder.feed(data)) + output = self._decoder.feed(data) + if output.clears_screen: + # `clear` and `reset` wipe the screen; they used to leave it as it was + self.terminal.clear() + self._rewind_pending = False + palette = self.terminal.palette() + for style, text in output.pieces: + self._insert_output(text, style_format(style, palette)) def _on_closed(self, msg: str): """The shell ended on the server's side (``exit``, a dropped link). @@ -377,21 +534,43 @@ def _on_closed(self, msg: str): self.state_changed.emit() def send_command(self): + """Send the typed line and a newline to the shell. + + An empty line is sent too, as Enter alone: a prompt's default + (``[Y/n]``, "Press Enter to continue") is taken that way. Without a + session it only asks to connect when something was typed. + """ cmd = self.command_input_edit.text() - if not cmd: - return - if self.shell_channel and not self.shell_channel.closed: - try: - send_all(self.shell_channel, (cmd + "\n").encode("utf-8")) + if self._has_shell(): + if self._send((cmd + "\n").encode("utf-8")): + self._history.add(cmd) self.command_input_edit.clear() - except (OSError, paramiko.SSHException) as e: - self.append_text(f"{self.word_dict.get('ssh_command_widget_error_message_send_failed')} {e}\n") - else: + elif cmd: QMessageBox.information( self, self.word_dict.get('ssh_command_widget_dialog_title_not_connected'), self.word_dict.get('ssh_command_widget_dialog_message_not_connected_shell')) + def send_interrupt(self) -> None: + """Send Ctrl+C to the shell, which stops what runs in it (``ping``, ``tail -f``). + + What is typed in the command line stays. Without a session it does nothing. + """ + if self._has_shell(): + self._send(INTERRUPT) + + def _has_shell(self) -> bool: + return self.shell_channel is not None and not self.shell_channel.closed + + def _send(self, data: bytes) -> bool: + """Send *data* to the shell; say so in the terminal and return False when it fails.""" + try: + send_all(self.shell_channel, data) + except (OSError, paramiko.SSHException) as e: + self.append_text(f"{self.word_dict.get('ssh_command_widget_error_message_send_failed')} {e}\n") + return False + return True + def disconnect_ssh(self): self.append_text(f"{self.word_dict.get('ssh_command_widget_log_message_disconnect_in_progress')} \n") self._cleanup() diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 6692ca89..3bf8283f 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -12,6 +12,7 @@ from collections.abc import Callable from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QLineEdit, QTreeWidget, QTreeWidgetItem, QMenu, QFileDialog, QMessageBox, QSplitter, QInputDialog, QStyle @@ -92,6 +93,10 @@ def folder_item(item: QTreeWidgetItem | None) -> QTreeWidgetItem | None: return item.parent() +# The keys that rename and delete the entry in focus, by the menu entry they stand for +_ENTRY_KEYS = {"rename": Qt.Key.Key_F2, "delete": Qt.Key.Key_Delete} + + class SSHFileTreeManager(QWidget): """ QWidget: connection form + tree + context menu. @@ -101,7 +106,7 @@ class SSHFileTreeManager(QWidget): # Emitted when the session comes up or goes down, for the tab's status label state_changed = Signal() - def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: bool = True): + def __init__(self, external_login_widget: LoginWidget | None = None, add_login_widget: bool = True): super().__init__() self.word_dict = language_wrapper.language_word_dict self.setWindowTitle( @@ -140,6 +145,11 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: self.tree.itemExpanded.connect(self.on_item_expanded) self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.tree.customContextMenuRequested.connect(self.on_context_menu) + # F2 and Delete act on the entry in focus while the tree has the focus, as in the project tree + for name, act in (("rename", self._rename_current), ("delete", self._delete_current)): + shortcut = QShortcut(QKeySequence(_ENTRY_KEYS[name]), self.tree) + shortcut.setContext(Qt.ShortcutContext.WidgetShortcut) + shortcut.activated.connect(act) # Layouts splitter = QSplitter(Qt.Orientation.Vertical) @@ -228,11 +238,12 @@ def closeEvent(self, event) -> None: if transfer_running: let_run_out(transfer, transfer.done, transfer.failed, transfer.cancelled) transfer.finished.connect(self.client.close) - for listing in list(self._listings): + # Over copies: a thread's slot may drop it from its set + for listing in tuple(self._listings): if listing.isRunning(): let_run_out(listing, listing.listed, listing.failed) self._listings.clear() - for call in list(self._calls): + for call in tuple(self._calls): if call.isRunning(): let_run_out(call, call.done, call.failed) self._calls.clear() @@ -394,10 +405,7 @@ def on_context_menu(self, pos): Show context menu for file operations. 顯示右鍵選單以進行檔案操作。 """ - item = self.tree.itemAt(pos) - if item is not None and not item.text(3): - # The "..." or loading row: no entry of its own, so the folder it is in - item = item.parent() + item = self._entry_of(self.tree.itemAt(pos)) menu = QMenu(self) handlers = {} for name, handler in ( @@ -408,16 +416,39 @@ def on_context_menu(self, pos): ("download", self.action_download), ("upload", self.action_upload), ): - handlers[menu.addAction(self.word_dict.get(f"ssh_file_viewer_context_menu_action_{name}"))] = handler + action = menu.addAction(self.word_dict.get(f"ssh_file_viewer_context_menu_action_{name}")) + if name in _ENTRY_KEYS: + action.setShortcut(QKeySequence(_ENTRY_KEYS[name])) # shown beside it: the tree's own keys do it + action.setShortcutVisibleInContextMenu(True) + handlers[action] = handler if self._transfer is not None and self._transfer.isRunning(): menu.addSeparator() cancel = menu.addAction(self.word_dict.get("ssh_file_viewer_context_menu_action_cancel_transfer")) handlers[cancel] = self.action_cancel_transfer chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) + menu.deleteLater() # a child of this widget: kept for good otherwise, one per right-click handler = handlers.get(chosen) - if handler is None: - return + if handler is not None: + self._act_on(handler, item) + + def _rename_current(self) -> None: + """F2: rename the entry in focus.""" + self._act_on(self.action_rename, self._entry_of(self.tree.currentItem())) + + def _delete_current(self) -> None: + """Delete: delete the entry in focus, once the user says so (No is the default).""" + self._act_on(self.action_delete, self._entry_of(self.tree.currentItem())) + + @staticmethod + def _entry_of(item: QTreeWidgetItem | None) -> QTreeWidgetItem | None: + """The entry *item* stands for: the "..." or loading row has none of its own, so its folder.""" + if item is not None and not item.text(3): + return item.parent() + return item + + def _act_on(self, handler: Callable[[QTreeWidgetItem | None], None], item: QTreeWidgetItem | None) -> None: + """Run a tree action on *item*; a failed SFTP request is reported, not raised.""" try: handler(item) # what an SFTP operation raises, a closed session's RuntimeError included @@ -553,7 +584,8 @@ def action_delete(self, item: QTreeWidgetItem | None): self, self.word_dict.get("ssh_file_viewer_dialog_title_confirm_delete"), as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_confirm_delete')} '{path}'?"), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py index 8c2069be..7c739c1b 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import io import warnings from pathlib import Path @@ -26,11 +27,21 @@ UNSUPPORTED_KEY = "ssh_command_widget_error_message_unsupported_private_key" PASSPHRASE_NEEDED = "ssh_key_error_passphrase_needed" PASSPHRASE_WRONG = "ssh_key_error_passphrase_wrong" +PUTTY_KEY = "ssh_key_error_putty_key" + +# A PuTTY key file (.ppk), which neither paramiko nor cryptography reads +_PUTTY_HEADER = b"PuTTY-User-Key-File-" + +# PKCS#8, which paramiko does not read (openssl genpkey, ssh-keygen -m PKCS8) +_PKCS8_PLAIN = b"-----BEGIN PRIVATE KEY-----" # nosemgrep # gitleaks:allow — a format marker, not a key +_PKCS8_ENCRYPTED = b"-----BEGIN ENCRYPTED PRIVATE KEY-----" def load_private_key(key_path: str, password: str, *, context: str = "SSH") -> paramiko.PKey | None: """Try each supported key type against *key_path*; return the first that parses. + A PKCS#8 file, which none of them reads, goes through :func:`_load_pkcs8`. + ``password`` is treated as the passphrase (empty string → no passphrase). ``context`` is included in debug logs so SFTP vs shell failures are distinguishable. """ @@ -40,6 +51,43 @@ def load_private_key(key_path: str, password: str, *, context: str = "SSH") -> p return key_cls.from_private_key_file(key_path, passphrase) except (paramiko.SSHException, ValueError, OSError) as error: pybreeze_logger.debug("%s key type %s rejected: %s", context, key_cls.__name__, error) + return _load_pkcs8(key_path, passphrase, context) + + +def _key_file_data(key_path: str, *headers: bytes) -> bytes | None: + """The contents of *key_path* if it starts with one of *headers*, else ``None``.""" + try: + data = Path(key_path).read_bytes().lstrip() + except OSError: + return None + return data if data.startswith(headers) else None + + +def _load_pkcs8(key_path: str, passphrase: str | None, context: str) -> paramiko.PKey | None: + """A PKCS#8 key file as a paramiko key, or ``None``. + + cryptography reads it and writes it again in OpenSSH's format, in memory + only, for paramiko to load. A passphrase is used only for an encrypted + file: paramiko ignores one given for a plain key, and so does this. + """ + data = _key_file_data(key_path, _PKCS8_PLAIN, _PKCS8_ENCRYPTED) + if data is None: + return None + password = passphrase.encode("utf-8") if passphrase and data.startswith(_PKCS8_ENCRYPTED) else None + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", CryptographyDeprecationWarning) # a DSA key + key = serialization.load_pem_private_key(data, password) + openssh = key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()) + except (ValueError, TypeError, UnsupportedAlgorithm) as error: + pybreeze_logger.debug("%s PKCS#8 key not read: %s", context, type(error).__name__) + return None + for key_cls in _KEY_CLASSES: + try: + return key_cls.from_private_key(io.StringIO(openssh.decode("ascii"))) + except (paramiko.SSHException, ValueError) as error: + pybreeze_logger.debug("%s PKCS#8 key type %s rejected: %s", context, key_cls.__name__, error) return None @@ -51,18 +99,34 @@ def unloadable_key_reason(key_path: str, password: str) -> str: unsupported, which is all the message used to say. A passphrase given is wrong only when it does not decrypt the file: an encrypted key of a type paramiko cannot load (DSA, a FIDO key) asks for one too, and with the - right one it is still unsupported. + right one it is still unsupported. An encrypted PKCS#8 file says so in + its first line, which paramiko does not read. A PuTTY key is to be + exported as an OpenSSH one. """ + if _key_file_data(key_path, _PUTTY_HEADER) is not None: + return PUTTY_KEY + if _key_file_data(key_path, _PKCS8_ENCRYPTED) is not None or _asks_for_passphrase(key_path): + return _encrypted_key_reason(key_path, password) + return UNSUPPORTED_KEY + + +def _asks_for_passphrase(key_path: str) -> bool: + """Whether some key class asks for a passphrase to load *key_path*: the file is encrypted.""" for key_cls in _KEY_CLASSES: try: key_cls.from_private_key_file(key_path, None) except paramiko.PasswordRequiredException: - if not password: - return PASSPHRASE_NEEDED - return UNSUPPORTED_KEY if _decrypts(key_path, password) else PASSPHRASE_WRONG + return True except (paramiko.SSHException, ValueError, OSError) as error: pybreeze_logger.debug("Key type %s rejected: %s", key_cls.__name__, error) - return UNSUPPORTED_KEY + return False + + +def _encrypted_key_reason(key_path: str, password: str) -> str: + """Why an encrypted key file did not load: no passphrase, a wrong one, or a type paramiko cannot use.""" + if not password: + return PASSPHRASE_NEEDED + return UNSUPPORTED_KEY if _decrypts(key_path, password) else PASSPHRASE_WRONG def _decrypts(key_path: str, password: str) -> bool: diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py index fde352eb..7f59dfe7 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py @@ -26,6 +26,8 @@ def __init__(self, parent=None): self.connect_btn = QPushButton(language_wrapper.language_word_dict.get("ssh_login_widget_button_connect")) self.disconnect_btn = QPushButton(language_wrapper.language_word_dict.get("ssh_login_widget_button_disconnect")) self.status_label = QLabel(language_wrapper.language_word_dict.get("ssh_login_widget_status_disconnected")) + # The password, or with key authentication the key's passphrase (see _name_the_secret) + self.pass_label = QLabel() # 初始化 UI self._setup_ui() @@ -37,9 +39,9 @@ def _setup_ui(self): self.port_spin.setValue(22) self.user_edit.setPlaceholderText( language_wrapper.language_word_dict.get("ssh_login_widget_placeholder_username")) - self.pass_edit.setPlaceholderText( - language_wrapper.language_word_dict.get("ssh_login_widget_placeholder_password")) self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password) + self._name_the_secret(False) + self.use_key_check.toggled.connect(self._name_the_secret) self.key_edit.setPlaceholderText( language_wrapper.language_word_dict.get("ssh_login_widget_placeholder_private_key")) self.browse_key_btn.clicked.connect(self.choose_key_file) @@ -61,7 +63,7 @@ def _setup_ui(self): auth.addWidget(QLabel(language_wrapper.language_word_dict.get("ssh_login_widget_label_key"))) auth.addWidget(self.key_edit) auth.addWidget(self.browse_key_btn) - auth.addWidget(QLabel(language_wrapper.language_word_dict.get("ssh_login_widget_label_password"))) + auth.addWidget(self.pass_label) auth.addWidget(self.pass_edit) conn = QHBoxLayout() @@ -76,6 +78,16 @@ def _setup_ui(self): self.setLayout(root) + def _name_the_secret(self, key_auth: bool) -> None: + """Label the secret field for what it holds: the password, or with *key_auth* the key's passphrase.""" + word = language_wrapper.language_word_dict + if key_auth: + label, placeholder = "ssh_login_widget_label_passphrase", "ssh_login_widget_placeholder_passphrase" + else: + label, placeholder = "ssh_login_widget_label_password", "ssh_login_widget_placeholder_password" + self.pass_label.setText(word.get(label)) + self.pass_edit.setPlaceholderText(word.get(placeholder)) + def choose_key_file(self) -> None: """Pick the private key file in a dialog, starting in ``~/.ssh``; it also ticks key authentication.""" start = Path.home() / ".ssh" diff --git a/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py b/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py index 0a259c21..02ea6a55 100644 --- a/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py +++ b/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py @@ -12,6 +12,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.app_dirs import pybreeze_data_dir from pybreeze.utils.file_process.replace_file import replace_text @@ -26,6 +27,7 @@ from pybreeze.utils.network.public_http import overall_deadline, public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url from pybreeze.pybreeze_ui.exact_text import exact_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font # What the "seen this URL before" file keeps. An API URL can carry a token in @@ -49,6 +51,8 @@ def looks_like_a_fingerprint(line: str) -> bool: SUPPORTED_METHODS = ("GET", "POST", "PUT", "DELETE") # The methods that carry the code in a body / 會把程式碼放進 body 的方法 METHODS_WITH_A_BODY = ("POST", "PUT") +# What a new panel sends with: GET carries no body, so the code went nowhere +DEFAULT_METHOD = "POST" # A saved count longer than this is not a count this panel wrote _MAX_COUNT_DIGITS = 16 @@ -165,6 +169,7 @@ def __init__(self): self.send_button = QPushButton( self.word_dict.get("ai_code_review_gui_button_send_request")) self.send_button.clicked.connect(self.send_request) + press_on_ctrl_enter(self, self.send_button) main_layout.addWidget(self.send_button) main_layout.addLayout(self._build_verdict_buttons()) self.setLayout(main_layout) @@ -187,6 +192,7 @@ def _build_request_row(self) -> QHBoxLayout: method_layout.addWidget(QLabel(self.word_dict.get("ai_code_review_gui_label_method"))) self.method_box = QComboBox() self.method_box.addItems(list(SUPPORTED_METHODS)) + self.method_box.setCurrentText(DEFAULT_METHOD) method_layout.addWidget(self.method_box) top_layout.addLayout(method_layout) return top_layout @@ -200,6 +206,7 @@ def _build_code_and_response(self) -> QHBoxLayout: left_layout.addWidget(QLabel( self.word_dict.get("ai_code_review_gui_label_code_to_send"))) self.code_input = QTextEdit() + use_fixed_pitch_font(self.code_input) self.code_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) left_layout.addWidget(self.code_input) @@ -243,7 +250,8 @@ def send_request(self): return url = self.url_input.text().strip() method = self.method_box.currentText() - code_content = exact_text(self.code_input).strip() + # As pasted: stripped, the first line of a selection lost its indent + code_content = exact_text(self.code_input) if not url: self.response_panel.setPlainText( @@ -253,6 +261,9 @@ def send_request(self): self.response_panel.setPlainText( self.word_dict.get("ai_code_review_gui_message_unsupported_http_method")) return + if method in METHODS_WITH_A_BODY and not code_content.strip(): + self.response_panel.setPlainText(self.word_dict.get("ai_code_review_gui_message_paste_code")) + return # 這個 URL 之前送過嗎 / Has this URL been sent before? if self.record_url(url): diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index bbdf989c..312b8a97 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -27,9 +27,12 @@ from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import parse_mermaid from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel -from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ImageDownloadThread, ToolMode +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import ( + IMAGE_SUFFIXES, DiagramScene, ImageDownloadThread, ToolMode, +) from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.pybreeze_ui.plain_text import as_text from pybreeze.utils.file_process.read_capped import read_text_capped @@ -42,6 +45,17 @@ def _lang(key: str, fallback: str = "") -> str: return language_wrapper.language_word_dict.get(key, fallback or key) +def _diagram_filter() -> str: + """The Open and Save dialogs' filter: diagrams, then any file.""" + return f"{_lang('diagram_editor_filter_diagram')};;{_lang('diagram_editor_filter_all')}" + + +def _image_filter() -> str: + """Add Image's filter: every suffix a saved diagram may keep (``IMAGE_SUFFIXES``), then any file.""" + patterns = " ".join(f"*{suffix}" for suffix in sorted(IMAGE_SUFFIXES)) + return f"{_lang('diagram_editor_filter_images').format(patterns=patterns)};;{_lang('diagram_editor_filter_all')}" + + _STATUS_HINTS: dict[ToolMode, str] = { ToolMode.SELECT: "diagram_editor_status_select", ToolMode.ADD_RECT: "diagram_editor_status_add_node", @@ -116,6 +130,7 @@ def __init__(self, parent=None): self._editor = QPlainTextEdit() self._editor.setPlaceholderText(_MERMAID_PLACEHOLDER) + use_fixed_pitch_font(self._editor) self._editor.setTabStopDistance(32) layout.addWidget(self._editor, 1) @@ -397,6 +412,7 @@ def _new_diagram(self) -> None: _lang("diagram_editor_confirm_title", "Confirm"), _lang("diagram_editor_confirm_new", "Discard current diagram?"), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return @@ -406,11 +422,16 @@ def _new_diagram(self) -> None: self._current_path = None def _open_diagram(self) -> None: + # Opening replaces the canvas and clears the undo history + if not self._may_discard_edits( + "diagram_editor_open_over_edits", + "The diagram has changes that are not saved. Open another and lose them?"): + return path, _ = QFileDialog.getOpenFileName( self, _lang("diagram_editor_dialog_open", "Open Diagram"), "", - "Diagram JSON (*.diagram.json);;All Files (*)", + _diagram_filter(), ) if not path: return @@ -438,7 +459,7 @@ def _save_as_diagram(self) -> None: self, _lang("diagram_editor_dialog_save", "Save Diagram"), "untitled.diagram.json", - "Diagram JSON (*.diagram.json);;All Files (*)", + _diagram_filter(), ) if not path: return @@ -447,10 +468,10 @@ def _save_as_diagram(self) -> None: def _import_mermaid(self) -> None: dialog = MermaidImportDialog(self) - if dialog.exec() != QDialog.DialogCode.Accepted: - return + accepted = dialog.exec() == QDialog.DialogCode.Accepted text = dialog.get_text().strip() - if not text: + dialog.deleteLater() # a child of the editor: kept for good otherwise, one per import + if not accepted or not text: return try: data = parse_mermaid(text) @@ -480,12 +501,15 @@ def may_close(self) -> bool: Asked by the main window before it closes this tab or the IDE: an unsaved diagram was lost on close without a word. """ + return self._may_discard_edits( + "diagram_editor_close_over_edits", "The diagram has changes that are not saved. Close and lose them?") + + def _may_discard_edits(self, question_key: str, fallback: str) -> bool: + """True when the diagram is as last saved or opened, or the user answers Yes to *question_key*.""" if self._scene.undo_stack.isClean(): return True reply = QMessageBox.question( - self, _lang("unsaved_close_title", "Unsaved changes"), - _lang("diagram_editor_close_over_edits", - "The diagram has changes that are not saved. Close and lose them?"), + self, _lang("unsaved_close_title", "Unsaved changes"), _lang(question_key, fallback), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) return reply == QMessageBox.StandardButton.Yes @@ -538,7 +562,7 @@ def _warn_export_failed(self, path: str, reason: str) -> None: def _export_png(self) -> None: path, _ = QFileDialog.getSaveFileName( self, _lang("diagram_editor_dialog_export_png", "Export PNG"), - "diagram.png", "PNG Image (*.png)", + "diagram.png", _lang("diagram_editor_filter_png"), ) if not path: return @@ -574,7 +598,7 @@ def _export_png(self) -> None: def _export_svg(self) -> None: path, _ = QFileDialog.getSaveFileName( self, _lang("diagram_editor_dialog_export_svg", "Export SVG"), - "diagram.svg", "SVG Image (*.svg)", + "diagram.svg", _lang("diagram_editor_filter_svg"), ) if not path: return @@ -612,7 +636,7 @@ def _add_image_from_file(self) -> None: self, _lang("diagram_editor_dialog_image_file", "Open Image"), "", - "Images (*.png *.jpg *.jpeg *.bmp *.gif *.svg *.webp);;All Files (*)", + _image_filter(), ) if not path: return diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 2f6dce3b..dcec99a3 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -3,6 +3,7 @@ import math from dataclasses import dataclass from enum import Enum, auto +from typing import TypeVar from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import ( @@ -155,8 +156,11 @@ def _safe_color(value: str | None, fallback: str) -> QColor: _MAX_LINE_WIDTH = 10.0 -def _number(value: object, fallback: float) -> float: - """Return *value* as a float, or *fallback* when it is not a finite number.""" +_Fallback = TypeVar("_Fallback") + + +def _number(value: object, fallback: _Fallback) -> float | _Fallback: + """Return *value* as a float, or *fallback* (a number, or ``None``) when it is not a finite number.""" try: number = float(value) except (TypeError, ValueError): @@ -164,6 +168,41 @@ def _number(value: object, fallback: float) -> float: return number if math.isfinite(number) else fallback +def resized_geometry(role: str, delta: QPointF, orig_rect: QRectF, orig_pos: QPointF, + min_w: float, min_h: float) -> tuple[float, float, float, float]: + """Where an item dragged by its resize handle goes: ``(x, y, width, height)``. + + :param role: the handle's sides, any of ``"l"``, ``"r"``, ``"t"``, ``"b"``; a left or top + handle moves the item as it resizes it + :param delta: how far the handle has been dragged + :param orig_rect: the item's rect when the drag started + :param orig_pos: the item's position when the drag started + :param min_w: the narrowest the item may become; the far side stays where it was + :param min_h: the lowest the item may become; the far side stays where it was + """ + new_x, new_y = orig_pos.x(), orig_pos.y() + new_w, new_h = orig_rect.width(), orig_rect.height() + if "r" in role: + new_w = orig_rect.width() + delta.x() + if "l" in role: + new_w = orig_rect.width() - delta.x() + new_x = orig_pos.x() + delta.x() + if "b" in role: + new_h = orig_rect.height() + delta.y() + if "t" in role: + new_h = orig_rect.height() - delta.y() + new_y = orig_pos.y() + delta.y() + if new_w < min_w: + if "l" in role: + new_x = orig_pos.x() + orig_rect.width() - min_w + new_w = min_w + if new_h < min_h: + if "t" in role: + new_y = orig_pos.y() + orig_rect.height() - min_h + new_h = min_h + return new_x, new_y, new_w, new_h + + # How far from the origin a saved item may be placed. A file is anyone's to # edit: NaN (which json.loads accepts) made an item invisible and the scene's # bounding rect NaN, so every export failed; 1e308 overflowed the export size. @@ -412,32 +451,8 @@ def set_size(self, w: float, h: float) -> None: conn.update_path() def _apply_resize(self, role: str, delta: QPointF, orig_rect: QRectF, orig_pos: QPointF) -> None: - new_w = orig_rect.width() - new_h = orig_rect.height() - new_x = orig_pos.x() - new_y = orig_pos.y() - - if "r" in role: - new_w = orig_rect.width() + delta.x() - if "l" in role: - new_w = orig_rect.width() - delta.x() - new_x = orig_pos.x() + delta.x() - if "b" in role: - new_h = orig_rect.height() + delta.y() - if "t" in role: - new_h = orig_rect.height() - delta.y() - new_y = orig_pos.y() + delta.y() - - # Clamp minimum - if new_w < 40: - if "l" in role: - new_x = orig_pos.x() + orig_rect.width() - 40 - new_w = 40 - if new_h < 20: - if "t" in role: - new_y = orig_pos.y() + orig_rect.height() - 20 - new_h = 20 - + new_x, new_y, new_w, new_h = resized_geometry( + role, delta, orig_rect, orig_pos, _MIN_NODE_W, _MIN_NODE_H) self.setPos(new_x, new_y) self.prepareGeometryChange() self.node_w = new_w @@ -868,27 +883,9 @@ def set_size(self, w: float, h: float) -> None: self._center_label() self._update_handles() - def _apply_resize(self, role, delta, orig_rect, orig_pos): - new_w, new_h = orig_rect.width(), orig_rect.height() - new_x, new_y = orig_pos.x(), orig_pos.y() - if "r" in role: - new_w = orig_rect.width() + delta.x() - if "l" in role: - new_w = orig_rect.width() - delta.x() - new_x = orig_pos.x() + delta.x() - if "b" in role: - new_h = orig_rect.height() + delta.y() - if "t" in role: - new_h = orig_rect.height() - delta.y() - new_y = orig_pos.y() + delta.y() - if new_w < 40: - if "l" in role: - new_x = orig_pos.x() + orig_rect.width() - 40 - new_w = 40 - if new_h < 40: - if "t" in role: - new_y = orig_pos.y() + orig_rect.height() - 40 - new_h = 40 + def _apply_resize(self, role: str, delta: QPointF, orig_rect: QRectF, orig_pos: QPointF) -> None: + new_x, new_y, new_w, new_h = resized_geometry( + role, delta, orig_rect, orig_pos, _MIN_IMAGE_SIDE, _MIN_IMAGE_SIDE) self.setPos(new_x, new_y) self.set_size(new_w, new_h) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py index 28e9b821..0b9b09b9 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py @@ -78,6 +78,10 @@ class _EdgeInfo: _LABEL_MAX = 200 # bound non-greedy match to prevent polynomial backtracking on pathological input +# An arrow's |label|: quoted (which may hold a "|"), or anything up to the next "|" +_QUOTED_ARROW_LABEL_RE = re.compile(r'\|\s*("[^"]*")\s*\|') +_PLAIN_ARROW_LABEL_RE = re.compile(r"\|([^|]*)\|") + def _normalize_inline_labels(line: str) -> str: """Convert ``-- label -->`` style to ``-->|label|`` pipe style. @@ -250,13 +254,25 @@ def _parse_node_group(raw: str, nodes: dict[str, _NodeInfo]) -> list[str]: return ids +def _arrow_label(token: str) -> str: + """The ``|label|`` of an arrow token, as written; ``""`` when it has none. + + A quoted label is taken whole: '|"a|b"|' stopped at the "|" inside it. The + two patterns share no characters between neighbouring parts, so each reads + its input once: one pattern for both, spaces allowed on either side of an + unquoted label, backtracked in cubic time. + """ + quoted = _QUOTED_ARROW_LABEL_RE.search(token) + plain = _PLAIN_ARROW_LABEL_RE.search(token) + # The leftmost, and the quoted one where both start at the same "|" + if quoted and (plain is None or quoted.start() <= plain.start()): + return quoted.group(1) + return plain.group(1).strip() if plain else "" + + def _parse_arrow(token: str) -> tuple[str, ConnectionStyle, float]: """Return ``(label, style, line_width)`` from an arrow token.""" - label = "" - # A quoted label is taken whole: '|"a|b"|' stopped at the "|" inside it - lm = re.search(r'\|\s*("[^"]*"|[^|]*)\s*\|', token) - if lm: - label = _unquote(lm.group(1).strip()) + label = _unquote(_arrow_label(token)) if "==" in token: return label, ConnectionStyle.SOLID, 3.5 # thick link diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index cc85caab..9eb8c2d2 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -31,13 +31,13 @@ ) from pybreeze.utils.logging.logger import pybreeze_logger -# Allowlist of image extensions that a saved diagram may reference on disk. -# Defined once at module scope because it is a security boundary (only these -# local files are read back when reloading a ``.diagram.json``). # How far a pasted copy sits from the original, so it is visible as a copy _PASTE_OFFSET = 30 -_VALID_IMAGE_SUFFIXES = frozenset( +# Allowlist of image extensions that a saved diagram may reference on disk. +# Defined once at module scope because it is a security boundary (only these +# local files are read back when reloading a ``.diagram.json``). +IMAGE_SUFFIXES = frozenset( {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg", ".webp", ".ico"} ) @@ -281,7 +281,9 @@ def mousePressEvent(self, event) -> None: def _add_shape_node(self, pos: QPointF, shape: NodeShape) -> None: with self.undo_scope("Add Node"): - self.addItem(DiagramNode(x=pos.x() - 70, y=pos.y() - 30, shape=shape)) + self.addItem(DiagramNode( + x=pos.x() - 70, y=pos.y() - 30, shape=shape, + text=language_wrapper.language_word_dict.get("diagram_editor_new_node_text", "Node"))) self.item_count_changed.emit() self.mode = ToolMode.SELECT @@ -289,7 +291,7 @@ def _add_text_node(self, pos: QPointF) -> None: with self.undo_scope("Add Text"): self.addItem(DiagramNode( x=pos.x() - 70, y=pos.y() - 20, - w=140, h=40, text="Text", + w=140, h=40, text=language_wrapper.language_word_dict.get("diagram_editor_new_text_text", "Text"), shape=NodeShape.RECTANGLE, )) self.item_count_changed.emit() @@ -835,7 +837,7 @@ def _try_load_image_source(self, img: DiagramImage, source: str) -> None: # to that host, so a diagram from someone else could collect the user's # credentials, and an unreachable host would block the UI thread until # SMB gives up. - if (path.suffix.lower() in _VALID_IMAGE_SUFFIXES + if (path.suffix.lower() in IMAGE_SUFFIXES and _is_on_this_machine(source) and path.is_file()): pix = QPixmap(str(path)) if not pix.isNull(): diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py index c8ff1ef9..69315fd4 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py @@ -1,8 +1,8 @@ from __future__ import annotations from PySide6.QtCore import QRectF, Qt, Signal -from PySide6.QtGui import QBrush, QColor, QPainter, QPen -from PySide6.QtWidgets import QGraphicsView +from PySide6.QtGui import QBrush, QColor, QContextMenuEvent, QPainter, QPen +from PySide6.QtWidgets import QApplication, QGraphicsView from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene @@ -12,6 +12,7 @@ _GRID_COLOR = QColor("#e0e0e0") _GRID_COLOR_MAJOR = QColor("#bdbdbd") _BG_COLOR = QColor("#ffffff") +_PAN_BUTTONS = (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton) class DiagramView(QGraphicsView): @@ -54,6 +55,10 @@ def __init__(self, scene: DiagramScene, parent=None): self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) self._panning = False self._pan_start = None + self._pan_origin = None + self._pan_button = Qt.MouseButton.NoButton + # A right-drag moved the canvas: the menu its release brings is not wanted + self._right_drag_panned = False self._draw_grid = False self._grid_size = 20 @@ -168,28 +173,56 @@ def zoom_out(self) -> None: # --- middle-button / right-button pan --- def mousePressEvent(self, event) -> None: - if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): + if event.button() in _PAN_BUTTONS: self._panning = True + self._pan_button = event.button() self._pan_start = event.position().toPoint() + self._pan_origin = self._pan_start + self._right_drag_panned = False self.setCursor(Qt.CursorShape.ClosedHandCursor) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event) -> None: + if self._panning and not event.buttons() & self._pan_button: + # The release went elsewhere (a context menu opened on the press + # took it): the canvas kept following a mouse with no button held + self._stop_panning() if self._panning and self._pan_start is not None: - delta = event.position().toPoint() - self._pan_start - self._pan_start = event.position().toPoint() + position = event.position().toPoint() + delta = position - self._pan_start + self._pan_start = position self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x()) self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y()) + if (self._pan_button == Qt.MouseButton.RightButton + and (position - self._pan_origin).manhattanLength() >= QApplication.startDragDistance()): + self._right_drag_panned = True event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event) -> None: - if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton) and self._panning: - self._panning = False - self.setCursor(Qt.CursorShape.ArrowCursor) + if event.button() == self._pan_button and self._panning: + self._stop_panning() event.accept() return super().mouseReleaseEvent(event) + + def _stop_panning(self) -> None: + self._panning = False + self._pan_button = Qt.MouseButton.NoButton + self.setCursor(Qt.CursorShape.ArrowCursor) + + def contextMenuEvent(self, event: QContextMenuEvent) -> None: + """Open the canvas menu, except at the end of a right-drag that panned. + + Windows opens a context menu as the right button is released, so every + pan with the right button ended in a menu. A right click that did not + move, and the keyboard's menu key, still open it. + """ + panned, self._right_drag_panned = self._right_drag_panned, False + if panned and event.reason() == QContextMenuEvent.Reason.Mouse: + event.accept() + return + super().contextMenuEvent(event) diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 963d1210..502d9ed1 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -8,8 +8,8 @@ from collections.abc import Callable from pathlib import Path, PureWindowsPath -from PySide6.QtCore import Qt, QModelIndex -from PySide6.QtGui import QCursor +from PySide6.QtCore import QFile, Qt, QModelIndex +from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( QTreeView, QMenu, QFileSystemModel, QInputDialog, QMessageBox, QApplication, @@ -73,6 +73,34 @@ def _attach_context_menu(tree_view: QTreeView, main_window) -> None: tree_view.customContextMenuRequested.connect( lambda pos, tv=tree_view, mw=main_window: _show_context_menu(pos, tv, mw) ) + _attach_keys(tree_view, main_window) + + +# Keys that act on the entry in focus while the tree has the focus, as in a file manager +_RENAME_KEY = QKeySequence(Qt.Key.Key_F2) +_DELETE_KEY = QKeySequence(Qt.Key.Key_Delete) + + +def _attach_keys(tree_view: QTreeView, main_window) -> None: + """F2 renames and Delete deletes the current entry, through the menu's own actions. + + Only while the tree itself has the focus (``WidgetShortcut``): Delete in the + editor beside it is the editor's. Delete asks first, No being the default. + """ + for keys, act in ((_RENAME_KEY, "rename"), (_DELETE_KEY, "delete")): + shortcut = QShortcut(keys, tree_view) + shortcut.setContext(Qt.ShortcutContext.WidgetShortcut) + shortcut.activated.connect( + lambda tv=tree_view, mw=main_window, what=act: _act_on_current(what, tv, mw)) + + +def _act_on_current(what: str, tree_view: QTreeView, main_window) -> None: + """Rename or delete (*what*) the entry in focus, if there is one.""" + path = _get_path_from_index(tree_view, tree_view.currentIndex()) + if what == "rename": + _action_rename(tree_view, main_window, path) + else: + _action_delete(tree_view, main_window, path) def _get_path_from_index(tree_view: QTreeView, index: QModelIndex) -> Path | None: @@ -108,6 +136,9 @@ def _show_context_menu(pos, tree_view: QTreeView, main_window) -> None: delete_act = menu.addAction(word.get("file_tree_ctx_delete")) rename_act.setEnabled(path is not None) delete_act.setEnabled(path is not None) + for act, keys in ((rename_act, _RENAME_KEY), (delete_act, _DELETE_KEY)): + act.setShortcut(keys) # shown beside the entry: the tree's own keys do it + act.setShortcutVisibleInContextMenu(True) menu.addSeparator() # --- Clipboard --- @@ -121,7 +152,8 @@ def _show_context_menu(pos, tree_view: QTreeView, main_window) -> None: reveal_act = menu.addAction(word.get("file_tree_ctx_reveal_in_explorer")) reveal_act.setEnabled(path is not None) - action = menu.exec(QCursor.pos()) + action = menu.exec(tree_view.viewport().mapToGlobal(pos)) + menu.deleteLater() # a child of the tree: kept for good otherwise, one per right-click if action is None: return @@ -239,7 +271,7 @@ def _unwatch(editor: EditorWidget) -> None: Done before the file moves: once it is gone, Windows does not let go of the old name. """ - watcher = editor._file_watcher # noqa: SLF001 — JEditor's own watcher, handled as open_an_file handles it (test_jeditor_contract.py) + watcher = editor._file_watcher # noqa: SLF001 — JEditor's own watcher; handled as open_an_file handles it (test_jeditor_contract.py) watched = watcher.files() if watched: watcher.removePaths(watched) @@ -355,6 +387,7 @@ def _action_delete(tree_view: QTreeView, main_window, path: Path | None) -> None word.get("file_tree_ctx_confirm_delete"), as_text(word.get("file_tree_ctx_confirm_delete_message").format(name=str(path))), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return @@ -366,15 +399,7 @@ def _action_delete(tree_view: QTreeView, main_window, path: Path | None) -> None for editor, _file in open_tabs: _stop_auto_save(editor) - def _delete() -> None: - if _is_link(path): - _remove_link(path) - elif path.is_dir(): - remove_folder(path) - else: - path.unlink() - - _perform_file_op(tree_view, _delete) + _remove(tree_view, path) # Only a tab whose file is gone closes. The delete can fail -- a locked or # read-only file -- or remove only part of a folder, and closing the tabs # beforehand lost a file's tab and its unsaved edits while the file stayed. @@ -388,6 +413,34 @@ def _delete() -> None: main_window.tab_widget.removeTab(index) +def _move_to_trash(path: Path) -> bool: + """Move *path* to the system's trash (the Recycle Bin on Windows); ``False`` where there is none.""" + return QFile.moveToTrash(str(path)) + + +def _remove(tree_view: QTreeView, path: Path) -> None: + """Move *path* to the trash; where there is none, delete it for good if the user says so. + + A link (a symbolic link, a junction) is removed itself, never moved: what + it points to stays where it is. + """ + if _is_link(path): + _perform_file_op(tree_view, lambda: _remove_link(path)) + return + if _move_to_trash(path): + return + word = language_wrapper.language_word_dict + reply = QMessageBox.question( + tree_view, + word.get("file_tree_ctx_confirm_delete"), + as_text(word.get("file_tree_ctx_no_trash").format(name=str(path))), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + _perform_file_op(tree_view, lambda: remove_folder(path) if path.is_dir() else path.unlink()) + + def _action_copy_path(tree_view: QTreeView, path: Path | None, relative: bool = False) -> None: if path is None: return diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index d8e5ab63..50e4d6b9 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -5,16 +5,25 @@ from os import environ from pathlib import Path -environ["LOCUST_SKIP_MONKEY_PATCH"] = "1" +from pybreeze.utils.subprocess_util import IDE_ONLY + +# locust patches the whole process with gevent as it imports unless this is set, +# and the IDE imports it: the Load Density GUI, or a user in JEditor's +# in-process console. IDE_ONLY keeps it out of the processes the IDE starts (a +# load test needs the patching to run its users at once); a value the user set +# is kept, for both. +environ["LOCUST_SKIP_MONKEY_PATCH"] = environ.get("LOCUST_SKIP_MONKEY_PATCH") or IDE_ONLY from PySide6.QtCore import QTimer, QCoreApplication from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication, QWidget from je_editor import EditorMain, EditorWidget, language_wrapper from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict from qt_material import apply_stylesheet from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.code_result_logs import show_only_warnings_in_code_result from pybreeze.pybreeze_ui.closing import AskingDock, may_close from pybreeze.pybreeze_ui.editor_main.file_tree_context_menu import setup_file_tree_context_menu from pybreeze.pybreeze_ui.gui_thread_gc import collect_garbage_on_gui_thread @@ -28,6 +37,10 @@ EDITOR_EXTEND_TAB: dict[str, type[QWidget]] = { } +# Shipped beside this module (package data): it was read from the working +# folder, which a started IDE never has, and the window had no icon +_ICON_PATH = Path(__file__).with_name("pybreeze_icon.ico") + def _close_guarded(widget: QWidget, *steps) -> None: """Run *widget*'s closing *steps*, logging a failure instead of raising it. @@ -54,6 +67,7 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, # missing from it, and a menu given a None title crashes Qt. update_language_dict() super().__init__(debug_mode, show_system_tray_ray, extend=True) + show_only_warnings_in_code_result() # Note: EditorMain.__init__ already calls load_external_plugins() # which auto-discovers jeditor_plugins/ in the current working directory. # Third-party plugins placed there will be loaded automatically. @@ -79,7 +93,7 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, # Icon if not extend: - self.icon_path = Path(os.getcwd()) / "pybreeze_icon.ico" + self.icon_path = _ICON_PATH self.icon = QIcon(str(self.icon_path)) if not self.icon.isNull(): self.setWindowIcon(self.icon) @@ -87,6 +101,8 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, # Menu add_menu_to_menubar(self) syntax_extend_package(self) + # JEditor's Stop All Program stops what its own menus started; PyBreeze's runs join it + self.run_menu.stop_all_program_action.triggered.connect(self.stop_all_runs) # Tab self._add_extend_tabs() @@ -145,6 +161,17 @@ def _tool_tabs_may_close(self) -> bool: dock.already_asked = True return agreed + def stop_all_runs(self) -> None: + """Stop the run in every run window: an automation script, a package install, a Run with... run. + + Connected to Run > Stop All Program, which stopped only the programs + JEditor's own menus started. The windows stay open with their output; + one whose stop fails is logged and the others are still stopped. + """ + # Over a copy: a window that ends its run may drop itself from the list + for run_window in tuple(self.current_run_code_window): + _close_guarded(run_window, run_window.stop_runner) + def closeEvent(self, event) -> None: # Asked before anything is stopped: a No keeps the IDE open as it was if not self._tool_tabs_may_close(): @@ -186,11 +213,13 @@ def debug_close(self) -> None: app.quit() -def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwargs) -> None: +def start_editor(debug_mode: bool = False, theme: str | None = None, **kwargs) -> None: """ Start editor instance :param debug_mode: enable debug mode with auto-close timer - :param theme: qt_material theme name (e.g. "dark_amber.xml", "dark_teal.xml", "light_blue.xml") + :param theme: qt_material theme name (e.g. "dark_teal.xml", "light_blue.xml"). It replaces + the theme picked from UI Style, and is kept as the picked one. ``None`` starts with the + picked theme, ``dark_amber.xml`` until one is picked :return: None """ new_ide = QCoreApplication.instance() @@ -199,14 +228,47 @@ def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwar # Workers allocate enough to trigger a collection, which then destroyed # Qt objects on the worker and crashed the IDE later collect_garbage_on_gui_thread(new_ide) + # Held until the application ends: the window is nobody else's + window = open_main_window(new_ide, debug_mode=debug_mode, theme=theme, **kwargs) + ret = new_ide.exec() + del window + os._exit(ret) + + +def open_main_window(app: QApplication, debug_mode: bool = False, theme: str | None = None, + **kwargs) -> PyBreezeMainWindow: + """Build the main window, apply a theme given here, and show it. + + JEditor's constructor applies the saved settings, the theme picked from UI + Style among them (``startup_setting()``). They are applied again only for a + theme given here, which becomes the picked one: applying a theme takes most + of a second, and the start applied the same one three times. + + :param app: the running application, which the theme is applied to + :param debug_mode: close by itself after a while, as the startup tests need + :param theme: qt_material theme name, which replaces the one picked from UI Style and is + kept as the picked one; ``None`` keeps the picked one + :return: the window, which the caller keeps for as long as the IDE runs + """ window = PyBreezeMainWindow(debug_mode=debug_mode, **kwargs) - apply_stylesheet(new_ide, theme=theme) + if theme is not None: + _apply_given_theme(app, window, theme) + else: + # startup_setting() sets the window's font style sheet before the + # application's theme; set again after it, as a second run of it did, + # the toolbar keeps the height it has with a theme given (4 px less) + window.setStyleSheet(window.styleSheet()) window.showMaximized() + return window + + +def _apply_given_theme(app: QApplication, window: PyBreezeMainWindow, theme: str) -> None: + """Make *theme* the picked one and apply the settings with it (``startup_setting()``).""" + user_setting_dict["ui_style"] = theme try: window.startup_setting() # The user's saved settings, and the files they reopen, can be anything: - # a bad one is logged and the IDE starts without it. + # a bad one is logged, and the theme is still applied. except (OSError, ValueError, TypeError, KeyError, RuntimeError) as error: pybreeze_logger.error("Startup setting error: %r", error) - ret = new_ide.exec() - os._exit(ret) + apply_stylesheet(app, theme=theme) diff --git a/exe/pybreeze_icon.ico b/pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico similarity index 100% rename from exe/pybreeze_icon.ico rename to pybreeze/pybreeze_ui/editor_main/pybreeze_icon.ico diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py index 5f7cbfc5..fd50a867 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py @@ -4,10 +4,12 @@ QMessageBox from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.pybreeze_ui.exact_text import exact_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font class CoTCodeReviewGUI(QWidget): @@ -29,20 +31,29 @@ def __init__(self): url_layout.addWidget(self.url_input) layout.addLayout(url_layout) - # 傳送資料區域 + # 要審查的程式碼 / The code to review; each step's prompt quotes it self.code_paste_area = QTextEdit() + use_fixed_pitch_font(self.code_paste_area) self.code_paste_area.setPlaceholderText( language_wrapper.language_word_dict.get("cot_gui_placeholder_code_paste_area")) layout.addWidget(QLabel(language_wrapper.language_word_dict.get("cot_gui_label_prompt_area"))) layout.addWidget(self.code_paste_area) # 回傳區域 - self.response_selector = QComboBox() # 改用 ComboBox + # The step whose answer is shown, labelled and at the top: alone and + # empty it sat halfway down the panel with nothing to say what it was + self.response_selector = QComboBox() + self.response_selector.setPlaceholderText( + language_wrapper.language_word_dict.get("cot_gui_placeholder_no_answers")) self.response_view = QTextEdit() self.response_view.setReadOnly(True) # 可複製但不可編輯 + step_layout = QVBoxLayout() + step_layout.addWidget(QLabel(language_wrapper.language_word_dict.get("cot_gui_label_step"))) + step_layout.addWidget(self.response_selector) + step_layout.addStretch() hbox_layout = QHBoxLayout() - hbox_layout.addWidget(self.response_selector, 2) + hbox_layout.addLayout(step_layout, 2) hbox_layout.addWidget(self.response_view, 5) layout.addWidget(QLabel(language_wrapper.language_word_dict.get("cot_gui_label_response_area"))) @@ -57,10 +68,11 @@ def __init__(self): # 綁定事件 self.response_selector.currentTextChanged.connect(self.show_response) self.send_button.clicked.connect(self.start_sending) + press_on_ctrl_enter(self, self.send_button) # 儲存回覆 self.responses = {} - self.thread = None + self.request_thread = None def show_response(self, filename): if filename in self.responses: @@ -73,12 +85,18 @@ def start_sending(self): word = language_wrapper.language_word_dict QMessageBox.warning(self, word.get("cot_gui_warning_title"), word.get("cot_gui_error_no_url")) return + code = exact_text(self.code_paste_area) + if not code.strip(): + # Every step of the chain would have gone out about no code at all + word = language_wrapper.language_word_dict + QMessageBox.warning(self, word.get("cot_gui_warning_title"), word.get("cot_gui_error_no_code")) + return # The URL is checked by the worker, which reports a refusal as the # "error" answer: checked here too, its DNS lookup froze the IDE. # Ignore re-submits while a run is in flight so we never drop a running # QThread or interleave two review passes into the same response store. - if self.thread is not None and self.thread.isRunning(): + if self.request_thread is not None and self.request_thread.isRunning(): return # A new run starts from nothing: answers about the previous code, or its @@ -89,10 +107,10 @@ def start_sending(self): # 啟動傳送 Thread self.send_button.setEnabled(False) - self.thread = SenderThread(files=self.files, code=exact_text(self.code_paste_area), url=url) - self.thread.update_response.connect(self.handle_response) - self.thread.finished.connect(self._enable_send) - self.thread.start() + self.request_thread = SenderThread(files=self.files, code=code, url=url) + self.request_thread.update_response.connect(self.handle_response) + self.request_thread.finished.connect(self._enable_send) + self.request_thread.start() def _enable_send(self) -> None: """Let the next request be sent, however this one ended. @@ -118,7 +136,7 @@ def closeEvent(self, event): that long. The thread is cut off from this widget and kept until it ends: a QThread destroyed while running aborts the process. """ - thread = self.thread + thread = self.request_thread if thread is not None and thread.isRunning(): thread.requestInterruption() let_run_out(thread, thread.update_response) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py index 8b59ebe1..3a308e61 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py @@ -10,6 +10,7 @@ from PySide6.QtCore import QThread, Signal from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( SKILLS_TEMPLATE_FILES, SKILLS_TEMPLATE_RELATION ) @@ -135,6 +136,7 @@ def __init__(self): # 傳送按鈕 self.send_button = QPushButton(language_wrapper.language_word_dict.get("skills_send_button")) self.send_button.clicked.connect(self.send_prompt) + press_on_ctrl_enter(self, self.send_button) layout.addWidget(self.send_button) # 回傳結果顯示區域 @@ -146,7 +148,7 @@ def __init__(self): self.setLayout(layout) - self.thread = None # 保存執行緒 + self.request_thread = None # 保存執行緒 # 編輯區裡是哪個模板:換模板被拒時選單要回到這裡 # The template in the edit area: where the selector goes back to when a switch is refused self._shown_template = self.prompt_select.currentText() @@ -193,10 +195,10 @@ def _may_replace_edits(self, name: str) -> bool: return reply == QMessageBox.StandardButton.Yes def send_prompt(self): - # Ignore re-submits while a request is in flight: reassigning self.thread + # Ignore re-submits while a request is in flight: reassigning self.request_thread # here would drop a still-running QThread (risking "destroyed while # running") and let a stale worker overwrite the panel. - if self.thread is not None and self.thread.isRunning(): + if self.request_thread is not None and self.request_thread.isRunning(): return api_url = self.api_url_input.text().strip() @@ -216,13 +218,13 @@ def send_prompt(self): # 啟動 QThread self.send_button.setEnabled(False) - self.thread = RequestThread(api_url, prompt_text) - self.thread.answered.connect(self.on_finished) - self.thread.error.connect(self.on_error) + self.request_thread = RequestThread(api_url, prompt_text) + self.request_thread.answered.connect(self.on_finished) + self.request_thread.error.connect(self.on_error) # However run() ends -- including an exception outside its handler -- # the button comes back. - self.thread.finished.connect(self._enable_send) - self.thread.start() + self.request_thread.finished.connect(self._enable_send) + self.request_thread.start() def _enable_send(self) -> None: """Let the next request be sent, however this one ended. @@ -249,7 +251,7 @@ def closeEvent(self, event): panel does not wait for it: the request can take the whole read timeout, and waiting froze the IDE for that long. """ - thread = self.thread + thread = self.request_thread if thread is not None and thread.isRunning(): let_run_out(thread, thread.answered, thread.error) event.accept() diff --git a/pybreeze/pybreeze_ui/fixed_pitch.py b/pybreeze/pybreeze_ui/fixed_pitch.py new file mode 100644 index 00000000..f57bf479 --- /dev/null +++ b/pybreeze/pybreeze_ui/fixed_pitch.py @@ -0,0 +1,40 @@ +"""A text view in a fixed-pitch font, whatever the theme names.""" +from __future__ import annotations + +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import QWidget + +# Tried before the system's own fixed-pitch font, which on Windows is Courier +# New: thin on a dark theme, and at the tools' size it lost the underscores. +# Consolas is what VS Code shows code in on Windows. +PREFERRED_FAMILIES = ("Consolas",) + + +def fixed_pitch_font() -> QFont: + """The first installed of ``PREFERRED_FAMILIES``, else the system's fixed-pitch font.""" + for family in PREFERRED_FAMILIES: + if QFontDatabase.hasFamily(family): + font = QFont(family) + font.setFixedPitch(True) + return font + return QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + + +def use_fixed_pitch_font(view: QWidget) -> None: + """Show *view* in :func:`fixed_pitch_font`, at the size it had. + + Output laid out in columns (``ls -l``, ``df``, a table a script prints) + lines up only when every character is as wide as the next; in the + interface's proportional font it came out ragged. + + The family is also set in the view's own style sheet, which this replaces: + a theme's sheet (qt_material names a font for every widget) overrides + ``setFont``, and the view's sheet overrides the application's. The theme's + size stays. + """ + font = fixed_pitch_font() + size = view.font().pointSizeF() + if size > 0: # -1 when a style sheet gave the size in pixels + font.setPointSizeF(size) + view.setFont(font) + view.setStyleSheet(f'font-family: "{font.family()}";') diff --git a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py index b2850e7d..fd3b9007 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -1,19 +1,18 @@ from __future__ import annotations -import os import socket import subprocess -import sys import tempfile import threading import time import traceback from PySide6.QtCore import QThread, Signal -from je_editor import language_wrapper +from je_editor import JEditorExecException, language_wrapper +from pybreeze.extend.process_executor.python_task_process_manager import default_interpreter from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.subprocess_util import no_window_creationflags +from pybreeze.utils.subprocess_util import child_environment, no_window_creationflags JUPYTER_STARTUP_TIMEOUT = 60 # How much of a failure's reason the tab shows: pip's stderr can run long @@ -32,43 +31,20 @@ def find_free_port() -> int: return s.getsockname()[1] -def get_venv_python() -> str: - # If already in a venv - if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix): - return sys.executable - - # Try common venv locations - if sys.platform in ["win32", "cygwin", "msys"]: - possible_paths = [ - os.path.join(os.getcwd(), "venv", "Scripts", "python.exe"), - os.path.join(os.getcwd(), ".venv", "Scripts", "python.exe"), - ] - else: - possible_paths = [ - os.path.join(os.getcwd(), "venv", "bin", "python"), - os.path.join(os.getcwd(), ".venv", "bin", "python"), - ] - - for path in possible_paths: - if os.path.exists(path): - return path - - raise RuntimeError("Cannot find venv python executable") - - def choose_python(chosen: str | None) -> str: - """The interpreter the lab runs in: the one chosen in the IDE, else a venv's, else the IDE's own. + """The interpreter the lab runs in: the one chosen in the IDE, else the one a run uses. It took the IDE's own or a ``venv``/``.venv`` in the working directory and never the one chosen in the IDE, so kernels ran in the wrong environment, - and an IDE installed outside a venv could not start the lab at all. + and an IDE installed outside a venv could not start the lab at all. Then an + IDE started from a venv of its own ran the lab there, while a run of the + project used the project's ``.venv``: now both go through + ``default_interpreter`` (a ``venv``/``.venv`` in the working directory, + else the IDE's own; a packaged build looks on ``PATH``). + + :raises JEditorExecException: when a packaged build finds no Python """ - if chosen: - return chosen - try: - return get_venv_python() - except RuntimeError: - return sys.executable + return chosen or default_interpreter() def is_jupyter_installed(python_exe: str) -> bool: @@ -115,8 +91,8 @@ def run(self): if not is_jupyter_installed(python_exe): self.status_update.emit(language_wrapper.language_word_dict.get("jupyterlab_downloading")) - # Install jupyterlab into the local venv. python_exe comes from - # get_venv_python(); shell=False. nosec B603. + # Install jupyterlab into the interpreter the lab runs in + # (choose_python); shell=False. nosec B603. result = subprocess.run([ # nosec B603 # nosemgrep # noqa: S603 python_exe, "-m", @@ -143,8 +119,9 @@ def run(self): self._wait_until_ready(port) self.server_ready.emit(f"http://localhost:{port}/lab") - # OSError includes the TimeoutError of a server that never came up - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + # OSError includes the TimeoutError of a server that never came up; + # JEditorExecException, a packaged build that found no Python + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, JEditorExecException) as error: if self._stopped.is_set(): # The tab closed: stop() ended the server, and the wait saw it # exit. Not a failure, and it used to be logged as one. @@ -182,7 +159,7 @@ def _start_server(self, python_exe: str, port: int) -> subprocess.Popen: "--ServerApp.password=", "--ServerApp.disable_check_xsrf=True", ], stdout=self._output, stderr=subprocess.STDOUT, text=True, - creationflags=no_window_creationflags()) + env=child_environment(), creationflags=no_window_creationflags()) @staticmethod def _port_open(port: int) -> bool: diff --git a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py index 2654714f..7b3c55e8 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py @@ -33,11 +33,11 @@ def __init__(self, python_exe: str | None = None): self.browser.hide() layout.addWidget(self.browser) - self.thread = JupyterLauncherThread(python_exe=python_exe) - self.thread.status_update.connect(self.update_status) - self.thread.server_ready.connect(self.load_lab) - self.thread.error_occurred.connect(self.show_error) - self.thread.start() + self.launcher = JupyterLauncherThread(python_exe=python_exe) + self.launcher.status_update.connect(self.update_status) + self.launcher.server_ready.connect(self.load_lab) + self.launcher.error_occurred.connect(self.show_error) + self.launcher.start() def update_status(self, text): # status_label is removed once the lab loads; a late status/error signal @@ -77,17 +77,17 @@ def closeEvent(self, event): JupyterLab process behind for every tab that had finished loading -- holding its port, and reachable for as long as the machine was up. """ - if self.thread.isRunning(): + if self.launcher.isRunning(): # Still installing or starting: cut off from this tab first, so a # late status or error cannot reach it, and kept until it ends # rather than waited for -- an install can take minutes, and a # QThread destroyed while running aborts the process. (Not # blockSignals: that would also block the ``finished`` that lets # the keeper release it.) - let_run_out(self.thread, self.thread.status_update, - self.thread.server_ready, self.thread.error_occurred) + let_run_out(self.launcher, self.launcher.status_update, + self.launcher.server_ready, self.launcher.error_occurred) # After this the launcher starts no server, even one still installing. - self.thread.stop() + self.launcher.stop() event.accept() diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py index 7bdaea0f..29849764 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py @@ -2,13 +2,13 @@ from typing import TYPE_CHECKING -from je_api_testka.gui.main_widget import APITestkaWidget - from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( AutomationMenu, HelpLink, RunAction, build_automation_menu, safe_create_project ) if TYPE_CHECKING: + from PySide6.QtWidgets import QWidget + from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow from pybreeze.extend.process_executor.api_testka.api_testka_process import ( @@ -17,6 +17,12 @@ ) +def _api_testka_gui() -> QWidget: + # Imported when its tab opens, not with the menus as the IDE starts + from je_api_testka.gui.main_widget import APITestkaWidget + return APITestkaWidget() + + def set_apitestka_menu(ui_we_want_to_set: PyBreezeMainWindow): build_automation_menu(ui_we_want_to_set, AutomationMenu( label_key="apitestka_menu_label", @@ -37,6 +43,6 @@ def set_apitestka_menu(ui_we_want_to_set: PyBreezeMainWindow): ), create_project=safe_create_project(ui_we_want_to_set, "je_api_testka"), create_project_label_key="apitestka_create_project_label", - gui_widget_class=APITestkaWidget, + gui_widget_factory=_api_testka_gui, gui_label="APITestka GUI", )) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py index ca5e91dc..7d83ab05 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py @@ -1,12 +1,11 @@ from __future__ import annotations import json +from types import ModuleType from typing import TYPE_CHECKING -import je_auto_control from PySide6.QtGui import QAction, QGuiApplication, QTextCharFormat -from PySide6.QtWidgets import QMessageBox -from je_auto_control.gui.main_widget import AutoControlGUIWidget +from PySide6.QtWidgets import QMessageBox, QWidget from je_editor import EditorWidget, language_wrapper from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict @@ -23,6 +22,27 @@ ) +def _auto_control() -> ModuleType: + """The ``je_auto_control`` package, imported the first time it is used. + + It makes the process system DPI aware as it imports. Imported with the + menus, before the application existed, it kept Qt from making the IDE + per-monitor aware, and Windows stretched the window as a bitmap on a screen + scaled differently from the main one. + """ + import je_auto_control + return je_auto_control + + +def _autocontrol_gui() -> QWidget: + from je_auto_control.gui.main_widget import AutoControlGUIWidget + return AutoControlGUIWidget() + + +def _start_recording() -> None: + _auto_control().record() + + def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): menu = build_automation_menu(ui_we_want_to_set, AutomationMenu( label_key="autocontrol_menu_label", @@ -43,7 +63,7 @@ def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): ), create_project=safe_create_project(ui_we_want_to_set, "je_auto_control"), create_project_label_key="autocontrol_create_project_label", - gui_widget_class=AutoControlGUIWidget, + gui_widget_factory=_autocontrol_gui, gui_label="AutoControl GUI", )) @@ -52,7 +72,7 @@ def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): record_menu = menu.addMenu(lang.get("autocontrol_record_menu_label")) record_action = QAction(lang.get("autocontrol_record_start_label"), record_menu) - record_action.triggered.connect(je_auto_control.record) + record_action.triggered.connect(_start_recording) record_menu.addAction(record_action) stop_record_action = QAction(lang.get("autocontrol_record_stop_label"), record_menu) @@ -70,7 +90,7 @@ def stop_record(editor_instance: PyBreezeMainWindow) -> None: or on the clipboard when there is none. When nothing was recorded -- or recording was never started -- the user is told, instead of getting "None". """ - actions = je_auto_control.stop_record() + actions = _auto_control().stop_record() lang = language_wrapper.language_word_dict title = lang.get("autocontrol_record_menu_label") if not actions: diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py index 8bbc38d5..31316506 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py @@ -38,15 +38,16 @@ class AutomationMenu: """Everything one automation package's menu holds. A submenu is built only when it has entries; the project entry needs both - ``create_project`` and its label key, and the GUI entry both the widget - class and its label. + ``create_project`` and its label key, and the GUI entry both + ``gui_widget_factory`` (called each time the entry is chosen, so a package + can import its GUI only then) and its label. """ label_key: str run_actions: tuple[RunAction, ...] = () help_links: tuple[HelpLink, ...] = () create_project: Callable[[], None] | None = None create_project_label_key: str | None = None - gui_widget_class: type[QWidget] | None = None + gui_widget_factory: Callable[[], QWidget] | None = None gui_label: str | None = None @@ -67,11 +68,11 @@ def build_automation_menu(ui: PyBreezeMainWindow, spec: AutomationMenu) -> QMenu if spec.run_actions: _add_run_menu(menu, spec.run_actions) if spec.help_links: - _add_help_menu(ui, menu, spec.help_links) + add_help_menu(ui, menu, spec.help_links) if spec.create_project and spec.create_project_label_key: _add_project_menu(menu, spec.create_project, spec.create_project_label_key) - if spec.gui_widget_class and spec.gui_label: - _add_gui_action(ui, menu, spec.gui_widget_class, spec.gui_label) + if spec.gui_widget_factory and spec.gui_label: + _add_gui_action(ui, menu, spec.gui_widget_factory, spec.gui_label) return menu @@ -84,7 +85,8 @@ def _add_run_menu(menu: QMenu, run_actions: tuple[RunAction, ...]) -> None: run_menu.addAction(action) -def _add_help_menu(ui: PyBreezeMainWindow, menu: QMenu, links: tuple[HelpLink, ...]) -> None: +def add_help_menu(ui: PyBreezeMainWindow, menu: QMenu, links: tuple[HelpLink, ...]) -> None: + """Add a HELP submenu to *menu*: one entry per link, each opening its page in a browser tab.""" lang = language_wrapper.language_word_dict help_menu = menu.addMenu(lang.get("help_label")) for link in links: @@ -105,10 +107,10 @@ def _add_project_menu(menu: QMenu, create_project: Callable[[], None], label_key def _add_gui_action( - ui: PyBreezeMainWindow, menu: QMenu, widget_class: type[QWidget], label: str) -> None: + ui: PyBreezeMainWindow, menu: QMenu, widget_factory: Callable[[], QWidget], label: str) -> None: action = QAction(label, menu) action.triggered.connect( - lambda checked=False: ui.tab_widget.addTab(widget_class(), label) + lambda checked=False: ui.tab_widget.addTab(widget_factory(), label) ) menu.addAction(action) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py index 5bcf05c9..92abc4dd 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py @@ -2,13 +2,13 @@ from typing import TYPE_CHECKING -from je_load_density.gui.main_widget import LoadDensityWidget - from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( AutomationMenu, HelpLink, RunAction, build_automation_menu, safe_create_project ) if TYPE_CHECKING: + from PySide6.QtWidgets import QWidget + from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow from pybreeze.extend.process_executor.load_density.load_density_process import ( @@ -17,6 +17,13 @@ ) +def _load_density_gui() -> QWidget: + # Imported when its tab opens: the package brings locust and gevent, about + # half a second of the IDE's start + from je_load_density.gui.main_widget import LoadDensityWidget + return LoadDensityWidget() + + def set_load_density_menu(ui_we_want_to_set: PyBreezeMainWindow): build_automation_menu(ui_we_want_to_set, AutomationMenu( label_key="load_density_menu_label", @@ -38,6 +45,6 @@ def set_load_density_menu(ui_we_want_to_set: PyBreezeMainWindow): ), create_project=safe_create_project(ui_we_want_to_set, "je_load_density"), create_project_label_key="load_density_create_project_label", - gui_widget_class=LoadDensityWidget, + gui_widget_factory=_load_density_gui, gui_label="LoadDensity GUI", )) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py index 3d6956b5..109b2a43 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py @@ -11,6 +11,7 @@ from pybreeze.extend.process_executor.test_pioneer.test_pioneer_process_manager import \ init_and_start_test_pioneer_process +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import HelpLink, add_help_menu from pybreeze.pybreeze_ui.syntax.syntax_keyword import TEST_PIONEER_SUFFIXES from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.plain_text import as_text @@ -22,6 +23,11 @@ _YAML_FILTER = "YAML ({})".format(" ".join(f"*{suffix}" for suffix in TEST_PIONEER_SUFFIXES)) # Where TestPioneer puts its template, under the project directory _TEMPLATE_DIR = ".TestPioneer" +# Its README is its manual: the readthedocs site it links to is not built +_HELP_LINKS = ( + HelpLink("https://github.com/Integration-Automation/TestPioneer", + "test_pioneer_github_label", "test_pioneer_github_tab_label"), +) def set_test_pioneer_menu(ui_we_want_to_set: PyBreezeMainWindow): @@ -50,6 +56,7 @@ def set_test_pioneer_menu(ui_we_want_to_set: PyBreezeMainWindow): ui_we_want_to_set.test_pioneer_menu.addAction( ui_we_want_to_set.run_yaml_action ) + add_help_menu(ui_we_want_to_set, ui_we_want_to_set.test_pioneer_menu, _HELP_LINKS) def create_template(ui_we_want_to_set: PyBreezeMainWindow) -> None: @@ -75,7 +82,7 @@ def create_template(ui_we_want_to_set: PyBreezeMainWindow) -> None: return try: create_template_dir(project_path=str(project), parent_name=_TEMPLATE_DIR) - except Exception as error: # noqa: BLE001 — TestPioneer raises its unexported ProjectException, a bare Exception subclass, for a write it could not make; it is logged and reported + except Exception as error: # noqa: BLE001 — TestPioneer raises its unexported ProjectException (a bare Exception subclass) for a write it could not make; it is logged and reported pybreeze_logger.error("TestPioneer template not created in %s: %r", project, error) QMessageBox.warning( ui_we_want_to_set, title, diff --git a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py index 05ea8358..43cba055 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py @@ -15,83 +15,33 @@ if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow - -def build_automation_install_menu(ui_we_want_to_set: PyBreezeMainWindow): - ui_we_want_to_set.install_automation_menu = ui_we_want_to_set.install_menu.addMenu( - language_wrapper.language_word_dict.get("automation_menu_label")) - # Try to install AutoControl - ui_we_want_to_set.install_autocontrol_action = QAction( - language_wrapper.language_word_dict.get("install_menu_autocontrol")) - ui_we_want_to_set.install_autocontrol_action.triggered.connect( - lambda: install_autocontrol(ui_we_want_to_set) - ) - ui_we_want_to_set.install_automation_menu.addAction(ui_we_want_to_set.install_autocontrol_action) - # Try to install APITestka - ui_we_want_to_set.install_api_testka = QAction( - language_wrapper.language_word_dict.get("install_menu_apitestka")) - ui_we_want_to_set.install_api_testka.triggered.connect( - lambda: install_api_testka(ui_we_want_to_set) - ) - ui_we_want_to_set.install_automation_menu.addAction(ui_we_want_to_set.install_api_testka) - # Try to install LoadDensity - ui_we_want_to_set.install_load_density_action = QAction( - language_wrapper.language_word_dict.get("install_menu_loaddensity")) - ui_we_want_to_set.install_load_density_action.triggered.connect( - lambda: install_load_density(ui_we_want_to_set) - ) - ui_we_want_to_set.install_automation_menu.addAction(ui_we_want_to_set.install_load_density_action) - # Try to install WebRunner - ui_we_want_to_set.install_web_runner_action = QAction( - language_wrapper.language_word_dict.get("install_menu_webrunner")) - ui_we_want_to_set.install_web_runner_action.triggered.connect( - lambda: install_web_runner(ui_we_want_to_set) - ) - ui_we_want_to_set.install_automation_menu.addAction(ui_we_want_to_set.install_web_runner_action) - # Try to install Automation File - ui_we_want_to_set.install_automation_file_action = QAction( - language_wrapper.language_word_dict.get("install_menu_automation_file")) - ui_we_want_to_set.install_automation_file_action.triggered.connect( - lambda: install_automation_file(ui_we_want_to_set) - ) - ui_we_want_to_set.install_automation_menu.addAction(ui_we_want_to_set.install_automation_file_action) - # Try to install MailThunder - ui_we_want_to_set.install_mail_thunder_action = QAction( - language_wrapper.language_word_dict.get("install_menu_mail_thunder")) - ui_we_want_to_set.install_mail_thunder_action.triggered.connect( - lambda: install_mail_thunder_file(ui_we_want_to_set) - ) - ui_we_want_to_set.install_automation_menu.addAction(ui_we_want_to_set.install_mail_thunder_action) - # Try to install prthinker - ui_we_want_to_set.install_prthinker_action = QAction( - language_wrapper.language_word_dict.get("install_menu_prthinker")) - ui_we_want_to_set.install_prthinker_action.triggered.connect( - lambda: install_prthinker(ui_we_want_to_set) - ) - ui_we_want_to_set.install_automation_menu.addAction(ui_we_want_to_set.install_prthinker_action) - - -def install_autocontrol(ui_we_want_to_set: PyBreezeMainWindow) -> None: - install_package("je_auto_control", ui_we_want_to_set) - - -def install_api_testka(ui_we_want_to_set: PyBreezeMainWindow) -> None: - install_package("je_api_testka", ui_we_want_to_set) - - -def install_load_density(ui_we_want_to_set: PyBreezeMainWindow) -> None: - install_package("je_load_density", ui_we_want_to_set) - - -def install_web_runner(ui_we_want_to_set: PyBreezeMainWindow) -> None: - install_package("je_web_runner", ui_we_want_to_set) - - -def install_automation_file(ui_we_want_to_set: PyBreezeMainWindow) -> None: - install_package("automation_file", ui_we_want_to_set) +# The entries installed from PyPI, in menu order: the language key of each +# label and the package it installs +PYPI_PACKAGES: tuple[tuple[str, str], ...] = ( + ("install_menu_autocontrol", "je_auto_control"), + ("install_menu_apitestka", "je_api_testka"), + ("install_menu_loaddensity", "je_load_density"), + ("install_menu_webrunner", "je_web_runner"), + ("install_menu_automation_file", "automation_file"), + ("install_menu_mail_thunder", "je_mail_thunder"), + ("install_menu_test_pioneer", "test_pioneer"), +) -def install_mail_thunder_file(ui_we_want_to_set: PyBreezeMainWindow) -> None: - install_package("je_mail_thunder", ui_we_want_to_set) +def build_automation_install_menu(ui_we_want_to_set: PyBreezeMainWindow): + """Add the Automation submenu of Install: one entry per package, then prthinker.""" + words = language_wrapper.language_word_dict + menu = ui_we_want_to_set.install_menu.addMenu(words.get("automation_menu_label")) + ui_we_want_to_set.install_automation_menu = menu + for label_key, package in PYPI_PACKAGES: + # The menu is the action's parent: a menu does not own what is added to it + action = QAction(words.get(label_key), menu) + action.triggered.connect( + lambda _checked=False, name=package: install_package(name, ui_we_want_to_set)) + menu.addAction(action) + prthinker_action = QAction(words.get("install_menu_prthinker"), menu) + prthinker_action.triggered.connect(lambda: install_prthinker(ui_we_want_to_set)) + menu.addAction(prthinker_action) def install_prthinker(ui_we_want_to_set: PyBreezeMainWindow) -> None: diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py index 3a4fefc9..0ae459d5 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py @@ -26,11 +26,10 @@ def set_plugin_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: 有執行設定的插件是一個子選單:About 和一個「Run with」項目,支援多種副檔名時一併列出。 A plugin with a run config gets a submenu: About, and one Run with entry that lists the suffixes when there are several. + 外掛瀏覽器一定在:沒有任何外掛時,第一個外掛就是從它安裝的。 + The Plugin Browser is always there, with no plugin loaded too: it is how + the first one gets installed. """ - metadata_list = get_all_plugin_metadata() - if not metadata_list: - return - ui_we_want_to_set.plugin_menu = ui_we_want_to_set.menu.addMenu( language_wrapper.language_word_dict.get("plugin_menu_label", "Plugins") ) @@ -42,8 +41,10 @@ def set_plugin_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: ) browse_action.triggered.connect(lambda: _open_plugin_browser(ui_we_want_to_set)) ui_we_want_to_set.plugin_menu.addAction(browse_action) - ui_we_want_to_set.plugin_menu.addSeparator() + metadata_list = get_all_plugin_metadata() + if metadata_list: + ui_we_want_to_set.plugin_menu.addSeparator() for meta in metadata_list: # One plugin with bad metadata costs its own entry, not the IDE's start if not isinstance(meta, dict): @@ -134,9 +135,9 @@ def callback(): message_box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) message_box.setWindowTitle(name) message_box.setText(as_text( - f"{name}\n" - f"Version: {version}\n" - f"Author: {author}" + language_wrapper.language_word_dict.get( + "plugin_about_text", "{name}\nVersion: {version}\nAuthor: {author}", + ).format(name=name, version=version, author=author) )) message_box.exec() return callback diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index b65d347e..deea9d7a 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -103,10 +103,7 @@ def save_current_file_for_run(main_window: PyBreezeMainWindow) -> str | None: def _save(main_window: PyBreezeMainWindow, widget: EditorWidget) -> str | None: """Write *widget* to its file, or through Save As; the path, or None if cancelled.""" if not widget.current_file: - if not choose_file_get_save_file_path(main_window): - return None - # The save dialog can be accepted without a path being set. - return widget.current_file or None + return _save_as(main_window, widget) write_file_with_encoding( str(widget.current_file), widget.code_edit.toPlainText(), getattr(widget, "file_encoding", DEFAULT_ENCODING), @@ -119,6 +116,14 @@ def _save(main_window: PyBreezeMainWindow, widget: EditorWidget) -> str | None: return widget.current_file +def _save_as(main_window: PyBreezeMainWindow, widget: EditorWidget) -> str | None: + """Save a tab that has no file through JEditor's Save As; its new path, or None if cancelled.""" + if not choose_file_get_save_file_path(main_window): + return None + # The dialog sets the tab's file; it can be accepted without a path being set. + return widget.current_file or None + + def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> None: """Save the current file, then run it with *run_config* in a new run window.""" file_path = save_current_file_for_run(main_window) diff --git a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py index d7c30b9f..b368b96e 100644 --- a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py +++ b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py @@ -9,7 +9,6 @@ from je_editor import jeditor_logger from pybreeze.pybreeze_ui.closing import AskingDock -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import AICodeReviewClient from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI @@ -32,16 +31,26 @@ from pybreeze.pybreeze_ui.tools_gui.url_builder_gui import UrlBuilderGUI if TYPE_CHECKING: + from PySide6.QtWidgets import QWidget + from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow # --------------------------------------------------------------------------- # Widget registry # --------------------------------------------------------------------------- + +def _ssh_widget() -> QWidget: + # Imported when the SSH client first opens: paramiko and cryptography take + # about a sixth of a second of the IDE's start + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget + return SSHMainWidget() + + # Widget key -> factory taking the main window. Shared by the Tools-menu tab # actions and the dock actions so each widget's constructor is written once. _WIDGET_FACTORIES: dict[str, Callable[[PyBreezeMainWindow], object]] = { - "SSH": lambda _win: SSHMainWidget(), + "SSH": lambda _win: _ssh_widget(), "AICodeReview": lambda _win: AICodeReviewClient(), "CoTPromptEditor": lambda _win: CoTPromptEditor(), "CoTCodeReview": lambda _win: CoTCodeReviewGUI(), @@ -107,7 +116,7 @@ "extend_tools_menu_skill_prompt_editor_tab_action", "extend_tools_menu_skill_prompt_editor_tab_label"), ("SkillSendGUI", "tools_ai_skill_send_action", "tools_ai_menu", - "extend_tools_menu_skill_prompt_send_tab_label", + "extend_tools_menu_skill_prompt_send_tab_action", "extend_tools_menu_skill_prompt_send_tab_label"), ("DiagramEditor", "tools_diagram_editor_action", "tools_menu", "extend_tools_menu_diagram_editor_tab_action", "extend_tools_menu_diagram_editor_tab_label"), @@ -253,9 +262,12 @@ def extend_dock_menu(ui_we_want_to_set: PyBreezeMainWindow): ui_we_want_to_set.dock_ssh_menu = ui_we_want_to_set.dock_menu.addMenu( language_wrapper.language_word_dict.get("extend_tools_menu_dock_ssh_menu") ) - ui_we_want_to_set.dock_ai_menu = ui_we_want_to_set.dock_menu.addMenu( - language_wrapper.language_word_dict.get("extend_tools_menu_dock_ai_menu") - ) + # JEditor's Dock menu has an AI submenu of its own (Chat UI): the review docks + # join it. A second one beside it showed two "AI" entries + if getattr(ui_we_want_to_set, "dock_ai_menu", None) is None: + ui_we_want_to_set.dock_ai_menu = ui_we_want_to_set.dock_menu.addMenu( + language_wrapper.language_word_dict.get("extend_tools_menu_dock_ai_menu") + ) for widget_key, attribute, menu_attribute, action_key in _DOCK_ACTIONS: _register_action( diff --git a/pybreeze/pybreeze_ui/run_shortcut.py b/pybreeze/pybreeze_ui/run_shortcut.py new file mode 100644 index 00000000..b45a8f92 --- /dev/null +++ b/pybreeze/pybreeze_ui/run_shortcut.py @@ -0,0 +1,37 @@ +"""Ctrl+Enter runs a tool: its text boxes take Enter as a new line.""" +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence, QShortcut +from PySide6.QtWidgets import QAbstractButton, QWidget + +# The main keyboard's Enter and the keypad's +RUN_KEYS = ("Ctrl+Return", "Ctrl+Enter") +# How the button's tooltip names them +RUN_KEYS_SHOWN = "Ctrl+Enter" + + +def act_on_ctrl_enter(tool: QWidget, action: Callable[[], object]) -> None: + """Let Ctrl+Enter anywhere in *tool* call *action*. + + The shortcuts are children of *tool*, so they go with it, and apply only + while the focus is in it: a tool in a dock does not take the keys from the + editor. Connect a bound method or a button's ``click``: a lambda holding + *tool* would keep it alive. + """ + for keys in RUN_KEYS: + shortcut = QShortcut(QKeySequence(keys), tool) + shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + shortcut.activated.connect(action) + + +def press_on_ctrl_enter(tool: QWidget, button: QAbstractButton) -> None: + """Let Ctrl+Enter anywhere in *tool* press *button*, as a click would. + + A disabled button (a run still going) is not pressed. The button's tooltip + names the keys. + """ + act_on_ctrl_enter(tool, button.click) + button.setToolTip(RUN_KEYS_SHOWN) diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index f0b8ddc8..6631c2ac 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -8,6 +8,8 @@ from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import QWidget, QGridLayout, QHBoxLayout, QPlainTextEdit, QPushButton, QScrollArea +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font +from pybreeze.pybreeze_ui.terminal_view import insert_rewinding from pybreeze.utils.terminal_text import strip_terminal_controls, take_leading_backspaces if TYPE_CHECKING: @@ -23,27 +25,6 @@ MAX_OUTPUT_BLOCKS = 10000 -def _insert_rewinding(cursor: QTextCursor, text: str, text_format: QTextCharFormat) -> bool: - """Insert *text* at *cursor*, a lone ``\\r`` going back to the start of the line. - - As a terminal does: a progress bar that rewinds with ``\\r`` redraws its - line instead of adding one per step. ``\\r\\n`` and ``\\n`` are line breaks. - - :return: whether *text* ended on a ``\\r`` still to be applied: it waits - for what comes next, since rewound now, a finished progress bar's last - line would be erased with nothing to replace it - """ - pieces = text.replace("\r\n", "\n").split("\r") - cursor.insertText(pieces[0], text_format) - for index, piece in enumerate(pieces[1:], start=1): - if not piece and index == len(pieces) - 1: - return True - cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.KeepAnchor) - cursor.removeSelectedText() - cursor.insertText(piece, text_format) - return False - - class CodeWindow(QWidget): # Emitted when a window closes with nothing left running in it, so the @@ -78,6 +59,7 @@ def __init__(self): self.code_result = QPlainTextEdit() self.code_result.setLineWrapMode(self.code_result.LineWrapMode.NoWrap) self.code_result.setReadOnly(True) + use_fixed_pitch_font(self.code_result) self.code_result.document().setMaximumBlockCount(MAX_OUTPUT_BLOCKS) self.code_result_scroll_area = QScrollArea() self.code_result_scroll_area.setWidgetResizable(True) @@ -169,6 +151,6 @@ def append_output(self, text: str, is_error: bool = False, *, own_line: bool = F cursor.movePosition(QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.KeepAnchor, min(backspaces, cursor.positionInBlock())) cursor.removeSelectedText() - self._rewind_pending = _insert_rewinding(cursor, text, text_format) + self._rewind_pending = insert_rewinding(cursor, text, text_format) if follow_output: scroll_bar.setValue(scroll_bar.maximum()) diff --git a/pybreeze/pybreeze_ui/syntax/syntax_extend.py b/pybreeze/pybreeze_ui/syntax/syntax_extend.py index 04734458..79ad5958 100644 --- a/pybreeze/pybreeze_ui/syntax/syntax_extend.py +++ b/pybreeze/pybreeze_ui/syntax/syntax_extend.py @@ -6,11 +6,17 @@ if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -from PySide6.QtGui import QColor from pybreeze.pybreeze_ui.syntax.syntax_keyword import TEST_PIONEER_SUFFIXES, package_keyword_list from pybreeze.utils.manager.package_manager.package_manager_class import package_manager +# Keyword colours as keys into JEditor's theme colours, which have a dark and a +# light set: fixed yellow (255, 255, 0) keywords could not be read on a light +# theme. The highlighter looks the key up each time it is built, so the +# colours follow a theme change. +JSON_KEYWORD_COLOUR = "warning_output_color" # yellow; dark yellow on light +YAML_KEYWORD_COLOUR = "diff_modified_marker_color" # orange + def syntax_extend_package(main_window: PyBreezeMainWindow) -> None: # Register JSON syntax keywords for each automation package @@ -20,7 +26,7 @@ def syntax_extend_package(main_window: PyBreezeMainWindow) -> None: # no words instead of crashing syntax setup with set(None). json_syntax_words[package] = { "words": set(package_keyword_list.get(package, [])), - "color": QColor(255, 255, 0), + "color": JSON_KEYWORD_COLOUR, } register_programming_language(".json", json_syntax_words) @@ -29,7 +35,7 @@ def syntax_extend_package(main_window: PyBreezeMainWindow) -> None: yml_syntax_words = { "test_pioneer": { "words": set(package_keyword_list.get("test_pioneer", [])), - "color": QColor(255, 153, 0), + "color": YAML_KEYWORD_COLOUR, } } for suffix in TEST_PIONEER_SUFFIXES: diff --git a/pybreeze/pybreeze_ui/terminal_view.py b/pybreeze/pybreeze_ui/terminal_view.py new file mode 100644 index 00000000..fb379904 --- /dev/null +++ b/pybreeze/pybreeze_ui/terminal_view.py @@ -0,0 +1,85 @@ +"""Terminal output written into a text view the way a terminal shows it. + +Shared by the run window and the SSH terminal. +""" +from __future__ import annotations + +from PySide6.QtGui import QColor, QFont, QFontMetricsF, QPalette, QTextCharFormat, QTextCursor +from PySide6.QtWidgets import QPlainTextEdit + +from pybreeze.utils.terminal_style import PLAIN, Colour, TextStyle, colour_rgb + + +# Smallest size given to a program for a view squeezed to almost nothing +MIN_COLUMNS = 20 +MIN_ROWS = 5 + + +def terminal_size(view: QPlainTextEdit) -> tuple[int, int]: + """The columns and rows of text *view* shows whole, in its font. + + What a pty is told, for programs to lay out their output (``ls``'s + columns, a progress bar's width) to fit the view. + """ + metrics = QFontMetricsF(view.font()) + margins = 2 * view.document().documentMargin() + viewport = view.viewport() + columns = int((viewport.width() - margins) // metrics.horizontalAdvance("M")) + rows = int((viewport.height() - margins) // metrics.lineSpacing()) + return max(columns, MIN_COLUMNS), max(rows, MIN_ROWS) + + +# Background lightness (0–255) below which a view counts as dark +_DARK_BELOW = 128 + + +def _qcolour(colour: Colour | None, on_dark: bool) -> QColor | None: + return None if colour is None else QColor(*colour_rgb(colour, on_dark=on_dark)) + + +def style_format(style: TextStyle, palette: QPalette) -> QTextCharFormat: + """The format for text in *style* in a view with *palette*. + + The palette's background picks the dark or the light set of the 16 basic + colours, and its colours stand in for defaults in inverse. The plain style + is an empty format: the view's own font and colours. + """ + text_format = QTextCharFormat() + if style == PLAIN: + return text_format + on_dark = palette.color(QPalette.ColorRole.Base).lightness() < _DARK_BELOW + foreground = _qcolour(style.foreground, on_dark) + background = _qcolour(style.background, on_dark) + if style.inverse: + foreground, background = (background or palette.color(QPalette.ColorRole.Base), + foreground or palette.color(QPalette.ColorRole.Text)) + if foreground is not None: + text_format.setForeground(foreground) + if background is not None: + text_format.setBackground(background) + if style.bold: + text_format.setFontWeight(QFont.Weight.Bold) + text_format.setFontItalic(style.italic) + text_format.setFontUnderline(style.underline) + return text_format + + +def insert_rewinding(cursor: QTextCursor, text: str, text_format: QTextCharFormat) -> bool: + """Insert *text* at *cursor*, a lone ``\\r`` going back to the start of the line. + + As a terminal does: a progress bar that rewinds with ``\\r`` redraws its + line instead of adding one per step. ``\\r\\n`` and ``\\n`` are line breaks. + + :return: whether *text* ended on a ``\\r`` still to be applied: it waits + for what comes next, since rewound now, a finished progress bar's last + line would be erased with nothing to replace it + """ + pieces = text.replace("\r\n", "\n").split("\r") + cursor.insertText(pieces[0], text_format) + for index, piece in enumerate(pieces[1:], start=1): + if not piece and index == len(pieces) - 1: + return True + cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.KeepAnchor) + cursor.removeSelectedText() + cursor.insertText(piece, text_format) + return False diff --git a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py index 6ba35ace..b2339df1 100644 --- a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py @@ -14,6 +14,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions @@ -25,6 +26,7 @@ from pybreeze.utils.header_tools.header_merge import stored_header_name from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font # The single target that generates JSON rather than Python _JSON_TARGET = "apitestka_action" @@ -47,6 +49,7 @@ def __init__(self, main_window=None) -> None: self.input_label = QLabel(word.get("curl_import_input_label")) self.input_edit = QTextEdit() + use_fixed_pitch_font(self.input_edit) self.input_edit.setPlaceholderText(word.get("curl_import_input_placeholder")) self.input_edit.setAcceptRichText(False) @@ -57,11 +60,14 @@ def __init__(self, main_window=None) -> None: self.target_select.addItem(word.get(label_key), target_key) self.target_select.currentIndexChanged.connect(self._on_target_changed) - self.convert_button = QPushButton(word.get("curl_import_convert_button")) + self.convert_button = QPushButton() + self._name_convert_button() self.convert_button.clicked.connect(self.convert) + press_on_ctrl_enter(self, self.convert_button) self.output_label = QLabel(word.get("curl_import_output_label")) self.output_edit = QTextEdit() + use_fixed_pitch_font(self.output_edit) self.output_edit.setReadOnly(True) # Cross-tool actions: hand the parsed parts to the tool that specialises @@ -79,7 +85,7 @@ def __init__(self, main_window=None) -> None: # Shared copy / open-in-editor / save actions. The extension and basename # follow the selected target; open/save are no-ops until a valid template. - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename=lambda: "action" if self.selected_target() == _JSON_TARGET else "request", extension=lambda: "json" if self.selected_target() == _JSON_TARGET else "py", @@ -93,15 +99,22 @@ def __init__(self, main_window=None) -> None: ): layout.addWidget(widget) layout.addLayout(cross_tool) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) def selected_target(self) -> str: """Return the template key of the currently selected target.""" return self.target_select.currentData() + def _name_convert_button(self) -> None: + """Name the chosen target on the button that generates it.""" + self.convert_button.setText( + language_wrapper.language_word_dict.get("curl_import_convert_button").format( + target=self.target_select.currentText())) + def _on_target_changed(self, _index: int) -> None: - """Regenerate when the target changes, if there is already input.""" + """Rename the button, and regenerate if there is already input.""" + self._name_convert_button() if exact_text(self.input_edit).strip(): self.convert() diff --git a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py index 6a74a9b0..da377104 100644 --- a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py @@ -2,16 +2,54 @@ from __future__ import annotations from PySide6.QtCore import QThread, Signal +from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget ) from je_editor import language_wrapper +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font from pybreeze.utils.diff_tools.text_diff import Comparison, DiffSummary, compare_texts +# A unified diff line's first characters -> JEditor theme colour (a dark and a +# light set, which the Style menu switches between); the first match counts +_LINE_COLOURS = ( + ("@@", "syntax_keyword_color"), + ("+", "diff_added_marker_color"), + ("-", "diff_removed_marker_color"), + ("\\", "blame_annotation_color"), # "\ No newline at end of file" +) +# The "--- expected" and "+++ actual" lines a diff starts with +_HEADER_LINES = 2 + + +def diff_line_colour(line: str, line_number: int) -> str | None: + """The theme colour key *line* of a unified diff is shown in, or ``None`` for the view's own. + + Only the first two lines are the header: a removed line reading ``--x`` + also starts with ``---``. + """ + if line_number < _HEADER_LINES and line.startswith(("--- ", "+++ ")): + return None + return next((key for prefix, key in _LINE_COLOURS if line.startswith(prefix)), None) + + +class UnifiedDiffHighlighter(QSyntaxHighlighter): + """Colours a unified diff's added, removed and hunk lines, in the theme's colours.""" + + def highlightBlock(self, text: str) -> None: + key = diff_line_colour(text, self.currentBlock().blockNumber()) + colour = actually_color_dict.get(key) if key is not None else None + if colour is not None: + text_format = QTextCharFormat() + text_format.setForeground(colour) + self.setFormat(0, len(text), text_format) + def build_summary_line(summary: DiffSummary) -> str: """Render a one-line summary of a diff. @@ -51,9 +89,11 @@ def __init__(self, main_window=None) -> None: self.left_label = QLabel(word.get("diff_left_label")) self.left_edit = QTextEdit() + use_fixed_pitch_font(self.left_edit) self.left_edit.setAcceptRichText(False) self.right_label = QLabel(word.get("diff_right_label")) self.right_edit = QTextEdit() + use_fixed_pitch_font(self.right_edit) self.right_edit.setAcceptRichText(False) inputs = QHBoxLayout() @@ -68,12 +108,15 @@ def __init__(self, main_window=None) -> None: self.compare_button = QPushButton(word.get("diff_compare_button")) self.compare_button.clicked.connect(self.compare) + press_on_ctrl_enter(self, self.compare_button) self.summary_label = QLabel("") self.output_edit = QTextEdit() + use_fixed_pitch_font(self.output_edit) self.output_edit.setReadOnly(True) + self._highlighter = UnifiedDiffHighlighter(self.output_edit.document()) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="diff", extension="txt") @@ -82,7 +125,7 @@ def __init__(self, main_window=None) -> None: layout.addWidget(self.compare_button) layout.addWidget(self.summary_label) layout.addWidget(self.output_edit) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) self._compare_thread: DiffThread | None = None diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index 12657620..1340e634 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -25,6 +25,7 @@ from pybreeze.utils.file_process.read_capped import read_text_capped from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font # The single target that generates JSON rather than Python _JSON_TARGET = "apitestka_action" @@ -87,9 +88,10 @@ def __init__(self, main_window=None) -> None: self.output_label = QLabel(word.get("curl_import_output_label")) self.output_edit = QTextEdit() + use_fixed_pitch_font(self.output_edit) self.output_edit.setReadOnly(True) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename=lambda: "actions" if self.selected_target() == _JSON_TARGET else "session", extension=lambda: "json" if self.selected_target() == _JSON_TARGET else "py", @@ -104,7 +106,7 @@ def __init__(self, main_window=None) -> None: layout.addLayout(generate_row) layout.addWidget(self.output_label) layout.addWidget(self.output_edit) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) def selected_target(self) -> str: diff --git a/pybreeze/pybreeze_ui/tools_gui/hash_gui.py b/pybreeze/pybreeze_ui/tools_gui/hash_gui.py index cdc98ee1..229beaf6 100644 --- a/pybreeze/pybreeze_ui/tools_gui/hash_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/hash_gui.py @@ -4,6 +4,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.hash_tools.hash_text import hash_all @@ -35,12 +36,13 @@ def __init__(self, main_window=None) -> None: self.hash_button = QPushButton(word.get("hash_button")) self.hash_button.clicked.connect(self.compute) + press_on_ctrl_enter(self, self.hash_button) self.output_label = QLabel(word.get("hash_output_label")) self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="hashes", extension="txt") @@ -50,7 +52,7 @@ def __init__(self, main_window=None) -> None: self.output_label, self.output_edit, ): layout.addWidget(widget) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) def compute(self) -> None: diff --git a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py index 362d3963..9a747fac 100644 --- a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py @@ -12,6 +12,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions @@ -75,6 +76,7 @@ def __init__(self, main_window=None, initial_headers: str | None = None) -> None self.analyze_button = QPushButton(word.get("header_analyzer_analyze_button")) self.analyze_button.clicked.connect(self.analyze) + press_on_ctrl_enter(self, self.analyze_button) self.output_label = QLabel(word.get("header_analyzer_output_label")) self.output_edit = QTextEdit() @@ -86,7 +88,7 @@ def __init__(self, main_window=None, initial_headers: str | None = None) -> None self.open_jwt_button.clicked.connect(self.open_jwt_in_decoder) self.open_jwt_button.setEnabled(False) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="headers", extension="txt", is_valid=lambda: self._analysis is not None) @@ -97,7 +99,7 @@ def __init__(self, main_window=None, initial_headers: str | None = None) -> None self.output_label, self.output_edit, self.open_jwt_button, ): layout.addWidget(widget) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) if initial_headers: diff --git a/pybreeze/pybreeze_ui/tools_gui/http_status_gui.py b/pybreeze/pybreeze_ui/tools_gui/http_status_gui.py index 1b1b3204..d430f016 100644 --- a/pybreeze/pybreeze_ui/tools_gui/http_status_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/http_status_gui.py @@ -7,6 +7,23 @@ from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.http_reference.status_codes import StatusInfo, search +# A status class's word-dict key: this and the class in lower case, underscored +CATEGORY_KEY_PREFIX = "http_status_category_" + + +def category_key(info: StatusInfo) -> str: + """The word-dict key of *info*'s class (``http_status_category_client_error``).""" + return CATEGORY_KEY_PREFIX + info.category.lower().replace(" ", "_") + + +def status_heading(info: StatusInfo) -> str: + """``404 Not Found [Client Error]``, the class in the IDE's language. + + The phrase and the description stay as the standard library words them. + """ + category = language_wrapper.language_word_dict.get(category_key(info), info.category) + return f"{info.code} {info.phrase} [{category}]" + def build_status_text(statuses: list[StatusInfo], empty_message: str) -> str: """Render a list of statuses into a readable block. @@ -19,7 +36,7 @@ def build_status_text(statuses: list[StatusInfo], empty_message: str) -> str: return empty_message lines: list[str] = [] for info in statuses: - lines.append(f"{info.code} {info.phrase} [{info.category}]") + lines.append(status_heading(info)) if info.description: lines.append(f" {info.description}") return "\n".join(lines) @@ -44,7 +61,7 @@ def __init__(self, initial_search: str = "", main_window=None) -> None: self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="http_status", extension="txt") @@ -52,7 +69,7 @@ def __init__(self, initial_search: str = "", main_window=None) -> None: layout.addWidget(self.search_label) layout.addWidget(self.search_edit) layout.addWidget(self.output_edit) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) # Setting the text triggers refresh; an empty value shows the whole table. diff --git a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py index 876ecb22..c48afa4c 100644 --- a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py @@ -6,12 +6,14 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import ITEJsonException from pybreeze.utils.json_format.json_process import minify_json, reformat_json from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font class JsonFormatGUI(QWidget): @@ -29,11 +31,13 @@ def __init__(self, main_window=None, initial_json: str | None = None) -> None: self.input_label = QLabel(word.get("json_format_input_label")) self.input_edit = QTextEdit() + use_fixed_pitch_font(self.input_edit) self.input_edit.setPlaceholderText(word.get("json_format_input_placeholder")) self.input_edit.setAcceptRichText(False) self.format_button = QPushButton(word.get("json_format_format_button")) self.format_button.clicked.connect(self.format_json) + press_on_ctrl_enter(self, self.format_button) self.minify_button = QPushButton(word.get("json_format_minify_button")) self.minify_button.clicked.connect(self.minify) @@ -43,9 +47,10 @@ def __init__(self, main_window=None, initial_json: str | None = None) -> None: self.output_label = QLabel(word.get("json_format_output_label")) self.output_edit = QTextEdit() + use_fixed_pitch_font(self.output_edit) self.output_edit.setReadOnly(True) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="formatted", extension="json", is_valid=lambda: self._valid_output) @@ -56,7 +61,7 @@ def __init__(self, main_window=None, initial_json: str | None = None) -> None: layout.addLayout(buttons) layout.addWidget(self.output_label) layout.addWidget(self.output_edit) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) if initial_json: diff --git a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py index daef8b4c..e9d0d52c 100644 --- a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py @@ -9,6 +9,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import JwtDecodeException @@ -17,6 +18,7 @@ ) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font def build_decoded_text(decoded: DecodedJwt) -> str: @@ -57,15 +59,18 @@ def __init__(self, initial_token: str | None = None, main_window=None) -> None: self.input_edit = QTextEdit() self.input_edit.setPlaceholderText(word.get("jwt_decoder_input_placeholder")) self.input_edit.setAcceptRichText(False) + use_fixed_pitch_font(self.input_edit) self.decode_button = QPushButton(word.get("jwt_decoder_decode_button")) self.decode_button.clicked.connect(self.decode) + press_on_ctrl_enter(self, self.decode_button) self.output_label = QLabel(word.get("jwt_decoder_output_label")) self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) + use_fixed_pitch_font(self.output_edit) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="jwt", extension="txt", is_valid=lambda: self._valid_output) @@ -75,7 +80,7 @@ def __init__(self, initial_token: str | None = None, main_window=None) -> None: self.output_label, self.output_edit, ): layout.addWidget(widget) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) if initial_token: diff --git a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py index 9045374f..325a147c 100644 --- a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py @@ -6,12 +6,14 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import act_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import QueryConvertException from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.query_tools.query_convert import json_to_query, query_to_json from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font class QueryJsonGUI(QWidget): @@ -29,11 +31,16 @@ def __init__(self, main_window=None) -> None: self.input_edit = QTextEdit() self.input_edit.setPlaceholderText(word.get("query_json_input_placeholder")) self.input_edit.setAcceptRichText(False) + use_fixed_pitch_font(self.input_edit) self.to_json_button = QPushButton(word.get("query_json_to_json_button")) self.to_json_button.clicked.connect(self.convert_to_json) self.to_query_button = QPushButton(word.get("query_json_to_query_button")) self.to_query_button.clicked.connect(self.convert_to_query) + # Ctrl+Enter goes the way the input reads: from JSON for a JSON object + self.to_json_button.setToolTip(word.get("ctrl_enter_when_not_json")) + self.to_query_button.setToolTip(word.get("ctrl_enter_when_json")) + act_on_ctrl_enter(self, self.convert_as_pasted) buttons = QHBoxLayout() buttons.addWidget(self.to_json_button) @@ -42,8 +49,9 @@ def __init__(self, main_window=None) -> None: self.output_label = QLabel(word.get("query_json_output_label")) self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) + use_fixed_pitch_font(self.output_edit) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="query", extension="txt", is_valid=lambda: self._valid_output) @@ -53,9 +61,14 @@ def __init__(self, main_window=None) -> None: layout.addLayout(buttons) layout.addWidget(self.output_label) layout.addWidget(self.output_edit) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) + def convert_as_pasted(self) -> None: + """Ctrl+Enter: JSON → query for a JSON object, query → JSON otherwise.""" + pasted_json = exact_text(self.input_edit).lstrip().startswith("{") + (self.to_query_button if pasted_json else self.to_json_button).click() + def convert_to_json(self) -> None: """Convert the input query string to JSON.""" text = exact_text(self.input_edit).strip() diff --git a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py index c4c7b20b..93eb238a 100644 --- a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -8,6 +8,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions @@ -56,9 +57,9 @@ def build_matches_text(matches: list[MatchResult], no_match_message: str) -> str for index, match in enumerate(matches, start=1): lines.append(f"[{index}] ({match.start}-{match.end}) {match.matched_text!r}") for group_index, value in enumerate(match.groups, start=1): - lines.append(f" group {group_index}: {value!r}") + lines.append(" " + word.get("regex_group_line").format(index=group_index, value=repr(value))) for name, value in match.named_groups.items(): - lines.append(f" {name}: {value!r}") + lines.append(" " + word.get("regex_named_group_line").format(name=name, value=repr(value))) return "\n".join(lines) @@ -94,12 +95,13 @@ def __init__(self, main_window=None) -> None: self.test_button = QPushButton(word.get("regex_test_button")) self.test_button.clicked.connect(self.test) + press_on_ctrl_enter(self, self.test_button) self.output_label = QLabel(word.get("regex_output_label")) self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="matches", extension="txt", is_valid=lambda: self._valid_output) @@ -112,7 +114,7 @@ def __init__(self, main_window=None) -> None: layout.addWidget(self.test_button) layout.addWidget(self.output_label) layout.addWidget(self.output_edit) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) def selected_flags(self) -> list[str]: diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py index 994fc235..aff46672 100644 --- a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -11,9 +11,10 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import press_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI -from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI +from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI, status_heading from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions @@ -31,7 +32,7 @@ def _status_section(analysis: ResponseAnalysis) -> list[str]: return [] word = language_wrapper.language_word_dict status = analysis.status - lines = [word.get("response_status_label"), f"{status.code} {status.phrase} [{status.category}]"] + lines = [word.get("response_status_label"), status_heading(status)] if status.description: lines.append(f" {status.description}") lines.append("") @@ -96,6 +97,7 @@ def __init__(self, main_window=None) -> None: self.analyze_button = QPushButton(word.get("response_analyze_button")) self.analyze_button.clicked.connect(self.analyze) + press_on_ctrl_enter(self, self.analyze_button) self.output_label = QLabel(word.get("response_output_label")) self.output_edit = QTextEdit() @@ -116,7 +118,7 @@ def __init__(self, main_window=None) -> None: self.open_body_button.setEnabled(False) # Shared copy / open-in-editor / save actions, valid once analysed. - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="response", extension="txt", is_valid=lambda: self._analysis is not None) @@ -134,7 +136,7 @@ def __init__(self, main_window=None) -> None: layout.addWidget(self.output_label) layout.addWidget(self.output_edit) layout.addLayout(cross_tool) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) def analyze(self) -> None: diff --git a/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py b/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py index 456cf21e..8914735b 100644 --- a/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py @@ -22,10 +22,11 @@ def build_result_text(result: TimestampResult) -> str: :return: display text listing every representation """ word = language_wrapper.language_word_dict + line = word.get("timestamp_result_line") return "\n".join([ - f"{word.get('timestamp_epoch_seconds_label')}: {result.epoch_seconds}", - f"{word.get('timestamp_epoch_millis_label')}: {result.epoch_millis}", - f"{word.get('timestamp_iso_label')}: {result.iso_utc}", + line.format(label=word.get("timestamp_epoch_seconds_label"), value=result.epoch_seconds), + line.format(label=word.get("timestamp_epoch_millis_label"), value=result.epoch_millis), + line.format(label=word.get("timestamp_iso_label"), value=result.iso_utc), ]) @@ -52,7 +53,7 @@ def __init__(self, main_window=None) -> None: self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="timestamp", extension="txt", is_valid=lambda: self._valid_output) @@ -62,7 +63,7 @@ def __init__(self, main_window=None) -> None: self.output_label, self.output_edit, ): layout.addWidget(widget) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) def convert(self) -> None: diff --git a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py index 017bd782..044dd13d 100644 --- a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py @@ -6,12 +6,14 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.run_shortcut import act_on_ctrl_enter from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.url_tools.url_convert import json_to_url, url_to_json from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.pybreeze_ui.fixed_pitch import use_fixed_pitch_font class UrlBuilderGUI(QWidget): @@ -30,11 +32,16 @@ def __init__(self, main_window=None, initial_url: str | None = None) -> None: self.input_edit = QTextEdit() self.input_edit.setPlaceholderText(word.get("url_builder_input_placeholder")) self.input_edit.setAcceptRichText(False) + use_fixed_pitch_font(self.input_edit) self.to_json_button = QPushButton(word.get("url_builder_to_json_button")) self.to_json_button.clicked.connect(self.convert_to_json) self.to_url_button = QPushButton(word.get("url_builder_to_url_button")) self.to_url_button.clicked.connect(self.convert_to_url) + # Ctrl+Enter goes the way the input reads: from JSON for a JSON object + self.to_json_button.setToolTip(word.get("ctrl_enter_when_not_json")) + self.to_url_button.setToolTip(word.get("ctrl_enter_when_json")) + act_on_ctrl_enter(self, self.convert_as_pasted) buttons = QHBoxLayout() buttons.addWidget(self.to_json_button) @@ -43,8 +50,9 @@ def __init__(self, main_window=None, initial_url: str | None = None) -> None: self.output_label = QLabel(word.get("url_builder_output_label")) self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) + use_fixed_pitch_font(self.output_edit) - self.actions = OutputActions( + self.output_actions = OutputActions( self, self.output_edit, main_window=main_window, basename="url", extension="txt", is_valid=lambda: self._valid_output) @@ -54,13 +62,18 @@ def __init__(self, main_window=None, initial_url: str | None = None) -> None: layout.addLayout(buttons) layout.addWidget(self.output_label) layout.addWidget(self.output_edit) - layout.addLayout(self.actions.button_row()) + layout.addLayout(self.output_actions.button_row()) self.setLayout(layout) if initial_url: self.input_edit.setPlainText(initial_url) self.convert_to_json() + def convert_as_pasted(self) -> None: + """Ctrl+Enter: JSON → URL for a JSON object, URL → JSON otherwise.""" + pasted_json = exact_text(self.input_edit).lstrip().startswith("{") + (self.to_url_button if pasted_json else self.to_json_button).click() + def convert_to_json(self) -> None: """Parse the input URL into its JSON parts.""" word = language_wrapper.language_word_dict diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 2314d302..c7fb0c65 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -401,10 +401,12 @@ def _apply_cookie(request: CurlRequest, value: str) -> None: ``curl -b 'a=1; b=2'`` yields inline cookies; a value with no ``=`` is a cookie *file* curl reads. It was sent as the header ``Cookie: cookies.txt``; - it is kept in ``cookie_files`` for the generators to say so. + it is kept in ``cookie_files`` for the generators to say so. An empty one + (``-b ''``) only switches curl's cookie engine on and reads no file. """ if "=" not in value: - request.cookie_files.append(value) + if value: + request.cookie_files.append(value) return for segment in value.split(";"): name, separator, cookie_value = segment.strip().partition("=") diff --git a/pybreeze/utils/curl_import/request_body.py b/pybreeze/utils/curl_import/request_body.py index 0402b6da..b7289e1f 100644 --- a/pybreeze/utils/curl_import/request_body.py +++ b/pybreeze/utils/curl_import/request_body.py @@ -75,8 +75,12 @@ def _exact_float(text: str) -> float: :raises ValueError: when it is not (too many digits, or out of range) """ value = float(text) - if not math.isfinite(value) or Decimal(repr(value)) != Decimal(text): - raise ValueError(f"{text} does not survive as a float") + survives = f"{text} does not survive as a float" + if not math.isfinite(value): + raise ValueError(survives) + # Decimal compares by value: "1.5" round-trips and this is false + if Decimal(repr(value)) != Decimal(text): # NOSONAR S2583 + raise ValueError(survives) return value diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py index 9d381d09..da83cc55 100644 --- a/pybreeze/utils/diff_tools/text_diff.py +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -16,6 +16,9 @@ _CONTEXT_LINES = 3 # The "--- left" and "+++ right" lines a non-empty unified diff starts with _HEADER_LINES = 2 +# Most lines, both texts together, for which a trimmed match is checked +# against the plain one (``_closest_match``); matching takes milliseconds there +_PLAIN_MATCH_MAX_LINES = 2000 # diff's own note under a last line that has no newline _NO_NEWLINE_MARK = "\\ No newline at end of file" @@ -157,11 +160,14 @@ def _closest_match(left_lines: list[str], right_lines: list[str]) -> tuple[diffl finds the smaller diff, but not always: in ``b b a b a`` against ``b a c b`` the first ``b`` taken as unchanged left a worse match, five lines changed where ``difflib`` changes three. When anything was set - aside, the plain match is made too and the smaller one kept. + aside, the plain match is made too and the smaller one kept -- for texts + of up to ``_PLAIN_MATCH_MAX_LINES`` lines between them. On larger ones a + second match doubled a wait of seconds (40,000 lines: 6 s became 16 s) + for the same diff. """ trimmed = _TrimmedMatcher(left_lines, right_lines) best = (trimmed, *_changed_lines(trimmed)) - if trimmed.trimmed_any(): + if trimmed.trimmed_any() and len(left_lines) + len(right_lines) <= _PLAIN_MATCH_MAX_LINES: plain = difflib.SequenceMatcher(None, left_lines, right_lines) added, removed = _changed_lines(plain) if added + removed < best[1] + best[2]: diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 7779d2f8..690016fa 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -104,3 +104,13 @@ # A diagram file (.diagram.json) that is not one diagram_not_an_object_error: str = "a diagram file holds an object, not a {kind}" diagram_section_not_a_list_error: str = "a diagram's '{section}' is a list, not a {kind}" + +# Why a run's report mail was not sent (mail_thunder_extend.send_after_test) +mail_not_installed_error: str = "je_mail_thunder is not installed" +mail_settings_unreadable_error: str = "the mail settings file (mail_thunder_content.json) could not be read" +mail_no_user_error: str = "no mail user is set" +mail_login_failed_error: str = "the mail server login failed" +mail_send_failed_error: str = "sending failed ({kind})" +report_missing_error: str = "the run wrote no {name}" +report_not_a_file_error: str = "{name} is not a file" +report_stale_error: str = "the run wrote no new {name}; the one there is from an earlier run" diff --git a/pybreeze/utils/network/public_http.py b/pybreeze/utils/network/public_http.py index 44c7451c..ad682cd5 100644 --- a/pybreeze/utils/network/public_http.py +++ b/pybreeze/utils/network/public_http.py @@ -150,7 +150,8 @@ def _new_conn(self) -> socket.socket: self._dns_host = address try: return super()._new_conn() # type: ignore[misc] - except (NewConnectionError, ConnectTimeoutError) as error: + # A refused connection too: urllib3's NewConnectionError is a ConnectTimeoutError + except ConnectTimeoutError as error: failure = error finally: self._dns_host = name @@ -208,7 +209,7 @@ def public_session() -> requests.Session: """ session = _NoRedirectSession() adapter = PublicAddressAdapter() - session.mount("http://", adapter) + session.mount("http://", adapter) # NOSONAR S5332 — the checked adapter for http URLs; which schemes are allowed is validate_url's call session.mount("https://", adapter) return session diff --git a/pybreeze/utils/network/url_validation.py b/pybreeze/utils/network/url_validation.py index f5074418..048c8674 100644 --- a/pybreeze/utils/network/url_validation.py +++ b/pybreeze/utils/network/url_validation.py @@ -23,7 +23,7 @@ # RFC 6598 shared address space (Carrier-Grade NAT). Not covered by # ``is_private`` / ``is_reserved`` yet routinely abused for SSRF in cloud # environments, so it is blocked explicitly. -_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10") +_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10") # NOSONAR S1313 — a range refused, not an address connected to # RFC 6052 NAT64 well-known prefix. The trailing 32 bits embed an IPv4 target # that a NAT64 gateway routes to, so it must be decoded and re-checked. @@ -115,8 +115,8 @@ def _as_ascii(host: str) -> str: try: import idna return idna.encode(host.lower(), strict=True, std3_rules=True).decode("ascii") - except (ImportError, UnicodeError, ValueError): - # idna.IDNAError is a UnicodeError + except (ImportError, ValueError): + # idna.IDNAError is a UnicodeError, which is a ValueError return host diff --git a/pybreeze/utils/subprocess_util.py b/pybreeze/utils/subprocess_util.py index 5ebfe54f..e2163571 100644 --- a/pybreeze/utils/subprocess_util.py +++ b/pybreeze/utils/subprocess_util.py @@ -11,16 +11,31 @@ # How long ending a process tree may take on the UI thread _TREE_KILL_SECONDS = 10 +# The value of an environment variable the IDE sets for its own process only; +# child_environment() leaves every variable with this value out +IDE_ONLY = "pybreeze-ide-only" + + +def child_environment() -> dict[str, str]: + """Return a copy of ``os.environ`` for a process the IDE starts. + + A variable the IDE set for itself alone (its value is ``IDE_ONLY``) is left + out: the IDE keeps locust from patching it with gevent + (``LOCUST_SKIP_MONKEY_PATCH``), and a load test it starts needs that + patching to run its users at once. + """ + return {name: value for name, value in os.environ.items() if value != IDE_ONLY} + def utf8_subprocess_env(encoding: str = "utf-8") -> dict[str, str]: - """Return a copy of ``os.environ`` forcing a child Python's stdio *encoding*. + """Return the environment for a child (``child_environment()``) forcing a child Python's stdio *encoding*. On Windows a child's piped stdout defaults to the console code page (e.g. cp950 / cp1252), so non-ASCII output would be mis-decoded by the utf-8 reader in the process managers and show up garbled. Pinning ``PYTHONIOENCODING`` makes the child emit the same encoding the manager decodes with. """ - env = os.environ.copy() + env = child_environment() env["PYTHONIOENCODING"] = encoding return env diff --git a/pybreeze/utils/terminal_style.py b/pybreeze/utils/terminal_style.py new file mode 100644 index 00000000..43a5ad07 --- /dev/null +++ b/pybreeze/utils/terminal_style.py @@ -0,0 +1,167 @@ +"""The colours and emphasis a program's SGR escape sequences ask for. + +``ESC [ … m`` (Select Graphic Rendition) sets how the text after it looks: +``ls --color``, ``git``, ``grep --color`` use it through a pty. This reads the +sequences into a :class:`TextStyle`, carried from one read to the next; the +view turns it into a text format. Other escape sequences are not read here. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, replace + +# A colour: an index into the 256-colour palette, or (red, green, blue) +Colour = int | tuple[int, int, int] + +# SGR with ";"-separated parameters. The ":" form (ESC [ 38:5:196 m) is left +# to strip_terminal_controls, which removes it. +SGR_PATTERN = re.compile(r'\x1B\[([0-9;]*)m') + +# Parameter -> (attribute, value) for the on/off parameters +_SWITCHES = { + 1: ("bold", True), 22: ("bold", False), + 3: ("italic", True), 23: ("italic", False), + 4: ("underline", True), 24: ("underline", False), + 7: ("inverse", True), 27: ("inverse", False), +} +_DEFAULT_FOREGROUND = 39 +_DEFAULT_BACKGROUND = 49 +_EXTENDED_FOREGROUND = 38 +_EXTENDED_BACKGROUND = 48 +# Parameter runs that pick one of the 16 basic colours: first, attribute, first colour +_COLOUR_RUNS = ((30, "foreground", 0), (90, "foreground", 8), (40, "background", 0), (100, "background", 8)) +_RUN_LENGTH = 8 +_PALETTE_FORM = 5 # 38;5;n +_RGB_FORM = 2 # 38;2;r;g;b + +# The first 16 colours (normal, then bright) as VS Code's terminal shows them +# on a dark and on a light theme. xterm's own blue (0, 0, 238) was all but +# unreadable on the IDE's dark background. +_ON_DARK = ( + (0, 0, 0), (205, 49, 49), (13, 188, 121), (229, 229, 16), + (36, 114, 200), (188, 63, 188), (17, 168, 205), (229, 229, 229), + (102, 102, 102), (241, 76, 76), (35, 209, 139), (245, 245, 67), + (59, 142, 234), (214, 112, 214), (41, 184, 219), (229, 229, 229), +) +_ON_LIGHT = ( + (0, 0, 0), (205, 49, 49), (16, 124, 16), (148, 152, 0), + (4, 81, 165), (188, 5, 188), (5, 152, 188), (85, 85, 85), + (102, 102, 102), (241, 76, 76), (20, 206, 20), (181, 186, 0), + (59, 142, 234), (214, 112, 214), (41, 184, 219), (165, 165, 165), +) +_CUBE_LEVELS = (0, 95, 135, 175, 215, 255) +_CUBE_START = 16 +_GREY_START = 232 +_PALETTE_SIZE = 256 +_CHANNEL_MAX = 255 +# Longest parameter read: a longer one (a server can send thousands of digits, +# and int() refuses more than 4300) is taken as unknown +_MAX_PARAMETER_DIGITS = 5 +_UNKNOWN = -1 + + +@dataclass(frozen=True) +class TextStyle: + """How text looks; ``None`` colours are the view's own.""" + + foreground: Colour | None = None + background: Colour | None = None + bold: bool = False + italic: bool = False + underline: bool = False + inverse: bool = False + + +PLAIN = TextStyle() + + +def colour_rgb(colour: Colour, *, on_dark: bool) -> tuple[int, int, int]: + """The (red, green, blue) of *colour*, on a dark background or a light one. + + The first 16 differ between the two; the rest of the palette (xterm's + colour cube and greys) and a colour given as red, green and blue do not. + """ + if isinstance(colour, tuple): + return colour + basic = _ON_DARK if on_dark else _ON_LIGHT + if colour < len(basic): + return basic[colour] + if colour < _GREY_START: + index = colour - _CUBE_START + return (_CUBE_LEVELS[index // 36], _CUBE_LEVELS[index // 6 % 6], _CUBE_LEVELS[index % 6]) + grey = 8 + 10 * (colour - _GREY_START) + return grey, grey, grey + + +def _extended_colour(numbers: list[int], start: int) -> tuple[Colour | None, int]: + """The colour ``38;…``/``48;…`` gives from *start*, and where the next parameter is. + + ``None`` for one malformed or out of range, which leaves the colour as it was. + """ + form = numbers[start] if start < len(numbers) else None + if form == _PALETTE_FORM and start + 1 < len(numbers): + index = numbers[start + 1] + return (index if 0 <= index < _PALETTE_SIZE else None), start + 2 + if form == _RGB_FORM and start + 3 < len(numbers): + rgb = tuple(numbers[start + 1:start + 4]) + return (rgb if 0 <= min(rgb) and max(rgb) <= _CHANNEL_MAX else None), start + 4 + return None, len(numbers) # the rest cannot be read + + +def _colour_parameter(style: TextStyle, number: int) -> TextStyle: + """*style* after one of the basic colour parameters (30–49, 90–107), or as it was.""" + for first, attribute, offset in _COLOUR_RUNS: + if first <= number < first + _RUN_LENGTH: + return replace(style, **{attribute: number - first + offset}) + if number == _DEFAULT_FOREGROUND: + return replace(style, foreground=None) + if number == _DEFAULT_BACKGROUND: + return replace(style, background=None) + return style + + +def _parameter(part: str) -> int: + """One SGR parameter: empty is 0, one too long to be real is ``_UNKNOWN``.""" + if not part: + return 0 + return int(part) if len(part) <= _MAX_PARAMETER_DIGITS else _UNKNOWN + + +def apply_sgr(style: TextStyle, parameters: str) -> TextStyle: + """*style* after an SGR sequence with *parameters* (``"1;31"``); unknown ones are ignored.""" + numbers = [_parameter(part) for part in parameters.split(";")] + index = 0 + while index < len(numbers): + number = numbers[index] + index += 1 + if number == 0: + style = PLAIN + elif number in _SWITCHES: + attribute, value = _SWITCHES[number] + style = replace(style, **{attribute: value}) + elif number in (_EXTENDED_FOREGROUND, _EXTENDED_BACKGROUND): + colour, index = _extended_colour(numbers, index) + if colour is not None: + attribute = "foreground" if number == _EXTENDED_FOREGROUND else "background" + style = replace(style, **{attribute: colour}) + else: + style = _colour_parameter(style, number) + return style + + +def split_styled(text: str, style: TextStyle) -> tuple[list[tuple[TextStyle, str]], TextStyle]: + """*text* cut at its SGR sequences, each piece with the style it is shown in. + + *style* is what earlier text left; the style *text* leaves is returned + with the pieces. The pieces keep every other escape sequence and control. + """ + pieces: list[tuple[TextStyle, str]] = [] + start = 0 + for match in SGR_PATTERN.finditer(text): + if match.start() > start: + pieces.append((style, text[start:match.start()])) + style = apply_sgr(style, match.group(1)) + start = match.end() + if start < len(text): + pieces.append((style, text[start:])) + return pieces, style diff --git a/pybreeze/utils/terminal_text.py b/pybreeze/utils/terminal_text.py index fa1c9259..69b8c098 100644 --- a/pybreeze/utils/terminal_text.py +++ b/pybreeze/utils/terminal_text.py @@ -25,14 +25,22 @@ # Backspace is applied first (_apply_backspaces) _CONTROL_CHARACTER = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') -# The end of a read that stops inside an escape sequence: a lone ESC, a CSI -# still waiting for its final byte, a control string still waiting for its -# terminator (or the second byte of ST), or a character-set escape still -# waiting for its final byte -_INCOMPLETE_ESCAPE = re.compile(r'\x1B(?:\[[0-?]*[ -/]*|[\]PX^_][^\x07\x1B]*\x1B?|[ -/]+)?\Z') +# The end of a read that stops inside an escape sequence: a lone ESC, or ESC and +# one of these, still waiting for the rest +_UNFINISHED_CSI = r'\[[0-?]*[ -/]*' # a CSI without its final byte +_UNFINISHED_STRING = r'[\]PX^_][^\x07\x1B]*\x1B?' # a control string without its terminator (or ST's second byte) +_UNFINISHED_CHARSET = r'[ -/]+' # a character-set escape without its final byte +_INCOMPLETE_ESCAPE = re.compile(rf'\x1B(?:{_UNFINISHED_CSI}|{_UNFINISHED_STRING}|{_UNFINISHED_CHARSET})?\Z') # Longest such tail held back for the next read; anything longer is shown as is _MAX_PENDING_ESCAPE = 256 +# What wipes the screen: Erase in Display of all of it (2) or of it and the +# scrollback (3), as `clear` sends, and a full reset (RIS), as `reset` sends. +# Erasing below the cursor alone (ESC [ J) is not one: shells send it to +# redraw a prompt. +_SCREEN_CLEAR = re.compile(r'\x1B\[[23]J|\x1Bc') +FULL_RESET = '\x1Bc' + def strip_terminal_controls(text: str) -> str: """*text* without escape sequences, with backspaces applied and other controls dropped.""" @@ -97,3 +105,16 @@ def split_unfinished_end(text: str) -> tuple[str, str]: if shown.endswith("\r"): return shown[:-1], "\r" + held return shown, held + + +def split_at_screen_clear(text: str) -> tuple[str, str, str] | None: + """*text* around its last screen clear: what comes before, the sequence, what comes after. + + ``None`` when it has none. Only what follows the last clear is left on + the screen. + """ + clears = list(_SCREEN_CLEAR.finditer(text)) + if not clears: + return None + last = clears[-1] + return text[:last.start()], last.group(), text[last.end():] diff --git a/pyproject.toml b/pyproject.toml index d01a1eef..3facb552 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,10 @@ content-type = "text/markdown" [tool.setuptools.packages] find = { namespaces = false } +# The main window's icon, loaded from beside main_ui.py +[tool.setuptools.package-data] +"pybreeze.pybreeze_ui.editor_main" = ["pybreeze_icon.ico"] + [tool.bandit] # Codacy / SonarCloud run Bandit across the tree; tests use `assert` intentionally # (pytest relies on it), and subprocess is a required dependency of the IDE's diff --git a/test/test_utils/conftest.py b/test/test_utils/conftest.py index 107e8ed8..5a333281 100644 --- a/test/test_utils/conftest.py +++ b/test/test_utils/conftest.py @@ -6,11 +6,42 @@ """ from __future__ import annotations +import functools +import sys +import threading + import pytest +from PySide6.QtCore import QThread from pybreeze.pybreeze_ui.gui_thread_gc import GuiThreadGarbageCollector +def _traced_run(run): + """*run*, handing its thread the tracer ``threading`` gives the threads Python starts.""" + @functools.wraps(run) + def traced(self, *args, **kwargs): + tracer = threading.gettrace() + if tracer is not None: + sys.settrace(tracer) + return run(self, *args, **kwargs) + return traced + + +def _measure_qthreads(cls, **kwargs) -> None: + """Let coverage see a ``QThread``'s ``run``: it traces the threads Python starts, not Qt's. + + Every QThread subclass defined after this (the package's are imported by the + tests) gets a ``run`` that installs coverage's tracer on its own thread. It + does nothing when coverage is not running: there is no tracer to install. + """ + super(QThread, cls).__init_subclass__(**kwargs) + if "run" in cls.__dict__: + cls.run = _traced_run(cls.__dict__["run"]) + + +QThread.__init_subclass__ = classmethod(_measure_qthreads) + + @pytest.fixture(scope="session", autouse=True) def _gui_thread_collector(): collector = GuiThreadGarbageCollector() diff --git a/test/test_utils/started_window.py b/test/test_utils/started_window.py index 133b9673..aeb6e263 100644 --- a/test/test_utils/started_window.py +++ b/test/test_utils/started_window.py @@ -43,7 +43,8 @@ def run_started_window( tmp_path: Path, body: str, *, saved_language: str | None = None, - before_window: str = "") -> object: + before_window: str = "", saved_settings: dict | None = None, + build: str = _BUILD_WINDOW) -> object: """Start the IDE in a child, run *body* there, and return the ``result`` it set. *body* runs after the window is built and garbage has been collected, with @@ -52,13 +53,17 @@ def run_started_window( where JEditor looks for its settings, so *saved_language* is written there as the saved language before the start. *before_window* runs after the application exists and before the window is built -- to register an - ``EDITOR_EXTEND_TAB``, say. + ``EDITOR_EXTEND_TAB``, say. *saved_settings* are further saved settings + (``{"ui_style": ...}``), and *build* replaces the code that builds + ``window`` -- to start it the way ``start_editor`` does, say. """ + settings = dict(saved_settings or {}) if saved_language is not None: + settings["language"] = saved_language + if settings: settings_dir = tmp_path / ".jeditor" settings_dir.mkdir() - (settings_dir / "user_setting.json").write_text( - json.dumps({"language": saved_language}), encoding="utf-8") + (settings_dir / "user_setting.json").write_text(json.dumps(settings), encoding="utf-8") result_file = tmp_path / "result.json" environment = { **os.environ, @@ -67,7 +72,7 @@ def run_started_window( filter(None, [str(_REPOSITORY_ROOT), os.environ.get("PYTHONPATH")])), } completed = subprocess.run( # noqa: S603 — fixed argv: this interpreter and a script built from literals - [sys.executable, "-c", _PRELUDE + before_window + _BUILD_WINDOW + body + _REPORT, + [sys.executable, "-c", _PRELUDE + before_window + build + body + _REPORT, str(result_file)], cwd=tmp_path, env=environment, capture_output=True, timeout=_START_TIMEOUT_SECONDS, check=False, shell=False, diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py index a180c758..c92bc257 100644 --- a/test/test_utils/test_ai_code_review_client.py +++ b/test/test_utils/test_ai_code_review_client.py @@ -29,7 +29,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def client(app, tmp_path, monkeypatch): monkeypatch.setattr(ai_code_review_gui, "pybreeze_data_dir", lambda: tmp_path) made = AICodeReviewClient() @@ -116,6 +116,48 @@ def request(sent_method, *_args, **_kwargs): assert sent == [method] # sent with the method chosen in the panel return answered, failed + def test_no_code_is_not_sent_in_a_body(self, client, monkeypatch): + # An empty code field went out, and the answer was about nothing + started: list = [] + monkeypatch.setattr(ai_code_review_gui, "ReviewRequestThread", lambda *args: started.append(args)) + client.url_input.setText(_A_URL) + client.code_input.setPlainText(" \n") + + client.send_request() + + assert started == [] + assert client.response_panel.toPlainText() == client.word_dict.get( + "ai_code_review_gui_message_paste_code") + + def test_a_method_without_a_body_needs_no_code(self, client, monkeypatch): + started: list = [] + monkeypatch.setattr(ai_code_review_gui, "ReviewRequestThread", lambda *args: started.append(args) or _Silent()) + monkeypatch.setattr(client, "record_url", lambda url: True) + client.url_input.setText(_A_URL) + client.method_box.setCurrentText("GET") + + client.send_request() + + assert [args[0] for args in started] == ["GET"] + client.request_thread.wait(5000) + + def test_the_code_goes_as_pasted(self, client, monkeypatch): + # It was stripped: the first line of a selection from inside a function lost its indent + started: list = [] + monkeypatch.setattr(ai_code_review_gui, "ReviewRequestThread", lambda *args: started.append(args) or _Silent()) + monkeypatch.setattr(client, "record_url", lambda url: True) + client.url_input.setText(_A_URL) + client.code_input.setPlainText(" total = 1\n return total\n") + + client.send_request() + + assert started[0][2] == " total = 1\n return total\n" + client.request_thread.wait(5000) + + def test_a_new_panel_sends_the_code(self, client): + # It started on GET, which sends no body: the code pasted went nowhere + assert client.method_box.currentText() in ai_code_review_gui.METHODS_WITH_A_BODY + def test_an_answer_reaches_the_panel(self, app, monkeypatch): class Response: ok = True @@ -266,6 +308,7 @@ def test_however_the_request_ended(self, client, monkeypatch): monkeypatch.setattr(ai_code_review_gui, "ReviewRequestThread", _Silent) monkeypatch.setattr(client, "record_url", lambda url: True) client.url_input.setText(_A_URL) + client.code_input.setPlainText("print(1)") client.send_request() assert not client.send_button.isEnabled() diff --git a/test/test_utils/test_ai_gui_lifecycle.py b/test/test_utils/test_ai_gui_lifecycle.py index 72242496..16969788 100644 --- a/test/test_utils/test_ai_gui_lifecycle.py +++ b/test/test_utils/test_ai_gui_lifecycle.py @@ -35,7 +35,7 @@ def test_a_running_review_is_interrupted_and_let_go_of_without_waiting(self): gui = CoTCodeReviewGUI.__new__(CoTCodeReviewGUI) thread = _running_thread() - gui.thread = thread + gui.request_thread = thread event = MagicMock() CoTCodeReviewGUI.closeEvent(gui, event) @@ -49,7 +49,7 @@ def test_a_running_review_is_interrupted_and_let_go_of_without_waiting(self): def test_no_thread_just_accepts(self): gui = CoTCodeReviewGUI.__new__(CoTCodeReviewGUI) - gui.thread = None + gui.request_thread = None event = MagicMock() CoTCodeReviewGUI.closeEvent(gui, event) @@ -60,7 +60,7 @@ def test_finished_thread_is_not_awaited(self): gui = CoTCodeReviewGUI.__new__(CoTCodeReviewGUI) thread = MagicMock() thread.isRunning.return_value = False - gui.thread = thread + gui.request_thread = thread event = MagicMock() CoTCodeReviewGUI.closeEvent(gui, event) @@ -78,7 +78,7 @@ def test_a_running_request_is_let_go_of_without_waiting(self): gui = SkillsSendGUI.__new__(SkillsSendGUI) thread = _running_thread() - gui.thread = thread + gui.request_thread = thread event = MagicMock() SkillsSendGUI.closeEvent(gui, event) @@ -115,7 +115,7 @@ def test_its_answer_signal_does_not_hide_the_thread_ending(self): def test_no_thread_just_accepts(self): gui = SkillsSendGUI.__new__(SkillsSendGUI) - gui.thread = None + gui.request_thread = None event = MagicMock() SkillsSendGUI.closeEvent(gui, event) @@ -139,3 +139,44 @@ def test_the_warning_speaks_the_ide_language(self, qapp, monkeypatch): assert shown == [("警告", "請先輸入 API URL!")] gui.deleteLater() + + +class TestCoTLabels: + @pytest.mark.parametrize("language", ["English", "Traditional_Chinese"]) + def test_the_box_for_the_code_is_labelled_as_the_code(self, qapp, monkeypatch, language): + # It read "Prompt Area": the prompts are the templates; this box holds + # the code each of them quotes + from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict, + ) + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import cot_code_review_gui as cot_mod + from PySide6.QtWidgets import QLabel + + word = {"English": pybreeze_english_word_dict, + "Traditional_Chinese": pybreeze_traditional_chinese_word_dict}[language] + monkeypatch.setattr(cot_mod.language_wrapper, "language_word_dict", word) + gui = CoTCodeReviewGUI() + + labels = [label.text() for label in gui.findChildren(QLabel)] + + assert {"English": "Code to Review", "Traditional_Chinese": "要審查的程式碼"}[language] in labels + assert not any("Prompt" in text or "傳送資料" in text for text in labels) + gui.deleteLater() + + +class TestCoTWithoutCode: + def test_nothing_is_sent_and_the_user_is_told(self, qapp, monkeypatch): + # The whole chain, eight requests, ran on an empty box + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import cot_code_review_gui as cot_mod + shown: list = [] + monkeypatch.setattr(cot_mod.QMessageBox, "warning", lambda *args: shown.append(args[2])) + gui = CoTCodeReviewGUI() + gui.url_input.setText("https://llm.example.com/api") + gui.code_paste_area.setPlainText(" \n\t\n") + + gui.start_sending() + + assert gui.request_thread is None + assert shown == [cot_mod.language_wrapper.language_word_dict.get("cot_gui_error_no_code")] + gui.deleteLater() diff --git a/test/test_utils/test_ai_request_formats.py b/test/test_utils/test_ai_request_formats.py new file mode 100644 index 00000000..8ea5792d --- /dev/null +++ b/test/test_utils/test_ai_request_formats.py @@ -0,0 +1,72 @@ +"""What the AI panels send, as the README tells whoever writes the endpoint. + +AI Code Review: the form field ``code`` in a POST or PUT body. Skill Send: the +JSON ``{"code": ...}`` holding the prompt. (CoT Code Review's JSON +``{"prompt": ...}`` is checked in ``test_cot_session_reuse.py``.) +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +from pybreeze.pybreeze_ui.connect_gui.url import ai_code_review_gui +from pybreeze.pybreeze_ui.extend_ai_gui.skills import skills_send_gui + +_URL = "https://llm.example.com/api" + + +class _Response: + ok = True + status_code = 200 + reason = "OK" + text = "fine" + + +class _Session: + """Records what is sent; used as ``public_session()`` or as its context.""" + + def __init__(self) -> None: + self.sent: list[tuple[str, dict]] = [] + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + """Nothing to close.""" + + def request(self, method, _url, **kwargs): + self.sent.append((method, kwargs)) + return _Response() + + def post(self, url, **kwargs): + return self.request("POST", url, **kwargs) + + +@pytest.fixture +def session(monkeypatch): + recorder = _Session() + for module in (ai_code_review_gui, skills_send_gui): + monkeypatch.setattr(module, "validate_url", lambda url: url) + monkeypatch.setattr(module, "public_session", lambda: recorder) + monkeypatch.setattr(module, "read_capped_text", lambda response: response.text) + return recorder + + +@pytest.mark.parametrize("method", ai_code_review_gui.METHODS_WITH_A_BODY) +def test_ai_code_review_sends_the_code_as_a_form_field(session, method): + ai_code_review_gui.ReviewRequestThread(method, _URL, "print(1)").run() + assert [(sent, kwargs["data"]) for sent, kwargs in session.sent] == [(method, {"code": "print(1)"})] + + +@pytest.mark.parametrize("method", ["GET", "DELETE"]) +def test_ai_code_review_sends_the_url_alone_otherwise(session, method): + ai_code_review_gui.ReviewRequestThread(method, _URL, "print(1)").run() + assert "data" not in session.sent[0][1] and "json" not in session.sent[0][1] + + +def test_skill_send_posts_the_prompt_as_json_code(session): + skills_send_gui.RequestThread(_URL, "Explain this code").run() + assert [(method, kwargs["json"]) for method, kwargs in session.sent] == [("POST", {"code": "Explain this code"})] diff --git a/test/test_utils/test_autocontrol_record.py b/test/test_utils/test_autocontrol_record.py index 7b4e5c67..81940170 100644 --- a/test/test_utils/test_autocontrol_record.py +++ b/test/test_utils/test_autocontrol_record.py @@ -6,6 +6,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +import je_auto_control import pytest from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox, QPlainTextEdit, QTabWidget, QWidget @@ -31,7 +32,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def window(app, monkeypatch): monkeypatch.setattr(record_menu, "EditorWidget", EditorTab) made = QMainWindow() @@ -49,7 +50,7 @@ def stop(): stopped.append(True) return actions - monkeypatch.setattr(record_menu.je_auto_control, "stop_record", stop) + monkeypatch.setattr(je_auto_control, "stop_record", stop) return stopped @@ -87,3 +88,44 @@ def test_nothing_recorded_says_so_and_inserts_nothing(window, monkeypatch, nothi # It used to insert the text "None". assert tab.code_edit.toPlainText() == "" assert window.told + + +class TestTheMenu: + """The entries that reach AutoControl import it only when chosen (see test_startup_imports.py).""" + + @staticmethod + def _menu(window) -> dict[str, object]: + from PySide6.QtWidgets import QMenu + + window.automation_menu = QMenu() + record_menu.set_autocontrol_menu(window) + (autocontrol,) = [action.menu() for action in window.automation_menu.actions()] + entries = {} + for action in autocontrol.actions(): + entries[action.text()] = action + if action.menu() is not None: + entries.update({sub.text(): sub for sub in action.menu().actions()}) + return entries + + def test_record_starts_autocontrol_recording(self, window, monkeypatch): + from je_editor import language_wrapper + + started: list = [] + monkeypatch.setattr(je_auto_control, "record", lambda: started.append(True)) + + self._menu(window)[language_wrapper.language_word_dict.get("autocontrol_record_start_label")].trigger() + + assert started == [True] + + def test_the_gui_entry_opens_autocontrols_gui_in_a_tab(self, window, monkeypatch): + from je_auto_control.gui import main_widget + + class AutoControlGUI(QWidget): + """Stands in for AutoControl's own GUI.""" + + monkeypatch.setattr(main_widget, "AutoControlGUIWidget", AutoControlGUI) + + self._menu(window)["AutoControl GUI"].trigger() + + assert isinstance(window.tab_widget.widget(0), AutoControlGUI) + assert window.tab_widget.tabText(0) == "AutoControl GUI" diff --git a/test/test_utils/test_automation_menu_factory.py b/test/test_utils/test_automation_menu_factory.py index 5db1c790..cdb1ba86 100644 --- a/test/test_utils/test_automation_menu_factory.py +++ b/test/test_utils/test_automation_menu_factory.py @@ -96,7 +96,7 @@ def test_project_and_gui_actions_survive_and_answer(window): "run_label", create_project=lambda: created.append(True), create_project_label_key="project_label", - gui_widget_class=QWidget, gui_label="GUI", + gui_widget_factory=QWidget, gui_label="GUI", )) gc.collect() @@ -124,3 +124,35 @@ def test_every_create_project_names_a_package_that_exists(): assert names, "no create-project entries found" assert [name for name in names if importlib.util.find_spec(name) is None] == [] + + +@pytest.mark.parametrize("menu_module, set_menu, gui_module, class_name, label", [ + ("load_density_menu.build_load_density_menu", "set_load_density_menu", + "je_load_density.gui.main_widget", "LoadDensityWidget", "LoadDensity GUI"), + ("api_testka_menu.build_api_testka_menu", "set_apitestka_menu", + "je_api_testka.gui.main_widget", "APITestkaWidget", "APITestka GUI"), +]) +def test_a_gui_entry_imports_the_packages_gui_when_chosen( + window, monkeypatch, menu_module, set_menu, gui_module, class_name, label): + # A stand-in module, so the package is never imported here: Load Density's + # brings locust, which patches the whole process with gevent as it imports + import importlib + import sys + import types + + class PackageGUI(QWidget): + """Stands in for the package's own GUI.""" + + stand_in = types.ModuleType(gui_module) + setattr(stand_in, class_name, PackageGUI) + monkeypatch.setitem(sys.modules, gui_module, stand_in) + module = importlib.import_module(f"pybreeze.pybreeze_ui.menu.automation_menu.{menu_module}") + getattr(module, set_menu)(window) + gc.collect() + + package_menus = window.automation_menu.actions() + (gui_action,) = [action for action in package_menus[0].menu().actions() if action.text() == label] + gui_action.trigger() + + assert isinstance(window.tab_widget.widget(0), PackageGUI) + assert window.tab_widget.tabText(0) == label diff --git a/test/test_utils/test_closed_panels_are_freed.py b/test/test_utils/test_closed_panels_are_freed.py index 5f9be74b..6d2d215f 100644 --- a/test/test_utils/test_closed_panels_are_freed.py +++ b/test/test_utils/test_closed_panels_are_freed.py @@ -102,13 +102,13 @@ def _diff(widget) -> None: def test_the_cot_review_panel(app): from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI - assert _freed_after_a_run(app, CoTCodeReviewGUI, _cot, lambda widget: widget.thread) + assert _freed_after_a_run(app, CoTCodeReviewGUI, _cot, lambda widget: widget.request_thread) def test_the_skill_send_panel(app): from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI - assert _freed_after_a_run(app, SkillsSendGUI, _skills, lambda widget: widget.thread) + assert _freed_after_a_run(app, SkillsSendGUI, _skills, lambda widget: widget.request_thread) def test_the_diff_tab(app): diff --git a/test/test_utils/test_code_boxes_fixed_pitch.py b/test/test_utils/test_code_boxes_fixed_pitch.py new file mode 100644 index 00000000..3052a337 --- /dev/null +++ b/test/test_utils/test_code_boxes_fixed_pitch.py @@ -0,0 +1,113 @@ +"""The text boxes that hold code show it in the fixed-pitch font. + +In the interface's proportional font, indentation and the columns of a diff did +not line up. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.fixed_pitch import fixed_pitch_font + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _diff(): + from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI + return DiffGUI(None) + + +def _curl(): + from pybreeze.pybreeze_ui.tools_gui.curl_import_gui import CurlImportGUI + return CurlImportGUI(None) + + +def _har(): + from pybreeze.pybreeze_ui.tools_gui.har_import_gui import HarImportGUI + return HarImportGUI(None) + + +def _json_format(): + from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI + return JsonFormatGUI(None) + + +def _ai_code_review(): + from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import AICodeReviewClient + return AICodeReviewClient() + + +def _cot_code_review(): + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI + return CoTCodeReviewGUI() + + +def _jwt_decoder(): + from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI + return JwtDecoderGUI() + + +def _query_json(): + from pybreeze.pybreeze_ui.tools_gui.query_json_gui import QueryJsonGUI + return QueryJsonGUI() + + +def _url_builder(): + from pybreeze.pybreeze_ui.tools_gui.url_builder_gui import UrlBuilderGUI + return UrlBuilderGUI() + + +def _mermaid_import(): + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import MermaidImportDialog + return MermaidImportDialog() + + +CODE_BOXES = [ + (_diff, "left_edit"), (_diff, "right_edit"), (_diff, "output_edit"), + (_curl, "input_edit"), (_curl, "output_edit"), + (_har, "output_edit"), + (_json_format, "input_edit"), (_json_format, "output_edit"), + (_jwt_decoder, "input_edit"), (_jwt_decoder, "output_edit"), + (_query_json, "input_edit"), (_query_json, "output_edit"), + (_url_builder, "input_edit"), (_url_builder, "output_edit"), + (_ai_code_review, "code_input"), + (_cot_code_review, "code_paste_area"), + (_mermaid_import, "_editor"), +] + + +@pytest.mark.parametrize(("build", "box"), CODE_BOXES, + ids=[f"{build.__name__[1:]}.{box}" for build, box in CODE_BOXES]) +def test_the_box_uses_the_fixed_pitch_font(app, build, box): + widget = build() + view = getattr(widget, box) + family = fixed_pitch_font().family() + + assert view.font().family() == family + # In the view's own sheet too: the theme's sheet overrides setFont + assert family in view.styleSheet() + widget.close() + + +def test_the_cot_step_selector_says_what_it_is_before_any_answer(app): + from je_editor import language_wrapper + from PySide6.QtWidgets import QLabel + + widget = _cot_code_review() + words = language_wrapper.language_word_dict + + # Empty and unlabelled, it sat halfway down the panel saying nothing + assert widget.response_selector.placeholderText() == words.get("cot_gui_placeholder_no_answers") + assert words.get("cot_gui_label_step") in [label.text() for label in widget.findChildren(QLabel)] + widget.close() diff --git a/test/test_utils/test_code_result_logs.py b/test/test_utils/test_code_result_logs.py new file mode 100644 index 00000000..a20f0562 --- /dev/null +++ b/test/test_utils/test_code_result_logs.py @@ -0,0 +1,41 @@ +"""Only warnings and errors from loggers reach the editor's Code Result panel. + +JEditor hooks a handler onto every logger that exists when the window is built +and shows what it receives in the panel, in red. The automation packages set the +root logger to DEBUG as they import, so opening a file there filled the panel +with gitpython's ``Popen(['git', 'cat-file', ...])`` lines, and PyBreeze's own +debug records went there too. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from test_utils.started_window import run_started_window + +_EMIT_AND_READ = """ +import logging +from je_editor.utils.redirect_manager.redirect_manager_class import redirect_manager_instance +queue = redirect_manager_instance.std_err_queue +while not queue.empty(): + queue.get_nowait() +logging.getLogger("git.util").debug("library debug") +logging.getLogger("urllib3.connectionpool").debug("connection debug") +logging.getLogger("Pybreeze").info("own info") +logging.getLogger("git.util").warning("library warning") +logging.getLogger("Pybreeze").error("own error") +result = [] +while not queue.empty(): + result.append(queue.get_nowait()) +""" + + +def test_debug_and_info_records_stay_out_of_code_result(tmp_path): + shown = "\n".join(run_started_window(tmp_path, _EMIT_AND_READ)) + + assert "library debug" not in shown + assert "connection debug" not in shown + assert "own info" not in shown + assert "library warning" in shown + assert "own error" in shown diff --git a/test/test_utils/test_context_menus_are_freed.py b/test/test_utils/test_context_menus_are_freed.py new file mode 100644 index 00000000..2216ace2 --- /dev/null +++ b/test/test_utils/test_context_menus_are_freed.py @@ -0,0 +1,78 @@ +"""The trees' right-click menus: deleted once they close, not kept as children of the tree +for good, and shown where they were asked for.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QCoreApplication, QEvent, QPoint +from PySide6.QtWidgets import QApplication, QFileSystemModel, QMenu, QTreeView + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget +from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class DismissedMenu(QMenu): + """A menu that closes at once, as when the user presses Escape.""" + + def exec(self, *args): + return None + + +@pytest.fixture +def menus_dismissed(monkeypatch): + # Setting exec on PySide's QMenu itself does not reach the call + for module in (file_tree_context_menu, ssh_file_viewer_widget): + monkeypatch.setattr(module, "QMenu", DismissedMenu) + + +def _menus_left(widget) -> int: + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + return len(widget.findChildren(QMenu)) + + +def test_the_project_tree_menu_goes_once_closed(app, tmp_path, menus_dismissed): + tree = QTreeView() + model = QFileSystemModel() + model.setRootPath(str(tmp_path)) + tree.setModel(model) + for _ in range(3): + file_tree_context_menu._show_context_menu(QPoint(1, 1), tree, main_window=None) + assert _menus_left(tree) == 0 + tree.deleteLater() + + +def test_the_sftp_tree_menu_goes_once_closed(app, menus_dismissed): + viewer = ssh_file_viewer_widget.SSHFileTreeManager() + for _ in range(3): + viewer.on_context_menu(QPoint(1, 1)) + assert _menus_left(viewer) == 0 + viewer.deleteLater() + + +def test_the_project_tree_menu_opens_where_it_was_asked_for(app, tmp_path, monkeypatch): + # It opened at the mouse pointer, wherever that was, when the Menu key asked for it + shown_at = [] + + class RecordingMenu(QMenu): + def exec(self, position, *args): + shown_at.append(position) + + monkeypatch.setattr(file_tree_context_menu, "QMenu", RecordingMenu) + tree = QTreeView() + model = QFileSystemModel() + model.setRootPath(str(tmp_path)) + tree.setModel(model) + file_tree_context_menu._show_context_menu(QPoint(7, 9), tree, main_window=None) + assert shown_at == [tree.viewport().mapToGlobal(QPoint(7, 9))] + tree.deleteLater() diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index 67584b65..2bb51e38 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -179,9 +179,9 @@ def test_closing_mid_review_does_not_wait(monkeypatch): answering = threading.Event() monkeypatch.setattr(code_review_thread.SenderThread, "run", lambda self: answering.wait(5)) gui = CoTCodeReviewGUI() - gui.thread = code_review_thread.SenderThread(files=[], code="", url="https://review.example") - gui.thread.start() - thread = gui.thread + gui.request_thread = code_review_thread.SenderThread(files=[], code="", url="https://review.example") + gui.request_thread.start() + thread = gui.request_thread gui.close() # returns while the request is still out @@ -263,6 +263,7 @@ def _sending_gui(monkeypatch): monkeypatch.setattr(code_review_thread.SenderThread, "start", lambda self: None) gui = CoTCodeReviewGUI() gui.url_input.setText("https://review.example/api") + gui.code_paste_area.setPlainText("print('x')") return gui @@ -278,7 +279,7 @@ def test_sending_resolves_nothing_on_the_ui_thread(monkeypatch): gui.start_sending() assert looked_up == [] - assert gui.thread is not None + assert gui.request_thread is not None gui.deleteLater() diff --git a/test/test_utils/test_create_project.py b/test/test_utils/test_create_project.py index d2b88b55..91e0481a 100644 --- a/test/test_utils/test_create_project.py +++ b/test/test_utils/test_create_project.py @@ -29,7 +29,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def package(monkeypatch): """A package whose create_project_dir writes one template file, as the real ones do.""" fake = types.ModuleType(_PACKAGE) @@ -48,7 +48,7 @@ def create_project_dir(project_path: str | None = None, parent_name: str = "Fake return fake -@pytest.fixture() +@pytest.fixture def dialogs(monkeypatch): """Record every message box; ``answer`` is what a question gets.""" shown: dict = {"question": [], "warning": [], "information": [], "answer": QMessageBox.StandardButton.No} diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index 681c61e6..9c272580 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -402,6 +402,15 @@ def test_a_cookie_file_is_kept_as_a_file_not_sent_as_a_cookie(self): assert "Cookie" not in request.headers assert request.cookie_files == ["cookies.txt"] + def test_an_empty_cookie_file_reads_nothing(self): + # curl -b '' only switches on the cookie engine; it reads no file. It + # was a file named "", which the scripts noted and the APITestka + # action refused to generate. + request = parse_curl("curl -b '' https://x") + assert request.cookie_files == [] + assert request.cookies == {} + assert "curl read cookies" not in to_requests_code(request) + def test_no_cookies_by_default(self): assert parse_curl("curl https://x").cookies == {} diff --git a/test/test_utils/test_curl_import_gui.py b/test/test_utils/test_curl_import_gui.py index 94638470..5b35d632 100644 --- a/test/test_utils/test_curl_import_gui.py +++ b/test/test_utils/test_curl_import_gui.py @@ -22,7 +22,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.curl_import_gui import CurlImportGUI gui = CurlImportGUI() @@ -32,6 +32,12 @@ def widget(app): class TestCurlImportGUI: + def test_the_button_names_the_chosen_target(self, widget): + # It said "Convert to Python requests" whichever target was chosen + for index in range(widget.target_select.count()): + widget.target_select.setCurrentIndex(index) + assert widget.target_select.itemText(index) in widget.convert_button.text() + def test_convert_produces_code(self, widget): widget.input_edit.setPlainText("curl https://example.com/api") widget.convert() @@ -54,12 +60,12 @@ def test_empty_input_shows_hint(self, widget): def test_copy_output_sets_clipboard(self, app, widget): widget.input_edit.setPlainText("curl https://example.com/api") widget.convert() - widget.actions.copy() + widget.output_actions.copy() assert "import requests" in QApplication.clipboard().text() def test_copy_with_empty_output_is_safe(self, widget): widget.output_edit.setPlainText("") - widget.actions.copy() # must not raise + widget.output_actions.copy() # must not raise def test_json_body_conversion(self, widget): widget.input_edit.setPlainText( @@ -122,7 +128,7 @@ def __init__(self, _main_window): self.code_edit = type("CodeEdit", (), {"setPlainText": lambda self, t: setattr(self, "text", t)})() -@pytest.fixture() +@pytest.fixture def widget_with_window(app): from pybreeze.pybreeze_ui.tools_gui.curl_import_gui import CurlImportGUI window = _FakeMainWindow() @@ -143,34 +149,34 @@ def test_open_in_editor_adds_tab_with_code(self, widget_with_window): with patch( "je_editor.EditorWidget", _FakeEditor ): - editor = gui.actions.open_in_editor() + editor = gui.output_actions.open_in_editor() assert len(window.tab_widget.added) == 1 assert "import requests" in editor.code_edit.text def test_open_in_editor_without_code_is_noop(self, widget_with_window): gui, window = widget_with_window - assert gui.actions.open_in_editor() is None + assert gui.output_actions.open_in_editor() is None assert window.tab_widget.added == [] def test_open_after_parse_error_is_noop(self, widget_with_window): gui, window = widget_with_window gui.input_edit.setPlainText("wget https://x") gui.convert() - assert gui.actions.open_in_editor() is None + assert gui.output_actions.open_in_editor() is None assert window.tab_widget.added == [] def test_open_in_editor_without_window_is_safe(self, widget): widget.input_edit.setPlainText("curl https://x") widget.convert() - assert widget.actions.open_in_editor() is None # no main window + assert widget.output_actions.open_in_editor() is None # no main window def test_suggested_filename_python(self, widget): self._select_target(widget, "requests") - assert widget.actions.suggested_filename() == "request.py" + assert widget.output_actions.suggested_filename() == "request.py" def test_suggested_filename_json(self, widget): self._select_target(widget, "apitestka_action") - assert widget.actions.suggested_filename() == "action.json" + assert widget.output_actions.suggested_filename() == "action.json" def test_save_to_file_writes(self, widget, tmp_path): widget.input_edit.setPlainText("curl https://x") @@ -180,7 +186,7 @@ def test_save_to_file_writes(self, widget, tmp_path): "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", return_value=(str(target), "Python (*.py)"), ): - result = widget.actions.save_to_file() + result = widget.output_actions.save_to_file() assert result == str(target) assert "import requests" in target.read_text(encoding="utf-8") @@ -191,12 +197,12 @@ def test_save_cancelled_returns_none(self, widget, tmp_path): "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", return_value=("", ""), ): - assert widget.actions.save_to_file() is None + assert widget.output_actions.save_to_file() is None def test_save_after_parse_error_is_noop(self, widget): widget.input_edit.setPlainText("wget https://x") widget.convert() - assert widget.actions.save_to_file() is None + assert widget.output_actions.save_to_file() is None class TestCurlImportOpenUrlInBuilder: diff --git a/test/test_utils/test_curl_request_fidelity.py b/test/test_utils/test_curl_request_fidelity.py index f4f94ed1..b94c0f2e 100644 --- a/test/test_utils/test_curl_request_fidelity.py +++ b/test/test_utils/test_curl_request_fidelity.py @@ -183,6 +183,12 @@ def test_the_json_action_refuses_it(self): with pytest.raises(CurlParseException): to_apitestka_action_json(parse_curl("curl -b cookies.txt https://h/")) + def test_an_empty_name_is_no_file_so_the_json_action_is_made(self): + # curl -b '' reads no file: it only switches the cookie engine on + from pybreeze.utils.curl_import.script_templates import to_apitestka_action_json + + assert '"https://h/"' in to_apitestka_action_json(parse_curl("curl -b '' https://h/")) + @pytest.mark.parametrize("url", [ "https://h/a?flag&q=%B0&r=/x&s=a,b", diff --git a/test/test_utils/test_diagram_align.py b/test/test_utils/test_diagram_align.py new file mode 100644 index 00000000..56869358 --- /dev/null +++ b/test/test_utils/test_diagram_align.py @@ -0,0 +1,107 @@ +"""Align and distribute in the diagram editor: where the selected nodes end up, and one undo step back.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture +def scene(app): + diagram = DiagramScene() + yield diagram + diagram.deleteLater() + + +def _nodes(scene: DiagramScene, *boxes: tuple[float, float, float, float]) -> list[DiagramNode]: + """Selected nodes at (x, y, w, h).""" + nodes = [] + for x, y, w, h in boxes: + node = DiagramNode(x=x, y=y, w=w, h=h, text="n") + scene.addItem(node) + node.setSelected(True) + nodes.append(node) + return nodes + + +def _positions(nodes: list[DiagramNode]) -> list[tuple[float, float]]: + return [(node.pos().x(), node.pos().y()) for node in nodes] + + +_THREE = ((10, 100, 100, 60), (200, 40, 50, 30), (400, 0, 80, 120)) + + +@pytest.mark.parametrize(("operation", "expected"), [ + ("align_left", [(10, 100), (10, 40), (10, 0)]), + ("align_right", [(380, 100), (430, 40), (400, 0)]), + ("align_top", [(10, 0), (200, 0), (400, 0)]), + ("align_bottom", [(10, 100), (200, 130), (400, 40)]), +]) +def test_each_edge_lines_up(scene, operation, expected): + nodes = _nodes(scene, *_THREE) + + getattr(scene, operation)() + + assert _positions(nodes) == expected + + +def test_centres_line_up_on_their_average(scene): + nodes = _nodes(scene, (0, 0, 100, 40), (300, 100, 50, 20)) + + scene.align_center_h() + scene.align_center_v() + + centres = {(node.pos().x() + node.node_w / 2, node.pos().y() + node.node_h / 2) for node in nodes} + assert centres == {(187.5, 65.0)} + + +def test_distributing_leaves_equal_gaps_between_the_outer_two(scene): + nodes = _nodes(scene, (0, 0, 100, 40), (130, 10, 60, 40), (400, 20, 100, 40)) + + scene.distribute_h() + + lefts = [node.pos().x() for node in nodes] + gaps = [lefts[1] - (lefts[0] + 100), lefts[2] - (lefts[1] + 60)] + assert lefts[0] == 0 and lefts[2] == 400 + assert gaps[0] == pytest.approx(gaps[1]) + + +def test_one_undo_takes_the_whole_alignment_back(scene): + nodes = _nodes(scene, *_THREE) + before = _positions(nodes) + + scene.align_left() + scene.undo_stack.undo() + + # Undo rebuilds the items from a snapshot: compare what is on the canvas now + now = sorted((item.pos().x(), item.pos().y()) for item in scene.items() if isinstance(item, DiagramNode)) + assert now == sorted(before) + assert not scene.undo_stack.canUndo() + + +@pytest.mark.parametrize("operation", ["align_left", "align_center_v", "distribute_h", "distribute_v"]) +def test_too_few_nodes_changes_nothing_and_leaves_no_undo_step(scene, operation): + nodes = _nodes(scene, (0, 0, 100, 40), (300, 100, 50, 20))[: 1 if operation.startswith("align") else 2] + for extra in scene.selectedItems(): + if extra not in nodes: + extra.setSelected(False) + before = _positions(nodes) + + getattr(scene, operation)() + + assert _positions(nodes) == before + assert not scene.undo_stack.canUndo() diff --git a/test/test_utils/test_diagram_editing.py b/test/test_utils/test_diagram_editing.py index 595e071e..09ce91e8 100644 --- a/test/test_utils/test_diagram_editing.py +++ b/test/test_utils/test_diagram_editing.py @@ -295,3 +295,64 @@ def test_a_scale_outside_the_range_can_step_back_toward_it(self, app): assert view.transform().m11() < 8 assert diagram_view._MAX_SCALE < 8 + + +class TestANewNodesText: + """A node the Rectangle or Text tool adds said "Node" or "Text" whatever the IDE spoke.""" + + @pytest.mark.parametrize(("add", "expected"), [("shape", "節點"), ("text", "文字")]) + def test_is_in_the_ide_language(self, app, monkeypatch, add, expected): + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict, + ) + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import NodeShape + + monkeypatch.setattr(scene_module.language_wrapper, "language_word_dict", pybreeze_traditional_chinese_word_dict) + scene = DiagramScene() + if add == "shape": + scene._add_shape_node(QPointF(0, 0), NodeShape.RECTANGLE) + else: + scene._add_text_node(QPointF(0, 0)) + + (node,) = [item for item in scene.items() if isinstance(item, DiagramNode)] + assert node.text() == expected + + +class TestResizeFromAHandle: + """Where a handle drag leaves a node or an image: the size follows the handle's + sides, a left or top handle moves the item too, and each keeps a minimum size + (a node 40 x 20, an image 40 x 40) with its far side where it was.""" + + @pytest.mark.parametrize("role,dx,dy,expected", [ + ("r", 60, 0, (10, 10, 200, 60)), + ("b", 0, 30, (10, 10, 140, 90)), + ("l", 20, 0, (30, 10, 120, 60)), + ("t", 0, 20, (10, 30, 140, 40)), + ("br", 10, 10, (10, 10, 150, 70)), + ("tl", -10, -10, (0, 0, 150, 70)), + ("r", -500, 0, (10, 10, 40, 60)), + ("l", 500, 0, (110, 10, 40, 60)), + ("b", 0, -500, (10, 10, 140, 20)), + ("t", 0, 500, (10, 50, 140, 20)), + ]) + def test_a_node(self, app, role, dx, dy, expected): + from PySide6.QtCore import QRectF + + node = DiagramNode(x=10, y=10, w=140, h=60, text="A") + node._apply_resize(role, QPointF(dx, dy), QRectF(0, 0, 140, 60), QPointF(10, 10)) + + assert (node.pos().x(), node.pos().y(), node.node_w, node.node_h) == expected + + @pytest.mark.parametrize("role,dx,dy,expected", [ + ("r", 60, 0, (10, 10, 200, 60)), + ("t", 0, 500, (10, 30, 140, 40)), + ("b", 0, -500, (10, 10, 140, 40)), + ("l", 500, 0, (110, 10, 40, 60)), + ]) + def test_an_image(self, app, role, dx, dy, expected): + from PySide6.QtCore import QRectF + + image = DiagramImage(x=10, y=10, w=140, h=60) + image._apply_resize(role, QPointF(dx, dy), QRectF(0, 0, 140, 60), QPointF(10, 10)) + + assert (image.pos().x(), image.pos().y(), image.img_w, image.img_h) == expected diff --git a/test/test_utils/test_diagram_editor_widget.py b/test/test_utils/test_diagram_editor_widget.py index 800846d4..91ecfb6f 100644 --- a/test/test_utils/test_diagram_editor_widget.py +++ b/test/test_utils/test_diagram_editor_widget.py @@ -19,7 +19,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def editor(app): from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget @@ -229,3 +229,90 @@ def refuse(*_args, **_kwargs): assert warned assert target.read_bytes() == b"the previous export" assert list(tmp_path.iterdir()) == [target], "a half-written file was left behind" + + +class TestMermaidImport: + @staticmethod + def _answer(monkeypatch, text: str, accepted: bool = True) -> None: + """Stub the paste dialog with what the user would have pasted, and Convert or Cancel.""" + from PySide6.QtWidgets import QDialog + + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + def exec_(dialog): + dialog._editor.setPlainText(text) + return QDialog.DialogCode.Accepted if accepted else QDialog.DialogCode.Rejected + + monkeypatch.setattr(diagram_editor_widget.MermaidImportDialog, "exec", exec_) + + @staticmethod + def _dialogs_left(editor) -> int: + from PySide6.QtCore import QCoreApplication, QEvent + + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import MermaidImportDialog + + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + return len(editor.findChildren(MermaidImportDialog)) + + def test_the_pasted_flowchart_becomes_the_diagram(self, editor, monkeypatch): + self._answer(monkeypatch, "flowchart TD\n A[Start] --> B[End]") + editor._import_mermaid() + assert sorted(n.text() for n in editor._scene.get_all_nodes()) == ["End", "Start"] + + def test_a_cancelled_import_leaves_the_canvas_alone(self, editor, monkeypatch): + editor._scene.load_from_dict(_A_DIAGRAM) + self._answer(monkeypatch, "flowchart TD\n A[Start] --> B[End]", accepted=False) + editor._import_mermaid() + assert [n.text() for n in editor._scene.get_all_nodes()] == ["A"] + + @pytest.mark.parametrize("accepted", [True, False]) + def test_the_dialog_goes_once_closed(self, editor, monkeypatch, accepted): + # It was a child of the editor, kept for good: one more per import + self._answer(monkeypatch, "flowchart TD\n A --> B", accepted=accepted) + for _ in range(3): + editor._import_mermaid() + assert self._dialogs_left(editor) == 0 + + +class TestFileDialogFilters: + """The dialogs' filters were English literals in every language.""" + + @staticmethod + def _filters(editor, monkeypatch) -> dict[str, str]: + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + asked: list[str] = [] + + def dialog(_parent, _title, _start, file_filter): + asked.append(file_filter) + return "", "" # cancelled + + for name in ("getOpenFileName", "getSaveFileName"): + monkeypatch.setattr(diagram_editor_widget.QFileDialog, name, staticmethod(dialog)) + seen: dict[str, str] = {} + for name, action in (("open", editor._open_diagram), ("save", editor._save_as_diagram), + ("png", editor._export_png), ("svg", editor._export_svg), + ("image", editor._add_image_from_file)): + action() + seen[name] = asked.pop() + return seen + + def test_they_speak_the_ide_language(self, editor, monkeypatch): + from je_editor import language_wrapper + + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict, + ) + monkeypatch.setattr(language_wrapper, "language_word_dict", pybreeze_traditional_chinese_word_dict) + filters = self._filters(editor, monkeypatch) + + assert "所有檔案 (*)" in filters["open"] and "所有檔案 (*)" in filters["image"] + assert filters["open"] == filters["save"] + assert all("Image" not in text and "Files" not in text for text in filters.values()), filters + + def test_the_image_filter_offers_every_suffix_a_diagram_keeps(self, editor, monkeypatch): + # .ico was allowed in a saved diagram but not offered by Add Image + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import IMAGE_SUFFIXES + + image_filter = self._filters(editor, monkeypatch)["image"] + assert all(f"*{suffix}" in image_filter for suffix in IMAGE_SUFFIXES) diff --git a/test/test_utils/test_diagram_images.py b/test/test_utils/test_diagram_images.py index 64eaa852..e134f7d9 100644 --- a/test/test_utils/test_diagram_images.py +++ b/test/test_utils/test_diagram_images.py @@ -50,7 +50,7 @@ def an_image() -> bytes: return bytes(data.data()) -@pytest.fixture() +@pytest.fixture def downloads(monkeypatch) -> list: """Record every fetch, and answer each with a real image.""" asked: list = [] @@ -161,7 +161,7 @@ def _wait_until(app, condition) -> None: time.sleep(0.01) -@pytest.fixture() +@pytest.fixture def slow_host(monkeypatch) -> threading.Event: """A host that answers with an image once the returned event is set.""" answering = threading.Event() @@ -353,3 +353,31 @@ def test_an_image_resized_down_and_back_is_as_sharp_as_before(app): item.set_size(400, 400) assert item._pix_item.pixmap().toImage() == before + + +class TestAnImageOnThisMachine: + """A saved diagram is untrusted: only an image file here, of a kind it may name, is read.""" + + @staticmethod + def _loaded(app, source: str, downloads: list) -> bool: + scene = DiagramScene() + scene.load_from_dict({"nodes": [], "connections": [], + "images": [{"x": 0, "y": 0, "w": 4, "h": 4, "source": source}]}) + app.processEvents() + loaded = not scene.get_all_images()[0]._pix_item.pixmap().isNull() + assert downloads == [] # a path is never fetched + return loaded + + def test_an_image_file_is_shown(self, app, tmp_path, downloads): + picture = tmp_path / "logo.png" + picture.write_bytes(an_image()) + assert self._loaded(app, str(picture), downloads) + + def test_a_file_of_another_kind_is_not_read(self, app, tmp_path, downloads): + # A PNG it is, but named .txt: the extension is checked before the file is touched + disguised = tmp_path / "notes.txt" + disguised.write_bytes(an_image()) + assert not self._loaded(app, str(disguised), downloads) + + def test_a_file_that_is_not_there_is_left_empty(self, app, tmp_path, downloads): + assert not self._loaded(app, str(tmp_path / "gone.png"), downloads) diff --git a/test/test_utils/test_diagram_property_panel.py b/test/test_utils/test_diagram_property_panel.py new file mode 100644 index 00000000..9ca40187 --- /dev/null +++ b/test/test_utils/test_diagram_property_panel.py @@ -0,0 +1,183 @@ +"""The property panel for a connection and an image: what it shows, and what an edit there does.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + ConnectionStyle, DiagramConnection, DiagramImage, DiagramNode, +) +from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture +def connection(app): + scene = DiagramScene() + source, target = DiagramNode(x=0, y=0, text="A"), DiagramNode(x=300, y=0, text="B") + scene.addItem(source) + scene.addItem(target) + edge = DiagramConnection(source, target, label="calls", line_width=2) + scene.addItem(edge) + panel = DiagramPropertyPanel(scene) + edge.setSelected(True) + yield scene, edge, panel + panel.deleteLater() + scene.deleteLater() + + +@pytest.fixture +def image(app): + scene = DiagramScene() + picture = DiagramImage(x=0, y=0, w=120, h=80) + scene.addItem(picture) + panel = DiagramPropertyPanel(scene) + picture.setSelected(True) + yield scene, picture, panel + panel.deleteLater() + scene.deleteLater() + + +def _edges(scene: DiagramScene) -> list[DiagramConnection]: + return [item for item in scene.items() if isinstance(item, DiagramConnection)] + + +class TestAConnection: + def test_selecting_it_shows_it_and_records_nothing(self, connection): + scene, _edge, panel = connection + + assert panel._conn_label.text() == "calls" + assert panel._conn_width.value() == 2 + assert not scene.undo_stack.canUndo() + + def test_its_style_colour_and_label_follow_the_panel(self, connection): + scene, edge, panel = connection + + panel._conn_style.setCurrentIndex(1) + panel._conn_color.set_color(QColor("#ff0000")) + panel._conn_color.color_changed.emit(QColor("#ff0000")) + panel._conn_label.setText("returns") + panel._conn_label.editingFinished.emit() + + assert edge._style == ConnectionStyle.DASHED + assert edge._line_color == QColor("#ff0000") + assert edge.edge_label() == "returns" + assert scene.undo_stack.count() == 3 + + def test_width_steps_are_one_undo_step(self, connection): + scene, _edge, panel = connection + + for width in (3, 4, 5): + panel._conn_width.setValue(width) + scene.undo_stack.undo() + + (restored,) = _edges(scene) + assert restored._line_width == 2 + assert not scene.undo_stack.canUndo() + + +class TestAnImage: + def test_selecting_it_shows_its_size(self, image): + scene, _picture, panel = image + + assert (panel._img_w.value(), panel._img_h.value()) == (120, 80) + assert not scene.undo_stack.canUndo() + + def test_a_width_edit_keeps_its_height_and_the_caption_follows(self, image): + _scene, picture, panel = image + + panel._img_w.setValue(200) + panel._img_caption.setText("logo") + panel._img_caption.editingFinished.emit() + + assert (picture.img_w, picture.img_h) == (200, 80) + assert picture.text() == "logo" + + +@pytest.fixture +def node(app): + scene = DiagramScene() + box = DiagramNode(x=0, y=0, w=140, h=60, text="A") + scene.addItem(box) + panel = DiagramPropertyPanel(scene) + box.setSelected(True) + yield scene, box, panel + panel.deleteLater() + scene.deleteLater() + + +class TestANode: + def test_its_text_shape_and_colours_follow_the_panel(self, node): + scene, box, panel = node + + panel._node_text.setText("Start") + panel._node_text.editingFinished.emit() + panel._node_shape.setCurrentIndex(3) + panel._node_fill.color_changed.emit(QColor("#00ff00")) + panel._node_border.color_changed.emit(QColor("#0000ff")) + + saved = box.to_dict(0) + assert (saved["text"], saved["shape"]) == ("Start", "DIAMOND") + assert (saved["fill_color"], saved["border_color"]) == ("#00ff00", "#0000ff") + assert scene.undo_stack.count() == 4 + + def test_leaving_the_text_box_unchanged_records_nothing(self, node): + # editingFinished comes with every focus change: a step for it would + # mark a saved diagram as changed + scene, _box, panel = node + + panel._node_text.editingFinished.emit() + + assert not scene.undo_stack.canUndo() + + def test_an_edit_with_nothing_selected_changes_nothing(self, node): + scene, box, panel = node + box.setSelected(False) + + panel._on_node_fill(QColor("#00ff00")) + panel._on_conn_label() + panel._on_img_caption() + + assert box.to_dict(0)["fill_color"] != "#00ff00" + assert not scene.undo_stack.canUndo() + + +class TestTheColourButton: + def test_a_colour_picked_is_shown_and_passed_on(self, node, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_property_panel + + _scene, box, panel = node + monkeypatch.setattr(diagram_property_panel.QColorDialog, "getColor", + staticmethod(lambda *args: QColor("#123456"))) + + panel._node_fill._pick() + + assert panel._node_fill.color() == QColor("#123456") + assert panel._node_fill.text() == "#123456" + assert box.to_dict(0)["fill_color"] == "#123456" + + def test_a_cancelled_dialog_changes_nothing(self, node, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_property_panel + + scene, _box, panel = node + before = panel._node_fill.color() + monkeypatch.setattr(diagram_property_panel.QColorDialog, "getColor", + staticmethod(lambda *args: QColor())) # what Cancel returns + + panel._node_fill._pick() + + assert panel._node_fill.color() == before + assert not scene.undo_stack.canUndo() diff --git a/test/test_utils/test_diagram_scene_actions.py b/test/test_utils/test_diagram_scene_actions.py new file mode 100644 index 00000000..8afffdc1 --- /dev/null +++ b/test/test_utils/test_diagram_scene_actions.py @@ -0,0 +1,192 @@ +"""What the diagram canvas does with clicks and keys: drawing a connection, Delete, Esc, Select All. + +The align commands and horizontal distribution are in test_diagram_align.py. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QEvent, QPointF, Qt +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QApplication, QGraphicsLineItem, QGraphicsSceneMouseEvent + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramConnection, DiagramImage, DiagramNode +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ToolMode + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture +def scene(app): + diagram = DiagramScene() + yield diagram + diagram.deleteLater() + + +def _two_nodes(scene: DiagramScene) -> tuple[DiagramNode, DiagramNode]: + first = DiagramNode(x=0, y=0, w=100, h=60, text="A") + second = DiagramNode(x=300, y=0, w=100, h=60, text="B") + scene.addItem(first) + scene.addItem(second) + return first, second + + +def _click(scene: DiagramScene, x: float, y: float) -> None: + press = QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMousePress) + press.setScenePos(QPointF(x, y)) + press.setButton(Qt.MouseButton.LeftButton) + press.setButtons(Qt.MouseButton.LeftButton) + scene.mousePressEvent(press) + + +def _press(scene: DiagramScene, key: Qt.Key) -> None: + scene.keyPressEvent(QKeyEvent(QEvent.Type.KeyPress, key, Qt.KeyboardModifier.NoModifier)) + + +def _dashed_lines(scene: DiagramScene) -> list: + return [item for item in scene.items() if isinstance(item, QGraphicsLineItem)] + + +class TestDrawingAConnection: + def test_the_source_then_the_target_makes_one_undoable_connection(self, scene): + first, second = _two_nodes(scene) + scene.mode = ToolMode.ADD_CONNECTION + + _click(scene, 50, 30) + assert len(_dashed_lines(scene)) == 1 # the line that follows the mouse + _click(scene, 350, 30) + + (connection,) = scene.get_all_connections() + assert (connection.source, connection.target) == (first, second) + assert _dashed_lines(scene) == [] + assert scene.mode == ToolMode.SELECT + scene.undo_stack.undo() + assert scene.get_all_connections() == [] + + def test_a_click_on_the_empty_canvas_cancels_it(self, scene): + _two_nodes(scene) + scene.mode = ToolMode.ADD_CONNECTION + + _click(scene, 50, 30) + _click(scene, 200, 300) + + assert scene.get_all_connections() == [] + assert _dashed_lines(scene) == [] + + def test_a_node_is_not_connected_to_itself(self, scene): + _two_nodes(scene) + scene.mode = ToolMode.ADD_CONNECTION + + _click(scene, 50, 30) + _click(scene, 60, 40) + + assert scene.get_all_connections() == [] + assert scene.mode == ToolMode.SELECT + + def test_esc_cancels_it_and_goes_back_to_select(self, scene): + _two_nodes(scene) + scene.mode = ToolMode.ADD_CONNECTION + _click(scene, 50, 30) + + _press(scene, Qt.Key.Key_Escape) + _click(scene, 350, 30) + + assert scene.get_all_connections() == [] + assert _dashed_lines(scene) == [] + assert scene.mode == ToolMode.SELECT + + +class TestDelete: + @pytest.mark.parametrize("key", [Qt.Key.Key_Delete, Qt.Key.Key_Backspace]) + def test_a_node_goes_with_its_connections_in_one_undo_step(self, scene, key): + first, second = _two_nodes(scene) + with scene.undo_scope("Connect"): + scene.addItem(DiagramConnection(first, second)) + first.setSelected(True) + + _press(scene, key) + + assert [node.text() for node in scene.get_all_nodes()] == ["B"] + assert scene.get_all_connections() == [] + scene.undo_stack.undo() + assert sorted(node.text() for node in scene.get_all_nodes()) == ["A", "B"] + assert len(scene.get_all_connections()) == 1 + + def test_a_connection_alone_leaves_its_nodes(self, scene): + first, second = _two_nodes(scene) + connection = DiagramConnection(first, second) + scene.addItem(connection) + connection.setSelected(True) + + scene.delete_selected() + + assert scene.get_all_connections() == [] + assert len(scene.get_all_nodes()) == 2 + assert first.connections == [] and second.connections == [] + + def test_nothing_selected_leaves_no_undo_step(self, scene): + _two_nodes(scene) + + scene.delete_selected() + + assert not scene.undo_stack.canUndo() + + def test_while_a_node_is_being_edited_the_key_edits_its_text(self, app): + # Delete removed the node being typed into + from PySide6.QtWidgets import QGraphicsView + + scene = DiagramScene() + view = QGraphicsView(scene) + view.show() + node, _second = _two_nodes(scene) + node.setSelected(True) + node.label.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction) + node.label.setFocus() + app.processEvents() + + _press(scene, Qt.Key.Key_Delete) + + assert node in scene.get_all_nodes() + view.close() + view.deleteLater() + + +class TestSelectAll: + def test_every_node_connection_and_image_but_not_the_dashed_line(self, scene): + first, second = _two_nodes(scene) + scene.addItem(DiagramConnection(first, second)) + scene.addItem(DiagramImage(x=0, y=200, w=50, h=50)) + scene.mode = ToolMode.ADD_CONNECTION + _click(scene, 50, 30) # a connection being drawn + + scene.select_all() + + assert len(scene.selectedItems()) == 4 + assert not any(isinstance(item, QGraphicsLineItem) for item in scene.selectedItems()) + + +class TestDistributeVertically: + def test_equal_gaps_between_the_outer_two(self, scene): + boxes = ((0, 0, 100, 40), (10, 70, 100, 20), (20, 400, 100, 60)) + nodes = [] + for x, y, w, h in boxes: + node = DiagramNode(x=x, y=y, w=w, h=h, text="n") + scene.addItem(node) + node.setSelected(True) + nodes.append(node) + + scene.distribute_v() + + tops = [node.pos().y() for node in nodes] + assert tops[0] == 0 and tops[2] == 400 + assert tops[1] - 40 == pytest.approx(400 - (tops[1] + 20)) + assert [node.pos().x() for node in nodes] == [0, 10, 20] diff --git a/test/test_utils/test_diagram_view_pan.py b/test/test_utils/test_diagram_view_pan.py new file mode 100644 index 00000000..b7fe6d68 --- /dev/null +++ b/test/test_utils/test_diagram_view_pan.py @@ -0,0 +1,159 @@ +"""Panning the diagram canvas with the right or middle button, and the menu a right click opens.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QContextMenuEvent, QGuiApplication, QMouseEvent +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.diagram_editor import diagram_scene as scene_module +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene +from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView + +_NO_KEYS = Qt.KeyboardModifier.NoModifier + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture +def menus(monkeypatch) -> list: + """Every canvas menu that opens, recorded instead of shown.""" + opened: list = [] + + class Menu: + def __init__(self, *_args) -> None: + self.entries: list = [] + + def addAction(self, *entry) -> None: + self.entries.append(entry) + + def addSeparator(self) -> None: + """Separators are not choices.""" + + def actions(self) -> list: + return self.entries + + def exec(self, *_args) -> None: + opened.append(self) + + monkeypatch.setattr(scene_module, "QMenu", Menu) + return opened + + +@pytest.fixture +def menu_on_release(app): + """Open context menus as Windows does, on the right button's release.""" + hints = QGuiApplication.styleHints() + before = hints.contextMenuTrigger() + hints.setContextMenuTrigger(Qt.ContextMenuTrigger.Release) + yield + hints.setContextMenuTrigger(before) + + +@pytest.fixture +def menu_on_press(app): + """Open context menus as Linux and macOS do, on the right button's press.""" + hints = QGuiApplication.styleHints() + before = hints.contextMenuTrigger() + hints.setContextMenuTrigger(Qt.ContextMenuTrigger.Press) + yield + hints.setContextMenuTrigger(before) + + +@pytest.fixture +def view(app): + scene = DiagramScene() + scene.setSceneRect(0, 0, 3000, 3000) + shown = DiagramView(scene) + shown.resize(400, 300) + shown.show() + assert QTest.qWaitForWindowExposed(shown) + yield shown + shown.close() + shown.deleteLater() + + +def _at(view: DiagramView, x: int, y: int) -> QPoint: + """A point on the canvas, in the window's coordinates.""" + return view.viewport().mapTo(view.window(), QPoint(x, y)) + + +def _drag(view: DiagramView, button: Qt.MouseButton, start: tuple, end: tuple) -> None: + # Through the window, as the platform delivers them: that is where Qt + # turns a right button into a context menu + window = view.windowHandle() + QTest.mousePress(window, button, _NO_KEYS, _at(view, *start)) + QTest.mouseMove(window, _at(view, (start[0] + end[0]) // 2, (start[1] + end[1]) // 2)) + QTest.mouseMove(window, _at(view, *end)) + QTest.mouseRelease(window, button, _NO_KEYS, _at(view, *end)) + QApplication.processEvents() + + +def test_a_right_drag_pans_and_opens_no_menu(view, menus, menu_on_release): + left = view.horizontalScrollBar().value() + + _drag(view, Qt.MouseButton.RightButton, (200, 150), (100, 100)) + + assert view.horizontalScrollBar().value() == left + 100 + # Every right-drag used to end in the canvas menu + assert menus == [] + + +def test_a_right_click_still_opens_the_menu(view, menus, menu_on_release): + _drag(view, Qt.MouseButton.RightButton, (200, 150), (200, 150)) + + assert len(menus) == 1 + + +def test_a_right_click_after_a_right_drag_opens_the_menu(view, menus, menu_on_release): + _drag(view, Qt.MouseButton.RightButton, (200, 150), (100, 100)) + _drag(view, Qt.MouseButton.RightButton, (50, 50), (50, 50)) + + assert len(menus) == 1 + + +def test_a_middle_drag_pans(view, menus, menu_on_release): + top = view.verticalScrollBar().value() + + _drag(view, Qt.MouseButton.MiddleButton, (200, 150), (200, 90)) + + assert view.verticalScrollBar().value() == top + 60 + assert menus == [] + + +def test_the_menu_key_after_a_right_drag_opens_the_menu(view, menus, menu_on_release): + _drag(view, Qt.MouseButton.RightButton, (200, 150), (100, 100)) + + at = QPoint(50, 50) + key = QContextMenuEvent(QContextMenuEvent.Reason.Keyboard, at, view.viewport().mapToGlobal(at)) + QApplication.sendEvent(view.viewport(), key) + + assert len(menus) == 1 + + +def test_a_pan_whose_release_went_elsewhere_stops_with_the_next_move(view, menus, menu_on_press): + # A menu opened on the press (Linux, macOS) takes the release, and the + # canvas went on following a mouse with no button held + window = view.windowHandle() + QTest.mousePress(window, Qt.MouseButton.RightButton, _NO_KEYS, _at(view, 200, 150)) + assert len(menus) == 1 + left = view.horizontalScrollBar().value() + + move = QMouseEvent( + QEvent.Type.MouseMove, QPointF(100, 150), QPointF(view.viewport().mapToGlobal(QPoint(100, 150))), + Qt.MouseButton.NoButton, Qt.MouseButton.NoButton, _NO_KEYS) + QApplication.sendEvent(view.viewport(), move) + + assert view.horizontalScrollBar().value() == left + QTest.mouseRelease(window, Qt.MouseButton.RightButton, _NO_KEYS, _at(view, 100, 150)) diff --git a/test/test_utils/test_diagram_view_zoom_grid.py b/test/test_utils/test_diagram_view_zoom_grid.py new file mode 100644 index 00000000..581efdce --- /dev/null +++ b/test/test_utils/test_diagram_view_zoom_grid.py @@ -0,0 +1,96 @@ +"""The diagram canvas's zoom (Ctrl+0) and the background grid it draws. + +Fit is covered by test_diagram_editing.py. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QRectF +from PySide6.QtGui import QImage, QPainter +from PySide6.QtWidgets import QApplication + +from pybreeze.pybreeze_ui.diagram_editor import diagram_view as view_module +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene +from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def view(app): + made = DiagramView(DiagramScene()) + made.resize(400, 300) + yield made + made.deleteLater() + + +def _zoom_reported(view) -> list[int]: + reported: list[int] = [] + view.zoom_changed.connect(reported.append) + return reported + + +class TestSetZoom: + @pytest.mark.parametrize("percent,scale", [(100, 1.0), (250, 2.5), (1, 0.1), (900, 5.0)]) + def test_the_zoom_is_set_within_the_range(self, view, percent, scale): + reported = _zoom_reported(view) + view.set_zoom(150) # from somewhere else, not added to it + + view.set_zoom(percent) + + assert view.transform().m11() == pytest.approx(scale) + assert reported[-1] == int(scale * 100) + + +class TestTheGrid: + def test_it_is_off_until_turned_on(self, view): + assert view.draw_grid is False + view.draw_grid = True + assert view.draw_grid is True + + def test_a_cell_is_never_smaller_than_five(self, view): + view.grid_size = 2 + assert view.grid_size == 5 + view.grid_size = 30 + assert view.grid_size == 30 + + @staticmethod + def _background(view, left: float, top: float) -> QImage: + """The background drawn for the 100 x 100 scene area at (*left*, *top*), as an image.""" + image = QImage(100, 100, QImage.Format.Format_RGB32) + painter = QPainter(image) + painter.translate(-left, -top) + view.drawBackground(painter, QRectF(left, top, 100, 100)) + painter.end() + return image + + def test_without_it_the_background_is_plain(self, view): + image = self._background(view, 0, 0) + + assert {image.pixelColor(x, 50).name() for x in range(100)} == {view_module._BG_COLOR.name()} + + def test_lines_fall_on_multiples_of_the_cell_size(self, view): + view.draw_grid = True + image = self._background(view, 0, 0) + + background = view_module._BG_COLOR.name() + assert image.pixelColor(0, 50).name() == view_module._GRID_COLOR_MAJOR.name() # every fifth line + assert image.pixelColor(20, 50).name() != background # a minor line, drawn thinner + assert image.pixelColor(10, 50).name() == background + + def test_left_of_the_origin_too(self, view): + # Scene x -30 to 70: lines at -20, 0, 20..., which are image columns 10, 30, 50... + view.draw_grid = True + image = self._background(view, -30, 0) + + background = view_module._BG_COLOR.name() + assert image.pixelColor(10, 50).name() != background + assert image.pixelColor(30, 50).name() == view_module._GRID_COLOR_MAJOR.name() + assert image.pixelColor(20, 50).name() == background diff --git a/test/test_utils/test_diff_gui.py b/test/test_utils/test_diff_gui.py index 42df3fe3..db138e07 100644 --- a/test/test_utils/test_diff_gui.py +++ b/test/test_utils/test_diff_gui.py @@ -18,7 +18,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI gui = DiffGUI() @@ -68,7 +68,7 @@ def test_copy_output(self, app, widget): widget.left_edit.setPlainText("a") widget.right_edit.setPlainText("b") _compare(widget) - widget.actions.copy() + widget.output_actions.copy() assert QApplication.clipboard().text() != "" def test_build_summary_line_identical(self, app): @@ -114,3 +114,40 @@ def test_a_second_compare_while_one_runs_is_ignored(self, widget, monkeypatch): release.set() _compare(widget, start=False) assert "+b" in widget.output_edit.toPlainText() + + +class TestTheColours: + @pytest.mark.parametrize(("line", "number", "key"), [ + ("--- expected", 0, None), + ("+++ actual", 1, None), + ("@@ -1,2 +1,2 @@", 2, "syntax_keyword_color"), + ("+added", 5, "diff_added_marker_color"), + ("-removed", 5, "diff_removed_marker_color"), + ("---x", 7, "diff_removed_marker_color"), # a removed "--x", not a header + ("+++x", 7, "diff_added_marker_color"), + (" unchanged", 5, None), + ("\\ No newline at end of file", 6, "blame_annotation_color"), + ("", 5, None), + ]) + def test_each_kind_of_line_has_its_theme_colour(self, line, number, key): + from pybreeze.pybreeze_ui.tools_gui.diff_gui import diff_line_colour + + assert diff_line_colour(line, number) == key + + def test_the_diff_is_shown_in_colour(self, widget): + # It was all one colour: added and removed lines had to be read by their sign + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict + + widget.left_edit.setPlainText("a\nb") + widget.right_edit.setPlainText("a\nc") + _compare(widget) + + colours = {} + block = widget.output_edit.document().begin() + while block.isValid(): + ranges = block.layout().formats() + colours[block.text()] = ranges[0].format.foreground().color() if ranges else None + block = block.next() + assert colours["-b"] == actually_color_dict["diff_removed_marker_color"] + assert colours["+c"] == actually_color_dict["diff_added_marker_color"] + assert colours[" a"] is None diff --git a/test/test_utils/test_dock_menu.py b/test/test_utils/test_dock_menu.py new file mode 100644 index 00000000..bd8b502f --- /dev/null +++ b/test/test_utils/test_dock_menu.py @@ -0,0 +1,49 @@ +"""PyBreeze's docks in JEditor's Dock menu: the AI ones join JEditor's AI submenu.""" +from __future__ import annotations + +import os +from types import SimpleNamespace + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMenu + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.tools.tools_menu import extend_dock_menu + +_AI_DOCKS = 5 # AI Code Review, CoT Prompt Editor, CoT Code Review, Skill Prompt Editor, Skill Send + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _submenus(menu: QMenu) -> list[str]: + return [action.text() for action in menu.actions() if action.menu() is not None] + + +def test_the_ai_docks_join_jeditors_ai_submenu(app): + dock_menu = QMenu() + jeditor_ai = dock_menu.addMenu("AI") + jeditor_ai.addAction("Chat UI") + window = SimpleNamespace(dock_menu=dock_menu, dock_ai_menu=jeditor_ai) + + extend_dock_menu(window) + + assert window.dock_ai_menu is jeditor_ai + assert _submenus(dock_menu).count("AI") == 1 + assert len(jeditor_ai.actions()) == 1 + _AI_DOCKS + + +def test_without_one_they_get_their_own(app): + dock_menu = QMenu() + window = SimpleNamespace(dock_menu=dock_menu) + + extend_dock_menu(window) + + assert "AI" in _submenus(dock_menu) + assert len(window.dock_ai_menu.actions()) == _AI_DOCKS diff --git a/test/test_utils/test_docs_markup.py b/test/test_utils/test_docs_markup.py new file mode 100644 index 00000000..b3a7e9be --- /dev/null +++ b/test/test_utils/test_docs_markup.py @@ -0,0 +1,35 @@ +"""The Sphinx pages' inline markup renders, in Chinese as in English. + +reStructuredText ends ``**strong**`` or ````literal```` only before whitespace or +certain punctuation, and starts one only after them. Chinese runs on without +spaces, so ``**程式碼輸入區**(左面板)`` was published with its asterisks showing. +An escaped space (backslash, space) between them renders as nothing. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +docutils_core = pytest.importorskip("docutils.core") +from docutils import nodes # noqa: E402 — docutils may be missing: skipped then + +_SOURCE = Path(__file__).resolve().parents[2] / "docs" / "source" + + +def _inline_markup_problems(path: Path) -> list[str]: + document = docutils_core.publish_doctree( + path.read_text(encoding="utf-8"), source_path=str(path), + settings_overrides={"report_level": 5, "halt_level": 5, "warning_stream": False}) + # Only the inline markup: the Sphinx directives (toctree) are unknown to plain docutils + return [message.astext() for message in document.findall(nodes.system_message) + if ") Inline " in message.astext()] + + +def test_there_are_pages_to_check(): + assert len(list(_SOURCE.rglob("*.rst"))) > 20 + + +@pytest.mark.parametrize("page", sorted(_SOURCE.rglob("*.rst")), ids=lambda page: str(page.relative_to(_SOURCE))) +def test_the_inline_markup_of_each_page_closes(page): + assert _inline_markup_problems(page) == [] diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py index bec3a280..a2d11802 100644 --- a/test/test_utils/test_error_text.py +++ b/test/test_utils/test_error_text.py @@ -25,7 +25,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def chinese(monkeypatch): monkeypatch.setattr(error_text_mod.language_wrapper, "language_word_dict", CHINESE) diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 5de4c68a..b0f59173 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +import time from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -33,7 +34,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def tree(app, tmp_path): """A tree view rooted at *tmp_path*, as the project tree would be.""" view = QTreeView() @@ -60,7 +61,28 @@ def confirm(monkeypatch, yes: bool) -> None: ctx.QMessageBox, "question", staticmethod(lambda *a, **k: button)) -@pytest.fixture() +# Before the stand-in below replaces it in every test +_REAL_MOVE_TO_TRASH = ctx._move_to_trash + + +@pytest.fixture(autouse=True) +def trash(tmp_path_factory, monkeypatch): + """A trash of the test's own: a delete must not fill the machine's Recycle Bin.""" + import shutil + + bin_folder = tmp_path_factory.mktemp("trash") + trashed: list[Path] = [] + + def move_to_trash(path: Path) -> bool: + trashed.append(path) + shutil.move(str(path), str(bin_folder / f"{len(trashed)}_{path.name}")) + return True + + monkeypatch.setattr(ctx, "_move_to_trash", move_to_trash) + return trashed + + +@pytest.fixture def warnings(monkeypatch): """Collect the warning dialogs an action raises instead of showing them.""" shown: list[str] = [] @@ -347,7 +369,9 @@ def under(_window, path): def test_a_delete_that_fails_keeps_the_tab_open(self, tree, tmp_path, monkeypatch): # The tabs used to close before the delete ran, so a locked file stayed - # on disk while its tab, and any unsaved edits in it, were gone. + # on disk while its tab, and any unsaved edits in it, were gone. Where + # there is no trash, the file is deleted for good, and that can fail. + monkeypatch.setattr(ctx, "_move_to_trash", lambda _path: False) target = tmp_path / "locked.py" target.touch() window = FakeWindow() @@ -374,6 +398,58 @@ def refuse(self, *args, **kwargs): assert restarted == [str(target)] +class TestDeletingToTheTrash: + """A delete from the tree was for good: it goes to the trash, as a file manager does.""" + + def test_a_file_goes_to_the_trash(self, tree, tmp_path, monkeypatch, trash): + target = tmp_path / "notes.py" + target.write_text("keep a copy", encoding="utf-8") + confirm(monkeypatch, yes=True) + + _action_delete(tree, FakeWindow(), target) + + assert trash == [target] + assert not target.exists() + + def test_a_folder_goes_to_the_trash_whole(self, tree, tmp_path, monkeypatch, trash): + folder = tmp_path / "pkg" + folder.mkdir() + (folder / "inner.py").touch() + confirm(monkeypatch, yes=True) + + _action_delete(tree, FakeWindow(), folder) + + assert trash == [folder] + + @pytest.mark.parametrize("delete_for_good", [True, False]) + def test_without_a_trash_it_asks_before_deleting_for_good(self, tree, tmp_path, monkeypatch, delete_for_good): + monkeypatch.setattr(ctx, "_move_to_trash", lambda _path: False) + target = tmp_path / "notes.py" + target.touch() + asked: list = [] + + def question(_parent, _title, text, _buttons, default): + asked.append((text, default)) + answer_now = delete_for_good or len(asked) == 1 # yes to the first question, then the choice + return QMessageBox.StandardButton.Yes if answer_now else QMessageBox.StandardButton.No + + monkeypatch.setattr(ctx.QMessageBox, "question", staticmethod(question)) + + _action_delete(tree, FakeWindow(), target) + + assert len(asked) == 2 + assert asked[1][1] == QMessageBox.StandardButton.No # for good is not the default + assert target.exists() is not delete_for_good + + def test_the_trash_is_the_systems(self, tmp_path, monkeypatch): + # The stand-in replaces _move_to_trash; the real one asks Qt for the system's trash + called: list = [] + monkeypatch.setattr(ctx.QFile, "moveToTrash", staticmethod(lambda name: called.append(name) or True)) + + assert _REAL_MOVE_TO_TRASH(tmp_path / "x.py") is True + assert called == [str(tmp_path / "x.py")] + + class TestCopyingThePath: def test_the_absolute_path_reaches_the_clipboard(self, tree, tmp_path): target = tmp_path / "module.py" @@ -435,6 +511,64 @@ def test_setup_leaves_later_tabs_working(self, app): placeholder.deleteLater() +class TestTheKeys: + """F2 renames and Delete deletes the entry in focus, as in a file manager: only the menu did.""" + + @staticmethod + def _shortcut(tree, keys: str): + from PySide6.QtGui import QKeySequence, QShortcut + + found = [shortcut for shortcut in tree.findChildren(QShortcut) if shortcut.key() == QKeySequence(keys)] + assert len(found) == 1, keys + assert found[0].context() == Qt.ShortcutContext.WidgetShortcut # only while the tree has the focus + return found[0] + + @pytest.mark.parametrize(("keys", "action"), [("F2", "_action_rename"), ("Del", "_action_delete")]) + def test_the_key_acts_on_the_current_entry(self, tree, tmp_path, monkeypatch, keys, action): + target = tmp_path / "notes.py" + target.touch() + asked: list = [] + monkeypatch.setattr(ctx, action, lambda _tree, _window, path: asked.append(path)) + _attach_context_menu(tree, FakeWindow()) + tree.setCurrentIndex(tree.model().index(str(target))) + + self._shortcut(tree, keys).activated.emit() + + assert asked == [target] + + @pytest.mark.parametrize(("key", "action"), [(Qt.Key.Key_F2, "_action_rename"), + (Qt.Key.Key_Delete, "_action_delete")]) + def test_a_key_pressed_in_the_tree_reaches_it(self, tree, tmp_path, monkeypatch, key, action): + # The view handles keys of its own (F2 starts an edit): the shortcut must still get them + from PySide6.QtTest import QTest + + target = tmp_path / "notes.py" + target.touch() + asked: list = [] + monkeypatch.setattr(ctx, action, lambda _tree, _window, path: asked.append(path)) + _attach_context_menu(tree, FakeWindow()) + tree.show() + tree.activateWindow() + tree.setFocus() + # The model lists the folder in the background, as it does for a user who then picks a file + deadline = time.monotonic() + 10 + while tree.model().rowCount(tree.rootIndex()) == 0 and time.monotonic() < deadline: + QApplication.processEvents() + tree.setCurrentIndex(tree.model().index(str(target))) + QApplication.processEvents() + + QTest.keyClick(tree, key) + + assert asked == [target] + tree.close() + + def test_attaching_twice_adds_the_keys_once(self, tree): + window = FakeWindow() + _attach_context_menu(tree, window) + _attach_context_menu(tree, window) + self._shortcut(tree, "F2") + + class TestANameStaysInItsFolder: """A drive, a root, '..' or ':' put the file elsewhere: /tmp/notes.py became C:\\tmp\\notes.py.""" @@ -562,3 +696,60 @@ def missing(_command): ctx._action_reveal_in_explorer(tree, tmp_path) assert len(warnings) == 1 and "xdg-open" in warnings[0] + + +class TestTheMenuEntries: + """Each entry of the right-click menu does its own action, and those that need an item wait for one.""" + + _HANDLERS = ("_action_new_file", "_action_new_folder", "_action_rename", "_action_delete", + "_action_copy_path", "_action_reveal_in_explorer") + + @staticmethod + def _open(tree, monkeypatch, *, under_the_cursor: Path | None, choose: str | None) -> tuple[list, dict]: + """Open the menu over *under_the_cursor* and pick the entry reading *choose*.""" + from PySide6.QtWidgets import QMenu + + calls: list = [] + enabled: dict[str, bool] = {} + + class ChoosingMenu(QMenu): + def exec(self, *args): + enabled.update({action.text(): action.isEnabled() for action in self.actions() if action.text()}) + return next((action for action in self.actions() if action.text() == choose), None) + + monkeypatch.setattr(ctx, "QMenu", ChoosingMenu) + monkeypatch.setattr(ctx, "_get_path_from_index", lambda _tree, _index: under_the_cursor) + for name in TestTheMenuEntries._HANDLERS: + monkeypatch.setattr(ctx, name, lambda *args, _name=name, **kwargs: calls.append((_name, args, kwargs))) + ctx._show_context_menu(QPoint(1, 1), tree, "the window") + return calls, enabled + + @pytest.mark.parametrize("key, handler, arguments, keywords", [ + ("file_tree_ctx_new_file", "_action_new_file", ("path",), {}), + ("file_tree_ctx_new_folder", "_action_new_folder", ("path",), {}), + ("file_tree_ctx_rename", "_action_rename", ("window", "path"), {}), + ("file_tree_ctx_delete", "_action_delete", ("window", "path"), {}), + ("file_tree_ctx_copy_path", "_action_copy_path", ("path",), {"relative": False}), + ("file_tree_ctx_copy_relative_path", "_action_copy_path", ("path",), {"relative": True}), + ("file_tree_ctx_reveal_in_explorer", "_action_reveal_in_explorer", ("path",), {}), + ]) + def test_an_entry_does_its_own_action(self, tree, tmp_path, monkeypatch, key, handler, arguments, keywords): + from je_editor import language_wrapper + + item = tmp_path / "a.py" + calls, _enabled = self._open(tree, monkeypatch, under_the_cursor=item, + choose=language_wrapper.language_word_dict.get(key)) + + given = {"path": item, "window": "the window"} + assert calls == [(handler, (tree, *(given[name] for name in arguments)), keywords)] + + def test_on_empty_space_only_new_entries_can_be_chosen(self, tree, monkeypatch): + from je_editor import language_wrapper + + words = language_wrapper.language_word_dict + calls, enabled = self._open(tree, monkeypatch, under_the_cursor=None, choose=None) + + assert calls == [] + assert {text for text, on in enabled.items() if on} == { + words.get("file_tree_ctx_new_file"), words.get("file_tree_ctx_new_folder")} + assert len(enabled) == 7 diff --git a/test/test_utils/test_gui_thread_gc.py b/test/test_utils/test_gui_thread_gc.py index bef3874e..6b64edcc 100644 --- a/test/test_utils/test_gui_thread_gc.py +++ b/test/test_utils/test_gui_thread_gc.py @@ -14,7 +14,7 @@ from pybreeze.pybreeze_ui.gui_thread_gc import GuiThreadGarbageCollector -@pytest.fixture() +@pytest.fixture def collector(): was_enabled = gc.isenabled() made = GuiThreadGarbageCollector() diff --git a/test/test_utils/test_har_import_gui.py b/test/test_utils/test_har_import_gui.py index 2ad9d2c2..68af0712 100644 --- a/test/test_utils/test_har_import_gui.py +++ b/test/test_utils/test_har_import_gui.py @@ -46,7 +46,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.har_import_gui import HarImportGUI gui = HarImportGUI() @@ -55,7 +55,7 @@ def widget(app): gui.deleteLater() -@pytest.fixture() +@pytest.fixture def loaded(widget): widget.load_text(_HAR) return widget @@ -130,17 +130,17 @@ def test_action_target_writes_one_action_list(self, loaded): def test_suggested_filename_follows_the_target(self, loaded): self._select_target(loaded, "apitestka_action") - assert loaded.actions.suggested_filename() == "actions.json" + assert loaded.output_actions.suggested_filename() == "actions.json" self._select_target(loaded, "pytest") - assert loaded.actions.suggested_filename() == "session.py" + assert loaded.output_actions.suggested_filename() == "session.py" def test_copy_output(self, app, loaded): loaded.generate_all() - loaded.actions.copy() + loaded.output_actions.copy() assert "/v1/items" in QApplication.clipboard().text() def test_save_before_generating_is_noop(self, loaded): - assert loaded.actions.save_to_file() is None + assert loaded.output_actions.save_to_file() is None class TestHarImportFileDialog: @@ -231,7 +231,7 @@ def test_loading_another_file_clears_the_previous_script(self, loaded): assert loaded.load_text(other) assert loaded.output_edit.toPlainText() == "" - assert not loaded.actions._has_output() + assert not loaded.output_actions._has_output() def test_a_file_that_cannot_be_read_unlists_the_previous_one(self, loaded, tmp_path): with patch( @@ -243,7 +243,7 @@ def test_a_file_that_cannot_be_read_unlists_the_previous_one(self, loaded, tmp_p assert loaded.entry_list.count() == 0 loaded.generate_all() assert "/v1/items" not in loaded.output_edit.toPlainText() - assert not loaded.actions._has_output() + assert not loaded.output_actions._has_output() def test_choosing_another_target_generates_again(self, loaded): loaded.generate_all() @@ -251,7 +251,7 @@ def test_choosing_another_target_generates_again(self, loaded): loaded.target_select.setCurrentIndex(loaded.target_select.findData("apitestka_action")) assert len(json.loads(loaded.output_edit.toPlainText())) == 2 - assert loaded.actions.suggested_filename() == "actions.json" + assert loaded.output_actions.suggested_filename() == "actions.json" def test_choosing_a_target_before_generating_generates_nothing(self, loaded): loaded.target_select.setCurrentIndex(loaded.target_select.findData("pytest")) @@ -271,8 +271,8 @@ def test_going_back_from_a_target_that_failed_generates_again(self, widget): requests_script = widget.output_edit.toPlainText() widget.target_select.setCurrentIndex(widget.target_select.findData("apitestka_action")) - assert not widget.actions._has_output() + assert not widget.output_actions._has_output() widget.target_select.setCurrentIndex(widget.target_select.findData("requests")) assert widget.output_edit.toPlainText() == requests_script - assert widget.actions._has_output() + assert widget.output_actions._has_output() diff --git a/test/test_utils/test_hash_gui.py b/test/test_utils/test_hash_gui.py index a1c09348..a2eabcfd 100644 --- a/test/test_utils/test_hash_gui.py +++ b/test/test_utils/test_hash_gui.py @@ -19,7 +19,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI gui = HashGUI() @@ -46,7 +46,7 @@ def test_empty_input_still_hashes(self, widget): def test_copy_output(self, app, widget): widget.input_edit.setPlainText("hello") widget.compute() - widget.actions.copy() + widget.output_actions.copy() assert hashlib.sha256(b"hello").hexdigest() in QApplication.clipboard().text() def test_build_hash_text_formats_lines(self): diff --git a/test/test_utils/test_header_analyzer_gui.py b/test/test_utils/test_header_analyzer_gui.py index 6b4b9451..1fbc4ce4 100644 --- a/test/test_utils/test_header_analyzer_gui.py +++ b/test/test_utils/test_header_analyzer_gui.py @@ -27,7 +27,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI gui = HeaderAnalyzerGUI() @@ -86,13 +86,13 @@ def test_initial_headers_are_analysed_on_open(self, app): def test_copy_output(self, app, widget): widget.input_edit.setPlainText(_RESPONSE) widget.analyze() - widget.actions.copy() + widget.output_actions.copy() assert "nginx/1.25.3" in QApplication.clipboard().text() def test_save_after_no_headers_is_noop(self, widget): widget.input_edit.setPlainText("just some prose") widget.analyze() - assert widget.actions.save_to_file() is None + assert widget.output_actions.save_to_file() is None class _FakeTabWidget: diff --git a/test/test_utils/test_http_client.py b/test/test_utils/test_http_client.py index d83a3722..c02e5cc1 100644 --- a/test/test_utils/test_http_client.py +++ b/test/test_utils/test_http_client.py @@ -188,3 +188,54 @@ def test_the_deadline_reads_as_a_timeout_to_the_user(self): from pybreeze.utils.network.http_client import describe_request_error assert "timed out" in describe_request_error(requests.exceptions.ReadTimeout("slow")) + + +class TestWhatTheReadSkipsAndPassesOn: + def test_empty_chunks_are_skipped(self): + # A chunked or kept-alive stream can hand back an empty chunk between real ones + class Gappy(FakeResponse): + def iter_content(self, chunk_size: int = 65536): + yield from (b"", b"ab", b"", b"c") + + assert read_capped_text(Gappy(b""), max_bytes=10) == "abc" + + def test_an_error_the_watchdog_did_not_cause_is_raised(self): + import requests + + class Broken(FakeResponse): + def iter_content(self, chunk_size: int = 65536): + yield b"ab" + raise requests.exceptions.ChunkedEncodingError("connection broken") + + response = Broken(b"") + with pytest.raises(requests.exceptions.ChunkedEncodingError): + read_capped_text(response, max_bytes=10) + assert response.closed + + +class TestTheWatchdog: + def test_a_cancelled_watchdog_does_nothing_when_its_time_comes(self): + from pybreeze.utils.network.http_client import _Watchdog + + response = FakeResponse(b"") + watchdog = _Watchdog(response, 60) + watchdog.cancel() + watchdog._fire() # the timer, had it fired late + + assert not watchdog.fired + assert not response.closed + + def test_a_connection_that_cannot_be_shut_is_logged_not_raised(self): + # On the timer's thread an exception would go nowhere and leave the read waiting + from pybreeze.utils.network.http_client import _Watchdog + + def refuse(): + raise OSError("already closed") + + response = FakeResponse(b"") + response.raw = SimpleNamespace(shutdown=refuse) + watchdog = _Watchdog(response, 60) + watchdog._fire() + + assert watchdog.fired + watchdog.cancel() diff --git a/test/test_utils/test_http_status_gui.py b/test/test_utils/test_http_status_gui.py index a5e4167d..5fca8f1b 100644 --- a/test/test_utils/test_http_status_gui.py +++ b/test/test_utils/test_http_status_gui.py @@ -18,7 +18,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI gui = HttpStatusGUI() @@ -67,6 +67,45 @@ def test_initial_search_prefills(self, app): def test_has_output_actions(self, widget): # The reference table is always content, so save/copy operate on it. - assert widget.actions.suggested_filename() == "http_status.txt" - widget.actions.copy() + assert widget.output_actions.suggested_filename() == "http_status.txt" + widget.output_actions.copy() assert "200 OK" in QApplication.clipboard().text() + + +class TestTheCategoryInTheIdeLanguage: + """The class of a status ([Client Error]) was English in the Traditional Chinese IDE.""" + + @pytest.fixture + def chinese(self, app, monkeypatch): + from je_editor import language_wrapper + + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict, + ) + monkeypatch.setattr(language_wrapper, "language_word_dict", pybreeze_traditional_chinese_word_dict) + + def test_the_reference_says_it_in_the_ide_language(self, chinese): + from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI + gui = HttpStatusGUI(initial_search="404") + assert "404 Not Found [用戶端錯誤]" in gui.output_edit.toPlainText() + gui.deleteLater() + + def test_the_response_inspector_says_it_too(self, chinese): + from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI + gui = ResponseInspectorGUI() + gui.input_edit.setPlainText("HTTP/1.1 503 Service Unavailable\n") + gui.analyze() + assert "503 Service Unavailable [伺服器錯誤]" in gui.output_edit.toPlainText() + gui.deleteLater() + + def test_every_class_has_words_in_both_languages(self): + from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict, + ) + from pybreeze.pybreeze_ui.tools_gui.http_status_gui import category_key + from pybreeze.utils.http_reference.status_codes import status_of + + for code in (100, 200, 300, 400, 500, 999): + key = category_key(status_of(code)) + assert pybreeze_english_word_dict.get(key) and pybreeze_traditional_chinese_word_dict.get(key), key diff --git a/test/test_utils/test_image_download.py b/test/test_utils/test_image_download.py new file mode 100644 index 00000000..f17b380a --- /dev/null +++ b/test/test_utils/test_image_download.py @@ -0,0 +1,133 @@ +"""The diagram editor's image download refuses what is not a public image of a bounded size. + +The scene's tests stand in for ``safe_download_image``, so none of its own +checks ran: a text page posing as an image, a size over the cap (declared or +not), an unsafe URL, a redirect to a private address. +""" +from __future__ import annotations + +import ipaddress +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from pybreeze.pybreeze_ui.diagram_editor import diagram_net_utils +from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import ImageDownloadError, safe_download_image +from pybreeze.utils.network import url_validation + +_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 40 +_LOOPBACK = ipaddress.ip_address("127.0.0.1") + + +class _Server: + """Serves ``routes[path] = (status, headers, body)`` on loopback.""" + + def __init__(self, routes: dict[str, tuple[int, dict[str, str], bytes]]) -> None: + self.requests: list[str] = [] + server = self + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" # a body with no length ends when the connection does + + def do_GET(self): # noqa: N802 — the name http.server calls + server.requests.append(self.path) + status, headers, body = routes[self.path] + self.send_response(status) + for name, value in headers.items(): + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + """Quiet.""" + + self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.port = self._httpd.server_address[1] + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + + def url(self, path: str) -> str: + return f"http://127.0.0.1:{self.port}{path}" + + def close(self) -> None: + self._httpd.shutdown() + self._httpd.server_close() + + +@pytest.fixture +def loopback_only(monkeypatch): + """127.0.0.1 may stand in for a public server; every other private address stays refused.""" + blocked = url_validation._is_blocked_ip + monkeypatch.setattr(url_validation, "_is_blocked_ip", lambda ip: ip != _LOOPBACK and blocked(ip)) + + +@pytest.fixture +def serve(): + servers: list[_Server] = [] + + def start(routes): + server = _Server(routes) + servers.append(server) + return server + + yield start + for server in servers: + server.close() + + +def test_an_image_comes_back_whole(loopback_only, serve): + server = serve({"/a.png": (200, {"Content-Type": "image/png", "Content-Length": str(len(_PNG))}, _PNG)}) + assert safe_download_image(server.url("/a.png")) == _PNG + + +def test_a_text_page_is_not_an_image(loopback_only, serve): + server = serve({"/a.png": (200, {"Content-Type": "text/html; charset=utf-8"}, b"not found")}) + with pytest.raises(ImageDownloadError, match="text/html"): + safe_download_image(server.url("/a.png")) + + +def test_a_declared_size_over_the_cap_is_refused_unread(loopback_only, serve, monkeypatch): + monkeypatch.setattr(diagram_net_utils, "MAX_DOWNLOAD_BYTES", 100) + server = serve({"/big.png": (200, {"Content-Type": "image/png", "Content-Length": "1000"}, b"\x00" * 1000)}) + with pytest.raises(ImageDownloadError): + safe_download_image(server.url("/big.png")) + + +def test_a_body_over_the_cap_with_no_length_is_refused(loopback_only, serve, monkeypatch): + # An absent or low Content-Length must not let a larger body through + monkeypatch.setattr(diagram_net_utils, "MAX_DOWNLOAD_BYTES", 100) + server = serve({"/big.png": (200, {"Content-Type": "image/png"}, b"\x00" * 500)}) + with pytest.raises(ImageDownloadError): + safe_download_image(server.url("/big.png")) + + +def test_an_unsafe_url_is_refused_as_a_download_error(serve): + # The canvas catches ImageDownloadError; an UnsafeURLError would escape it + server = serve({"/a.png": (200, {"Content-Type": "image/png"}, _PNG)}) + with pytest.raises(ImageDownloadError): + safe_download_image(server.url("/a.png")) + assert server.requests == [] + + +def test_a_redirect_to_a_private_address_is_not_followed(loopback_only, serve): + server = serve({"/a.png": (302, {"Location": "http://169.254.169.254/latest/meta-data/"}, b"")}) + with pytest.raises(ImageDownloadError): + safe_download_image(server.url("/a.png")) + assert server.requests == ["/a.png"] + + +def test_a_redirect_to_a_public_address_is_followed(loopback_only, serve): + server = serve({ + "/old.png": (301, {"Location": "/new.png"}, b""), + "/new.png": (200, {"Content-Type": "image/png", "Content-Length": str(len(_PNG))}, _PNG), + }) + assert safe_download_image(server.url("/old.png")) == _PNG + assert server.requests == ["/old.png", "/new.png"] + + +@pytest.mark.parametrize("length", ["abc", "-5"]) +def test_a_length_that_is_no_length_leaves_the_read_to_decide(loopback_only, serve, length): + # A hostile or broken header is not trusted either way: the bounded read still caps it + server = serve({"/a.png": (200, {"Content-Type": "image/png", "Content-Length": length}, _PNG)}) + assert safe_download_image(server.url("/a.png")) == _PNG diff --git a/test/test_utils/test_install_menu.py b/test/test_utils/test_install_menu.py index f60a7762..46362b0b 100644 --- a/test/test_utils/test_install_menu.py +++ b/test/test_utils/test_install_menu.py @@ -31,7 +31,7 @@ def start_module_process(self, package, arguments, environment=None) -> None: self.runs.append((package, list(arguments))) -@pytest.fixture() +@pytest.fixture def processes(monkeypatch) -> list: started: list = [] @@ -108,6 +108,47 @@ def test_one_pip_installs_all_three(self, app, processes): assert _runs(processes) == [("pip", ["install", "-U", "setuptools", "build", "wheel"])] +class TestTheAutomationInstallMenu: + """Each entry installs its own package.""" + + @staticmethod + def _menu(app): + from types import SimpleNamespace + + from PySide6.QtWidgets import QMenu + + from pybreeze.pybreeze_ui.menu.install_menu.automation_menu.build_automation_install_menu import ( + build_automation_install_menu, + ) + + window = SimpleNamespace(install_menu=QMenu()) + build_automation_install_menu(window) + return window, window.install_automation_menu + + def test_the_entries_and_their_packages(self, app, processes): + from je_editor import language_wrapper + + window, menu = self._menu(app) + words = language_wrapper.language_word_dict + pip_entries = [action for action in menu.actions() + if action.text() != words.get("install_menu_prthinker")] + installed = [] + for action in pip_entries: + action.trigger() + installed.append((action.text(), _runs(processes)[-1][1][-1])) + + assert installed == [ + (words.get("install_menu_autocontrol"), "je_auto_control"), + (words.get("install_menu_apitestka"), "je_api_testka"), + (words.get("install_menu_loaddensity"), "je_load_density"), + (words.get("install_menu_webrunner"), "je_web_runner"), + (words.get("install_menu_automation_file"), "automation_file"), + (words.get("install_menu_mail_thunder"), "je_mail_thunder"), + (words.get("install_menu_test_pioneer"), "test_pioneer"), + ] + assert menu.actions()[-1].text() == words.get("install_menu_prthinker") + + class TestInstallingPrthinker: def test_a_remembered_source_folder_is_installed(self, app, processes, monkeypatch, tmp_path): from pybreeze.pybreeze_ui.menu.install_menu.automation_menu import ( diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py index af4d3b97..f3148c62 100644 --- a/test/test_utils/test_jeditor_contract.py +++ b/test/test_utils/test_jeditor_contract.py @@ -43,7 +43,11 @@ ("je_editor.utils.encodings.text_codec", "LINE_ENDING_LF", "menu/plugin_menu/build_run_with_menu.py"), ("je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file", "actually_color_dict", - "show_code_window/code_window.py, auto_control_menu/build_autocontrol_menu.py"), + "show_code_window/code_window.py, auto_control_menu/build_autocontrol_menu.py, tools_gui/diff_gui.py"), + ("je_editor.utils.redirect_manager.redirect_manager_class", "RedirectStdErr", + "code_result_logs.py"), + ("je_editor.pyside_ui.main_ui.save_settings.user_setting_file", "user_setting_dict", + "editor_main/main_ui.py"), ] # Names PyBreeze imports from je_editor's top level, i.e. from its __all__. @@ -84,6 +88,25 @@ def test_the_main_window_takes_the_arguments_pybreeze_passes(self): assert callable(EditorMain.clear_code_result) assert callable(EditorMain.startup_setting) + def test_the_run_menu_keeps_its_stop_all_action(self): + # PyBreezeMainWindow connects its run windows' stop to this action. + build = importlib.import_module("je_editor.pyside_ui.main_ui.menu.run_menu.build_run_menu") + assert "run_menu.stop_all_program_action" in inspect.getsource(build) + + def test_the_window_is_built_with_the_saved_settings(self): + from je_editor import EditorMain + + # open_main_window() applies them again only for a theme start_editor() is given + assert "self.startup_setting()" in inspect.getsource(EditorMain.__init__) + + def test_the_startup_applies_the_saved_theme(self): + from je_editor import EditorMain + + # open_main_window() gives start_editor's theme to startup_setting() as the saved one. + assert '"ui_style"' in inspect.getsource(EditorMain.startup_setting) + settings = _internal("je_editor.pyside_ui.main_ui.save_settings.user_setting_file", "user_setting_dict") + assert settings.get("ui_style") is not None + def test_an_editor_tab_can_be_saved_the_way_a_run_saves_it(self): from je_editor import EditorWidget @@ -113,6 +136,20 @@ def test_the_output_colours_are_there(self): "je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file", "actually_color_dict") assert {"normal_output_color", "error_output_color"} <= set(colours) + # The diff tool's line colours + assert {"diff_added_marker_color", "diff_removed_marker_color", "syntax_keyword_color", + "blame_annotation_color"} <= set(colours) + + def test_a_keyword_colour_can_be_a_theme_colour_key(self): + # syntax_extend registers keys, not colours, so keywords follow the theme + from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + from je_editor.utils.theme.theme_colors import DARK_COLORS, LIGHT_COLORS + + from pybreeze.pybreeze_ui.syntax.syntax_extend import JSON_KEYWORD_COLOUR, YAML_KEYWORD_COLOUR + + assert "actually_color_dict.get(color)" in inspect.getsource(PythonHighlighter._make_format) + for key in (JSON_KEYWORD_COLOUR, YAML_KEYWORD_COLOUR): + assert key in DARK_COLORS and key in LIGHT_COLORS, key def test_the_widgets_build_without_arguments(self): from PySide6.QtWidgets import QDockWidget, QWidget diff --git a/test/test_utils/test_json_format_gui.py b/test/test_utils/test_json_format_gui.py index 93fc48d7..db75ffce 100644 --- a/test/test_utils/test_json_format_gui.py +++ b/test/test_utils/test_json_format_gui.py @@ -19,7 +19,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI gui = JsonFormatGUI() @@ -54,5 +54,5 @@ def test_empty_shows_hint(self, widget): def test_copy_output(self, app, widget): widget.input_edit.setPlainText('{"a":1}') widget.minify() - widget.actions.copy() + widget.output_actions.copy() assert '{"a":1}' in QApplication.clipboard().text() diff --git a/test/test_utils/test_jupyter_lifecycle.py b/test/test_utils/test_jupyter_lifecycle.py index 36c3687f..c8e844b5 100644 --- a/test/test_utils/test_jupyter_lifecycle.py +++ b/test/test_utils/test_jupyter_lifecycle.py @@ -44,7 +44,7 @@ def poll(self): return self.returncode -@pytest.fixture() +@pytest.fixture def launched(monkeypatch) -> list: """Start the launcher's run() against a fake server and collect what it started.""" started: list = [] @@ -54,7 +54,7 @@ def popen(argv, **options): started.append(server) return server - monkeypatch.setattr(jupyter_lab_thread, "get_venv_python", lambda: "python") + monkeypatch.setattr(jupyter_lab_thread, "default_interpreter", lambda: "python") monkeypatch.setattr(jupyter_lab_thread, "is_jupyter_installed", lambda _python: True) monkeypatch.setattr(jupyter_lab_thread.subprocess, "Popen", popen) monkeypatch.setattr( @@ -75,6 +75,19 @@ def test_it_is_bound_to_this_machine_and_takes_no_other_origin(self, app, launch assert not [arg for arg in argv if arg.startswith("--ServerApp.allow_origin")] thread.stop() + def test_it_runs_without_what_the_ide_set_for_itself(self, app, launched, monkeypatch): + # The IDE keeps locust from patching it with gevent; a notebook's kernel + # running a load test needs the patching + from pybreeze.utils.subprocess_util import IDE_ONLY + + monkeypatch.setenv("LOCUST_SKIP_MONKEY_PATCH", IDE_ONLY) + thread = jupyter_lab_thread.JupyterLauncherThread() + + thread.run() + + assert "LOCUST_SKIP_MONKEY_PATCH" not in launched[0].options["env"] + thread.stop() + def test_its_output_goes_to_a_file_not_a_pipe_nobody_reads(self, app, launched): import subprocess @@ -144,7 +157,7 @@ def test_the_server_is_stopped_after_the_lab_has_loaded(self, app, monkeypatch): tab.close() - assert tab.thread.stopped + assert tab.launcher.stopped _CLOSE_WITH_A_TOOL_TAB_AND_DOCK = """ @@ -203,7 +216,7 @@ def test_closing_the_tab_does_not_wait_for_the_launcher(self, app, monkeypatch): monkeypatch.setattr( jupyter_lab_thread.JupyterLauncherThread, "run", lambda self: installing.wait(5)) tab = jupyter_lab_widget.JupyterLabWidget() - launcher = tab.thread + launcher = tab.launcher tab.close() # returns with the install still going diff --git a/test/test_utils/test_jupyter_ready.py b/test/test_utils/test_jupyter_ready.py index e1dc663f..6842a8c4 100644 --- a/test/test_utils/test_jupyter_ready.py +++ b/test/test_utils/test_jupyter_ready.py @@ -77,7 +77,7 @@ def test_returns_when_port_open(self, qt_app, monkeypatch): class TestTheReasonIsInTheIdeLanguage: """A start that timed out or a server that exited read in English whatever the IDE spoke.""" - @pytest.fixture() + @pytest.fixture def chinese(self, monkeypatch): from pybreeze.extend_multi_language.extend_traditional_chinese import ( pybreeze_traditional_chinese_word_dict as word, @@ -121,7 +121,7 @@ def terminate(self): self.terminated = True proc = _RecordingProc() - monkeypatch.setattr(mod, "get_venv_python", lambda: "python") + monkeypatch.setattr(mod, "default_interpreter", lambda: "python") monkeypatch.setattr(mod, "is_jupyter_installed", lambda exe: True) monkeypatch.setattr(mod, "find_free_port", lambda: 59999) monkeypatch.setattr(mod.subprocess, "Popen", lambda *a, **k: proc) @@ -199,24 +199,59 @@ class TestWhichInterpreterRunsTheLab: def test_the_one_chosen_in_the_ide_comes_first(self, monkeypatch): from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod - monkeypatch.setattr(mod, "get_venv_python", lambda: "venv-python") + monkeypatch.setattr(mod, "default_interpreter", lambda: "run-python") assert mod.choose_python("C:/envs/project/python.exe") == "C:/envs/project/python.exe" - assert mod.choose_python(None) == "venv-python" + assert mod.choose_python(None) == "run-python" - def test_without_a_venv_the_ides_own_is_used(self, monkeypatch): - # It raised "Cannot find venv python executable" and the lab never started + def test_the_lab_runs_where_a_run_would(self, monkeypatch, tmp_path): + # An IDE started from a venv of its own ran the lab there, while a run of + # the same project used the project's .venv: its notebooks did not see + # the project's packages import sys + from pybreeze.extend.process_executor.python_task_process_manager import default_interpreter from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod - def no_venv(): - raise RuntimeError("Cannot find venv python executable") + scripts = tmp_path / ".venv" / ("Scripts" if sys.platform == "win32" else "bin") + scripts.mkdir(parents=True) + project_python = scripts / ("python.exe" if sys.platform == "win32" else "python") + project_python.write_bytes(b"") + project_python.chmod(0o755) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "base_prefix", sys.prefix + "-elsewhere") # the IDE runs in a venv + + assert mod.choose_python(None) == default_interpreter() + assert mod.choose_python(None).startswith(str(scripts)) + + def test_without_a_venv_the_ides_own_is_used(self, monkeypatch, tmp_path): + import sys + + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod - monkeypatch.setattr(mod, "get_venv_python", no_venv) + monkeypatch.chdir(tmp_path) assert mod.choose_python(None) == sys.executable + def test_a_packaged_build_with_no_python_says_so(self, qt_app, monkeypatch): + # default_interpreter raises JEditorExecException there, which the + # thread did not catch: it would have died with nothing shown + from je_editor import JEditorExecException + + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + def none_found(): + raise JEditorExecException("no python interpreter found") + + monkeypatch.setattr(mod, "default_interpreter", none_found) + thread = mod.JupyterLauncherThread() + errors: list = [] + thread.error_occurred.connect(errors.append) + + thread.run() + + assert errors == ["no python interpreter found"] + def test_installed_is_asked_of_the_interpreter_not_of_pip(self, tmp_path, monkeypatch): # A venv made without pip failed "pip show" with jupyterlab installed import os @@ -257,3 +292,94 @@ def test_the_server_is_told_not_to_move_to_another_port(monkeypatch): assert "--ServerApp.port_retries=0" in started[0] assert "--ServerApp.port=8888" in started[0] + + +class TestInstallingJupyterLab: + """When the interpreter has no JupyterLab, it is installed there first.""" + + @staticmethod + def _launch(monkeypatch, pip_result, during_pip=None): + from types import SimpleNamespace + + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + seen: dict = {"pip": [], "started": [], "status": [], "errors": [], "ready": []} + thread = mod.JupyterLauncherThread(python_exe="C:/envs/lab/python.exe") + + def run(args, **options): + seen["pip"].append((args, options.get("timeout"))) + if during_pip is not None: + during_pip(thread) + return SimpleNamespace(returncode=pip_result[0], stderr=pip_result[1]) + + monkeypatch.setattr(mod, "is_jupyter_installed", lambda exe: False) + monkeypatch.setattr(mod.subprocess, "run", run) + monkeypatch.setattr(mod, "find_free_port", lambda: 58888) + monkeypatch.setattr(thread, "_start_server", lambda exe, port: seen["started"].append((exe, port)) or object()) + monkeypatch.setattr(thread, "_wait_until_ready", lambda port: None) + thread.status_update.connect(seen["status"].append) + thread.error_occurred.connect(seen["errors"].append) + thread.server_ready.connect(seen["ready"].append) + thread.run() + return seen + + def test_it_is_installed_into_the_interpreter_the_lab_runs_in(self, qt_app, monkeypatch): + from je_editor import language_wrapper + + seen = self._launch(monkeypatch, (0, "")) + + assert seen["pip"] == [(["C:/envs/lab/python.exe", "-m", "pip", "install", "jupyterlab", "-U"], 300)] + assert seen["status"][0] == language_wrapper.language_word_dict.get("jupyterlab_downloading") + assert seen["started"] == [("C:/envs/lab/python.exe", 58888)] + assert seen["ready"] == ["http://localhost:58888/lab"] + assert seen["errors"] == [] + + def test_a_failed_install_says_why_and_starts_no_server(self, qt_app, monkeypatch): + monkeypatch.setattr("pybreeze.pybreeze_ui.jupyter_lab_gui.jupyter_lab_thread.pybreeze_logger.error", + lambda *args: None) + + seen = self._launch(monkeypatch, (1, "ERROR: No matching distribution found for jupyterlab")) + + assert seen["errors"] == ["ERROR: No matching distribution found for jupyterlab"] + assert seen["started"] == [] + assert seen["ready"] == [] + + def test_a_tab_closed_during_the_install_gets_no_server(self, qt_app, monkeypatch): + seen = self._launch(monkeypatch, (0, ""), during_pip=lambda thread: thread.stop()) + + assert seen["started"] == [] + assert seen["ready"] == [] + assert seen["errors"] == [] + + +class TestTheTab: + @staticmethod + def _tab(monkeypatch): + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_widget + + update_language_dict() + monkeypatch.setattr(jupyter_lab_widget.JupyterLauncherThread, "start", lambda self: None) + return jupyter_lab_widget.JupyterLabWidget() + + def test_the_lab_replaces_the_status_and_a_late_status_is_ignored(self, qt_app, monkeypatch): + from PySide6.QtCore import QCoreApplication, QEvent + + tab = self._tab(monkeypatch) + # Recorded, not loaded: a page Chromium had loaded, in a view still alive + # when the test process ended, crashed the interpreter on its way out + # (exit 139 after every test had passed) + loaded: list[str] = [] + tab.browser.setUrl = lambda url: loaded.append(url.toString()) + tab.update_status("Loading...") + assert tab.status_label.text() == "Loading..." + + tab.load_lab("http://localhost:58888/lab") + tab.update_status("Loading... (3s / 60s)") # queued before the lab was ready + tab.show_error("too late to matter") + + assert tab.status_label is None + assert loaded == ["http://localhost:58888/lab"] + assert tab.browser.isVisibleTo(tab) + tab.close() # deleted on close; the delete is carried out here, not at exit + QCoreApplication.sendPostedEvents(tab, QEvent.Type.DeferredDelete) diff --git a/test/test_utils/test_jwt_decoder_gui.py b/test/test_utils/test_jwt_decoder_gui.py index 610eea1c..d71ee59a 100644 --- a/test/test_utils/test_jwt_decoder_gui.py +++ b/test/test_utils/test_jwt_decoder_gui.py @@ -27,7 +27,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI gui = JwtDecoderGUI() @@ -63,7 +63,7 @@ def test_empty_input_shows_hint(self, widget): def test_copy_output(self, app, widget): widget.input_edit.setPlainText(_make_jwt({"alg": "HS256"}, {"sub": "42"})) widget.decode() - widget.actions.copy() + widget.output_actions.copy() assert '"sub": "42"' in QApplication.clipboard().text() def test_build_decoded_text_without_timestamps(self, app): diff --git a/test/test_utils/test_language_keys_used.py b/test/test_utils/test_language_keys_used.py new file mode 100644 index 00000000..867a286a --- /dev/null +++ b/test/test_utils/test_language_keys_used.py @@ -0,0 +1,41 @@ +"""Every word the language dictionaries define is still asked for somewhere.""" +from __future__ import annotations + +import re +from pathlib import Path + +from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict + +PACKAGE = Path(__file__).resolve().parents[2] / "pybreeze" + +# Keys whose name is put together at run time, and JEditor's own widgets' +# (its plugin browser reads plugin_browser_*, which PyBreeze translates) +BUILT_OR_READ_ELSEWHERE = ( + "error_text_", # error_text: ERROR_TEXT_KEY_PREFIX + an exception tag + "run_window_", # run_notice.RUN_NOTICE_KEY_PREFIX + "ssh_file_viewer_context_menu_action_", # ssh_file_viewer_widget: f"...{name}" + "ssh_file_viewer_tree_header_", + "ssh_file_viewer_type_", + "header_analyzer_level_", # header_analyzer_gui: f"...{finding.level}" + "header_finding_", + "http_status_category_", # http_status_gui.CATEGORY_KEY_PREFIX + the class + "plugin_browser_", +) + + +def _source_outside_the_dictionaries() -> str: + return "\n".join( + path.read_text(encoding="utf-8") + for path in PACKAGE.rglob("*.py") + if "extend_multi_language" not in path.parts + ) + + +def test_no_word_is_left_unused(): + source = _source_outside_the_dictionaries() + unused = [ + key for key in pybreeze_english_word_dict + if not key.startswith(BUILT_OR_READ_ELSEWHERE) + and not re.search(rf"[\"']{re.escape(key)}[\"']", source) + ] + assert unused == [] diff --git a/test/test_utils/test_language_parity.py b/test/test_utils/test_language_parity.py index 43191802..6af4ab7d 100644 --- a/test/test_utils/test_language_parity.py +++ b/test/test_utils/test_language_parity.py @@ -58,6 +58,13 @@ def test_placeholders_match_across_languages(self): } assert not mismatched, f"Placeholder mismatches between languages: {mismatched}" + def test_no_value_starts_or_ends_with_a_space(self): + # The code puts its own space between a label and what follows it: the SSH + # terminal's "[Error] " came out "[Error] " in English only + padded = {key: value for words in (EN, ZH) for key, value in words.items() + if str(value) != str(value).strip(" ")} + assert padded == {} + def test_no_word_is_written_twice(self): # Four Chinese menu entries read "運行 Multi WebRunner 腳本 腳本並寄信" doubled = { @@ -68,6 +75,138 @@ def test_no_word_is_written_twice(self): } assert not doubled, f"A word written twice: {doubled}" + def test_traditional_chinese_uses_taiwan_terms(self): + # The automation menus said 運行 where the run window said 執行; JEditor's + # own entries (its Run and plugin menus) are not in this dictionary + mainland = {"運行": "執行", "字體": "字型", "插件": "外掛", "默認": "預設", "文件夾": "資料夾", + "信息": "訊息", "軟件": "軟體", "數據": "資料", "屏幕": "螢幕", "鼠標": "滑鼠", + "幫助": "說明", "模板": "範本"} + found = { + key: [word for word in mainland if word in value] + for key, value in ZH.items() + if any(word in value for word in mainland) + } + assert not found, f"Mainland terms, Taiwan uses {mainland}: {found}" + # 終端 on its own is 終端機 + assert not {key: value for key, value in ZH.items() if re.search("終端(?!機)", value)} + + def test_traditional_chinese_uses_full_width_punctuation(self): + # Forty-two labels ended in ":" and a few in ":"; the SSH placeholder read + # "主機 (例如: ...)". A file dialog's filter keeps "(*.txt)", or "({patterns})" + # filled in with them: Qt reads it + cjk = "[一-鿿]" + half_width = { + key: value for key, value in ZH.items() + if re.search(rf"{cjk}\s?(:|\((?![*{{]))|:(\s*$|\s+\{{)|\((?![*{{])[^)]*{cjk}", value) + } + assert half_width == {} + + +class TestLabelsAreTold: + def test_side_by_side_controls_are_named_apart(self): + # The diagram toolbar's Align menu and Snap box both read 對齊 + for first, second in [("diagram_editor_action_snap", "diagram_editor_align_menu")]: + assert EN[first] != EN[second] + assert ZH[first] != ZH[second] + + def test_a_dock_is_titled_like_its_tab(self): + # The prompt editors' docks said "CoT PromptEditor" + for tab, dock in [ + ("extend_tools_menu_cot_prompt_editor_tab_label", "extend_tools_menu_cot_prompt_editor_dock_title"), + ("extend_tools_menu_skill_prompt_editor_tab_label", "extend_tools_menu_skill_prompt_editor_dock_title")]: + assert EN[dock] == EN[tab] + assert ZH[dock] == ZH[tab] + + def test_every_tools_entry_says_whether_it_opens_a_tab_or_a_dock(self): + # Tools > AI read "CoT Prompt Editor", "Skill Send GUI" beside "CoT Code Review Tab" + from pybreeze.pybreeze_ui.menu.tools.tools_menu import _DOCK_ACTIONS, _TAB_ACTIONS + + for *_start, action_key, _label_key in _TAB_ACTIONS: + assert EN[action_key].endswith(" Tab") and ZH[action_key].endswith("分頁"), action_key + for *_start, action_key in _DOCK_ACTIONS: + assert EN[action_key].endswith(" Dock") and ZH[action_key].endswith("停駐窗格"), action_key + + def test_a_tab_is_titled_as_its_menu_entry_names_it(self): + # The Regex tab was "Regex" in Traditional Chinese, opened by 正規表示式測試器分頁 + from pybreeze.pybreeze_ui.menu.tools.tools_menu import _TAB_ACTIONS + + for *_start, action_key, label_key in _TAB_ACTIONS: + for words in (EN, ZH): + assert words[label_key] in words[action_key], (label_key, words[label_key], words[action_key]) + + def test_a_dock_is_titled_like_the_tab_of_the_same_tool(self): + # "AI Code-Review" and "Skill Send GUI" as titles; the Skill Send dock's + # menu entry read "Skill Prompt Dock" + for tool in ("ai_code_review", "cot_code_review", "cot_prompt_editor", "skill_prompt_editor", + "skill_prompt_send"): + for words in (EN, ZH): + tab = words[f"extend_tools_menu_{tool}_tab_label"] + assert words[f"extend_tools_menu_{tool}_dock_title"] == tab + assert words[f"extend_tools_menu_{tool}_dock_action"].startswith(tab), tool + + def test_traditional_chinese_says_prompt_one_way(self): + # The prompt editors' tabs said 提示詞 and their labels "Prompt 檔案位置(會覆寫內建 prompt)" + assert {key: value for key, value in ZH.items() if re.search(r"\b[Pp]rompt\b", str(value))} == {} + + def test_a_name_is_spelled_one_way(self): + # "Autocontrol" beside "AutoControl GUI" and "Install AutoControl"; + # "Test Pioneer" beside "TestPioneer"; "Yaml" for YAML; "Install Automation File" + # beside the FileAutomation menu + misspelt = {key: value for words in (EN, ZH) for key, value in words.items() + if re.search(r"Autocontrol|Test Pioneer|Yaml|Code-Review|Automation File", str(value))} + assert misspelt == {} + + def test_the_same_action_reads_the_same_on_every_tab(self): + # Header Analyzer said "Open token in JWT decoder", Response Inspector + # "Open JWT in decoder", for the same hand-over + for words in (EN, ZH): + assert words["header_analyzer_open_jwt_button"] == words["response_open_jwt_button"] + + def test_a_hand_over_button_does_not_name_a_tab_that_is_not_there(self): + # "Open status in reference": no tab is called Reference (the HTTP Status tab looks codes up) + assert "reference" not in EN["response_open_status_button"].lower() + assert "參考" not in ZH["response_open_status_button"] + + def test_no_string_holds_an_ip_address(self): + # The SSH host placeholder's example was a private address (CLAUDE.md: no + # hardcoded IPs or hostnames outside documented loopback) + addresses = {key: value for words in (EN, ZH) for key, value in words.items() + if re.search(r"\b(?!127\.)\d{1,3}(\.\d{1,3}){3}\b", str(value))} + assert addresses == {} + + def test_an_example_url_is_one_the_ide_would_send_to(self, monkeypatch): + # CoT Code Review and Skill Send suggested http://127.0.0.1:5000/api, which + # the URL check they send through refuses as not public + import ipaddress + import socket + + from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url + + def getaddrinfo(host, *_args, **_kwargs): + try: + address = str(ipaddress.ip_address(host)) + except ValueError: + address = "127.0.0.1" if host == "localhost" else "8.8.8.8" + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, 0))] + + monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) + refused = {} + for words in (EN, ZH): + for key, value in words.items(): + for url in re.findall(r"https?://[^\s'\",))]+", str(value)): + try: + validate_url(url) + except UnsafeURLError as error: + refused[key] = (url, str(error)) + assert refused == {} + + def test_no_label_is_in_capitals(self): + # Every automation menu's Help submenu read "HELP", beside JEditor's "Help" menu + acronyms = {"HTTP", "JSON", "MIME", "SFTP", "YAML"} + shouted = {key: text for key, text in EN.items() if key.endswith("_label") + for word in re.findall(r"\b[A-Z]{4,}\b", text) if word not in acronyms} + assert shouted == {} + class TestCodeKeysAreDefined: def test_every_get_key_exists_in_dict(self): @@ -154,3 +293,19 @@ def test_the_window_is_called_pybreeze_in_every_language(self): finally: language_wrapper.reset_language(original) assert set(names.values()) == {"PyBreeze"}, names + + +def test_the_readmes_count_the_keys_there_are(): + # They said 735 while the dictionaries held 753: a count kept by hand drifts. + # The architecture map quotes it too + readmes = { + "README.md": r"the same (\d+) keys", + "README/README_zh-TW.md": r"相同的 (\d+) 個鍵", + "README/README_zh-CN.md": r"同样的 (\d+) 个键", + "architecture_explore.md": r"各 (\d+) 個鍵", + } + root = pathlib.Path(pybreeze.__file__).parent.parent + for name, pattern in readmes.items(): + found = re.search(pattern, (root / name).read_text(encoding="utf-8")) + assert found is not None, name + assert int(found.group(1)) == len(EN) == len(ZH), name diff --git a/test/test_utils/test_long_scripts.py b/test/test_utils/test_long_scripts.py index 91373cc1..01ae195f 100644 --- a/test/test_utils/test_long_scripts.py +++ b/test/test_utils/test_long_scripts.py @@ -34,7 +34,7 @@ def app(): return QApplication.instance() or QApplication([]) -@pytest.fixture() +@pytest.fixture def reporter(tmp_path, monkeypatch): """A package that says which flag it was given and what it read, in ASCII.""" (tmp_path / "report_args.py").write_text(_REPORTER, encoding="utf-8") diff --git a/test/test_utils/test_mail_report.py b/test/test_utils/test_mail_report.py index 2cbc4e32..5e79ab56 100644 --- a/test/test_utils/test_mail_report.py +++ b/test/test_utils/test_mail_report.py @@ -51,7 +51,7 @@ class MailThunderException(Exception): pass -@pytest.fixture() +@pytest.fixture def mail_thunder(monkeypatch): """A stand-in je_mail_thunder with a user in its content file.""" FakeSmtp.instances = [] @@ -69,14 +69,14 @@ def mail_thunder(monkeypatch): return package -@pytest.fixture() +@pytest.fixture def logger(monkeypatch) -> MagicMock: fake = MagicMock() monkeypatch.setattr(mail, "pybreeze_logger", fake) return fake -@pytest.fixture() +@pytest.fixture def report(tmp_path) -> str: path = tmp_path / "report.html" path.write_text("ok", encoding="utf-8") diff --git a/test/test_utils/test_mermaid_parser.py b/test/test_utils/test_mermaid_parser.py index 0e68bbdf..db09ada7 100644 --- a/test/test_utils/test_mermaid_parser.py +++ b/test/test_utils/test_mermaid_parser.py @@ -1,6 +1,10 @@ from __future__ import annotations +import re + import pytest +from hypothesis import given, settings +from hypothesis import strategies as st from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import ( _order_layers, @@ -381,3 +385,48 @@ def test_percent_signs_in_a_label_are_not_a_comment(self): def test_a_quoted_edge_label_loses_its_quotes(self): assert _edges(parse_mermaid('graph TD\nA -->|"quoted label"| B')) == [("A", "B", "quoted label")] + + +class TestArrowLabels: + @pytest.mark.parametrize("token,label", [ + ("-->", ""), + ("-->|yes|", "yes"), + ("-->| yes |", "yes"), + ('-->|"a|b"|', "a|b"), + ('-->| "a|b" |', "a|b"), + ('-->|"a" b|', '"a" b'), + ('-->|"a|', '"a'), + ("-->||", ""), + ("-->| |", ""), + ("==>|thick|", "thick"), + ('-->|""|', ""), + ("-->|x|y|", "x"), + ("--> |yes|", "yes"), + ]) + def test_the_label_of_an_arrow(self, token, label): + from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import _parse_arrow + + assert _parse_arrow(token)[0] == label + + @given(st.text(alphabet='|" a\t', max_size=14)) + @settings(max_examples=400, deadline=None) + def test_the_label_is_the_one_the_old_pattern_found(self, tail): + # The pattern the two replaced, as the oracle: cubic, but not on 14 characters + from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import _arrow_label + + old = re.search(r'\|\s*("[^"]*"|[^|]*)\s*\|', "-->" + tail) + + assert _arrow_label("-->" + tail).strip() == (old.group(1).strip() if old else "") + + def test_a_long_unclosed_label_is_read_in_linear_time(self): + # The label pattern backtracked in cubic time: 800 spaces took half a + # second, 3,000 about half a minute, on the UI thread + import time + + from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import _parse_arrow + + started = time.perf_counter() + _parse_arrow("-->|" + " " * 3000 + "x") + _parse_arrow('-->| "' + " " * 3000 + "x") + + assert time.perf_counter() - started < 1 diff --git a/test/test_utils/test_no_blind_except.py b/test/test_utils/test_no_blind_except.py index 231dadb6..c396c395 100644 --- a/test/test_utils/test_no_blind_except.py +++ b/test/test_utils/test_no_blind_except.py @@ -41,3 +41,15 @@ def test_every_blind_catch_re_raises_or_says_why(): continue offenders.append(f"{path.relative_to(PACKAGE.parent)}:{node.lineno}") assert offenders == [] + + +def test_no_noqa_reason_has_a_comma(): + # SonarCloud reads what follows a comma in a noqa comment's reason as more + # rule codes, and reports the comment as a malformed suppression (S7632) + offenders = [] + for path in sorted(PACKAGE.rglob("*.py")): + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + _, marker, rest = line.partition("# noqa:") + if marker and "," in rest.partition("—")[2]: + offenders.append(f"{path.relative_to(PACKAGE)}:{number}") + assert not offenders, offenders diff --git a/test/test_utils/test_no_qt_member_shadowing.py b/test/test_utils/test_no_qt_member_shadowing.py new file mode 100644 index 00000000..e68d3fa5 --- /dev/null +++ b/test/test_utils/test_no_qt_member_shadowing.py @@ -0,0 +1,56 @@ +"""No PyBreeze Qt class sets an instance attribute that hides a member of its Qt base. + +Thirteen tool tabs kept their output buttons in ``self.actions`` and three panels +their worker in ``self.thread``: ``widget.actions()`` and ``widget.thread()`` +then gave back that object, not what Qt's own methods return, to anything that +asked -- a plugin, a later helper, JEditor collecting a widget's actions. +""" +from __future__ import annotations + +import ast +import importlib +import inspect +import os +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QObject +from PySide6.QtWidgets import QApplication + +_PACKAGE = Path(__file__).resolve().parents[2] / "pybreeze" + + +def _self_attributes(node: ast.ClassDef) -> set[str]: + return {target.attr for sub in ast.walk(node) if isinstance(sub, ast.Assign) + for target in sub.targets + if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) + and target.value.id == "self"} + + +def _shadowing(module_name: str, node: ast.ClassDef) -> list[str]: + cls = getattr(importlib.import_module(module_name), node.name, None) + if not inspect.isclass(cls) or not issubclass(cls, QObject): + return [] + qt_bases = [base for base in cls.__mro__ if base.__module__.startswith(("PySide6", "Shiboken"))] + found = [] + for name in sorted(_self_attributes(node)): + owner = next((base for base in qt_bases if name in vars(base)), None) + if owner is not None: + found.append(f"{module_name}.{node.name}: self.{name} hides {owner.__name__}.{name}") + return found + + +def test_no_instance_attribute_hides_a_qt_member(): + QApplication.instance() or QApplication([]) + found = [] + for path in sorted(_PACKAGE.rglob("*.py")): + classes = [node for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, ast.ClassDef)] + if not classes: + continue + module_name = ".".join(path.relative_to(_PACKAGE.parent).with_suffix("").parts) + for node in classes: + found += _shadowing(module_name, node) + + assert found == [] diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index 5b288667..6fea0912 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -48,7 +48,7 @@ def __init__(self) -> None: self.encoding = "utf-8" -@pytest.fixture() +@pytest.fixture def window(app): made = FakeWindow() yield made @@ -214,10 +214,23 @@ def test_a_config_without_suffixes_accepts_any_file( class TestThePluginMenu: - def test_no_plugins_means_no_menu(self, window, monkeypatch): + def test_with_no_plugins_the_menu_still_offers_the_plugin_browser(self, window, monkeypatch): + # The browser is how a first plugin gets installed: the menu was left + # out while none was loaded, so it could not be reached until one was + # copied into jeditor_plugins/ by hand. JEditor's own menu always has it. + monkeypatch.setattr(plugin_menu, "get_all_plugin_metadata", lambda: []) + set_plugin_menu(window) + assert labels(window.plugin_menu) == ["Plugin Browser"] + + def test_the_plugin_browser_opens_as_a_tab(self, window, monkeypatch): monkeypatch.setattr(plugin_menu, "get_all_plugin_metadata", lambda: []) + monkeypatch.setattr(plugin_menu, "PluginBrowserWidget", QWidget) set_plugin_menu(window) - assert not hasattr(window, "plugin_menu") + + window.plugin_menu.actions()[0].trigger() + + assert window.tab_widget.count() == 1 + assert window.tab_widget.tabText(0).startswith("Plugin Browser") def test_a_plugin_without_a_run_config_gets_a_bare_entry( self, window, monkeypatch): @@ -266,6 +279,21 @@ def test_the_about_dialog_names_version_and_author(self, app, monkeypatch): assert "2.1" in shown[0] assert "someone" in shown[0] + def test_the_about_dialog_speaks_the_ide_language(self, app, monkeypatch): + # "Version:" and "Author:" were English whatever the IDE spoke + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict, + ) + monkeypatch.setattr(plugin_menu.language_wrapper, "language_word_dict", + pybreeze_traditional_chinese_word_dict) + shown: list[str] = [] + monkeypatch.setattr( + plugin_menu.QMessageBox, "exec", lambda self: shown.append(self.text())) + plugin_menu._make_about_callback(None, "Go", "2.1", "someone")() + + assert "版本" in shown[0] and "作者" in shown[0] + assert "Version" not in shown[0] and "Author" not in shown[0] + def test_the_about_dialog_shows_markup_as_text(self, app, monkeypatch): # A plugin's name or author went to the box as markup, and all from PySide6.QtGui import QTextDocument @@ -318,7 +346,7 @@ def mark_saved(self) -> None: self.events.append("saved") -@pytest.fixture() +@pytest.fixture def editor_tab(window, monkeypatch): """Put an editor tab in the window; the helper recognises it by type.""" monkeypatch.setattr(run_with, "EditorWidget", EditorTab) diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index 4d73ab92..b95cd4e6 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -20,7 +20,7 @@ CODE = "def f():\n pass\n" -@pytest.fixture() +@pytest.fixture def prompts(tmp_path, monkeypatch): """Point the prompt directory at a temporary one, never the real home.""" monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: tmp_path) diff --git a/test/test_utils/test_prthinker_contract.py b/test/test_utils/test_prthinker_contract.py index af5ea679..5e4123c7 100644 --- a/test/test_utils/test_prthinker_contract.py +++ b/test/test_utils/test_prthinker_contract.py @@ -46,7 +46,7 @@ def _prthinker_python() -> str | None: reason="prthinker is not installed here (set PYBREEZE_PRTHINKER_PYTHON to run these)") -@pytest.fixture() +@pytest.fixture def closed_url() -> str: """An address on this machine where nothing listens.""" with socket.socket() as probe: diff --git a/test/test_utils/test_prthinker_menu_review.py b/test/test_utils/test_prthinker_menu_review.py index 8b8902ea..b74fd0d5 100644 --- a/test/test_utils/test_prthinker_menu_review.py +++ b/test/test_utils/test_prthinker_menu_review.py @@ -15,7 +15,7 @@ class EditorTab(QWidget): """Stands in for a JEditor editor tab.""" -@pytest.fixture() +@pytest.fixture def window(monkeypatch): QApplication.instance() or QApplication([]) monkeypatch.setattr(menu, "EditorWidget", EditorTab) @@ -57,3 +57,85 @@ def test_a_tab_that_is_not_an_editor_says_what_is_needed(window, monkeypatch): menu._review_current_file(window) assert window.events == [("told", "prthinker_need_saved_file_message")] + + +class TestReviewAPullRequest: + @staticmethod + def _answer(monkeypatch, number: int, chosen: bool) -> list: + asked: list = [] + + def get_int(*args): + asked.append(args[3:]) # the value it starts at, the lowest and the highest + return number, chosen + + monkeypatch.setattr(menu.QInputDialog, "getInt", staticmethod(get_int)) + return asked + + def test_the_number_asked_for_is_reviewed(self, window, monkeypatch): + asked = self._answer(monkeypatch, 42, True) + monkeypatch.setattr( + menu, "review_pull_request", lambda _window, number: window.events.append(number) or True) + + menu._review_pull_request(window) + + assert window.events == [42] + assert asked == [(1, 1, 1000000)] + + def test_cancelling_the_question_reviews_nothing(self, window, monkeypatch): + self._answer(monkeypatch, 1, False) + monkeypatch.setattr( + menu, "review_pull_request", lambda _window, number: window.events.append(number) or True) + + menu._review_pull_request(window) + + assert window.events == [] + + def test_without_a_repository_set_it_says_so(self, window, monkeypatch): + self._answer(monkeypatch, 7, True) + monkeypatch.setattr(menu, "review_pull_request", lambda _window, _number: False) + + menu._review_pull_request(window) + + assert window.events == [("told", "prthinker_need_repository_message")] + + +class TestTheMenu: + def test_its_entries_and_what_they_open(self, window, monkeypatch): + from PySide6.QtWidgets import QMenu + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + update_language_dict() + window.automation_menu = QMenu() + opened: list = [] + monkeypatch.setattr(menu, "open_web_browser", lambda _window, url, _title: opened.append(url)) + monkeypatch.setattr(menu, "_open_setting", lambda _window: opened.append("settings")) + + menu.set_prthinker_menu(window) + window.prthinker_setting_action.trigger() + window.prthinker_doc_action.trigger() + window.prthinker_github_action.trigger() + + labels = [action.text() for action in window.prthinker_menu.actions()] + assert labels == ["Review the current file", "Review a Pull Request", "Settings", "Help"] + assert opened == ["settings", menu.DOCUMENT_URL, menu.GITHUB_URL] + window.automation_menu.deleteLater() + + def test_the_settings_open_as_a_dialog_of_the_window(self, window, monkeypatch): + shown: list = [] + + class Dialog: + def __init__(self, parent): + shown.append(parent) + + def setAttribute(self, *_args): + shown.append("deleted on close") + + def exec(self): + shown.append("shown") + + monkeypatch.setattr(menu, "PRThinkerSettingDialog", Dialog) + + menu._open_setting(window) + + assert shown == [window, "deleted on close", "shown"] diff --git a/test/test_utils/test_prthinker_process.py b/test/test_utils/test_prthinker_process.py index 9f8e3b5c..da6261c8 100644 --- a/test/test_utils/test_prthinker_process.py +++ b/test/test_utils/test_prthinker_process.py @@ -61,7 +61,7 @@ def start_module_process(self, package, arguments, environment=None): RecordingProcess.calls.append((package, list(arguments), dict(environment or {}))) -@pytest.fixture() +@pytest.fixture def recorded(monkeypatch): """Catch the review before it reaches a real process or a real window.""" RecordingProcess.calls = [] @@ -72,7 +72,7 @@ def recorded(monkeypatch): return RecordingProcess.calls -@pytest.fixture() +@pytest.fixture def settings(monkeypatch): """Let each test decide what the settings hold.""" stored = dict(DEFAULT_SETTING) diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index 80f1058a..1a8e7684 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -15,7 +15,7 @@ ) -@pytest.fixture() +@pytest.fixture def data_dir(tmp_path, monkeypatch): """Keep every test's settings file inside its own temporary directory.""" monkeypatch.setattr(prthinker_setting, "pybreeze_data_path", lambda: tmp_path) diff --git a/test/test_utils/test_prthinker_setting_dialog.py b/test/test_utils/test_prthinker_setting_dialog.py index 2bfb7e03..73c646ec 100644 --- a/test/test_utils/test_prthinker_setting_dialog.py +++ b/test/test_utils/test_prthinker_setting_dialog.py @@ -27,14 +27,14 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def data_dir(tmp_path, monkeypatch): """Point the settings file at a temporary directory, never the real home.""" monkeypatch.setattr(prthinker_setting, "pybreeze_data_path", lambda: tmp_path) return tmp_path -@pytest.fixture() +@pytest.fixture def dialog(app, data_dir): made = PRThinkerSettingDialog() yield made diff --git a/test/test_utils/test_public_http.py b/test/test_utils/test_public_http.py index d5afb912..ee3b34dd 100644 --- a/test/test_utils/test_public_http.py +++ b/test/test_utils/test_public_http.py @@ -6,9 +6,11 @@ """ from __future__ import annotations +import http.client import socket import ssl import threading +import time import urllib.error import urllib.request @@ -33,7 +35,7 @@ def _answer(ip: str, port) -> list: return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, int(port or 0)))] -@pytest.fixture() +@pytest.fixture def dns(monkeypatch): """Names ending in ``.test`` answer from a table; ``rebind.test`` public first, then loopback.""" rebind_answers = [_PUBLIC_IP] @@ -49,7 +51,7 @@ def getaddrinfo(host, port=None, *args, **kwargs): monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) -@pytest.fixture() +@pytest.fixture def loopback_allowed(monkeypatch): """Let the checks pass loopback, so a local server can stand in for a public one.""" monkeypatch.setattr(url_validation, "_is_blocked_ip", lambda _ip: False) @@ -108,7 +110,7 @@ def _session() -> requests.Session: return session -@pytest.fixture() +@pytest.fixture def listener(): server = _Listener() yield server @@ -201,8 +203,20 @@ def test_a_proxy_on_the_local_network_is_still_used(self, dns, listener): assert listener.received[0].startswith(b"GET http://rebind.test/i.png HTTP/1.1\r\n") + def test_https_through_a_proxy_is_left_to_the_proxy(self, dns, listener): + # The proxy connects to the name; there is no address here to pin + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"https": f"http://127.0.0.1:{listener.port}"}), + PublicHTTPHandler(), PublicHTTPSHandler()) + + with pytest.raises((urllib.error.URLError, ssl.SSLError, ConnectionError, http.client.HTTPException)): + opener.open("https://rebind.test/i.png", timeout=3) + + listener.close() + assert listener.received[0].startswith(b"CONNECT rebind.test:443 HTTP/1.") -@pytest.fixture() + +@pytest.fixture def two_addresses(monkeypatch): """``two.test`` answers an address nothing listens on first, then loopback.""" def getaddrinfo(host, port=None, *args, **kwargs): @@ -228,6 +242,26 @@ def test_urllib_goes_on_to_the_next_address(self, two_addresses, loopback_allowe with opener.open(f"http://two.test:{listener.port}/", timeout=3) as response: assert response.read() == b"ok" + @staticmethod + def _closed_port() -> int: + """A loopback port nothing listens on any more.""" + probe = socket.socket() + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + probe.close() + return port + + def test_requests_fails_when_no_address_answers(self, two_addresses, loopback_allowed): + # After the last address the error is the connection's own, not a hang or a None raised + with _session() as session, pytest.raises(requests.ConnectionError): + session.get(f"http://two.test:{self._closed_port()}/", timeout=(3, 3)) + + def test_urllib_fails_when_no_address_answers(self, two_addresses, loopback_allowed): + opener = urllib.request.build_opener(PublicHTTPHandler()) + + with pytest.raises(urllib.error.URLError): + opener.open(f"http://two.test:{self._closed_port()}/", timeout=3) + def test_one_blocked_address_still_refuses_the_name(self, two_addresses, listener): with _session() as session, pytest.raises(requests.ConnectionError): session.get(f"http://two.test:{listener.port}/", timeout=(3, 3)) @@ -251,6 +285,55 @@ def getaddrinfo(host, port=None, *args, **kwargs): assert looked_up == ["xn--strae-oqa.de"] +class TestTheDeadlineItself: + """What ``overall_deadline`` promises in the cases no request above runs into.""" + + @staticmethod + def _passed(seconds: float = 0.0): + from pybreeze.utils.network.public_http import _Deadline + + deadline = _Deadline(seconds) + limit = time.monotonic() + 5 + while not deadline.passed and time.monotonic() < limit: + time.sleep(0.01) + return deadline + + def test_a_block_that_ends_after_the_time_is_up_still_times_out(self): + # A read cut off where no length was announced returns normally: the block must not + from pybreeze.utils.network.public_http import overall_deadline + + with pytest.raises(requests.exceptions.ReadTimeout), overall_deadline(0.01): + time.sleep(0.2) + + def test_a_socket_watched_after_the_time_is_up_is_shut_at_once(self): + deadline = self._passed() + mine, theirs = socket.socketpair() + with mine, theirs: + deadline.watch(mine) + theirs.settimeout(2) + assert theirs.recv(1) == b"" # the other end sees it closed + deadline.end() + + def test_nothing_is_shut_once_the_request_is_over(self): + from pybreeze.utils.network.public_http import _Deadline + + deadline = _Deadline(60) + mine, theirs = socket.socketpair() + with mine, theirs: + deadline.watch(mine) + deadline.end() + deadline._pass() # the timer firing late, after end() + assert not deadline.passed + mine.sendall(b"x") + theirs.settimeout(2) + assert theirs.recv(1) == b"x" + + def test_no_socket_is_nothing_to_watch(self): + deadline = self._passed() + deadline.watch(None) + deadline.end() + + class TestTheOverallDeadline: """A read timeout restarts with every byte; the deadline bounds the whole request.""" diff --git a/test/test_utils/test_query_json_gui.py b/test/test_utils/test_query_json_gui.py index 5406f48d..5c03c234 100644 --- a/test/test_utils/test_query_json_gui.py +++ b/test/test_utils/test_query_json_gui.py @@ -19,7 +19,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.query_json_gui import QueryJsonGUI gui = QueryJsonGUI() @@ -58,7 +58,7 @@ def test_empty_to_query_shows_hint(self, widget): def test_copy_output(self, app, widget): widget.input_edit.setPlainText("a=1&b=2") widget.convert_to_json() - widget.actions.copy() + widget.output_actions.copy() assert "a" in QApplication.clipboard().text() diff --git a/test/test_utils/test_questions_default_to_no.py b/test/test_utils/test_questions_default_to_no.py new file mode 100644 index 00000000..962925d1 --- /dev/null +++ b/test/test_utils/test_questions_default_to_no.py @@ -0,0 +1,41 @@ +"""A yes-or-no question names its default button, so Enter does not answer Yes by itself. + +Given Yes and No and no default, Qt makes Yes the default: Enter, or a key press +meant for the window behind, deleted a file on the SFTP server or in the project, +or dropped the diagram being drawn. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pybreeze + +# QMessageBox.question(parent, title, text, buttons, defaultButton) +_DEFAULT_BUTTON_POSITION = 4 + + +def _questions_without_a_default() -> list[str]: + found = [] + for path in Path(pybreeze.__file__).parent.rglob("*.py"): + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr == "question" and ast.unparse(node.func.value) == "QMessageBox"): + continue + named = any(keyword.arg == "defaultButton" for keyword in node.keywords) + if not named and len(node.args) <= _DEFAULT_BUTTON_POSITION: + found.append(f"{path.name}:{node.lineno}") + return found + + +def test_every_question_names_its_default_button(): + assert _questions_without_a_default() == [] + + +def test_the_check_sees_the_questions(): + # Nine questions ask in the package; a check that saw none would pass on anything + count = sum( + 1 for path in Path(pybreeze.__file__).parent.rglob("*.py") + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "question") + assert count >= 9 diff --git a/test/test_utils/test_regex_gui.py b/test/test_utils/test_regex_gui.py index e49aaf7d..2a861654 100644 --- a/test/test_utils/test_regex_gui.py +++ b/test/test_utils/test_regex_gui.py @@ -26,7 +26,7 @@ def run(widget) -> None: QApplication.processEvents() -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.regex_gui import RegexGUI gui = RegexGUI() @@ -86,7 +86,7 @@ def test_copy_output(self, app, widget): widget.pattern_edit.setText(r"\d+") widget.text_edit.setPlainText("a1") run(widget) - widget.actions.copy() + widget.output_actions.copy() assert "'1'" in QApplication.clipboard().text() def test_build_matches_text_no_matches(self, app): diff --git a/test/test_utils/test_regex_tester.py b/test/test_utils/test_regex_tester.py index e5af7424..89ef704a 100644 --- a/test/test_utils/test_regex_tester.py +++ b/test/test_utils/test_regex_tester.py @@ -206,3 +206,60 @@ def test_the_packaged_builds_spawned_worker_finds_matches(self): assert found == regex_tester.find_matches(r"\d+", "a1 b22") assert not regex_tester._RUNNING + + def test_the_packaged_builds_worker_is_stopped_when_it_runs_too_long(self): + import time + + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools import regex_tester + + started = time.monotonic() + with pytest.raises(RegexTesterException, match="still running"): + regex_tester._find_in_spawned_process("(a+)+$", "a" * 40 + "b", [], 3.0) + + assert time.monotonic() - started < 20 + assert not regex_tester._RUNNING + + def test_the_packaged_builds_worker_reports_a_pattern_it_cannot_run(self): + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools import regex_tester + + with pytest.raises(RegexTesterException): + regex_tester._find_in_spawned_process("(", "abc", [], 30) + + assert not regex_tester._RUNNING + + +class TestWhatTheSpawnedWorkerSends: + """_matches_into_pipe, run here with a stand-in for its end of the pipe.""" + + class _Pipe: + def __init__(self) -> None: + self.sent: list = [] + self.closed = False + + def send(self, message) -> None: + self.sent.append(message) + + def close(self) -> None: + self.closed = True + + def test_the_matches(self): + from pybreeze.utils.regex_tools.regex_tester import _matches_into_pipe + + pipe = self._Pipe() + _matches_into_pipe(pipe, r"\d+", "a1 b22", []) + + assert pipe.sent == [("matches", find_matches(r"\d+", "a1 b22"))] + assert [match.matched_text for match in pipe.sent[0][1]] == ["1", "22"] + assert pipe.closed + + def test_the_error_for_a_pattern_that_does_not_compile(self): + from pybreeze.utils.regex_tools.regex_tester import _matches_into_pipe + + pipe = self._Pipe() + _matches_into_pipe(pipe, "(", "abc", []) + + ((kind, message),) = pipe.sent + assert kind == "error" and message + assert pipe.closed diff --git a/test/test_utils/test_response_inspector_gui.py b/test/test_utils/test_response_inspector_gui.py index 1ba8ad2d..bd181bdf 100644 --- a/test/test_utils/test_response_inspector_gui.py +++ b/test/test_utils/test_response_inspector_gui.py @@ -27,7 +27,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI gui = ResponseInspectorGUI() @@ -65,7 +65,7 @@ def test_empty_shows_hint(self, widget): def test_copy_output(self, app, widget): widget.input_edit.setPlainText('{"a": 1}') widget.analyze() - widget.actions.copy() + widget.output_actions.copy() assert '"a": 1' in QApplication.clipboard().text() def test_build_report_text_minimal(self, app): @@ -94,7 +94,7 @@ def __init__(self): self.tab_widget = _FakeTabWidget() -@pytest.fixture() +@pytest.fixture def widget_with_window(app): from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI window = _FakeMainWindow() diff --git a/test/test_utils/test_run_folder.py b/test/test_utils/test_run_folder.py index 6d7d02be..96f84feb 100644 --- a/test/test_utils/test_run_folder.py +++ b/test/test_utils/test_run_folder.py @@ -21,7 +21,7 @@ def window(): made.deleteLater() -@pytest.fixture() +@pytest.fixture def picked(monkeypatch): """The folder the dialog returns, and the parents it was opened with.""" state = {"folder": "", "parents": [], "told": []} diff --git a/test/test_utils/test_run_notice.py b/test/test_utils/test_run_notice.py new file mode 100644 index 00000000..07f92f11 --- /dev/null +++ b/test/test_utils/test_run_notice.py @@ -0,0 +1,74 @@ +"""A run window's own notices ([Error], [Run], [Mail] ...) in the IDE language.""" +from __future__ import annotations + +import pathlib +import re + +import pybreeze +from pybreeze.extend.process_executor import run_notice as run_notice_mod +from pybreeze.extend.process_executor.run_notice import RUN_NOTICE_KEY_PREFIX, run_notice +from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict as ENGLISH +from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as CHINESE, +) + + +def test_a_notice_in_chinese(monkeypatch): + # "[Error] Command not found: gcc" whatever the IDE spoke + monkeypatch.setattr(run_notice_mod.language_wrapper, "language_word_dict", CHINESE) + + assert run_notice("command_not_found", command="gcc") == "[錯誤] 找不到指令:gcc\n" + assert run_notice("run", name="build/main.exe") == "[執行] build/main.exe\n" + + +def test_english_when_the_ide_dictionary_lacks_the_notice(monkeypatch): + # An executor started before update_language_dict() (a script, a test) + monkeypatch.setattr(run_notice_mod.language_wrapper, "language_word_dict", {}) + + assert run_notice("compile_failed", code=3) == "[Compile failed] exit code 3\n" + + +def test_every_notice_the_executors_use_is_in_both_dictionaries(): + executors = pathlib.Path(pybreeze.__file__).parent / "extend" / "process_executor" + used = {name for path in executors.rglob("*.py") + for name in re.findall(r'run_notice\(\s*"(\w+)"', path.read_text(encoding="utf-8"))} + + assert used + assert not {RUN_NOTICE_KEY_PREFIX + name for name in used} - set(ENGLISH) + assert not {RUN_NOTICE_KEY_PREFIX + name for name in used} - set(CHINESE) + + +def test_no_executor_writes_an_english_notice_of_its_own(): + # They were f"[Error] ..." literals in append_output and the mail notice + executors = pathlib.Path(pybreeze.__file__).parent / "extend" / "process_executor" + literal = re.compile(r'f?"\[(?:Error|Run|Compile|Compile failed|Stopped|Mail)\]') + offenders = [path.name for path in executors.rglob("*.py") if literal.search(path.read_text(encoding="utf-8"))] + assert not offenders + + +def test_why_a_report_mail_was_not_sent_in_chinese(monkeypatch): + # "[郵件] 沒有寄出測試報告:no mail user is set" + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + + from pybreeze.extend.process_executor.process_executor_utils import _MailNotice + from pybreeze.utils.exception.exception_tags import mail_no_user_error + + QApplication.instance() or QApplication([]) + monkeypatch.setattr(run_notice_mod.language_wrapper, "language_word_dict", CHINESE) + notice = _MailNotice() + told: list = [] + notice.told.connect(lambda text, is_error: told.append((text, is_error))) + + notice.tell(mail_no_user_error) + + assert told == [("[郵件] 沒有寄出測試報告:沒有設定郵件使用者\n", True)] + + +def test_the_exit_code_and_held_output_lines_in_chinese(monkeypatch): + monkeypatch.setattr(run_notice_mod.language_wrapper, "language_word_dict", CHINESE) + + assert run_notice("exit_code", code=0) == "執行結束,結束代碼 0\n" + assert run_notice("output_still_held").startswith("[這次執行啟動的某個行程仍握著輸出") diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index 14d2c52e..8177958d 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -221,8 +221,8 @@ def test_runs_the_chosen_yaml_through_the_task_manager(self, monkeypatch): calls: list = [] class Recorder: - def start_module_process(self, package, arguments, environment=None): - calls.append((package, list(arguments), environment)) + def start_module_process(self, package, arguments, environment=None, subject=""): + calls.append((package, list(arguments), environment, subject)) monkeypatch.setattr( test_pioneer_process_manager, "build_task_process", @@ -231,7 +231,8 @@ def start_module_process(self, package, arguments, environment=None): test_pioneer_process_manager.init_and_start_test_pioneer_process( MainWindow(), "C:/tests/run.yml") - assert calls == [("test_pioneer", ["-e", "C:/tests/run.yml"], None)] + # The run window is titled with the file's name + assert calls == [("test_pioneer", ["-e", "C:/tests/run.yml"], None, "run.yml")] def test_no_interpreter_is_reported_not_raised(self, qt_app, monkeypatch): from je_editor import JEditorExecException @@ -623,3 +624,49 @@ def test_stop_ends_what_the_run_started_too(qt_app, tmp_path): finally: _stop_grandchild(run_window.code_result.toPlainText()) + + +class TestTextInAnyLanguage: + """A folder and output in Chinese, and a character outside the BMP, reach the run window intact.""" + + _TEXT = "你好,世界 🙂 naïve" + + def test_a_package_run(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + folder = tmp_path / "中文資料夾" + folder.mkdir() + data = folder / "資料.json" + data.write_text(json.dumps({"問候": self._TEXT}, ensure_ascii=False), encoding="utf-8") + process = build_task_process(MainWindow(sys.executable)) + + process.start_module_process("json.tool", ["--no-ensure-ascii", str(data)]) + _run_events_until(qt_app, lambda: process.process is None) + + text = process.main_window.code_result.toPlainText() + assert f'"問候": "{self._TEXT}"' in text + assert text.endswith("Task exit with code 0\n") + + def test_a_run_with_run(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + folder = tmp_path / "中文資料夾" + folder.mkdir() + script = folder / "腳本.py" + script.write_text( + "import sys\n" + f"print({self._TEXT!r})\n" + f"print('錯誤:' + {self._TEXT!r}, file=sys.stderr)\n", + encoding="utf-8", + ) + window = CodeWindow() + runner = FileRunnerProcess(window) + + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + _run_events_until(qt_app, lambda: runner.process is None) + + text = window.code_result.toPlainText() + assert f"{self._TEXT}\n" in text + assert f"錯誤:{self._TEXT}\n" in text + assert text.endswith("[Process exited with code 0]\n") diff --git a/test/test_utils/test_run_window_title.py b/test/test_utils/test_run_window_title.py new file mode 100644 index 00000000..a135d3a5 --- /dev/null +++ b/test/test_utils/test_run_window_title.py @@ -0,0 +1,62 @@ +"""A run window says what it runs: the package, and the file when there is one. + +Running a folder opens one window per file, and every one was titled with the +package alone (``je_api_testka``), so nothing told them apart; the plugin run +windows already read ``Run - main.go``. +""" +from __future__ import annotations + +import os +import sys +import time + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +# A module that runs, ignores its arguments and ends at once +_PACKAGE = "this" + + +@pytest.fixture(scope="module") +def app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def manager(app): + from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + runner = TaskProcessManager(window) + runner.renew_path = lambda: True + runner.compiler_path = sys.executable + yield runner + deadline = time.monotonic() + 30 + while runner.still_run_program and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.02) + window.close() + + +def test_a_file_run_names_the_file(manager, tmp_path): + manager.start_test_process_file(_PACKAGE, str(tmp_path / "login_flow.json")) + assert manager.main_window.windowTitle() == f"{_PACKAGE} - login_flow.json" + + +def test_a_script_run_names_the_tab_it_came_from(manager): + manager.start_test_process(_PACKAGE, "[]", subject="checkout.json") + assert manager.main_window.windowTitle() == f"{_PACKAGE} - checkout.json" + + +def test_a_script_from_nowhere_names_the_package(manager): + manager.start_test_process(_PACKAGE, "[]") + assert manager.main_window.windowTitle() == _PACKAGE + + +def test_a_module_run_names_what_it_was_given(manager): + manager.start_module_process(_PACKAGE, ["-e", "suite.yml"], subject="suite.yml") + assert manager.main_window.windowTitle() == f"{_PACKAGE} - suite.yml" diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py index db53eef7..411ec4c2 100644 --- a/test/test_utils/test_sftp_tree_actions.py +++ b/test/test_utils/test_sftp_tree_actions.py @@ -66,7 +66,7 @@ def close(self) -> None: """Nothing to close.""" -@pytest.fixture() +@pytest.fixture def tree(app, monkeypatch): client = FakeClient({ "/": [_entry("src", directory=True), _entry("notes.txt")], @@ -371,3 +371,129 @@ def refuse(_path: str) -> None: _wait_for(lambda: _idle(widget)) assert len(shown) == 1 and "empty" not in shown[0] and "Permission denied" in shown[0] + + +class TestTheKeys: + """F2 renames and Delete deletes the entry in focus, as in the project tree (U-20260925-35).""" + + @staticmethod + def _shortcut(widget, keys: str): + from PySide6.QtCore import Qt + from PySide6.QtGui import QKeySequence, QShortcut + + found = [shortcut for shortcut in widget.tree.findChildren(QShortcut) if shortcut.key() == QKeySequence(keys)] + assert len(found) == 1, keys + assert found[0].context() == Qt.ShortcutContext.WidgetShortcut # only while the tree has the focus + return found[0] + + @pytest.mark.parametrize(("keys", "action"), [("F2", "action_rename"), ("Del", "action_delete")]) + def test_the_key_acts_on_the_current_entry(self, tree, monkeypatch, keys, action): + widget, _client, root, _warnings = tree + asked: list = [] + monkeypatch.setattr(widget, action, asked.append) + notes = _child(root, "notes.txt") + widget.tree.setCurrentItem(notes) + + self._shortcut(widget, keys).activated.emit() + + assert asked == [notes] + + def test_a_dropped_session_is_reported_not_raised(self, tree, monkeypatch): + widget, _client, root, _warnings = tree + reported: list = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: reported.append(args[2])) + + def dropped(_item): + raise OSError("Socket is closed") + + monkeypatch.setattr(widget, "action_delete", dropped) + widget.tree.setCurrentItem(_child(root, "notes.txt")) + + self._shortcut(widget, "Del").activated.emit() + + assert reported and "Socket is closed" in reported[0] + + +class TestDownloadAndUpload: + """The dialogs in front of a transfer, and what the transfer is then asked to do.""" + + @staticmethod + def _transfers(widget, monkeypatch) -> list[dict]: + started: list[dict] = [] + monkeypatch.setattr(widget, "_start_transfer", lambda **kwargs: started.append(kwargs) or True) + return started + + @staticmethod + def _informed(monkeypatch) -> list: + told: list = [] + monkeypatch.setattr(tree_mod.QMessageBox, "information", lambda *args: told.append(args[2])) + return told + + def test_a_file_downloads_to_where_it_is_saved(self, tree, monkeypatch, tmp_path): + widget, _client, root, _warnings = tree + started = self._transfers(widget, monkeypatch) + offered: list = [] + target = str(tmp_path / "copy.txt") + monkeypatch.setattr(tree_mod.QFileDialog, "getSaveFileName", + staticmethod(lambda _parent, _title, name: offered.append(name) or (target, ""))) + + widget.action_download(_child(root, "notes.txt")) + + assert offered == ["notes.txt"] + assert [(s["downloading"], s["remote_path"], s["local_path"]) for s in started] == [ + (True, "/notes.txt", target)] + + def test_a_cancelled_save_downloads_nothing(self, tree, monkeypatch): + widget, _client, root, _warnings = tree + started = self._transfers(widget, monkeypatch) + monkeypatch.setattr(tree_mod.QFileDialog, "getSaveFileName", staticmethod(lambda *args: ("", ""))) + + widget.action_download(_child(root, "notes.txt")) + + assert started == [] + + def test_a_folder_is_not_downloaded(self, tree, monkeypatch): + widget, _client, root, _warnings = tree + started = self._transfers(widget, monkeypatch) + told = self._informed(monkeypatch) + + widget.action_download(_child(root, "src")) + + assert started == [] + assert len(told) == 1 + + @pytest.mark.parametrize(("name", "remote"), [("src", "/src/upload.bin"), ("notes.txt", "/upload.bin")]) + def test_an_upload_goes_into_the_folder_chosen(self, tree, monkeypatch, tmp_path, name, remote): + # A file chosen in the tree stands for the folder it is in + widget, _client, root, _warnings = tree + started = self._transfers(widget, monkeypatch) + local = tmp_path / "upload.bin" + local.write_bytes(b"x") + monkeypatch.setattr(tree_mod.QFileDialog, "getOpenFileName", staticmethod(lambda *args: (str(local), ""))) + refreshed: list = [] + monkeypatch.setattr(widget, "action_refresh", lambda item: refreshed.append(item.text(3))) + + widget.action_upload(_child(root, name)) + started[0]["after"]() + + assert [(s["downloading"], s["remote_path"], s["local_path"]) for s in started] == [ + (False, remote, str(local))] + assert refreshed == [remote.rsplit("/", 1)[0] or "/"] + + def test_a_cancelled_choice_uploads_nothing(self, tree, monkeypatch): + widget, _client, root, _warnings = tree + started = self._transfers(widget, monkeypatch) + monkeypatch.setattr(tree_mod.QFileDialog, "getOpenFileName", staticmethod(lambda *args: ("", ""))) + + widget.action_upload(_child(root, "src")) + + assert started == [] + + def test_a_folder_needs_a_place_to_be_created_in(self, tree, monkeypatch): + widget, client, _root, _warnings = tree + told = self._informed(monkeypatch) + + widget.action_create_folder(None) + + assert len(told) == 1 + assert client.made == [] diff --git a/test/test_utils/test_sftp_upload.py b/test/test_utils/test_sftp_upload.py index 20583165..9f24a978 100644 --- a/test/test_utils/test_sftp_upload.py +++ b/test/test_utils/test_sftp_upload.py @@ -75,7 +75,7 @@ def _wrapper(sftp: FakeSftp) -> sftp_session.SFTPClientWrapper: return wrapper -@pytest.fixture() +@pytest.fixture def local_file(tmp_path): path = tmp_path / "config.yaml" path.write_bytes(b"new config") diff --git a/test/test_utils/test_skills_panel.py b/test/test_utils/test_skills_panel.py index e9b28f16..416ada4f 100644 --- a/test/test_utils/test_skills_panel.py +++ b/test/test_utils/test_skills_panel.py @@ -29,7 +29,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def panel(tmp_path, monkeypatch): # The built-in prompts, never the user's own monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: tmp_path) @@ -38,7 +38,7 @@ def panel(tmp_path, monkeypatch): widget.deleteLater() -@pytest.fixture() +@pytest.fixture def answer(monkeypatch): """What the replace question gets, and how often it was asked.""" state = {"reply": QMessageBox.StandardButton.No, "asked": 0} diff --git a/test/test_utils/test_ssh_host_key_policy.py b/test/test_utils/test_ssh_host_key_policy.py index 2960d2c6..04066842 100644 --- a/test/test_utils/test_ssh_host_key_policy.py +++ b/test/test_utils/test_ssh_host_key_policy.py @@ -27,7 +27,7 @@ def keys(): return paramiko.RSAKey.generate(1024), paramiko.RSAKey.generate(1024) -@pytest.fixture() +@pytest.fixture def asked(app, tmp_path, monkeypatch): """Answers every question with ``asked["answer"]`` and counts them.""" state = {"answer": True, "count": 0} @@ -223,3 +223,51 @@ def locked(self): monkeypatch.setattr(Path, "read_bytes", real_read) assert known.read_bytes() == b"trusted.example ssh-ed25519 AAAA\n" + + +def test_a_file_without_a_last_newline_keeps_its_last_host(asked, keys): + # Written straight after it, the new key would join the last line and spoil both + _meet("first.example", keys[0]) + path = policy_mod._known_hosts_path() + path.write_bytes(path.read_bytes().rstrip(b"\n")) + + _meet("second.example", keys[1]) + + known = policy_mod._read_known_hosts() + assert known.lookup("first.example")["ssh-rsa"] == keys[0] + assert known.lookup("second.example")["ssh-rsa"] == keys[1] + + +class TestThePanelThatAsked: + def test_its_question_carries_the_panel(self, asked, keys, monkeypatch): + from PySide6.QtWidgets import QWidget + + parents: list = [] + + class Asker: + def ask(self, parent, _title, _message) -> bool: + parents.append(parent) + return False + + monkeypatch.setattr(policy_mod, "host_key_asker", Asker) + panel = QWidget() + with pytest.raises(paramiko.SSHException): # the answer was No + policy_mod.InteractiveHostKeyPolicy(panel).missing_host_key( + paramiko.SSHClient(), "host.example", keys[0]) + + assert parents == [panel] + panel.deleteLater() + + def test_a_panel_closed_before_the_question_is_a_no(self, asked, keys): + import gc + + from PySide6.QtWidgets import QWidget + + panel = QWidget() + policy = policy_mod.InteractiveHostKeyPolicy(panel) + del panel + gc.collect() + + with pytest.raises(paramiko.SSHException): + policy.missing_host_key(paramiko.SSHClient(), "host.example", keys[0]) + assert asked["count"] == 0 # nobody was asked on its behalf diff --git a/test/test_utils/test_ssh_login_form.py b/test/test_utils/test_ssh_login_form.py index 8fb03273..678f909a 100644 --- a/test/test_utils/test_ssh_login_form.py +++ b/test/test_utils/test_ssh_login_form.py @@ -19,7 +19,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def form(app): widget = login_mod.LoginWidget() yield widget @@ -71,3 +71,25 @@ def test_cancelling_changes_nothing(self, form, monkeypatch): assert form.key_edit.text() == "typed" assert not form.use_key_check.isChecked() + + +class TestTheSecretField: + """With key authentication the password field holds the key's passphrase, and says so.""" + + def _words(self): + from je_editor import language_wrapper + return language_wrapper.language_word_dict + + def test_it_asks_for_the_password_at_first(self, form): + assert form.pass_label.text() == self._words().get("ssh_login_widget_label_password") + + def test_key_authentication_makes_it_the_passphrase(self, form): + form.use_key_check.setChecked(True) + assert form.pass_label.text() == self._words().get("ssh_login_widget_label_passphrase") + assert form.pass_edit.placeholderText() == self._words().get("ssh_login_widget_placeholder_passphrase") + + def test_unticking_it_gives_the_password_back(self, form): + form.use_key_check.setChecked(True) + form.use_key_check.setChecked(False) + assert form.pass_label.text() == self._words().get("ssh_login_widget_label_password") + assert form.pass_edit.placeholderText() == self._words().get("ssh_login_widget_placeholder_password") diff --git a/test/test_utils/test_ssh_reentrancy.py b/test/test_utils/test_ssh_reentrancy.py index 94ab57aa..83dc9925 100644 --- a/test/test_utils/test_ssh_reentrancy.py +++ b/test/test_utils/test_ssh_reentrancy.py @@ -406,3 +406,78 @@ def close(self) -> None: sftp_session.SFTPClientWrapper().connect("host", 22, "user", "pw") assert connects[0]["disabled_algorithms"] is sftp_session.SHA1_ALGORITHMS + + +class TestKeyAuthentication: + @staticmethod + def _key_file(tmp_path, password: str | None = None) -> str: + import paramiko + + path = tmp_path / "id_rsa" + paramiko.RSAKey.generate(2048).write_private_key_file(str(path), password=password) + return str(path) + + @staticmethod + def _use_key(widget, key_path: str, passphrase: str = "") -> None: + widget.login_widget.use_key_check.setChecked(True) + widget.login_widget.key_edit.setText(key_path) + widget.login_widget.pass_edit.setText(passphrase) + + def test_it_refuses_sha1_as_the_password_does(self, app, monkeypatch, tmp_path): + import paramiko + + client = FakeClient() + widget = _shell(monkeypatch, client) + widget._start_shell = lambda *args: None + self._use_key(widget, self._key_file(tmp_path)) + + widget.connect_ssh() + _wait_for(lambda: client.connect_options and not widget._connecting.isRunning()) + + assert client.connect_options["disabled_algorithms"] is shell_mod.SHA1_ALGORITHMS + assert isinstance(client.connect_options["pkey"], paramiko.RSAKey) + assert "password" not in client.connect_options + + def test_a_key_needing_a_passphrase_says_so_and_connects_nothing(self, app, monkeypatch, tmp_path): + client = FakeClient() + widget = _shell(monkeypatch, client) + self._use_key(widget, self._key_file(tmp_path, password="secret")) + + widget.connect_ssh() + _wait_for(lambda: not widget._connecting.isRunning()) + _wait_for(lambda: widget.ssh_client is None) + + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import PASSPHRASE_NEEDED + + assert client.connect_options == {} + assert widget.word_dict.get(PASSPHRASE_NEEDED) in widget.terminal.toPlainText() + + +class TestConnectInputs: + @staticmethod + def _warned(monkeypatch) -> list: + warned: list = [] + monkeypatch.setattr(shell_mod.QMessageBox, "warning", lambda *args: warned.append(args[2])) + return warned + + @pytest.mark.parametrize("empty", ["host_edit", "user_edit"]) + def test_host_and_user_are_required(self, app, monkeypatch, empty): + widget = _shell(monkeypatch, FakeClient()) + getattr(widget.login_widget, empty).setText(" ") + warned = self._warned(monkeypatch) + + widget.connect_ssh() + + assert len(warned) == 1 + assert widget._connecting is None + + def test_a_key_file_that_is_not_there_is_refused(self, app, monkeypatch, tmp_path): + widget = _shell(monkeypatch, FakeClient()) + widget.login_widget.use_key_check.setChecked(True) + widget.login_widget.key_edit.setText(str(tmp_path / "missing_key")) + warned = self._warned(monkeypatch) + + widget.connect_ssh() + + assert len(warned) == 1 + assert widget._connecting is None diff --git a/test/test_utils/test_ssh_screen_clear.py b/test/test_utils/test_ssh_screen_clear.py new file mode 100644 index 00000000..6ceaf0b5 --- /dev/null +++ b/test/test_utils/test_ssh_screen_clear.py @@ -0,0 +1,88 @@ +"""`clear` and `reset` wipe the SSH terminal, as they do a terminal's screen.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget, TerminalDecoder +from pybreeze.utils.terminal_style import PLAIN, TextStyle +from pybreeze.utils.terminal_text import split_at_screen_clear + +# What `clear` sends with TERM=xterm: home, erase the screen, erase the scrollback +CLEAR = b"\x1b[H\x1b[2J\x1b[3J" + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class TestSplitAtScreenClear: + @pytest.mark.parametrize("sequence", ["\x1b[2J", "\x1b[3J", "\x1bc"]) + def test_what_follows_the_last_clear_is_what_is_left(self, sequence): + assert split_at_screen_clear(f"old{sequence}mid\x1b[2Jnew") == (f"old{sequence}mid", "\x1b[2J", "new") + assert split_at_screen_clear(f"old{sequence}new") == ("old", sequence, "new") + + @pytest.mark.parametrize("text", ["plain", "\x1b[J prompt redrawn", "\x1b[0J", "\x1b[1J", "\x1b[2K line"]) + def test_erasing_less_than_the_screen_is_not_a_clear(self, text): + # Shells send ESC [ J to redraw a prompt: that must not wipe the view + assert split_at_screen_clear(text) is None + + +class TestTheDecoder: + def test_a_clear_is_reported_with_only_what_follows_it(self): + output = TerminalDecoder().feed(b"old output\r\n" + CLEAR + b"$ ") + + assert output.clears_screen + assert output.pieces == [(PLAIN, "$ ")] + + def test_a_clear_cut_between_reads_is_still_one(self): + decoder = TerminalDecoder() + + first = decoder.feed(b"old\r\n\x1b[H\x1b[2") + second = decoder.feed(b"J\x1b[3J$ ") + + assert not first.clears_screen + assert second.clears_screen + assert "".join(text for _style, text in second.pieces) == "$ " + + def test_a_colour_set_before_the_clear_carries_on(self): + output = TerminalDecoder().feed(b"\x1b[31mred" + CLEAR + b"still red") + + assert output.pieces == [(TextStyle(foreground=1), "still red")] + + def test_a_full_reset_drops_the_colour(self): + output = TerminalDecoder().feed(b"\x1b[31mred\x1bcplain") + + assert output.clears_screen + assert output.pieces == [(PLAIN, "plain")] + + +class TestTheTerminal: + def test_clear_wipes_the_view(self, app): + # The sequences were removed and nothing else happened: `clear` left + # everything on the screen + widget = SSHCommandWidget() + widget._on_data(b"line 1\r\nline 2\r\n$ clear\r\n") + + widget._on_data(CLEAR + b"$ ") + + assert widget.terminal.toPlainText() == "$ " + widget.close() + + def test_a_rewind_left_before_the_clear_is_forgotten(self, app): + widget = SSHCommandWidget() + widget._on_data(b"50%\r\x1b[32m") # ends on a rewind still to be applied + + widget._on_data(CLEAR + b"$ ") + + assert widget.terminal.toPlainText() == "$ " + assert not widget._rewind_pending + widget.close() diff --git a/test/test_utils/test_ssh_security.py b/test/test_utils/test_ssh_security.py index 44644b7c..dd54d628 100644 --- a/test/test_utils/test_ssh_security.py +++ b/test/test_utils/test_ssh_security.py @@ -125,10 +125,97 @@ def test_each_reason_has_a_message(self): ) from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_key_loader - for key in (ssh_key_loader.UNSUPPORTED_KEY, ssh_key_loader.PASSPHRASE_NEEDED, ssh_key_loader.PASSPHRASE_WRONG): + for key in (ssh_key_loader.UNSUPPORTED_KEY, ssh_key_loader.PASSPHRASE_NEEDED, ssh_key_loader.PASSPHRASE_WRONG, + ssh_key_loader.PUTTY_KEY): assert english_word_dict.get(key) and traditional_chinese_word_dict.get(key) +def _pkcs8_key_file(tmp_path, kind: str, passphrase: bytes | None): + """A PKCS#8 key file (BEGIN PRIVATE KEY / BEGIN ENCRYPTED PRIVATE KEY) and its public key.""" + import warnings + + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # cryptography deprecates DSA + key = { + "rsa": lambda: rsa.generate_private_key(65537, 2048), + "ed25519": ed25519.Ed25519PrivateKey.generate, + "ecdsa": lambda: ec.generate_private_key(ec.SECP256R1()), + "dsa": lambda: dsa.generate_private_key(1024), + }[kind]() + public = key.public_key().public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH) + encryption = (serialization.BestAvailableEncryption(passphrase) if passphrase + else serialization.NoEncryption()) + path = tmp_path / f"{kind}.pem" + path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, encryption)) + return str(path), public.split()[1].decode("ascii") + + +class TestPkcs8Keys: + """openssl genpkey and ssh-keygen -m PKCS8 write PKCS#8, which paramiko does not read.""" + + @pytest.mark.parametrize("kind", ["rsa", "ed25519", "ecdsa"]) + @pytest.mark.parametrize("passphrase", [None, b"right"], ids=["plain", "encrypted"]) + def test_the_key_loads(self, tmp_path, kind, passphrase): + path, public = _pkcs8_key_file(tmp_path, kind, passphrase) + loaded = load_private_key(path, passphrase.decode() if passphrase else "") + assert loaded is not None + assert loaded.get_base64() == public + + def test_a_passphrase_given_for_a_plain_key_is_ignored(self, tmp_path): + # as paramiko ignores it for a plain OpenSSH key + path, public = _pkcs8_key_file(tmp_path, "rsa", None) + assert load_private_key(path, "typed anyway").get_base64() == public + + def test_the_passphrase_is_named(self, tmp_path): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import ( + PASSPHRASE_NEEDED, PASSPHRASE_WRONG, unloadable_key_reason + ) + + path, _public = _pkcs8_key_file(tmp_path, "ed25519", b"right") + assert load_private_key(path, "wrong") is None + assert unloadable_key_reason(path, "wrong") == PASSPHRASE_WRONG + assert load_private_key(path, "") is None + assert unloadable_key_reason(path, "") == PASSPHRASE_NEEDED + + @pytest.mark.parametrize("passphrase", [None, b"right"], ids=["plain", "encrypted"]) + def test_a_type_paramiko_cannot_use_is_still_unsupported(self, tmp_path, passphrase): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import UNSUPPORTED_KEY, unloadable_key_reason + + path, _public = _pkcs8_key_file(tmp_path, "dsa", passphrase) + typed = passphrase.decode() if passphrase else "" + assert load_private_key(path, typed) is None + assert unloadable_key_reason(path, typed) == UNSUPPORTED_KEY + + +class TestPuttyKeys: + """paramiko reads no PuTTY key; the login form offered .ppk and then called it invalid.""" + + _PPK = ("PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: laptop\n" + "Public-Lines: 1\nAAAAC3NzaC1lZDI1NTE5AAAAIA==\nPrivate-Lines: 1\nAAAAIA==\n" + "Private-MAC: 00\n") + + @pytest.mark.parametrize("typed", ["", "a passphrase"]) + def test_the_user_is_told_to_export_it_as_openssh(self, tmp_path, typed): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import PUTTY_KEY, unloadable_key_reason + + path = tmp_path / "laptop.ppk" + path.write_text(self._PPK, encoding="ascii") + assert load_private_key(str(path), typed) is None + assert unloadable_key_reason(str(path), typed) == PUTTY_KEY + + def test_the_login_form_does_not_offer_it(self): + from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict, + ) + + for words in (pybreeze_english_word_dict, pybreeze_traditional_chinese_word_dict): + assert ".ppk" not in words["ssh_login_widget_placeholder_private_key"] + + class TestSha1Algorithms: def test_a_transport_given_them_offers_no_sha1(self): import socket diff --git a/test/test_utils/test_ssh_terminal_output.py b/test/test_utils/test_ssh_terminal_output.py index 3f1a9eee..a80eb1f1 100644 --- a/test/test_utils/test_ssh_terminal_output.py +++ b/test/test_utils/test_ssh_terminal_output.py @@ -7,14 +7,21 @@ import paramiko import pytest +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import ( + CommandHistory, SSHCommandWidget, SSHReaderThread, TerminalDecoder, ) +from pybreeze.pybreeze_ui.terminal_view import terminal_size +from pybreeze.utils.terminal_style import PLAIN @pytest.fixture(scope="module") @@ -24,20 +31,25 @@ def app(): return instance +def _text(output) -> str: + """What the decoder's output shows, its styles left out.""" + return "".join(text for _style, text in output.pieces) + + class TestTerminalDecoder: @pytest.mark.parametrize("cut", range(1, 6)) def test_a_character_cut_between_reads_is_joined(self, cut): data = "中文".encode("utf-8") # six bytes, two characters decoder = TerminalDecoder() - assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "中文" + assert _text(decoder.feed(data[:cut])) + _text(decoder.feed(data[cut:])) == "中文" @pytest.mark.parametrize("cut", range(1, 5)) def test_an_escape_cut_between_reads_is_still_removed(self, cut): data = b"\x1b[31mred" decoder = TerminalDecoder() - assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "red" + assert _text(decoder.feed(data[:cut])) + _text(decoder.feed(data[cut:])) == "red" @pytest.mark.parametrize("data", [b"\x1b(Bok", b"\x1b]0;title\x1b\\ok", b"\x1bP1$r0m\x1b\\ok"]) def test_a_character_set_or_string_escape_cut_anywhere_is_removed(self, data): @@ -45,13 +57,13 @@ def test_a_character_set_or_string_escape_cut_anywhere_is_removed(self, data): for cut in range(1, len(data) - 2): decoder = TerminalDecoder() - assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "ok", cut + assert _text(decoder.feed(data[:cut])) + _text(decoder.feed(data[cut:])) == "ok", cut def test_text_before_an_unfinished_escape_is_shown_now(self): decoder = TerminalDecoder() - assert decoder.feed(b"ready \x1b[") == "ready " - assert decoder.feed(b"0mgo") == "go" + assert _text(decoder.feed(b"ready \x1b[")) == "ready " + assert _text(decoder.feed(b"0mgo")) == "go" def test_a_very_long_unterminated_sequence_is_not_held_forever(self): decoder = TerminalDecoder() @@ -68,7 +80,7 @@ def test_reset_forgets_what_was_carried_over(self): decoder.reset() - assert decoder.feed(b"plain") == "plain" + assert _text(decoder.feed(b"plain")) == "plain" class TestTheTerminal: @@ -105,6 +117,24 @@ def test_a_line_ending_cut_between_reads_is_one_line_break(self, app): assert widget.terminal.toPlainText() == "line1\nline2\n" widget.close() + def test_a_progress_bar_redraws_its_line(self, app): + # Each "\r" was a line break: one line per step of pip's or wget's bar + widget = self._widget() + + for chunk in (b"start\r\n", b" 10%\r", b" 20%\r", b"100%\r\n", b"done\r\n"): + widget._on_data(chunk) + + assert widget.terminal.toPlainText() == "start\n100%\ndone\n" + widget.close() + + def test_a_rewind_and_its_text_in_one_read_redraw_the_line(self, app): + widget = self._widget() + + widget._on_data(b"a 10%\r a 20%\r\x1b[Ka100%\r\ndone\r\n") + + assert widget.terminal.toPlainText() == "a100%\ndone\n" + widget.close() + def test_a_notice_starts_on_a_line_of_its_own(self, app): widget = self._widget() widget._on_data(b"$ ") @@ -194,6 +224,98 @@ def test_a_long_command_arrives_whole_as_utf8(self, app): widget.shell_channel = None widget.close() + def test_an_empty_line_sends_enter(self, app): + # It sent nothing, so a prompt's default ("[Y/n]", "Press Enter to + # continue") could not be taken + widget = SSHCommandWidget() + channel = PartialSendChannel() + widget.shell_channel = channel + + widget.send_command() + + assert channel.sent == [b"\n"] + widget.shell_channel = None + widget.close() + + def test_interrupt_sends_ctrl_c_and_keeps_the_line(self, app): + # A ping or tail -f could only be stopped by disconnecting + widget = SSHCommandWidget() + channel = PartialSendChannel() + widget.shell_channel = channel + widget.command_input_edit.setText("half typed") + + widget.interrupt_button.click() + + assert channel.sent == [b"\x03"] + assert widget.command_input_edit.text() == "half typed" + widget.shell_channel = None + widget.close() + + def test_ctrl_c_in_the_command_line_interrupts(self, app): + widget = SSHCommandWidget() + channel = PartialSendChannel() + widget.shell_channel = channel + widget.command_input_edit.setText("ping example.com") + + QTest.keyClick(widget.command_input_edit, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier) + + assert channel.sent == [b"\x03"] + widget.shell_channel = None + widget.close() + + def test_ctrl_c_on_a_selection_copies_it(self, app): + widget = SSHCommandWidget() + channel = PartialSendChannel() + widget.shell_channel = channel + widget.command_input_edit.setText("copy me") + widget.command_input_edit.selectAll() + + QTest.keyClick(widget.command_input_edit, Qt.Key.Key_C, Qt.KeyboardModifier.ControlModifier) + + assert channel.sent == [] + assert QApplication.clipboard().text() == "copy me" + widget.shell_channel = None + widget.close() + + def test_up_and_down_bring_back_what_was_sent(self, app): + widget = SSHCommandWidget() + widget.shell_channel = PartialSendChannel() + line = widget.command_input_edit + for command in ("ls", "pwd"): + line.setText(command) + widget.send_command() + line.setText("half") + + QTest.keyClick(line, Qt.Key.Key_Up) + assert line.text() == "pwd" + QTest.keyClick(line, Qt.Key.Key_Up) + assert line.text() == "ls" + QTest.keyClick(line, Qt.Key.Key_Down) + QTest.keyClick(line, Qt.Key.Key_Down) + assert line.text() == "half" + widget.shell_channel = None + widget.close() + + def test_interrupt_without_a_session_does_nothing(self, app, monkeypatch): + widget = SSHCommandWidget() + asked: list = [] + monkeypatch.setattr(ssh_command_widget.QMessageBox, "information", lambda *args: asked.append(args)) + + widget.interrupt_button.click() + + assert asked == [] + widget.close() + + def test_an_empty_line_without_a_session_asks_nothing(self, app, monkeypatch): + widget = SSHCommandWidget() + asked: list = [] + monkeypatch.setattr(ssh_command_widget.QMessageBox, "information", lambda *args: asked.append(args)) + + widget.send_command() + + assert asked == [] + widget.close() + class _IdleReader: """A reader that is never started: the connect message is all that is looked at.""" @@ -221,9 +343,203 @@ def test_names_the_session_in_the_ide_language(self, app, monkeypatch, host, sho widget = SSHCommandWidget() widget.word_dict = word - widget._start_shell(object(), host, 22, "alice") + widget._start_shell(ResizableChannel(), host, 22, "alice") assert widget.terminal.toPlainText().endswith(f"已以 alice 身分連線至 {shown}\n") assert widget.login_widget.status_label.text() == "已連線" widget.shell_channel = widget.reader_thread = None widget.close() + + +class TestCommandHistory: + def _history(self, *lines: str) -> CommandHistory: + history = CommandHistory(limit=3) + for line in lines: + history.add(line) + return history + + def test_up_goes_back_and_stops_at_the_oldest(self): + history = self._history("a", "b") + + assert history.older("") == "b" + assert history.older("") == "a" + assert history.older("") is None + + def test_down_past_the_newest_gives_back_what_was_typed(self): + history = self._history("a", "b") + history.older("typing") + + assert history.newer() == "typing" + assert history.newer() is None + + def test_nothing_sent_leaves_nothing_to_walk(self): + history = self._history() + + assert history.older("x") is None + assert history.newer() is None + + def test_empty_lines_and_a_repeat_are_not_remembered(self): + history = self._history("a", "", "a") + + assert history.older("") == "a" + assert history.older("") is None + + def test_only_the_newest_lines_are_kept(self): + history = self._history("1", "2", "3", "4") + + assert [history.older(""), history.older(""), history.older(""), history.older("")] == ["4", "3", "2", None] + + def test_sending_goes_back_to_a_new_line(self): + history = self._history("a", "b") + history.older("") + history.older("") + + history.add("a") # sent again from the history + + assert history.older("") == "a" + assert history.older("") == "b" + + +class ResizableChannel: + """Records the pty sizes it is given.""" + + closed = False + + def __init__(self, error: Exception | None = None) -> None: + self.sizes: list[tuple[int, int]] = [] + self.error = error + + def resize_pty(self, width: int, height: int) -> None: + if self.error is not None: + raise self.error + self.sizes.append((width, height)) + + +class TestThePtySize: + @staticmethod + def _shown(app, channel) -> SSHCommandWidget: + widget = SSHCommandWidget() + widget.resize(900, 600) + widget.show() + QApplication.processEvents() + widget.shell_channel = channel + return widget + + @staticmethod + def _resize(widget: SSHCommandWidget, width: int) -> None: + widget.resize(width, 600) + QApplication.processEvents() + + def test_the_pty_follows_the_view(self, app): + # It stayed at 120 columns whatever the view's width + channel = ResizableChannel() + widget = self._shown(app, channel) + + self._resize(widget, 500) + + assert channel.sizes[-1] == terminal_size(widget.terminal) + assert channel.sizes[-1][0] < 120 - 40 + widget.shell_channel = None + widget.close() + + def test_a_resize_within_a_column_sends_nothing(self, app): + channel = ResizableChannel() + widget = self._shown(app, channel) + self._resize(widget, 500) + sent = len(channel.sizes) + + self._resize(widget, 501) + + assert len(channel.sizes) == sent + widget.shell_channel = None + widget.close() + + def test_without_a_session_nothing_is_sent(self, app): + widget = self._shown(app, None) + + self._resize(widget, 500) # no channel to give it to: nothing raised + + widget.close() + + def test_a_resize_the_server_refuses_is_not_raised(self, app): + widget = self._shown(app, ResizableChannel(OSError("link down"))) + + self._resize(widget, 500) + + assert widget._pty_size != terminal_size(widget.terminal) # tried again next time + widget.shell_channel = None + widget.close() + + def test_the_shell_opens_at_the_view_size(self, app): + options = {} + + class Client: + @staticmethod + def get_transport(): + return None + + @staticmethod + def invoke_shell(**given): + options.update(given) + return paramiko.Channel(0) + + ssh_command_widget.open_shell_channel(Client(), (77, 21)) + + assert (options["width"], options["height"]) == (77, 21) + + +def _colour_at(widget: SSHCommandWidget, position: int): + cursor = widget.terminal.textCursor() + cursor.setPosition(position + 1) # the format of the character before the cursor + return cursor.charFormat().foreground().color() + + +class TestColours: + def test_output_shows_the_colours_it_asks_for(self, app): + # They were removed: ls --color, git and grep came out all one colour + widget = SSHCommandWidget() + + widget._on_data(b"\x1b[31mred\x1b[0m plain") + + assert widget.terminal.toPlainText() == "red plain" + assert _colour_at(widget, 0) == QColor(205, 49, 49) + assert _colour_at(widget, 4) != QColor(205, 49, 49) + widget.close() + + def test_a_colour_carries_over_to_the_next_read(self, app): + widget = SSHCommandWidget() + + widget._on_data(b"\x1b[31mr") + widget._on_data(b"ed") + + assert _colour_at(widget, 2) == QColor(205, 49, 49) # the same on any background + widget.close() + + def test_a_new_session_starts_without_the_last_one_colour(self, app): + decoder = TerminalDecoder() + decoder.feed(b"\x1b[31m") + + decoder.reset() + + assert decoder.feed(b"plain").pieces == [(PLAIN, "plain")] + + @pytest.mark.parametrize("chunks", [ + [b"50%\r\x1b[32m60%"], + [b"50%\r\x1b[32m", b"60%"], + ]) + def test_a_bar_that_changes_colour_still_redraws_its_line(self, app, chunks): + widget = SSHCommandWidget() + + for chunk in chunks: + widget._on_data(chunk) + + assert widget.terminal.toPlainText() == "60%" + widget.close() + + def test_a_line_ending_around_a_colour_reset_is_one_line_break(self, app): + widget = SSHCommandWidget() + + widget._on_data(b"a\r\x1b[0m\nb") + + assert widget.terminal.toPlainText() == "a\nb" + widget.close() diff --git a/test/test_utils/test_start_theme.py b/test/test_utils/test_start_theme.py new file mode 100644 index 00000000..1e2c6411 --- /dev/null +++ b/test/test_utils/test_start_theme.py @@ -0,0 +1,91 @@ +"""The theme ``start_editor(theme=...)`` is given is the one the IDE shows. + +JEditor's ``startup_setting()`` applies the saved theme (``dark_amber.xml`` until +one is picked from UI Style) after the window is shown, over the one +``start_editor`` had applied, so the argument never showed at all. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_OPEN = """ +import os +from pybreeze.pybreeze_ui.editor_main.main_ui import open_main_window +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +window = open_main_window(app, debug_mode=True{theme}) +gc.collect() +""" + +_READ = """ +result = {"shown": os.environ.get("QTMATERIAL_THEME"), "saved": user_setting_dict.get("ui_style")} +""" + + +def _start(tmp_path, theme: str | None = None, saved: str | None = None) -> dict: + return run_started_window( + tmp_path, _READ, build=_OPEN.format(theme="" if theme is None else f", theme={theme!r}"), + saved_settings={"ui_style": saved} if saved else None) + + +def test_a_theme_given_at_launch_is_the_one_shown(tmp_path): + assert _start(tmp_path, theme="dark_teal.xml") == {"shown": "dark_teal.xml", "saved": "dark_teal.xml"} + + +def test_a_theme_given_at_launch_replaces_the_one_picked_before(tmp_path): + assert _start(tmp_path, theme="dark_teal.xml", saved="light_blue.xml")["shown"] == "dark_teal.xml" + + +def test_without_one_the_theme_picked_before_is_shown(tmp_path): + assert _start(tmp_path, saved="light_blue.xml")["shown"] == "light_blue.xml" + + +def test_without_either_the_default_is_shown(tmp_path): + assert _start(tmp_path)["shown"] == "dark_amber.xml" + + +_COUNT_THEMES = """ +applied = [] +_set_style_sheet = QApplication.setStyleSheet +def _counting(self, sheet): + applied.append(len(sheet)) + return _set_style_sheet(self, sheet) +QApplication.setStyleSheet = _counting +""" + +_READ_COUNT = """ +from PySide6.QtWidgets import QToolBar +for _ in range(5): + app.processEvents() +result = {"shown": os.environ.get("QTMATERIAL_THEME"), "applied": len(applied), + "toolbar": window.findChildren(QToolBar)[0].height()} +""" + + +def _count(tmp_path, theme: str | None = None, saved: str = "light_blue.xml") -> dict: + return run_started_window( + tmp_path, _READ_COUNT, before_window=_COUNT_THEMES, + build=_OPEN.format(theme="" if theme is None else f", theme={theme!r}"), + saved_settings={"ui_style": saved}) + + +def test_the_saved_theme_is_applied_once(tmp_path): + # The window applies the saved settings as it is built; they were applied + # twice more after it (about 0.7 s and 0.9 s of the start) + seen = _count(tmp_path) + assert (seen["shown"], seen["applied"]) == ("light_blue.xml", 1) + + +def test_a_theme_given_at_launch_is_applied_once_over_the_saved_one(tmp_path): + seen = _count(tmp_path, theme="dark_teal.xml") + assert (seen["shown"], seen["applied"]) == ("dark_teal.xml", 2) + + +def test_the_window_looks_the_same_with_the_theme_given_or_saved(tmp_path): + # Applied once, the theme left the toolbar 4 px taller than a theme given + # at launch, which applies the settings again + for folder in ("saved", "given"): + (tmp_path / folder).mkdir() + saved = _count(tmp_path / "saved", saved="dark_amber.xml") + given = _count(tmp_path / "given", theme="dark_amber.xml", saved="dark_amber.xml") + + assert saved["toolbar"] == given["toolbar"] diff --git a/test/test_utils/test_started_menus.py b/test/test_utils/test_started_menus.py index 5149a0e1..c657809a 100644 --- a/test/test_utils/test_started_menus.py +++ b/test/test_utils/test_started_menus.py @@ -73,3 +73,27 @@ def test_a_broken_extend_tab_costs_only_itself(tmp_path): assert "fine" in tabs assert "broken" not in tabs + + +_REPORT_REPEATED_ENTRIES = """ +def repeated(menu, path): + found = [] + texts = [action.text() for action in menu.actions() if not action.isSeparator()] + found.extend(" > ".join(path + [text]) for text in sorted(set(texts)) if texts.count(text) > 1) + for action in menu.actions(): + if action.menu() is not None: + found.extend(repeated(action.menu(), path + [action.text()])) + return found + +result = [ + entry + for top in window.menuBar().actions() if top.menu() is not None + for entry in repeated(top.menu(), [top.text()]) +] +""" + + +def test_no_menu_has_two_entries_of_the_same_name(tmp_path): + # Dock had two "AI" submenus side by side: JEditor's (Chat UI) and PyBreeze's + # (the review docks) + assert run_started_window(tmp_path, _REPORT_REPEATED_ENTRIES) == [] diff --git a/test/test_utils/test_startup_imports.py b/test/test_utils/test_startup_imports.py new file mode 100644 index 00000000..d4e9bc8b --- /dev/null +++ b/test/test_utils/test_startup_imports.py @@ -0,0 +1,66 @@ +"""What starting the IDE leaves alone: the DPI awareness Qt sets, and packages nobody has used yet.""" +from __future__ import annotations + +import sys + +import pytest + +from test_utils.started_window import run_started_window + +# Imported by the entries that use them: together they took about a fifth of +# the IDE's start (the Load Density GUI brings locust and gevent; SSH brings +# paramiko and cryptography) +_UNUSED_YET = ("je_auto_control", "je_load_density", "locust", "je_api_testka", "paramiko") + +_WHAT_THE_START_LOADED = f"UNUSED_YET = {_UNUSED_YET!r}\n" + """ +import ctypes +import os +from pybreeze.utils.subprocess_util import utf8_subprocess_env +awareness = None +if sys.platform == "win32": + user32 = ctypes.windll.user32 + user32.GetThreadDpiAwarenessContext.restype = ctypes.c_void_p + user32.GetAwarenessFromDpiAwarenessContext.argtypes = [ctypes.c_void_p] + awareness = user32.GetAwarenessFromDpiAwarenessContext(user32.GetThreadDpiAwarenessContext()) +result = { + "awareness": awareness, + "loaded": sorted(name for name in UNUSED_YET if name in sys.modules), + "locust_patching": { + "ide": os.environ.get("LOCUST_SKIP_MONKEY_PATCH"), + "child": utf8_subprocess_env().get("LOCUST_SKIP_MONKEY_PATCH"), + }, +} +""" + + +@pytest.fixture(scope="module") +def started(tmp_path_factory) -> dict: + with pytest.MonkeyPatch.context() as patch: + # As a user starts it: an earlier test here may have imported the IDE, + # which sets this in this process + patch.delenv("LOCUST_SKIP_MONKEY_PATCH", raising=False) + return run_started_window(tmp_path_factory.mktemp("started"), _WHAT_THE_START_LOADED) + + +@pytest.mark.skipif(sys.platform != "win32", reason="DPI awareness is a Windows process setting") +def test_the_dpi_awareness_is_left_to_qt(started): + # je_auto_control makes the process system DPI aware as it imports. Imported + # with the menus, before the application existed, it left Qt unable to set + # per-monitor awareness ("SetProcessDpiAwarenessContext() failed" at every + # start): Windows stretched the IDE as a bitmap on a screen scaled + # differently from the main one. The offscreen platform sets none, so the + # process is still unaware (0) here. + assert started["awareness"] == 0 + + +def test_the_automation_packages_and_ssh_are_loaded_when_used(started): + assert started["loaded"] == [] + + +def test_locust_leaves_the_ide_unpatched_and_the_processes_it_starts_patched(started): + # locust patches a process with gevent as it imports unless this is set. The + # IDE sets it for itself (the Load Density GUI imports locust there), and it + # went on to every process the IDE started: a load test of HttpUser users + # then ran them one at a time, and a 3 s test took over three minutes. + assert started["locust_patching"]["ide"] + assert started["locust_patching"]["child"] is None diff --git a/test/test_utils/test_stop_all_runs.py b/test/test_utils/test_stop_all_runs.py new file mode 100644 index 00000000..3d244c10 --- /dev/null +++ b/test/test_utils/test_stop_all_runs.py @@ -0,0 +1,69 @@ +"""Run > Stop All Program stops PyBreeze's runs too, not only JEditor's. + +JEditor's Stop All Program stops the programs its own menus started. An +automation script, a package install or a Run with... run shows in a run window +of PyBreeze's, and went on after it. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_BODY = """ +class FakeRunWindow: + stopped = 0 + + def stop_runner(self): + FakeRunWindow.stopped += 1 + + +class BrokenRunWindow: + def stop_runner(self): + raise RuntimeError("its process is gone") + + +window.current_run_code_window.extend([FakeRunWindow(), BrokenRunWindow(), FakeRunWindow()]) +window.run_menu.stop_all_program_action.trigger() +result = {"stopped": FakeRunWindow.stopped, "kept": len(window.current_run_code_window)} +# The fakes cannot close: the harness closes the window next +window.current_run_code_window.clear() +""" + + +def test_stop_all_program_stops_every_run_window(tmp_path): + # One whose stop fails costs only its own stop; the windows stay, with their output + assert run_started_window(tmp_path, _BODY) == {"stopped": 2, "kept": 3} + + +_CLOSED_WINDOW = """ +from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + +class StillRunning: + def poll(self): + return None + + +class Runner: + process = StillRunning() + stopped = 0 + + def stop(self): + Runner.stopped += 1 + + +run_window = open_run_window(window, "a long run") +run_window.runner = Runner() +run_window.show() +run_window.close() +kept = run_window in window.current_run_code_window +window.run_menu.stop_all_program_action.trigger() +result = {"kept after closing": kept, "stopped": Runner.stopped} +run_window.runner = None +window.current_run_code_window.clear() +""" + + +def test_a_run_whose_window_was_closed_can_still_be_stopped(tmp_path): + # Closing a run window lets its run go on (progress #2): Stop All Program + # is how it is stopped without closing the IDE + assert run_started_window(tmp_path, _CLOSED_WINDOW) == {"kept after closing": True, "stopped": 1} diff --git a/test/test_utils/test_subprocess_util.py b/test/test_utils/test_subprocess_util.py index 647fdcbd..9108f693 100644 --- a/test/test_utils/test_subprocess_util.py +++ b/test/test_utils/test_subprocess_util.py @@ -3,7 +3,11 @@ import subprocess import sys -from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env +import pytest + +from pybreeze.utils.subprocess_util import ( + IDE_ONLY, child_environment, no_window_creationflags, utf8_subprocess_env, +) class TestUtf8SubprocessEnv: @@ -28,6 +32,35 @@ def test_child_actually_uses_the_encoding(self): assert result.stdout.strip().replace("-", "").lower() == "utf8" +class TestWhatTheIdeSetsForItself: + """A variable the IDE sets for its own process only stays out of the processes it starts.""" + + @staticmethod + def _set_for_the_ide(monkeypatch, name: str) -> None: + monkeypatch.setenv(name, IDE_ONLY) # restored when the test ends + + def test_a_child_does_not_get_it(self, monkeypatch): + self._set_for_the_ide(monkeypatch, "PYBREEZE_TEST_IDE_ONLY") + + assert "PYBREEZE_TEST_IDE_ONLY" not in child_environment() + assert "PYBREEZE_TEST_IDE_ONLY" not in utf8_subprocess_env() + + def test_one_the_user_set_is_passed_on(self, monkeypatch): + monkeypatch.setenv("PYBREEZE_TEST_IDE_ONLY", "1") + + assert child_environment()["PYBREEZE_TEST_IDE_ONLY"] == "1" + + def test_a_real_child_runs_without_it(self, monkeypatch): + self._set_for_the_ide(monkeypatch, "PYBREEZE_TEST_IDE_ONLY") + + result = subprocess.run( + [sys.executable, "-c", "import os; print(os.environ.get('PYBREEZE_TEST_IDE_ONLY'))"], + capture_output=True, text=True, env=utf8_subprocess_env(), timeout=60, check=True, + ) + + assert result.stdout.strip() == "None" + + class TestNoWindowCreationflags: def test_returns_int(self): assert isinstance(no_window_creationflags(), int) @@ -43,3 +76,81 @@ def test_is_a_noop_when_or_combined_on_posix_semantics(self): # OR-ing the flag with other creationflags must never lose existing bits. base = 0x4 assert base | no_window_creationflags() >= base + + +class TestStoppingATree: + """Stopping a run stops what it started: ``go run`` and a web run start the program as a grandchild.""" + + _GRANDCHILD = ( + "import sys, time\n" + "deadline = time.monotonic() + 30\n" + "while time.monotonic() < deadline:\n" + " open(sys.argv[1], 'w').write(str(time.monotonic()))\n" + " time.sleep(0.05)\n" + ) + + def test_the_program_a_launcher_started_stops_too(self, tmp_path): + import time + + from pybreeze.utils.subprocess_util import own_session_options, stop_tree + + heartbeat = tmp_path / "beat" + grandchild = tmp_path / "grandchild.py" + grandchild.write_text(self._GRANDCHILD, encoding="utf-8") + launcher = subprocess.Popen( # noqa: S603 — this interpreter and a script the test wrote + [sys.executable, "-c", + "import subprocess, sys, time\n" + f"subprocess.Popen([sys.executable, {str(grandchild)!r}, {str(heartbeat)!r}])\n" + "time.sleep(30)\n"], + creationflags=no_window_creationflags(), **own_session_options()) + deadline = time.monotonic() + 20 + while not heartbeat.exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert heartbeat.exists(), "the grandchild never started" + + stop_tree(launcher) + launcher.wait(10) + time.sleep(0.5) + last = heartbeat.read_text(encoding="utf-8") + time.sleep(0.5) + + assert heartbeat.read_text(encoding="utf-8") == last, "the grandchild is still running" + + def test_a_process_that_has_ended_is_left_alone(self, monkeypatch): + from pybreeze.utils import subprocess_util + + class Ended: + pid = 1 + + def poll(self): + return 0 + + def terminate(self): + raise AssertionError("an ended process was terminated") + + monkeypatch.setattr(subprocess_util.subprocess, "run", lambda *a, **k: pytest.fail("taskkill was run")) + subprocess_util.stop_tree(Ended()) + + def test_when_the_tree_cannot_be_stopped_the_child_still_is(self, monkeypatch): + from pybreeze.utils import subprocess_util + + class Running: + pid = 4242 + terminated = False + + def poll(self): + return 0 if self.terminated else None + + def terminate(self): + self.terminated = True + + def refuse(*_args, **_kwargs): + raise OSError("taskkill is not there") + + monkeypatch.setattr(subprocess_util.subprocess, "run", refuse) + monkeypatch.setattr(subprocess_util.os, "killpg", refuse, raising=False) + child = Running() + + subprocess_util.stop_tree(child) + + assert child.terminated diff --git a/test/test_utils/test_syntax_extend.py b/test/test_utils/test_syntax_extend.py index af34d80b..55d3bfb1 100644 --- a/test/test_utils/test_syntax_extend.py +++ b/test/test_utils/test_syntax_extend.py @@ -49,3 +49,42 @@ def test_every_automation_package_gets_json_keywords(registered): groups = get_programming_language_plugin(".json")["syntax_words"] assert set(groups) == set(package_manager.syntax_check_list) + + +class _File: + def __init__(self, name: str) -> None: + self.current_file = name + + +def _keyword_colour(suffix: str, keyword: str): + from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + from PySide6.QtCore import QRegularExpression + from PySide6.QtWidgets import QApplication + + QApplication.instance() or QApplication([]) + highlighter = PythonHighlighter(main_window=_File(f"script{suffix}")) + pattern = QRegularExpression(rf"\b{keyword}\b").pattern() + return next(fmt.foreground().color() for rule, fmt in highlighter.highlight_rules if rule.pattern() == pattern) + + +@pytest.mark.parametrize(("suffix", "package", "key"), [ + (".json", "je_auto_control", "warning_output_color"), + (".yaml", "test_pioneer", "diff_modified_marker_color"), +]) +def test_keywords_take_the_theme_colour_and_follow_a_light_theme(registered, suffix, package, key): + # Fixed pure yellow keywords could not be read on a light theme + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict, apply_theme_colors, + ) + + keyword = sorted(package_keyword_list[package])[0] + try: + apply_theme_colors("dark_amber.xml") + on_dark = _keyword_colour(suffix, keyword) + apply_theme_colors("light_blue.xml") + on_light = _keyword_colour(suffix, keyword) + assert on_light == actually_color_dict[key] + finally: + apply_theme_colors("dark_amber.xml") + + assert on_dark != on_light diff --git a/test/test_utils/test_terminal_font.py b/test/test_utils/test_terminal_font.py new file mode 100644 index 00000000..44d71b16 --- /dev/null +++ b/test/test_utils/test_terminal_font.py @@ -0,0 +1,111 @@ +"""Terminal views: a fixed-pitch font, so columns line up, and the size a pty is given.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtGui import QFontDatabase +from PySide6.QtWidgets import QApplication, QPlainTextEdit, QWidget + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget +from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow +from pybreeze.pybreeze_ui import fixed_pitch +from pybreeze.pybreeze_ui.fixed_pitch import fixed_pitch_font, use_fixed_pitch_font +from pybreeze.pybreeze_ui.terminal_view import MIN_COLUMNS, MIN_ROWS, terminal_size + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _fixed_family() -> str: + return fixed_pitch_font().family() + + +def test_the_view_gets_the_fixed_pitch_font_at_its_own_size(app): + view = QPlainTextEdit() + font = view.font() + font.setPointSizeF(13.5) + view.setFont(font) + + use_fixed_pitch_font(view) + + assert view.font().family() == _fixed_family() + assert view.font().pointSizeF() == 13.5 + + +def test_a_theme_style_sheet_does_not_take_the_font_back(app): + # qt_material names a font for every widget ("* { font-family: Roboto }"), + # and a style sheet's font overrides setFont: the IDE showed Roboto + theme = QWidget() + theme.setStyleSheet('* { font-family: "Arial"; }') + view = QPlainTextEdit(theme) + use_fixed_pitch_font(view) + + view.ensurePolished() + + assert view.font().family() == _fixed_family() + theme.close() + + +def test_the_ssh_terminal_shows_a_fixed_pitch_font(app): + widget = SSHCommandWidget() + try: + assert widget.terminal.font().family() == _fixed_family() + finally: + widget.close() + + +def test_the_run_window_shows_a_fixed_pitch_font(app): + window = CodeWindow() + try: + assert window.code_result.font().family() == _fixed_family() + finally: + window.close() + + +def _view(width: int, height: int) -> QPlainTextEdit: + view = QPlainTextEdit() + use_fixed_pitch_font(view) + view.resize(width, height) + view.show() + QApplication.processEvents() + return view + + +def test_a_wider_view_has_more_columns_and_a_taller_one_more_rows(app): + small, wide, tall = _view(400, 300), _view(800, 300), _view(400, 600) + try: + assert terminal_size(wide)[0] > terminal_size(small)[0] + assert terminal_size(tall)[1] > terminal_size(small)[1] + assert terminal_size(wide)[1] == terminal_size(small)[1] + finally: + for view in (small, wide, tall): + view.close() + + +def test_a_squeezed_view_still_gives_a_usable_size(app): + view = _view(10, 10) + try: + assert terminal_size(view) == (MIN_COLUMNS, MIN_ROWS) + finally: + view.close() + + +class TestTheFamilyChosen: + def test_consolas_comes_before_the_system_font_when_it_is_installed(self, app, monkeypatch): + # The system's fixed-pitch font on Windows is Courier New + monkeypatch.setattr(fixed_pitch.QFontDatabase, "hasFamily", staticmethod(lambda family: family == "Consolas")) + + assert fixed_pitch_font().family() == "Consolas" + + def test_without_it_the_system_font_is_used(self, app, monkeypatch): + monkeypatch.setattr(fixed_pitch.QFontDatabase, "hasFamily", staticmethod(lambda family: False)) + + assert fixed_pitch_font().family() == QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family() diff --git a/test/test_utils/test_terminal_style.py b/test/test_utils/test_terminal_style.py new file mode 100644 index 00000000..4149419f --- /dev/null +++ b/test/test_utils/test_terminal_style.py @@ -0,0 +1,137 @@ +"""SGR escape sequences read into a text style, and the format a view shows it in.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtGui import QColor, QFont, QPalette +from PySide6.QtWidgets import QApplication + +from pybreeze.pybreeze_ui.terminal_view import style_format +from pybreeze.utils.terminal_style import PLAIN, TextStyle, apply_sgr, colour_rgb, split_styled + + +class TestApplySgr: + @pytest.mark.parametrize(("parameters", "expected"), [ + ("31", TextStyle(foreground=1)), + ("91", TextStyle(foreground=9)), + ("44", TextStyle(background=4)), + ("107", TextStyle(background=15)), + ("1;4;3;7", TextStyle(bold=True, underline=True, italic=True, inverse=True)), + ("38;5;196", TextStyle(foreground=196)), + ("48;2;10;20;30", TextStyle(background=(10, 20, 30))), + ("38;5;208;1", TextStyle(foreground=208, bold=True)), + ]) + def test_sets_what_it_names(self, parameters, expected): + assert apply_sgr(PLAIN, parameters) == expected + + @pytest.mark.parametrize("parameters", ["0", "", "00"]) + def test_zero_or_nothing_resets(self, parameters): + styled = TextStyle(foreground=1, background=2, bold=True, underline=True) + + assert apply_sgr(styled, parameters) == PLAIN + + def test_the_off_switches_and_defaults_undo_one_thing_each(self): + styled = TextStyle(foreground=1, background=2, bold=True, italic=True, underline=True, inverse=True) + + assert apply_sgr(styled, "22;23") == TextStyle(foreground=1, background=2, underline=True, inverse=True) + assert apply_sgr(styled, "39;49;24;27") == TextStyle(bold=True, italic=True) + + @pytest.mark.parametrize("parameters", ["38;5", "38;5;256", "38;2;1;2", "38;2;1;2;300", "38;9;1", "38", "5", "1000"]) + def test_what_cannot_be_read_changes_nothing(self, parameters): + styled = TextStyle(foreground=3) + + assert apply_sgr(styled, parameters).foreground == 3 + + def test_a_parameter_thousands_of_digits_long_is_ignored(self): + # int() refuses more than 4300 digits: a server could send them + assert apply_sgr(PLAIN, "9" * 5000 + ";31") == TextStyle(foreground=1) + + +class TestColourRgb: + @pytest.mark.parametrize(("colour", "on_dark", "rgb"), [ + (4, True, (36, 114, 200)), # xterm's (0, 0, 238) was unreadable on a dark theme + (4, False, (4, 81, 165)), + (7, True, (229, 229, 229)), + (7, False, (85, 85, 85)), # "white" text stays readable on white + (1, True, (205, 49, 49)), + ]) + def test_the_basic_colours_suit_the_background(self, colour, on_dark, rgb): + assert colour_rgb(colour, on_dark=on_dark) == rgb + + @pytest.mark.parametrize(("colour", "rgb"), [ + (16, (0, 0, 0)), + (196, (255, 0, 0)), + (231, (255, 255, 255)), + (232, (8, 8, 8)), + (255, (238, 238, 238)), + ((1, 2, 3), (1, 2, 3)), + ]) + @pytest.mark.parametrize("on_dark", [True, False]) + def test_the_rest_of_the_palette_is_xterm_on_any_background(self, colour, rgb, on_dark): + assert colour_rgb(colour, on_dark=on_dark) == rgb + + +class TestSplitStyled: + def test_each_piece_has_the_style_it_is_shown_in(self): + pieces, style = split_styled("a\x1b[1;31mred\x1b[0m plain", PLAIN) + + assert pieces == [(PLAIN, "a"), (TextStyle(foreground=1, bold=True), "red"), (PLAIN, " plain")] + assert style == PLAIN + + def test_the_style_carries_on_from_earlier_text(self): + pieces, style = split_styled("still red\x1b[4m", TextStyle(foreground=1)) + + assert pieces == [(TextStyle(foreground=1), "still red")] + assert style == TextStyle(foreground=1, underline=True) + + def test_other_escapes_and_controls_stay_for_the_text_cleaning(self): + pieces, _style = split_styled("\x1b[2Kbar\r\x1b]0;t\x07", PLAIN) + + assert pieces == [(PLAIN, "\x1b[2Kbar\r\x1b]0;t\x07")] + + +@pytest.fixture(scope="module") +def palette(): + QApplication.instance() or QApplication([]) + return QPalette() + + +class TestStyleFormat: + def test_the_plain_style_changes_nothing(self, palette): + text_format = style_format(PLAIN, palette) + + assert not text_format.hasProperty(text_format.Property.ForegroundBrush) + assert not text_format.hasProperty(text_format.Property.FontWeight) + + def test_colours_and_emphasis(self, palette): + text_format = style_format(TextStyle(foreground=1, background=(1, 2, 3), bold=True, underline=True), palette) + + assert text_format.foreground().color() == QColor(205, 49, 49) + assert text_format.background().color() == QColor(1, 2, 3) + assert text_format.fontWeight() == QFont.Weight.Bold + assert text_format.fontUnderline() + + def test_inverse_swaps_the_view_colours_in_for_defaults(self, palette): + text_format = style_format(TextStyle(foreground=1, inverse=True), palette) + + assert text_format.foreground().color() == palette.color(QPalette.ColorRole.Base) + assert text_format.background().color() == QColor(205, 49, 49) + + +def _palette_with_base(colour: QColor) -> QPalette: + palette = QPalette() + palette.setColor(QPalette.ColorRole.Base, colour) + return palette + + +@pytest.mark.parametrize(("base", "blue"), [ + (QColor(30, 30, 30), QColor(36, 114, 200)), + (QColor(255, 255, 255), QColor(4, 81, 165)), +]) +def test_the_view_background_picks_the_basic_colours(palette, base, blue): + text_format = style_format(TextStyle(foreground=4), _palette_with_base(base)) + + assert text_format.foreground().color() == blue diff --git a/test/test_utils/test_test_pioneer_template.py b/test/test_utils/test_test_pioneer_template.py index f4b0a1df..1c44a23a 100644 --- a/test/test_utils/test_test_pioneer_template.py +++ b/test/test_utils/test_test_pioneer_template.py @@ -19,7 +19,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def window(app, tmp_path, monkeypatch): made = QMainWindow() made.working_dir = str(tmp_path / "project") diff --git a/test/test_utils/test_testpioneer_menu.py b/test/test_utils/test_testpioneer_menu.py index 9d7a42a0..2ae2344e 100644 --- a/test/test_utils/test_testpioneer_menu.py +++ b/test/test_utils/test_testpioneer_menu.py @@ -19,7 +19,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def chosen(monkeypatch) -> dict: """Answer the file dialog with ``chosen["path"]``; record the filter and what ran.""" state: dict = {"path": "", "ran": [], "filter": None, "told": 0} @@ -84,3 +84,24 @@ def test_cancelling_does_nothing(app, chosen): menu.check_file(None) assert chosen["ran"] == [] and chosen["told"] == 0 + + +def test_its_help_opens_the_github_page(app, monkeypatch): + # Every other automation menu has a HELP submenu; TestPioneer's had none. + from types import SimpleNamespace + + from PySide6.QtWidgets import QMenu + + from pybreeze.pybreeze_ui.menu.automation_menu import automation_menu_factory + + opened: list = [] + monkeypatch.setattr( + automation_menu_factory, "open_web_browser", lambda _ui, url, label: opened.append((url, label))) + window = SimpleNamespace(automation_menu=QMenu()) + menu.set_test_pioneer_menu(window) + + (help_menu,) = [action.menu() for action in window.test_pioneer_menu.actions() if action.menu()] + for action in help_menu.actions(): + action.trigger() + + assert opened == [("https://github.com/Integration-Automation/TestPioneer", "TestPioneer GitHub")] diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index 776d2301..a4fab36f 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -223,3 +223,26 @@ def test_repetitive_text_just_under_2000_lines_is_quick(): compare_texts(left, right) assert time.monotonic() - started < 5 + + +@pytest.mark.parametrize(("lines", "plain_matches"), [(10, 1), (1500, 0)]) +def test_the_plain_match_is_made_only_for_small_texts(monkeypatch, lines, plain_matches): + # Made for every text, it took a 40,000-line comparison from 6 s to 16 s + import difflib + + from pybreeze.utils.diff_tools import text_diff + + made: list = [] + + class Counting(difflib.SequenceMatcher): + def __init__(self, *args, **kwargs): + made.append(True) + super().__init__(*args, **kwargs) + + monkeypatch.setattr(text_diff.difflib, "SequenceMatcher", Counting) + left = "\n".join(["same"] + [f"l{i}" for i in range(lines)]) + right = "\n".join(["same"] + [f"r{i}" for i in range(lines)]) + + text_diff.compare_texts(left, right) + + assert len(made) == plain_matches diff --git a/test/test_utils/test_timestamp_gui.py b/test/test_utils/test_timestamp_gui.py index 4fb4ec3a..def04bc1 100644 --- a/test/test_utils/test_timestamp_gui.py +++ b/test/test_utils/test_timestamp_gui.py @@ -18,7 +18,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.timestamp_gui import TimestampGUI gui = TimestampGUI() @@ -54,7 +54,7 @@ def test_empty_shows_hint(self, widget): def test_copy_output(self, app, widget): widget.input_edit.setText("1609459200") widget.convert() - widget.actions.copy() + widget.output_actions.copy() assert "1609459200" in QApplication.clipboard().text() def test_save_after_error_is_noop(self, widget, tmp_path): @@ -66,7 +66,7 @@ def test_save_after_error_is_noop(self, widget, tmp_path): "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", return_value=(str(tmp_path / "x.txt"), "Text (*.txt)"), ): - assert widget.actions.save_to_file() is None + assert widget.output_actions.save_to_file() is None def test_suggested_filename(self, widget): - assert widget.actions.suggested_filename() == "timestamp.txt" + assert widget.output_actions.suggested_filename() == "timestamp.txt" diff --git a/test/test_utils/test_tool_output_wording.py b/test/test_utils/test_tool_output_wording.py new file mode 100644 index 00000000..4d102aeb --- /dev/null +++ b/test/test_utils/test_tool_output_wording.py @@ -0,0 +1,72 @@ +"""Tool output lines in the IDE's language, punctuation included. + +In the Traditional Chinese IDE the regex tester wrote ``group 1: 'GET'`` and the +timestamp converter ``Epoch(秒): 1754208900``: an English word, and a +half-width colon after a Chinese label. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.utils.regex_tools.regex_tester import MatchResult +from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + +_MATCH = MatchResult(matched_text="GET /a", start=0, end=6, groups=["GET", "/a"], named_groups={"m": "GET"}) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture +def chinese(app, monkeypatch): + from je_editor import language_wrapper + + from pybreeze.extend_multi_language.extend_traditional_chinese import pybreeze_traditional_chinese_word_dict + monkeypatch.setattr(language_wrapper, "language_word_dict", pybreeze_traditional_chinese_word_dict) + + +@pytest.fixture +def english(app, monkeypatch): + from je_editor import language_wrapper + + from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict + monkeypatch.setattr(language_wrapper, "language_word_dict", pybreeze_english_word_dict) + + +def _regex_lines() -> list[str]: + from pybreeze.pybreeze_ui.tools_gui.regex_gui import build_matches_text + return build_matches_text([_MATCH], "none").splitlines()[3:] + + +def _timestamp_lines() -> list[str]: + from pybreeze.pybreeze_ui.tools_gui.timestamp_gui import build_result_text + return build_result_text(convert_timestamp("1754208900")).splitlines() + + +def test_the_regex_groups_in_chinese(chinese): + assert _regex_lines() == [" 群組 1:'GET'", " 群組 2:'/a'", " m:'GET'"] + + +def test_the_regex_groups_in_english(english): + assert _regex_lines() == [" group 1: 'GET'", " group 2: '/a'", " m: 'GET'"] + + +def test_the_timestamp_lines_in_chinese(chinese): + assert _timestamp_lines() == [ + "Epoch(秒):1754208900", "Epoch(毫秒):1754208900000", "ISO-8601(UTC):2025-08-03T08:15:00+00:00"] + + +def test_the_timestamp_lines_in_english(english): + assert _timestamp_lines() == [ + "Epoch (seconds): 1754208900", "Epoch (milliseconds): 1754208900000", + "ISO-8601 (UTC): 2025-08-03T08:15:00+00:00"] diff --git a/test/test_utils/test_tool_run_shortcut.py b/test/test_utils/test_tool_run_shortcut.py new file mode 100644 index 00000000..1d051e60 --- /dev/null +++ b/test/test_utils/test_tool_run_shortcut.py @@ -0,0 +1,139 @@ +"""Ctrl+Enter runs a tool: its text boxes take Enter as a new line, and only the mouse ran it.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence, QShortcut +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _tool(module: str, name: str, *args): + import importlib + return getattr(importlib.import_module(f"pybreeze.pybreeze_ui.tools_gui.{module}"), name)(*args) + + +# (module, class, constructor arguments, the button Ctrl+Enter presses) +TOOLS = [ + ("curl_import_gui", "CurlImportGUI", (None,), "convert_button"), + ("diff_gui", "DiffGUI", (None,), "compare_button"), + ("hash_gui", "HashGUI", (), "hash_button"), + ("header_analyzer_gui", "HeaderAnalyzerGUI", (), "analyze_button"), + ("json_format_gui", "JsonFormatGUI", (), "format_button"), + ("jwt_decoder_gui", "JwtDecoderGUI", (), "decode_button"), + ("regex_gui", "RegexGUI", (), "test_button"), + ("response_inspector_gui", "ResponseInspectorGUI", (), "analyze_button"), +] + + +def _run_shortcuts(tool) -> list[QShortcut]: + return [shortcut for shortcut in tool.findChildren(QShortcut) + if shortcut.key() in (QKeySequence("Ctrl+Return"), QKeySequence("Ctrl+Enter"))] + + +@pytest.mark.parametrize(("module", "name", "args", "button"), TOOLS, ids=[tool[1] for tool in TOOLS]) +def test_ctrl_enter_presses_the_tools_button(app, module, name, args, button): + tool = _tool(module, name, *args) + pressed: list = [] + getattr(tool, button).clicked.connect(lambda: pressed.append(True)) + + shortcuts = _run_shortcuts(tool) + assert len(shortcuts) == 2 # the main keyboard's Enter and the keypad's + assert all(shortcut.context() == Qt.ShortcutContext.WidgetWithChildrenShortcut for shortcut in shortcuts) + shortcuts[0].activated.emit() + + assert pressed == [True] + assert "Ctrl+Enter" in getattr(tool, button).toolTip() + tool.deleteLater() + + +def test_a_disabled_button_is_not_pressed(app): + # A diff still being worked out keeps Compare greyed out + tool = _tool("diff_gui", "DiffGUI", None) + pressed: list = [] + tool.compare_button.clicked.connect(lambda: pressed.append(True)) + tool.compare_button.setEnabled(False) + + _run_shortcuts(tool)[0].activated.emit() + + assert pressed == [] + tool.deleteLater() + + +def test_the_keys_work_from_the_text_box(app): + tool = _tool("hash_gui", "HashGUI") + tool.show() + tool.activateWindow() + tool.input_edit.setFocus() + tool.input_edit.setPlainText("abc") + QApplication.processEvents() + + QTest.keyClick(tool.input_edit, Qt.Key.Key_Return, Qt.KeyboardModifier.ControlModifier) + + assert "ba7816bf" in tool.output_edit.toPlainText() # SHA-256 of "abc" + tool.close() + tool.deleteLater() + + +# The AI panels have one send button each, and a code or prompt box where Enter is a new line +AI_PANELS = [ + ("pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui", "AICodeReviewClient"), + ("pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui", "CoTCodeReviewGUI"), + ("pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui", "SkillsSendGUI"), +] + + +@pytest.mark.parametrize(("module", "name"), AI_PANELS, ids=[panel[1] for panel in AI_PANELS]) +def test_ctrl_enter_sends_from_an_ai_panel(app, module, name): + import importlib + + panel = getattr(importlib.import_module(module), name)() + pressed: list = [] + panel.send_button.clicked.disconnect() # nothing goes out + panel.send_button.clicked.connect(lambda: pressed.append(True)) + + shortcuts = _run_shortcuts(panel) + assert len(shortcuts) == 2 + shortcuts[0].activated.emit() + + assert pressed == [True] + panel.deleteLater() + + +# The tools with a button for each direction: (module, class, to-JSON button, from-JSON button, text that is not JSON) +TWO_WAY = [ + ("query_json_gui", "QueryJsonGUI", "to_json_button", "to_query_button", "a=1&b=2"), + ("url_builder_gui", "UrlBuilderGUI", "to_json_button", "to_url_button", "https://api.example.com/v1?x=1"), +] + + +@pytest.mark.parametrize(("module", "name", "to_json", "from_json", "text"), TWO_WAY, ids=[t[1] for t in TWO_WAY]) +@pytest.mark.parametrize("pasted", ["text", "json"]) +def test_ctrl_enter_goes_the_way_the_input_reads(app, module, name, to_json, from_json, text, pasted): + tool = _tool(module, name) + pressed: list = [] + getattr(tool, to_json).clicked.connect(lambda: pressed.append(to_json)) + getattr(tool, from_json).clicked.connect(lambda: pressed.append(from_json)) + tool.input_edit.setPlainText(text if pasted == "text" else ' {"a": "1"}') + + shortcuts = _run_shortcuts(tool) + assert len(shortcuts) == 2 + shortcuts[0].activated.emit() + + assert pressed == [to_json if pasted == "text" else from_json] + assert "Ctrl+Enter" in getattr(tool, to_json).toolTip() + assert "Ctrl+Enter" in getattr(tool, from_json).toolTip() + tool.deleteLater() diff --git a/test/test_utils/test_tools_menu_docks.py b/test/test_utils/test_tools_menu_docks.py index cfbb3a24..ff05194f 100644 --- a/test/test_utils/test_tools_menu_docks.py +++ b/test/test_utils/test_tools_menu_docks.py @@ -84,3 +84,32 @@ def test_every_tool_widget_has_a_tab_a_dock_and_a_dock_title(): assert {entry[0] for entry in tools_menu._TAB_ACTIONS} == factories assert {entry[0] for entry in tools_menu._DOCK_ACTIONS} == factories assert set(tools_menu._DOCK_TITLES) == factories + + +def test_every_tools_tab_entry_opens_its_widget_under_its_label(app, tmp_path, monkeypatch): + # The docks are checked above; the tab entries were only built, never opened + from je_editor import language_wrapper + from PySide6.QtWidgets import QTabWidget + + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # the prompt editors and review stats live under ~ + monkeypatch.setenv("HOME", str(tmp_path)) + window = QMainWindow() + window.menu = window.menuBar() + window.tab_widget = QTabWidget() + try: + tools_menu.build_tools_menu(window) + for widget_key, attribute, menu_attribute, action_key, label_key in tools_menu._TAB_ACTIONS: + action = getattr(window, attribute) + assert action in getattr(window, menu_attribute).actions() + assert action.text() == language_wrapper.language_word_dict.get(action_key) + + action.trigger() + + index = window.tab_widget.count() - 1 + assert window.tab_widget.tabText(index) == language_wrapper.language_word_dict.get(label_key), widget_key + assert window.tab_widget.widget(index) is not None + assert window.tab_widget.count() == len(tools_menu._TAB_ACTIONS) + finally: + for index in range(window.tab_widget.count()): + window.tab_widget.widget(index).close() + window.deleteLater() diff --git a/test/test_utils/test_unsaved_on_close.py b/test/test_utils/test_unsaved_on_close.py index 4284603d..08f07099 100644 --- a/test/test_utils/test_unsaved_on_close.py +++ b/test/test_utils/test_unsaved_on_close.py @@ -1,4 +1,4 @@ -"""Closing a tab or the IDE asks before unsaved prompt or diagram edits are lost. +"""Closing a tab or the IDE, or opening another diagram, asks before unsaved edits are lost. JEditor's close_tab asked about its own editor tabs only and closed every other tab whatever it said, so a prompt or a diagram being edited was lost unasked. @@ -22,7 +22,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def answers(monkeypatch): """Answer every question with ``answers["reply"]`` and count them.""" state = {"reply": QMessageBox.StandardButton.No, "asked": 0} @@ -66,6 +66,59 @@ def test_a_saved_diagram_closes_without_a_question(self, app, answers, tmp_path) assert editor.may_close() is True assert answers["asked"] == 0 + @staticmethod + def _offer(monkeypatch, path): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + chosen: list = [] + + def dialog(*_args, **_kwargs): + chosen.append(path) + return str(path), "" + + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getOpenFileName", staticmethod(dialog)) + return chosen + + def test_opening_another_diagram_asks_before_unsaved_changes_go(self, app, answers, tmp_path, monkeypatch): + # Open replaced the canvas and cleared the undo history: the edits were + # gone without a word and could not be undone. + other = tmp_path / "other.diagram.json" + self._editor()._write_json(other) + editor = self._editor() + editor._current_path = tmp_path / "work.diagram.json" + chosen = self._offer(monkeypatch, other) + + editor._open_diagram() + + assert answers["asked"] == 1 + assert chosen == [], "the file dialog opened after No" + assert editor._current_path == tmp_path / "work.diagram.json" + assert not editor._scene.undo_stack.isClean(), "the edits were dropped" + + def test_yes_lets_the_other_diagram_open(self, app, answers, tmp_path, monkeypatch): + other = tmp_path / "other.diagram.json" + self._editor()._write_json(other) + editor = self._editor() + answers["reply"] = QMessageBox.StandardButton.Yes + self._offer(monkeypatch, other) + + editor._open_diagram() + + assert answers["asked"] == 1 + assert editor._current_path == other + + def test_a_diagram_with_nothing_unsaved_opens_another_unasked(self, app, answers, tmp_path, monkeypatch): + other = tmp_path / "other.diagram.json" + self._editor()._write_json(other) + editor = self._editor() + editor._write_json(tmp_path / "saved.diagram.json") + self._offer(monkeypatch, other) + + editor._open_diagram() + + assert answers["asked"] == 0 + assert editor._current_path == other + class TestThePromptEditor: def test_unsaved_edits_are_asked_about(self, app, answers, tmp_path, monkeypatch): diff --git a/test/test_utils/test_url_builder_gui.py b/test/test_utils/test_url_builder_gui.py index 90ef9d0a..2d861dc1 100644 --- a/test/test_utils/test_url_builder_gui.py +++ b/test/test_utils/test_url_builder_gui.py @@ -19,7 +19,7 @@ def app(): return instance -@pytest.fixture() +@pytest.fixture def widget(app): from pybreeze.pybreeze_ui.tools_gui.url_builder_gui import UrlBuilderGUI gui = UrlBuilderGUI() @@ -60,7 +60,7 @@ def test_empty_to_url_shows_hint(self, widget): def test_copy_output(self, app, widget): widget.input_edit.setPlainText("https://example.com/api?a=1") widget.convert_to_json() - widget.actions.copy() + widget.output_actions.copy() assert "example.com" in QApplication.clipboard().text() diff --git a/test/test_utils/test_url_parser_differential.py b/test/test_utils/test_url_parser_differential.py index 80e0307a..54d23858 100644 --- a/test/test_utils/test_url_parser_differential.py +++ b/test/test_utils/test_url_parser_differential.py @@ -18,7 +18,7 @@ _PUBLIC = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.215.14", 0))] -@pytest.fixture() +@pytest.fixture def public_dns(monkeypatch): """Every name resolves to a public address, so only the parsing decides.""" monkeypatch.setattr(url_validation.socket, "getaddrinfo", lambda *_a, **_k: _PUBLIC) @@ -86,3 +86,35 @@ def serve() -> None: server.join(5) assert served == [] + + +def test_the_two_readings_are_compared_past_the_character_check(public_dns): + # Every URL above stops at the character check; this one passes it, and only + # the comparison sees that urllib3 decodes the zone ID and urlparse does not + from pybreeze.utils.exception.exception_tags import url_ambiguous_host_error + + with pytest.raises(UnsafeURLError) as raised: + validate_url("http://[fe80::1%25eth0]/") + + assert str(raised.value) == url_ambiguous_host_error + + +def test_a_url_only_urlparse_refuses_is_refused_without_quoting_it(public_dns, monkeypatch): + # No URL urllib3 takes is known to fail urlparse, but one would have escaped + # into the Qt slot as a ValueError quoting the whole URL + from pybreeze.utils.exception.exception_tags import url_unparsable_error + + def refuse(url): + raise ValueError(f"netloc {url!r} contains invalid characters") + + monkeypatch.setattr(url_validation, "urlparse", refuse) + + with pytest.raises(UnsafeURLError) as raised: + validate_url("https://example.com/?token=sk-live-not-a-real-key") + + assert str(raised.value) == url_unparsable_error + + +def test_a_name_idna_cannot_encode_is_left_as_it_is(): + # It then fails the comparison or the lookup; it is not changed into another name + assert url_validation._as_ascii("a☕b.com") == "a☕b.com" diff --git a/test/test_utils/test_url_validation.py b/test/test_utils/test_url_validation.py index c2b170d4..50c22113 100644 --- a/test/test_utils/test_url_validation.py +++ b/test/test_utils/test_url_validation.py @@ -79,3 +79,57 @@ class TestHostnamesThatCannotBeLookedUp: def test_an_impossible_hostname_is_refused_like_any_other(self, url): with pytest.raises(UnsafeURLError): validate_url(url) + + +class TestTheAddressesOfAName: + @staticmethod + def _resolving(monkeypatch, *addresses): + import socket + + from pybreeze.utils.network import url_validation + + answer = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, 0)) for address in addresses] + monkeypatch.setattr(url_validation.socket, "getaddrinfo", lambda *_a, **_k: answer) + + def test_a_name_with_no_address_is_refused(self, monkeypatch): + from pybreeze.utils.network.url_validation import public_addresses + + self._resolving(monkeypatch) + with pytest.raises(UnsafeURLError, match="Cannot resolve hostname 'example.com'"): + public_addresses("example.com") + + def test_each_address_comes_once_in_the_resolvers_order(self, monkeypatch): + # The resolver repeats an address once per socket type it was not asked to filter + from pybreeze.utils.network.url_validation import public_addresses + + self._resolving(monkeypatch, "93.184.215.14", "93.184.215.14", "8.8.8.8", "93.184.215.14") + assert public_addresses("example.com") == ["93.184.215.14", "8.8.8.8"] + + +class TestTheIPv4InsideAnIPv6Wrapper: + """The standard library already refuses Teredo (private) and NAT64 (reserved) addresses. + + ``_embedded_ipv4`` is the check behind that, should a Python version class + them otherwise, as 3.12 and 3.13 reclassified other ranges: it must find + the IPv4 each wrapper routes to, and the suite never reached it. + """ + + @pytest.mark.parametrize(("wrapped", "inside"), [ + ("::ffff:169.254.169.254", "169.254.169.254"), # IPv4-mapped + ("2002:a9fe:a9fe::1", "169.254.169.254"), # 6to4 + ("2001:0:0:0:0:0:5601:5601", "169.254.169.254"), # Teredo: the client, inverted + ("64:ff9b::a9fe:a9fe", "169.254.169.254"), # NAT64 well-known prefix + ]) + def test_the_address_it_routes_to_is_found(self, wrapped, inside): + import ipaddress + + from pybreeze.utils.network.url_validation import _embedded_ipv4 + + assert ipaddress.ip_address(inside) in _embedded_ipv4(ipaddress.ip_address(wrapped)) + + def test_a_plain_ipv6_address_wraps_nothing(self): + import ipaddress + + from pybreeze.utils.network.url_validation import _embedded_ipv4 + + assert _embedded_ipv4(ipaddress.ip_address("2606:4700:4700::1111")) == [] diff --git a/test/test_utils/test_window_icon.py b/test/test_utils/test_window_icon.py new file mode 100644 index 00000000..be9db2a0 --- /dev/null +++ b/test/test_utils/test_window_icon.py @@ -0,0 +1,30 @@ +"""The main window shows PyBreeze's icon wherever the IDE is started from.""" +from __future__ import annotations + +from pathlib import Path + +from test_utils.started_window import run_started_window + +_ICON = """ +result = {"has_icon": not window.windowIcon().isNull(), + "sizes": [size.width() for size in window.windowIcon().availableSizes()]} +""" + + +def test_the_window_has_pybreezes_icon_whatever_the_working_folder(tmp_path): + # It was read from pybreeze_icon.ico in the working folder, which a started + # IDE (python -m pybreeze, an installed package) never has: the window had + # no icon. The child here runs in an empty temporary folder. + seen = run_started_window(tmp_path, _ICON) + + assert seen["has_icon"] + assert seen["sizes"] + + +def test_the_icon_is_package_data(): + # Shipped inside the package, beside the module that loads it + from pybreeze.pybreeze_ui.editor_main import main_ui + + icon = Path(main_ui.__file__).with_name("pybreeze_icon.ico") + assert icon.is_file() + assert icon.read_bytes()[:4] == b"\x00\x00\x01\x00" # an .ico file's header