Compare commits
8 Commits
af8c1dd6df
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe915178c5 | ||
|
|
a2900682a0 | ||
|
|
9d04d8c711 | ||
|
|
413731df47 | ||
|
|
d6f0ed3009 | ||
|
|
54c69484d5 | ||
|
|
e24922828c | ||
|
|
725d04aeed |
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal 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
|
||||
@@ -1 +1 @@
|
||||
3.8
|
||||
3.14
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["v"], "command": "vdiff_action", "args": {"action": "view"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["right"], "command": "vdiff_action", "args": {"action": "expand"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["left"], "command": "vdiff_action", "args": {"action": "collapse"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["z"], "command": "vdiff_action", "args": {"action": "fold_all"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
|
||||
|
||||
57
README.md
57
README.md
@@ -4,16 +4,49 @@ 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 |
|
||||
|---|---|
|
||||
@@ -24,9 +57,25 @@ Keys inside the review view:
|
||||
| `o` or Enter | open the real file at this line |
|
||||
| `]` / `[` | next / previous change (wraps at the ends) |
|
||||
| `n` / `p` | next / previous file of the current list (wraps at the ends) |
|
||||
| `v` | switch between the file-by-file and all-files views |
|
||||
| `left` / `right` | collapse / expand the file under the caret in the all-files view |
|
||||
| `z` | collapse all files in the all-files view, or expand them all when every one is collapsed |
|
||||
| `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 header links do the same with the mouse. The all-files view shows five context lines around each change by default; configure this with `all_files_context` in `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.
|
||||
|
||||
29
TODO.txt
29
TODO.txt
@@ -1,30 +1,19 @@
|
||||
macbook: claude --resume 275357c1-ce75-41de-a85a-4e06288bc8f0
|
||||
|
||||
macstudio: claude --resume ca7e7e5d-0ad3-45e5-bd67-e5dbd3af1b4c
|
||||
## Bugs
|
||||
- In Commits list commit message can be too long to move changes stats too far to the right so its not visible or is partially visible only
|
||||
|
||||
|
||||
## New functionality
|
||||
- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?)
|
||||
|
||||
Add amend action. Similar to commit but commit --amend. When user has chosen amend open commit message input field with last commit message and cursor on the end of message and user can change commit message or confirm it by pressing Enter. When User presses Esc the commmit amend is canceled.
|
||||
Amend action is available under key "m"
|
||||
|
||||
|
||||
|
||||
- in Files in first line there is 2 staged, 2 unstaged - add there deletions and insertions
|
||||
|
||||
- Add pull and push action
|
||||
- Add tags into commits list (and branches?)
|
||||
- Add stash action ..
|
||||
|
||||
- Add (commit) summary view (just changes across files with 5 lines before and after change?)
|
||||
|
||||
## Improvements
|
||||
- commit messages are oneline. What are options for multiline editing?
|
||||
- visual bell blinking time setting
|
||||
|
||||
- When reviewing changes in python files the deleted and inserted lines have red and green background but the text on these lines has no syntax highlighting (it's white on darker theme). How difficult would be to add syntax highlighting to changed lines and keep red and green background?
|
||||
(tady full syntax coloring je hard, nabizi se barevnej gutter, nebo barevne podtrzeni)
|
||||
|
||||
- v detailu komitu when pressing n and p the second line is nonsence
|
||||
|
||||
- add type hints
|
||||
|
||||
- add tags into commits list (and branches?)
|
||||
|
||||
- commit messages are oneline. What are options for multiline editing?
|
||||
|
||||
## Code
|
||||
|
||||
10
main.py
10
main.py
@@ -1,18 +1,17 @@
|
||||
""" vDiff plugin entry point: review uncommitted changes and recent commits inside Sublime Text
|
||||
"""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
|
||||
|
||||
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.
|
||||
# drop the submodules so an edited one is re-imported; open views are rebuilt from settings by _restore_all
|
||||
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
|
||||
del sys.modules[_name]
|
||||
|
||||
# imported for sublime's command discovery, not called here
|
||||
# command and listener classes are imported for sublime's discovery
|
||||
from .vdiff.commands import (
|
||||
VdiffActionCommand,
|
||||
VdiffCommitsCommand,
|
||||
@@ -35,6 +34,7 @@ def plugin_loaded():
|
||||
|
||||
|
||||
def _restore_all():
|
||||
"""Rebuild every review view, in every window, that has no in-memory state yet"""
|
||||
for window in sublime.windows():
|
||||
for view in window.views():
|
||||
if state.is_review_view(view) and state.get(view) is None:
|
||||
|
||||
21
repository.json
Normal file
21
repository.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +36,10 @@ class Region:
|
||||
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):
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, key, default)
|
||||
@@ -109,6 +114,28 @@ class Window:
|
||||
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"""
|
||||
|
||||
@@ -125,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
|
||||
@@ -193,9 +224,19 @@ class View:
|
||||
def set_status(self, key, value):
|
||||
self.status[key] = value
|
||||
|
||||
def window(self):
|
||||
# detached: the all-files view then skips tokenising in a hidden panel
|
||||
return None
|
||||
|
||||
def viewport_position(self):
|
||||
return (0, 0)
|
||||
|
||||
def set_viewport_position(self, xy, animate=True):
|
||||
pass
|
||||
|
||||
def show(self, point):
|
||||
pass
|
||||
|
||||
def show_at_center(self, point):
|
||||
pass
|
||||
|
||||
@@ -211,8 +252,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
|
||||
|
||||
@@ -329,8 +329,8 @@ def test_commit_items_fall_back_for_an_empty_subject():
|
||||
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, which is why the real plugin's async window - the one
|
||||
n / p used to read stale state through - is invisible to every other test in the suite.
|
||||
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)))
|
||||
@@ -522,8 +522,8 @@ def test_a_file_without_changes_neither_moves_nor_rings(rings):
|
||||
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 when the table was built, so the subprocess is stubbed
|
||||
one level down, at git.run; that keeps the real wiring of the action under test.
|
||||
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,): "")
|
||||
@@ -583,3 +583,90 @@ def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
|
||||
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"]
|
||||
|
||||
|
||||
def all_files_view():
|
||||
"""An all-files view of a.py (two hunks) and b.py (one), rendered with the caret at the top"""
|
||||
from vdiff import parse
|
||||
lines_a = [parse.Line(" ", i, i, f"a{i}") for i in range(1, 30)]
|
||||
lines_a[2] = parse.Line("+", None, 3, "new a3")
|
||||
lines_a[25] = parse.Line("+", None, 26, "new a26")
|
||||
lines_b = [parse.Line(" ", 1, 1, "b1"), parse.Line("-", 2, None, "old b2")]
|
||||
view = sublime.View()
|
||||
st = state.ViewState("/repo")
|
||||
st.all_mode = True
|
||||
st.files = [change("a.py"), change("b.py")]
|
||||
st.all_diffs = {path: (lines, parse.change_blocks(lines), False)
|
||||
for path, lines in (("a.py", lines_a), ("b.py", lines_b))}
|
||||
commands.render.render_all(view, st)
|
||||
return view, st
|
||||
|
||||
|
||||
def heading_row(st, path):
|
||||
return next(i for i, (c, _, section) in enumerate(st.all_rows) if section and c.path == path)
|
||||
|
||||
|
||||
def put_caret(view, row):
|
||||
view.sel().clear()
|
||||
view.sel().add(sublime.Region(commands.render.index_point(view, row)))
|
||||
|
||||
|
||||
def test_all_files_step_change_from_a_heading_lands_on_its_first_hunk():
|
||||
view, st = all_files_view()
|
||||
rows = dict(zip(st.all_change_row_targets, st.all_change_rows))
|
||||
|
||||
# from the top, outside any file: the very first hunk, not the second
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == rows[("a.py", 0)]
|
||||
|
||||
# from a.py's heading: its first hunk
|
||||
put_caret(view, heading_row(st, "a.py"))
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == rows[("a.py", 0)]
|
||||
|
||||
# from b.py's heading back: the hunk before b.py's first, a.py's last
|
||||
put_caret(view, heading_row(st, "b.py"))
|
||||
commands.ACTIONS["prev_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == rows[("a.py", 1)]
|
||||
|
||||
# from a hunk: the next one, across files
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == rows[("b.py", 0)]
|
||||
|
||||
|
||||
def test_arrows_do_nothing_in_the_single_file_view():
|
||||
view, st = at_change([(2, 4)], 2)
|
||||
st.change = change("a.py")
|
||||
commands.ACTIONS["collapse"](view, None, st)
|
||||
assert st.collapsed == set() and not st.all_mode and st.all_rows == []
|
||||
|
||||
|
||||
def test_git_action_refresh_stays_in_the_all_files_view(monkeypatch):
|
||||
view, st = all_files_view()
|
||||
st.change = st.files[1]
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py"), change("b.py")])
|
||||
shown = []
|
||||
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(focus))
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(("file", c.path)))
|
||||
commands.refresh_listing(view, st, pick=None)
|
||||
assert shown == ["b.py"] and st.change.path == "b.py"
|
||||
|
||||
|
||||
def test_all_files_step_file_from_nothing_and_with_no_files():
|
||||
view, st = all_files_view()
|
||||
commands.ACTIONS["next_file"](view, None, st)
|
||||
assert st.change.path == "a.py"
|
||||
st.change = None
|
||||
put_caret(view, 0)
|
||||
commands.ACTIONS["prev_file"](view, None, st)
|
||||
assert st.change.path == "b.py"
|
||||
st.files = []
|
||||
commands.ACTIONS["next_file"](view, None, st) # no ZeroDivisionError
|
||||
|
||||
|
||||
def test_all_files_context_zero_shows_only_changed_lines(monkeypatch):
|
||||
option(monkeypatch, "all_files_context", 0)
|
||||
view, st = all_files_view()
|
||||
shown = [line for _, line, _ in st.all_rows if line is not None]
|
||||
assert shown and all(line.kind != " " for line in shown)
|
||||
|
||||
86
tests/test_palette.py
Normal file
86
tests/test_palette.py
Normal 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()
|
||||
|
||||
|
||||
@@ -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,13 +34,33 @@ 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)
|
||||
|
||||
assert view.text.split("\n") == ["", "def f():", " return 1", "", " return 10", " # added", " # more", "", "def g():", " pass"]
|
||||
assert view.text.split("\n") == [
|
||||
"",
|
||||
"def f():",
|
||||
" return 1",
|
||||
"",
|
||||
" return 10",
|
||||
" # added",
|
||||
" # more",
|
||||
"",
|
||||
"def g():",
|
||||
" pass",
|
||||
]
|
||||
assert view.is_read_only()
|
||||
assert view.syntax.endswith("Python.sublime-syntax")
|
||||
|
||||
@@ -132,9 +152,9 @@ def test_help_popup_lists_every_key():
|
||||
view, html = help_popup(st)
|
||||
|
||||
assert view.is_popup_visible()
|
||||
for group in ("Navigate", "Panels", "Actions", "View"):
|
||||
for group in ("Navigate", "Actions", "View"):
|
||||
assert f">{group}<" in html
|
||||
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "?", "esc"):
|
||||
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "z", "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
|
||||
@@ -142,8 +162,8 @@ def test_help_popup_lists_every_key():
|
||||
assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html
|
||||
# rows covering two actions stay plain text
|
||||
assert 'href="next_change"' not in html
|
||||
# the status bar advertises it too
|
||||
assert "? keys" in view.status[render.STATUS_KEY]
|
||||
# the popup does not list the key that opens it
|
||||
assert ">?<" not in html
|
||||
|
||||
|
||||
def test_help_popup_dims_file_actions_in_commit_mode():
|
||||
@@ -190,18 +210,19 @@ def test_keymap_matches_help_table():
|
||||
assert all(b["command"] == "vdiff_action" for b in bindings)
|
||||
bound = {b["args"]["action"] for b in bindings}
|
||||
listed = {action for k in render.KEYS for action in k.actions}
|
||||
assert bound == set(commands.ACTIONS) == listed
|
||||
# "?" opens the popup, so the popup leaves it out
|
||||
assert bound == set(commands.ACTIONS) == listed | {"help"}
|
||||
|
||||
# rebinding a key without updating the table would leave the popup and status bar lying
|
||||
bound_keys = {DISPLAY_KEYS.get(key, key) for b in bindings for key in b["keys"]}
|
||||
assert bound_keys == {token for k in render.KEYS for token in k.keys.split()}
|
||||
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 the view in command mode; a binding that still carried the old
|
||||
`command_mode: false` context would go dead there, which is what broke n / p.
|
||||
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():
|
||||
@@ -212,8 +233,7 @@ def test_no_binding_yields_to_vintage_command_mode():
|
||||
|
||||
|
||||
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)]
|
||||
@@ -316,9 +336,97 @@ def test_flash_survives_a_re_render(monkeypatch):
|
||||
assert render.BELL_KEY in view.regions
|
||||
|
||||
|
||||
def test_bell_scope_falls_back_when_the_scheme_lacks_it(monkeypatch):
|
||||
view = sublime.View()
|
||||
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
|
||||
monkeypatch.setattr(view, "style_for_scope", lambda scope: {"foreground": "#fff"})
|
||||
# an invisible bell is a silent failure, so fall back to a scope the plugin guarantees
|
||||
assert render._bell_scope(view) == render.SCOPE_INS
|
||||
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]
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
// mark the changed characters inside paired deleted/inserted lines
|
||||
"word_diff": true,
|
||||
|
||||
// number of unchanged lines shown around each change in the all-files view
|
||||
"all_files_context": 5,
|
||||
|
||||
// git executable; sublime's PATH may not include a user-installed git
|
||||
"git_binary": "git",
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
""" Commands: the two quick panels (changed files, recent commits) and the actions of a review view """
|
||||
"""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 .git import Commit, FileChange
|
||||
from .state import Kept, ViewState
|
||||
from .utils import file_count, run_async, settings, status
|
||||
|
||||
# an ACTIONS entry: act(view, window, state) on the review view it 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,30 +27,33 @@ STATUS_COLOURS = {
|
||||
}
|
||||
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
|
||||
|
||||
# a quick-panel row draws the annotation hard right on the subject's line; past this budget the
|
||||
# subject would run into it, so it is clipped. The floor keeps a readable stub on a narrow panel.
|
||||
# row width shared by subject and annotation; the subject is clipped to fit, never below MIN_SUBJECT_WIDTH
|
||||
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 = {}
|
||||
# cache directory -> repo root: resolve_repo runs git on the UI thread
|
||||
_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] = []
|
||||
# a review view already knows its repo; a file's directory is tried before the project folders
|
||||
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())
|
||||
# first candidate in a repo wins; only hits are cached
|
||||
for directory in candidates:
|
||||
root = _roots.get(directory)
|
||||
if root is None:
|
||||
@@ -56,15 +65,16 @@ def resolve_repo(window):
|
||||
return None
|
||||
|
||||
|
||||
def find_review_view(window):
|
||||
def find_review_view(window: sublime.Window) -> Optional[sublime.View]:
|
||||
"""The window's first review view, if it has one"""
|
||||
for view in window.views():
|
||||
if state.is_review_view(view):
|
||||
return view
|
||||
return None
|
||||
|
||||
|
||||
def open_review(window):
|
||||
"""(view, state) of the window's single review view for the resolved repo, focused; None without a repo"""
|
||||
def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]:
|
||||
"""Focus the window's review view (created if missing) on the resolved repo; (view, state), or None without a repo"""
|
||||
repo = resolve_repo(window)
|
||||
if not repo:
|
||||
status("no git repository for the active file or project folders")
|
||||
@@ -81,22 +91,48 @@ def open_review(window):
|
||||
return view, st
|
||||
|
||||
|
||||
def show_file(view, st, change):
|
||||
def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) -> None:
|
||||
"""Load all file diffs off the UI thread and switch to the aggregate view, on `focus`'s heading if given"""
|
||||
st.all_mode = True
|
||||
# bump the token so earlier loads drop their results
|
||||
st.token += 1
|
||||
token, files = st.token, list(st.files)
|
||||
def work() -> Dict[str, Tuple[List[parse.Line], List[parse.Block], bool]]:
|
||||
"""Parse every listed file's diff and its change blocks, keyed by path"""
|
||||
result = {}
|
||||
for change in files:
|
||||
diff = parse.parse_unified(git.full_diff(st.repo, change.path, sha=st.sha, untracked=(change.status == "?")))
|
||||
result[change.path] = (diff.lines, parse.change_blocks(diff.lines), diff.binary)
|
||||
return result
|
||||
def done(result: Dict[str, Tuple[List[parse.Line], List[parse.Block], bool]]) -> None:
|
||||
"""Store the diffs and draw the aggregate view unless superseded or closed"""
|
||||
if token == st.token and view.is_valid():
|
||||
st.set_all_diffs(result)
|
||||
if st.change is None and files:
|
||||
st.change = files[0]
|
||||
render.render_all(view, st, focus=focus)
|
||||
run_async(work, done)
|
||||
|
||||
|
||||
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.all_mode = False
|
||||
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]]]:
|
||||
"""Run git diff and parse it, unless a newer request already superseded this one"""
|
||||
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:
|
||||
"""Make the file current and render it if this is still the latest request"""
|
||||
if result is None or token != st.token or not view.is_valid():
|
||||
return
|
||||
diff, blocks = result
|
||||
@@ -108,8 +144,9 @@ def show_file(view, st, change):
|
||||
run_async(work, done)
|
||||
|
||||
|
||||
def file_items(files):
|
||||
items = []
|
||||
def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
||||
"""Quick-panel rows: path; status, staged flag, rename source; line totals or binary"""
|
||||
items: List[sublime.QuickPanelItem] = []
|
||||
for change in files:
|
||||
description = git.STATUS_NAMES.get(change.status, "")
|
||||
colour = STATUS_COLOURS.get(change.status)
|
||||
@@ -117,6 +154,7 @@ def file_items(files):
|
||||
details = ", ".join(filter(None, (description, "staged" if change.staged else "")))
|
||||
if change.old_path:
|
||||
details += " ← " + change.old_path
|
||||
# no line counts means git saw a binary file
|
||||
if None in (change.insertions, change.deletions):
|
||||
annotation = "binary"
|
||||
else:
|
||||
@@ -125,14 +163,15 @@ def file_items(files):
|
||||
return items
|
||||
|
||||
|
||||
def commit_items(commits):
|
||||
"""Quick-panel rows: subject on the left, its totals and the hash right-aligned"""
|
||||
items = []
|
||||
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
||||
"""Quick-panel rows: subject, author · date, and diffstat plus short hash right-aligned"""
|
||||
items: List[sublime.QuickPanelItem] = []
|
||||
for c in commits:
|
||||
# merges and empty commits have no diffstat, so they show the hash alone
|
||||
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}"
|
||||
# clip the subject so the annotation keeps its room
|
||||
subject = c.subject or "(no subject)"
|
||||
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
|
||||
if len(subject) > budget:
|
||||
@@ -141,20 +180,24 @@ def commit_items(commits):
|
||||
return items
|
||||
|
||||
|
||||
def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder="Changed files"):
|
||||
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
|
||||
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`: highlight previews, Enter shows, Esc restores `kept` (then runs on_cancel)"""
|
||||
# the panel opens on the shown file, the view focused behind it
|
||||
st.files = files
|
||||
selected = max(0, st.index())
|
||||
window.focus_view(view)
|
||||
|
||||
def on_select(index):
|
||||
def on_select(index: int) -> None:
|
||||
"""Enter shows the chosen file; Esc restores `kept`, then runs on_cancel if given"""
|
||||
st.highlight += 1 # cancel a pending preview
|
||||
if index == -1:
|
||||
if on_cancel is not None:
|
||||
st.restore(kept)
|
||||
on_cancel()
|
||||
elif kept.change is not None:
|
||||
# go back to the file that was kept before the panel opened
|
||||
# reshow the kept file
|
||||
st.restore(kept)
|
||||
show_file(view, st, kept.change)
|
||||
# nothing was kept before: the last previewed file stays
|
||||
@@ -164,28 +207,37 @@ 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:
|
||||
"""Schedule a debounced preview of the highlighted file"""
|
||||
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:
|
||||
"""Show the file unless another highlight came since"""
|
||||
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, pick=None):
|
||||
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 to show instead; `index` is where the shown file
|
||||
sits in the new list, or the slot it left behind when it is gone.
|
||||
`pick(files, index)` may choose another file; `index` is the shown file's new slot, or its old one if it left the list
|
||||
"""
|
||||
if message:
|
||||
status(message)
|
||||
# remember the shown file and its slot before the list changes
|
||||
previous_index = max(0, st.index())
|
||||
path = st.change.path if st.change else None
|
||||
|
||||
def done(files):
|
||||
def done(files: List[FileChange]) -> None:
|
||||
"""Show the same file again, its slot's successor, or what `pick` chooses"""
|
||||
if not view.is_valid():
|
||||
return
|
||||
st.files = files
|
||||
@@ -197,12 +249,18 @@ def refresh_listing(view, st, message=None, pick=None):
|
||||
# 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])
|
||||
change = chosen if chosen is not None else files[index]
|
||||
if st.all_mode:
|
||||
# stay in the aggregate view, re-diffed over the new list, on the same file
|
||||
st.change = change
|
||||
show_all(view, st, focus=change.path)
|
||||
else:
|
||||
show_file(view, st, change)
|
||||
|
||||
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
|
||||
@@ -213,14 +271,16 @@ 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:
|
||||
"""Open the review view, then list the changed files off the UI thread"""
|
||||
window = self.window
|
||||
opened = open_review(window)
|
||||
if not opened:
|
||||
return
|
||||
view, st = opened
|
||||
|
||||
def done(files):
|
||||
def done(files: List[FileChange]) -> None:
|
||||
"""Show the files panel, or a notice when there is nothing uncommitted"""
|
||||
if not view.is_valid():
|
||||
return
|
||||
kept = st.enter()
|
||||
@@ -237,7 +297,8 @@ 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:
|
||||
"""Open the review view, then the given commit or the recent-commits panel"""
|
||||
opened = open_review(self.window)
|
||||
if not opened:
|
||||
return
|
||||
@@ -247,7 +308,8 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||
return
|
||||
count = int(settings().get("commits_count", 30) or 30)
|
||||
|
||||
def done(commits):
|
||||
def done(commits: List[Commit]) -> None:
|
||||
"""Show the commits panel unless the repository has none"""
|
||||
if not view.is_valid():
|
||||
return
|
||||
if not commits:
|
||||
@@ -257,23 +319,26 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||
|
||||
run_async(lambda: git.recent_commits(st.repo, count), done)
|
||||
|
||||
def show_commits(self, view, st, commits, selected):
|
||||
def show_commits(self, view: sublime.View, st: ViewState, commits: Sequence[Commit], selected: int) -> None:
|
||||
"""Recent-commits panel, preselecting the commit already shown"""
|
||||
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:
|
||||
"""Open the chosen commit's files, remembering its row for Esc"""
|
||||
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):
|
||||
"""Files panel of one commit; Esc goes back to the commit list when it came from there (index given)"""
|
||||
def open_commit(self, view: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None:
|
||||
"""Files panel of one commit; with `index`, Esc reopens the commit list on that row"""
|
||||
window = self.window
|
||||
|
||||
def done(result):
|
||||
def done(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
||||
"""Enter the commit and show its files panel, or a notice when it touches none"""
|
||||
if not view.is_valid():
|
||||
return
|
||||
info, files = result
|
||||
@@ -282,7 +347,8 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||
render.render_message(view, st, f"Commit {info.short} touches no files")
|
||||
return
|
||||
|
||||
def back():
|
||||
def back() -> None:
|
||||
"""Reopen the commit list on the row this commit came from"""
|
||||
window.run_command("vdiff_commits", {"selected": index})
|
||||
|
||||
placeholder = f"{info.short} · {info.subject}"
|
||||
@@ -295,33 +361,37 @@ 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:
|
||||
"""Open the changed-files panel"""
|
||||
window.run_command("vdiff_files")
|
||||
|
||||
|
||||
def _act_commits(view, window, st):
|
||||
def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Open the recent-commits panel"""
|
||||
window.run_command("vdiff_commits")
|
||||
|
||||
|
||||
def _act_close(view, window, st):
|
||||
def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Close the review view"""
|
||||
view.close()
|
||||
|
||||
|
||||
def _act_help(view, window, st):
|
||||
def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Show the help popup"""
|
||||
render.show_help(view, st)
|
||||
|
||||
|
||||
def _git_action(verb, done_verb, operation, confirm=None, pick=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:
|
||||
"""Action running `operation(repo, change)` on the current working-tree file, then re-listing; `pick` is passed to refresh_listing"""
|
||||
|
||||
`pick` is handed to refresh_listing to choose which file to show afterwards.
|
||||
"""
|
||||
|
||||
def act(view, window, st):
|
||||
if st.in_commit or st.change is None:
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Confirm if asked, run the operation off the UI thread, then refresh the listing"""
|
||||
change = _current_change(view, st)
|
||||
if st.in_commit or change is None:
|
||||
status(f"{verb} works on uncommitted changes only")
|
||||
return
|
||||
change = st.change
|
||||
if confirm is not None and not confirm(change):
|
||||
return
|
||||
run_async(
|
||||
@@ -332,9 +402,11 @@ def _git_action(verb, done_verb, operation, confirm=None, pick=None):
|
||||
return act
|
||||
|
||||
|
||||
def _confirm_discard(change):
|
||||
def _confirm_discard(change: FileChange) -> bool:
|
||||
"""Ask before discarding or deleting, unless the confirm_discard setting is off"""
|
||||
if not settings().get("confirm_discard", True):
|
||||
return True
|
||||
# untracked files are deleted outright, tracked ones reset
|
||||
if change.status == "?":
|
||||
message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
|
||||
else:
|
||||
@@ -345,7 +417,7 @@ def _confirm_discard(change):
|
||||
return sublime.ok_cancel_dialog(message, "Discard")
|
||||
|
||||
|
||||
def _next_unstaged(files, start):
|
||||
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)
|
||||
@@ -354,16 +426,20 @@ def _next_unstaged(files, start):
|
||||
return -1
|
||||
|
||||
|
||||
def _stage_next(files, index):
|
||||
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
|
||||
# scanning from the staged file's slot is safe: it is skipped as staged
|
||||
following = _next_unstaged(files, index)
|
||||
return files[following] if following >= 0 else None
|
||||
|
||||
|
||||
def _act_refresh(view, window, st):
|
||||
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Reload what is shown: all diffs, the commit's file, or the working-tree listing"""
|
||||
if st.all_mode:
|
||||
show_all(view, st)
|
||||
return
|
||||
if st.change is None:
|
||||
return
|
||||
if st.in_commit:
|
||||
@@ -372,26 +448,92 @@ def _act_refresh(view, window, st):
|
||||
refresh_listing(view, st)
|
||||
|
||||
|
||||
def _act_open(view, window, st):
|
||||
if st.change is None:
|
||||
def _current_change(view: sublime.View, st: ViewState) -> Optional[FileChange]:
|
||||
"""File under the caret in the aggregate view (made current), else the shown file"""
|
||||
if st.all_mode:
|
||||
change = render.all_change_at(view, st)
|
||||
if change is not None:
|
||||
st.change = change
|
||||
return st.change
|
||||
|
||||
|
||||
def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Open the working-tree file at the line under the caret"""
|
||||
change = _current_change(view, st)
|
||||
if change is None:
|
||||
return
|
||||
path = os.path.join(st.repo, st.change.path)
|
||||
path = os.path.join(st.repo, change.path)
|
||||
if not os.path.exists(path):
|
||||
status(f"{st.change.path} does not exist in the working tree")
|
||||
status(f"{change.path} does not exist in the working tree")
|
||||
return
|
||||
# map the caret row to a line number of the new side
|
||||
if st.all_mode:
|
||||
row = render.caret_index(view)
|
||||
displayed = st.all_rows[row][1] if 0 <= row < len(st.all_rows) else None
|
||||
line = displayed.new_no if displayed and displayed.new_no is not None else 1
|
||||
else:
|
||||
line = parse.line_for_open(st.lines, render.caret_index(view))
|
||||
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
|
||||
|
||||
|
||||
def _step_file(delta):
|
||||
def act(view, window, st):
|
||||
def _act_view(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Switch between the single-file and the aggregate view"""
|
||||
if st.all_mode:
|
||||
st.all_mode = False
|
||||
if st.change is not None:
|
||||
show_file(view, st, st.change)
|
||||
else:
|
||||
show_all(view, st)
|
||||
|
||||
|
||||
def _toggle_file(delta: int) -> Action:
|
||||
"""Action expanding (delta > 0) or collapsing the caret's file in the aggregate view"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Expand or collapse the file under the caret and redraw focused on it"""
|
||||
if not st.all_mode:
|
||||
return
|
||||
change = _current_change(view, st)
|
||||
if change is not None:
|
||||
(st.collapsed.discard if delta > 0 else st.collapsed.add)(change.path)
|
||||
render.render_all(view, st, focus=change.path)
|
||||
return act
|
||||
|
||||
|
||||
def _act_fold_all(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Collapse every file while any is expanded, otherwise expand them all"""
|
||||
if not st.all_mode:
|
||||
return
|
||||
change = _current_change(view, st)
|
||||
paths = {c.path for c in st.files}
|
||||
st.collapsed = set() if st.collapsed >= paths else paths
|
||||
render.render_all(view, st, focus=change.path if change else None)
|
||||
|
||||
|
||||
def _step_file(delta: int) -> Action:
|
||||
"""Action moving `delta` files through the list, wrapping round"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Move to the neighbouring file: scroll to it in the aggregate view, else show its diff"""
|
||||
# aggregate view: unfold the neighbouring file and scroll to it
|
||||
if st.all_mode:
|
||||
if not st.files:
|
||||
status("no files to step through")
|
||||
return
|
||||
change = _current_change(view, st)
|
||||
index = state.find_change(st.files, change.path if change else None)
|
||||
# nothing selected yet: n starts at the first file, p at the last
|
||||
index = (index + delta) % len(st.files) if index >= 0 else (0 if delta > 0 else len(st.files) - 1)
|
||||
st.change = st.files[index]
|
||||
st.collapsed.discard(st.change.path)
|
||||
render.render_all(view, st, focus=st.change.path, center=True)
|
||||
return
|
||||
# single-file view: show the neighbouring file, ringing the bell on a wrap
|
||||
if not st.files:
|
||||
status("no file list, press f or c")
|
||||
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.
|
||||
# nothing shown yet: n starts at the first file, p at the last, no wrap
|
||||
index, wrapped = (0 if delta > 0 else count - 1), False
|
||||
else:
|
||||
index = position + delta
|
||||
@@ -406,8 +548,43 @@ def _step_file(delta):
|
||||
return act
|
||||
|
||||
|
||||
def _step_change(delta):
|
||||
def act(view, window, st):
|
||||
def _step_change(delta: int) -> Action:
|
||||
"""Action moving the caret `delta` changes on, wrapping round"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Jump to the next or previous change, across files in the aggregate view"""
|
||||
if st.all_mode:
|
||||
# aggregate view: the hunks of all files are the targets
|
||||
current_change = _current_change(view, st)
|
||||
rows = st.all_change_rows
|
||||
targets = st.all_change_targets
|
||||
if not targets:
|
||||
return
|
||||
current = render.caret_index(view)
|
||||
current_path = current_change.path if current_change else None
|
||||
# anchor: the caret file's last shown hunk at or above the caret
|
||||
current_target = None
|
||||
for row, target in zip(rows, st.all_change_row_targets):
|
||||
if target[0] == current_path and row <= current:
|
||||
current_target = target
|
||||
if current_target in targets:
|
||||
# step from the anchor, wrapping round
|
||||
index = targets.index(current_target) + delta
|
||||
else:
|
||||
# above the file's first hunk (heading, folded) or outside any file: its first hunk
|
||||
# is next and the one before it previous; outside a file, the list's first or last
|
||||
first = next((i for i, t in enumerate(targets) if t[0] == current_path), None)
|
||||
if first is None:
|
||||
index = 0 if delta > 0 else -1
|
||||
else:
|
||||
index = first if delta > 0 else first - 1
|
||||
target = targets[index % len(targets)]
|
||||
# make the target's file current, unfold it and centre on the hunk
|
||||
target_change = next(c for c in st.files if c.path == target[0])
|
||||
st.change = target_change
|
||||
st.collapsed.discard(target_change.path)
|
||||
render.render_all(view, st, focus=target, center=True)
|
||||
return
|
||||
# single-file view: step between change starts from the caret row
|
||||
starts = [start for start, _ in st.blocks]
|
||||
if not starts:
|
||||
status("no changes to step through")
|
||||
@@ -417,6 +594,7 @@ def _step_change(delta):
|
||||
if not 0 <= position < len(starts):
|
||||
render.flash(view)
|
||||
position %= len(starts) # -1 is the last change, len(starts) the first
|
||||
# move the caret onto the change and centre it
|
||||
point = render.index_point(view, starts[position])
|
||||
view.sel().clear()
|
||||
view.sel().add(sublime.Region(point))
|
||||
@@ -425,7 +603,7 @@ def _step_change(delta):
|
||||
return act
|
||||
|
||||
|
||||
def _act_commit(view, window, st):
|
||||
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")
|
||||
@@ -435,7 +613,8 @@ def _act_commit(view, window, st):
|
||||
status("no staged files")
|
||||
return
|
||||
|
||||
def on_done(message):
|
||||
def on_done(message: str) -> None:
|
||||
"""Commit with the typed message off the UI thread, or cancel on an empty one"""
|
||||
if not message.strip():
|
||||
status("commit cancelled: empty message")
|
||||
return
|
||||
@@ -447,21 +626,22 @@ def _act_commit(view, window, st):
|
||||
window.show_input_panel("Commit message", "", on_done, None, None)
|
||||
|
||||
|
||||
def _act_amend(view, window, st):
|
||||
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 panel is one line, so only the subject is offered; the rest of the message rides along
|
||||
verbatim. Nothing staged is fine - then the amend is a pure reword.
|
||||
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):
|
||||
def prompt(message: str) -> None:
|
||||
"""Show the input panel prefilled with HEAD's subject"""
|
||||
subject, newline, body = message.partition("\n")
|
||||
|
||||
def on_done(text):
|
||||
def on_done(text: str) -> None:
|
||||
"""Amend with the typed subject and the original body, or cancel on an empty one"""
|
||||
if not text.strip():
|
||||
status("amend cancelled: empty message")
|
||||
return
|
||||
@@ -469,7 +649,7 @@ def _act_amend(view, window, st):
|
||||
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
|
||||
# a reword needs --allow-empty when HEAD itself is an empty commit
|
||||
lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged),
|
||||
lambda _: refresh_listing(view, st, done),
|
||||
)
|
||||
@@ -482,14 +662,16 @@ def _act_amend(view, window, st):
|
||||
run_async(lambda: git.head_message(st.repo), prompt)
|
||||
|
||||
|
||||
def _move_line(forward):
|
||||
def act(view, window, st):
|
||||
def _move_line(forward: bool) -> Action:
|
||||
"""Action moving the caret one line down (forward) or up"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Move the caret one line"""
|
||||
view.run_command("move", {"by": "lines", "forward": forward})
|
||||
|
||||
return act
|
||||
|
||||
|
||||
ACTIONS = {
|
||||
ACTIONS: Dict[str, Action] = {
|
||||
"files": _act_files,
|
||||
"commits": _act_commits,
|
||||
"close": _act_close,
|
||||
@@ -500,6 +682,10 @@ ACTIONS = {
|
||||
"commit": _act_commit,
|
||||
"amend": _act_amend,
|
||||
"refresh": _act_refresh,
|
||||
"view": _act_view,
|
||||
"expand": _toggle_file(1),
|
||||
"collapse": _toggle_file(-1),
|
||||
"fold_all": _act_fold_all,
|
||||
"open": _act_open,
|
||||
"next_file": _step_file(1),
|
||||
"prev_file": _step_file(-1),
|
||||
@@ -513,10 +699,12 @@ ACTIONS = {
|
||||
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:
|
||||
"""Only in review views"""
|
||||
return state.is_review_view(self.view)
|
||||
|
||||
def run(self, edit, action):
|
||||
def run(self, edit: sublime.Edit, action: str) -> None:
|
||||
"""Run the named action with the view's state, rebuilt if lost (e.g. after a restart)"""
|
||||
act = ACTIONS.get(action)
|
||||
if act is None:
|
||||
status(f"unknown action {action}")
|
||||
@@ -525,39 +713,55 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
|
||||
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"""
|
||||
# the repo, commit and file saved with the view
|
||||
data = view.settings().get(state.VIEW_DATA) or {}
|
||||
repo = data.get("repo")
|
||||
render.init_view(view)
|
||||
# repository gone: show a notice
|
||||
if not repo or not os.path.isdir(repo):
|
||||
st = state.put(view, state.ViewState(""))
|
||||
render.render_message(view, st, "Repository not found. Press f for changed files, c for commits.")
|
||||
return st
|
||||
st = state.put(view, state.ViewState(repo))
|
||||
st.sha = data.get("sha")
|
||||
st.all_mode = bool(data.get("all_mode", False))
|
||||
path = data.get("path")
|
||||
if not path:
|
||||
render.render_message(view, st, "Press f for changed files, c for commits.")
|
||||
return st
|
||||
# stand-in FileChange: no line counts or rename source
|
||||
change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None)
|
||||
|
||||
if st.in_commit:
|
||||
# a commit view re-lists the commit's files, a working-tree view the uncommitted changes
|
||||
sha = st.sha
|
||||
if sha is not None:
|
||||
|
||||
def done(result):
|
||||
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
||||
"""Adopt the commit's files and redraw the saved file or the aggregate view"""
|
||||
st.commit, st.files = result
|
||||
if st.all_mode:
|
||||
st.change = change
|
||||
show_all(view, st)
|
||||
else:
|
||||
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:
|
||||
"""Redraw the saved file if it still has changes, else say it has none"""
|
||||
st.files = files
|
||||
index = state.find_change(files, path)
|
||||
if index >= 0:
|
||||
if st.all_mode:
|
||||
st.change = files[index]
|
||||
show_all(view, st)
|
||||
else:
|
||||
show_file(view, st, files[index])
|
||||
else:
|
||||
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
|
||||
|
||||
143
vdiff/git.py
143
vdiff/git.py
@@ -1,14 +1,47 @@
|
||||
""" Git access: every call is one subprocess; results are plain tuples, no sublime imports (testable) """
|
||||
"""Git access via 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")
|
||||
# shortstat totals; all three are None when git prints no diffstat (merges, empty commits)
|
||||
Commit = namedtuple("Commit", "sha short author date subject files insertions deletions")
|
||||
CommitInfo = namedtuple("CommitInfo", "sha short author date subject body")
|
||||
|
||||
class FileChange(NamedTuple):
|
||||
"""Path, one-letter status (see STATUS_NAMES), 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):
|
||||
"""One log entry, date relative; files/insertions/deletions are None without a 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):
|
||||
"""One commit: its hashes, author, date, subject and body"""
|
||||
|
||||
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"
|
||||
@@ -17,9 +50,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",
|
||||
@@ -38,16 +71,17 @@ _SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(
|
||||
|
||||
|
||||
class GitError(Exception):
|
||||
"""git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it"""
|
||||
"""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:
|
||||
"""Keep stderr and the exit code"""
|
||||
self.stderr = stderr
|
||||
self.code = code
|
||||
first = stderr.strip().splitlines()
|
||||
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")
|
||||
@@ -56,13 +90,15 @@ def run(repo, args, ok_codes=(0,)):
|
||||
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
|
||||
# start from the directory itself, or the file's parent
|
||||
directory = path if os.path.isdir(path) else os.path.dirname(path)
|
||||
if not os.path.isdir(directory):
|
||||
return None
|
||||
# outside a repository git fails, which reads as None
|
||||
try:
|
||||
root = run(directory, ["rev-parse", "--show-toplevel"]).strip()
|
||||
except (GitError, OSError):
|
||||
@@ -70,7 +106,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"])
|
||||
@@ -79,19 +115,20 @@ 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):
|
||||
"""A numstat count: "-" marks a binary file"""
|
||||
def _int_or_none(value: str) -> Optional[int]:
|
||||
"""A numstat count as int, None for "-" (binary)"""
|
||||
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] = {}
|
||||
# each NUL field is one "insertions<TAB>deletions<TAB>path" entry
|
||||
fields = iter(text.split("\0"))
|
||||
for entry in fields:
|
||||
parts = entry.split("\t")
|
||||
@@ -107,9 +144,10 @@ 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]]] = []
|
||||
# a status field, then its path; a rename or copy adds the new path after the old one
|
||||
fields = iter(text.split("\0"))
|
||||
for status in fields:
|
||||
if not status:
|
||||
@@ -122,15 +160,17 @@ 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:
|
||||
# a NUL in the first 8000 bytes marks a binary file
|
||||
head = f.read(8000)
|
||||
if b"\0" in head:
|
||||
return None
|
||||
count = head.count(b"\n")
|
||||
last = head[-1:]
|
||||
# the rest in 1 MiB chunks, remembering the final byte
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
@@ -139,12 +179,13 @@ def _count_lines(path):
|
||||
last = chunk[-1:]
|
||||
except OSError:
|
||||
return None
|
||||
# an empty file has no lines; a last line without a newline still counts
|
||||
if not last:
|
||||
return 0
|
||||
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]
|
||||
@@ -152,7 +193,8 @@ 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 = []
|
||||
# "1" ordinary, "u" unmerged, "?" untracked entries; anything else (branch headers) is skipped
|
||||
changes: List[FileChange] = []
|
||||
for entry in entries:
|
||||
kind = entry[0]
|
||||
if kind == "1":
|
||||
@@ -164,9 +206,11 @@ def changed_files(repo):
|
||||
xy, path = ".?", entry[2:]
|
||||
else:
|
||||
continue
|
||||
# any index change means staged; the worktree letter wins over the index letter
|
||||
x, y = xy[0], xy[1]
|
||||
staged = x not in (".", "?")
|
||||
letter = y if y != "." else x
|
||||
# untracked files have no numstat: their lines count as insertions
|
||||
if letter == "?":
|
||||
lines = _count_lines(os.path.join(repo, path))
|
||||
ins, dele, old = lines, (None if lines is None else 0), None
|
||||
@@ -177,39 +221,44 @@ 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 []
|
||||
# --shortstat rides along on the same call: one subprocess, totals for free
|
||||
# --shortstat adds per-commit totals to the same call
|
||||
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
|
||||
commits = []
|
||||
# a five-field line starts a commit; the shortstat line after it fills in its totals
|
||||
commits: List[Commit] = []
|
||||
for line in text.split("\n"):
|
||||
parts = line.split("\x1f")
|
||||
if len(parts) == 5:
|
||||
commits.append(Commit(*parts, files=None, insertions=None, deletions=None))
|
||||
sha, short, author, date, subject = parts
|
||||
commits.append(Commit(sha, short, author, date, subject, files=None, insertions=None, deletions=None))
|
||||
continue
|
||||
stat = commits and commits[-1].files is None and _SHORTSTAT.match(line)
|
||||
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 + [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
|
||||
# our %x00 ends the header; strip the separator git adds before the file list
|
||||
info_text, _, files_text = text.partition("\0")
|
||||
files_text = files_text.lstrip("\0\n")
|
||||
# pad missing trailing fields so CommitInfo always gets six
|
||||
parts = info_text.split("\x1f")
|
||||
while len(parts) < 6:
|
||||
parts.append("")
|
||||
info = CommitInfo(*parts[:6])
|
||||
|
||||
# second call: per-file counts, matched to the name-status list by path
|
||||
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))
|
||||
@@ -217,7 +266,7 @@ 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", f"{ref}:{path}"])
|
||||
@@ -226,13 +275,14 @@ def _in_tree(repo, ref, path):
|
||||
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]
|
||||
# untracked: against /dev/null, where exit code 1 only means the files differ
|
||||
if untracked:
|
||||
return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1))
|
||||
if sha:
|
||||
@@ -240,31 +290,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 commit(repo, message):
|
||||
"""Commit what is staged; the message is one argv element, so it needs no quoting of its own"""
|
||||
def commit(repo: str, message: str) -> None:
|
||||
"""Commit what is staged; `message` goes as one argv element, unquoted"""
|
||||
run(repo, ["commit", "-m", message])
|
||||
|
||||
|
||||
def head_message(repo):
|
||||
"""HEAD's whole message, exactly as git stores it"""
|
||||
def head_message(repo: str) -> str:
|
||||
"""HEAD's full message, surrounding newlines stripped"""
|
||||
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
||||
|
||||
|
||||
def amend(repo, message, allow_empty=False):
|
||||
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 otherwise refuses an amend whose result carries no change,
|
||||
which would fail on a commit that happens to be empty.
|
||||
`allow_empty` lets a reword through when HEAD itself is empty (no diff vs its parent)
|
||||
"""
|
||||
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
|
||||
|
||||
|
||||
def unstage(repo, change):
|
||||
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])
|
||||
@@ -272,8 +321,8 @@ def unstage(repo, change):
|
||||
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
|
||||
|
||||
|
||||
def discard(repo, change):
|
||||
"""Throw away the file's changes, staged and unstaged, restoring the HEAD version"""
|
||||
def discard(repo: str, change: FileChange) -> None:
|
||||
"""Throw away the file's staged and unstaged changes: restore HEAD's version, or delete a new file"""
|
||||
if change.status == "?":
|
||||
run(repo, ["clean", "-f", "--", change.path])
|
||||
return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" Event listener: state cleanup, refresh when the tab comes back or a file is saved """
|
||||
"""Event listener: state cleanup, refresh when the tab comes back or a file is saved"""
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
|
||||
@@ -7,15 +7,16 @@ 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:
|
||||
"""Forget the closing view's state"""
|
||||
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)
|
||||
|
||||
314
vdiff/palette.py
Normal file
314
vdiff/palette.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""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")
|
||||
|
||||
# tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text
|
||||
SCOPE_DEL = "diff.deleted"
|
||||
SCOPE_INS = "diff.inserted"
|
||||
SCOPE_DEL_CHAR = "diff.deleted.char"
|
||||
SCOPE_INS_CHAR = "diff.inserted.char"
|
||||
# context rows of the all-files view: the editor background, nudged so the colour still paints
|
||||
SCOPE_CTX = "vdiff.context"
|
||||
|
||||
# background kinds (tinted ones paint changed lines, "ctx" the all-files context rows) and their tint scopes
|
||||
TINT_KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
|
||||
KINDS: Tuple[str, ...] = TINT_KINDS + ("ctx",)
|
||||
KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR,
|
||||
"ctx": SCOPE_CTX}
|
||||
|
||||
# all-files file heading and its -/+ counts; the colours are user-requested
|
||||
SCOPE_FILE_HEADER = "vdiff.file.header"
|
||||
SCOPE_FILE_DEL_COUNT = "vdiff.file.del.count"
|
||||
SCOPE_FILE_INS_COUNT = "vdiff.file.ins.count"
|
||||
FILE_HEADER_BACKGROUND = "#0000FF"
|
||||
# sublime's former markup.deleted / markup.inserted colours
|
||||
FILE_DEL_FOREGROUND = "#d64646"
|
||||
FILE_INS_FOREGROUND = "#46d646"
|
||||
|
||||
# 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)",
|
||||
}
|
||||
|
||||
# standard scope roots probed for the palette; no API returns it whole, and parsing the scheme
|
||||
# file would mean re-doing sublime's 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 user 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 to tell the user to restart; True at most once per scheme
|
||||
|
||||
Only when the override is written but not yet loaded
|
||||
"""
|
||||
if not (_active and _palettes.get(_active)) or tokens_resolved(view, KINDS) 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"""
|
||||
return _style(view, scope).get("background")
|
||||
|
||||
|
||||
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
|
||||
"""The scheme's style for a scope, or {} when style_for_scope fails"""
|
||||
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, kinds: Tuple[str, ...] = TINT_KINDS) -> 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
|
||||
# a kind added since the override was written loads only after a restart, so check each kind
|
||||
# probe a colour unlike the default foreground (an unknown scope falls back to it);
|
||||
# ctx's background may go unreported
|
||||
default = foreground(view, "text.plain")
|
||||
colour, index = next(((c, i) for c, i in colours.items() if c != default), next(iter(colours.items())))
|
||||
# live when the rule's background shows, or its distinct foreground does
|
||||
return all(scope_background(view, _scope_name(kind, index)) or
|
||||
(colour != default and foreground(view, _scope_name(kind, index)) == colour)
|
||||
for kind in kinds)
|
||||
|
||||
|
||||
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"""
|
||||
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:
|
||||
"""Generated scope name for a background kind and palette index"""
|
||||
return f"vdiff.{kind}.c{index}"
|
||||
|
||||
|
||||
def ensure(view: sublime.View) -> None:
|
||||
"""Provision the scopes for the active scheme, once per scheme per session
|
||||
|
||||
A new override loads only on a restart; tokens_resolved() says when it is live
|
||||
"""
|
||||
global _active
|
||||
name = override_name(view)
|
||||
_active = name
|
||||
if not name or name in _palettes:
|
||||
return
|
||||
# merge into the existing override, if any
|
||||
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:
|
||||
# invalid JSON raises here, leaving the file untouched; render still shows the changed lines
|
||||
scheme = sublime.decode_value(f.read()) or {}
|
||||
rules = scheme.setdefault("rules", [])
|
||||
# probe the scheme, record the palette, and write only when a rule changed
|
||||
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)
|
||||
# mark the scheme as handled so this session does not retry it
|
||||
_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]
|
||||
# a region whose background is missing, transparent or equal to the editor's is filled with its
|
||||
# foreground, so context colours carry the editor background nudged by an invisible step
|
||||
tints[SCOPE_CTX] = _nudge(_global_background(view))
|
||||
return tints
|
||||
|
||||
|
||||
def _global_background(view: sublime.View) -> str:
|
||||
"""The scheme's editor background; style_for_scope reports none for scopes that do not set one"""
|
||||
try:
|
||||
background = view.style().get("background")
|
||||
except Exception:
|
||||
LOG.debug("view.style failed", exc_info=True)
|
||||
background = None
|
||||
return background or "#000000"
|
||||
|
||||
|
||||
def _nudge(colour: str) -> str:
|
||||
"""#rrggbb[aa] with blue moved one step, towards the middle so it never overflows"""
|
||||
if not (colour.startswith("#") and len(colour) in (7, 9)):
|
||||
return colour
|
||||
blue = int(colour[5:7], 16)
|
||||
return f"{colour[:5]}{blue + 1 if blue < 128 else blue - 1:02x}{colour[7:]}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
Each (kind, colour) rule carries a background and a foreground, as a background alone would flatten
|
||||
the line's colours; plain tints are added only when missing and never overwritten, so a hand-tuned one survives
|
||||
"""
|
||||
present = {rule.get("scope"): rule for rule in rules}
|
||||
changed = False
|
||||
# the all-files file heading
|
||||
header = present.get(SCOPE_FILE_HEADER)
|
||||
wanted_header = {"scope": SCOPE_FILE_HEADER, "background": FILE_HEADER_BACKGROUND}
|
||||
if header is None:
|
||||
rules.append(wanted_header)
|
||||
changed = True
|
||||
elif header != wanted_header:
|
||||
header.clear()
|
||||
header.update(wanted_header)
|
||||
changed = True
|
||||
# its deletion and insertion counts
|
||||
for scope, foreground in ((SCOPE_FILE_DEL_COUNT, FILE_DEL_FOREGROUND),
|
||||
(SCOPE_FILE_INS_COUNT, FILE_INS_FOREGROUND)):
|
||||
count_rule = present.get(scope)
|
||||
wanted_count = {"scope": scope, "background": FILE_HEADER_BACKGROUND,
|
||||
"foreground": foreground, "font_style": "bold"}
|
||||
if count_rule is None:
|
||||
rules.append(wanted_count)
|
||||
changed = True
|
||||
elif count_rule != wanted_count:
|
||||
count_rule.clear()
|
||||
count_rule.update(wanted_count)
|
||||
changed = True
|
||||
# plain tints the scheme lacks; the context tint gets no rule of its own
|
||||
for scope, background in tints.items():
|
||||
if scope != SCOPE_CTX and scope not in present and not scope_background(view, scope):
|
||||
rules.append({"scope": scope, "background": background})
|
||||
changed = True
|
||||
# one rule per (kind, colour) pair
|
||||
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 ""
|
||||
# no usable scheme name: nothing to override
|
||||
if not name or name == "auto":
|
||||
return None
|
||||
root, _ = os.path.splitext(os.path.basename(name))
|
||||
return root + ".sublime-color-scheme"
|
||||
@@ -1,32 +1,56 @@
|
||||
""" Unified diff -> lines with old/new numbers, change blocks, word-level ranges; no sublime imports """
|
||||
"""Unified diff -> lines with old/new numbers, change blocks, character-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")
|
||||
|
||||
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
|
||||
class Line(NamedTuple):
|
||||
"""One diff row: kind " " (context), "-" (deleted) or "+" (inserted); the missing line number is None"""
|
||||
|
||||
kind: str
|
||||
old_no: Optional[int]
|
||||
new_no: Optional[int]
|
||||
text: str
|
||||
|
||||
|
||||
class Diff(NamedTuple):
|
||||
"""A parsed unified diff: its rows, and whether git reported the file as binary"""
|
||||
|
||||
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")
|
||||
|
||||
# a half-open (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]
|
||||
|
||||
# character-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
|
||||
CHAR_DIFF_MIN_RATIO = 0.5
|
||||
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
|
||||
for raw in text.split("\n"):
|
||||
# a hunk header resets both line counters
|
||||
if raw.startswith("@@"):
|
||||
match = _HUNK.match(raw)
|
||||
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
|
||||
in_hunk = True
|
||||
continue
|
||||
# before the first hunk only headers: watch for a binary marker
|
||||
if not in_hunk:
|
||||
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
|
||||
binary = True
|
||||
@@ -34,6 +58,7 @@ def parse_unified(text):
|
||||
if raw == "":
|
||||
# only the trailing split remainder; an empty context line is " "
|
||||
continue
|
||||
# hunk body: the first character is the line kind
|
||||
marker, body = raw[0], raw[1:]
|
||||
if marker == " ":
|
||||
lines.append(Line(" ", old_no, new_no, body))
|
||||
@@ -52,26 +77,26 @@ 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):
|
||||
"""[(start, end)) index ranges of consecutive changed lines"""
|
||||
def change_blocks(lines: Sequence[Line]) -> List[Block]:
|
||||
"""Half-open (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):
|
||||
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
|
||||
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
|
||||
"""(deleted, inserted) index pairs of a block; [] unless its - and + counts match"""
|
||||
start, end = block
|
||||
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
|
||||
inserted = [i for i in range(start, end) if lines[i].kind == "+"]
|
||||
@@ -80,15 +105,16 @@ 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 = []
|
||||
# each opcode span becomes a range on whichever side it touches
|
||||
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,10 +123,11 @@ 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
|
||||
# the first numbered line at or after the row, else the nearest one before it
|
||||
index = max(0, min(index, len(lines) - 1))
|
||||
for line in lines[index:]:
|
||||
if line.new_no is not None:
|
||||
|
||||
624
vdiff/render.py
624
vdiff/render.py
@@ -1,17 +1,18 @@
|
||||
""" Draw a parsed diff into a review view: the file text, coloured line regions, gutter marks, header phantom """
|
||||
"""Draw a parsed diff into a review view: file text, line tints, 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, Union
|
||||
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
|
||||
from . import parse
|
||||
from .git import STATUS_NAMES
|
||||
from .state import VIEW_FLAG
|
||||
from .utils import file_count, 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,45 +22,61 @@ 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)
|
||||
KEY_FILE_HEADER = "vdiff.file.header"
|
||||
KEY_FILE_DEL_COUNT = palette.SCOPE_FILE_DEL_COUNT
|
||||
KEY_FILE_INS_COUNT = palette.SCOPE_FILE_INS_COUNT
|
||||
KEY_FILE_PREFIX = "vdiff.file.prefix"
|
||||
REGION_KEYS = (
|
||||
KEY_DEL,
|
||||
KEY_INS,
|
||||
KEY_DEL_GUTTER,
|
||||
KEY_INS_GUTTER,
|
||||
KEY_DEL_CHAR,
|
||||
KEY_INS_CHAR,
|
||||
KEY_FILE_HEADER,
|
||||
KEY_FILE_DEL_COUNT,
|
||||
KEY_FILE_INS_COUNT,
|
||||
KEY_FILE_PREFIX,
|
||||
)
|
||||
|
||||
PHANTOM_KEY = "vdiff.header"
|
||||
STATUS_KEY = "vdiff"
|
||||
|
||||
# visual bell: a whole-buffer tint, erased on a timer. Deliberately not in REGION_KEYS, so the
|
||||
# re-render a wrap triggers does not wipe it before it has been seen.
|
||||
# visual bell: a timed whole-buffer tint, kept out of REGION_KEYS so a re-render cannot erase it early
|
||||
BELL_KEY = "vdiff.bell"
|
||||
BELL_MS = 90
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
# the hidden output panel the all-files view tokenises each file in
|
||||
TOKEN_PANEL = "vdiff_tokens"
|
||||
|
||||
# a block phantom at point 0 draws below row 0, so file text starts at row 1
|
||||
_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"
|
||||
SCOPE_FILE_HEADER = palette.SCOPE_FILE_HEADER
|
||||
|
||||
# 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 loads a newly created scheme override only after 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")
|
||||
|
||||
KEY_GROUPS = [
|
||||
# actions: the ACTIONS names the row runs, a link when there is one; short: status-bar label, "" omits it
|
||||
class Key(NamedTuple):
|
||||
"""One row of the status-bar hints and the ? popup"""
|
||||
|
||||
keys: str
|
||||
label: str
|
||||
short: str
|
||||
actions: Tuple[str, ...]
|
||||
|
||||
|
||||
# (group title, applies in a commit (False: needs a working-tree file), rows)
|
||||
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
||||
(
|
||||
"Navigate",
|
||||
True,
|
||||
@@ -70,14 +87,6 @@ KEY_GROUPS = [
|
||||
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",)),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Actions",
|
||||
False,
|
||||
@@ -93,14 +102,18 @@ KEY_GROUPS = [
|
||||
"View",
|
||||
True,
|
||||
[
|
||||
Key("f", "changed files", "files", ("files",)),
|
||||
Key("c", "recent commits", "commits", ("commits",)),
|
||||
Key("v", "toggle all-files view", "view", ("view",)),
|
||||
Key("r", "refresh", "refresh", ("refresh",)),
|
||||
Key("?", "this list", "keys", ("help",)),
|
||||
Key("left right", "collapse / expand file", "fold", ("collapse", "expand")),
|
||||
Key("z", "collapse / expand all files", "", ("fold_all",)),
|
||||
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(f"{k.keys} {k.short}" for k in KEYS if k.short)
|
||||
|
||||
@@ -124,10 +137,8 @@ a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-
|
||||
.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, so each rule uses one bare element or class
|
||||
# .group's top margin replaces body's top padding; .kdim inherits 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; }
|
||||
@@ -143,13 +154,25 @@ 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:
|
||||
"""Replace the buffer"""
|
||||
self.view.replace(edit, sublime.Region(0, self.view.size()), text)
|
||||
|
||||
|
||||
def init_view(view):
|
||||
def _clear_external_diagnostics(view: sublime.View) -> None:
|
||||
"""Erase LSP diagnostic regions painted before the view became a widget"""
|
||||
for prefix in ("lspLSP-pyright", "lspLSP-pylsp", "lspLSP"):
|
||||
for severity in range(1, 5):
|
||||
for mode in ("s", "m"):
|
||||
key = f"{prefix}d{mode}{severity}"
|
||||
for suffix in ("", "_underline", "_icon"):
|
||||
view.erase_regions(key + suffix)
|
||||
|
||||
|
||||
def init_view(view: sublime.View) -> None:
|
||||
"""Settings a review view needs, applied once when it is created or restored"""
|
||||
s = view.settings()
|
||||
# mark the view as vdiff's and turn off editor chrome that does not fit a diff
|
||||
s.set(VIEW_FLAG, True)
|
||||
# native line numbers would be wrong: deleted lines have none
|
||||
s.set("line_numbers", False)
|
||||
@@ -160,64 +183,81 @@ def init_view(view):
|
||||
s.set("detect_indentation", False)
|
||||
s.set("translate_tabs_to_spaces", False)
|
||||
s.set("show_definitions", False)
|
||||
# LSP ignores widget views; is_scratch alone does not stop every LSP version
|
||||
s.set("is_widget", True)
|
||||
s.set("mini_diff", False)
|
||||
s.set("spell_check", False)
|
||||
# a read-only viewer, so vim's insert mode is meaningless: open in command mode with the block
|
||||
# caret. vdiff's own keys carry no command_mode context, so they fire in either mode regardless.
|
||||
# keep linters and LSP off this synthetic buffer, whichever of these settings they read
|
||||
s.set("syntax_checking", False)
|
||||
s.set("sublime_linter_disable", True)
|
||||
s.set("lsp_diagnostics", False)
|
||||
_clear_external_diagnostics(view)
|
||||
# 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
|
||||
s.set("command_mode", True)
|
||||
s.set("inverse_caret_state", True)
|
||||
# a scratch, read-only buffer with the key hints in the status bar
|
||||
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]:
|
||||
"""Modification time of `path`, or None when it cannot be stat'ed"""
|
||||
try:
|
||||
return os.stat(path).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def flash(view):
|
||||
"""Visual bell: tint the buffer for a moment, for feedback that has nothing to say in words"""
|
||||
def flash(view: sublime.View) -> None:
|
||||
"""Visual bell: briefly tint the whole buffer"""
|
||||
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):
|
||||
"""region.yellowish, or a scope the plugin guarantees a background for when a scheme lacks it"""
|
||||
try:
|
||||
if view.style_for_scope(SCOPE_BELL).get("background"):
|
||||
def _file_header_scope(view: sublime.View) -> str:
|
||||
"""Heading tint scope: vdiff's own, else the first fallback the scheme gives a background"""
|
||||
# vdiff's own scope, once the generated scheme override has loaded
|
||||
if palette.scope_background(view, SCOPE_FILE_HEADER):
|
||||
return SCOPE_FILE_HEADER
|
||||
for scope in ("region.blueish", "region.cyanish", SCOPE_BELL, "diff.inserted"):
|
||||
if palette.scope_background(view, scope):
|
||||
return scope
|
||||
return SCOPE_FILE_HEADER
|
||||
|
||||
|
||||
def _bell_scope(view: sublime.View) -> str:
|
||||
"""region.yellowish, or the insert tint when the scheme lacks it"""
|
||||
if palette.scope_background(view, SCOPE_BELL):
|
||||
return SCOPE_BELL
|
||||
except Exception:
|
||||
LOG.debug("style_for_scope failed for %s", SCOPE_BELL, exc_info=True)
|
||||
return SCOPE_INS
|
||||
# an invisible bell is a silent failure, so borrow the insert tint
|
||||
return palette.SCOPE_INS
|
||||
|
||||
|
||||
def render_message(view, st, message):
|
||||
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 = []
|
||||
@@ -228,9 +268,219 @@ def render_message(view, st, message):
|
||||
_finish(view, st, 0, 0, "vDiff")
|
||||
|
||||
|
||||
def render(view, st):
|
||||
def _trim_context(lines: Sequence[Line], context: int) -> List[Line]:
|
||||
"""Changed lines plus up to `context` lines either side of each change"""
|
||||
if not lines:
|
||||
return []
|
||||
keep = set()
|
||||
for start, end in parse.change_blocks(lines):
|
||||
keep.update(range(max(0, start - context), min(len(lines), end + context)))
|
||||
return [line for i, line in enumerate(lines) if i in keep]
|
||||
|
||||
|
||||
def _all_line_text(line: Line) -> str:
|
||||
"""One all-files diff row: old and new line numbers, then the line's text"""
|
||||
old = f"{line.old_no:>4}" if line.old_no is not None else " "
|
||||
new = f"{line.new_no:>4}" if line.new_no is not None else " "
|
||||
return f"{old} {new} {line.text}"
|
||||
|
||||
|
||||
def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[str, int]] = None, center: bool = False) -> None:
|
||||
"""Draw every file, with a compact collapsible file heading before its diff.
|
||||
|
||||
Without `focus` the caret goes to the top. A path puts it on that file's heading, a (path, hunk)
|
||||
target on that change's first row; the scroll is kept unless the caret has left the screen, or
|
||||
the caret is centred with `center`
|
||||
"""
|
||||
# keep the scroll only for a focused redraw; reset the rows and change-navigation tables
|
||||
viewport = view.viewport_position() if focus is not None else (0, 0)
|
||||
heading = 0
|
||||
rows: List[str] = []
|
||||
metadata: List[Tuple[Optional[FileChange], Optional[Line], bool]] = []
|
||||
st.all_rows = metadata
|
||||
st.all_change_rows = []
|
||||
st.all_change_targets = []
|
||||
st.all_change_row_targets = []
|
||||
# build the rows: a blank line and heading per file, then its trimmed diff unless folded
|
||||
for change in st.files:
|
||||
rows.append("")
|
||||
metadata.append((None, None, False))
|
||||
lines, _, binary = st.all_diffs.get(change.path, ([], [], False))
|
||||
context = settings().get("all_files_context", 5)
|
||||
trimmed = _trim_context(lines, max(0, int(context)) if context is not None else 5)
|
||||
# number hunks even when folded, so navigation can unfold to one
|
||||
hunk_index = 0
|
||||
in_change = False
|
||||
for line in trimmed:
|
||||
changed = line.kind != " "
|
||||
if changed and not in_change:
|
||||
st.all_change_targets.append((change.path, hunk_index))
|
||||
hunk_index += 1
|
||||
in_change = changed
|
||||
# the heading: fold marker, path and the file's -/+ counts
|
||||
ins, dele = parse.counts(lines)
|
||||
marker = "▶" if change.path in st.collapsed else "▼"
|
||||
if change.path == focus:
|
||||
heading = len(rows)
|
||||
rows.append(f"{marker} {change.path} -{dele} +{ins}")
|
||||
metadata.append((change, None, True))
|
||||
# the diff rows, recording the row where each visible change starts
|
||||
if change.path not in st.collapsed:
|
||||
previous_changed = False
|
||||
visible_hunk = -1
|
||||
for line in trimmed:
|
||||
rows.append(_all_line_text(line))
|
||||
metadata.append((change, line, False))
|
||||
changed = line.kind != " "
|
||||
if changed and not previous_changed:
|
||||
visible_hunk += 1
|
||||
st.all_change_rows.append(len(rows) - 1)
|
||||
st.all_change_row_targets.append((change.path, visible_hunk))
|
||||
previous_changed = changed
|
||||
# write the text and syntax
|
||||
text = "\n".join(rows) if rows else "(no changes)"
|
||||
_replace(view, text)
|
||||
# one buffer syntax would parse prefixes and headings as code: tokenise each file alone,
|
||||
# paint its colours as regions
|
||||
view.assign_syntax("scope:text.plain")
|
||||
palette.ensure(view)
|
||||
if palette.claim_restart_notice(view):
|
||||
status(NEEDS_RESTART)
|
||||
_all_colours(view, st, [c.path for c in st.files if c.path not in st.collapsed])
|
||||
# per-row regions: heading counts, number prefix, tint, token colours
|
||||
starts = _row_starts_for_text(rows)
|
||||
del_regions, ins_regions, file_regions, prefix_regions = [], [], [], []
|
||||
file_del_counts, file_ins_counts = [], []
|
||||
token_groups: Dict[str, List[sublime.Region]] = {}
|
||||
default_colour = palette.foreground(view, "text.plain")
|
||||
resolved = palette.tokens_resolved(view)
|
||||
# context colours come from a later scheme rule, so they may still await a restart
|
||||
ctx_resolved = palette.tokens_resolved(view, ("ctx",))
|
||||
for i, (change, line, section) in enumerate(metadata):
|
||||
if section:
|
||||
file_regions.append(sublime.Region(starts[i], starts[i] + len(rows[i])))
|
||||
# colour just the counts over the heading tint; rfind, as a path may contain " -"
|
||||
minus = rows[i].rfind(" -") + 1
|
||||
plus = rows[i].rfind(" +") + 1
|
||||
if minus and plus > minus:
|
||||
file_del_counts.append(sublime.Region(starts[i] + minus, starts[i] + plus - 1))
|
||||
if plus:
|
||||
file_ins_counts.append(sublime.Region(starts[i] + plus, starts[i] + len(rows[i])))
|
||||
if section or line is None:
|
||||
continue
|
||||
text_start = starts[i] + len(rows[i]) - len(line.text)
|
||||
# tint only the file text, leaving the line-number columns plain
|
||||
region = sublime.Region(text_start, starts[i] + len(rows[i]))
|
||||
# the last separator column of _all_line_text keeps the view background: a gap before the text
|
||||
prefix_regions.append(sublime.Region(starts[i], text_start - 1))
|
||||
if resolved and change is not None:
|
||||
kind = {"-": "del", "+": "ins"}.get(line.kind, "ctx")
|
||||
for a, b, colour in st.all_colours.get(change.path, {}).get((line.old_no, line.new_no), []):
|
||||
# context text in the default colour needs no region: plain text draws it already
|
||||
if kind == "ctx" and (colour == default_colour or not ctx_resolved):
|
||||
continue
|
||||
scope = palette.scope_for(kind, colour)
|
||||
if scope:
|
||||
token_groups.setdefault(scope, []).append(sublime.Region(text_start + a, text_start + b))
|
||||
if line.kind == "-":
|
||||
del_regions.append(region)
|
||||
elif line.kind == "+":
|
||||
ins_regions.append(region)
|
||||
# paint everything
|
||||
view.add_regions(KEY_DEL, del_regions, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_INS, ins_regions, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_FILE_HEADER, file_regions, _file_header_scope(view), flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_FILE_DEL_COUNT, file_del_counts, KEY_FILE_DEL_COUNT, flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_FILE_INS_COUNT, file_ins_counts, KEY_FILE_INS_COUNT, flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_FILE_PREFIX, prefix_regions, "text.plain", flags=sublime.DRAW_NO_OUTLINE)
|
||||
for scope, regions in token_groups.items():
|
||||
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
|
||||
insertions, deletions = _totals(st.files)
|
||||
_finish(view, st, insertions, deletions, "⟐ all files")
|
||||
# place the caret: on the focused change's first row, else on `heading` (a file's or the top)
|
||||
if isinstance(focus, tuple):
|
||||
heading = next((row for row, shown in zip(st.all_change_rows, st.all_change_row_targets) if shown == focus), 0)
|
||||
point = index_point(view, heading)
|
||||
view.sel().clear()
|
||||
view.sel().add(sublime.Region(point))
|
||||
if center:
|
||||
view.show_at_center(point)
|
||||
else:
|
||||
view.set_viewport_position(viewport, False)
|
||||
if focus is not None:
|
||||
view.show(point)
|
||||
|
||||
|
||||
def _all_colours(view: sublime.View, st: ViewState, paths: Sequence[str]) -> None:
|
||||
"""Fill st.all_colours for `paths`, tokenising each file with its own syntax in a hidden panel"""
|
||||
missing = [path for path in paths if path not in st.all_colours and path in st.all_diffs]
|
||||
window = view.window()
|
||||
if not missing or window is None:
|
||||
return
|
||||
# one hidden panel tokenises every missing file, then is destroyed
|
||||
panel = window.create_output_panel(TOKEN_PANEL, unlisted=True)
|
||||
colours: Dict[str, Optional[str]] = {} # scope name -> colour, shared by every file
|
||||
try:
|
||||
for path in missing:
|
||||
lines = st.all_diffs[path][0]
|
||||
st.all_colours[path] = _file_colours(view, panel, os.path.join(st.repo, path), lines, colours)
|
||||
finally:
|
||||
window.destroy_output_panel(TOKEN_PANEL)
|
||||
|
||||
|
||||
def _file_colours(
|
||||
view: sublime.View, panel: sublime.View, path: str, lines: Sequence[Line], colours: Dict[str, Optional[str]]
|
||||
) -> Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]:
|
||||
"""{(old_no, new_no): [(start, end, colour)]} for every line of one file's full-context diff
|
||||
|
||||
The new side (context and insertions) and the old side (context and deletions) are each a whole,
|
||||
valid version of the file, so each is parsed on its own; interleaved they would confuse the parser
|
||||
"""
|
||||
result: Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]] = {}
|
||||
if sum(len(line.text) + 1 for line in lines) > TOKEN_COLOUR_MAX_CHARS:
|
||||
# too big to colour; the flat tint still shows it
|
||||
return result
|
||||
_apply_syntax(panel, path)
|
||||
for side in ("+", "-"):
|
||||
if side == "-" and not any(line.kind == "-" for line in lines):
|
||||
continue # nothing deleted: the old side would only repeat the context rows
|
||||
stream = [line for line in lines if line.kind in (" ", side)]
|
||||
panel.run_command("vdiff_replace", {"text": "\n".join(line.text for line in stream)})
|
||||
point = 0
|
||||
for line in stream:
|
||||
# context rows come from the new side
|
||||
if line.kind == side or side == "+":
|
||||
runs = _colour_runs(panel, point, line.text, colours, view)
|
||||
result[(line.old_no, line.new_no)] = [(r.a - point, r.b - point, c) for r, c in runs]
|
||||
point += len(line.text) + 1
|
||||
return result
|
||||
|
||||
|
||||
def _row_starts_for_text(rows: Sequence[str]) -> List[int]:
|
||||
"""Text point where each all-files row starts, below the header rows"""
|
||||
starts: List[int] = []
|
||||
point = _HEADER_ROWS
|
||||
for row in rows:
|
||||
starts.append(point)
|
||||
point += len(row) + 1
|
||||
return starts
|
||||
|
||||
|
||||
def all_change_at(view: sublime.View, st: ViewState) -> Optional[FileChange]:
|
||||
"""File whose heading or diff row holds the caret, else None"""
|
||||
index = caret_index(view)
|
||||
if 0 <= index < len(st.all_rows):
|
||||
return st.all_rows[index][0]
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
# the text: a placeholder for a binary or empty diff, else the lines
|
||||
if st.binary:
|
||||
text = "(binary file)"
|
||||
elif not lines:
|
||||
@@ -238,34 +488,140 @@ def render(view, st):
|
||||
else:
|
||||
text = "\n".join(line.text for line in lines)
|
||||
|
||||
# write the text and syntax
|
||||
_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)
|
||||
|
||||
# header and bookkeeping, then the caret and scroll back to the top
|
||||
ins, dele = parse.counts(lines)
|
||||
_finish(view, st, ins, dele, f"⟐ {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):
|
||||
"""Header, tab name, status bar and bookkeeping shared by render and render_message"""
|
||||
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
|
||||
# full-line tint
|
||||
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)
|
||||
# gutter dots
|
||||
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)
|
||||
# word-diff marks, when enabled
|
||||
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)
|
||||
# syntax colours on top of the tints
|
||||
_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:
|
||||
# too big to colour; the flat tint still shows it
|
||||
return
|
||||
|
||||
# word-diff ranges sorted by start, so each line can bisect for its own
|
||||
stronger = sorted((r.a, r.b) for r in char_regions)
|
||||
starts_of = [a for a, _ in stronger]
|
||||
# group every changed line's colour runs by scope
|
||||
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)
|
||||
# one region set per scope
|
||||
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)]
|
||||
# walk the intervals, emitting the gap before each and its overlap with the region
|
||||
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
|
||||
# the tail after the last interval, or the whole region when none overlapped
|
||||
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]], scheme_view: Optional[sublime.View] = None
|
||||
) -> List[Tuple[sublime.Region, str]]:
|
||||
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged
|
||||
|
||||
`scheme_view` resolves the colours when `view` is only a tokenising buffer
|
||||
"""
|
||||
runs: List[Tuple[sublime.Region, str]] = []
|
||||
end = start + len(text)
|
||||
for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, end)):
|
||||
# a token can run past the line's end, e.g. into the newline
|
||||
region = sublime.Region(max(region.begin(), start), min(region.end(), end))
|
||||
if region.b <= region.a:
|
||||
continue
|
||||
# resolve each scope's colour once, skipping tokens without one
|
||||
if scope not in colours:
|
||||
colours[scope] = palette.foreground(scheme_view or view, scope)
|
||||
colour = colours[scope]
|
||||
if not colour:
|
||||
continue
|
||||
# extend the previous run when the same colour continues it
|
||||
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 every render"""
|
||||
_header(view, st, ins, dele)
|
||||
view.set_name(name)
|
||||
view.set_status(STATUS_KEY, KEY_HINTS)
|
||||
@@ -273,17 +629,19 @@ def _finish(view, st, ins, dele, name):
|
||||
st.fingerprint = fingerprint(st)
|
||||
|
||||
|
||||
def _replace(view, text):
|
||||
def _replace(view: sublime.View, text: str) -> None:
|
||||
"""Write `text` below the header rows, then erase every vdiff region and token layer"""
|
||||
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:
|
||||
@@ -294,9 +652,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)
|
||||
@@ -305,9 +663,9 @@ 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
|
||||
@@ -315,11 +673,14 @@ 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:
|
||||
# word-diff each deleted/inserted pair and offset its ranges to buffer points
|
||||
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)
|
||||
deleted.extend(sublime.Region(starts[del_row] + a, starts[del_row] + b) for a, b in old_ranges)
|
||||
@@ -327,13 +688,13 @@ def _char_regions(lines, blocks, starts):
|
||||
return deleted, inserted
|
||||
|
||||
|
||||
def _totals(files):
|
||||
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):
|
||||
"""Line above the header: the whole commit on one line, or how much of the tree is staged"""
|
||||
def _summary_html(st: ViewState) -> str:
|
||||
"""Summary line above the title: the commit on one line, or the staged/unstaged split and totals"""
|
||||
if not st.files and not st.commit:
|
||||
return ""
|
||||
if st.in_commit:
|
||||
@@ -349,10 +710,11 @@ def _summary_html(st):
|
||||
return f'<div class="summary">{text}</div>'
|
||||
|
||||
|
||||
def _commit_parts(st):
|
||||
"""hash, author, date, totals and message — everything about the reviewed commit, in that order"""
|
||||
def _commit_parts(st: ViewState) -> List[str]:
|
||||
"""Hash, author, date, totals and message of the reviewed commit, in order"""
|
||||
info = st.commit
|
||||
parts = []
|
||||
parts: List[str] = []
|
||||
# the commit's identity: hash, author, date
|
||||
if info:
|
||||
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
|
||||
parts.append(html.escape(info.author))
|
||||
@@ -360,6 +722,7 @@ def _commit_parts(st):
|
||||
# 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>')
|
||||
# the subject, plus the body's first line
|
||||
if info:
|
||||
message = html.escape(info.subject)
|
||||
body = info.body.strip().splitlines()
|
||||
@@ -369,9 +732,10 @@ def _commit_parts(st):
|
||||
return parts
|
||||
|
||||
|
||||
def header_html(st, ins, dele):
|
||||
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
|
||||
# the title: path, rename source, counts and working-tree state
|
||||
if change is None:
|
||||
title = '<span class="path">vDiff</span>'
|
||||
else:
|
||||
@@ -384,7 +748,8 @@ def header_html(st, ins, dele):
|
||||
if state:
|
||||
title += f' <span class="state">{state}</span>'
|
||||
|
||||
actions = []
|
||||
# action links: stage, unstage and discard need a working-tree file, open needs any file
|
||||
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>')
|
||||
@@ -401,15 +766,16 @@ def header_html(st, ins, dele):
|
||||
)
|
||||
|
||||
|
||||
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 the clicked header link's action"""
|
||||
_run_action(view, href)
|
||||
|
||||
view.erase_phantoms(PHANTOM_KEY)
|
||||
@@ -419,9 +785,10 @@ 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] = []
|
||||
# per group a heading, dimmed with a note when it does not apply in a commit
|
||||
for title, applies, group in KEY_GROUPS:
|
||||
dim = st.in_commit and not applies
|
||||
heading = html.escape(title)
|
||||
@@ -430,6 +797,7 @@ def help_html(st):
|
||||
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")
|
||||
# a row per key with a padded key column; a single-action row becomes a link
|
||||
for key in group:
|
||||
row = (
|
||||
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
|
||||
@@ -441,61 +809,17 @@ def help_html(st):
|
||||
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:
|
||||
"""Close the popup, then run the clicked row's action"""
|
||||
view.hide_popup()
|
||||
_run_action(view, href)
|
||||
|
||||
# minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size
|
||||
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"
|
||||
|
||||
100
vdiff/state.py
100
vdiff/state.py
@@ -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
|
||||
"""Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore"""
|
||||
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,42 +34,52 @@ 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
|
||||
requested: object = None # file asked for, maybe still loading; navigation counts from here
|
||||
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
|
||||
# all-files view: diffs cached so collapse/expand is instant
|
||||
all_mode: bool = False
|
||||
all_diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]] = field(default_factory=dict)
|
||||
# path -> {(old_no, new_no): [(start, end, colour)]}: syntax colours per row, offsets within the line
|
||||
all_colours: Dict[str, Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]] = field(default_factory=dict)
|
||||
collapsed: set = field(default_factory=set)
|
||||
all_rows: List[Tuple[Optional["FileChange"], Optional["Line"], bool]] = field(default_factory=list)
|
||||
all_change_rows: List[int] = field(default_factory=list)
|
||||
all_change_targets: List[Tuple[str, int]] = field(default_factory=list)
|
||||
all_change_row_targets: List[Tuple[str, int]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def in_commit(self):
|
||||
def in_commit(self) -> bool:
|
||||
"""Whether a commit is being reviewed rather than the working tree"""
|
||||
return self.sha is not None
|
||||
|
||||
def index(self):
|
||||
def index(self) -> int:
|
||||
"""Position in `files` of the file being shown or loaded, or -1
|
||||
|
||||
`requested` leads: a diff arrives asynchronously, so counting from `change` alone would make
|
||||
n / p step from whatever was on screen before the pending file landed.
|
||||
`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):
|
||||
def kept(self) -> Kept:
|
||||
"""Snapshot of what to go back to on Esc"""
|
||||
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:
|
||||
@@ -64,13 +87,22 @@ class ViewState:
|
||||
self.sha, self.commit = sha, commit
|
||||
return kept
|
||||
|
||||
def restore(self, kept):
|
||||
def restore(self, kept: Kept) -> None:
|
||||
"""Go back to a snapshot; its file becomes the requested one"""
|
||||
self.sha, self.commit, self.change, self.files = kept
|
||||
self.requested = kept.change
|
||||
|
||||
def persist(self, view):
|
||||
def set_all_diffs(self, diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]]) -> None:
|
||||
"""Replace the all-files diffs; a file's cached colours survive only if its diff is unchanged"""
|
||||
self.all_colours = {path: colours for path, colours in self.all_colours.items()
|
||||
if path in diffs and path in self.all_diffs and diffs[path][0] == self.all_diffs[path][0]}
|
||||
self.all_diffs = diffs
|
||||
|
||||
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.all_mode:
|
||||
data["all_mode"] = True
|
||||
if self.change:
|
||||
data["path"] = self.change.path
|
||||
data["status"] = self.change.status
|
||||
@@ -78,26 +110,30 @@ class ViewState:
|
||||
view.settings().set(VIEW_DATA, data)
|
||||
|
||||
|
||||
STATES = {}
|
||||
STATES: Dict[int, "ViewState"] = {}
|
||||
|
||||
|
||||
def get(view):
|
||||
def get(view: "sublime.View") -> Optional[ViewState]:
|
||||
"""State of a review view, or None when not tracked"""
|
||||
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:
|
||||
"""Track `st` as the view's state and return it"""
|
||||
STATES[view.id()] = st
|
||||
return st
|
||||
|
||||
|
||||
def drop(view):
|
||||
def drop(view: "sublime.View") -> None:
|
||||
"""Forget the view's state, if any"""
|
||||
STATES.pop(view.id(), None)
|
||||
|
||||
|
||||
def is_review_view(view):
|
||||
def is_review_view(view: Optional["sublime.View"]) -> bool:
|
||||
"""Whether the view carries the review-view flag"""
|
||||
return bool(view and view.settings().get(VIEW_FLAG))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
""" Small helpers shared across vdiff modules """
|
||||
"""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,25 +23,26 @@ 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 file_count(count):
|
||||
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):
|
||||
def status(message: str) -> None:
|
||||
"""Status-bar message with the plugin prefix"""
|
||||
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:
|
||||
"""Worker-thread body: run `work`, pass on its result or report its error"""
|
||||
try:
|
||||
result = work()
|
||||
except Exception as error:
|
||||
|
||||
Reference in New Issue
Block a user