Skip to content

Bring git, diagnostics, multi-caret editing and a language server into the editor - #247

Merged
JE-Chen merged 36 commits into
mainfrom
dev
Jul 26, 2026
Merged

Bring git, diagnostics, multi-caret editing and a language server into the editor#247
JE-Chen merged 36 commits into
mainfrom
dev

Conversation

@JE-Chen

@JE-Chen JE-Chen commented Jul 26, 2026

Copy link
Copy Markdown
Member

What this adds

Thirty-four commits of editor work, grouped by area.

Git in the editor

  • Change markers in the gutter against the committed file, with F7 / Shift+F7 to move between them and Ctrl+Alt+Z to revert the change under the caret.
  • A whole-file diff against HEAD, staging one hunk at a time, and a staged-versus-working comparison.
  • Inline blame (Ctrl+Alt+B), painted beside the line rather than in a separate pane.

Baselines and blame are read on a worker thread, and the repository handle is closed after each read so git cat-file processes do not accumulate.

Diagnostics

  • ruff findings underlined in the editor and listed in a Problems panel with severity filtering, project scope, and applying the fixes ruff can make.
  • Language-server diagnostics shown the same way for files ruff does not cover.

Both the single-file and project-wide checks run off the UI thread.

Editing

Multiple carets (with column selection by Alt-drag), snippets with tab stops and a per-language snippet editor, macro recording, surround-selection, recent locations, a right-click menu, indent guides and trailing-whitespace marks, a minimap marking diagnostics and changes, a split view of the same file, and syntax highlighting for eleven languages beyond Python.

Language server

Completion, hover, rename and formatting for non-Python files, over stdio with Content-Length framing.

Running and testing

A pytest panel that runs one test, re-runs the failures, and filters the list; breakpoints in the gutter that reach pdb when the debugger starts, with stepping bound to F10 / F11 / Shift+F11.

Session and files

The caret, bookmarks and folds come back with the session; the encoding menu now takes effect and line endings can be chosen; Python can be formatted on save behind a setting.

Fixes to earlier work in this branch

  • Seven key sequences had two owners. Qt runs neither action when that happens, so each clash quietly disabled both features -- Ctrl+D stopped duplicating lines, and F5/F9 stopped reaching the toolbar. Registration now goes through one helper backed by a registry that records who owns each sequence and logs a clash, and the tests walk the real actions so the next duplicate fails the build. Ctrl+D is duplicate-line again; find-and-replace moved to Ctrl+H so the toolbar keeps Ctrl+Shift+F for searching across files.
  • The test suite crashed the interpreter (0xC0000409). Tests were building QKeyEvent objects and calling handlers directly; Qt kept the event while Python was free to collect it. They use QTest.keyClick now. Tracking that down also turned up two real defects: signals connected to lambdas with no receiver, so a queued signal could arrive at a deleted editor, and worker wrappers left in place after deleteLater.
  • Reverting a pure insertion left a blank line behind; format-on-save was blocked by YapfError rather than SyntaxError, so saving half-written code failed.

Verification

ruff check . clean. 1359 tests pass, run three times over to confirm the suite is stable.

JE-Chen added 30 commits July 25, 2026 16:52
Groundwork for showing in the editor what changed since the last commit.

line_status compares a buffer against a baseline with difflib and reports
which lines were added, modified, or sit just below a deletion, plus the
navigation helpers for stepping between them. It is pure logic: no Qt, no
git, no I/O, and it gives up on very large buffers rather than stalling
the edit it is meant to annotate.

file_baseline reads the HEAD version of a path through GitPython, finding
the repository from the file itself. Line endings are normalised to \n so
the comparison is unaffected by how the repository stores them; a file
outside a repository, never committed, or not utf-8 text simply has no
baseline.
The gutter now shows how the open file differs from its last commit: a
green bar for an added line, orange for a modified one, and a thin red
line where lines were deleted. Ctrl+Alt+Down and Ctrl+Alt+Up step through
the changes, wrapping around.

The baseline is read on a background thread when a file is opened, since
that is git and file I/O, and a read left in flight when the file changes
is discarded rather than applied to the wrong buffer. Recomputing against
that baseline is a pure in-memory diff, debounced so a burst of typing
diffs once, skipped entirely when there is no baseline, and repainting
only when the markers actually changed.
code_scan could already run ruff, but nothing in the UI consumed its
output, so the results went into a queue nobody read.

Findings are now underlined where they occur and listed in a Problems
dock panel that jumps to the line on double-click. The buffer is linted
rather than the file on disk, so unsaved edits are covered, and the run
happens on a worker thread after typing pauses. A result from a run that
has since been superseded is discarded instead of being applied to newer
text. A missing or failing ruff yields no diagnostics rather than an
error, so the editor keeps working without it.

Parsing ruff JSON lives in utils/lint as pure logic: anything it cannot
recognise is skipped, because a change in the linter output must not
break the editor.
The baseline the gutter markers compare against was already in memory, so
restoring a change needed no new git call: Ctrl+Alt+Z puts the block under
the caret back to its committed content as a single undo step.

Reverting a purely added line removes the whole line rather than blanking
it, and a deletion is restored by inserting the committed lines back at
that point.
GitService could only show a whole commit's diff, so seeing what changed
in the file being edited meant leaving the editor.

The Git menu now opens the current file's diff against its committed
version in the existing side-by-side viewer, built from the baseline the
editor already holds rather than a fresh git read. An unchanged file
opens nothing instead of an empty tab.
Ctrl+Alt+B annotates each line with the commit, author and summary that
last touched it, drawn after the line text so the code itself is not
moved. The blame runs git, so it is fetched on a worker thread and only
once the display is actually turned on; toggling off drops the data.

Also silences the repository''s one remaining ruff finding: the Qt import
in the CI smoke test must follow the platform variable it sets, which is
now stated in a comment rather than left to look like an oversight.
The Encodings menu recorded the chosen encoding in the settings, but
nothing on the read or write path ever consulted it: every file was
opened and saved as UTF-8 regardless.

Choosing an encoding now applies to the current tab. An unmodified file
is re-read with it, so mojibake can be corrected in place, while a file
with unsaved work only changes the encoding it will be saved with —
re-reading would have thrown that work away. An encoding that cannot
decode the file leaves the text alone instead of clearing it.

A file''s line ending is detected on open and written back on save, so
editing one line of a CRLF file no longer rewrites every line in it, and
a new Line Endings menu converts between LF, CRLF and CR. Auto-save
follows the same encoding and ending as the tab it belongs to.
yapf was only reachable as a manual menu action. A Format on Save toggle
in the Check menu now applies the same formatting when a file is saved,
for Python files only, keeping the caret on its line afterwards.

The formatting itself moves into utils/format_code so the menu action and
the save path share one implementation. That implementation also catches
yapf''s own YapfError, which it raises instead of SyntaxError for
unparseable input: without that, saving half-written code with the
setting on would have failed the save, and the existing menu action would
have raised.
Reopening a session brought the files back but nothing else: every file
returned at line one, with its bookmarks and folds gone.

Each tab now stores its caret line, bookmarked lines and folded headers
alongside the file list, and restoring applies them. Restoring is
best-effort: a file that shrank outside the editor simply skips the lines
that no longer exist, and a hand-edited settings entry is cleaned rather
than trusted, since neither should be able to break startup.
A vertical guide now marks each indentation level, and stray whitespace
at the end of a line is shaded so it can be seen before it reaches a
diff. Both are drawn in the viewport and can be turned off from the Style
menu; guides paint behind the text so they never obscure it.

Where the marks go is computed in utils/indentation as pure logic: tabs
advance to the next stop rather than adding a fixed width, partial
indentation rounds down to the level it reached, and a blank line shows
no guide because it carries no indentation to describe.
Ctrl+Alt+\ opens a second view below the editor showing the same
document: an edit in either side appears in the other at once, while
scrolling and the caret stay independent, so one end of a file can be
read while the other is edited.

The view shares the QTextDocument rather than copying text, which also
means the existing syntax highlighter — attached to the document, not the
editor — colours it without a second highlighter. Closing it detaches
from the shared document first, so tearing the view down cannot take the
main editor''s document state with it.
Ctrl+Alt+M shows an overview of the whole file: each line is drawn as a
bar following its length and indentation, so the shape of the code is
recognisable, and a band marks what is currently on screen. Clicking or
dragging jumps there.

A file with more lines than the minimap has pixels is sampled rather than
drawn line by line, and the redraw after an edit is debounced, so a large
file costs the same to display as a small one.
Typing a trigger word and pressing Tab now expands a snippet, with Tab
stepping through its placeholders and selecting each default value on the
way. Once the stops run out, Tab indents as it always did, and a
selection still indents rather than expanding anything.

Snippets use the ``$1`` / ``${2:default}`` / ``$0`` notation other
editors use, so existing ones can be pasted straight into snippets.json
beside the settings. A missing or broken snippet file falls back to the
built-in Python set instead of leaving the editor unable to expand
anything, and an expansion is a single undo step.
A Tests dock panel runs pytest in the project directory and lists what it
reported, failures first, with the summary line as its status. Double-
clicking a row opens that test, jumping to the failing line when the test
failed.

The run happens on a worker thread since it spawns a subprocess, a
re-entrant Run is ignored rather than dropping a thread that is still
going, and closing the panel waits for it. Parsing lives in
utils/test_runner as pure logic, so a result line — which also contains a
colon — is never mistaken for a failure location.
Ctrl+Shift+L puts a caret at the end of every selected line and Alt-click
adds or removes one anywhere; typing and Backspace then apply at all of
them as a single undo step. Escape, or a plain click, goes back to one
caret.

QPlainTextEdit has only one caret, so the extra ones are tracked and
drawn here. Edits run from the end of the document backwards, which keeps
an edit from moving a position that has not been handled yet, and the
carets are then advanced by how much text went in ahead of each. Backspace
is refused outright when any caret sits at the start of the document,
since deleting for the others would pull the carets out of step.
Completion was jedi-only, so it covered Python and nothing else. Files
with another suffix now talk to a language server over stdio — TypeScript,
Rust, Go, C/C++, Lua and JSON out of the box, and any other suffix through
a setting — while Python keeps jedi rather than gaining a second source of
completions for the same file.

The client uses QProcess rather than a thread of its own, since QProcess
already signals when output arrives. A server that is not installed, fails
to start, or dies mid-session simply means no completions; the editor is
never blocked on it, and it is shut down politely before being killed.

Message framing lives in utils/lsp as pure logic: a body arriving split
across reads is held until the rest turns up, and a header without a
usable length or a body that is not JSON is dropped rather than raised, so
a misbehaving server cannot stall the editor.
The full test suite had started crashing the interpreter partway through
(0xC0000409), always at the first test that spins the event loop.
Bisecting placed it at the multi-caret commit, and the cause was in its
tests: a QKeyEvent built in Python and handed straight to a handler leaves
Qt holding an object Python is free to collect, which is only noticed once
the queued events are processed. Those tests now send keys through QTest,
which keeps ownership with Qt.

Two real defects surfaced while tracking that down:

- Each worker was connected with a lambda, which gives Qt no receiver to
  disconnect, so a queued result could arrive at an editor already
  destroyed. The lint, git-baseline and blame managers are now QObject
  children of their editor and connect bound methods instead.
- A manager kept holding its worker after finished/deleteLater had removed
  the C++ object, so the next stop() raised RuntimeError. Each now drops
  the reference when the worker finishes and tolerates one already gone.

The git baseline and blame readers also close the GitPython Repo they
open, which was leaving a pair of long-lived git subprocesses behind on
every read.
The LSP client already emitted diagnostics_ready, but nothing was
connected to it, so errors from a language server were fetched and then
dropped. They now take the same path as ruff''s findings: the same wavy
underlines, the same tooltip, the same problems panel — a non-Python file
is no longer a blank slate.

The two sources report different fields, so both are converted into one
diagnostic shape and the display never has to know which tool found what.
The ruff pass, which skips non-Python files, no longer clears the
diagnostics on its way out when a server is supplying them; otherwise
every pause in typing wiped what the server had just reported.

CodeEditor also stops its workers in closeEvent, so a directly created
editor releases its threads the way an editor tab already did.
Typing and Backspace were the only keys that reached every caret. Delete,
Enter and the left/right arrows do too now, and three shortcuts grow the
set: Ctrl+Alt+Shift+Up/Down puts a caret on the neighbouring line at the
same column, and Ctrl+D adds one at the next occurrence of the word under
the caret, wrapping to the top like other editors.

Delete needed the edit arithmetic split in two: an insertion or Backspace
moves the caret that made it, while Delete removes the character after the
caret and so moves only what follows. A caret on a shorter line clamps to
its end rather than dangling past it.
Alt-drag now puts a caret on every line the rectangle covers, at the
column the pointer reached, which is what makes editing a block of lines
at once possible. Dragging upwards works the same, and a line shorter than
that column stops at its end rather than dangling past it.

An Alt-click that does not drag still toggles a single caret, so the two
gestures share the modifier without either losing out: the anchor is
recorded on press and only becomes a column selection once the pointer
actually moves.
The editor had no context menu at all, so cut, paste, commenting and the
rest were keyboard-only. Right-click now opens Qt''s own edit menu — which
already tracks what is enabled — followed by toggle comment, toggle
bookmark, go to definition and revert this change.

The two that depend on something being available say so by being greyed
out: revert only when a git baseline is known, go to definition only when
a language server is running. Definition itself is a new LSP request, with
its reply parsed from any of the three shapes servers use for it.
The minimap showed the shape of the file but not where anything was.
Ticks down its left edge now mark diagnostics, ticks down its right edge
mark lines that differ from the last commit, and a middle column marks the
other occurrences of the word under the caret — so the overview says where
to look, not just how long the file is.

Every marker reads state the editor has already computed, so nothing
rescans the file, and moving the caret repaints on the same debounce as an
edit.
Running the whole suite to check one fix was the only option the panel
offered. Run Selected takes whatever is highlighted, Re-run Failures takes
what failed last time — the action wanted most after a fix — and a filter
box narrows the list as it is typed, keeping failures at the top.

The node ids handed back to pytest are the ones it printed, passed as
separate arguments rather than through a shell.
The panel listed the current tab''s findings and nothing else. It now also
checks the whole project on demand, filters by severity, and applies the
fixes ruff can make.

Severity comes from the rule code for ruff findings and from the server''s
own field for LSP ones, so both sources sort into the same three levels.
Project results carry the file they came from, and double-clicking one
opens that file before jumping. Applying fixes rewrites files on disk, so
the open tab is re-read afterwards instead of being left on stale text.
Completion and diagnostics were all the client asked for. The context
menu now also describes the symbol under the caret, renames it, and
formats the document, each backed by the matching LSP request.

Renaming falls back to the existing whole-file word replace when no server
is running, so a Python file keeps the behaviour it had. Edits coming back
from a server are applied from the end of the document backwards, so one
edit never shifts another that has not been applied yet, and the batch is
a single undo step.

The reply parsing covers the shapes servers actually use: hover contents
as a string, an object or a list of both, and edits either as a plain list
or inside a WorkspaceEdit keyed by URI.
Three conveniences that were missing:

- Ctrl+Shift+R records the keystrokes that follow and Ctrl+Shift+P plays
  them back as one undo step. Playback sends each event synchronously
  rather than queuing it, so the event objects live only for that call.
- Typing a bracket or quote over a selection now wraps it instead of
  replacing it, leaving the original text selected so it can be wrapped
  again.
- Ctrl+Alt+S saves every tab that has a file name, each with its own
  encoding and line ending, and running format-on-save first when that is
  turned on. A tab that has never been saved is skipped rather than having
  a location chosen for it silently.
The navigation history could only be stepped through one entry at a time.
Ctrl+Alt+E now lists the lines visited recently, newest first and labelled
with their text, and jumping to one takes a single choice rather than
several presses of back.
Only Python was ever coloured, so every other file opened as plain text.
A keyword-driven highlighter now covers TypeScript, JavaScript, Rust, Go,
C, C++, Java, shell, SQL, JSON, YAML and TOML, colouring keywords,
strings, comments and numbers — short of real parsing, but enough to read
the shape of the code.

The rules are plain data, so adding a language is a table entry rather
than a class. A block comment carries its state across lines, a language
without one skips that pass, and Python still uses the highlighter written
for it. A file whose suffix has no rules also falls back to that one,
since a new unnamed file is usually about to become Python.
Snippets could only be changed by hand-editing snippets.json, and the
built-in set was Python whatever the file. A dialog now adds, edits and
removes them, and saving reloads every open tab so the change is live.

Snippet sets are now layered from general to specific: the built-in Python
set, then the ones belonging to the file''s language (TypeScript,
JavaScript, Go and Rust to start), then the user''s own — so a user
definition always wins. The user file may list snippets flat or grouped by
suffix, and a group meant for another language is ignored rather than
leaking into this one.
Staging was all-or-nothing: GitService could only add the whole file.
Stage This Change in the context menu now puts just the block under the
caret into the index, by assembling the committed content with that one
hunk applied and writing it as the index entry — the file on disk is left
exactly as it is being edited, and every other change stays uncommitted.

Reading the index back also gives a diff of staged against working, so
what is about to be committed can be checked before committing it.
JE-Chen added 4 commits July 25, 2026 21:53
The debugger could be started but not directed: there was no way to say
where to stop or what to do once it did.

F9 toggles a breakpoint on the caret''s line, drawn in the gutter, and the
marks are anchored to the text so inserting a line above one moves it with
its code rather than leaving it pointing at the wrong place. F5, F10, F11
and Shift+F11 continue, step over, step into and step out by writing the
matching pdb command to the running debugger, and the breakpoints can be
handed to it the same way.

Nothing is sent when no debugger is running, since a pdb command only
means anything while it waits for input, and a broken pipe is reported
rather than raised.
Seven key sequences had two owners, and Qt runs neither action when that
happens, so each clash quietly disabled both features:

- Ctrl+D added a caret instead of duplicating the line
- Ctrl+Shift+P collided with pip install
- Ctrl+Alt+S collided with Save All
- Ctrl+Alt+Up/Down collided with incrementing the number under the caret
- F5 and F9 collided with the toolbar Run and Debug

Change-navigation moves to F7 and Shift+F7, macro playback to Ctrl+Shift+G,
the caret-at-next-occurrence to Ctrl+Alt+N, breakpoints to Ctrl+F9 and
Ctrl+F5, Save All to Ctrl+Shift+S, and find-and-replace to Ctrl+H so the
toolbar keeps Ctrl+Shift+F for searching across files.

Registration went through eight copies of the same four lines. They now
share one helper backed by a registry that records who owns each sequence,
logs a clash instead of letting it pass, and gives every editor command a
stable name. Tests walk the real actions rather than a list, so the next
duplicate fails the build.
The Problems panel ran ruff over every file directly in its refresh, so
ticking "whole project" froze the window until the walk finished -- seconds
on a real tree. The single-file check has always used a worker thread; this
gives the project check the same treatment and reports the result through a
signal, with the panel showing that a check is running meanwhile.
Breakpoints set in the gutter never reached pdb: the code that sends them
existed but nothing called it, so a breakpoint was only a mark on screen and
the run went straight through it. Starting the debugger now hands them over
while pdb waits at the first line.
@codacy-production

codacy-production Bot commented Jul 26, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 1833 complexity · 165 duplication

Metric Results
Complexity 1833
Duplication 165

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

JE-Chen added 2 commits July 26, 2026 12:11
UnicodeDecodeError subclasses ValueError, so listing ValueError first made
the decode handler unreachable: a non-text blob took the general branch and
the reason was never logged. Both readers now try the specific one first.
The suite aborted the interpreter on Python 3.10 roughly half the time, at
whichever test next spun an event loop. The message behind it was "QThread:
Destroyed while thread is still running", which Qt reports through qFatal --
no traceback, and a crash site far from the cause.

Closing an editor stopped the lint, baseline and blame workers but left the
debounce timers running. A timer firing after the close started a fresh lint
thread, and by then nothing was left to wait for it: the editor was destroyed
with that thread still going.

The timers now stop first, and EditorWidget closes the editor rather than
stopping its managers by hand, so the two paths cannot drift apart. Each
background thread also carries its name, so if this ever happens again Qt says
which one.

Verified on 3.10: 6 full runs with no crash, against 4 in 6 before.
@sonarqubecloud

Copy link
Copy Markdown

@JE-Chen
JE-Chen merged commit 910a802 into main Jul 26, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant