17 Commits

Author SHA1 Message Date
Vaclav Tvrdik
a2900682a0 Move channel to shared vaclavt/sublime-chanel repo
channel.json is removed; vDiff's Package Control channel now lives
in vaclavt/sublime-chanel alongside vAI and vterm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 11:29:26 +02:00
Vaclav Tvrdik
9d04d8c711 Merge remote-tracking branch 'github/main' 2026-09-29 15:24:49 +02:00
Vaclav Tvrdik
413731df47 tighten the multi-line comments 2026-09-29 15:14:02 +02:00
Vaclav Tvrdik
d6f0ed3009 type hints across vdiff 2026-09-29 15:14:02 +02:00
Vaclav Tvrdik
54c69484d5 syntax highlighting on changed lines, keeping the red/green background 2026-09-29 15:13:52 +02:00
Vaclav Tvrdik
e24922828c installable through a Package Control channel
repository.json describes vDiff itself; "tags": true means a release is a
pushed git tag. channel.json is the file users add to their "channels"
setting - it holds nothing but repository URLs, so packages added to it
later reach existing users without them changing anything.

The two are easy to confuse: Package Control rejects a file under
"channels" unless it has a top-level "repositories" key, so a repository
file belongs in "repositories". The README spells that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 22:51:55 +02:00
Vaclav Tvrdik
725d04aeed target the Python 3.14 plugin host
Build 4213 replaced Sublime's Python 3.8 plugin host with 3.14. The value
3.8 is still accepted as a legacy alias and resolves to the same host, but
naming 3.14 survives those aliases being dropped. Package Control 4.2.8
honours it from build 4203 on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 22:50:17 +02:00
Vaclav Tvrdik
af8c1dd6df .format replaced by f-strings 2026-09-26 20:25:50 +02:00
Vaclav Tvrdik
bb7ae537ea TODO.txt added 2026-09-25 12:57:15 +02:00
Vaclav Tvrdik
4749835e4f commit --amend initial version 2026-09-25 10:33:32 +02:00
Vaclav Tvrdik
0655d3b74a stage action moves to next unstaged file (there is setting to turn this off) 2026-09-25 10:08:36 +02:00
Vaclav Tvrdik
57476e78dc "[" and "]" cycle over changes (hunks) 2026-09-24 22:32:14 +02:00
Vaclav Tvrdik
18551641b5 "n" and "p" keys cycle over files 2026-09-24 17:13:29 +02:00
Vaclav Tvrdik
419b1a95fb fix "n" and "p" keys not working all the time 2026-09-24 16:49:52 +02:00
Vaclav Tvrdik
120676e952 more info in commit list 2026-09-24 16:23:27 +02:00
Vaclav Tvrdik
fda13684d2 summary line initial version added 2026-09-24 15:38:03 +02:00
Vaclav Tvrdik
d45a250e62 added j / k for vim-style line movement
j and k move the caret one line, delegated to sublime's own `move` command
so they behave exactly like the arrow keys. They dispatch through the
existing ACTIONS registry, so the keymap, the status-bar hints and the ?
popup all stay in sync through the single KEY_GROUPS table.

Unlike the other letter keys, j / k keep no command_mode context: the view
is read-only, so leaving them live while Vintage is in command mode can
only ever move the caret. That asymmetry was previously documented only in
a comment, which was wrong (it claimed j / k were the only unguarded
bindings, but escape has always carried its own context instead). It is now
pinned by test_command_mode_guard_is_deliberate, so normalising the keymap
fails the suite with the reason attached.

Also tidies two long lines in commands.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 15:03:32 +02:00
22 changed files with 2076 additions and 392 deletions

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
# Keep the release archive to what Sublime actually loads. Package Control
# installs whatever the tag's zip contains, and GitHub builds that zip with
# git archive, which honours export-ignore.
.claude/ export-ignore
tests/ export-ignore
.gitattributes export-ignore
.gitignore export-ignore
diff.sublime-project export-ignore
TODO.txt export-ignore
channel.json export-ignore
repository.json export-ignore

View File

@@ -1 +1 @@
3.8
3.14

View File

@@ -1,30 +1,38 @@
[
{ "keys": ["f"], "command": "vdiff_action", "args": {"action": "files"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["j"], "command": "vdiff_action", "args": {"action": "line_down"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "line_up"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["m"], "command": "vdiff_action", "args": {"action": "amend"},
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
"context": [{"key": "setting.vdiff_view"}] },
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
"context": [
{"key": "setting.vdiff_view"},

View File

@@ -4,26 +4,75 @@ Review what a coding agent changed without leaving Sublime Text: the whole file,
insertions in green, in the file's own syntax highlighting. Stage, unstage or discard the file, or jump
to the real file at the caret's line.
Install: symlink this directory into `Packages` as `vDiff` (Python 3.8 plugin host, no dependencies).
Needs Sublime Text build 4213 or later, the build that replaced the Python 3.8 plugin host with
Python 3.14. No dependencies.
Command Palette:
## Install with Package Control
vDiff is distributed through the shared channel at
[vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not the default one. Open
**Preferences → Package Settings → Package Control → Settings** and put the channel URL in the
`channels` array of the right-hand (User) pane:
```json
{
"channels": [
"https://packagecontrol.io/channel_v3.json",
"https://raw.githubusercontent.com/vaclavt/sublime-chanel/main/channel.json"
]
}
```
Keep `https://packagecontrol.io/channel_v3.json` in the list — dropping it hides every default
package. Then run **Package Control: Install Package** from the Command Palette and pick **vDiff**.
Upgrades arrive through **Package Control: Upgrade Package** like any other package.
## Install from source
Clone or symlink the repository into the Sublime Text `Packages` directory as `vDiff`:
```bash
git clone https://github.com/vaclavt/vdiff.git \
"$HOME/Library/Application Support/Sublime Text/Packages/vDiff"
```
Use one method or the other, not both: an unpacked `Packages/vDiff` directory overrides the
`vDiff.sublime-package` that Package Control installs.
## Command Palette
- **vDiff: Changed Files** – uncommitted changes (staged, unstaged, untracked). Arrowing through
the list previews each file; Enter keeps it, Esc goes back to what was shown.
- **vDiff: Recent Commits** – last N commits, then the files of the chosen commit. A key binding to
`vdiff_commits` with `{"sha": "HEAD"}` skips the list and reviews the last commit directly.
Keys inside the review view:
## Keys inside the review view
| key | action |
|---|---|
| `f` / `c` | changed files / recent commits panel |
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode) |
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file |
| `C` | commit the staged files, typing the message in an input panel |
| `m` | amend the last commit: edit its subject, keep its body, fold in anything staged |
| `o` or Enter | open the real file at this line |
| `]` / `[` | next / previous change |
| `n` / `p` | next / previous file of the current list |
| `]` / `[` | next / previous change (wraps at the ends) |
| `n` / `p` | next / previous file of the current list (wraps at the ends) |
| `j` / `k` | move down / up one line (vim keys) |
| `r` | refresh |
| `?` | show this key list in a popup |
| Esc | close the view |
The header links do the same with the mouse. Settings: `vDiff.sublime-settings`.
## The channel
`repository.json` in this repository is a Package Control **repository** file: its top-level key
is `packages`, and it describes vDiff itself — where the code lives and which releases exist.
`"tags": true` means Package Control reads the repository's git tags, so publishing a release is
just `git tag v1.0.1 && git push --tags`. A package whose tags are all unpushed resolves to zero
releases, and Package Control drops it from the list without an error — if vDiff is missing from
**Install Package**, check the tags reached GitHub.
The channel itself — the `channels`-array file listing `repository.json` for vDiff and vaclavt's
other packages — lives in [vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not
here. Add a package there instead of in this repository.

18
TODO.txt Normal file
View File

@@ -0,0 +1,18 @@
## New functionality
- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?)
- Add pull and push action
- Add tags into commits list (and branches?)
- Add stash action ..
## Improvements
- commit messages are oneline. What are options for multiline editing?
- visual bell blinking time setting
## Code

View File

@@ -1,6 +1,6 @@
""" vDiff plugin entry point: review uncommitted changes and recent commits inside Sublime Text
Sublime reloads a plugin only when this file changes; after editing a submodule, save this file to reload everything.
Sublime reloads a plugin only when this file changes, so save it after editing a submodule.
"""
import logging
import sys
@@ -8,7 +8,7 @@ import sys
import sublime
# sublime reloads only this file: drop the submodules so an edited one is re-imported below.
# Open review views lose their in-memory state and are rebuilt from their settings by _restore_all.
# Open views lose in-memory state and are rebuilt from their settings by _restore_all.
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
del sys.modules[_name]

21
repository.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "sublime://packagecontrol.io/schemas/repository",
"schema_version": "4.0.0",
"packages": [
{
"name": "vDiff",
"details": "https://github.com/vaclavt/vdiff",
"description": "Review uncommitted changes and commits in Sublime Text: whole files with deletions in red and insertions in green, in the file's own syntax highlighting",
"author": "vaclavt",
"labels": ["git", "diff", "review", "vcs"],
"releases": [
{
"sublime_text": ">=4213",
"platforms": ["*"],
"python_versions": ["3.14"],
"tags": true
}
]
}
]
}

View File

@@ -1,7 +1,25 @@
import os
import sys
import pytest
# tests import the package directly; sublime/sublime_plugin come from tests/stubs
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, os.path.join(HERE, "stubs"))
@pytest.fixture(autouse=True)
def scheme_override_sandbox(tmp_path, monkeypatch):
"""Keep the colour-scheme override palette.py writes inside the test's own tmp_path
Both caches are per session and every stub view shares one id, so they would otherwise leak and
make override assertions depend on test order.
"""
import sublime
from vdiff import palette, state
palette.reset()
monkeypatch.setattr(state, "STATES", {})
monkeypatch.setattr(sublime, "packages_path", lambda: str(tmp_path))

View File

@@ -1,4 +1,5 @@
""" Minimal sublime API stub so the plugin modules import and header/regions logic runs offline """
import re
OP_EQUAL = 0
OP_NOT_EQUAL = 1
@@ -32,7 +33,11 @@ class Region:
return (self.a, self.b) == (other.a, other.b)
def __repr__(self):
return "Region({}, {})".format(self.a, self.b)
return f"Region({self.a}, {self.b})"
class Edit:
"""Opaque edit token; only ever passed through, so the annotations have something to name"""
class Settings(dict):
@@ -84,10 +89,12 @@ class Phantom:
class Window:
"""Captures the quick panel so tests can drive its callbacks"""
"""Captures the quick panel and the input panel so tests can drive their callbacks"""
def __init__(self):
self.panel = None
self.input = None
self.input_view = None
self.commands = []
def focus_view(self, view):
@@ -99,6 +106,35 @@ class Window:
def show_quick_panel(self, items, on_select, flags=0, selected=-1, on_highlight=None, placeholder=None):
self.panel = (items, on_select, selected, on_highlight, placeholder)
def show_input_panel(self, caption, initial_text, on_done, on_change, on_cancel):
self.input = (caption, initial_text, on_done, on_change, on_cancel)
self.input_view = View(initial_text)
# the real panel hands its prefill back selected, which is why callers clear the selection
self.input_view.sel().add(Region(0, len(initial_text)))
return self.input_view
# a bundled scheme's styles: diff.* and region.* defined, the plugin's vdiff.* ones not. Nothing
# simulates sublime merging an override, so a test wanting those to resolve adds them itself.
DEFAULT_FG = "#ffffff"
DEFAULT_STYLES = {
"diff.deleted": {"foreground": DEFAULT_FG, "background": "#4a2b2b"},
"diff.deleted.char": {"foreground": DEFAULT_FG, "background": "#6a3535"},
"diff.inserted": {"foreground": DEFAULT_FG, "background": "#2b4a3a"},
"diff.inserted.char": {"foreground": DEFAULT_FG, "background": "#356a4a"},
"region.yellowish": {"foreground": "#e6db74", "background": "#4a4a2b"},
"markup.deleted": {"foreground": "#d64646"},
"markup.inserted": {"foreground": "#46d646"},
# a few of palette.PALETTE_PROBES, so the palette probe finds a working set of colours
"source": {"foreground": DEFAULT_FG},
"comment": {"foreground": "#7c8595"},
"string": {"foreground": "#99c794"},
"keyword": {"foreground": "#c594c5"},
"constant.numeric": {"foreground": "#f99157"},
"entity.name.function": {"foreground": "#6699cc"},
"punctuation.definition": {"foreground": "#5fb3b3"},
}
class View:
"""Text buffer with just the calls render.py makes"""
@@ -116,6 +152,10 @@ class View:
self.commands = []
self.popup = None
self.popup_navigate = None
# what style_for_scope answers; a test reshapes it to model another colour scheme
self.styles = {scope: dict(style) for scope, style in DEFAULT_STYLES.items()}
# whether sublime has merged the override vdiff writes; it only does so on a restart
self.override_loaded = False
def id(self):
return 1
@@ -202,8 +242,25 @@ class View:
def em_width(self):
return 8.0
def extract_tokens_with_scopes(self, region):
"""One token per run of word characters and one per run of anything else, scoped by its shape
Enough for the colouring tests: real sublime returns the syntax's tokens, but the painter only
needs adjacent tokens to differ and each to map to a coloured scope.
"""
tokens = []
text = self.text[region.a:region.b]
for match in re.finditer(r"\w+|\s+|[^\w\s]+", text):
scope = "keyword" if match.group().isidentifier() else "punctuation.definition"
tokens.append((Region(region.a + match.start(), region.a + match.end()), scope))
return tokens
def style_for_scope(self, scope):
return {"foreground": "#ffffff", "background": "#333333"}
# a fresh dict per call, and no background for an unknown scope, like the real API
if self.override_loaded and scope.startswith("vdiff.") and scope not in self.styles:
# sublime has merged the override, so every rule vdiff wrote in it resolves
return {"foreground": DEFAULT_FG, "background": "#333333"}
return dict(self.styles.get(scope, {"foreground": DEFAULT_FG}))
def is_valid(self):
return True
@@ -231,7 +288,13 @@ def find_syntax_for_file(path, first_line=""):
def ui_info():
return {"system": {"style": "dark"}, "color_scheme": {"value": "Mariana.sublime-color-scheme", "resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme"}}
return {
"system": {"style": "dark"},
"color_scheme": {
"value": "Mariana.sublime-color-scheme",
"resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme",
},
}
def decode_value(text):

View File

@@ -1,16 +1,38 @@
import pytest
import sublime # the stub, via conftest
from vdiff import commands, git, state
from vdiff import commands, git, state, utils
def change(path, status="M"):
return git.FileChange(path, status, False, 1, 1, None)
def flagged(*flags):
"""f0.py, f1.py ... with the given staged flags"""
return [git.FileChange(f"f{i}.py", "M", flag, 1, 1, None) for i, flag in enumerate(flags)]
def option(monkeypatch, name, value):
"""Set a plugin setting for one test; the stub hands out one Settings dict per file"""
monkeypatch.setitem(sublime.load_settings(utils.SETTINGS_FILE), name, value)
def flush_previews():
"""The stub runs set_timeout synchronously, so previews are already shown"""
def at_change(blocks, row):
"""A rendered view with `blocks` as its changes and the caret on `row`"""
view = sublime.View()
st = state.ViewState("/repo")
st.blocks = blocks
view.text = "\n" + "\n".join(f"l{i}" for i in range(20))
view.sel().add(sublime.Region(commands.render.index_point(view, row)))
return view, st
def test_esc_restores_previous_file_and_mode(monkeypatch):
shown = []
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append((st.sha, c.path)))
@@ -81,18 +103,15 @@ def test_on_cancel_goes_back_to_commit_list(monkeypatch):
assert back == [1] and st.sha is None
def test_step_change_uses_blocks():
view = sublime.View()
st = state.ViewState("/repo")
st.blocks = [(2, 4), (7, 8)]
view.text = "\n" + "\n".join("l{}".format(i) for i in range(10))
view.sel().add(sublime.Region(commands.render.index_point(view, 0)))
def test_step_change_uses_blocks(rings):
view, st = at_change([(2, 4), (7, 8)], 0)
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 2
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 7
commands.ACTIONS["prev_change"](view, None, st)
assert commands.render.caret_index(view) == 2
# stepping inside the list stays silent
assert commands.render.caret_index(view) == 2 and rings == []
def test_file_items_annotations():
@@ -135,3 +154,432 @@ def test_run_async_reports_generic_error(monkeypatch):
commands.run_async(boom, lambda r: messages.append("done"))
assert messages == ["vDiff: boom"]
def test_j_and_k_move_the_caret_one_line():
view = sublime.View()
commands.ACTIONS["line_down"](view, None, None)
commands.ACTIONS["line_up"](view, None, None)
assert view.commands == [
("move", {"by": "lines", "forward": True}),
("move", {"by": "lines", "forward": False}),
]
def staged_state(*flags):
st = state.ViewState("/repo")
st.files = flagged(*flags)
return st
def test_commit_needs_a_staged_file(monkeypatch):
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
window = sublime.Window()
commands.ACTIONS["commit"](sublime.View(), window, staged_state(False, False))
assert messages == ["vDiff: no staged files"] and window.input is None
# a commit under review has nothing to stage in the first place
st = staged_state(True)
st.enter("abc", None)
commands.ACTIONS["commit"](sublime.View(), window, st)
assert messages[-1] == "vDiff: commit works on uncommitted changes only" and window.input is None
def test_commit_prompts_then_commits(monkeypatch):
committed = []
refreshed = []
monkeypatch.setattr(commands.git, "commit", lambda repo, message: committed.append((repo, message)))
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
window = sublime.Window()
commands.ACTIONS["commit"](sublime.View(), window, staged_state(True, False, True))
caption, initial, on_done, _, _ = window.input
assert caption == "Commit message" and initial == "" and committed == []
on_done(" ") # an empty message is not a commit
assert committed == []
on_done("real message")
assert committed == [("/repo", "real message")]
assert refreshed == ["committed 2 files"]
def prompt_amend(monkeypatch, st, head="last subject"):
"""Press m; git reports `head` as HEAD's message. Returns (window, amended, refreshed)"""
amended, refreshed = [], []
monkeypatch.setattr(commands.git, "head_message", lambda repo: head)
monkeypatch.setattr(
commands.git, "amend", lambda repo, message, allow_empty=False: amended.append((repo, message, allow_empty))
)
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
window = sublime.Window()
commands.ACTIONS["amend"](sublime.View(), window, st)
return window, amended, refreshed
def test_amend_refuses_commit_mode(monkeypatch):
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
st = staged_state(True)
st.enter("abc", None)
window, amended, _ = prompt_amend(monkeypatch, st)
assert messages[-1] == "vDiff: amend works on uncommitted changes only"
assert window.input is None and amended == []
def test_amend_prefills_the_subject_with_the_caret_at_its_end(monkeypatch):
window, amended, _ = prompt_amend(monkeypatch, staged_state(True), head="fix the parser")
caption, initial, _, _, on_cancel = window.input
assert caption == "Amend message" and initial == "fix the parser" and amended == []
# the panel selects its prefill; the caret must sit after it, ready to type
assert list(window.input_view.sel()) == [sublime.Region(len("fix the parser"))]
# Esc is a no-op: there is no cancel callback to run
assert on_cancel is None
def test_amend_offers_the_subject_and_keeps_the_rest_verbatim(monkeypatch):
head = "typo hree\n\nCloses #12.\n\n- a\n- b"
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(True, False, True), head=head)
assert window.input[1] == "typo hree" # only the first line is offered for editing
window.input[2]("typo here")
# everything below the first line comes back untouched, and staged files keep git's guard
assert amended == [("/repo", "typo here" + head[len("typo hree") :], False)]
assert refreshed == ["amended the last commit with 2 files"]
def test_amend_rewords_with_nothing_staged(monkeypatch):
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(False, False))
window.input[2]("just a reword")
# a reword alone opts out of git's refusal, so an empty commit can still be renamed
assert amended == [("/repo", "just a reword", True)]
assert refreshed == ["amended the last commit"]
def test_amend_with_an_empty_message_does_nothing(monkeypatch):
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(True))
window.input[2](" ")
assert amended == [] and refreshed == []
assert messages[-1] == "vDiff: amend cancelled: empty message"
def test_commit_message_counts_one_file(monkeypatch):
refreshed = []
monkeypatch.setattr(commands.git, "commit", lambda repo, message: None)
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
window = sublime.Window()
commands.ACTIONS["commit"](sublime.View(), window, staged_state(True, False))
window.input[2]("one")
assert refreshed == ["committed 1 file"]
def commit(subject, files=1, insertions=1, deletions=0, short="abc1234"):
return git.Commit("a" * 40, short, "Ann", "2 days ago", subject, files, insertions, deletions)
def test_commit_items_show_totals_before_the_hash():
plural, single, merge = commands.commit_items(
[
commit("fix the parser", 23, 210, 0, "fda1368"),
commit("tweak", 1, 1, 2, "d45a250"),
commit("merge branch 'side'", None, None, None, "beef123"),
]
)
assert plural.annotation == "23 files changed -0 +210 fda1368"
assert single.annotation == "1 file changed -2 +1 d45a250"
# no diffstat for a merge, so the row falls back to the hash alone
assert merge.annotation == "beef123"
assert plural.trigger == "fix the parser" and plural.details == "Ann · 2 days ago"
def test_long_subject_is_clipped_to_clear_the_annotation():
long_subject = "a subject that runs on and on " * 6
item = commands.commit_items([commit(long_subject, 3, 9, 4)])[0]
assert item.trigger.endswith("..") and item.trigger[:-2] == long_subject[: len(item.trigger) - 2]
# the clipped subject plus the annotation fit the row, leaving the totals and hash readable
assert len(item.trigger) + len(item.annotation) <= commands.COMMIT_ROW_WIDTH
# a subject that already fits is left alone
assert commands.commit_items([commit("short one")])[0].trigger == "short one"
def test_commit_items_keep_a_readable_subject_when_the_annotation_is_huge():
# an annotation long enough to eat the whole row: the subject still keeps its floor
item = commands.commit_items([commit("x" * 200, 999999, 999999, 999999, "a" * 90)])[0]
assert len(item.trigger) == commands.MIN_SUBJECT_WIDTH and item.trigger.endswith("..")
def test_commit_items_fall_back_for_an_empty_subject():
assert commands.commit_items([commit("")])[0].trigger == "(no subject)"
# --- navigating while a diff is still loading -------------------------------
@pytest.fixture
def loading(monkeypatch):
"""Queue run_async instead of running it inline, so a test can act mid-load like a user can
The stub runs set_timeout synchronously, so the real async window — the one n / p read stale
state through — is invisible to every other test.
"""
queue = []
monkeypatch.setattr(commands, "run_async", lambda work, done: queue.append((work, done)))
monkeypatch.setattr(commands.render, "render", lambda view, st: None)
monkeypatch.setattr(commands.git, "full_diff", lambda *a, **k: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n")
def flush():
while queue:
work, done = queue.pop(0)
done(work())
return queue, flush
def commit_panel(files):
"""A review view showing a commit, its file panel open, nothing loaded yet"""
view, window = sublime.View(), sublime.Window()
st = state.ViewState("/repo")
st.enter("abc", git.CommitInfo("abc", "abc1234", "A", "d", "s", ""))
commands.show_files_panel(window, view, st, files, st.kept(), placeholder="c")
return view, window, st
def numbered(count=5):
return flagged(*(False,) * count)
def requested_path(queue):
"""Path of the file the newest queued load will fetch"""
return queue[-1][0].__closure__[0].cell_contents.path
def test_next_file_steps_from_the_file_being_loaded(loading):
queue, _ = loading
files = numbered()
view, window, st = commit_panel(files)
window.panel[1](2) # Enter on the third file; its diff has not arrived yet
assert st.change is None
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f3.py"
def test_prev_file_works_before_the_diff_arrives(loading, monkeypatch):
queue, _ = loading
messages = []
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
files = numbered()
view, window, st = commit_panel(files)
window.panel[1](2)
commands.ACTIONS["prev_file"](view, window, st)
assert requested_path(queue) == "f1.py" and messages == []
def test_rapid_next_file_presses_each_advance(loading):
queue, flush = loading
files = numbered()
view, window, st = commit_panel(files)
window.panel[1](1)
for _ in range(3):
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f4.py"
flush()
assert st.change.path == "f4.py"
def test_esc_rewinds_the_file_navigation_counts_from(loading):
queue, flush = loading
files = numbered()
view, window, st = commit_panel(files)
window.panel[1](3)
flush()
kept = st.kept()
window.panel[1](0) # preview another file, then back out
st.restore(kept)
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f4.py"
# --- cycling past the ends --------------------------------------------------
@pytest.fixture
def rings(monkeypatch):
"""Record visual bells instead of drawing them"""
bells = []
monkeypatch.setattr(commands.render, "flash", lambda view: bells.append(view))
return bells
def at_file(files, path):
"""A loaded working-tree view sitting on `path`"""
view, window = sublime.View(), sublime.Window()
st = state.ViewState("/repo")
st.files = files
st.change = st.requested = next(c for c in files if c.path == path)
return view, window, st
def test_next_file_wraps_past_the_last(loading, rings):
queue, _ = loading
files = numbered()
view, window, st = at_file(files, "f4.py")
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f0.py" and len(rings) == 1
def test_prev_file_wraps_before_the_first(loading, rings):
queue, _ = loading
files = numbered()
view, window, st = at_file(files, "f0.py")
commands.ACTIONS["prev_file"](view, window, st)
assert requested_path(queue) == "f4.py" and len(rings) == 1
def test_stepping_inside_the_list_does_not_ring(loading, rings):
queue, _ = loading
files = numbered()
view, window, st = at_file(files, "f2.py")
commands.ACTIONS["next_file"](view, window, st)
commands.ACTIONS["prev_file"](view, window, st)
assert requested_path(queue) == "f2.py" and rings == []
def test_single_file_list_rings_without_refetching(loading, rings):
queue, _ = loading
files = numbered(1)
view, window, st = at_file(files, "f0.py")
commands.ACTIONS["next_file"](view, window, st)
commands.ACTIONS["prev_file"](view, window, st)
# it rings both times, but never re-runs git for the file already on screen
assert len(rings) == 2 and queue == []
def test_first_step_without_a_shown_file_picks_an_end(loading, rings):
queue, _ = loading
files = numbered()
view, window, st = commit_panel(files) # opening a commit leaves nothing shown
assert st.index() == -1
commands.ACTIONS["next_file"](view, window, st)
assert requested_path(queue) == "f0.py"
st.requested = None
commands.ACTIONS["prev_file"](view, window, st)
assert requested_path(queue) == "f4.py"
# neither is a wrap: there was no position to run past
assert rings == []
def test_next_change_wraps_past_the_last(rings):
view, st = at_change([(2, 4), (7, 8)], 7)
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 2 and len(rings) == 1
def test_prev_change_wraps_before_the_first(rings):
view, st = at_change([(2, 4), (7, 8)], 2)
commands.ACTIONS["prev_change"](view, None, st)
assert commands.render.caret_index(view) == 7 and len(rings) == 1
def test_single_change_rings_and_stays_put(rings):
view, st = at_change([(2, 4)], 2)
commands.ACTIONS["next_change"](view, None, st)
commands.ACTIONS["prev_change"](view, None, st)
assert commands.render.caret_index(view) == 2 and len(rings) == 2
def test_a_file_without_changes_neither_moves_nor_rings(rings):
view, st = at_change([], 3)
commands.ACTIONS["next_change"](view, None, st)
assert commands.render.caret_index(view) == 3 and rings == []
# --- staging moves on to the next unstaged file -----------------------------
def press(monkeypatch, action, before, path, after):
"""Run `action` on `path` of `before`; git then reports `after`. Returns the paths shown
ACTIONS captured git.stage and friends at build time, so the stub goes one level down at git.run,
keeping the action's real wiring under test.
"""
shown = []
monkeypatch.setattr(commands.git, "run", lambda repo, args, ok_codes=(0,): "")
monkeypatch.setattr(commands.git, "changed_files", lambda repo: after)
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(c.path))
view, window, st = at_file(before, path)
commands.ACTIONS[action](view, window, st)
return shown
def test_next_unstaged_scans_forward_and_wraps():
files = flagged(False, True, False, True)
assert commands._next_unstaged(files, 1) == 2
assert commands._next_unstaged(files, 2) == 2 # the slot we count from counts itself
assert commands._next_unstaged(files, 3) == 0 # wraps past the end
assert commands._next_unstaged(flagged(True, True), 0) == -1
def test_staging_moves_to_the_next_unstaged_file(monkeypatch):
shown = press(monkeypatch, "stage", flagged(True, False, True, False), "f1.py", flagged(True, True, True, False))
assert shown == ["f3.py"]
def test_staging_wraps_to_an_unstaged_file_above(monkeypatch):
shown = press(monkeypatch, "stage", flagged(False, True, False), "f2.py", flagged(False, True, True))
assert shown == ["f0.py"]
def test_staging_the_last_unstaged_file_stays_put(monkeypatch):
shown = press(monkeypatch, "stage", flagged(True, False), "f1.py", flagged(True, True))
assert shown == ["f1.py"]
def test_staging_a_file_that_leaves_the_list_counts_from_its_slot(monkeypatch):
# staging can make the entry vanish (the change was identical to HEAD); f2.py takes the slot
before = flagged(True, False, False)
after = [before[0], before[2]]
shown = press(monkeypatch, "stage", before, "f1.py", after)
assert shown == ["f2.py"]
def test_staging_does_not_move_when_the_option_is_off(monkeypatch):
option(monkeypatch, "stage_and_move_next_file", False)
shown = press(monkeypatch, "stage", flagged(True, False, False), "f1.py", flagged(True, True, False))
assert shown == ["f1.py"]
def test_unstage_and_discard_do_not_move(monkeypatch):
option(monkeypatch, "confirm_discard", False)
files = flagged(True, True, False)
assert press(monkeypatch, "unstage", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
messages = []
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
assert messages == ["No uncommitted changes in /repo"]

View File

@@ -109,6 +109,37 @@ def test_commits(repo):
assert info.sha == commits[0].sha
def test_recent_commits_carry_shortstat_totals(repo):
write(repo, "new.py", "one\ntwo\n")
sh(repo, "add", "-A")
sh(repo, "commit", "-q", "-m", "added a file")
write(repo, "a.py", "x\n") # 6 lines become 1
write(repo, "b.txt", "y\n") # 1 line becomes 1
sh(repo, "commit", "-q", "-am", "changed two files")
newest, added, initial = git.recent_commits(repo, 3)
assert (newest.files, newest.insertions, newest.deletions) == (2, 2, 7)
# a pure addition: git omits the deletions clause entirely, which reads as zero, not unknown
assert (added.files, added.insertions, added.deletions) == (1, 2, 0)
assert initial.subject == "initial" and initial.files == 3
def test_commits_without_a_diffstat_report_no_totals(repo):
sh(repo, "commit", "-q", "--allow-empty", "-m", "empty")
sh(repo, "checkout", "-q", "-b", "side")
write(repo, "s.py", "s\n")
sh(repo, "add", "-A")
sh(repo, "commit", "-q", "-m", "on the side")
sh(repo, "checkout", "-q", "main")
sh(repo, "merge", "-q", "--no-ff", "side", "-m", "merge side")
totals = {c.subject: (c.files, c.insertions, c.deletions) for c in git.recent_commits(repo, 5)}
# git log prints no diffstat for either, so the totals stay unknown rather than a false zero
assert totals["merge side"] == (None, None, None)
assert totals["empty"] == (None, None, None)
assert totals["on the side"] == (1, 1, 0)
def test_no_head_repo(tmp_path):
repo = str(tmp_path)
sh(repo, "init", "-q")
@@ -146,3 +177,95 @@ def test_count_lines(tmp_path):
assert git._count_lines(p) is None
open(p, "wb").write(b"")
assert git._count_lines(p) == 0
def test_commit_only_records_staged_files(repo):
write(repo, "a.py", "staged\n")
write(repo, "b.txt", "left alone\n")
changes = {c.path: c for c in git.changed_files(repo)}
git.stage(repo, changes["a.py"])
git.commit(repo, "a message with 'quotes' and \"doubles\"")
left = {c.path: c for c in git.changed_files(repo)}
assert "a.py" not in left and not left["b.txt"].staged
info, files = git.commit_with_files(repo, "HEAD")
# the message reaches git verbatim: it is one argv element, never a shell string
assert info.subject == "a message with 'quotes' and \"doubles\""
assert [f.path for f in files] == ["a.py"]
def test_commit_without_staged_files_raises(repo):
write(repo, "a.py", "unstaged only\n")
with pytest.raises(git.GitError) as info:
git.commit(repo, "nothing to record")
assert info.value.code != 0
def message(repo):
"""HEAD's raw message, as git stores it"""
return git.run(repo, ["log", "-1", "--format=%B"]).strip("\n")
def test_head_message_returns_the_whole_message(repo):
assert git.head_message(repo) == "initial"
sh(repo, "commit", "-q", "--allow-empty", "-m", "second", "-m", "body text\nsecond line")
assert git.head_message(repo) == "second\n\nbody text\nsecond line"
def test_amend_keeps_everything_below_the_first_line_byte_for_byte(repo):
body = "Closes #12.\n\n- a\n- b"
sh(repo, "commit", "-q", "--allow-empty", "-m", "typo hree", "-m", body)
before = git.run(repo, ["rev-list", "--count", "HEAD"])
_, newline, rest = git.head_message(repo).partition("\n")
git.amend(repo, "typo here" + newline + rest, allow_empty=True)
# the paragraph the user never touched comes back exactly as git stored it
assert message(repo) == "typo here\n\n" + body
# an amend rewrites HEAD, it does not add a commit
assert git.run(repo, ["rev-list", "--count", "HEAD"]) == before
def test_amend_without_a_body_leaves_a_one_line_message(repo):
git.amend(repo, "reworded")
assert message(repo) == "reworded"
def test_amend_rewords_an_empty_commit(repo):
# git refuses an amend that leaves nothing behind unless --allow-empty says otherwise
sh(repo, "commit", "-q", "--allow-empty", "-m", "marker")
git.amend(repo, "marker, reworded", allow_empty=True)
assert message(repo) == "marker, reworded"
def test_amend_that_would_empty_the_commit_is_refused(repo):
# folding staged files in keeps git's guard: only a reword opts out of it
write(repo, "new.py", "temporary\n")
sh(repo, "add", "new.py")
sh(repo, "commit", "-q", "-m", "add new.py")
sh(repo, "rm", "-q", "new.py")
with pytest.raises(git.GitError):
git.amend(repo, "add new.py")
def test_amend_folds_staged_files_into_head(repo):
write(repo, "a.py", "amended\n")
changes = {c.path: c for c in git.changed_files(repo)}
git.stage(repo, changes["a.py"])
before = git.run(repo, ["rev-list", "--count", "HEAD"])
git.amend(repo, "initial, with a.py")
assert git.run(repo, ["rev-list", "--count", "HEAD"]) == before
assert not git.changed_files(repo)
assert message(repo) == "initial, with a.py"
def test_head_message_without_a_commit_raises(tmp_path):
fresh = str(tmp_path)
sh(fresh, "init", "-q", "-b", "main")
with pytest.raises(git.GitError):
git.head_message(fresh)

86
tests/test_palette.py Normal file
View File

@@ -0,0 +1,86 @@
import json
import sublime # the stub, via conftest
from vdiff import palette
def scheme_file(tmp_path):
"""The override of whichever scheme the stub reports as active"""
return tmp_path / "User" / palette.override_name(sublime.View())
def override(tmp_path):
"""The rules palette.py wrote into that override"""
path = scheme_file(tmp_path)
return json.loads(path.read_text())["rules"] if path.exists() else None
def by_scope(tmp_path):
return {rule["scope"]: rule for rule in override(tmp_path)}
def test_a_scheme_with_diff_colours_needs_no_tint_rules(tmp_path):
view = sublime.View()
palette.ensure(view)
# the tints are the scheme's own scopes, so only the generated colour rules are ours to write
assert not [r for r in override(tmp_path) if r["scope"].startswith("diff.")]
def test_a_scheme_without_diff_colours_gets_our_tints(tmp_path):
view = sublime.View()
# all or nothing per pair: taking only the half a scheme has would put our hue next to its own
del view.styles["diff.deleted.char"]
del view.styles["diff.inserted"]
del view.styles["diff.inserted.char"]
palette.ensure(view)
rules = by_scope(tmp_path)
assert rules[palette.SCOPE_INS]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_INS]
assert rules[palette.SCOPE_DEL_CHAR]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_DEL_CHAR]
# the one colour it does define is left to the scheme
assert palette.SCOPE_DEL not in rules
def test_every_palette_colour_gets_a_rule_per_background_kind(tmp_path):
view = sublime.View()
palette.ensure(view)
rules = by_scope(tmp_path)
colours = {view.styles[scope]["foreground"] for scope in ("comment", "string", "keyword")}
for colour in colours:
scope = palette.scope_for("ins", colour)
assert scope is not None
# both properties, which is the whole point: the tint alone would flatten the text
assert rules[scope] == {"scope": scope, "background": view.styles["diff.inserted"]["background"],
"foreground": colour}
for kind in palette.KINDS:
assert palette.scope_for(kind, colour) in rules
def test_a_colour_the_scheme_does_not_use_has_no_scope():
palette.ensure(sublime.View())
assert palette.scope_for("ins", "#123456") is None
def test_the_override_is_written_once_per_scheme(tmp_path):
view = sublime.View()
palette.ensure(view)
scheme_file(tmp_path).write_text(json.dumps({"rules": []}))
# sublime reloads the whole scheme every time we touch the file, so nothing must rewrite it
palette.ensure(view)
assert override(tmp_path) == []
def test_a_hand_edited_override_is_never_clobbered(tmp_path):
(tmp_path / "User").mkdir()
path = tmp_path / "User" / "Mariana.sublime-color-scheme"
# comments are legal in a .sublime-color-scheme but decode_value here is plain json
path.write_text('{\n // mine\n "rules": []\n}')
palette.ensure(sublime.View())
assert "// mine" in path.read_text()

View File

@@ -4,7 +4,7 @@ import re
import sublime # the stub, via conftest
from vdiff import commands, git, listeners, parse, render, state # noqa: F401
from vdiff import commands, git, listeners, palette, parse, render, state # noqa: F401
DIFF = """--- a/x.py
+++ b/x.py
@@ -34,8 +34,17 @@ def header(view):
return view.phantoms[render.PHANTOM_KEY][0].content
def with_loaded_override(view):
"""Model a view whose scheme has picked up the override palette.py writes
The stub no more simulates that merge than sublime does before a restart, so a test says so itself.
"""
view.override_loaded = True
return view
def test_render_text_regions_and_header():
view = sublime.View()
view = with_loaded_override(sublime.View())
render.init_view(view)
st = make_state()
render.render(view, st)
@@ -132,10 +141,10 @@ def test_help_popup_lists_every_key():
view, html = help_popup(st)
assert view.is_popup_visible()
for group in ("Navigate", "Panels", "This file", "View"):
assert ">{}<".format(group) in html
for keys in ("] [", "n p", "o ⏎", "f", "c", "s", "u", "d", "r", "?", "esc"):
assert ">{}<".format(keys) in html
for group in ("Navigate", "Panels", "Actions", "View"):
assert f">{group}<" in html
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "?", "esc"):
assert f">{keys}<" in html
assert "next / previous change" in html and "open the real file at this line" in html
# working-tree mode: nothing dimmed, every single-action row is a link
assert 'class="dim"' not in html and "uncommitted only" not in html
@@ -152,8 +161,9 @@ def test_help_popup_dims_file_actions_in_commit_mode():
_, html = help_popup(st)
assert "uncommitted only" in html
assert html.count('class="dim"') == 3
assert html.count('class="dim"') == 5
assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html
assert 'href="commit"' not in html and 'href="amend"' not in html
# everything that still applies stays clickable
assert 'href="files"' in html and 'href="open"' in html and 'href="refresh"' in html
@@ -176,11 +186,15 @@ def test_help_popup_toggles_and_navigates():
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc"}
def test_keymap_matches_help_table():
"""The keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
def keymap_bindings():
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Default.sublime-keymap")
with open(path, encoding="utf-8") as f:
bindings = json.load(f)
return json.load(f)
def test_keymap_matches_help_table():
"""The keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
bindings = keymap_bindings()
assert all(b["command"] == "vdiff_action" for b in bindings)
bound = {b["args"]["action"] for b in bindings}
@@ -192,9 +206,22 @@ def test_keymap_matches_help_table():
assert bound_keys == {token for k in render.KEYS for token in k.keys.split()}
def test_no_binding_yields_to_vintage_command_mode():
"""The review view is read-only, so its letters are vdiff commands in either vim mode
init_view opens in command mode; a binding still carrying the old `command_mode: false` context
would go dead there, which is what broke n / p.
"""
guard = {"key": "setting.command_mode", "operand": False}
for binding in keymap_bindings():
assert guard not in binding["context"], binding["keys"]
view = sublime.View()
render.init_view(view)
assert view.settings().get("command_mode") is True
def test_minihtml_stylesheets_reject_unsupported_selectors():
"""minihtml drops the rest of a stylesheet at a selector it cannot parse; these are the forms
that silently broke HELP_CSS, so no stylesheet constant may use them"""
"""minihtml drops the rest of a stylesheet at an unparsable selector; these forms broke HELP_CSS"""
# descendant/child/sibling combinators, pseudo-classes, attribute selectors, compound classes
unsupported = re.compile(r"[\s:>~+\[]|[.#][^.#]*[.#]")
sheets = [(name, css) for name, css in vars(render).items() if name.endswith("_CSS") and isinstance(css, str)]
@@ -203,4 +230,193 @@ def test_minihtml_stylesheets_reject_unsupported_selectors():
for rule in css.split("}"):
head = rule.split("{")[0].strip()
for selector in filter(None, (s.strip() for s in head.split(","))):
assert not unsupported.search(selector), "{}: {}".format(name, selector)
assert not unsupported.search(selector), f"{name}: {selector}"
# --- header summary line ----------------------------------------------------
def test_summary_counts_staged_and_unstaged_files():
view = sublime.View()
render.init_view(view)
st = make_state()
st.files = [
git.FileChange("a.py", "M", True, 1, 1, None),
git.FileChange("b.py", "M", False, 1, 1, None),
git.FileChange("c.py", "?", False, 1, 0, None),
]
render.render(view, st)
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
# the untracked file's whole content counts as insertions, the way git.changed_files records it
assert summary.startswith("1 staged, 2 unstaged · ")
assert '<span class="del">-2</span>' in summary and '<span class="ins">+3</span>' in summary
def test_summary_totals_skip_binary_files_but_still_count_them():
view = sublime.View()
render.init_view(view)
st = make_state()
st.files = [
git.FileChange("a.py", "M", False, 4, 1, None),
git.FileChange("logo.png", "M", False, None, None, None), # binary: no counts, still a file
]
render.render(view, st)
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
assert summary.startswith("0 staged, 2 unstaged · ")
assert '<span class="del">-1</span>' in summary and '<span class="ins">+4</span>' in summary
def test_summary_totals_a_commit_and_owns_the_date():
view = sublime.View()
render.init_view(view)
st = make_state()
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", ""))
st.change = git.FileChange("x.py", "M", False, 3, 2, None)
st.files = [
git.FileChange("x.py", "M", False, 3, 2, None),
git.FileChange("y.py", "M", False, 7, 0, None),
git.FileChange("logo.png", "M", False, None, None, None), # binary: no counts, still a file
]
render.render(view, st)
html = header(view)
# hash, author, date, totals and message all live on the one summary line
summary = html.split('<div class="summary">')[1].split("</div>")[0]
for part in ("abc1234", "Ann", "2026-09-23 10:00", "3 files changed", "-2", "+10", "Fix things"):
assert part in summary, part
# and nowhere else: there is no second commit line any more
assert html.count("abc1234") == 1 and html.count("2026-09-23 10:00") == 1
assert 'class="commit"' not in html
def test_summary_is_omitted_without_a_file_list():
view = sublime.View()
render.init_view(view)
st = make_state()
render.render(view, st)
assert 'class="summary"' not in header(view)
def test_flash_tints_the_buffer_then_clears(monkeypatch):
view = sublime.View("some text")
timers = []
monkeypatch.setattr(render.sublime, "set_timeout", lambda fn, ms=0: timers.append((fn, ms)))
render.flash(view)
regions, scope, _, flags = view.regions[render.BELL_KEY]
assert regions == [sublime.Region(0, len(view.text))] and flags == sublime.DRAW_NO_OUTLINE
assert scope and timers[0][1] == render.BELL_MS
timers[0][0]()
assert render.BELL_KEY not in view.regions
def test_flash_survives_a_re_render(monkeypatch):
"""The wrap that rings also re-renders; _replace must not wipe the bell before it is seen"""
assert render.BELL_KEY not in render.REGION_KEYS
monkeypatch.setattr(render.sublime, "set_timeout", lambda fn, ms=0: None) # hold the bell up
view = sublime.View()
render.init_view(view)
render.flash(view)
render.render(view, make_state())
assert render.BELL_KEY in view.regions
def test_bell_scope_falls_back_when_the_scheme_lacks_it():
view = with_loaded_override(sublime.View())
assert render._bell_scope(view) == render.SCOPE_BELL
del view.styles["region.yellowish"]
# an invisible bell is a silent failure, so fall back to a scope that is resolving
assert render._bell_scope(view) == palette.SCOPE_INS
# ... and when the plugin's own scopes are not resolving either, to the scheme's
view.override_loaded = False
assert render._bell_scope(view) == "diff.inserted"
def token_regions(view, kind, colour):
"""The regions painted for one (background kind, colour) pair of the token layer"""
scope = palette.scope_for(kind, colour)
entry = view.regions.get(scope and scope.replace("vdiff.", "vdiff.tok.", 1))
return entry[0] if entry else []
def test_changed_lines_are_repainted_in_the_files_own_colours():
view = with_loaded_override(sublime.View())
render.init_view(view)
render.render(view, make_state())
keyword = view.styles["keyword"]["foreground"]
punctuation = view.styles["punctuation.definition"]["foreground"]
# " return 10" is inserted: its words carry the keyword colour, its spaces and digits their
# own runs. Without this layer all of them would be the tint's flat foreground.
inserted = token_regions(view, "ins", keyword)
assert sublime.Region(view.text_point(4, 4), view.text_point(4, 10)) in inserted
# and a deleted line is painted from the deleted palette, not the inserted one
assert sublime.Region(view.text_point(2, 4), view.text_point(2, 10)) in token_regions(view, "del", keyword)
assert token_regions(view, "del", punctuation)
# the plain tint still covers the whole line underneath, so gaps and line ends stay coloured
assert view.regions[render.KEY_INS][1] == palette.SCOPE_INS
def test_token_runs_merge_but_stop_at_a_word_diff_range():
view = with_loaded_override(sublime.View())
render.init_view(view)
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1 +1 @@\n-foo = bar\n+foo = baz\n"))
keyword = view.styles["keyword"]["foreground"]
# only the changed character sits in the stronger tint; the rest of the word keeps the line tint
assert token_regions(view, "ins.char", keyword) == [sublime.Region(view.text_point(2, 8), view.text_point(2, 9))]
plain = token_regions(view, "ins", keyword)
assert sublime.Region(view.text_point(2, 6), view.text_point(2, 8)) in plain
assert sublime.Region(view.text_point(2, 0), view.text_point(2, 3)) in plain
def test_token_layer_waits_for_the_scopes_to_resolve():
view = sublime.View()
render.init_view(view)
render.render(view, make_state())
# the override is not loaded yet, so the lines keep the flat tint and no token key is painted:
# an unknown scope would draw its text in sublime's marker green
assert view.regions[render.KEY_DEL][1] == "diff.deleted"
assert view.regions[render.KEY_INS][1] == "diff.inserted"
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
def test_token_layer_is_skipped_for_a_diff_too_big_to_read(monkeypatch):
monkeypatch.setattr(render, "TOKEN_COLOUR_MAX_CHARS", 4)
view = with_loaded_override(sublime.View())
render.init_view(view)
render.render(view, make_state())
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
def test_token_regions_are_dropped_when_a_re_render_no_longer_needs_them():
view = with_loaded_override(sublime.View())
render.init_view(view)
render.render(view, make_state())
assert [key for key in view.regions if key.startswith("vdiff.tok.")]
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n"))
keyword = view.styles["keyword"]["foreground"]
# the new diff paints far fewer runs; the keys the old one used must not linger
assert token_regions(view, "ins", keyword) == [sublime.Region(view.text_point(3, 0), view.text_point(3, 1))]
def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
said = []
monkeypatch.setattr(sublime, "status_message", said.append)
view = sublime.View()
render.init_view(view)
render.render(view, make_state())
assert said == ["vDiff: " + render.NEEDS_RESTART]
# said once per scheme, not once per render
render.render(view, make_state())
assert said == ["vDiff: " + render.NEEDS_RESTART]

View File

@@ -5,6 +5,9 @@
// ask before discarding a file's changes
"confirm_discard": true,
// after staging a file, move on to the next unstaged file of the list
"stage_and_move_next_file": true,
// mark the changed characters inside paired deleted/inserted lines
"word_diff": true,

View File

@@ -1,15 +1,21 @@
""" Commands: the two quick panels (changed files, recent commits) and the actions of a review view """
import bisect
import os
from typing import Callable, Dict, List, Optional, Sequence, Tuple
import sublime
import sublime_plugin
from . import git, parse, render, state
from .utils import run_async, settings, status
from .git import Commit, FileChange
from .state import Kept, ViewState
from .utils import file_count, run_async, settings, status
# what an ACTIONS entry does: act(view, window, state) for the review view it was fired in
Action = Callable[[sublime.View, sublime.Window, ViewState], None]
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES
STATUS_COLOURS = {
STATUS_COLOURS: Dict[str, int] = {
"M": sublime.KIND_ID_COLOR_ORANGISH,
"A": sublime.KIND_ID_COLOR_GREENISH,
"?": sublime.KIND_ID_COLOR_GREENISH,
@@ -21,24 +27,31 @@ STATUS_COLOURS = {
}
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
# the annotation is drawn hard right of the subject, so past this budget the subject is clipped.
# The floor keeps a readable stub on a narrow panel.
COMMIT_ROW_WIDTH = 120
MIN_SUBJECT_WIDTH = 24
# highlight-to-preview delay: holding an arrow key must not spawn a git process per row
PREVIEW_DELAY_MS = 60
# directory -> repository root; roots do not move, and resolving runs on the UI thread
_roots = {}
_roots: Dict[str, str] = {}
def resolve_repo(window):
def resolve_repo(window: sublime.Window) -> Optional[str]:
"""Repository of the review view or active file, else of the first project folder that is one"""
view = window.active_view()
candidates = []
candidates: List[str] = []
if view is not None:
if state.is_review_view(view):
st = state.get(view) or restore_view(view)
if st.repo:
return st.repo
elif view.file_name():
candidates.append(os.path.dirname(view.file_name()))
else:
name = view.file_name()
if name:
candidates.append(os.path.dirname(name))
candidates.extend(window.folders())
for directory in candidates:
root = _roots.get(directory)
@@ -51,14 +64,14 @@ def resolve_repo(window):
return None
def find_review_view(window):
def find_review_view(window: sublime.Window) -> Optional[sublime.View]:
for view in window.views():
if state.is_review_view(view):
return view
return None
def open_review(window):
def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]:
"""(view, state) of the window's single review view for the resolved repo, focused; None without a repo"""
repo = resolve_repo(window)
if not repo:
@@ -76,20 +89,22 @@ def open_review(window):
return view, st
def show_file(view, st, change):
def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
"""Fetch and parse the file's diff on the worker thread, then render it"""
st.token += 1
# set before the worker starts: n / p count from here while the diff is still loading
st.requested = change
token = st.token
repo, sha = st.repo, st.sha
def work():
def work() -> Optional[Tuple[parse.Diff, List[parse.Block]]]:
if token != st.token:
return None # superseded before it started: skip the subprocess
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
diff = parse.parse_unified(text)
return diff, parse.change_blocks(diff.lines)
def done(result):
def done(result: Optional[Tuple[parse.Diff, List[parse.Block]]]) -> None:
if result is None or token != st.token or not view.is_valid():
return
diff, blocks = result
@@ -101,8 +116,8 @@ def show_file(view, st, change):
run_async(work, done)
def file_items(files):
items = []
def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
items: List[sublime.QuickPanelItem] = []
for change in files:
description = git.STATUS_NAMES.get(change.status, "")
colour = STATUS_COLOURS.get(change.status)
@@ -113,18 +128,36 @@ def file_items(files):
if None in (change.insertions, change.deletions):
annotation = "binary"
else:
annotation = "-{} +{}".format(change.deletions, change.insertions)
annotation = f"-{change.deletions} +{change.insertions}"
items.append(sublime.QuickPanelItem(change.path, details, annotation, kind))
return items
def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder="Changed files"):
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
"""Quick-panel rows: subject on the left, its totals and the hash right-aligned"""
items: List[sublime.QuickPanelItem] = []
for c in commits:
annotation = c.short
if c.files is not None:
# merges and empty commits have no diffstat, so they show the hash alone
annotation = f"{file_count(c.files)} changed -{c.deletions} +{c.insertions} {c.short}"
subject = c.subject or "(no subject)"
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
if len(subject) > budget:
subject = subject[: budget - 2] + ".."
items.append(sublime.QuickPanelItem(subject, f"{c.author} · {c.date}", annotation, COMMIT_KIND))
return items
def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
placeholder: str = "Changed files") -> None:
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
st.files = files
selected = max(0, st.index())
window.focus_view(view)
def on_select(index):
def on_select(index: int) -> None:
st.highlight += 1 # cancel a pending preview
if index == -1:
if on_cancel is not None:
@@ -141,39 +174,50 @@ def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder=
return # the preview already shows it
show_file(view, st, change)
def on_highlight(index):
def on_highlight(index: int) -> None:
if not 0 <= index < len(files):
return
st.highlight += 1
ticket = st.highlight
sublime.set_timeout(lambda: ticket == st.highlight and show_file(view, st, files[index]), PREVIEW_DELAY_MS)
def preview() -> None:
if ticket == st.highlight:
show_file(view, st, files[index])
sublime.set_timeout(preview, PREVIEW_DELAY_MS)
window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)
def refresh_listing(view, st, message=None):
"""Re-list the working tree changes and show the same file, or the next one when it is gone"""
def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] = None,
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> None:
"""Re-list the working tree changes and show the same file, or the next one when it is gone
`pick(files, index)` may name another file instead; `index` is the shown file's slot, new or vacated.
"""
if message:
status(message)
previous_index = max(0, st.index())
path = st.change.path if st.change else None
def done(files):
def done(files: List[FileChange]) -> None:
if not view.is_valid():
return
st.files = files
if not files:
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
return
index = state.find_change(files, path)
if index >= 0:
show_file(view, st, files[index])
elif files:
show_file(view, st, files[min(previous_index, len(files) - 1)])
else:
render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
if index < 0:
# the file left the list: whatever moved up into its slot takes over
index = min(previous_index, len(files) - 1)
chosen = pick(files, index) if pick else None
show_file(view, st, chosen if chosen is not None else files[index])
run_async(lambda: git.changed_files(st.repo), done)
def refresh_if_changed(view, st):
def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
"""Re-render a working-tree view when the file or the index changed since it was drawn"""
if st.in_commit or st.change is None:
return
@@ -184,22 +228,22 @@ def refresh_if_changed(view, st):
class VdiffFilesCommand(sublime_plugin.WindowCommand):
"""List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
def run(self):
def run(self) -> None:
window = self.window
opened = open_review(window)
if not opened:
return
view, st = opened
def done(files):
def done(files: List[FileChange]) -> None:
if not view.is_valid():
return
kept = st.enter()
if not files:
render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
status("no uncommitted changes")
return
placeholder = "Changed files in {}".format(os.path.basename(st.repo))
placeholder = f"Changed files in {os.path.basename(st.repo)}"
show_files_panel(window, view, st, files, kept, placeholder=placeholder)
run_async(lambda: git.changed_files(st.repo), done)
@@ -208,7 +252,7 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
class VdiffCommitsCommand(sublime_plugin.WindowCommand):
"""List recent commits, then the files of the chosen commit; `sha` skips straight to one commit"""
def run(self, selected=0, sha=None):
def run(self, selected: int = 0, sha: Optional[str] = None) -> None:
opened = open_review(self.window)
if not opened:
return
@@ -218,7 +262,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
return
count = int(settings().get("commits_count", 30) or 30)
def done(commits):
def done(commits: List[Commit]) -> None:
if not view.is_valid():
return
if not commits:
@@ -228,38 +272,35 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
run_async(lambda: git.recent_commits(st.repo, count), done)
def show_commits(self, view, st, commits, selected):
items = [
sublime.QuickPanelItem(c.subject or "(no subject)", "{} · {}".format(c.author, c.date), c.short, COMMIT_KIND)
for c in commits
]
def show_commits(self, view: sublime.View, st: ViewState, commits: Sequence[Commit], selected: int) -> None:
items = commit_items(commits)
for i, c in enumerate(commits):
if c.sha == st.sha:
selected = i
def on_select(index):
def on_select(index: int) -> None:
if index != -1:
self.open_commit(view, st, commits[index].sha, index)
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
def open_commit(self, view, st, sha, index):
def open_commit(self, view: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None:
"""Files panel of one commit; Esc goes back to the commit list when it came from there (index given)"""
window = self.window
def done(result):
def done(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
if not view.is_valid():
return
info, files = result
kept = st.enter(info.sha, info)
if not files:
render.render_message(view, st, "Commit {} touches no files".format(info.short))
render.render_message(view, st, f"Commit {info.short} touches no files")
return
def back():
def back() -> None:
window.run_command("vdiff_commits", {"selected": index})
placeholder = "{} · {}".format(info.short, info.subject)
placeholder = f"{info.short} · {info.subject}"
on_cancel = back if index is not None else None
show_files_panel(window, view, st, files, kept, on_cancel=on_cancel, placeholder=placeholder)
@@ -269,48 +310,74 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
# --- review view actions ----------------------------------------------------
def _act_files(view, window, st):
def _act_files(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
window.run_command("vdiff_files")
def _act_commits(view, window, st):
def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
window.run_command("vdiff_commits")
def _act_close(view, window, st):
def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
view.close()
def _act_help(view, window, st):
def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
render.show_help(view, st)
def _git_action(verb, done_verb, operation, confirm=None):
"""Action running `operation(repo, change)` on the shown working-tree file, then re-listing"""
def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None],
confirm: Optional[Callable[[FileChange], bool]] = None,
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> Action:
"""Run `operation(repo, change)` on the shown working-tree file, then re-list; `pick` goes to refresh_listing"""
def act(view, window, st):
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
if st.in_commit or st.change is None:
status("{} works on uncommitted changes only".format(verb))
status(f"{verb} works on uncommitted changes only")
return
change = st.change
if confirm is not None and not confirm(change):
return
run_async(lambda: operation(st.repo, change), lambda _: refresh_listing(view, st, "{} {}".format(done_verb, change.path)))
run_async(
lambda: operation(st.repo, change),
lambda _: refresh_listing(view, st, f"{done_verb} {change.path}", pick=pick),
)
return act
def _confirm_discard(change):
def _confirm_discard(change: FileChange) -> bool:
if not settings().get("confirm_discard", True):
return True
if change.status == "?":
message = "Delete the untracked file {}?\n\nThis cannot be undone.".format(change.path)
message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
else:
message = "Discard all changes to {}?\n\nThe file goes back to its last committed version. This cannot be undone.".format(change.path)
message = (
f"Discard all changes to {change.path}?\n\n"
"The file goes back to its last committed version. This cannot be undone."
)
return sublime.ok_cancel_dialog(message, "Discard")
def _act_refresh(view, window, st):
def _next_unstaged(files: Sequence[FileChange], start: int) -> int:
"""Index of the first unstaged file from `start` on, wrapping round; -1 when every file is staged"""
for offset in range(len(files)):
index = (start + offset) % len(files)
if not files[index].staged:
return index
return -1
def _stage_next(files: Sequence[FileChange], index: int) -> Optional[FileChange]:
"""After staging, the next unstaged file to show; None keeps the file the refresh would show"""
if not settings().get("stage_and_move_next_file", True):
return None
# the file just staged is staged now, so a scan starting on it moves off by itself
following = _next_unstaged(files, index)
return files[following] if following >= 0 else None
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
if st.change is None:
return
if st.in_commit:
@@ -319,39 +386,51 @@ def _act_refresh(view, window, st):
refresh_listing(view, st)
def _act_open(view, window, st):
def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
if st.change is None:
return
path = os.path.join(st.repo, st.change.path)
if not os.path.exists(path):
status("{} does not exist in the working tree".format(st.change.path))
status(f"{st.change.path} does not exist in the working tree")
return
line = parse.line_for_open(st.lines, render.caret_index(view))
window.open_file("{}:{}".format(path, line), sublime.ENCODED_POSITION)
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
def _step_file(delta):
def act(view, window, st):
def _step_file(delta: int) -> Action:
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
if not st.files:
status("no file list, press f or c")
return
index = st.index() + delta
if not 0 <= index < len(st.files):
status("no more files")
return
count = len(st.files)
position = st.index()
if position < 0:
# nothing shown yet: n starts at the first file, p at the last. Not a wrap.
index, wrapped = (0 if delta > 0 else count - 1), False
else:
index = position + delta
wrapped = not 0 <= index < count
index %= count # -1 is the last file, count is the first
if wrapped:
render.flash(view)
if index != position:
# a one-file list wraps onto itself: ring, but do not re-run git for the same file
show_file(view, st, st.files[index])
return act
def _step_change(delta):
def act(view, window, st):
def _step_change(delta: int) -> Action:
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
starts = [start for start, _ in st.blocks]
if not starts:
status("no changes to step through")
return
row = render.caret_index(view)
position = bisect.bisect_right(starts, row) if delta > 0 else bisect.bisect_left(starts, row) - 1
if not 0 <= position < len(starts):
status("no more changes")
return
render.flash(view)
position %= len(starts) # -1 is the last change, len(starts) the first
point = render.index_point(view, starts[position])
view.sel().clear()
view.sel().add(sublime.Region(point))
@@ -360,39 +439,106 @@ def _step_change(delta):
return act
ACTIONS = {
def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Commit the staged files with a message typed into an input panel; Esc there cancels"""
if st.in_commit:
status("commit works on uncommitted changes only")
return
staged = [c for c in st.files if c.staged]
if not staged:
status("no staged files")
return
def on_done(message: str) -> None:
if not message.strip():
status("commit cancelled: empty message")
return
run_async(
lambda: git.commit(st.repo, message),
lambda _: refresh_listing(view, st, f"committed {file_count(len(staged))}"),
)
window.show_input_panel("Commit message", "", on_done, None, None)
def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
"""Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels
The one-line panel offers only the subject; the body rides along verbatim, and nothing staged is a reword.
"""
if st.in_commit:
status("amend works on uncommitted changes only")
return
staged = [c for c in st.files if c.staged]
def prompt(message: str) -> None:
subject, newline, body = message.partition("\n")
def on_done(text: str) -> None:
if not text.strip():
status("amend cancelled: empty message")
return
done = "amended the last commit"
if staged:
done += f" with {file_count(len(staged))}"
run_async(
# nothing staged makes this a pure reword, which git refuses on an empty commit
lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged),
lambda _: refresh_listing(view, st, done),
)
panel = window.show_input_panel("Amend message", subject, on_done, None, None)
# the panel selects the whole prefill; the caret belongs after it, ready to type
panel.sel().clear()
panel.sel().add(sublime.Region(panel.size()))
run_async(lambda: git.head_message(st.repo), prompt)
def _move_line(forward: bool) -> Action:
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
view.run_command("move", {"by": "lines", "forward": forward})
return act
ACTIONS: Dict[str, Action] = {
"files": _act_files,
"commits": _act_commits,
"close": _act_close,
"help": _act_help,
"stage": _git_action("stage", "staged", git.stage),
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
"unstage": _git_action("unstage", "unstaged", git.unstage),
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
"commit": _act_commit,
"amend": _act_amend,
"refresh": _act_refresh,
"open": _act_open,
"next_file": _step_file(1),
"prev_file": _step_file(-1),
"next_change": _step_change(1),
"prev_change": _step_change(-1),
"line_down": _move_line(True),
"line_up": _move_line(False),
}
class VdiffActionCommand(sublime_plugin.TextCommand):
"""Every key/link action of a review view, dispatched by name through ACTIONS"""
def is_enabled(self, action=None):
def is_enabled(self, action: Optional[str] = None) -> bool:
return state.is_review_view(self.view)
def run(self, edit, action):
def run(self, edit: sublime.Edit, action: str) -> None:
act = ACTIONS.get(action)
if act is None:
status("unknown action {}".format(action))
status(f"unknown action {action}")
return
st = state.get(self.view) or restore_view(self.view)
act(self.view, self.view.window(), st)
def restore_view(view):
def restore_view(view: sublime.View) -> ViewState:
"""Rebuild the state of a review view restored from a previous session; re-renders asynchronously"""
data = view.settings().get(state.VIEW_DATA) or {}
repo = data.get("repo")
@@ -409,22 +555,23 @@ def restore_view(view):
return st
change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None)
if st.in_commit:
sha = st.sha
if sha is not None:
def done(result):
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
st.commit, st.files = result
show_file(view, st, change)
run_async(lambda: git.commit_with_files(repo, st.sha), done)
run_async(lambda: git.commit_with_files(repo, sha), show_commit)
else:
def done(files):
def show_working_tree(files: List[FileChange]) -> None:
st.files = files
index = state.find_change(files, path)
if index >= 0:
show_file(view, st, files[index])
else:
render.render_message(view, st, "{} has no uncommitted changes any more".format(path))
render.render_message(view, st, f"{path} has no uncommitted changes any more")
run_async(lambda: git.changed_files(repo), done)
run_async(lambda: git.changed_files(repo), show_working_tree)
return st

View File

@@ -1,12 +1,45 @@
""" Git access: every call is one subprocess; results are plain tuples, no sublime imports (testable) """
import os
import re
import subprocess
from collections import namedtuple
from typing import Dict, List, Optional, NamedTuple, Sequence, Tuple
# path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source
FileChange = namedtuple("FileChange", "path status staged insertions deletions old_path")
Commit = namedtuple("Commit", "sha short author date subject")
CommitInfo = namedtuple("CommitInfo", "sha short author date subject body")
class FileChange(NamedTuple):
"""path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source"""
path: str
status: str
staged: bool
insertions: Optional[int]
deletions: Optional[int]
old_path: Optional[str]
class Commit(NamedTuple):
"""shortstat totals; all three are None when git prints no diffstat (merges, empty commits)"""
sha: str
short: str
author: str
date: str
subject: str
files: Optional[int]
insertions: Optional[int]
deletions: Optional[int]
class CommitInfo(NamedTuple):
sha: str
short: str
author: str
date: str
subject: str
body: str
# a numstat entry: insertions, deletions (both None for a binary file) and the rename source
Numstat = Tuple[Optional[int], Optional[int], Optional[str]]
# hash of the empty tree: the diff base for a repository without commits
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
@@ -15,9 +48,9 @@ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
FULL_CONTEXT = "-U999999"
# overridable from settings (sublime's PATH may not have a user-installed git)
GIT_BINARY = "git"
GIT_BINARY: str = "git"
STATUS_NAMES = {
STATUS_NAMES: Dict[str, str] = {
"M": "modified",
"A": "added",
"D": "deleted",
@@ -31,29 +64,30 @@ STATUS_NAMES = {
_INFO_FORMAT = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%b"
_DATE_FORMAT = "--date=format:%Y-%m-%d %H:%M"
# " 3 files changed, 10 insertions(+), 2 deletions(-)" - either count clause is dropped when zero
_SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?")
class GitError(Exception):
"""git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it"""
def __init__(self, stderr, code):
def __init__(self, stderr: str, code: int) -> None:
self.stderr = stderr
self.code = code
first = stderr.strip().splitlines()
super().__init__(first[0] if first else "git exited with {}".format(code))
super().__init__(first[0] if first else f"git exited with {code}")
def run(repo, args, ok_codes=(0,)):
def run(repo: str, args: Sequence[str], ok_codes: Tuple[int, ...] = (0,)) -> str:
"""Run git in `repo`, return stdout as text"""
cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args)
proc = subprocess.run(
cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace"
)
proc = subprocess.run(cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace")
if proc.returncode not in ok_codes:
raise GitError(proc.stderr, proc.returncode)
return proc.stdout
def repo_root(path):
def repo_root(path: Optional[str]) -> Optional[str]:
"""Top-level directory of the repository containing `path`, or None"""
if not path:
return None
@@ -67,7 +101,7 @@ def repo_root(path):
return root or None
def has_head(repo):
def has_head(repo: str) -> bool:
"""False in a repository without commits"""
try:
run(repo, ["rev-parse", "--verify", "-q", "HEAD"])
@@ -76,19 +110,19 @@ def has_head(repo):
return False
def base_ref(repo):
def base_ref(repo: str) -> str:
"""HEAD, or the empty tree when there is no commit yet"""
return "HEAD" if has_head(repo) else EMPTY_TREE
def _int_or_none(value):
def _int_or_none(value: str) -> Optional[int]:
"""A numstat count: "-" marks a binary file"""
return None if value == "-" else int(value)
def _parse_numstat(text):
def _parse_numstat(text: str) -> Dict[str, Numstat]:
"""`--numstat -z` output -> {path: (insertions, deletions, old_path)}"""
result = {}
result: Dict[str, Numstat] = {}
fields = iter(text.split("\0"))
for entry in fields:
parts = entry.split("\t")
@@ -104,9 +138,9 @@ def _parse_numstat(text):
return result
def _parse_name_status(text):
def _parse_name_status(text: str) -> List[Tuple[str, str, Optional[str]]]:
"""`--name-status -z` output -> [(status letter, path, old_path)]"""
result = []
result: List[Tuple[str, str, Optional[str]]] = []
fields = iter(text.split("\0"))
for status in fields:
if not status:
@@ -119,7 +153,7 @@ def _parse_name_status(text):
return result
def _count_lines(path):
def _count_lines(path: str) -> Optional[int]:
"""Line count of a working-tree file, None when binary or unreadable"""
try:
with open(path, "rb") as f:
@@ -141,7 +175,7 @@ def _count_lines(path):
return count + (0 if last == b"\n" else 1)
def changed_files(repo):
def changed_files(repo: str) -> List[FileChange]:
"""Working tree changes vs HEAD: staged, unstaged and untracked, sorted by path"""
status = run(repo, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--no-renames"])
entries = [e for e in status.split("\0") if e]
@@ -149,7 +183,7 @@ def changed_files(repo):
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base]))
changes = []
changes: List[FileChange] = []
for entry in entries:
kind = entry[0]
if kind == "1":
@@ -174,23 +208,31 @@ def changed_files(repo):
return changes
def recent_commits(repo, count):
def recent_commits(repo: str, count: int) -> List[Commit]:
"""Last `count` commits, newest first; empty list without commits"""
if not has_head(repo):
return []
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s"])
commits = []
# --shortstat rides along on the same call: one subprocess, totals for free
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
commits: List[Commit] = []
for line in text.split("\n"):
parts = line.split("\x1f")
if len(parts) == 5:
commits.append(Commit(*parts))
sha, short, author, date, subject = parts
commits.append(Commit(sha, short, author, date, subject, files=None, insertions=None, deletions=None))
continue
if commits and commits[-1].files is None:
stat = _SHORTSTAT.match(line)
if stat:
files, ins, dele = (int(g or 0) for g in stat.groups())
commits[-1] = commits[-1]._replace(files=files, insertions=ins, deletions=dele)
return commits
def commit_with_files(repo, sha):
def commit_with_files(repo: str, sha: str) -> Tuple[CommitInfo, List[FileChange]]:
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
show = ["show", "--first-parent", "-m", "-z"]
text = run(repo, show + ["--format={}%x00".format(_INFO_FORMAT), _DATE_FORMAT, "--name-status", sha])
text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
# git terminates the header with its own NUL and a newline before the file list
info_text, _, files_text = text.partition("\0")
files_text = files_text.lstrip("\0\n")
@@ -200,7 +242,7 @@ def commit_with_files(repo, sha):
info = CommitInfo(*parts[:6])
counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha]))
changes = []
changes: List[FileChange] = []
for status, path, old_path in _parse_name_status(files_text):
ins, dele, _ = counts.get(path, (None, None, None))
changes.append(FileChange(path, status, False, ins, dele, old_path))
@@ -208,20 +250,20 @@ def commit_with_files(repo, sha):
return info, changes
def _in_tree(repo, ref, path):
def _in_tree(repo: str, ref: str, path: str) -> bool:
"""Whether `path` exists in the tree of `ref`"""
try:
run(repo, ["cat-file", "-e", "{}:{}".format(ref, path)])
run(repo, ["cat-file", "-e", f"{ref}:{path}"])
return True
except GitError:
return False
def full_diff(repo, path, sha=None, untracked=False):
def full_diff(repo: str, path: str, sha: Optional[str] = None, untracked: bool = False) -> str:
"""Unified diff of one file with the whole file as context
Working tree vs HEAD (staged and unstaged merged) by default; a commit vs its
first parent when `sha` is given; the whole file as insertions when untracked.
Working tree vs HEAD by default (staged and unstaged merged), a commit vs its first parent with
`sha`, the whole file as insertions when untracked.
"""
common = ["--no-color", "--no-ext-diff", FULL_CONTEXT]
if untracked:
@@ -231,12 +273,30 @@ def full_diff(repo, path, sha=None, untracked=False):
return run(repo, ["diff"] + common + [base_ref(repo), "--", path])
def stage(repo, change):
def stage(repo: str, change: FileChange) -> None:
"""Stage the whole file, including its deletion"""
run(repo, ["add", "-A", "--", change.path])
def unstage(repo, change):
def commit(repo: str, message: str) -> None:
"""Commit what is staged; the message is one argv element, so it needs no quoting of its own"""
run(repo, ["commit", "-m", message])
def head_message(repo: str) -> str:
"""HEAD's whole message, exactly as git stores it"""
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
def amend(repo: str, message: str, allow_empty: bool = False) -> None:
"""Rewrite HEAD with `message`, folding in whatever is staged
`allow_empty` for a pure reword: git refuses an amend carrying no change, failing on an empty commit.
"""
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
def unstage(repo: str, change: FileChange) -> None:
"""Move the file's staged changes back to the working tree"""
if has_head(repo):
run(repo, ["reset", "-q", "--", change.path])
@@ -244,7 +304,7 @@ def unstage(repo, change):
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
def discard(repo, change):
def discard(repo: str, change: FileChange) -> None:
"""Throw away the file's changes, staged and unstaged, restoring the HEAD version"""
if change.status == "?":
run(repo, ["clean", "-f", "--", change.path])

View File

@@ -7,15 +7,15 @@ from .commands import refresh_if_changed, restore_view
class VdiffEventListener(sublime_plugin.EventListener):
def on_pre_close(self, view):
def on_pre_close(self, view: sublime.View) -> None:
state.drop(view)
def on_activated(self, view):
def on_activated(self, view: sublime.View) -> None:
"""Re-render when the tab comes back and the file or index changed meanwhile (agent edits)"""
if state.is_review_view(view):
refresh_if_changed(view, state.get(view) or restore_view(view))
def on_post_save(self, view):
def on_post_save(self, view: sublime.View) -> None:
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
for view_id, st in state.tracked():
review = sublime.View(view_id)

246
vdiff/palette.py Normal file
View File

@@ -0,0 +1,246 @@
""" The colour-scheme scopes vDiff paints changed lines with, provisioned into a scheme override
view.add_regions repaints text with the region scope's foreground, so one scope cannot give both a
red/green background and the file's syntax colours. This module generates a scope per (background
kind, colour) pair carrying both, and render paints each run of same-coloured tokens with it.
Sublime picks up a new override only on a restart, so the palette is probed and written in one pass
rather than a colour at a time.
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional, Set, Tuple
import sublime
LOG = logging.getLogger("vdiff")
# the full-line tint under the token layer, the scheme's own: the token layer repaints the text on
# top so the tint's foreground never shows, and a scope the scheme defines needs no restart.
SCOPE_DEL = "diff.deleted"
SCOPE_INS = "diff.inserted"
SCOPE_DEL_CHAR = "diff.deleted.char"
SCOPE_INS_CHAR = "diff.inserted.char"
# background kinds, and the tint each one draws over
KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR}
# tints to provision when the active scheme defines none of its own (some third-party schemes)
FALLBACK_TINTS: Dict[str, str] = {
SCOPE_DEL: "hsla(357, 45%, 60%, 0.15)",
SCOPE_DEL_CHAR: "hsla(357, 60%, 60%, 0.30)",
SCOPE_INS: "hsla(114, 31%, 68%, 0.15)",
SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)",
}
# scope roots to probe for the scheme's palette: schemes colour by these standard names, so one pass
# yields the working set. No API returns a scheme's palette — style_for_scope takes one scope at a
# time, and reading the file would mean re-implementing sublime's override and .tmTheme merging.
PALETTE_PROBES: Tuple[str, ...] = (
"source",
"comment",
"string",
"constant.numeric",
"constant.language",
"constant.character.escape",
"variable",
"variable.parameter",
"variable.function",
"keyword",
"keyword.operator",
"storage.type",
"storage.modifier",
"entity.name.function",
"entity.name.class",
"entity.name.tag",
"entity.other.attribute-name",
"entity.other.inherited-class",
"support.function",
"support.class",
"support.type",
"support.constant",
"punctuation.definition",
"punctuation.separator",
"meta.function-call",
"invalid",
)
# {override file name: {colour: index}} for every scheme provisioned this session
_palettes: Dict[str, Dict[str, int]] = {}
# the override name the last ensure() resolved, so scope_for knows which palette to read
_active: Optional[str] = None
# schemes the reader has already been told need a restart
_notified: Set[Optional[str]] = set()
def reset() -> None:
"""Drop the caches, so the next ensure() re-reads and re-probes. Only the tests need this"""
global _active
_palettes.clear()
_notified.clear()
_active = None
def claim_restart_notice(view: sublime.View) -> bool:
"""Whether the reader still has to be told to restart, asked once per scheme
True only when a restart helps — provisioned but not yet loaded; it cannot fix one we never wrote.
"""
if not (_active and _palettes.get(_active)) or tokens_resolved(view) or _active in _notified:
return False
_notified.add(_active)
return True
def scope_background(view: sublime.View, scope: str) -> Optional[str]:
"""The scheme's background for a scope, or None; style_for_scope can fail on a closing view"""
return _style(view, scope).get("background")
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
try:
return view.style_for_scope(scope)
except Exception:
LOG.debug("style_for_scope failed for %s", scope, exc_info=True)
return {}
def foreground(view: sublime.View, scope: str) -> Optional[str]:
"""The scheme's foreground for a scope, normalised by sublime to #rrggbb"""
return _style(view, scope).get("foreground")
def tokens_resolved(view: sublime.View) -> bool:
"""Whether the generated (tint + colour) scopes are live
The token layer must never paint with a scope sublime does not know: it would draw that text in
sublime's unknown-scope green. A new override loads only on a restart, so the writing session sees false.
"""
colours = (_active and _palettes.get(_active)) or {}
if not colours:
return False
# they are all written by the same pass into the same file, so any one of them answers for all
return bool(scope_background(view, _scope_name(KINDS[0], next(iter(colours.values())))))
def scope_for(kind: str, colour: str) -> Optional[str]:
"""The generated scope carrying `kind`'s background and `colour`, or None if never provisioned"""
index = _palettes.get(_active or "", {}).get(colour)
return None if index is None else _scope_name(kind, index)
def token_key(scope: str) -> str:
"""The add_regions key for a generated scope; one naming rule, spelled here only"""
return scope.replace("vdiff.", "vdiff.tok.", 1)
def token_keys() -> List[str]:
"""Every region key the token layer can use, so a re-render can erase them all"""
colours = _palettes.get(_active or "", {})
return [token_key(_scope_name(kind, i)) for kind in KINDS for i in sorted(colours.values())]
def _scope_name(kind: str, index: int) -> str:
return f"vdiff.{kind}.c{index}"
def ensure(view: sublime.View) -> None:
"""Provision the scopes for the active scheme, once per scheme per session
Whether they have taken effect is tokens_resolved()'s question: a new override loads only on a restart.
"""
global _active
name = override_name(view)
_active = name
if not name or name in _palettes:
return
path = os.path.join(sublime.packages_path(), "User", name)
try:
scheme: Dict[str, Any] = {}
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
# a hand-edited scheme may hold comments decode_value rejects: the raise leaves the
# file untouched, and render still shows the changed lines
scheme = sublime.decode_value(f.read()) or {}
rules = scheme.setdefault("rules", [])
tints = _tints(view)
colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
_palettes[name] = colours
if not _merge_rules(rules, tints, colours, view):
return
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(scheme, f, indent=4)
LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path)
except Exception:
LOG.warning("could not write colour scheme override %s", path, exc_info=True)
_palettes.setdefault(name, {})
def _probe_palette(view: sublime.View) -> List[str]:
"""The distinct foregrounds the active scheme uses, in probe order so indices stay meaningful"""
seen: List[str] = []
for scope in PALETTE_PROBES:
colour = foreground(view, scope)
if colour and colour not in seen:
seen.append(colour)
return seen
def _tints(view: sublime.View) -> Dict[str, str]:
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own.
"""
tints: Dict[str, str] = {}
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)):
line = scope_background(view, line_scope)
char = scope_background(view, char_scope)
tints[line_scope] = line if line and char else FALLBACK_TINTS[line_scope]
tints[char_scope] = char if line and char else FALLBACK_TINTS[char_scope]
return tints
def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
view: sublime.View) -> bool:
"""Bring the scheme's rules up to date, in place; True when anything changed
add_regions draws text in the region scope's own foreground, so each (kind, colour) pair needs a
rule carrying both; a background alone would flatten the line. Plain tints are written only when
the scheme has none, and never overwritten, so a hand-tuned one survives.
"""
present = {rule.get("scope"): rule for rule in rules}
changed = False
for scope, background in tints.items():
if scope not in present and not scope_background(view, scope):
rules.append({"scope": scope, "background": background})
changed = True
for colour, index in colours.items():
for kind in KINDS:
wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]],
"foreground": colour}
rule = present.get(wanted["scope"])
if rule is None:
rules.append(wanted)
changed = True
elif rule != wanted:
# the palette shifted under a rule an earlier session wrote; ours to keep correct
rule.clear()
rule.update(wanted)
changed = True
return changed
def override_name(view: sublime.View) -> Optional[str]:
"""File name in Packages/User that overrides the active scheme (same base name, .sublime-color-scheme)"""
try:
# already resolved through "auto" and dark/light settings by sublime
name = sublime.ui_info()["color_scheme"]["resolved_value"]
except Exception:
name = view.settings().get("color_scheme") or ""
if not name or name == "auto":
return None
root, _ = os.path.splitext(os.path.basename(name))
return root + ".sublime-color-scheme"

View File

@@ -1,12 +1,32 @@
""" Unified diff -> lines with old/new numbers, change blocks, word-level ranges; no sublime imports """
import difflib
import re
from collections import Counter, namedtuple
from collections import Counter
from itertools import groupby
from typing import Callable, Iterator, List, NamedTuple, Optional, Sequence, Tuple, TypeVar
# kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None
Line = namedtuple("Line", "kind old_no new_no text")
Diff = namedtuple("Diff", "lines binary")
class Line(NamedTuple):
"""kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None"""
kind: str
old_no: Optional[int]
new_no: Optional[int]
text: str
class Diff(NamedTuple):
lines: List[Line]
binary: bool
# the value `runs` groups by, so a caller gets its own key type back rather than `object`
Key = TypeVar("Key")
# an (start, end) index range of consecutive changed lines
Block = Tuple[int, int]
# a (start, end) character range inside one line's text
CharRange = Tuple[int, int]
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
CHAR_DIFF_MIN_RATIO = 0.5
@@ -15,9 +35,9 @@ CHAR_DIFF_MAX_CHARS = 2000
_HUNK = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)")
def parse_unified(text):
def parse_unified(text: str) -> Diff:
"""Parse one file's unified diff (any number of hunks) into a Diff"""
lines = []
lines: List[Line] = []
binary = False
old_no = new_no = 0
in_hunk = False
@@ -52,25 +72,25 @@ def parse_unified(text):
return Diff(lines, binary)
def runs(items, key):
def runs(items: Sequence[Line], key: Callable[[Line], Key]) -> Iterator[Tuple[Key, int, int]]:
"""(key value, start, end) for each maximal run of consecutive items with the same key"""
for value, group in groupby(enumerate(items), key=lambda pair: key(pair[1])):
indexes = [i for i, _ in group]
yield value, indexes[0], indexes[-1] + 1
def change_blocks(lines):
def change_blocks(lines: Sequence[Line]) -> List[Block]:
"""[(start, end)) index ranges of consecutive changed lines"""
return [(start, end) for changed, start, end in runs(lines, lambda line: line.kind != " ") if changed]
def counts(lines):
def counts(lines: Sequence[Line]) -> Tuple[int, int]:
"""(insertions, deletions)"""
tally = Counter(line.kind for line in lines)
return tally["+"], tally["-"]
def paired_lines(lines, block):
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
start, end = block
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
@@ -80,15 +100,15 @@ def paired_lines(lines, block):
return list(zip(deleted, inserted))
def char_ranges(old, new):
def char_ranges(old: str, new: str) -> Tuple[List[CharRange], List[CharRange]]:
"""Character ranges that differ: ([(a, b) in old], [(a, b) in new]); empty when too different or too long"""
if len(old) + len(new) > CHAR_DIFF_MAX_CHARS:
return [], []
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
return [], []
old_ranges = []
new_ranges = []
old_ranges: List[CharRange] = []
new_ranges: List[CharRange] = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag in ("replace", "delete") and i2 > i1:
old_ranges.append((i1, i2))
@@ -97,7 +117,7 @@ def char_ranges(old, new):
return old_ranges, new_ranges
def line_for_open(lines, index):
def line_for_open(lines: Sequence[Line], index: int) -> int:
"""New-file line number to open for a row: a deleted row maps to the next surviving line"""
if not lines:
return 1

View File

@@ -1,17 +1,18 @@
""" Draw a parsed diff into a review view: the file text, coloured line regions, gutter marks, header phantom """
import bisect
import html
import json
import logging
import os
from collections import namedtuple
from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple
import sublime
import sublime_plugin
from . import parse
from .git import STATUS_NAMES
from .state import VIEW_FLAG
from .utils import settings
from . import palette, parse
from .git import FileChange, STATUS_NAMES
from .parse import Block, Line
from .state import VIEW_FLAG, ViewState
from .utils import file_count, settings, status
LOG = logging.getLogger("vdiff")
@@ -21,62 +22,86 @@ KEY_DEL_GUTTER = "vdiff.del.gutter"
KEY_INS_GUTTER = "vdiff.ins.gutter"
KEY_DEL_CHAR = "vdiff.del.char"
KEY_INS_CHAR = "vdiff.ins.char"
REGION_KEYS = (KEY_DEL, KEY_INS, KEY_DEL_GUTTER, KEY_INS_GUTTER, KEY_DEL_CHAR, KEY_INS_CHAR)
REGION_KEYS: Tuple[str, ...] = (KEY_DEL, KEY_INS, KEY_DEL_GUTTER, KEY_INS_GUTTER, KEY_DEL_CHAR, KEY_INS_CHAR)
PHANTOM_KEY = "vdiff.header"
STATUS_KEY = "vdiff"
# a block phantom anchored at point 0 is drawn below row 0, so row 0 stays empty and the file starts at row 1.
# Only index_point / caret_index know about it.
# visual bell: a whole-buffer tint on a timer. Not in REGION_KEYS, so a wrap's re-render cannot wipe it early.
BELL_KEY = "vdiff.bell"
BELL_MS = 90
# above this much changed text the colours cost more than they are worth: such a diff is scrolled, not read
TOKEN_COLOUR_MAX_CHARS = 200000
# a block phantom at point 0 draws below row 0, so the file starts at row 1; only index_point / caret_index know
_HEADER_ROWS = 1
# scopes with a background in every bundled colour scheme (Mariana, Monokai, Breakers, Celeste, Sixteen)
SCOPE_DEL = "diff.deleted"
SCOPE_INS = "diff.inserted"
SCOPE_DEL_CHAR = "diff.deleted.char"
SCOPE_INS_CHAR = "diff.inserted.char"
# gutter dots use the scope's foreground
SCOPE_DEL_MARK = "markup.deleted"
SCOPE_INS_MARK = "markup.inserted"
# ST4 defines region.<colour>ish in every bundled scheme for exactly this kind of transient marking
SCOPE_BELL = "region.yellowish"
# rules written into a colour-scheme override when the active scheme lacks the scopes above
FALLBACK_RULES = [
{"scope": SCOPE_DEL, "background": "hsla(357, 45%, 60%, 0.15)", "foreground_adjust": "l(+ 5%)"},
{"scope": SCOPE_DEL_CHAR, "background": "hsla(357, 60%, 60%, 0.30)", "foreground_adjust": "l(+ 10%)"},
{"scope": SCOPE_INS, "background": "hsla(114, 31%, 68%, 0.15)", "foreground_adjust": "l(+ 5%)"},
{"scope": SCOPE_INS_CHAR, "background": "hsla(114, 45%, 68%, 0.30)", "foreground_adjust": "l(+ 10%)"},
]
# sublime only loads an override it has not seen before on a restart
NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines"
# one source for the status-bar hints and the ? popup.
# actions: the ACTIONS names the row covers; a single one makes the row a link. short: status-bar
# wording, "" keeps the row out of it. The group flag is False for groups needing a working-tree file.
Key = namedtuple("Key", "keys label short actions")
# actions: the ACTIONS names the row covers, a single one making it a link. short: status-bar
# wording, "" keeps the row out. The group flag is False for groups needing a working-tree file.
class Key(NamedTuple):
"""One row of the status-bar hints and the ? popup"""
KEY_GROUPS = [
("Navigate", True, [
Key("] [", "next / previous change", "change", ("next_change", "prev_change")),
Key("n p", "next / previous file", "file", ("next_file", "prev_file")),
keys: str
label: str
short: str
actions: Tuple[str, ...]
# (group title, applies in a commit, rows)
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
(
"Navigate",
True,
[
Key("] [", "next / previous change (wraps)", "change", ("next_change", "prev_change")),
Key("n p", "next / previous file (wraps)", "file", ("next_file", "prev_file")),
Key("o \u23ce", "open the real file at this line", "open", ("open",)),
]),
("Panels", True, [
Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
],
),
(
"Panels",
True,
[
Key("f", "changed files", "files", ("files",)),
Key("c", "recent commits", "commits", ("commits",)),
]),
("This file", False, [
],
),
(
"Actions",
False,
[
Key("s", "stage", "stage", ("stage",)),
Key("u", "unstage", "unstage", ("unstage",)),
Key("d", "discard", "discard", ("discard",)),
]),
("View", True, [
Key("C", "commit the staged files", "commit", ("commit",)),
Key("m", "amend the last commit", "amend", ("amend",)),
],
),
(
"View",
True,
[
Key("r", "refresh", "refresh", ("refresh",)),
Key("?", "this list", "keys", ("help",)),
Key("esc", "close the view", "", ("close",)),
]),
],
),
]
KEYS = [key for _, _, group in KEY_GROUPS for key in group]
KEYS: List[Key] = [key for _, _, group in KEY_GROUPS for key in group]
KEY_HINTS = " ".join("{} {}".format(k.keys, k.short) for k in KEYS if k.short)
KEY_HINTS = " ".join(f"{k.keys} {k.short}" for k in KEYS if k.short)
# the popup font is the editor's, so the key column is padded to a fixed width in characters
_KEY_WIDTH = max(len(k.keys) for k in KEYS)
@@ -89,18 +114,18 @@ body { margin: 0; padding: 0.5rem 0.8rem; background-color: color(var(--backgrou
.ins { color: var(--greenish); font-weight: bold; }
.state { color: var(--orangish); }
.actions { margin-left: 1rem; }
.summary { margin-bottom: 0.3rem; color: color(var(--foreground) alpha(0.75)); }
.when { color: color(var(--foreground) alpha(0.6)); }
a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-radius: 3px;
background-color: color(var(--foreground) alpha(0.12)); color: var(--foreground); }
.danger { color: var(--redish); }
.commit { margin-top: 0.4rem; color: color(var(--foreground) alpha(0.75)); }
.hash { color: var(--bluish); font-weight: bold; }
.body { color: color(var(--foreground) alpha(0.6)); }
"""
# minihtml stops applying a stylesheet at the first selector it cannot parse, and its documentation
# covers properties only, not selectors: every rule here sticks to one bare element or one class,
# the forms HEADER_CSS proves work. The first group's margin stands in for body's top padding, and
# .kdim takes its colour from the .dim row around it.
# minihtml drops the rest of a stylesheet at a selector it cannot parse, and documents properties
# only, not selectors: every rule sticks to one bare element or class, the forms HEADER_CSS proves.
# The first group's margin stands in for body's top padding; .kdim takes its colour from .dim.
HELP_CSS = """
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
@@ -116,11 +141,11 @@ a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
class VdiffReplaceCommand(sublime_plugin.TextCommand):
"""Replace the whole buffer; the only writer of a review view"""
def run(self, edit, text):
def run(self, edit: sublime.Edit, text: str) -> None:
self.view.replace(edit, sublime.Region(0, self.view.size()), text)
def init_view(view):
def init_view(view: sublime.View) -> None:
"""Settings a review view needs, applied once when it is created or restored"""
s = view.settings()
s.set(VIEW_FLAG, True)
@@ -135,41 +160,59 @@ def init_view(view):
s.set("show_definitions", False)
s.set("mini_diff", False)
s.set("spell_check", False)
# a read-only viewer, so vim's insert mode is meaningless: command mode, block caret. vdiff's
# own keys carry no command_mode context, so they fire in either mode regardless.
s.set("command_mode", True)
s.set("inverse_caret_state", True)
view.set_scratch(True)
view.set_read_only(True)
if not view.name():
view.set_name("vDiff")
view.set_status(STATUS_KEY, KEY_HINTS)
ensure_scheme_scopes(view)
def index_point(view, index, col=0):
def index_point(view: sublime.View, index: int, col: int = 0) -> int:
"""Text point of column `col` on the row showing st.lines[index]"""
return view.text_point(index + _HEADER_ROWS, col)
def caret_index(view):
def caret_index(view: sublime.View) -> int:
"""Index into st.lines of the caret's row (the header row maps to -1)"""
if not len(view.sel()):
return 0
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
def fingerprint(st):
def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float]]]:
"""Cheap "did anything change" key for working-tree mode: mtimes of the file and of the git index"""
if st.in_commit or st.change is None:
return None
return (_mtime(os.path.join(st.repo, st.change.path)), _mtime(os.path.join(st.repo, ".git", "index")))
def _mtime(path):
def _mtime(path: str) -> Optional[float]:
try:
return os.stat(path).st_mtime
except OSError:
return None
def render_message(view, st, message):
def flash(view: sublime.View) -> None:
"""Visual bell: tint the buffer for a moment, for feedback that has nothing to say in words"""
view.add_regions(BELL_KEY, [sublime.Region(0, view.size())], _bell_scope(view), flags=sublime.DRAW_NO_OUTLINE)
# unconditional erase: overlapping flashes may cut one short, but none can leave the tint stuck
sublime.set_timeout(lambda: view.is_valid() and view.erase_regions(BELL_KEY), BELL_MS)
def _bell_scope(view: sublime.View) -> str:
"""region.yellowish, or a scope with a background we know of when a scheme lacks it"""
if palette.scope_background(view, SCOPE_BELL):
return SCOPE_BELL
# an invisible bell is a silent failure, so borrow the insert tint
return palette.SCOPE_INS
def render_message(view: sublime.View, st: ViewState, message: str) -> None:
"""Show a one-line notice instead of a file (nothing to review, binary, ...)"""
st.change = None
st.lines = []
@@ -180,8 +223,11 @@ def render_message(view, st, message):
_finish(view, st, 0, 0, "vDiff")
def render(view, st):
def render(view: sublime.View, st: ViewState) -> None:
"""Draw st.lines for st.change into the view"""
change = st.change
# every caller with nothing to show goes to render_message instead, so there is a file here
assert change is not None
lines = st.lines
if st.binary:
text = "(binary file)"
@@ -191,32 +237,117 @@ def render(view, st):
text = "\n".join(line.text for line in lines)
_replace(view, text)
path = os.path.join(st.repo, st.change.path)
path = os.path.join(st.repo, change.path)
_apply_syntax(view, path if lines and not st.binary else None)
if lines and not st.binary:
starts = _row_starts(lines)
deleted, inserted = _line_regions(lines, starts)
view.add_regions(KEY_DEL, deleted, SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, inserted, SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"]
ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"]
view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags)
view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags)
if settings().get("word_diff", True):
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
view.add_regions(KEY_DEL_CHAR, del_chars, SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS_CHAR, ins_chars, SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE)
# here rather than init_view: during a session restore the scheme colours still read as absent
palette.ensure(view)
_paint_regions(view, st)
if palette.claim_restart_notice(view):
status(NEEDS_RESTART)
ins, dele = parse.counts(lines)
_finish(view, st, ins, dele, "⟐ {}".format(os.path.basename(st.change.path)))
_finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}")
view.sel().clear()
view.sel().add(sublime.Region(index_point(view, 0)))
view.set_viewport_position((0, 0), False)
def _finish(view, st, ins, dele, name):
def _paint_regions(view: sublime.View, st: ViewState) -> None:
"""The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
lines = st.lines
starts = _row_starts(lines)
deleted, inserted = _line_regions(lines, starts)
view.add_regions(KEY_DEL, deleted, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS, inserted, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"]
ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"]
view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags)
view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags)
del_chars: List[sublime.Region] = []
ins_chars: List[sublime.Region] = []
if settings().get("word_diff", True):
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
view.add_regions(KEY_DEL_CHAR, del_chars, palette.SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE)
view.add_regions(KEY_INS_CHAR, ins_chars, palette.SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE)
_paint_tokens(view, lines, starts, del_chars + ins_chars)
def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int],
char_regions: Sequence[sublime.Region]) -> None:
"""Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour
add_regions draws text in the region scope's own foreground, so the tints above have just
flattened every changed line; only handing each run its own colour gives the highlighting back.
"""
if not palette.tokens_resolved(view):
return
changed = [i for i, line in enumerate(lines) if line.kind != " "]
if sum(len(lines[i].text) for i in changed) > TOKEN_COLOUR_MAX_CHARS:
# a huge diff would spend longer being tokenised than read; the flat tint still shows it
return
stronger = sorted((r.a, r.b) for r in char_regions)
starts_of = [a for a, _ in stronger]
groups: Dict[str, List[sublime.Region]] = {}
colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render
for i in changed:
kind = "del" if lines[i].kind == "-" else "ins"
end = starts[i] + len(lines[i].text)
# only this line's word-diff ranges, so a diff with thousands of them stays linear
on_line = stronger[bisect.bisect_left(starts_of, starts[i]):bisect.bisect_left(starts_of, end)]
for region, colour in _colour_runs(view, starts[i], lines[i].text, colours):
# a word-diff range cuts across token boundaries, so the run splits on it
for part, inside in _split(region, on_line):
# a colour outside the scheme's probed palette keeps the plain tint under it
scope = palette.scope_for(kind + ".char" if inside else kind, colour)
if scope:
groups.setdefault(scope, []).append(part)
for scope, regions in groups.items():
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List[Tuple[sublime.Region, bool]]:
"""(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
if not intervals:
return [(region, False)]
parts: List[Tuple[sublime.Region, bool]] = []
pos = region.a
for a, b in intervals:
a, b = max(a, region.a), min(b, region.b)
if b <= pos:
continue
if a >= region.b:
break
if a > pos:
parts.append((sublime.Region(pos, a), False))
parts.append((sublime.Region(a, b), True))
pos = b
if pos < region.b or not parts:
parts.append((sublime.Region(pos, region.b), False))
return parts
def _colour_runs(view: sublime.View, start: int, text: str,
colours: Dict[str, Optional[str]]) -> List[Tuple[sublime.Region, str]]:
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged"""
runs: List[Tuple[sublime.Region, str]] = []
for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, start + len(text))):
if scope not in colours:
colours[scope] = palette.foreground(view, scope)
colour = colours[scope]
if not colour:
continue
if runs and runs[-1][1] == colour and runs[-1][0].b == region.a:
runs[-1] = (sublime.Region(runs[-1][0].a, region.b), colour)
else:
runs.append((region, colour))
return runs
def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -> None:
"""Header, tab name, status bar and bookkeeping shared by render and render_message"""
_header(view, st, ins, dele)
view.set_name(name)
@@ -225,17 +356,18 @@ def _finish(view, st, ins, dele, name):
st.fingerprint = fingerprint(st)
def _replace(view, text):
def _replace(view: sublime.View, text: str) -> None:
view.set_read_only(False)
try:
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
finally:
view.set_read_only(True)
for key in REGION_KEYS:
# the token layer's keys vary with the palette, so they are listed rather than fixed
for key in list(REGION_KEYS) + palette.token_keys():
view.erase_regions(key)
def _apply_syntax(view, path):
def _apply_syntax(view: sublime.View, path: Optional[str]) -> None:
"""The real file's syntax, so the diff is highlighted like the file itself"""
syntax = None
if path:
@@ -246,9 +378,9 @@ def _apply_syntax(view, path):
view.assign_syntax(syntax.path if syntax else "scope:text.plain")
def _row_starts(lines):
def _row_starts(lines: Sequence[Line]) -> List[int]:
"""Text point where each line starts; computed from the text we wrote, no view round-trips"""
starts = []
starts: List[int] = []
point = _HEADER_ROWS
for line in lines:
starts.append(point)
@@ -257,9 +389,10 @@ def _row_starts(lines):
return starts
def _line_regions(lines, starts):
def _line_regions(lines: Sequence[Line],
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
"""Full-line regions of deleted and inserted rows, consecutive rows merged"""
regions = {"-": [], "+": []}
regions: Dict[str, List[sublime.Region]] = {"-": [], "+": []}
for kind, start, end in parse.runs(lines, lambda line: line.kind):
if kind in regions:
# starts has a sentinel at len(lines): the last line stops at the buffer end
@@ -267,10 +400,11 @@ def _line_regions(lines, starts):
return regions["-"], regions["+"]
def _char_regions(lines, blocks, starts):
def _char_regions(lines: Sequence[Line], blocks: Sequence[Block],
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
"""Changed characters inside paired deleted/inserted lines"""
deleted = []
inserted = []
deleted: List[sublime.Region] = []
inserted: List[sublime.Region] = []
for block in blocks:
for del_row, ins_row in parse.paired_lines(lines, block):
old_ranges, new_ranges = parse.char_ranges(lines[del_row].text, lines[ins_row].text)
@@ -279,22 +413,64 @@ def _char_regions(lines, blocks, starts):
return deleted, inserted
def header_html(st, ins, dele):
"""The minihtml header: path, counts, state, action links and the commit line"""
def _totals(files: Sequence[FileChange]) -> Tuple[int, int]:
"""Summed numstat counts; binary files carry None counts and add nothing"""
return sum(c.insertions or 0 for c in files), sum(c.deletions or 0 for c in files)
def _summary_html(st: ViewState) -> str:
"""Line above the header: the whole commit on one line, or how much of the tree is staged"""
if not st.files and not st.commit:
return ""
if st.in_commit:
text = " · ".join(_commit_parts(st))
else:
# staged means "has something staged": a partially staged file counts as staged only
staged = sum(1 for c in st.files if c.staged)
insertions, deletions = _totals(st.files)
text = (
f"{staged} staged, {len(st.files) - staged} unstaged"
f' · <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>'
)
return f'<div class="summary">{text}</div>'
def _commit_parts(st: ViewState) -> List[str]:
"""hash, author, date, totals and message — everything about the reviewed commit, in that order"""
info = st.commit
parts: List[str] = []
if info:
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
parts.append(html.escape(info.author))
parts.append(f'<span class="when">{html.escape(info.date)}</span>')
# binary files add nothing to the line totals but still count toward the file total
insertions, deletions = _totals(st.files)
parts.append(f"{file_count(len(st.files))} changed" f' <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>')
if info:
message = html.escape(info.subject)
body = info.body.strip().splitlines()
if body:
message += f' <span class="body">— {html.escape(body[0])}</span>'
parts.append(message)
return parts
def header_html(st: ViewState, ins: int, dele: int) -> str:
"""The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
change = st.change
if change is None:
title = '<span class="path">vDiff</span>'
else:
title = '<span class="path">{}</span>'.format(html.escape(change.path))
title = f'<span class="path">{html.escape(change.path)}</span>'
if change.old_path:
title += ' <span class="old">← {}</span>'.format(html.escape(change.old_path))
title += '&nbsp;&nbsp;<span class="del">-{}</span> <span class="ins">+{}</span>'.format(dele, ins)
title += f' <span class="old">← {html.escape(change.old_path)}</span>'
title += f'&nbsp;&nbsp;<span class="del">-{dele}</span> <span class="ins">+{ins}</span>'
if not st.in_commit:
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
if state:
title += '&nbsp;&nbsp;<span class="state">{}</span>'.format(state)
title += f'&nbsp;&nbsp;<span class="state">{state}</span>'
actions = []
actions: List[str] = []
if change is not None and not st.in_commit:
actions.append('<a href="stage">Stage</a>')
actions.append('<a href="unstage">Unstage</a>')
@@ -305,31 +481,21 @@ def header_html(st, ins, dele):
actions.append('<a href="commits">Commits</a>')
actions.append('<a href="help" title="keys">?</a>')
commit = ""
if st.in_commit and st.commit:
info = st.commit
commit = '<div class="commit"><span class="hash">{}</span> · {} · {} · {}'.format(
html.escape(info.short), html.escape(info.author), html.escape(info.date), html.escape(info.subject)
)
body = info.body.strip().splitlines()
if body:
commit += ' <span class="body">— {}</span>'.format(html.escape(body[0]))
commit += "</div>"
return '<body id="vdiff-header"><style>{}</style><div>{}<span class="actions">{}</span></div>{}</body>'.format(
HEADER_CSS, title, "".join(actions), commit
return (
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{_summary_html(st)}'
f'<div>{title}<span class="actions">{"".join(actions)}</span></div></body>'
)
def _run_action(view, href):
def _run_action(view: sublime.View, href: str) -> None:
"""Dispatch a header or popup link through the same command the keymap uses"""
view.run_command("vdiff_action", {"action": href})
def _header(view, st, ins, dele):
def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None:
"""One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep"""
def navigate(href):
def navigate(href: str) -> None:
_run_action(view, href)
view.erase_phantoms(PHANTOM_KEY)
@@ -339,34 +505,35 @@ def _header(view, st, ins, dele):
# --- key help popup ---------------------------------------------------------
def help_html(st):
def help_html(st: ViewState) -> str:
"""The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown"""
parts = []
parts: List[str] = []
for title, applies, group in KEY_GROUPS:
dim = st.in_commit and not applies
heading = html.escape(title)
if dim:
heading += ' <span class="note">uncommitted only</span>'
parts.append('<div class="group">{}</div>'.format(heading))
parts.append(f'<div class="group">{heading}</div>')
# one class per element: a compound selector would silently drop the rest of HELP_CSS
row_class, key_class = ("dim", "kdim") if dim else ("row", "k")
for key in group:
row = '<span class="{}">{}</span>{}{}'.format(
key_class, html.escape(key.keys), "&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2), html.escape(key.label)
row = (
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
f'{"&nbsp;" * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
)
if len(key.actions) == 1 and not dim:
row = '<a href="{}">{}</a>'.format(key.actions[0], row)
parts.append('<div class="{}">{}</div>'.format(row_class, row))
return '<body id="vdiff-help"><style>{}</style>{}</body>'.format(HELP_CSS, "".join(parts))
row = f'<a href="{key.actions[0]}">{row}</a>'
parts.append(f'<div class="{row_class}">{row}</div>')
return f'<body id="vdiff-help"><style>{HELP_CSS}</style>{"".join(parts)}</body>'
def show_help(view, st):
def show_help(view: sublime.View, st: ViewState) -> None:
"""Toggle the key list popup at the caret"""
if view.is_popup_visible():
view.hide_popup()
return
def navigate(href):
def navigate(href: str) -> None:
view.hide_popup()
_run_action(view, href)
@@ -374,47 +541,3 @@ def show_help(view, st):
em = view.em_width() or 8
view.show_popup(help_html(st), location=-1, max_width=int(em * 56), max_height=int(em * 44), on_navigate=navigate)
# --- colour scheme fallback -------------------------------------------------
_scheme_checked = set()
def ensure_scheme_scopes(view):
"""Write a scheme override with diff backgrounds when the active scheme has none (third-party schemes)"""
name = _scheme_override_name(view)
if not name or name in _scheme_checked:
return
_scheme_checked.add(name)
try:
if view.style_for_scope(SCOPE_INS).get("background"):
return
except Exception:
return
path = os.path.join(sublime.packages_path(), "User", name)
try:
scheme = {}
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
scheme = sublime.decode_value(f.read()) or {}
rules = scheme.setdefault("rules", [])
present = {rule.get("scope") for rule in rules}
rules.extend(rule for rule in FALLBACK_RULES if rule["scope"] not in present)
with open(path, "w", encoding="utf-8") as f:
json.dump(scheme, f, indent=4)
LOG.info("wrote diff scopes into colour scheme override %s", path)
except Exception:
LOG.warning("could not write colour scheme override %s", path, exc_info=True)
def _scheme_override_name(view):
"""File name in Packages/User that overrides the active scheme (same base name, .sublime-color-scheme)"""
try:
# already resolved through "auto" and dark/light settings by sublime
name = sublime.ui_info()["color_scheme"]["resolved_value"]
except Exception:
name = view.settings().get("color_scheme") or ""
if not name or name == "auto":
return None
root, _ = os.path.splitext(os.path.basename(name))
return root + ".sublime-color-scheme"

View File

@@ -1,17 +1,30 @@
""" Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore """
from collections import namedtuple
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Sequence, Tuple
if TYPE_CHECKING:
# annotation-only, so this module keeps importing neither sublime nor the rest of the package
import sublime
from .git import CommitInfo, FileChange
from .parse import Block, Line
# view setting marking a review view (keymap context "setting.vdiff_view")
VIEW_FLAG = "vdiff_view"
# view setting holding what the view shows, so a restored tab can re-render
VIEW_DATA = "vdiff"
# what a quick panel goes back to on Esc
Kept = namedtuple("Kept", "sha commit change files")
class Kept(NamedTuple):
"""What a quick panel goes back to on Esc"""
sha: Optional[str]
commit: Optional["CommitInfo"]
change: Optional["FileChange"]
files: List["FileChange"]
def find_change(files, path):
def find_change(files: Sequence["FileChange"], path: Optional[str]) -> int:
"""Index of the FileChange with `path`, or -1"""
for i, change in enumerate(files):
if change.path == path:
@@ -21,49 +34,54 @@ def find_change(files, path):
@dataclass(eq=False)
class ViewState:
"""Everything a review view needs to re-render itself and act on its file
`sha` is None in working-tree mode and the reviewed commit's hash otherwise.
"""
"""Everything a review view needs to re-render itself and act on its file"""
repo: str
sha: object = None # None in working-tree mode, the reviewed commit's hash otherwise
commit: object = None # git.CommitInfo when sha is set
change: object = None # git.FileChange currently shown
files: list = field(default_factory=list) # git.FileChange list the user is browsing (next/prev file)
lines: list = field(default_factory=list) # parse.Line list currently rendered
blocks: list = field(default_factory=list) # parse.change_blocks(lines)
sha: Optional[str] = None # None in working-tree mode, the reviewed commit's hash otherwise
commit: Optional["CommitInfo"] = None # set when sha is
change: Optional["FileChange"] = None # file currently shown
requested: Optional["FileChange"] = None # file asked for, maybe still loading; navigation counts from here
files: List["FileChange"] = field(default_factory=list) # what the user is browsing (next/prev file)
lines: List["Line"] = field(default_factory=list) # currently rendered
blocks: List["Block"] = field(default_factory=list) # parse.change_blocks(lines)
binary: bool = False
token: int = 0 # bumped per show request; a stale async result is dropped
rendered_token: int = -1 # token of the request that produced the current render
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
fingerprint: object = None # (file mtime, index mtime) at render time, working-tree mode
# (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable
fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None
@property
def in_commit(self):
def in_commit(self) -> bool:
return self.sha is not None
def index(self):
"""Position of the shown file in `files`, or -1"""
return find_change(self.files, self.change.path) if self.change else -1
def index(self) -> int:
"""Position in `files` of the file being shown or loaded, or -1
def kept(self):
`requested` leads: a diff arrives asynchronously, so counting from `change` would make n / p
step from what was on screen before the pending file landed.
"""
change = self.requested or self.change
return find_change(self.files, change.path) if change else -1
def kept(self) -> Kept:
return Kept(self.sha, self.commit, self.change, list(self.files))
def enter(self, sha=None, commit=None):
def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
kept = self.kept()
if sha != self.sha:
self.change = None
self.change = self.requested = None
self.sha, self.commit = sha, commit
return kept
def restore(self, kept):
def restore(self, kept: Kept) -> None:
self.sha, self.commit, self.change, self.files = kept
self.requested = kept.change
def persist(self, view):
def persist(self, view: "sublime.View") -> None:
"""Mirror the essentials into view settings (survives a restart)"""
data = {"repo": self.repo, "sha": self.sha}
data: Dict[str, Any] = {"repo": self.repo, "sha": self.sha}
if self.change:
data["path"] = self.change.path
data["status"] = self.change.status
@@ -71,26 +89,26 @@ class ViewState:
view.settings().set(VIEW_DATA, data)
STATES = {}
STATES: Dict[int, "ViewState"] = {}
def get(view):
def get(view: "sublime.View") -> Optional[ViewState]:
return STATES.get(view.id())
def tracked():
def tracked() -> List[Tuple[int, ViewState]]:
"""Snapshot of (view id, state) for every tracked review view"""
return list(STATES.items())
def put(view, st):
def put(view: "sublime.View", st: ViewState) -> ViewState:
STATES[view.id()] = st
return st
def drop(view):
def drop(view: "sublime.View") -> None:
STATES.pop(view.id(), None)
def is_review_view(view):
def is_review_view(view: Optional["sublime.View"]) -> bool:
return bool(view and view.settings().get(VIEW_FLAG))

View File

@@ -1,5 +1,6 @@
""" Small helpers shared across vdiff modules """
import logging
from typing import Any, Callable, Optional
import sublime
@@ -9,10 +10,10 @@ SETTINGS_FILE = "vDiff.sublime-settings"
LOG = logging.getLogger("vdiff")
_settings = None
_settings: Optional[sublime.Settings] = None
def settings():
def settings() -> sublime.Settings:
"""Cached settings handle; a sublime Settings object stays live"""
global _settings
if _settings is None:
@@ -22,20 +23,25 @@ def settings():
return _settings
def apply_git_binary():
def apply_git_binary() -> None:
"""Push the configured git executable into the (sublime-free) git module"""
git.GIT_BINARY = settings().get("git_binary") or "git"
def status(message):
def file_count(count: int) -> str:
"""'1 file' / '3 files' for a message"""
return f'{count} {"file" if count == 1 else "files"}'
def status(message: str) -> None:
"""Status-bar message with the plugin prefix"""
sublime.status_message("vDiff: {}".format(message))
sublime.status_message(f"vDiff: {message}")
def run_async(work, done):
def run_async(work: Callable[[], Any], done: Callable[[Any], None]) -> None:
"""Run `work()` on sublime's worker thread, hand the result to `done` on the main thread"""
def job():
def job() -> None:
try:
result = work()
except Exception as error: