Compare commits
18 Commits
07f0503cef
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe915178c5 | ||
|
|
a2900682a0 | ||
|
|
9d04d8c711 | ||
|
|
413731df47 | ||
|
|
d6f0ed3009 | ||
|
|
54c69484d5 | ||
|
|
e24922828c | ||
|
|
725d04aeed | ||
|
|
af8c1dd6df | ||
|
|
bb7ae537ea | ||
|
|
4749835e4f | ||
|
|
0655d3b74a | ||
|
|
57476e78dc | ||
|
|
18551641b5 | ||
|
|
419b1a95fb | ||
|
|
120676e952 | ||
|
|
fda13684d2 | ||
|
|
d45a250e62 |
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
|
||||||
|
|||||||
@@ -1,30 +1,46 @@
|
|||||||
[
|
[
|
||||||
{ "keys": ["f"], "command": "vdiff_action", "args": {"action": "files"},
|
{ "keys": ["f"], "command": "vdiff_action", "args": {"action": "files"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
|
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
|
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
|
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
|
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
|
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
|
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
|
{ "keys": ["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"},
|
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
|
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
|
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
|
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
|
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
|
{ "keys": ["j"], "command": "vdiff_action", "args": {"action": "line_down"},
|
||||||
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
|
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "line_up"},
|
||||||
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
|
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
|
||||||
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
|
{ "keys": ["m"], "command": "vdiff_action", "args": {"action": "amend"},
|
||||||
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
|
{ "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
|
||||||
"context": [{"key": "setting.vdiff_view"}, {"key": "setting.command_mode", "operand": false}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
|
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
|
||||||
"context": [
|
"context": [
|
||||||
{"key": "setting.vdiff_view"},
|
{"key": "setting.vdiff_view"},
|
||||||
|
|||||||
66
README.md
66
README.md
@@ -4,26 +4,78 @@ 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
|
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.
|
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
|
- **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.
|
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: 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.
|
`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 |
|
| key | action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `f` / `c` | changed files / recent commits panel |
|
| `f` / `c` | changed files / recent commits panel |
|
||||||
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode) |
|
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file |
|
||||||
|
| `C` | commit the staged files, typing the message in an input panel |
|
||||||
|
| `m` | amend the last commit: edit its subject, keep its body, fold in anything staged |
|
||||||
| `o` or Enter | open the real file at this line |
|
| `o` or Enter | open the real file at this line |
|
||||||
| `]` / `[` | next / previous change |
|
| `]` / `[` | next / previous change (wraps at the ends) |
|
||||||
| `n` / `p` | next / previous file of the current list |
|
| `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 |
|
| `r` | refresh |
|
||||||
| `?` | show this key list in a popup |
|
| `?` | show this key list in a popup |
|
||||||
| Esc | close the view |
|
| 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.
|
||||||
|
|||||||
19
TODO.txt
Normal file
19
TODO.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
## 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 pull and push action
|
||||||
|
- Add tags into commits list (and branches?)
|
||||||
|
- Add stash action ..
|
||||||
|
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
- commit messages are oneline. What are options for multiline editing?
|
||||||
|
- visual bell blinking time setting
|
||||||
|
|
||||||
|
|
||||||
|
## Code
|
||||||
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 logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
|
|
||||||
# sublime reloads only this file: drop the submodules so an edited one is re-imported below.
|
# drop the submodules so an edited one is re-imported; open views are rebuilt from settings by _restore_all
|
||||||
# Open review views lose their in-memory state and are rebuilt from their settings by _restore_all.
|
|
||||||
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
|
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
|
||||||
del sys.modules[_name]
|
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 (
|
from .vdiff.commands import (
|
||||||
VdiffActionCommand,
|
VdiffActionCommand,
|
||||||
VdiffCommitsCommand,
|
VdiffCommitsCommand,
|
||||||
@@ -35,6 +34,7 @@ def plugin_loaded():
|
|||||||
|
|
||||||
|
|
||||||
def _restore_all():
|
def _restore_all():
|
||||||
|
"""Rebuild every review view, in every window, that has no in-memory state yet"""
|
||||||
for window in sublime.windows():
|
for window in sublime.windows():
|
||||||
for view in window.views():
|
for view in window.views():
|
||||||
if state.is_review_view(view) and state.get(view) is None:
|
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 os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
# tests import the package directly; sublime/sublime_plugin come from tests/stubs
|
# tests import the package directly; sublime/sublime_plugin come from tests/stubs
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, os.path.dirname(HERE))
|
sys.path.insert(0, os.path.dirname(HERE))
|
||||||
sys.path.insert(0, os.path.join(HERE, "stubs"))
|
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 """
|
""" Minimal sublime API stub so the plugin modules import and header/regions logic runs offline """
|
||||||
|
import re
|
||||||
|
|
||||||
OP_EQUAL = 0
|
OP_EQUAL = 0
|
||||||
OP_NOT_EQUAL = 1
|
OP_NOT_EQUAL = 1
|
||||||
@@ -32,7 +33,11 @@ class Region:
|
|||||||
return (self.a, self.b) == (other.a, other.b)
|
return (self.a, self.b) == (other.a, other.b)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "Region({}, {})".format(self.a, self.b)
|
return f"Region({self.a}, {self.b})"
|
||||||
|
|
||||||
|
|
||||||
|
class Edit:
|
||||||
|
"""Opaque edit token; only ever passed through, so the annotations have something to name"""
|
||||||
|
|
||||||
|
|
||||||
class Settings(dict):
|
class Settings(dict):
|
||||||
@@ -84,10 +89,12 @@ class Phantom:
|
|||||||
|
|
||||||
|
|
||||||
class Window:
|
class Window:
|
||||||
"""Captures the quick panel so tests can drive its callbacks"""
|
"""Captures the quick panel and the input panel so tests can drive their callbacks"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.panel = None
|
self.panel = None
|
||||||
|
self.input = None
|
||||||
|
self.input_view = None
|
||||||
self.commands = []
|
self.commands = []
|
||||||
|
|
||||||
def focus_view(self, view):
|
def focus_view(self, view):
|
||||||
@@ -99,6 +106,35 @@ class Window:
|
|||||||
def show_quick_panel(self, items, on_select, flags=0, selected=-1, on_highlight=None, placeholder=None):
|
def show_quick_panel(self, items, on_select, flags=0, selected=-1, on_highlight=None, placeholder=None):
|
||||||
self.panel = (items, on_select, selected, on_highlight, placeholder)
|
self.panel = (items, on_select, selected, on_highlight, placeholder)
|
||||||
|
|
||||||
|
def show_input_panel(self, caption, initial_text, on_done, on_change, on_cancel):
|
||||||
|
self.input = (caption, initial_text, on_done, on_change, on_cancel)
|
||||||
|
self.input_view = View(initial_text)
|
||||||
|
# the real panel hands its prefill back selected, which is why callers clear the selection
|
||||||
|
self.input_view.sel().add(Region(0, len(initial_text)))
|
||||||
|
return self.input_view
|
||||||
|
|
||||||
|
|
||||||
|
# a bundled scheme's styles: diff.* and region.* defined, the plugin's vdiff.* ones not. Nothing
|
||||||
|
# simulates sublime merging an override, so a test wanting those to resolve adds them itself.
|
||||||
|
DEFAULT_FG = "#ffffff"
|
||||||
|
DEFAULT_STYLES = {
|
||||||
|
"diff.deleted": {"foreground": DEFAULT_FG, "background": "#4a2b2b"},
|
||||||
|
"diff.deleted.char": {"foreground": DEFAULT_FG, "background": "#6a3535"},
|
||||||
|
"diff.inserted": {"foreground": DEFAULT_FG, "background": "#2b4a3a"},
|
||||||
|
"diff.inserted.char": {"foreground": DEFAULT_FG, "background": "#356a4a"},
|
||||||
|
"region.yellowish": {"foreground": "#e6db74", "background": "#4a4a2b"},
|
||||||
|
"markup.deleted": {"foreground": "#d64646"},
|
||||||
|
"markup.inserted": {"foreground": "#46d646"},
|
||||||
|
# a few of palette.PALETTE_PROBES, so the palette probe finds a working set of colours
|
||||||
|
"source": {"foreground": DEFAULT_FG},
|
||||||
|
"comment": {"foreground": "#7c8595"},
|
||||||
|
"string": {"foreground": "#99c794"},
|
||||||
|
"keyword": {"foreground": "#c594c5"},
|
||||||
|
"constant.numeric": {"foreground": "#f99157"},
|
||||||
|
"entity.name.function": {"foreground": "#6699cc"},
|
||||||
|
"punctuation.definition": {"foreground": "#5fb3b3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class View:
|
class View:
|
||||||
"""Text buffer with just the calls render.py makes"""
|
"""Text buffer with just the calls render.py makes"""
|
||||||
@@ -116,6 +152,10 @@ class View:
|
|||||||
self.commands = []
|
self.commands = []
|
||||||
self.popup = None
|
self.popup = None
|
||||||
self.popup_navigate = 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):
|
def id(self):
|
||||||
return 1
|
return 1
|
||||||
@@ -184,9 +224,19 @@ class View:
|
|||||||
def set_status(self, key, value):
|
def set_status(self, key, value):
|
||||||
self.status[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):
|
def set_viewport_position(self, xy, animate=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def show(self, point):
|
||||||
|
pass
|
||||||
|
|
||||||
def show_at_center(self, point):
|
def show_at_center(self, point):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -202,8 +252,25 @@ class View:
|
|||||||
def em_width(self):
|
def em_width(self):
|
||||||
return 8.0
|
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):
|
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):
|
def is_valid(self):
|
||||||
return True
|
return True
|
||||||
@@ -231,7 +298,13 @@ def find_syntax_for_file(path, first_line=""):
|
|||||||
|
|
||||||
|
|
||||||
def ui_info():
|
def ui_info():
|
||||||
return {"system": {"style": "dark"}, "color_scheme": {"value": "Mariana.sublime-color-scheme", "resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme"}}
|
return {
|
||||||
|
"system": {"style": "dark"},
|
||||||
|
"color_scheme": {
|
||||||
|
"value": "Mariana.sublime-color-scheme",
|
||||||
|
"resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def decode_value(text):
|
def decode_value(text):
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
import sublime # the stub, via conftest
|
import sublime # the stub, via conftest
|
||||||
|
|
||||||
from vdiff import commands, git, state
|
from vdiff import commands, git, state, utils
|
||||||
|
|
||||||
|
|
||||||
def change(path, status="M"):
|
def change(path, status="M"):
|
||||||
return git.FileChange(path, status, False, 1, 1, None)
|
return git.FileChange(path, status, False, 1, 1, None)
|
||||||
|
|
||||||
|
|
||||||
|
def flagged(*flags):
|
||||||
|
"""f0.py, f1.py ... with the given staged flags"""
|
||||||
|
return [git.FileChange(f"f{i}.py", "M", flag, 1, 1, None) for i, flag in enumerate(flags)]
|
||||||
|
|
||||||
|
|
||||||
|
def option(monkeypatch, name, value):
|
||||||
|
"""Set a plugin setting for one test; the stub hands out one Settings dict per file"""
|
||||||
|
monkeypatch.setitem(sublime.load_settings(utils.SETTINGS_FILE), name, value)
|
||||||
|
|
||||||
|
|
||||||
def flush_previews():
|
def flush_previews():
|
||||||
"""The stub runs set_timeout synchronously, so previews are already shown"""
|
"""The stub runs set_timeout synchronously, so previews are already shown"""
|
||||||
|
|
||||||
|
|
||||||
|
def at_change(blocks, row):
|
||||||
|
"""A rendered view with `blocks` as its changes and the caret on `row`"""
|
||||||
|
view = sublime.View()
|
||||||
|
st = state.ViewState("/repo")
|
||||||
|
st.blocks = blocks
|
||||||
|
view.text = "\n" + "\n".join(f"l{i}" for i in range(20))
|
||||||
|
view.sel().add(sublime.Region(commands.render.index_point(view, row)))
|
||||||
|
return view, st
|
||||||
|
|
||||||
|
|
||||||
def test_esc_restores_previous_file_and_mode(monkeypatch):
|
def test_esc_restores_previous_file_and_mode(monkeypatch):
|
||||||
shown = []
|
shown = []
|
||||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append((st.sha, c.path)))
|
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append((st.sha, c.path)))
|
||||||
@@ -81,18 +103,15 @@ def test_on_cancel_goes_back_to_commit_list(monkeypatch):
|
|||||||
assert back == [1] and st.sha is None
|
assert back == [1] and st.sha is None
|
||||||
|
|
||||||
|
|
||||||
def test_step_change_uses_blocks():
|
def test_step_change_uses_blocks(rings):
|
||||||
view = sublime.View()
|
view, st = at_change([(2, 4), (7, 8)], 0)
|
||||||
st = state.ViewState("/repo")
|
|
||||||
st.blocks = [(2, 4), (7, 8)]
|
|
||||||
view.text = "\n" + "\n".join("l{}".format(i) for i in range(10))
|
|
||||||
view.sel().add(sublime.Region(commands.render.index_point(view, 0)))
|
|
||||||
commands.ACTIONS["next_change"](view, None, st)
|
commands.ACTIONS["next_change"](view, None, st)
|
||||||
assert commands.render.caret_index(view) == 2
|
assert commands.render.caret_index(view) == 2
|
||||||
commands.ACTIONS["next_change"](view, None, st)
|
commands.ACTIONS["next_change"](view, None, st)
|
||||||
assert commands.render.caret_index(view) == 7
|
assert commands.render.caret_index(view) == 7
|
||||||
commands.ACTIONS["prev_change"](view, None, st)
|
commands.ACTIONS["prev_change"](view, None, st)
|
||||||
assert commands.render.caret_index(view) == 2
|
# stepping inside the list stays silent
|
||||||
|
assert commands.render.caret_index(view) == 2 and rings == []
|
||||||
|
|
||||||
|
|
||||||
def test_file_items_annotations():
|
def test_file_items_annotations():
|
||||||
@@ -135,3 +154,519 @@ def test_run_async_reports_generic_error(monkeypatch):
|
|||||||
|
|
||||||
commands.run_async(boom, lambda r: messages.append("done"))
|
commands.run_async(boom, lambda r: messages.append("done"))
|
||||||
assert messages == ["vDiff: boom"]
|
assert messages == ["vDiff: boom"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_j_and_k_move_the_caret_one_line():
|
||||||
|
view = sublime.View()
|
||||||
|
commands.ACTIONS["line_down"](view, None, None)
|
||||||
|
commands.ACTIONS["line_up"](view, None, None)
|
||||||
|
assert view.commands == [
|
||||||
|
("move", {"by": "lines", "forward": True}),
|
||||||
|
("move", {"by": "lines", "forward": False}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def staged_state(*flags):
|
||||||
|
st = state.ViewState("/repo")
|
||||||
|
st.files = flagged(*flags)
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_needs_a_staged_file(monkeypatch):
|
||||||
|
messages = []
|
||||||
|
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
|
||||||
|
window = sublime.Window()
|
||||||
|
|
||||||
|
commands.ACTIONS["commit"](sublime.View(), window, staged_state(False, False))
|
||||||
|
assert messages == ["vDiff: no staged files"] and window.input is None
|
||||||
|
|
||||||
|
# a commit under review has nothing to stage in the first place
|
||||||
|
st = staged_state(True)
|
||||||
|
st.enter("abc", None)
|
||||||
|
commands.ACTIONS["commit"](sublime.View(), window, st)
|
||||||
|
assert messages[-1] == "vDiff: commit works on uncommitted changes only" and window.input is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_prompts_then_commits(monkeypatch):
|
||||||
|
committed = []
|
||||||
|
refreshed = []
|
||||||
|
monkeypatch.setattr(commands.git, "commit", lambda repo, message: committed.append((repo, message)))
|
||||||
|
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
|
||||||
|
window = sublime.Window()
|
||||||
|
|
||||||
|
commands.ACTIONS["commit"](sublime.View(), window, staged_state(True, False, True))
|
||||||
|
caption, initial, on_done, _, _ = window.input
|
||||||
|
assert caption == "Commit message" and initial == "" and committed == []
|
||||||
|
|
||||||
|
on_done(" ") # an empty message is not a commit
|
||||||
|
assert committed == []
|
||||||
|
|
||||||
|
on_done("real message")
|
||||||
|
assert committed == [("/repo", "real message")]
|
||||||
|
assert refreshed == ["committed 2 files"]
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_amend(monkeypatch, st, head="last subject"):
|
||||||
|
"""Press m; git reports `head` as HEAD's message. Returns (window, amended, refreshed)"""
|
||||||
|
amended, refreshed = [], []
|
||||||
|
monkeypatch.setattr(commands.git, "head_message", lambda repo: head)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
commands.git, "amend", lambda repo, message, allow_empty=False: amended.append((repo, message, allow_empty))
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
|
||||||
|
window = sublime.Window()
|
||||||
|
commands.ACTIONS["amend"](sublime.View(), window, st)
|
||||||
|
return window, amended, refreshed
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_refuses_commit_mode(monkeypatch):
|
||||||
|
messages = []
|
||||||
|
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
|
||||||
|
st = staged_state(True)
|
||||||
|
st.enter("abc", None)
|
||||||
|
|
||||||
|
window, amended, _ = prompt_amend(monkeypatch, st)
|
||||||
|
assert messages[-1] == "vDiff: amend works on uncommitted changes only"
|
||||||
|
assert window.input is None and amended == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_prefills_the_subject_with_the_caret_at_its_end(monkeypatch):
|
||||||
|
window, amended, _ = prompt_amend(monkeypatch, staged_state(True), head="fix the parser")
|
||||||
|
|
||||||
|
caption, initial, _, _, on_cancel = window.input
|
||||||
|
assert caption == "Amend message" and initial == "fix the parser" and amended == []
|
||||||
|
# the panel selects its prefill; the caret must sit after it, ready to type
|
||||||
|
assert list(window.input_view.sel()) == [sublime.Region(len("fix the parser"))]
|
||||||
|
# Esc is a no-op: there is no cancel callback to run
|
||||||
|
assert on_cancel is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_offers_the_subject_and_keeps_the_rest_verbatim(monkeypatch):
|
||||||
|
head = "typo hree\n\nCloses #12.\n\n- a\n- b"
|
||||||
|
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(True, False, True), head=head)
|
||||||
|
|
||||||
|
assert window.input[1] == "typo hree" # only the first line is offered for editing
|
||||||
|
window.input[2]("typo here")
|
||||||
|
# everything below the first line comes back untouched, and staged files keep git's guard
|
||||||
|
assert amended == [("/repo", "typo here" + head[len("typo hree") :], False)]
|
||||||
|
assert refreshed == ["amended the last commit with 2 files"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_rewords_with_nothing_staged(monkeypatch):
|
||||||
|
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(False, False))
|
||||||
|
|
||||||
|
window.input[2]("just a reword")
|
||||||
|
# a reword alone opts out of git's refusal, so an empty commit can still be renamed
|
||||||
|
assert amended == [("/repo", "just a reword", True)]
|
||||||
|
assert refreshed == ["amended the last commit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_with_an_empty_message_does_nothing(monkeypatch):
|
||||||
|
messages = []
|
||||||
|
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
|
||||||
|
window, amended, refreshed = prompt_amend(monkeypatch, staged_state(True))
|
||||||
|
|
||||||
|
window.input[2](" ")
|
||||||
|
assert amended == [] and refreshed == []
|
||||||
|
assert messages[-1] == "vDiff: amend cancelled: empty message"
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_message_counts_one_file(monkeypatch):
|
||||||
|
refreshed = []
|
||||||
|
monkeypatch.setattr(commands.git, "commit", lambda repo, message: None)
|
||||||
|
monkeypatch.setattr(commands, "refresh_listing", lambda view, st, message=None: refreshed.append(message))
|
||||||
|
window = sublime.Window()
|
||||||
|
|
||||||
|
commands.ACTIONS["commit"](sublime.View(), window, staged_state(True, False))
|
||||||
|
window.input[2]("one")
|
||||||
|
assert refreshed == ["committed 1 file"]
|
||||||
|
|
||||||
|
|
||||||
|
def commit(subject, files=1, insertions=1, deletions=0, short="abc1234"):
|
||||||
|
return git.Commit("a" * 40, short, "Ann", "2 days ago", subject, files, insertions, deletions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_items_show_totals_before_the_hash():
|
||||||
|
plural, single, merge = commands.commit_items(
|
||||||
|
[
|
||||||
|
commit("fix the parser", 23, 210, 0, "fda1368"),
|
||||||
|
commit("tweak", 1, 1, 2, "d45a250"),
|
||||||
|
commit("merge branch 'side'", None, None, None, "beef123"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert plural.annotation == "23 files changed -0 +210 fda1368"
|
||||||
|
assert single.annotation == "1 file changed -2 +1 d45a250"
|
||||||
|
# no diffstat for a merge, so the row falls back to the hash alone
|
||||||
|
assert merge.annotation == "beef123"
|
||||||
|
assert plural.trigger == "fix the parser" and plural.details == "Ann · 2 days ago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_subject_is_clipped_to_clear_the_annotation():
|
||||||
|
long_subject = "a subject that runs on and on " * 6
|
||||||
|
item = commands.commit_items([commit(long_subject, 3, 9, 4)])[0]
|
||||||
|
|
||||||
|
assert item.trigger.endswith("..") and item.trigger[:-2] == long_subject[: len(item.trigger) - 2]
|
||||||
|
# the clipped subject plus the annotation fit the row, leaving the totals and hash readable
|
||||||
|
assert len(item.trigger) + len(item.annotation) <= commands.COMMIT_ROW_WIDTH
|
||||||
|
# a subject that already fits is left alone
|
||||||
|
assert commands.commit_items([commit("short one")])[0].trigger == "short one"
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_items_keep_a_readable_subject_when_the_annotation_is_huge():
|
||||||
|
# an annotation long enough to eat the whole row: the subject still keeps its floor
|
||||||
|
item = commands.commit_items([commit("x" * 200, 999999, 999999, 999999, "a" * 90)])[0]
|
||||||
|
assert len(item.trigger) == commands.MIN_SUBJECT_WIDTH and item.trigger.endswith("..")
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_items_fall_back_for_an_empty_subject():
|
||||||
|
assert commands.commit_items([commit("")])[0].trigger == "(no subject)"
|
||||||
|
|
||||||
|
|
||||||
|
# --- navigating while a diff is still loading -------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loading(monkeypatch):
|
||||||
|
"""Queue run_async instead of running it inline, so a test can act mid-load like a user can
|
||||||
|
|
||||||
|
The stub runs set_timeout synchronously, so the real async window — the one n / p read stale
|
||||||
|
state through — is invisible to every other test.
|
||||||
|
"""
|
||||||
|
queue = []
|
||||||
|
monkeypatch.setattr(commands, "run_async", lambda work, done: queue.append((work, done)))
|
||||||
|
monkeypatch.setattr(commands.render, "render", lambda view, st: None)
|
||||||
|
monkeypatch.setattr(commands.git, "full_diff", lambda *a, **k: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n")
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
while queue:
|
||||||
|
work, done = queue.pop(0)
|
||||||
|
done(work())
|
||||||
|
|
||||||
|
return queue, flush
|
||||||
|
|
||||||
|
|
||||||
|
def commit_panel(files):
|
||||||
|
"""A review view showing a commit, its file panel open, nothing loaded yet"""
|
||||||
|
view, window = sublime.View(), sublime.Window()
|
||||||
|
st = state.ViewState("/repo")
|
||||||
|
st.enter("abc", git.CommitInfo("abc", "abc1234", "A", "d", "s", ""))
|
||||||
|
commands.show_files_panel(window, view, st, files, st.kept(), placeholder="c")
|
||||||
|
return view, window, st
|
||||||
|
|
||||||
|
|
||||||
|
def numbered(count=5):
|
||||||
|
return flagged(*(False,) * count)
|
||||||
|
|
||||||
|
|
||||||
|
def requested_path(queue):
|
||||||
|
"""Path of the file the newest queued load will fetch"""
|
||||||
|
return queue[-1][0].__closure__[0].cell_contents.path
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_file_steps_from_the_file_being_loaded(loading):
|
||||||
|
queue, _ = loading
|
||||||
|
files = numbered()
|
||||||
|
view, window, st = commit_panel(files)
|
||||||
|
|
||||||
|
window.panel[1](2) # Enter on the third file; its diff has not arrived yet
|
||||||
|
assert st.change is None
|
||||||
|
commands.ACTIONS["next_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f3.py"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prev_file_works_before_the_diff_arrives(loading, monkeypatch):
|
||||||
|
queue, _ = loading
|
||||||
|
messages = []
|
||||||
|
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
|
||||||
|
files = numbered()
|
||||||
|
view, window, st = commit_panel(files)
|
||||||
|
|
||||||
|
window.panel[1](2)
|
||||||
|
commands.ACTIONS["prev_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f1.py" and messages == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rapid_next_file_presses_each_advance(loading):
|
||||||
|
queue, flush = loading
|
||||||
|
files = numbered()
|
||||||
|
view, window, st = commit_panel(files)
|
||||||
|
|
||||||
|
window.panel[1](1)
|
||||||
|
for _ in range(3):
|
||||||
|
commands.ACTIONS["next_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f4.py"
|
||||||
|
flush()
|
||||||
|
assert st.change.path == "f4.py"
|
||||||
|
|
||||||
|
|
||||||
|
def test_esc_rewinds_the_file_navigation_counts_from(loading):
|
||||||
|
queue, flush = loading
|
||||||
|
files = numbered()
|
||||||
|
view, window, st = commit_panel(files)
|
||||||
|
window.panel[1](3)
|
||||||
|
flush()
|
||||||
|
|
||||||
|
kept = st.kept()
|
||||||
|
window.panel[1](0) # preview another file, then back out
|
||||||
|
st.restore(kept)
|
||||||
|
commands.ACTIONS["next_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f4.py"
|
||||||
|
|
||||||
|
|
||||||
|
# --- cycling past the ends --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rings(monkeypatch):
|
||||||
|
"""Record visual bells instead of drawing them"""
|
||||||
|
bells = []
|
||||||
|
monkeypatch.setattr(commands.render, "flash", lambda view: bells.append(view))
|
||||||
|
return bells
|
||||||
|
|
||||||
|
|
||||||
|
def at_file(files, path):
|
||||||
|
"""A loaded working-tree view sitting on `path`"""
|
||||||
|
view, window = sublime.View(), sublime.Window()
|
||||||
|
st = state.ViewState("/repo")
|
||||||
|
st.files = files
|
||||||
|
st.change = st.requested = next(c for c in files if c.path == path)
|
||||||
|
return view, window, st
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_file_wraps_past_the_last(loading, rings):
|
||||||
|
queue, _ = loading
|
||||||
|
files = numbered()
|
||||||
|
view, window, st = at_file(files, "f4.py")
|
||||||
|
|
||||||
|
commands.ACTIONS["next_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f0.py" and len(rings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_prev_file_wraps_before_the_first(loading, rings):
|
||||||
|
queue, _ = loading
|
||||||
|
files = numbered()
|
||||||
|
view, window, st = at_file(files, "f0.py")
|
||||||
|
|
||||||
|
commands.ACTIONS["prev_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f4.py" and len(rings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stepping_inside_the_list_does_not_ring(loading, rings):
|
||||||
|
queue, _ = loading
|
||||||
|
files = numbered()
|
||||||
|
view, window, st = at_file(files, "f2.py")
|
||||||
|
|
||||||
|
commands.ACTIONS["next_file"](view, window, st)
|
||||||
|
commands.ACTIONS["prev_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f2.py" and rings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_file_list_rings_without_refetching(loading, rings):
|
||||||
|
queue, _ = loading
|
||||||
|
files = numbered(1)
|
||||||
|
view, window, st = at_file(files, "f0.py")
|
||||||
|
|
||||||
|
commands.ACTIONS["next_file"](view, window, st)
|
||||||
|
commands.ACTIONS["prev_file"](view, window, st)
|
||||||
|
# it rings both times, but never re-runs git for the file already on screen
|
||||||
|
assert len(rings) == 2 and queue == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_step_without_a_shown_file_picks_an_end(loading, rings):
|
||||||
|
queue, _ = loading
|
||||||
|
files = numbered()
|
||||||
|
view, window, st = commit_panel(files) # opening a commit leaves nothing shown
|
||||||
|
assert st.index() == -1
|
||||||
|
|
||||||
|
commands.ACTIONS["next_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f0.py"
|
||||||
|
st.requested = None
|
||||||
|
commands.ACTIONS["prev_file"](view, window, st)
|
||||||
|
assert requested_path(queue) == "f4.py"
|
||||||
|
# neither is a wrap: there was no position to run past
|
||||||
|
assert rings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_change_wraps_past_the_last(rings):
|
||||||
|
view, st = at_change([(2, 4), (7, 8)], 7)
|
||||||
|
|
||||||
|
commands.ACTIONS["next_change"](view, None, st)
|
||||||
|
assert commands.render.caret_index(view) == 2 and len(rings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_prev_change_wraps_before_the_first(rings):
|
||||||
|
view, st = at_change([(2, 4), (7, 8)], 2)
|
||||||
|
|
||||||
|
commands.ACTIONS["prev_change"](view, None, st)
|
||||||
|
assert commands.render.caret_index(view) == 7 and len(rings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_change_rings_and_stays_put(rings):
|
||||||
|
view, st = at_change([(2, 4)], 2)
|
||||||
|
|
||||||
|
commands.ACTIONS["next_change"](view, None, st)
|
||||||
|
commands.ACTIONS["prev_change"](view, None, st)
|
||||||
|
assert commands.render.caret_index(view) == 2 and len(rings) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_without_changes_neither_moves_nor_rings(rings):
|
||||||
|
view, st = at_change([], 3)
|
||||||
|
|
||||||
|
commands.ACTIONS["next_change"](view, None, st)
|
||||||
|
assert commands.render.caret_index(view) == 3 and rings == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- staging moves on to the next unstaged file -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def press(monkeypatch, action, before, path, after):
|
||||||
|
"""Run `action` on `path` of `before`; git then reports `after`. Returns the paths shown
|
||||||
|
|
||||||
|
ACTIONS captured git.stage and friends at build time, so the stub goes one level down at git.run,
|
||||||
|
keeping the action's real wiring under test.
|
||||||
|
"""
|
||||||
|
shown = []
|
||||||
|
monkeypatch.setattr(commands.git, "run", lambda repo, args, ok_codes=(0,): "")
|
||||||
|
monkeypatch.setattr(commands.git, "changed_files", lambda repo: after)
|
||||||
|
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(c.path))
|
||||||
|
view, window, st = at_file(before, path)
|
||||||
|
commands.ACTIONS[action](view, window, st)
|
||||||
|
return shown
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_unstaged_scans_forward_and_wraps():
|
||||||
|
files = flagged(False, True, False, True)
|
||||||
|
assert commands._next_unstaged(files, 1) == 2
|
||||||
|
assert commands._next_unstaged(files, 2) == 2 # the slot we count from counts itself
|
||||||
|
assert commands._next_unstaged(files, 3) == 0 # wraps past the end
|
||||||
|
assert commands._next_unstaged(flagged(True, True), 0) == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_moves_to_the_next_unstaged_file(monkeypatch):
|
||||||
|
shown = press(monkeypatch, "stage", flagged(True, False, True, False), "f1.py", flagged(True, True, True, False))
|
||||||
|
assert shown == ["f3.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_wraps_to_an_unstaged_file_above(monkeypatch):
|
||||||
|
shown = press(monkeypatch, "stage", flagged(False, True, False), "f2.py", flagged(False, True, True))
|
||||||
|
assert shown == ["f0.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_the_last_unstaged_file_stays_put(monkeypatch):
|
||||||
|
shown = press(monkeypatch, "stage", flagged(True, False), "f1.py", flagged(True, True))
|
||||||
|
assert shown == ["f1.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_a_file_that_leaves_the_list_counts_from_its_slot(monkeypatch):
|
||||||
|
# staging can make the entry vanish (the change was identical to HEAD); f2.py takes the slot
|
||||||
|
before = flagged(True, False, False)
|
||||||
|
after = [before[0], before[2]]
|
||||||
|
shown = press(monkeypatch, "stage", before, "f1.py", after)
|
||||||
|
assert shown == ["f2.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_does_not_move_when_the_option_is_off(monkeypatch):
|
||||||
|
option(monkeypatch, "stage_and_move_next_file", False)
|
||||||
|
shown = press(monkeypatch, "stage", flagged(True, False, False), "f1.py", flagged(True, True, False))
|
||||||
|
assert shown == ["f1.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unstage_and_discard_do_not_move(monkeypatch):
|
||||||
|
option(monkeypatch, "confirm_discard", False)
|
||||||
|
files = flagged(True, True, False)
|
||||||
|
assert press(monkeypatch, "unstage", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
|
||||||
|
assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
|
||||||
|
messages = []
|
||||||
|
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
||||||
|
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
|
||||||
|
assert messages == ["No uncommitted changes in /repo"]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -109,6 +109,37 @@ def test_commits(repo):
|
|||||||
assert info.sha == commits[0].sha
|
assert info.sha == commits[0].sha
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_commits_carry_shortstat_totals(repo):
|
||||||
|
write(repo, "new.py", "one\ntwo\n")
|
||||||
|
sh(repo, "add", "-A")
|
||||||
|
sh(repo, "commit", "-q", "-m", "added a file")
|
||||||
|
write(repo, "a.py", "x\n") # 6 lines become 1
|
||||||
|
write(repo, "b.txt", "y\n") # 1 line becomes 1
|
||||||
|
sh(repo, "commit", "-q", "-am", "changed two files")
|
||||||
|
|
||||||
|
newest, added, initial = git.recent_commits(repo, 3)
|
||||||
|
assert (newest.files, newest.insertions, newest.deletions) == (2, 2, 7)
|
||||||
|
# a pure addition: git omits the deletions clause entirely, which reads as zero, not unknown
|
||||||
|
assert (added.files, added.insertions, added.deletions) == (1, 2, 0)
|
||||||
|
assert initial.subject == "initial" and initial.files == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_commits_without_a_diffstat_report_no_totals(repo):
|
||||||
|
sh(repo, "commit", "-q", "--allow-empty", "-m", "empty")
|
||||||
|
sh(repo, "checkout", "-q", "-b", "side")
|
||||||
|
write(repo, "s.py", "s\n")
|
||||||
|
sh(repo, "add", "-A")
|
||||||
|
sh(repo, "commit", "-q", "-m", "on the side")
|
||||||
|
sh(repo, "checkout", "-q", "main")
|
||||||
|
sh(repo, "merge", "-q", "--no-ff", "side", "-m", "merge side")
|
||||||
|
|
||||||
|
totals = {c.subject: (c.files, c.insertions, c.deletions) for c in git.recent_commits(repo, 5)}
|
||||||
|
# git log prints no diffstat for either, so the totals stay unknown rather than a false zero
|
||||||
|
assert totals["merge side"] == (None, None, None)
|
||||||
|
assert totals["empty"] == (None, None, None)
|
||||||
|
assert totals["on the side"] == (1, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_no_head_repo(tmp_path):
|
def test_no_head_repo(tmp_path):
|
||||||
repo = str(tmp_path)
|
repo = str(tmp_path)
|
||||||
sh(repo, "init", "-q")
|
sh(repo, "init", "-q")
|
||||||
@@ -146,3 +177,95 @@ def test_count_lines(tmp_path):
|
|||||||
assert git._count_lines(p) is None
|
assert git._count_lines(p) is None
|
||||||
open(p, "wb").write(b"")
|
open(p, "wb").write(b"")
|
||||||
assert git._count_lines(p) == 0
|
assert git._count_lines(p) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_only_records_staged_files(repo):
|
||||||
|
write(repo, "a.py", "staged\n")
|
||||||
|
write(repo, "b.txt", "left alone\n")
|
||||||
|
changes = {c.path: c for c in git.changed_files(repo)}
|
||||||
|
git.stage(repo, changes["a.py"])
|
||||||
|
|
||||||
|
git.commit(repo, "a message with 'quotes' and \"doubles\"")
|
||||||
|
|
||||||
|
left = {c.path: c for c in git.changed_files(repo)}
|
||||||
|
assert "a.py" not in left and not left["b.txt"].staged
|
||||||
|
info, files = git.commit_with_files(repo, "HEAD")
|
||||||
|
# the message reaches git verbatim: it is one argv element, never a shell string
|
||||||
|
assert info.subject == "a message with 'quotes' and \"doubles\""
|
||||||
|
assert [f.path for f in files] == ["a.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_without_staged_files_raises(repo):
|
||||||
|
write(repo, "a.py", "unstaged only\n")
|
||||||
|
with pytest.raises(git.GitError) as info:
|
||||||
|
git.commit(repo, "nothing to record")
|
||||||
|
assert info.value.code != 0
|
||||||
|
|
||||||
|
|
||||||
|
def message(repo):
|
||||||
|
"""HEAD's raw message, as git stores it"""
|
||||||
|
return git.run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_message_returns_the_whole_message(repo):
|
||||||
|
assert git.head_message(repo) == "initial"
|
||||||
|
|
||||||
|
sh(repo, "commit", "-q", "--allow-empty", "-m", "second", "-m", "body text\nsecond line")
|
||||||
|
assert git.head_message(repo) == "second\n\nbody text\nsecond line"
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_keeps_everything_below_the_first_line_byte_for_byte(repo):
|
||||||
|
body = "Closes #12.\n\n- a\n- b"
|
||||||
|
sh(repo, "commit", "-q", "--allow-empty", "-m", "typo hree", "-m", body)
|
||||||
|
before = git.run(repo, ["rev-list", "--count", "HEAD"])
|
||||||
|
_, newline, rest = git.head_message(repo).partition("\n")
|
||||||
|
|
||||||
|
git.amend(repo, "typo here" + newline + rest, allow_empty=True)
|
||||||
|
|
||||||
|
# the paragraph the user never touched comes back exactly as git stored it
|
||||||
|
assert message(repo) == "typo here\n\n" + body
|
||||||
|
# an amend rewrites HEAD, it does not add a commit
|
||||||
|
assert git.run(repo, ["rev-list", "--count", "HEAD"]) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_without_a_body_leaves_a_one_line_message(repo):
|
||||||
|
git.amend(repo, "reworded")
|
||||||
|
assert message(repo) == "reworded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_rewords_an_empty_commit(repo):
|
||||||
|
# git refuses an amend that leaves nothing behind unless --allow-empty says otherwise
|
||||||
|
sh(repo, "commit", "-q", "--allow-empty", "-m", "marker")
|
||||||
|
git.amend(repo, "marker, reworded", allow_empty=True)
|
||||||
|
assert message(repo) == "marker, reworded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_that_would_empty_the_commit_is_refused(repo):
|
||||||
|
# folding staged files in keeps git's guard: only a reword opts out of it
|
||||||
|
write(repo, "new.py", "temporary\n")
|
||||||
|
sh(repo, "add", "new.py")
|
||||||
|
sh(repo, "commit", "-q", "-m", "add new.py")
|
||||||
|
sh(repo, "rm", "-q", "new.py")
|
||||||
|
|
||||||
|
with pytest.raises(git.GitError):
|
||||||
|
git.amend(repo, "add new.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_amend_folds_staged_files_into_head(repo):
|
||||||
|
write(repo, "a.py", "amended\n")
|
||||||
|
changes = {c.path: c for c in git.changed_files(repo)}
|
||||||
|
git.stage(repo, changes["a.py"])
|
||||||
|
before = git.run(repo, ["rev-list", "--count", "HEAD"])
|
||||||
|
|
||||||
|
git.amend(repo, "initial, with a.py")
|
||||||
|
|
||||||
|
assert git.run(repo, ["rev-list", "--count", "HEAD"]) == before
|
||||||
|
assert not git.changed_files(repo)
|
||||||
|
assert message(repo) == "initial, with a.py"
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_message_without_a_commit_raises(tmp_path):
|
||||||
|
fresh = str(tmp_path)
|
||||||
|
sh(fresh, "init", "-q", "-b", "main")
|
||||||
|
with pytest.raises(git.GitError):
|
||||||
|
git.head_message(fresh)
|
||||||
|
|||||||
86
tests/test_palette.py
Normal file
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
|
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
|
DIFF = """--- a/x.py
|
||||||
+++ b/x.py
|
+++ b/x.py
|
||||||
@@ -34,13 +34,33 @@ def header(view):
|
|||||||
return view.phantoms[render.PHANTOM_KEY][0].content
|
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():
|
def test_render_text_regions_and_header():
|
||||||
view = sublime.View()
|
view = with_loaded_override(sublime.View())
|
||||||
render.init_view(view)
|
render.init_view(view)
|
||||||
st = make_state()
|
st = make_state()
|
||||||
render.render(view, st)
|
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.is_read_only()
|
||||||
assert view.syntax.endswith("Python.sublime-syntax")
|
assert view.syntax.endswith("Python.sublime-syntax")
|
||||||
|
|
||||||
@@ -132,18 +152,18 @@ def test_help_popup_lists_every_key():
|
|||||||
view, html = help_popup(st)
|
view, html = help_popup(st)
|
||||||
|
|
||||||
assert view.is_popup_visible()
|
assert view.is_popup_visible()
|
||||||
for group in ("Navigate", "Panels", "This file", "View"):
|
for group in ("Navigate", "Actions", "View"):
|
||||||
assert ">{}<".format(group) in html
|
assert f">{group}<" in html
|
||||||
for keys in ("] [", "n p", "o ⏎", "f", "c", "s", "u", "d", "r", "?", "esc"):
|
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "z", "esc"):
|
||||||
assert ">{}<".format(keys) in html
|
assert f">{keys}<" in html
|
||||||
assert "next / previous change" in html and "open the real file at this line" 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
|
# working-tree mode: nothing dimmed, every single-action row is a link
|
||||||
assert 'class="dim"' not in html and "uncommitted only" not in html
|
assert 'class="dim"' not in html and "uncommitted only" not in html
|
||||||
assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html
|
assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html
|
||||||
# rows covering two actions stay plain text
|
# rows covering two actions stay plain text
|
||||||
assert 'href="next_change"' not in html
|
assert 'href="next_change"' not in html
|
||||||
# the status bar advertises it too
|
# the popup does not list the key that opens it
|
||||||
assert "? keys" in view.status[render.STATUS_KEY]
|
assert ">?<" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_help_popup_dims_file_actions_in_commit_mode():
|
def test_help_popup_dims_file_actions_in_commit_mode():
|
||||||
@@ -152,8 +172,9 @@ def test_help_popup_dims_file_actions_in_commit_mode():
|
|||||||
_, html = help_popup(st)
|
_, html = help_popup(st)
|
||||||
|
|
||||||
assert "uncommitted only" in html
|
assert "uncommitted only" in html
|
||||||
assert html.count('class="dim"') == 3
|
assert html.count('class="dim"') == 5
|
||||||
assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html
|
assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html
|
||||||
|
assert 'href="commit"' not in html and 'href="amend"' not in html
|
||||||
# everything that still applies stays clickable
|
# everything that still applies stays clickable
|
||||||
assert 'href="files"' in html and 'href="open"' in html and 'href="refresh"' in html
|
assert 'href="files"' in html and 'href="open"' in html and 'href="refresh"' in html
|
||||||
|
|
||||||
@@ -176,25 +197,43 @@ def test_help_popup_toggles_and_navigates():
|
|||||||
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc"}
|
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc"}
|
||||||
|
|
||||||
|
|
||||||
def test_keymap_matches_help_table():
|
def keymap_bindings():
|
||||||
"""The keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
|
|
||||||
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Default.sublime-keymap")
|
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Default.sublime-keymap")
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
bindings = json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def test_keymap_matches_help_table():
|
||||||
|
"""The keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
|
||||||
|
bindings = keymap_bindings()
|
||||||
|
|
||||||
assert all(b["command"] == "vdiff_action" for b in bindings)
|
assert all(b["command"] == "vdiff_action" for b in bindings)
|
||||||
bound = {b["args"]["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}
|
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
|
# 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"]}
|
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 in command mode; a binding still carrying the old `command_mode: false` context
|
||||||
|
would go dead there, which is what broke n / p.
|
||||||
|
"""
|
||||||
|
guard = {"key": "setting.command_mode", "operand": False}
|
||||||
|
for binding in keymap_bindings():
|
||||||
|
assert guard not in binding["context"], binding["keys"]
|
||||||
|
view = sublime.View()
|
||||||
|
render.init_view(view)
|
||||||
|
assert view.settings().get("command_mode") is True
|
||||||
|
|
||||||
|
|
||||||
def test_minihtml_stylesheets_reject_unsupported_selectors():
|
def test_minihtml_stylesheets_reject_unsupported_selectors():
|
||||||
"""minihtml drops the rest of a stylesheet at a selector it cannot parse; these are the forms
|
"""minihtml drops the rest of a stylesheet at an unparsable selector; these forms broke HELP_CSS"""
|
||||||
that silently broke HELP_CSS, so no stylesheet constant may use them"""
|
|
||||||
# descendant/child/sibling combinators, pseudo-classes, attribute selectors, compound classes
|
# descendant/child/sibling combinators, pseudo-classes, attribute selectors, compound classes
|
||||||
unsupported = re.compile(r"[\s:>~+\[]|[.#][^.#]*[.#]")
|
unsupported = re.compile(r"[\s:>~+\[]|[.#][^.#]*[.#]")
|
||||||
sheets = [(name, css) for name, css in vars(render).items() if name.endswith("_CSS") and isinstance(css, str)]
|
sheets = [(name, css) for name, css in vars(render).items() if name.endswith("_CSS") and isinstance(css, str)]
|
||||||
@@ -203,4 +242,191 @@ def test_minihtml_stylesheets_reject_unsupported_selectors():
|
|||||||
for rule in css.split("}"):
|
for rule in css.split("}"):
|
||||||
head = rule.split("{")[0].strip()
|
head = rule.split("{")[0].strip()
|
||||||
for selector in filter(None, (s.strip() for s in head.split(","))):
|
for selector in filter(None, (s.strip() for s in head.split(","))):
|
||||||
assert not unsupported.search(selector), "{}: {}".format(name, selector)
|
assert not unsupported.search(selector), f"{name}: {selector}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- header summary line ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_counts_staged_and_unstaged_files():
|
||||||
|
view = sublime.View()
|
||||||
|
render.init_view(view)
|
||||||
|
st = make_state()
|
||||||
|
st.files = [
|
||||||
|
git.FileChange("a.py", "M", True, 1, 1, None),
|
||||||
|
git.FileChange("b.py", "M", False, 1, 1, None),
|
||||||
|
git.FileChange("c.py", "?", False, 1, 0, None),
|
||||||
|
]
|
||||||
|
render.render(view, st)
|
||||||
|
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
|
||||||
|
|
||||||
|
# the untracked file's whole content counts as insertions, the way git.changed_files records it
|
||||||
|
assert summary.startswith("1 staged, 2 unstaged · ")
|
||||||
|
assert '<span class="del">-2</span>' in summary and '<span class="ins">+3</span>' in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_totals_skip_binary_files_but_still_count_them():
|
||||||
|
view = sublime.View()
|
||||||
|
render.init_view(view)
|
||||||
|
st = make_state()
|
||||||
|
st.files = [
|
||||||
|
git.FileChange("a.py", "M", False, 4, 1, None),
|
||||||
|
git.FileChange("logo.png", "M", False, None, None, None), # binary: no counts, still a file
|
||||||
|
]
|
||||||
|
render.render(view, st)
|
||||||
|
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
|
||||||
|
|
||||||
|
assert summary.startswith("0 staged, 2 unstaged · ")
|
||||||
|
assert '<span class="del">-1</span>' in summary and '<span class="ins">+4</span>' in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_totals_a_commit_and_owns_the_date():
|
||||||
|
view = sublime.View()
|
||||||
|
render.init_view(view)
|
||||||
|
st = make_state()
|
||||||
|
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", ""))
|
||||||
|
st.change = git.FileChange("x.py", "M", False, 3, 2, None)
|
||||||
|
st.files = [
|
||||||
|
git.FileChange("x.py", "M", False, 3, 2, None),
|
||||||
|
git.FileChange("y.py", "M", False, 7, 0, None),
|
||||||
|
git.FileChange("logo.png", "M", False, None, None, None), # binary: no counts, still a file
|
||||||
|
]
|
||||||
|
render.render(view, st)
|
||||||
|
html = header(view)
|
||||||
|
|
||||||
|
# hash, author, date, totals and message all live on the one summary line
|
||||||
|
summary = html.split('<div class="summary">')[1].split("</div>")[0]
|
||||||
|
for part in ("abc1234", "Ann", "2026-09-23 10:00", "3 files changed", "-2", "+10", "Fix things"):
|
||||||
|
assert part in summary, part
|
||||||
|
# and nowhere else: there is no second commit line any more
|
||||||
|
assert html.count("abc1234") == 1 and html.count("2026-09-23 10:00") == 1
|
||||||
|
assert 'class="commit"' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_is_omitted_without_a_file_list():
|
||||||
|
view = sublime.View()
|
||||||
|
render.init_view(view)
|
||||||
|
st = make_state()
|
||||||
|
render.render(view, st)
|
||||||
|
assert 'class="summary"' not in header(view)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flash_tints_the_buffer_then_clears(monkeypatch):
|
||||||
|
view = sublime.View("some text")
|
||||||
|
timers = []
|
||||||
|
monkeypatch.setattr(render.sublime, "set_timeout", lambda fn, ms=0: timers.append((fn, ms)))
|
||||||
|
|
||||||
|
render.flash(view)
|
||||||
|
regions, scope, _, flags = view.regions[render.BELL_KEY]
|
||||||
|
assert regions == [sublime.Region(0, len(view.text))] and flags == sublime.DRAW_NO_OUTLINE
|
||||||
|
assert scope and timers[0][1] == render.BELL_MS
|
||||||
|
|
||||||
|
timers[0][0]()
|
||||||
|
assert render.BELL_KEY not in view.regions
|
||||||
|
|
||||||
|
|
||||||
|
def test_flash_survives_a_re_render(monkeypatch):
|
||||||
|
"""The wrap that rings also re-renders; _replace must not wipe the bell before it is seen"""
|
||||||
|
assert render.BELL_KEY not in render.REGION_KEYS
|
||||||
|
monkeypatch.setattr(render.sublime, "set_timeout", lambda fn, ms=0: None) # hold the bell up
|
||||||
|
view = sublime.View()
|
||||||
|
render.init_view(view)
|
||||||
|
render.flash(view)
|
||||||
|
render.render(view, make_state())
|
||||||
|
assert render.BELL_KEY in view.regions
|
||||||
|
|
||||||
|
|
||||||
|
def test_bell_scope_falls_back_when_the_scheme_lacks_it():
|
||||||
|
view = with_loaded_override(sublime.View())
|
||||||
|
assert render._bell_scope(view) == render.SCOPE_BELL
|
||||||
|
del view.styles["region.yellowish"]
|
||||||
|
# an invisible bell is a silent failure, so fall back to a scope that is resolving
|
||||||
|
assert render._bell_scope(view) == palette.SCOPE_INS
|
||||||
|
# ... and when the plugin's own scopes are not resolving either, to the scheme's
|
||||||
|
view.override_loaded = False
|
||||||
|
assert render._bell_scope(view) == "diff.inserted"
|
||||||
|
|
||||||
|
|
||||||
|
def token_regions(view, kind, colour):
|
||||||
|
"""The regions painted for one (background kind, colour) pair of the token layer"""
|
||||||
|
scope = palette.scope_for(kind, colour)
|
||||||
|
entry = view.regions.get(scope and scope.replace("vdiff.", "vdiff.tok.", 1))
|
||||||
|
return entry[0] if entry else []
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_lines_are_repainted_in_the_files_own_colours():
|
||||||
|
view = with_loaded_override(sublime.View())
|
||||||
|
render.init_view(view)
|
||||||
|
render.render(view, make_state())
|
||||||
|
|
||||||
|
keyword = view.styles["keyword"]["foreground"]
|
||||||
|
punctuation = view.styles["punctuation.definition"]["foreground"]
|
||||||
|
# " return 10" is inserted: its words carry the keyword colour, its spaces and digits their
|
||||||
|
# own runs. Without this layer all of them would be the tint's flat foreground.
|
||||||
|
inserted = token_regions(view, "ins", keyword)
|
||||||
|
assert sublime.Region(view.text_point(4, 4), view.text_point(4, 10)) in inserted
|
||||||
|
# and a deleted line is painted from the deleted palette, not the inserted one
|
||||||
|
assert sublime.Region(view.text_point(2, 4), view.text_point(2, 10)) in token_regions(view, "del", keyword)
|
||||||
|
assert token_regions(view, "del", punctuation)
|
||||||
|
|
||||||
|
# the plain tint still covers the whole line underneath, so gaps and line ends stay coloured
|
||||||
|
assert view.regions[render.KEY_INS][1] == palette.SCOPE_INS
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_runs_merge_but_stop_at_a_word_diff_range():
|
||||||
|
view = with_loaded_override(sublime.View())
|
||||||
|
render.init_view(view)
|
||||||
|
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1 +1 @@\n-foo = bar\n+foo = baz\n"))
|
||||||
|
|
||||||
|
keyword = view.styles["keyword"]["foreground"]
|
||||||
|
# only the changed character sits in the stronger tint; the rest of the word keeps the line tint
|
||||||
|
assert token_regions(view, "ins.char", keyword) == [sublime.Region(view.text_point(2, 8), view.text_point(2, 9))]
|
||||||
|
plain = token_regions(view, "ins", keyword)
|
||||||
|
assert sublime.Region(view.text_point(2, 6), view.text_point(2, 8)) in plain
|
||||||
|
assert sublime.Region(view.text_point(2, 0), view.text_point(2, 3)) in plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_layer_waits_for_the_scopes_to_resolve():
|
||||||
|
view = sublime.View()
|
||||||
|
render.init_view(view)
|
||||||
|
render.render(view, make_state())
|
||||||
|
|
||||||
|
# the override is not loaded yet, so the lines keep the flat tint and no token key is painted:
|
||||||
|
# an unknown scope would draw its text in sublime's marker green
|
||||||
|
assert view.regions[render.KEY_DEL][1] == "diff.deleted"
|
||||||
|
assert view.regions[render.KEY_INS][1] == "diff.inserted"
|
||||||
|
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_layer_is_skipped_for_a_diff_too_big_to_read(monkeypatch):
|
||||||
|
monkeypatch.setattr(render, "TOKEN_COLOUR_MAX_CHARS", 4)
|
||||||
|
view = with_loaded_override(sublime.View())
|
||||||
|
render.init_view(view)
|
||||||
|
render.render(view, make_state())
|
||||||
|
|
||||||
|
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_regions_are_dropped_when_a_re_render_no_longer_needs_them():
|
||||||
|
view = with_loaded_override(sublime.View())
|
||||||
|
render.init_view(view)
|
||||||
|
render.render(view, make_state())
|
||||||
|
assert [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||||
|
|
||||||
|
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n"))
|
||||||
|
keyword = view.styles["keyword"]["foreground"]
|
||||||
|
# the new diff paints far fewer runs; the keys the old one used must not linger
|
||||||
|
assert token_regions(view, "ins", keyword) == [sublime.Region(view.text_point(3, 0), view.text_point(3, 1))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
|
||||||
|
said = []
|
||||||
|
monkeypatch.setattr(sublime, "status_message", said.append)
|
||||||
|
view = sublime.View()
|
||||||
|
render.init_view(view)
|
||||||
|
render.render(view, make_state())
|
||||||
|
|
||||||
|
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
||||||
|
# said once per scheme, not once per render
|
||||||
|
render.render(view, make_state())
|
||||||
|
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
||||||
|
|||||||
@@ -5,9 +5,15 @@
|
|||||||
// ask before discarding a file's changes
|
// ask before discarding a file's changes
|
||||||
"confirm_discard": true,
|
"confirm_discard": true,
|
||||||
|
|
||||||
|
// after staging a file, move on to the next unstaged file of the list
|
||||||
|
"stage_and_move_next_file": true,
|
||||||
|
|
||||||
// mark the changed characters inside paired deleted/inserted lines
|
// mark the changed characters inside paired deleted/inserted lines
|
||||||
"word_diff": true,
|
"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 executable; sublime's PATH may not include a user-installed git
|
||||||
"git_binary": "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 bisect
|
||||||
import os
|
import os
|
||||||
|
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from . import git, parse, render, state
|
from . import git, parse, render, state
|
||||||
from .utils import run_async, settings, status
|
from .git import Commit, FileChange
|
||||||
|
from .state import Kept, ViewState
|
||||||
|
from .utils import file_count, run_async, settings, status
|
||||||
|
|
||||||
|
# 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
|
# 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,
|
"M": sublime.KIND_ID_COLOR_ORANGISH,
|
||||||
"A": sublime.KIND_ID_COLOR_GREENISH,
|
"A": sublime.KIND_ID_COLOR_GREENISH,
|
||||||
"?": sublime.KIND_ID_COLOR_GREENISH,
|
"?": sublime.KIND_ID_COLOR_GREENISH,
|
||||||
@@ -21,25 +27,33 @@ STATUS_COLOURS = {
|
|||||||
}
|
}
|
||||||
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
|
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
|
||||||
|
|
||||||
|
# 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
|
# highlight-to-preview delay: holding an arrow key must not spawn a git process per row
|
||||||
PREVIEW_DELAY_MS = 60
|
PREVIEW_DELAY_MS = 60
|
||||||
|
|
||||||
# directory -> repository root; roots do not move, and resolving runs on the UI thread
|
# cache directory -> repo root: resolve_repo runs git on the UI thread
|
||||||
_roots = {}
|
_roots: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def resolve_repo(window):
|
def resolve_repo(window: sublime.Window) -> Optional[str]:
|
||||||
"""Repository of the review view or active file, else of the first project folder that is one"""
|
"""Repository of the review view or active file, else of the first project folder that is one"""
|
||||||
view = window.active_view()
|
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 view is not None:
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
st = state.get(view) or restore_view(view)
|
st = state.get(view) or restore_view(view)
|
||||||
if st.repo:
|
if st.repo:
|
||||||
return st.repo
|
return st.repo
|
||||||
elif view.file_name():
|
else:
|
||||||
candidates.append(os.path.dirname(view.file_name()))
|
name = view.file_name()
|
||||||
|
if name:
|
||||||
|
candidates.append(os.path.dirname(name))
|
||||||
candidates.extend(window.folders())
|
candidates.extend(window.folders())
|
||||||
|
# first candidate in a repo wins; only hits are cached
|
||||||
for directory in candidates:
|
for directory in candidates:
|
||||||
root = _roots.get(directory)
|
root = _roots.get(directory)
|
||||||
if root is None:
|
if root is None:
|
||||||
@@ -51,15 +65,16 @@ def resolve_repo(window):
|
|||||||
return None
|
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():
|
for view in window.views():
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
return view
|
return view
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def open_review(window):
|
def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]:
|
||||||
"""(view, state) of the window's single review view for the resolved repo, focused; None without a repo"""
|
"""Focus the window's review view (created if missing) on the resolved repo; (view, state), or None without a repo"""
|
||||||
repo = resolve_repo(window)
|
repo = resolve_repo(window)
|
||||||
if not repo:
|
if not repo:
|
||||||
status("no git repository for the active file or project folders")
|
status("no git repository for the active file or project folders")
|
||||||
@@ -76,20 +91,48 @@ def open_review(window):
|
|||||||
return view, st
|
return view, st
|
||||||
|
|
||||||
|
|
||||||
def show_file(view, st, change):
|
def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) -> None:
|
||||||
"""Fetch and parse the file's diff on the worker thread, then render it"""
|
"""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
|
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
|
token = st.token
|
||||||
repo, sha = st.repo, st.sha
|
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:
|
if token != st.token:
|
||||||
return None # superseded before it started: skip the subprocess
|
return None # superseded before it started: skip the subprocess
|
||||||
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
|
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
|
||||||
diff = parse.parse_unified(text)
|
diff = parse.parse_unified(text)
|
||||||
return diff, parse.change_blocks(diff.lines)
|
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():
|
if result is None or token != st.token or not view.is_valid():
|
||||||
return
|
return
|
||||||
diff, blocks = result
|
diff, blocks = result
|
||||||
@@ -101,8 +144,9 @@ def show_file(view, st, change):
|
|||||||
run_async(work, done)
|
run_async(work, done)
|
||||||
|
|
||||||
|
|
||||||
def file_items(files):
|
def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
||||||
items = []
|
"""Quick-panel rows: path; status, staged flag, rename source; line totals or binary"""
|
||||||
|
items: List[sublime.QuickPanelItem] = []
|
||||||
for change in files:
|
for change in files:
|
||||||
description = git.STATUS_NAMES.get(change.status, "")
|
description = git.STATUS_NAMES.get(change.status, "")
|
||||||
colour = STATUS_COLOURS.get(change.status)
|
colour = STATUS_COLOURS.get(change.status)
|
||||||
@@ -110,28 +154,50 @@ def file_items(files):
|
|||||||
details = ", ".join(filter(None, (description, "staged" if change.staged else "")))
|
details = ", ".join(filter(None, (description, "staged" if change.staged else "")))
|
||||||
if change.old_path:
|
if change.old_path:
|
||||||
details += " ← " + change.old_path
|
details += " ← " + change.old_path
|
||||||
|
# no line counts means git saw a binary file
|
||||||
if None in (change.insertions, change.deletions):
|
if None in (change.insertions, change.deletions):
|
||||||
annotation = "binary"
|
annotation = "binary"
|
||||||
else:
|
else:
|
||||||
annotation = "-{} +{}".format(change.deletions, change.insertions)
|
annotation = f"-{change.deletions} +{change.insertions}"
|
||||||
items.append(sublime.QuickPanelItem(change.path, details, annotation, kind))
|
items.append(sublime.QuickPanelItem(change.path, details, annotation, kind))
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder="Changed files"):
|
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
||||||
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
|
"""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:
|
||||||
|
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:
|
||||||
|
subject = subject[: budget - 2] + ".."
|
||||||
|
items.append(sublime.QuickPanelItem(subject, f"{c.author} · {c.date}", annotation, COMMIT_KIND))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
|
||||||
|
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
|
||||||
|
placeholder: str = "Changed files") -> None:
|
||||||
|
"""Quick panel over `files`: 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
|
st.files = files
|
||||||
selected = max(0, st.index())
|
selected = max(0, st.index())
|
||||||
window.focus_view(view)
|
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
|
st.highlight += 1 # cancel a pending preview
|
||||||
if index == -1:
|
if index == -1:
|
||||||
if on_cancel is not None:
|
if on_cancel is not None:
|
||||||
st.restore(kept)
|
st.restore(kept)
|
||||||
on_cancel()
|
on_cancel()
|
||||||
elif kept.change is not None:
|
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)
|
st.restore(kept)
|
||||||
show_file(view, st, kept.change)
|
show_file(view, st, kept.change)
|
||||||
# nothing was kept before: the last previewed file stays
|
# nothing was kept before: the last previewed file stays
|
||||||
@@ -141,39 +207,60 @@ def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder=
|
|||||||
return # the preview already shows it
|
return # the preview already shows it
|
||||||
show_file(view, st, change)
|
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):
|
if not 0 <= index < len(files):
|
||||||
return
|
return
|
||||||
st.highlight += 1
|
st.highlight += 1
|
||||||
ticket = st.highlight
|
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)
|
window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)
|
||||||
|
|
||||||
|
|
||||||
def refresh_listing(view, st, message=None):
|
def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] = None,
|
||||||
"""Re-list the working tree changes and show the same file, or the next one when it is gone"""
|
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 choose another file; `index` is the shown file's new slot, or its old one if it left the list
|
||||||
|
"""
|
||||||
if message:
|
if message:
|
||||||
status(message)
|
status(message)
|
||||||
|
# remember the shown file and its slot before the list changes
|
||||||
previous_index = max(0, st.index())
|
previous_index = max(0, st.index())
|
||||||
path = st.change.path if st.change else None
|
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():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
st.files = files
|
st.files = files
|
||||||
|
if not files:
|
||||||
|
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
|
||||||
|
return
|
||||||
index = state.find_change(files, path)
|
index = state.find_change(files, path)
|
||||||
if index >= 0:
|
if index < 0:
|
||||||
show_file(view, st, files[index])
|
# the file left the list: whatever moved up into its slot takes over
|
||||||
elif files:
|
index = min(previous_index, len(files) - 1)
|
||||||
show_file(view, st, files[min(previous_index, len(files) - 1)])
|
chosen = pick(files, index) if pick else None
|
||||||
|
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:
|
else:
|
||||||
render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
|
show_file(view, st, change)
|
||||||
|
|
||||||
run_async(lambda: git.changed_files(st.repo), done)
|
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"""
|
"""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:
|
if st.in_commit or st.change is None:
|
||||||
return
|
return
|
||||||
@@ -184,22 +271,24 @@ def refresh_if_changed(view, st):
|
|||||||
class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
||||||
"""List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
|
"""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
|
window = self.window
|
||||||
opened = open_review(window)
|
opened = open_review(window)
|
||||||
if not opened:
|
if not opened:
|
||||||
return
|
return
|
||||||
view, st = opened
|
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():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
kept = st.enter()
|
kept = st.enter()
|
||||||
if not files:
|
if not files:
|
||||||
render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
|
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
|
||||||
status("no uncommitted changes")
|
status("no uncommitted changes")
|
||||||
return
|
return
|
||||||
placeholder = "Changed files in {}".format(os.path.basename(st.repo))
|
placeholder = f"Changed files in {os.path.basename(st.repo)}"
|
||||||
show_files_panel(window, view, st, files, kept, placeholder=placeholder)
|
show_files_panel(window, view, st, files, kept, placeholder=placeholder)
|
||||||
|
|
||||||
run_async(lambda: git.changed_files(st.repo), done)
|
run_async(lambda: git.changed_files(st.repo), done)
|
||||||
@@ -208,7 +297,8 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
|||||||
class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||||
"""List recent commits, then the files of the chosen commit; `sha` skips straight to one commit"""
|
"""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)
|
opened = open_review(self.window)
|
||||||
if not opened:
|
if not opened:
|
||||||
return
|
return
|
||||||
@@ -218,7 +308,8 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
return
|
return
|
||||||
count = int(settings().get("commits_count", 30) or 30)
|
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():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
if not commits:
|
if not commits:
|
||||||
@@ -228,38 +319,39 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
|
|
||||||
run_async(lambda: git.recent_commits(st.repo, count), done)
|
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:
|
||||||
items = [
|
"""Recent-commits panel, preselecting the commit already shown"""
|
||||||
sublime.QuickPanelItem(c.subject or "(no subject)", "{} · {}".format(c.author, c.date), c.short, COMMIT_KIND)
|
items = commit_items(commits)
|
||||||
for c in commits
|
|
||||||
]
|
|
||||||
for i, c in enumerate(commits):
|
for i, c in enumerate(commits):
|
||||||
if c.sha == st.sha:
|
if c.sha == st.sha:
|
||||||
selected = i
|
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:
|
if index != -1:
|
||||||
self.open_commit(view, st, commits[index].sha, index)
|
self.open_commit(view, st, commits[index].sha, index)
|
||||||
|
|
||||||
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
|
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
|
||||||
|
|
||||||
def open_commit(self, view, st, sha, index):
|
def open_commit(self, view: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None:
|
||||||
"""Files panel of one commit; Esc goes back to the commit list when it came from there (index given)"""
|
"""Files panel of one commit; with `index`, Esc reopens the commit list on that row"""
|
||||||
window = self.window
|
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():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
info, files = result
|
info, files = result
|
||||||
kept = st.enter(info.sha, info)
|
kept = st.enter(info.sha, info)
|
||||||
if not files:
|
if not files:
|
||||||
render.render_message(view, st, "Commit {} touches no files".format(info.short))
|
render.render_message(view, st, f"Commit {info.short} touches no files")
|
||||||
return
|
return
|
||||||
|
|
||||||
def back():
|
def back() -> None:
|
||||||
|
"""Reopen the commit list on the row this commit came from"""
|
||||||
window.run_command("vdiff_commits", {"selected": index})
|
window.run_command("vdiff_commits", {"selected": index})
|
||||||
|
|
||||||
placeholder = "{} · {}".format(info.short, info.subject)
|
placeholder = f"{info.short} · {info.subject}"
|
||||||
on_cancel = back if index is not None else None
|
on_cancel = back if index is not None else None
|
||||||
show_files_panel(window, view, st, files, kept, on_cancel=on_cancel, placeholder=placeholder)
|
show_files_panel(window, view, st, files, kept, on_cancel=on_cancel, placeholder=placeholder)
|
||||||
|
|
||||||
@@ -269,48 +361,85 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
# --- review view actions ----------------------------------------------------
|
# --- 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")
|
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")
|
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()
|
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)
|
render.show_help(view, st)
|
||||||
|
|
||||||
|
|
||||||
def _git_action(verb, done_verb, operation, confirm=None):
|
def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None],
|
||||||
"""Action running `operation(repo, change)` on the shown working-tree file, then re-listing"""
|
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"""
|
||||||
|
|
||||||
def act(view, window, st):
|
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if st.in_commit or st.change is None:
|
"""Confirm if asked, run the operation off the UI thread, then refresh the listing"""
|
||||||
status("{} works on uncommitted changes only".format(verb))
|
change = _current_change(view, st)
|
||||||
|
if st.in_commit or change is None:
|
||||||
|
status(f"{verb} works on uncommitted changes only")
|
||||||
return
|
return
|
||||||
change = st.change
|
|
||||||
if confirm is not None and not confirm(change):
|
if confirm is not None and not confirm(change):
|
||||||
return
|
return
|
||||||
run_async(lambda: operation(st.repo, change), lambda _: refresh_listing(view, st, "{} {}".format(done_verb, change.path)))
|
run_async(
|
||||||
|
lambda: operation(st.repo, change),
|
||||||
|
lambda _: refresh_listing(view, st, f"{done_verb} {change.path}", pick=pick),
|
||||||
|
)
|
||||||
|
|
||||||
return act
|
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):
|
if not settings().get("confirm_discard", True):
|
||||||
return True
|
return True
|
||||||
|
# untracked files are deleted outright, tracked ones reset
|
||||||
if change.status == "?":
|
if change.status == "?":
|
||||||
message = "Delete the untracked file {}?\n\nThis cannot be undone.".format(change.path)
|
message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
|
||||||
else:
|
else:
|
||||||
message = "Discard all changes to {}?\n\nThe file goes back to its last committed version. This cannot be undone.".format(change.path)
|
message = (
|
||||||
|
f"Discard all changes to {change.path}?\n\n"
|
||||||
|
"The file goes back to its last committed version. This cannot be undone."
|
||||||
|
)
|
||||||
return sublime.ok_cancel_dialog(message, "Discard")
|
return sublime.ok_cancel_dialog(message, "Discard")
|
||||||
|
|
||||||
|
|
||||||
def _act_refresh(view, window, st):
|
def _next_unstaged(files: Sequence[FileChange], start: int) -> int:
|
||||||
|
"""Index of the first unstaged file from `start` on, wrapping round; -1 when every file is staged"""
|
||||||
|
for offset in range(len(files)):
|
||||||
|
index = (start + offset) % len(files)
|
||||||
|
if not files[index].staged:
|
||||||
|
return index
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_next(files: Sequence[FileChange], index: int) -> Optional[FileChange]:
|
||||||
|
"""After staging, the next unstaged file to show; None keeps the file the refresh would show"""
|
||||||
|
if not settings().get("stage_and_move_next_file", True):
|
||||||
|
return None
|
||||||
|
# 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: 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:
|
if st.change is None:
|
||||||
return
|
return
|
||||||
if st.in_commit:
|
if st.in_commit:
|
||||||
@@ -319,39 +448,153 @@ def _act_refresh(view, window, st):
|
|||||||
refresh_listing(view, st)
|
refresh_listing(view, st)
|
||||||
|
|
||||||
|
|
||||||
def _act_open(view, window, st):
|
def _current_change(view: sublime.View, st: ViewState) -> Optional[FileChange]:
|
||||||
if st.change is None:
|
"""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
|
return
|
||||||
path = os.path.join(st.repo, st.change.path)
|
path = os.path.join(st.repo, change.path)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
status("{} does not exist in the working tree".format(st.change.path))
|
status(f"{change.path} does not exist in the working tree")
|
||||||
return
|
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))
|
line = parse.line_for_open(st.lines, render.caret_index(view))
|
||||||
window.open_file("{}:{}".format(path, line), sublime.ENCODED_POSITION)
|
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
|
||||||
|
|
||||||
|
|
||||||
def _step_file(delta):
|
def _act_view(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
def act(view, window, st):
|
"""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:
|
if not st.files:
|
||||||
status("no file list, press f or c")
|
status("no file list, press f or c")
|
||||||
return
|
return
|
||||||
index = st.index() + delta
|
count = len(st.files)
|
||||||
if not 0 <= index < len(st.files):
|
position = st.index()
|
||||||
status("no more files")
|
if position < 0:
|
||||||
return
|
# 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
|
||||||
|
wrapped = not 0 <= index < count
|
||||||
|
index %= count # -1 is the last file, count is the first
|
||||||
|
if wrapped:
|
||||||
|
render.flash(view)
|
||||||
|
if index != position:
|
||||||
|
# a one-file list wraps onto itself: ring, but do not re-run git for the same file
|
||||||
show_file(view, st, st.files[index])
|
show_file(view, st, st.files[index])
|
||||||
|
|
||||||
return act
|
return act
|
||||||
|
|
||||||
|
|
||||||
def _step_change(delta):
|
def _step_change(delta: int) -> Action:
|
||||||
def act(view, window, st):
|
"""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]
|
starts = [start for start, _ in st.blocks]
|
||||||
|
if not starts:
|
||||||
|
status("no changes to step through")
|
||||||
|
return
|
||||||
row = render.caret_index(view)
|
row = render.caret_index(view)
|
||||||
position = bisect.bisect_right(starts, row) if delta > 0 else bisect.bisect_left(starts, row) - 1
|
position = bisect.bisect_right(starts, row) if delta > 0 else bisect.bisect_left(starts, row) - 1
|
||||||
if not 0 <= position < len(starts):
|
if not 0 <= position < len(starts):
|
||||||
status("no more changes")
|
render.flash(view)
|
||||||
return
|
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])
|
point = render.index_point(view, starts[position])
|
||||||
view.sel().clear()
|
view.sel().clear()
|
||||||
view.sel().add(sublime.Region(point))
|
view.sel().add(sublime.Region(point))
|
||||||
@@ -360,71 +603,165 @@ def _step_change(delta):
|
|||||||
return act
|
return act
|
||||||
|
|
||||||
|
|
||||||
ACTIONS = {
|
def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Commit the staged files with a message typed into an input panel; Esc there cancels"""
|
||||||
|
if st.in_commit:
|
||||||
|
status("commit works on uncommitted changes only")
|
||||||
|
return
|
||||||
|
staged = [c for c in st.files if c.staged]
|
||||||
|
if not staged:
|
||||||
|
status("no staged files")
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_done(message: str) -> None:
|
||||||
|
"""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
|
||||||
|
run_async(
|
||||||
|
lambda: git.commit(st.repo, message),
|
||||||
|
lambda _: refresh_listing(view, st, f"committed {file_count(len(staged))}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
window.show_input_panel("Commit message", "", on_done, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels
|
||||||
|
|
||||||
|
The one-line panel offers only the subject; the body rides along verbatim, and nothing staged is a reword
|
||||||
|
"""
|
||||||
|
if st.in_commit:
|
||||||
|
status("amend works on uncommitted changes only")
|
||||||
|
return
|
||||||
|
staged = [c for c in st.files if c.staged]
|
||||||
|
|
||||||
|
def prompt(message: str) -> None:
|
||||||
|
"""Show the input panel prefilled with HEAD's subject"""
|
||||||
|
subject, newline, body = message.partition("\n")
|
||||||
|
|
||||||
|
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
|
||||||
|
done = "amended the last commit"
|
||||||
|
if staged:
|
||||||
|
done += f" with {file_count(len(staged))}"
|
||||||
|
run_async(
|
||||||
|
# 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
panel = window.show_input_panel("Amend message", subject, on_done, None, None)
|
||||||
|
# the panel selects the whole prefill; the caret belongs after it, ready to type
|
||||||
|
panel.sel().clear()
|
||||||
|
panel.sel().add(sublime.Region(panel.size()))
|
||||||
|
|
||||||
|
run_async(lambda: git.head_message(st.repo), prompt)
|
||||||
|
|
||||||
|
|
||||||
|
def _move_line(forward: bool) -> Action:
|
||||||
|
"""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: Dict[str, Action] = {
|
||||||
"files": _act_files,
|
"files": _act_files,
|
||||||
"commits": _act_commits,
|
"commits": _act_commits,
|
||||||
"close": _act_close,
|
"close": _act_close,
|
||||||
"help": _act_help,
|
"help": _act_help,
|
||||||
"stage": _git_action("stage", "staged", git.stage),
|
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
|
||||||
"unstage": _git_action("unstage", "unstaged", git.unstage),
|
"unstage": _git_action("unstage", "unstaged", git.unstage),
|
||||||
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
|
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
|
||||||
|
"commit": _act_commit,
|
||||||
|
"amend": _act_amend,
|
||||||
"refresh": _act_refresh,
|
"refresh": _act_refresh,
|
||||||
|
"view": _act_view,
|
||||||
|
"expand": _toggle_file(1),
|
||||||
|
"collapse": _toggle_file(-1),
|
||||||
|
"fold_all": _act_fold_all,
|
||||||
"open": _act_open,
|
"open": _act_open,
|
||||||
"next_file": _step_file(1),
|
"next_file": _step_file(1),
|
||||||
"prev_file": _step_file(-1),
|
"prev_file": _step_file(-1),
|
||||||
"next_change": _step_change(1),
|
"next_change": _step_change(1),
|
||||||
"prev_change": _step_change(-1),
|
"prev_change": _step_change(-1),
|
||||||
|
"line_down": _move_line(True),
|
||||||
|
"line_up": _move_line(False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class VdiffActionCommand(sublime_plugin.TextCommand):
|
class VdiffActionCommand(sublime_plugin.TextCommand):
|
||||||
"""Every key/link action of a review view, dispatched by name through ACTIONS"""
|
"""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)
|
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)
|
act = ACTIONS.get(action)
|
||||||
if act is None:
|
if act is None:
|
||||||
status("unknown action {}".format(action))
|
status(f"unknown action {action}")
|
||||||
return
|
return
|
||||||
st = state.get(self.view) or restore_view(self.view)
|
st = state.get(self.view) or restore_view(self.view)
|
||||||
act(self.view, self.view.window(), st)
|
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"""
|
"""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 {}
|
data = view.settings().get(state.VIEW_DATA) or {}
|
||||||
repo = data.get("repo")
|
repo = data.get("repo")
|
||||||
render.init_view(view)
|
render.init_view(view)
|
||||||
|
# repository gone: show a notice
|
||||||
if not repo or not os.path.isdir(repo):
|
if not repo or not os.path.isdir(repo):
|
||||||
st = state.put(view, state.ViewState(""))
|
st = state.put(view, state.ViewState(""))
|
||||||
render.render_message(view, st, "Repository not found. Press f for changed files, c for commits.")
|
render.render_message(view, st, "Repository not found. Press f for changed files, c for commits.")
|
||||||
return st
|
return st
|
||||||
st = state.put(view, state.ViewState(repo))
|
st = state.put(view, state.ViewState(repo))
|
||||||
st.sha = data.get("sha")
|
st.sha = data.get("sha")
|
||||||
|
st.all_mode = bool(data.get("all_mode", False))
|
||||||
path = data.get("path")
|
path = data.get("path")
|
||||||
if not path:
|
if not path:
|
||||||
render.render_message(view, st, "Press f for changed files, c for commits.")
|
render.render_message(view, st, "Press f for changed files, c for commits.")
|
||||||
return st
|
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)
|
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
|
st.commit, st.files = result
|
||||||
|
if st.all_mode:
|
||||||
|
st.change = change
|
||||||
|
show_all(view, st)
|
||||||
|
else:
|
||||||
show_file(view, st, change)
|
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:
|
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
|
st.files = files
|
||||||
index = state.find_change(files, path)
|
index = state.find_change(files, path)
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
|
if st.all_mode:
|
||||||
|
st.change = files[index]
|
||||||
|
show_all(view, st)
|
||||||
|
else:
|
||||||
show_file(view, st, files[index])
|
show_file(view, st, files[index])
|
||||||
else:
|
else:
|
||||||
render.render_message(view, st, "{} has no uncommitted changes any more".format(path))
|
render.render_message(view, st, f"{path} has no uncommitted changes any more")
|
||||||
|
|
||||||
run_async(lambda: git.changed_files(repo), done)
|
run_async(lambda: git.changed_files(repo), show_working_tree)
|
||||||
return st
|
return st
|
||||||
|
|||||||
165
vdiff/git.py
165
vdiff/git.py
@@ -1,12 +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 os
|
||||||
|
import re
|
||||||
import subprocess
|
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")
|
class FileChange(NamedTuple):
|
||||||
Commit = namedtuple("Commit", "sha short author date subject")
|
"""Path, one-letter status (see STATUS_NAMES), staged flag, numstat counts (None for binary), rename source"""
|
||||||
CommitInfo = namedtuple("CommitInfo", "sha short author date subject body")
|
|
||||||
|
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
|
# hash of the empty tree: the diff base for a repository without commits
|
||||||
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
||||||
@@ -15,9 +50,9 @@ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|||||||
FULL_CONTEXT = "-U999999"
|
FULL_CONTEXT = "-U999999"
|
||||||
|
|
||||||
# overridable from settings (sublime's PATH may not have a user-installed git)
|
# 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",
|
"M": "modified",
|
||||||
"A": "added",
|
"A": "added",
|
||||||
"D": "deleted",
|
"D": "deleted",
|
||||||
@@ -31,35 +66,39 @@ STATUS_NAMES = {
|
|||||||
_INFO_FORMAT = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%b"
|
_INFO_FORMAT = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%b"
|
||||||
_DATE_FORMAT = "--date=format:%Y-%m-%d %H:%M"
|
_DATE_FORMAT = "--date=format:%Y-%m-%d %H:%M"
|
||||||
|
|
||||||
|
# " 3 files changed, 10 insertions(+), 2 deletions(-)" - either count clause is dropped when zero
|
||||||
|
_SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?")
|
||||||
|
|
||||||
|
|
||||||
class GitError(Exception):
|
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.stderr = stderr
|
||||||
self.code = code
|
self.code = code
|
||||||
first = stderr.strip().splitlines()
|
first = stderr.strip().splitlines()
|
||||||
super().__init__(first[0] if first else "git exited with {}".format(code))
|
super().__init__(first[0] if first else f"git exited with {code}")
|
||||||
|
|
||||||
|
|
||||||
def run(repo, args, ok_codes=(0,)):
|
def run(repo: str, args: Sequence[str], ok_codes: Tuple[int, ...] = (0,)) -> str:
|
||||||
"""Run git in `repo`, return stdout as text"""
|
"""Run git in `repo`, return stdout as text"""
|
||||||
cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args)
|
cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args)
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace")
|
||||||
cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace"
|
|
||||||
)
|
|
||||||
if proc.returncode not in ok_codes:
|
if proc.returncode not in ok_codes:
|
||||||
raise GitError(proc.stderr, proc.returncode)
|
raise GitError(proc.stderr, proc.returncode)
|
||||||
return proc.stdout
|
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"""
|
"""Top-level directory of the repository containing `path`, or None"""
|
||||||
if not path:
|
if not path:
|
||||||
return None
|
return None
|
||||||
|
# start from the directory itself, or the file's parent
|
||||||
directory = path if os.path.isdir(path) else os.path.dirname(path)
|
directory = path if os.path.isdir(path) else os.path.dirname(path)
|
||||||
if not os.path.isdir(directory):
|
if not os.path.isdir(directory):
|
||||||
return None
|
return None
|
||||||
|
# outside a repository git fails, which reads as None
|
||||||
try:
|
try:
|
||||||
root = run(directory, ["rev-parse", "--show-toplevel"]).strip()
|
root = run(directory, ["rev-parse", "--show-toplevel"]).strip()
|
||||||
except (GitError, OSError):
|
except (GitError, OSError):
|
||||||
@@ -67,7 +106,7 @@ def repo_root(path):
|
|||||||
return root or None
|
return root or None
|
||||||
|
|
||||||
|
|
||||||
def has_head(repo):
|
def has_head(repo: str) -> bool:
|
||||||
"""False in a repository without commits"""
|
"""False in a repository without commits"""
|
||||||
try:
|
try:
|
||||||
run(repo, ["rev-parse", "--verify", "-q", "HEAD"])
|
run(repo, ["rev-parse", "--verify", "-q", "HEAD"])
|
||||||
@@ -76,19 +115,20 @@ def has_head(repo):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def base_ref(repo):
|
def base_ref(repo: str) -> str:
|
||||||
"""HEAD, or the empty tree when there is no commit yet"""
|
"""HEAD, or the empty tree when there is no commit yet"""
|
||||||
return "HEAD" if has_head(repo) else EMPTY_TREE
|
return "HEAD" if has_head(repo) else EMPTY_TREE
|
||||||
|
|
||||||
|
|
||||||
def _int_or_none(value):
|
def _int_or_none(value: str) -> Optional[int]:
|
||||||
"""A numstat count: "-" marks a binary file"""
|
"""A numstat count as int, None for "-" (binary)"""
|
||||||
return None if value == "-" else int(value)
|
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)}"""
|
"""`--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"))
|
fields = iter(text.split("\0"))
|
||||||
for entry in fields:
|
for entry in fields:
|
||||||
parts = entry.split("\t")
|
parts = entry.split("\t")
|
||||||
@@ -104,9 +144,10 @@ def _parse_numstat(text):
|
|||||||
return result
|
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)]"""
|
"""`--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"))
|
fields = iter(text.split("\0"))
|
||||||
for status in fields:
|
for status in fields:
|
||||||
if not status:
|
if not status:
|
||||||
@@ -119,15 +160,17 @@ def _parse_name_status(text):
|
|||||||
return result
|
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"""
|
"""Line count of a working-tree file, None when binary or unreadable"""
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
|
# a NUL in the first 8000 bytes marks a binary file
|
||||||
head = f.read(8000)
|
head = f.read(8000)
|
||||||
if b"\0" in head:
|
if b"\0" in head:
|
||||||
return None
|
return None
|
||||||
count = head.count(b"\n")
|
count = head.count(b"\n")
|
||||||
last = head[-1:]
|
last = head[-1:]
|
||||||
|
# the rest in 1 MiB chunks, remembering the final byte
|
||||||
while True:
|
while True:
|
||||||
chunk = f.read(1 << 20)
|
chunk = f.read(1 << 20)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
@@ -136,12 +179,13 @@ def _count_lines(path):
|
|||||||
last = chunk[-1:]
|
last = chunk[-1:]
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
|
# an empty file has no lines; a last line without a newline still counts
|
||||||
if not last:
|
if not last:
|
||||||
return 0
|
return 0
|
||||||
return count + (0 if last == b"\n" else 1)
|
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"""
|
"""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"])
|
status = run(repo, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--no-renames"])
|
||||||
entries = [e for e in status.split("\0") if e]
|
entries = [e for e in status.split("\0") if e]
|
||||||
@@ -149,7 +193,8 @@ def changed_files(repo):
|
|||||||
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
|
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
|
||||||
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base]))
|
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:
|
for entry in entries:
|
||||||
kind = entry[0]
|
kind = entry[0]
|
||||||
if kind == "1":
|
if kind == "1":
|
||||||
@@ -161,9 +206,11 @@ def changed_files(repo):
|
|||||||
xy, path = ".?", entry[2:]
|
xy, path = ".?", entry[2:]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
# any index change means staged; the worktree letter wins over the index letter
|
||||||
x, y = xy[0], xy[1]
|
x, y = xy[0], xy[1]
|
||||||
staged = x not in (".", "?")
|
staged = x not in (".", "?")
|
||||||
letter = y if y != "." else x
|
letter = y if y != "." else x
|
||||||
|
# untracked files have no numstat: their lines count as insertions
|
||||||
if letter == "?":
|
if letter == "?":
|
||||||
lines = _count_lines(os.path.join(repo, path))
|
lines = _count_lines(os.path.join(repo, path))
|
||||||
ins, dele, old = lines, (None if lines is None else 0), None
|
ins, dele, old = lines, (None if lines is None else 0), None
|
||||||
@@ -174,33 +221,44 @@ def changed_files(repo):
|
|||||||
return changes
|
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"""
|
"""Last `count` commits, newest first; empty list without commits"""
|
||||||
if not has_head(repo):
|
if not has_head(repo):
|
||||||
return []
|
return []
|
||||||
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s"])
|
# --shortstat adds per-commit totals to the same call
|
||||||
commits = []
|
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
|
||||||
|
# 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"):
|
for line in text.split("\n"):
|
||||||
parts = line.split("\x1f")
|
parts = line.split("\x1f")
|
||||||
if len(parts) == 5:
|
if len(parts) == 5:
|
||||||
commits.append(Commit(*parts))
|
sha, short, author, date, subject = parts
|
||||||
|
commits.append(Commit(sha, short, author, date, subject, files=None, insertions=None, deletions=None))
|
||||||
|
continue
|
||||||
|
if commits and commits[-1].files is None:
|
||||||
|
stat = _SHORTSTAT.match(line)
|
||||||
|
if stat:
|
||||||
|
files, ins, dele = (int(g or 0) for g in stat.groups())
|
||||||
|
commits[-1] = commits[-1]._replace(files=files, insertions=ins, deletions=dele)
|
||||||
return commits
|
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"""
|
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
|
||||||
show = ["show", "--first-parent", "-m", "-z"]
|
show = ["show", "--first-parent", "-m", "-z"]
|
||||||
text = run(repo, show + ["--format={}%x00".format(_INFO_FORMAT), _DATE_FORMAT, "--name-status", sha])
|
text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
|
||||||
# git terminates the header with its own NUL and a newline before the file list
|
# our %x00 ends the header; strip the separator git adds before the file list
|
||||||
info_text, _, files_text = text.partition("\0")
|
info_text, _, files_text = text.partition("\0")
|
||||||
files_text = files_text.lstrip("\0\n")
|
files_text = files_text.lstrip("\0\n")
|
||||||
|
# pad missing trailing fields so CommitInfo always gets six
|
||||||
parts = info_text.split("\x1f")
|
parts = info_text.split("\x1f")
|
||||||
while len(parts) < 6:
|
while len(parts) < 6:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
info = CommitInfo(*parts[:6])
|
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]))
|
counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha]))
|
||||||
changes = []
|
changes: List[FileChange] = []
|
||||||
for status, path, old_path in _parse_name_status(files_text):
|
for status, path, old_path in _parse_name_status(files_text):
|
||||||
ins, dele, _ = counts.get(path, (None, None, None))
|
ins, dele, _ = counts.get(path, (None, None, None))
|
||||||
changes.append(FileChange(path, status, False, ins, dele, old_path))
|
changes.append(FileChange(path, status, False, ins, dele, old_path))
|
||||||
@@ -208,22 +266,23 @@ def commit_with_files(repo, sha):
|
|||||||
return info, changes
|
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`"""
|
"""Whether `path` exists in the tree of `ref`"""
|
||||||
try:
|
try:
|
||||||
run(repo, ["cat-file", "-e", "{}:{}".format(ref, path)])
|
run(repo, ["cat-file", "-e", f"{ref}:{path}"])
|
||||||
return True
|
return True
|
||||||
except GitError:
|
except GitError:
|
||||||
return False
|
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
|
"""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
|
Working tree vs HEAD by default (staged and unstaged merged), a commit vs its first parent with
|
||||||
first parent when `sha` is given; the whole file as insertions when untracked.
|
`sha`, the whole file as insertions when untracked
|
||||||
"""
|
"""
|
||||||
common = ["--no-color", "--no-ext-diff", FULL_CONTEXT]
|
common = ["--no-color", "--no-ext-diff", FULL_CONTEXT]
|
||||||
|
# untracked: against /dev/null, where exit code 1 only means the files differ
|
||||||
if untracked:
|
if untracked:
|
||||||
return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1))
|
return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1))
|
||||||
if sha:
|
if sha:
|
||||||
@@ -231,12 +290,30 @@ def full_diff(repo, path, sha=None, untracked=False):
|
|||||||
return run(repo, ["diff"] + common + [base_ref(repo), "--", path])
|
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"""
|
"""Stage the whole file, including its deletion"""
|
||||||
run(repo, ["add", "-A", "--", change.path])
|
run(repo, ["add", "-A", "--", change.path])
|
||||||
|
|
||||||
|
|
||||||
def unstage(repo, change):
|
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: str) -> str:
|
||||||
|
"""HEAD's full message, surrounding newlines stripped"""
|
||||||
|
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def amend(repo: str, message: str, allow_empty: bool = False) -> None:
|
||||||
|
"""Rewrite HEAD with `message`, folding in whatever is staged
|
||||||
|
|
||||||
|
`allow_empty` 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: str, change: FileChange) -> None:
|
||||||
"""Move the file's staged changes back to the working tree"""
|
"""Move the file's staged changes back to the working tree"""
|
||||||
if has_head(repo):
|
if has_head(repo):
|
||||||
run(repo, ["reset", "-q", "--", change.path])
|
run(repo, ["reset", "-q", "--", change.path])
|
||||||
@@ -244,8 +321,8 @@ def unstage(repo, change):
|
|||||||
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
|
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
|
||||||
|
|
||||||
|
|
||||||
def discard(repo, change):
|
def discard(repo: str, change: FileChange) -> None:
|
||||||
"""Throw away the file's changes, staged and unstaged, restoring the HEAD version"""
|
"""Throw away the file's staged and unstaged changes: restore HEAD's version, or delete a new file"""
|
||||||
if change.status == "?":
|
if change.status == "?":
|
||||||
run(repo, ["clean", "-f", "--", change.path])
|
run(repo, ["clean", "-f", "--", change.path])
|
||||||
return
|
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
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
@@ -7,15 +7,16 @@ from .commands import refresh_if_changed, restore_view
|
|||||||
|
|
||||||
|
|
||||||
class VdiffEventListener(sublime_plugin.EventListener):
|
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)
|
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)"""
|
"""Re-render when the tab comes back and the file or index changed meanwhile (agent edits)"""
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
refresh_if_changed(view, state.get(view) or restore_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"""
|
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
|
||||||
for view_id, st in state.tracked():
|
for view_id, st in state.tracked():
|
||||||
review = sublime.View(view_id)
|
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 difflib
|
||||||
import re
|
import re
|
||||||
from collections import Counter, namedtuple
|
from collections import Counter
|
||||||
from itertools import groupby
|
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_MIN_RATIO = 0.5
|
||||||
CHAR_DIFF_MAX_CHARS = 2000
|
CHAR_DIFF_MAX_CHARS = 2000
|
||||||
|
|
||||||
_HUNK = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)")
|
_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"""
|
"""Parse one file's unified diff (any number of hunks) into a Diff"""
|
||||||
lines = []
|
lines: List[Line] = []
|
||||||
binary = False
|
binary = False
|
||||||
old_no = new_no = 0
|
old_no = new_no = 0
|
||||||
in_hunk = False
|
in_hunk = False
|
||||||
for raw in text.split("\n"):
|
for raw in text.split("\n"):
|
||||||
|
# a hunk header resets both line counters
|
||||||
if raw.startswith("@@"):
|
if raw.startswith("@@"):
|
||||||
match = _HUNK.match(raw)
|
match = _HUNK.match(raw)
|
||||||
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
|
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
|
||||||
in_hunk = True
|
in_hunk = True
|
||||||
continue
|
continue
|
||||||
|
# before the first hunk only headers: watch for a binary marker
|
||||||
if not in_hunk:
|
if not in_hunk:
|
||||||
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
|
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
|
||||||
binary = True
|
binary = True
|
||||||
@@ -34,6 +58,7 @@ def parse_unified(text):
|
|||||||
if raw == "":
|
if raw == "":
|
||||||
# only the trailing split remainder; an empty context line is " "
|
# only the trailing split remainder; an empty context line is " "
|
||||||
continue
|
continue
|
||||||
|
# hunk body: the first character is the line kind
|
||||||
marker, body = raw[0], raw[1:]
|
marker, body = raw[0], raw[1:]
|
||||||
if marker == " ":
|
if marker == " ":
|
||||||
lines.append(Line(" ", old_no, new_no, body))
|
lines.append(Line(" ", old_no, new_no, body))
|
||||||
@@ -52,26 +77,26 @@ def parse_unified(text):
|
|||||||
return Diff(lines, binary)
|
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"""
|
"""(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])):
|
for value, group in groupby(enumerate(items), key=lambda pair: key(pair[1])):
|
||||||
indexes = [i for i, _ in group]
|
indexes = [i for i, _ in group]
|
||||||
yield value, indexes[0], indexes[-1] + 1
|
yield value, indexes[0], indexes[-1] + 1
|
||||||
|
|
||||||
|
|
||||||
def change_blocks(lines):
|
def change_blocks(lines: Sequence[Line]) -> List[Block]:
|
||||||
"""[(start, end)) index ranges of consecutive changed lines"""
|
"""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]
|
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)"""
|
"""(insertions, deletions)"""
|
||||||
tally = Counter(line.kind for line in lines)
|
tally = Counter(line.kind for line in lines)
|
||||||
return tally["+"], tally["-"]
|
return tally["+"], tally["-"]
|
||||||
|
|
||||||
|
|
||||||
def paired_lines(lines, block):
|
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
|
||||||
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
|
"""(deleted, inserted) index pairs of a block; [] unless its - and + counts match"""
|
||||||
start, end = block
|
start, end = block
|
||||||
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
|
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
|
||||||
inserted = [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))
|
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"""
|
"""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:
|
if len(old) + len(new) > CHAR_DIFF_MAX_CHARS:
|
||||||
return [], []
|
return [], []
|
||||||
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
|
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
|
||||||
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
|
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
|
||||||
return [], []
|
return [], []
|
||||||
old_ranges = []
|
# each opcode span becomes a range on whichever side it touches
|
||||||
new_ranges = []
|
old_ranges: List[CharRange] = []
|
||||||
|
new_ranges: List[CharRange] = []
|
||||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
if tag in ("replace", "delete") and i2 > i1:
|
if tag in ("replace", "delete") and i2 > i1:
|
||||||
old_ranges.append((i1, i2))
|
old_ranges.append((i1, i2))
|
||||||
@@ -97,10 +123,11 @@ def char_ranges(old, new):
|
|||||||
return old_ranges, new_ranges
|
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"""
|
"""New-file line number to open for a row: a deleted row maps to the next surviving line"""
|
||||||
if not lines:
|
if not lines:
|
||||||
return 1
|
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))
|
index = max(0, min(index, len(lines) - 1))
|
||||||
for line in lines[index:]:
|
for line in lines[index:]:
|
||||||
if line.new_no is not None:
|
if line.new_no is not None:
|
||||||
|
|||||||
727
vdiff/render.py
727
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 html
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections import namedtuple
|
from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from . import parse
|
from . import palette, parse
|
||||||
from .git import STATUS_NAMES
|
from .git import FileChange, STATUS_NAMES
|
||||||
from .state import VIEW_FLAG
|
from .parse import Block, Line
|
||||||
from .utils import settings
|
from .state import VIEW_FLAG, ViewState
|
||||||
|
from .utils import file_count, settings, status
|
||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
LOG = logging.getLogger("vdiff")
|
||||||
|
|
||||||
@@ -21,62 +22,100 @@ KEY_DEL_GUTTER = "vdiff.del.gutter"
|
|||||||
KEY_INS_GUTTER = "vdiff.ins.gutter"
|
KEY_INS_GUTTER = "vdiff.ins.gutter"
|
||||||
KEY_DEL_CHAR = "vdiff.del.char"
|
KEY_DEL_CHAR = "vdiff.del.char"
|
||||||
KEY_INS_CHAR = "vdiff.ins.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"
|
PHANTOM_KEY = "vdiff.header"
|
||||||
STATUS_KEY = "vdiff"
|
STATUS_KEY = "vdiff"
|
||||||
|
|
||||||
# a block phantom anchored at point 0 is drawn below row 0, so row 0 stays empty and the file starts at row 1.
|
# visual bell: a timed whole-buffer tint, kept out of REGION_KEYS so a re-render cannot erase it early
|
||||||
# Only index_point / caret_index know about it.
|
BELL_KEY = "vdiff.bell"
|
||||||
|
BELL_MS = 90
|
||||||
|
|
||||||
|
# above this much changed text the colours cost more than they are worth: such a diff is scrolled, not read
|
||||||
|
TOKEN_COLOUR_MAX_CHARS = 200000
|
||||||
|
# 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
|
_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
|
# gutter dots use the scope's foreground
|
||||||
SCOPE_DEL_MARK = "markup.deleted"
|
SCOPE_DEL_MARK = "markup.deleted"
|
||||||
SCOPE_INS_MARK = "markup.inserted"
|
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
|
# sublime loads a newly created scheme override only after a restart
|
||||||
FALLBACK_RULES = [
|
NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines"
|
||||||
{"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%)"},
|
|
||||||
]
|
|
||||||
|
|
||||||
# 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
|
||||||
("Navigate", True, [
|
class Key(NamedTuple):
|
||||||
Key("] [", "next / previous change", "change", ("next_change", "prev_change")),
|
"""One row of the status-bar hints and the ? popup"""
|
||||||
Key("n p", "next / previous file", "file", ("next_file", "prev_file")),
|
|
||||||
|
keys: str
|
||||||
|
label: str
|
||||||
|
short: str
|
||||||
|
actions: Tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
# (group title, applies in a commit (False: needs a working-tree file), rows)
|
||||||
|
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
||||||
|
(
|
||||||
|
"Navigate",
|
||||||
|
True,
|
||||||
|
[
|
||||||
|
Key("] [", "next / previous change (wraps)", "change", ("next_change", "prev_change")),
|
||||||
|
Key("n p", "next / previous file (wraps)", "file", ("next_file", "prev_file")),
|
||||||
Key("o \u23ce", "open the real file at this line", "open", ("open",)),
|
Key("o \u23ce", "open the real file at this line", "open", ("open",)),
|
||||||
]),
|
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",
|
||||||
("This file", False, [
|
False,
|
||||||
|
[
|
||||||
Key("s", "stage", "stage", ("stage",)),
|
Key("s", "stage", "stage", ("stage",)),
|
||||||
Key("u", "unstage", "unstage", ("unstage",)),
|
Key("u", "unstage", "unstage", ("unstage",)),
|
||||||
Key("d", "discard", "discard", ("discard",)),
|
Key("d", "discard", "discard", ("discard",)),
|
||||||
]),
|
Key("C", "commit the staged files", "commit", ("commit",)),
|
||||||
("View", True, [
|
Key("m", "amend the last commit", "amend", ("amend",)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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("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",)),
|
Key("esc", "close the view", "", ("close",)),
|
||||||
]),
|
],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
KEYS = [key for _, _, group in KEY_GROUPS for key in group]
|
KEYS: List[Key] = [key for _, _, group in KEY_GROUPS for key in group]
|
||||||
|
|
||||||
KEY_HINTS = " ".join("{} {}".format(k.keys, k.short) for k in KEYS if k.short)
|
KEY_HINTS = " ".join(f"{k.keys} {k.short}" for k in KEYS if k.short)
|
||||||
|
|
||||||
# the popup font is the editor's, so the key column is padded to a fixed width in characters
|
# the popup font is the editor's, so the key column is padded to a fixed width in characters
|
||||||
_KEY_WIDTH = max(len(k.keys) for k in KEYS)
|
_KEY_WIDTH = max(len(k.keys) for k in KEYS)
|
||||||
@@ -89,18 +128,17 @@ body { margin: 0; padding: 0.5rem 0.8rem; background-color: color(var(--backgrou
|
|||||||
.ins { color: var(--greenish); font-weight: bold; }
|
.ins { color: var(--greenish); font-weight: bold; }
|
||||||
.state { color: var(--orangish); }
|
.state { color: var(--orangish); }
|
||||||
.actions { margin-left: 1rem; }
|
.actions { margin-left: 1rem; }
|
||||||
|
.summary { margin-bottom: 0.3rem; color: color(var(--foreground) alpha(0.75)); }
|
||||||
|
.when { color: color(var(--foreground) alpha(0.6)); }
|
||||||
a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-radius: 3px;
|
a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-radius: 3px;
|
||||||
background-color: color(var(--foreground) alpha(0.12)); color: var(--foreground); }
|
background-color: color(var(--foreground) alpha(0.12)); color: var(--foreground); }
|
||||||
.danger { color: var(--redish); }
|
.danger { color: var(--redish); }
|
||||||
.commit { margin-top: 0.4rem; color: color(var(--foreground) alpha(0.75)); }
|
|
||||||
.hash { color: var(--bluish); font-weight: bold; }
|
.hash { color: var(--bluish); font-weight: bold; }
|
||||||
.body { color: color(var(--foreground) alpha(0.6)); }
|
.body { color: color(var(--foreground) alpha(0.6)); }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# minihtml stops applying a stylesheet at the first selector it cannot parse, and its documentation
|
# minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class
|
||||||
# covers properties only, not selectors: every rule here sticks to one bare element or one class,
|
# .group's top margin replaces body's top padding; .kdim inherits its colour from .dim
|
||||||
# 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.
|
|
||||||
HELP_CSS = """
|
HELP_CSS = """
|
||||||
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
||||||
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
|
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
|
||||||
@@ -116,13 +154,25 @@ a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
|
|||||||
class VdiffReplaceCommand(sublime_plugin.TextCommand):
|
class VdiffReplaceCommand(sublime_plugin.TextCommand):
|
||||||
"""Replace the whole buffer; the only writer of a review view"""
|
"""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)
|
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"""
|
"""Settings a review view needs, applied once when it is created or restored"""
|
||||||
s = view.settings()
|
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)
|
s.set(VIEW_FLAG, True)
|
||||||
# native line numbers would be wrong: deleted lines have none
|
# native line numbers would be wrong: deleted lines have none
|
||||||
s.set("line_numbers", False)
|
s.set("line_numbers", False)
|
||||||
@@ -133,43 +183,81 @@ def init_view(view):
|
|||||||
s.set("detect_indentation", False)
|
s.set("detect_indentation", False)
|
||||||
s.set("translate_tabs_to_spaces", False)
|
s.set("translate_tabs_to_spaces", False)
|
||||||
s.set("show_definitions", 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("mini_diff", False)
|
||||||
s.set("spell_check", False)
|
s.set("spell_check", False)
|
||||||
|
# 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_scratch(True)
|
||||||
view.set_read_only(True)
|
view.set_read_only(True)
|
||||||
if not view.name():
|
if not view.name():
|
||||||
view.set_name("vDiff")
|
view.set_name("vDiff")
|
||||||
view.set_status(STATUS_KEY, KEY_HINTS)
|
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]"""
|
"""Text point of column `col` on the row showing st.lines[index]"""
|
||||||
return view.text_point(index + _HEADER_ROWS, col)
|
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)"""
|
"""Index into st.lines of the caret's row (the header row maps to -1)"""
|
||||||
if not len(view.sel()):
|
if not len(view.sel()):
|
||||||
return 0
|
return 0
|
||||||
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
|
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"""
|
"""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:
|
if st.in_commit or st.change is None:
|
||||||
return None
|
return None
|
||||||
return (_mtime(os.path.join(st.repo, st.change.path)), _mtime(os.path.join(st.repo, ".git", "index")))
|
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:
|
try:
|
||||||
return os.stat(path).st_mtime
|
return os.stat(path).st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def render_message(view, st, message):
|
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 _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
|
||||||
|
# an invisible bell is a silent failure, so borrow the insert tint
|
||||||
|
return palette.SCOPE_INS
|
||||||
|
|
||||||
|
|
||||||
|
def render_message(view: sublime.View, st: ViewState, message: str) -> None:
|
||||||
"""Show a one-line notice instead of a file (nothing to review, binary, ...)"""
|
"""Show a one-line notice instead of a file (nothing to review, binary, ...)"""
|
||||||
st.change = None
|
st.change = None
|
||||||
st.lines = []
|
st.lines = []
|
||||||
@@ -180,9 +268,219 @@ def render_message(view, st, message):
|
|||||||
_finish(view, st, 0, 0, "vDiff")
|
_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"""
|
"""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
|
lines = st.lines
|
||||||
|
# the text: a placeholder for a binary or empty diff, else the lines
|
||||||
if st.binary:
|
if st.binary:
|
||||||
text = "(binary file)"
|
text = "(binary file)"
|
||||||
elif not lines:
|
elif not lines:
|
||||||
@@ -190,34 +488,140 @@ def render(view, st):
|
|||||||
else:
|
else:
|
||||||
text = "\n".join(line.text for line in lines)
|
text = "\n".join(line.text for line in lines)
|
||||||
|
|
||||||
|
# write the text and syntax
|
||||||
_replace(view, text)
|
_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)
|
_apply_syntax(view, path if lines and not st.binary else None)
|
||||||
|
|
||||||
if lines and not st.binary:
|
if lines and not st.binary:
|
||||||
starts = _row_starts(lines)
|
# here rather than init_view: during a session restore the scheme colours still read as absent
|
||||||
deleted, inserted = _line_regions(lines, starts)
|
palette.ensure(view)
|
||||||
view.add_regions(KEY_DEL, deleted, SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
|
_paint_regions(view, st)
|
||||||
view.add_regions(KEY_INS, inserted, SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
|
if palette.claim_restart_notice(view):
|
||||||
mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
|
status(NEEDS_RESTART)
|
||||||
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)
|
|
||||||
|
|
||||||
|
# header and bookkeeping, then the caret and scroll back to the top
|
||||||
ins, dele = parse.counts(lines)
|
ins, dele = parse.counts(lines)
|
||||||
_finish(view, st, ins, dele, "⟐ {}".format(os.path.basename(st.change.path)))
|
_finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}")
|
||||||
view.sel().clear()
|
view.sel().clear()
|
||||||
view.sel().add(sublime.Region(index_point(view, 0)))
|
view.sel().add(sublime.Region(index_point(view, 0)))
|
||||||
view.set_viewport_position((0, 0), False)
|
view.set_viewport_position((0, 0), False)
|
||||||
|
|
||||||
|
|
||||||
def _finish(view, st, ins, dele, name):
|
def _paint_regions(view: sublime.View, st: ViewState) -> None:
|
||||||
"""Header, tab name, status bar and bookkeeping shared by render and render_message"""
|
"""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)
|
_header(view, st, ins, dele)
|
||||||
view.set_name(name)
|
view.set_name(name)
|
||||||
view.set_status(STATUS_KEY, KEY_HINTS)
|
view.set_status(STATUS_KEY, KEY_HINTS)
|
||||||
@@ -225,17 +629,19 @@ def _finish(view, st, ins, dele, name):
|
|||||||
st.fingerprint = fingerprint(st)
|
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)
|
view.set_read_only(False)
|
||||||
try:
|
try:
|
||||||
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
|
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
|
||||||
finally:
|
finally:
|
||||||
view.set_read_only(True)
|
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)
|
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"""
|
"""The real file's syntax, so the diff is highlighted like the file itself"""
|
||||||
syntax = None
|
syntax = None
|
||||||
if path:
|
if path:
|
||||||
@@ -246,9 +652,9 @@ def _apply_syntax(view, path):
|
|||||||
view.assign_syntax(syntax.path if syntax else "scope:text.plain")
|
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"""
|
"""Text point where each line starts; computed from the text we wrote, no view round-trips"""
|
||||||
starts = []
|
starts: List[int] = []
|
||||||
point = _HEADER_ROWS
|
point = _HEADER_ROWS
|
||||||
for line in lines:
|
for line in lines:
|
||||||
starts.append(point)
|
starts.append(point)
|
||||||
@@ -257,9 +663,9 @@ def _row_starts(lines):
|
|||||||
return starts
|
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"""
|
"""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):
|
for kind, start, end in parse.runs(lines, lambda line: line.kind):
|
||||||
if kind in regions:
|
if kind in regions:
|
||||||
# starts has a sentinel at len(lines): the last line stops at the buffer end
|
# starts has a sentinel at len(lines): the last line stops at the buffer end
|
||||||
@@ -267,11 +673,14 @@ def _line_regions(lines, starts):
|
|||||||
return regions["-"], regions["+"]
|
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"""
|
"""Changed characters inside paired deleted/inserted lines"""
|
||||||
deleted = []
|
deleted: List[sublime.Region] = []
|
||||||
inserted = []
|
inserted: List[sublime.Region] = []
|
||||||
for block in blocks:
|
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):
|
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)
|
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)
|
deleted.extend(sublime.Region(starts[del_row] + a, starts[del_row] + b) for a, b in old_ranges)
|
||||||
@@ -279,22 +688,68 @@ def _char_regions(lines, blocks, starts):
|
|||||||
return deleted, inserted
|
return deleted, inserted
|
||||||
|
|
||||||
|
|
||||||
def header_html(st, ins, dele):
|
def _totals(files: Sequence[FileChange]) -> Tuple[int, int]:
|
||||||
"""The minihtml header: path, counts, state, action links and the commit line"""
|
"""Summed numstat counts; binary files carry None counts and add nothing"""
|
||||||
|
return sum(c.insertions or 0 for c in files), sum(c.deletions or 0 for c in files)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_html(st: ViewState) -> str:
|
||||||
|
"""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:
|
||||||
|
text = " · ".join(_commit_parts(st))
|
||||||
|
else:
|
||||||
|
# staged means "has something staged": a partially staged file counts as staged only
|
||||||
|
staged = sum(1 for c in st.files if c.staged)
|
||||||
|
insertions, deletions = _totals(st.files)
|
||||||
|
text = (
|
||||||
|
f"{staged} staged, {len(st.files) - staged} unstaged"
|
||||||
|
f' · <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>'
|
||||||
|
)
|
||||||
|
return f'<div class="summary">{text}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_parts(st: ViewState) -> List[str]:
|
||||||
|
"""Hash, author, date, totals and message of the reviewed commit, in order"""
|
||||||
|
info = st.commit
|
||||||
|
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))
|
||||||
|
parts.append(f'<span class="when">{html.escape(info.date)}</span>')
|
||||||
|
# binary files add nothing to the line totals but still count toward the file total
|
||||||
|
insertions, deletions = _totals(st.files)
|
||||||
|
parts.append(f"{file_count(len(st.files))} changed" f' <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>')
|
||||||
|
# the subject, plus the body's first line
|
||||||
|
if info:
|
||||||
|
message = html.escape(info.subject)
|
||||||
|
body = info.body.strip().splitlines()
|
||||||
|
if body:
|
||||||
|
message += f' <span class="body">— {html.escape(body[0])}</span>'
|
||||||
|
parts.append(message)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def header_html(st: ViewState, ins: int, dele: int) -> str:
|
||||||
|
"""The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
|
||||||
change = st.change
|
change = st.change
|
||||||
|
# the title: path, rename source, counts and working-tree state
|
||||||
if change is None:
|
if change is None:
|
||||||
title = '<span class="path">vDiff</span>'
|
title = '<span class="path">vDiff</span>'
|
||||||
else:
|
else:
|
||||||
title = '<span class="path">{}</span>'.format(html.escape(change.path))
|
title = f'<span class="path">{html.escape(change.path)}</span>'
|
||||||
if change.old_path:
|
if change.old_path:
|
||||||
title += ' <span class="old">← {}</span>'.format(html.escape(change.old_path))
|
title += f' <span class="old">← {html.escape(change.old_path)}</span>'
|
||||||
title += ' <span class="del">-{}</span> <span class="ins">+{}</span>'.format(dele, ins)
|
title += f' <span class="del">-{dele}</span> <span class="ins">+{ins}</span>'
|
||||||
if not st.in_commit:
|
if not st.in_commit:
|
||||||
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
|
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
|
||||||
if state:
|
if state:
|
||||||
title += ' <span class="state">{}</span>'.format(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:
|
if change is not None and not st.in_commit:
|
||||||
actions.append('<a href="stage">Stage</a>')
|
actions.append('<a href="stage">Stage</a>')
|
||||||
actions.append('<a href="unstage">Unstage</a>')
|
actions.append('<a href="unstage">Unstage</a>')
|
||||||
@@ -305,31 +760,22 @@ def header_html(st, ins, dele):
|
|||||||
actions.append('<a href="commits">Commits</a>')
|
actions.append('<a href="commits">Commits</a>')
|
||||||
actions.append('<a href="help" title="keys">?</a>')
|
actions.append('<a href="help" title="keys">?</a>')
|
||||||
|
|
||||||
commit = ""
|
return (
|
||||||
if st.in_commit and st.commit:
|
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{_summary_html(st)}'
|
||||||
info = st.commit
|
f'<div>{title}<span class="actions">{"".join(actions)}</span></div></body>'
|
||||||
commit = '<div class="commit"><span class="hash">{}</span> · {} · {} · {}'.format(
|
|
||||||
html.escape(info.short), html.escape(info.author), html.escape(info.date), html.escape(info.subject)
|
|
||||||
)
|
|
||||||
body = info.body.strip().splitlines()
|
|
||||||
if body:
|
|
||||||
commit += ' <span class="body">— {}</span>'.format(html.escape(body[0]))
|
|
||||||
commit += "</div>"
|
|
||||||
|
|
||||||
return '<body id="vdiff-header"><style>{}</style><div>{}<span class="actions">{}</span></div>{}</body>'.format(
|
|
||||||
HEADER_CSS, title, "".join(actions), commit
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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"""
|
"""Dispatch a header or popup link through the same command the keymap uses"""
|
||||||
view.run_command("vdiff_action", {"action": href})
|
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"""
|
"""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)
|
_run_action(view, href)
|
||||||
|
|
||||||
view.erase_phantoms(PHANTOM_KEY)
|
view.erase_phantoms(PHANTOM_KEY)
|
||||||
@@ -339,82 +785,41 @@ def _header(view, st, ins, dele):
|
|||||||
# --- key help popup ---------------------------------------------------------
|
# --- 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"""
|
"""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:
|
for title, applies, group in KEY_GROUPS:
|
||||||
dim = st.in_commit and not applies
|
dim = st.in_commit and not applies
|
||||||
heading = html.escape(title)
|
heading = html.escape(title)
|
||||||
if dim:
|
if dim:
|
||||||
heading += ' <span class="note">uncommitted only</span>'
|
heading += ' <span class="note">uncommitted only</span>'
|
||||||
parts.append('<div class="group">{}</div>'.format(heading))
|
parts.append(f'<div class="group">{heading}</div>')
|
||||||
# one class per element: a compound selector would silently drop the rest of HELP_CSS
|
# 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")
|
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:
|
for key in group:
|
||||||
row = '<span class="{}">{}</span>{}{}'.format(
|
row = (
|
||||||
key_class, html.escape(key.keys), " " * (_KEY_WIDTH - len(key.keys) + 2), html.escape(key.label)
|
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
|
||||||
|
f'{" " * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
|
||||||
)
|
)
|
||||||
if len(key.actions) == 1 and not dim:
|
if len(key.actions) == 1 and not dim:
|
||||||
row = '<a href="{}">{}</a>'.format(key.actions[0], row)
|
row = f'<a href="{key.actions[0]}">{row}</a>'
|
||||||
parts.append('<div class="{}">{}</div>'.format(row_class, row))
|
parts.append(f'<div class="{row_class}">{row}</div>')
|
||||||
return '<body id="vdiff-help"><style>{}</style>{}</body>'.format(HELP_CSS, "".join(parts))
|
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"""
|
"""Toggle the key list popup at the caret"""
|
||||||
if view.is_popup_visible():
|
if view.is_popup_visible():
|
||||||
view.hide_popup()
|
view.hide_popup()
|
||||||
return
|
return
|
||||||
|
|
||||||
def navigate(href):
|
def navigate(href: str) -> None:
|
||||||
|
"""Close the popup, then run the clicked row's action"""
|
||||||
view.hide_popup()
|
view.hide_popup()
|
||||||
_run_action(view, href)
|
_run_action(view, href)
|
||||||
|
|
||||||
# minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size
|
# minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size
|
||||||
em = view.em_width() or 8
|
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)
|
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"
|
|
||||||
|
|||||||
107
vdiff/state.py
107
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 """
|
"""Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore"""
|
||||||
from collections import namedtuple
|
|
||||||
from dataclasses import dataclass, field
|
from 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 setting marking a review view (keymap context "setting.vdiff_view")
|
||||||
VIEW_FLAG = "vdiff_view"
|
VIEW_FLAG = "vdiff_view"
|
||||||
# view setting holding what the view shows, so a restored tab can re-render
|
# view setting holding what the view shows, so a restored tab can re-render
|
||||||
VIEW_DATA = "vdiff"
|
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"""
|
"""Index of the FileChange with `path`, or -1"""
|
||||||
for i, change in enumerate(files):
|
for i, change in enumerate(files):
|
||||||
if change.path == path:
|
if change.path == path:
|
||||||
@@ -21,49 +34,75 @@ def find_change(files, path):
|
|||||||
|
|
||||||
@dataclass(eq=False)
|
@dataclass(eq=False)
|
||||||
class ViewState:
|
class ViewState:
|
||||||
"""Everything a review view needs to re-render itself and act on its file
|
"""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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
repo: str
|
repo: str
|
||||||
sha: object = None # None in working-tree mode, the reviewed commit's hash otherwise
|
sha: Optional[str] = None # None in working-tree mode, the reviewed commit's hash otherwise
|
||||||
commit: object = None # git.CommitInfo when sha is set
|
commit: Optional["CommitInfo"] = None # set when sha is
|
||||||
change: object = None # git.FileChange currently shown
|
change: Optional["FileChange"] = None # file currently shown
|
||||||
files: list = field(default_factory=list) # git.FileChange list the user is browsing (next/prev file)
|
requested: Optional["FileChange"] = None # file asked for, maybe still loading; navigation counts from here
|
||||||
lines: list = field(default_factory=list) # parse.Line list currently rendered
|
files: List["FileChange"] = field(default_factory=list) # what the user is browsing (next/prev file)
|
||||||
blocks: list = field(default_factory=list) # parse.change_blocks(lines)
|
lines: List["Line"] = field(default_factory=list) # currently rendered
|
||||||
|
blocks: List["Block"] = field(default_factory=list) # parse.change_blocks(lines)
|
||||||
binary: bool = False
|
binary: bool = False
|
||||||
token: int = 0 # bumped per show request; a stale async result is dropped
|
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
|
rendered_token: int = -1 # token of the request that produced the current render
|
||||||
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
|
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
|
@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
|
return self.sha is not None
|
||||||
|
|
||||||
def index(self):
|
def index(self) -> int:
|
||||||
"""Position of the shown file in `files`, or -1"""
|
"""Position in `files` of the file being shown or loaded, or -1
|
||||||
return find_change(self.files, self.change.path) if self.change else -1
|
|
||||||
|
|
||||||
def kept(self):
|
`requested` leads: a diff arrives asynchronously, so counting from `change` would make n / p
|
||||||
|
step from what was on screen before the pending file landed
|
||||||
|
"""
|
||||||
|
change = self.requested or self.change
|
||||||
|
return find_change(self.files, change.path) if change else -1
|
||||||
|
|
||||||
|
def kept(self) -> Kept:
|
||||||
|
"""Snapshot of what to go back to on Esc"""
|
||||||
return Kept(self.sha, self.commit, self.change, list(self.files))
|
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"""
|
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
|
||||||
kept = self.kept()
|
kept = self.kept()
|
||||||
if sha != self.sha:
|
if sha != self.sha:
|
||||||
self.change = None
|
self.change = self.requested = None
|
||||||
self.sha, self.commit = sha, commit
|
self.sha, self.commit = sha, commit
|
||||||
return kept
|
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.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)"""
|
"""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:
|
if self.change:
|
||||||
data["path"] = self.change.path
|
data["path"] = self.change.path
|
||||||
data["status"] = self.change.status
|
data["status"] = self.change.status
|
||||||
@@ -71,26 +110,30 @@ class ViewState:
|
|||||||
view.settings().set(VIEW_DATA, data)
|
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())
|
return STATES.get(view.id())
|
||||||
|
|
||||||
|
|
||||||
def tracked():
|
def tracked() -> List[Tuple[int, ViewState]]:
|
||||||
"""Snapshot of (view id, state) for every tracked review view"""
|
"""Snapshot of (view id, state) for every tracked review view"""
|
||||||
return list(STATES.items())
|
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
|
STATES[view.id()] = st
|
||||||
return st
|
return st
|
||||||
|
|
||||||
|
|
||||||
def drop(view):
|
def drop(view: "sublime.View") -> None:
|
||||||
|
"""Forget the view's state, if any"""
|
||||||
STATES.pop(view.id(), None)
|
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))
|
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
|
import logging
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
|
|
||||||
@@ -9,10 +10,10 @@ SETTINGS_FILE = "vDiff.sublime-settings"
|
|||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
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"""
|
"""Cached settings handle; a sublime Settings object stays live"""
|
||||||
global _settings
|
global _settings
|
||||||
if _settings is None:
|
if _settings is None:
|
||||||
@@ -22,20 +23,26 @@ def settings():
|
|||||||
return _settings
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
def apply_git_binary():
|
def apply_git_binary() -> None:
|
||||||
"""Push the configured git executable into the (sublime-free) git module"""
|
"""Push the configured git executable into the (sublime-free) git module"""
|
||||||
git.GIT_BINARY = settings().get("git_binary") or "git"
|
git.GIT_BINARY = settings().get("git_binary") or "git"
|
||||||
|
|
||||||
|
|
||||||
def status(message):
|
def file_count(count: int) -> str:
|
||||||
|
"""'1 file' / '3 files' for a message"""
|
||||||
|
return f'{count} {"file" if count == 1 else "files"}'
|
||||||
|
|
||||||
|
|
||||||
|
def status(message: str) -> None:
|
||||||
"""Status-bar message with the plugin prefix"""
|
"""Status-bar message with the plugin prefix"""
|
||||||
sublime.status_message("vDiff: {}".format(message))
|
sublime.status_message(f"vDiff: {message}")
|
||||||
|
|
||||||
|
|
||||||
def run_async(work, done):
|
def run_async(work: Callable[[], Any], done: Callable[[Any], None]) -> None:
|
||||||
"""Run `work()` on sublime's worker thread, hand the result to `done` on the main thread"""
|
"""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:
|
try:
|
||||||
result = work()
|
result = work()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
Reference in New Issue
Block a user