Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
17ce4df
Read a file committed content and diff it line by line
JE-Chen Jul 25, 2026
a8b2bb7
Mark git changes in the editor gutter
JE-Chen Jul 25, 2026
786c318
Show ruff diagnostics in the editor and a problems panel
JE-Chen Jul 25, 2026
931aa76
Revert the git change under the caret
JE-Chen Jul 25, 2026
22d2a50
Diff the open file against HEAD
JE-Chen Jul 25, 2026
f3ab39f
Show inline git blame
JE-Chen Jul 25, 2026
4333f26
Make the encoding menu take effect, and add line endings
JE-Chen Jul 25, 2026
8227e41
Format Python on save, behind a setting
JE-Chen Jul 25, 2026
9e30614
Restore the caret, bookmarks and folds with the session
JE-Chen Jul 25, 2026
0f2952e
Show indent guides and trailing whitespace
JE-Chen Jul 25, 2026
1a03a72
Add a split view of the same file
JE-Chen Jul 25, 2026
af7c878
Add a minimap beside the editor
JE-Chen Jul 25, 2026
8047a4e
Expand code snippets on Tab
JE-Chen Jul 25, 2026
9de26ac
Run the project tests from a panel
JE-Chen Jul 25, 2026
6ec65ea
Edit at several carets at once
JE-Chen Jul 25, 2026
71a6fdf
Complete non-Python files through a language server
JE-Chen Jul 25, 2026
5a98f93
Stop background workers cleanly, and send test keys through QTest
JE-Chen Jul 25, 2026
69a7147
Show language-server diagnostics in the editor
JE-Chen Jul 25, 2026
72ca1e3
Complete the multi-caret keys
JE-Chen Jul 25, 2026
a100326
Select a column by Alt-dragging
JE-Chen Jul 25, 2026
a30d7d9
Add a right-click menu to the editor
JE-Chen Jul 25, 2026
65adfea
Mark diagnostics, changes and occurrences in the minimap
JE-Chen Jul 25, 2026
ffaaada
Run one test, re-run failures, and filter the list
JE-Chen Jul 25, 2026
dbce355
Give the problems panel severity, scope and fixes
JE-Chen Jul 25, 2026
5afc63a
Use the language server for hover, rename and formatting
JE-Chen Jul 25, 2026
ff7d7cd
Record macros, surround selections, and save every tab
JE-Chen Jul 25, 2026
5a3cb3b
List recent locations to jump back to
JE-Chen Jul 25, 2026
2ae58d5
Highlight languages other than Python
JE-Chen Jul 25, 2026
689452f
Edit snippets in the editor, and scope them per language
JE-Chen Jul 25, 2026
6a9bf4e
Stage one change at a time
JE-Chen Jul 25, 2026
d7baa57
Set breakpoints and step through a debug run
JE-Chen Jul 25, 2026
94b88fa
Give every editor shortcut one owner
JE-Chen Jul 26, 2026
f910e3e
Check the whole project off the UI thread
JE-Chen Jul 26, 2026
706ffe8
Send breakpoints to the debugger it starts
JE-Chen Jul 26, 2026
d9f1499
Catch the decode error before its base class
JE-Chen Jul 26, 2026
bb62733
Stop the debounce timers when an editor closes
JE-Chen Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,17 @@ The editor launches in a maximized window with a dark amber theme by default.
- **Search & Replace** -- Search within the current file, across folders, or project-wide with regex and case-sensitive options. Runs in background threads for large projects.
- **Code Folding** -- Collapse and expand indented blocks (functions, classes, loops) from the gutter fold triangles or the keyboard. Folding is indentation-based and only toggles line visibility -- it never modifies the text, so saving always writes the complete file. Folds self-heal after edits: a fold whose header no longer exists simply reopens instead of hiding the wrong lines.
- **Bookmarks** -- Mark lines and jump between them with the keyboard, or click the gutter to toggle. Bookmarks are anchored to the text (via `QTextCursor`), so they follow their code when lines are inserted or removed above them instead of drifting.
- **Multiple Carets** -- `Ctrl+Shift+L` puts a caret at the end of every selected line, and `Alt`-click adds or removes one anywhere. Typing and Backspace then apply at all of them as a single undo step; `Esc` or a plain click returns to one caret.
- **Split View** (`Ctrl+Alt+\`) -- A second view of the same file, sharing one document: an edit in either side shows up in the other at once, while scrolling and the caret stay independent.
- **Minimap** (`Ctrl+Alt+M`) -- An overview of the whole file drawn as bars following each line's length and indentation, with a band marking what is on screen. Click or drag to jump. Large files are sampled rather than drawn line by line.
- **Snippets** -- Type a trigger word and press Tab to expand it, then Tab through the placeholders with each default value selected. Uses the usual `$1` / `${2:default}` / `$0` notation, so existing snippets drop straight into `snippets.json`; a missing or broken file falls back to the built-in Python set.
- **Test Panel** -- Run pytest from a dock panel and read the results, failures first, with the summary as the status line. Double-click a row to open that test, jumping to the failing line.
- **Language Server Support** -- Non-Python files complete through a language server over stdio (TypeScript, Rust, Go, C/C++, Lua, JSON and more, configurable per suffix), while Python keeps using jedi. A server that is not installed simply means no completions rather than an error.
- **Encoding & Line Endings** -- A file's encoding and line-ending style are detected when it opens and written back unchanged when it saves, so editing one line of a CRLF file no longer rewrites every line. Both can be changed from the File menu; changing the encoding re-reads an unmodified file so mojibake can be fixed in place, and never discards unsaved work.
- **Format on Save** -- Optionally run yapf when a file is saved, keeping the caret on its line. Source that cannot be parsed is left alone rather than blocking the save.
- **Indent Guides & Trailing Whitespace** -- A vertical guide at each indentation level and shading on stray end-of-line whitespace, both toggleable from the Style menu.
- **Lint Diagnostics** -- Findings from `ruff` are underlined in the editor and listed in a Problems dock panel (rule, message, line), with double-click to jump. The **buffer** is checked, not the file on disk, so unsaved edits are covered; the run happens on a worker thread after typing pauses, and a stale result from a superseded run is discarded. If `ruff` is not installed, or a run fails, the editor simply shows no diagnostics rather than reporting an error.
- **Git Change Markers** -- The gutter shows how the file differs from its last commit: a green bar for added lines, orange for modified, and a thin red line where lines were deleted. Jump between changes with `Ctrl+Alt+Down` / `Ctrl+Alt+Up`, revert the change under the caret back to its committed form with `Ctrl+Alt+Z` (one undo step), and open a side-by-side diff of the whole file against HEAD from the Git menu. `Ctrl+Alt+B` toggles inline blame, showing the commit, author and summary that last touched each line. The committed version is read on a background thread when the file opens, and the comparison itself is a pure in-memory diff, recomputed only after typing pauses -- so editing never waits on git. Files outside a repository, or not yet committed, simply show no markers.
- **Occurrence Highlighting** -- Placing the caret on an identifier highlights every other whole-word occurrence of it in the file. Keywords and single characters are ignored, and the scan is skipped on very large files to keep caret movement instant.
- **Line Operations** -- Delete the current line or selection (`Ctrl+Shift+D`), sort selected lines (`Ctrl+Alt+S`), join selected lines into one (`Ctrl+Shift+J`), and (from the Text menu) natural sort, remove duplicate lines, remove blank lines, reverse line order, or align lines on a delimiter (e.g. `=`). Each is a single undo step.
- **Duplicate** (`Ctrl+D`) -- Duplicates the selection when there is one (selecting the new copy), or the whole line when there isn't.
Expand Down Expand Up @@ -295,6 +306,7 @@ Plugins are automatically discovered from the `jeditor_plugins/` directory. See
| `Ctrl+O` | Open file |
| `Ctrl+K` | Open folder |
| `Ctrl+S` | Save file |
| `Ctrl+Shift+S` | Save every modified tab |
| `Ctrl+Shift+A` | Command palette |
| `Ctrl+P` | Quick open (go to file) |
| `Ctrl+Shift+O` | Go to symbol |
Expand All @@ -314,13 +326,33 @@ Plugins are automatically discovered from the `jeditor_plugins/` directory. See
| `Ctrl+Alt+Up` | Increment number under caret |
| `Ctrl+Alt+Down` | Decrement number under caret |
| `F2` | Rename occurrences in file |
| `Ctrl+D` | Duplicate line / selection |
| `Alt+Up` / `Alt+Down` | Move line up / down |
| `Ctrl+/` | Toggle comment |
| `Ctrl+Shift+L` | Caret at the end of every selected line |
| `Ctrl+Alt+N` | Add caret at next occurrence |
| `Ctrl+Alt+Shift+Up` / `Down` | Add caret above / below |
| `Ctrl+Shift+Esc` | Back to a single caret |
| `Ctrl+Shift+R` | Start / stop macro recording |
| `Ctrl+Shift+G` | Play macro |
| `Ctrl+Alt+E` | Recent locations |
| `Ctrl+Alt+\` | Toggle split view |
| `Ctrl+Alt+M` | Toggle minimap |
| `F7` / `Shift+F7` | Next / previous change |
| `Ctrl+Alt+Z` | Revert the change at the caret |
| `Ctrl+Alt+B` | Toggle inline blame |
| `Ctrl+J` | Reformat JSON |
| `Ctrl+Shift+Y` | YAPF Python format |
| `Ctrl+Alt+P` | PEP8 format checker |
| `Ctrl+F` | Find text (browser) |
| `Ctrl+F` | Find text (editor, browser) |
| `Ctrl+H` | Find and replace |
| `Ctrl+G` | Go to line |
| `F5` | Run program |
| `F9` | Debug |
| `Shift+F5` | Stop program |
| `Ctrl+F9` | Toggle breakpoint |
| `Ctrl+F5` | Debugger: continue |
| `F10` / `F11` / `Shift+F11` | Debugger: step over / into / out |
| `Up/Down` | Command history (console) |

---
Expand Down
171 changes: 171 additions & 0 deletions je_editor/code_scan/ruff_lint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""
對編輯中的內容執行 ruff,取得診斷
Run ruff over the text being edited and collect its diagnostics.

檢查的是**緩衝區內容**而不是磁碟上的檔案,所以還沒存檔的修改也會被檢查;
內容經由標準輸入傳給 ruff,只用 ``--stdin-filename`` 告訴它檔名,好讓 per-file
的設定與規則照常套用。
This lints the **buffer**, not the file on disk, so unsaved edits are checked
too. The text goes in through standard input and only ``--stdin-filename`` tells
ruff what the file is called, so per-file configuration still applies.
"""
from __future__ import annotations

import shutil
import subprocess # nosec B404 - 以引數清單呼叫 ruff,未使用 shell
import sys
from pathlib import Path

from je_editor.utils.lint.ruff_diagnostics import Diagnostic, parse_ruff_json
from je_editor.utils.logging.loggin_instance import jeditor_logger

# ruff 執行檔名稱 / The ruff executable's name
_RUFF_NAME = "ruff.exe" if sys.platform == "win32" else "ruff"
# 單次檢查的逾時(秒):ruff 很快,超過就是出了別的問題
# Timeout for one run: ruff is fast, so exceeding this means something else is wrong
LINT_TIMEOUT_SECONDS = 20
# 可以檢查的副檔名 / Suffixes worth linting
PYTHON_SUFFIXES = (".py", ".pyi")


def find_ruff_executable() -> str | None:
"""
找出可用的 ruff 執行檔
Locate a usable ruff executable.

先找目前直譯器所在的環境(通常就是專案的虛擬環境),再退回 PATH。
The running interpreter's environment is tried first, since that is usually
the project's virtual environment, then PATH.

:return: 執行檔路徑,找不到時為 ``None`` / the path, or ``None`` when absent
"""
beside_interpreter = Path(sys.executable).parent / _RUFF_NAME
if beside_interpreter.is_file():
return str(beside_interpreter)
return shutil.which("ruff")


def is_lintable(file_path: str | Path | None) -> bool:
"""
判斷這個檔案是否值得檢查
Whether this file is worth linting.

:param file_path: 檔案路徑 / the file to consider
:return: 是 Python 檔時為 ``True`` / ``True`` for a Python file
"""
if file_path is None:
return False
return Path(file_path).suffix.lower() in PYTHON_SUFFIXES


def lint_command(executable: str, file_path: str | Path) -> list[str]:
"""
組出檢查用的指令
Build the command that lints one buffer.

:param executable: ruff 執行檔 / the ruff executable
:param file_path: 緩衝區對應的檔名 / the name the buffer is saved under
:return: 引數清單(不經過 shell)/ the argument list, never a shell string
"""
return [
executable, "check",
"--output-format", "json",
"--stdin-filename", str(file_path),
"-",
]


def _run_ruff(arguments: list[str], text: str | None = None) -> str:
"""
執行一次 ruff 並回傳其標準輸出
Run ruff once and return what it printed.

ruff 找到問題時回傳碼是 1,那是結果而不是失敗,因此不檢查回傳碼。
ruff exits with 1 when it finds problems, which is a result rather than a
failure, so the return code is not inspected.
"""
executable = find_ruff_executable()
if executable is None:
return ""
try:
completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603
[executable, *arguments],
input=text,
capture_output=True,
text=True,
encoding="utf-8",
timeout=LINT_TIMEOUT_SECONDS,
check=False,
)
except (OSError, subprocess.SubprocessError) as error:
jeditor_logger.debug(f"ruff_lint: could not run ruff: {error!r}")
return ""
return completed.stdout or ""


def lint_project(root: str | Path) -> list[Diagnostic]:
"""
檢查整個專案目錄
Lint a whole project directory.

這裡檢查的是磁碟上的檔案,因此未存檔的修改不會反映在結果中——與單檔檢查
不同,那是直接檢查緩衝區。
This lints the files on disk, so unsaved edits are not reflected, unlike the
single-file check which lints the buffer itself.

:param root: 專案根目錄 / the project root
:return: 診斷清單,含各自的檔案路徑 / the diagnostics, each with its file
"""
return parse_ruff_json(
_run_ruff(["check", "--output-format", "json", str(root)]))


def apply_fixes(target: str | Path) -> bool:
"""
對檔案或目錄套用 ruff 能自動修正的部分
Apply the fixes ruff can make to a file or directory.

會改寫磁碟上的檔案,因此呼叫端要負責重新載入編輯器中的內容。
This rewrites the files on disk, so the caller is responsible for reloading
whatever the editor is showing.

:param target: 檔案或目錄 / the file or directory to fix
:return: ruff 可用且已執行時為 ``True`` / ``True`` when ruff ran
"""
if find_ruff_executable() is None:
return False
_run_ruff(["check", "--fix", "--output-format", "json", str(target)])
return True


def lint_text(text: str, file_path: str | Path) -> list[Diagnostic]:
"""
檢查一段內容並回傳診斷
Lint *text* as if it were *file_path*.

ruff 找到問題時回傳碼是 1,那是正常結果而不是失敗,因此不特別處理回傳碼。
ruff exits with 1 when it finds problems, which is a result rather than a
failure, so the return code is not inspected.

:param text: 要檢查的內容 / the text to lint
:param file_path: 內容對應的檔名 / the name the text is saved under
:return: 診斷清單;ruff 不存在或執行失敗時為空清單
the diagnostics, or an empty list when ruff is missing or fails
"""
executable = find_ruff_executable()
if executable is None:
return []
try:
completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603
lint_command(executable, file_path),
input=text,
capture_output=True,
text=True,
encoding="utf-8",
timeout=LINT_TIMEOUT_SECONDS,
check=False,
)
except (OSError, subprocess.SubprocessError) as error:
jeditor_logger.debug(f"ruff_lint: could not run ruff: {error!r}")
return []
return parse_ruff_json(completed.stdout or "")
72 changes: 72 additions & 0 deletions je_editor/git_client/file_baseline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""
讀取檔案在 HEAD 的內容,作為變更標記的比較基準
Read a file's committed content, to serve as the baseline for change markers.

編輯器需要知道「上次提交時這個檔案長什麼樣」才能標出改動。這裡只做讀取,
不執行任何會改動工作區的 git 指令。
The editor needs to know what a file looked like at the last commit before it
can mark what changed. This only reads; it never runs a git command that
touches the working tree.
"""
from __future__ import annotations

from pathlib import Path

from git import InvalidGitRepositoryError, NoSuchPathError, Repo
from git.exc import GitError

from je_editor.utils.logging.loggin_instance import jeditor_logger


def open_repository(file_path: str | Path) -> Repo | None:
"""
找出包含此檔案的 git 儲存庫
Find the git repository that contains *file_path*.

:param file_path: 檔案路徑 / the file to locate
:return: 儲存庫,不在儲存庫內時為 ``None`` / the repository, or ``None``
"""
try:
return Repo(Path(file_path).parent, search_parent_directories=True)
except (InvalidGitRepositoryError, NoSuchPathError, ValueError, OSError):
# 不在 git 儲存庫內是常態,不是錯誤 / Not being in a repo is normal
return None


def baseline_text(file_path: str | Path) -> str | None:
"""
取得檔案在 HEAD 的文字內容(行尾統一為 ``\\n``)
Return the file's content as of HEAD, with line endings normalised to ``\\n``.

編輯器的文件一律以 ``\\n`` 表示換行,基準也統一成同樣形式,比對才不會受
儲存庫的行尾設定(例如 Windows 的 ``core.autocrlf``)影響。
A Qt document always uses ``\\n``, so the baseline is normalised to match and
the comparison is unaffected by how the repository stores line endings (for
example ``core.autocrlf`` on Windows).

:param file_path: 檔案路徑 / the file to read
:return: HEAD 版本的文字;不在儲存庫、尚未提交過、或不是文字檔時為 ``None``
the committed text, or ``None`` when the file is not in a repository,
has never been committed, or is not text
"""
repo = open_repository(file_path)
if repo is None:
return None
# 每個 Repo 都會開著常駐的 git 子程序,用完一定要關掉
# Every Repo keeps long-lived git subprocesses open, so it must be closed
with repo:
try:
relative = Path(file_path).resolve().relative_to(
Path(repo.working_tree_dir).resolve())
blob = repo.head.commit.tree / relative.as_posix()
text = blob.data_stream.read().decode("utf-8")
return text.replace("\r\n", "\n").replace("\r", "\n")
except UnicodeDecodeError:
# 必須排在 ValueError 之前,否則永遠輪不到它
# Must come before ValueError, which is its base class
jeditor_logger.debug("file_baseline: %s is not utf-8 text", file_path)
return None
except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError):
# 新檔案、尚無提交、或路徑不在工作區內
# New file, no commit yet, or outside the tree
return None
Loading
Loading