initial version of all-in-one diff view
This commit is contained in:
@@ -13,6 +13,14 @@
|
|||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"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"}] },
|
"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"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
|
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
|
||||||
|
|||||||
@@ -57,12 +57,15 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov
|
|||||||
| `o` or Enter | open the real file at this line |
|
| `o` or Enter | open the real file at this line |
|
||||||
| `]` / `[` | next / previous change (wraps at the ends) |
|
| `]` / `[` | next / previous change (wraps at the ends) |
|
||||||
| `n` / `p` | next / previous file of the current list (wraps at the ends) |
|
| `n` / `p` | next / previous file of the current list (wraps at the ends) |
|
||||||
|
| `v` | switch between the file-by-file and all-files views |
|
||||||
|
| `left` / `right` | collapse / expand the file under the caret in the all-files view |
|
||||||
|
| `z` | collapse all files in the all-files view, or expand them all when every one is collapsed |
|
||||||
| `j` / `k` | move down / up one line (vim keys) |
|
| `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
|
## The channel
|
||||||
|
|
||||||
|
|||||||
7
TODO.txt
7
TODO.txt
@@ -1,6 +1,9 @@
|
|||||||
|
|
||||||
|
## 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
|
## New functionality
|
||||||
|
|
||||||
- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?)
|
- 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 pull and push action
|
||||||
@@ -9,10 +12,8 @@
|
|||||||
|
|
||||||
|
|
||||||
## Improvements
|
## Improvements
|
||||||
|
|
||||||
- commit messages are oneline. What are options for multiline editing?
|
- commit messages are oneline. What are options for multiline editing?
|
||||||
- visual bell blinking time setting
|
- visual bell blinking time setting
|
||||||
|
|
||||||
|
|
||||||
## Code
|
## Code
|
||||||
|
|
||||||
|
|||||||
8
main.py
8
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, so save it after editing a submodule.
|
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 views lose in-memory state and are rebuilt from their settings by _restore_all.
|
|
||||||
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
|
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:
|
||||||
|
|||||||
@@ -224,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
|
||||||
|
|
||||||
|
|||||||
@@ -583,3 +583,90 @@ def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
|
|||||||
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
||||||
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
|
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
|
||||||
assert messages == ["No uncommitted changes in /repo"]
|
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)
|
||||||
|
|||||||
@@ -49,7 +49,18 @@ def test_render_text_regions_and_header():
|
|||||||
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")
|
||||||
|
|
||||||
@@ -141,9 +152,9 @@ 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", "Actions", "View"):
|
for group in ("Navigate", "Actions", "View"):
|
||||||
assert f">{group}<" in html
|
assert f">{group}<" in html
|
||||||
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "?", "esc"):
|
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "z", "esc"):
|
||||||
assert f">{keys}<" in html
|
assert 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
|
||||||
@@ -151,8 +162,8 @@ def test_help_popup_lists_every_key():
|
|||||||
assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html
|
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():
|
||||||
@@ -199,11 +210,12 @@ def test_keymap_matches_help_table():
|
|||||||
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():
|
def test_no_binding_yields_to_vintage_command_mode():
|
||||||
@@ -418,5 +430,3 @@ def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
|
|||||||
# said once per scheme, not once per render
|
# said once per scheme, not once per render
|
||||||
render.render(view, make_state())
|
render.render(view, make_state())
|
||||||
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
// 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",
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from .git import Commit, FileChange
|
|||||||
from .state import Kept, ViewState
|
from .state import Kept, ViewState
|
||||||
from .utils import file_count, run_async, settings, status
|
from .utils import file_count, run_async, settings, status
|
||||||
|
|
||||||
# what an ACTIONS entry does: act(view, window, state) for the review view it was fired in
|
# an ACTIONS entry: act(view, window, state) on the review view it fired in
|
||||||
Action = Callable[[sublime.View, sublime.Window, ViewState], None]
|
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
|
||||||
@@ -27,15 +27,14 @@ STATUS_COLOURS: Dict[str, int] = {
|
|||||||
}
|
}
|
||||||
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
|
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
|
||||||
|
|
||||||
# the annotation is drawn hard right of the subject, so past this budget the subject is clipped.
|
# row width shared by subject and annotation; the subject is clipped to fit, never below MIN_SUBJECT_WIDTH
|
||||||
# The floor keeps a readable stub on a narrow panel.
|
|
||||||
COMMIT_ROW_WIDTH = 120
|
COMMIT_ROW_WIDTH = 120
|
||||||
MIN_SUBJECT_WIDTH = 24
|
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: Dict[str, str] = {}
|
_roots: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +42,7 @@ 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: List[str] = []
|
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)
|
||||||
@@ -53,6 +53,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]:
|
|||||||
if name:
|
if name:
|
||||||
candidates.append(os.path.dirname(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:
|
||||||
@@ -65,6 +66,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def find_review_view(window: sublime.Window) -> Optional[sublime.View]:
|
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
|
||||||
@@ -72,7 +74,7 @@ def find_review_view(window: sublime.Window) -> Optional[sublime.View]:
|
|||||||
|
|
||||||
|
|
||||||
def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]:
|
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")
|
||||||
@@ -89,8 +91,32 @@ def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewStat
|
|||||||
return view, st
|
return view, st
|
||||||
|
|
||||||
|
|
||||||
|
def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) -> None:
|
||||||
|
"""Load all file diffs off the UI thread and switch to the aggregate view, on `focus`'s heading if given"""
|
||||||
|
st.all_mode = True
|
||||||
|
# bump the token so earlier loads drop their results
|
||||||
|
st.token += 1
|
||||||
|
token, files = st.token, list(st.files)
|
||||||
|
def work() -> Dict[str, Tuple[List[parse.Line], List[parse.Block], bool]]:
|
||||||
|
"""Parse every listed file's diff and its change blocks, keyed by path"""
|
||||||
|
result = {}
|
||||||
|
for change in files:
|
||||||
|
diff = parse.parse_unified(git.full_diff(st.repo, change.path, sha=st.sha, untracked=(change.status == "?")))
|
||||||
|
result[change.path] = (diff.lines, parse.change_blocks(diff.lines), diff.binary)
|
||||||
|
return result
|
||||||
|
def done(result: Dict[str, Tuple[List[parse.Line], List[parse.Block], bool]]) -> None:
|
||||||
|
"""Store the diffs and draw the aggregate view unless superseded or closed"""
|
||||||
|
if token == st.token and view.is_valid():
|
||||||
|
st.set_all_diffs(result)
|
||||||
|
if st.change is None and files:
|
||||||
|
st.change = files[0]
|
||||||
|
render.render_all(view, st, focus=focus)
|
||||||
|
run_async(work, done)
|
||||||
|
|
||||||
|
|
||||||
def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
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"""
|
"""Fetch and parse the file's diff on the worker thread, then render it"""
|
||||||
|
st.all_mode = False
|
||||||
st.token += 1
|
st.token += 1
|
||||||
# set before the worker starts: n / p count from here while the diff is still loading
|
# set before the worker starts: n / p count from here while the diff is still loading
|
||||||
st.requested = change
|
st.requested = change
|
||||||
@@ -98,6 +124,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
|||||||
repo, sha = st.repo, st.sha
|
repo, sha = st.repo, st.sha
|
||||||
|
|
||||||
def work() -> Optional[Tuple[parse.Diff, List[parse.Block]]]:
|
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 == "?"))
|
||||||
@@ -105,6 +132,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
|||||||
return diff, parse.change_blocks(diff.lines)
|
return diff, parse.change_blocks(diff.lines)
|
||||||
|
|
||||||
def done(result: Optional[Tuple[parse.Diff, List[parse.Block]]]) -> None:
|
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
|
||||||
@@ -117,6 +145,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
||||||
|
"""Quick-panel rows: path; status, staged flag, rename source; line totals or binary"""
|
||||||
items: List[sublime.QuickPanelItem] = []
|
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, "")
|
||||||
@@ -125,6 +154,7 @@ def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
|||||||
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:
|
||||||
@@ -134,13 +164,14 @@ def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
|||||||
|
|
||||||
|
|
||||||
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
||||||
"""Quick-panel rows: subject on the left, its totals and the hash right-aligned"""
|
"""Quick-panel rows: subject, author · date, and diffstat plus short hash right-aligned"""
|
||||||
items: List[sublime.QuickPanelItem] = []
|
items: List[sublime.QuickPanelItem] = []
|
||||||
for c in commits:
|
for c in commits:
|
||||||
|
# merges and empty commits have no diffstat, so they show the hash alone
|
||||||
annotation = c.short
|
annotation = c.short
|
||||||
if c.files is not None:
|
if c.files is not None:
|
||||||
# merges and empty commits have no diffstat, so they show the hash alone
|
|
||||||
annotation = f"{file_count(c.files)} changed -{c.deletions} +{c.insertions} {c.short}"
|
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)"
|
subject = c.subject or "(no subject)"
|
||||||
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
|
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
|
||||||
if len(subject) > budget:
|
if len(subject) > budget:
|
||||||
@@ -152,19 +183,21 @@ def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
|||||||
def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
|
def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
|
||||||
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
|
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
|
||||||
placeholder: str = "Changed files") -> None:
|
placeholder: str = "Changed files") -> None:
|
||||||
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
|
"""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: int) -> None:
|
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
|
||||||
@@ -175,12 +208,14 @@ def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState,
|
|||||||
show_file(view, st, change)
|
show_file(view, st, change)
|
||||||
|
|
||||||
def on_highlight(index: int) -> None:
|
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
|
||||||
|
|
||||||
def preview() -> None:
|
def preview() -> None:
|
||||||
|
"""Show the file unless another highlight came since"""
|
||||||
if ticket == st.highlight:
|
if ticket == st.highlight:
|
||||||
show_file(view, st, files[index])
|
show_file(view, st, files[index])
|
||||||
|
|
||||||
@@ -193,14 +228,16 @@ def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] =
|
|||||||
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> None:
|
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> None:
|
||||||
"""Re-list the working tree changes and show the same file, or the next one when it is gone
|
"""Re-list the working tree changes and show the same file, or the next one when it is gone
|
||||||
|
|
||||||
`pick(files, index)` may name another file instead; `index` is the shown file's slot, new or vacated.
|
`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: List[FileChange]) -> None:
|
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
|
||||||
@@ -212,7 +249,13 @@ def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] =
|
|||||||
# the file left the list: whatever moved up into its slot takes over
|
# the file left the list: whatever moved up into its slot takes over
|
||||||
index = min(previous_index, len(files) - 1)
|
index = min(previous_index, len(files) - 1)
|
||||||
chosen = pick(files, index) if pick else None
|
chosen = pick(files, index) if pick else None
|
||||||
show_file(view, st, chosen if chosen is not None else files[index])
|
change = chosen if chosen is not None else files[index]
|
||||||
|
if st.all_mode:
|
||||||
|
# stay in the aggregate view, re-diffed over the new list, on the same file
|
||||||
|
st.change = change
|
||||||
|
show_all(view, st, focus=change.path)
|
||||||
|
else:
|
||||||
|
show_file(view, st, change)
|
||||||
|
|
||||||
run_async(lambda: git.changed_files(st.repo), done)
|
run_async(lambda: git.changed_files(st.repo), done)
|
||||||
|
|
||||||
@@ -229,6 +272,7 @@ 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) -> None:
|
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:
|
||||||
@@ -236,6 +280,7 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
|||||||
view, st = opened
|
view, st = opened
|
||||||
|
|
||||||
def done(files: List[FileChange]) -> None:
|
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()
|
||||||
@@ -253,6 +298,7 @@ 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: int = 0, sha: Optional[str] = None) -> 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
|
||||||
@@ -263,6 +309,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
count = int(settings().get("commits_count", 30) or 30)
|
count = int(settings().get("commits_count", 30) or 30)
|
||||||
|
|
||||||
def done(commits: List[Commit]) -> None:
|
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:
|
||||||
@@ -273,22 +320,25 @@ 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: sublime.View, st: ViewState, commits: Sequence[Commit], selected: int) -> None:
|
def show_commits(self, view: sublime.View, st: ViewState, commits: Sequence[Commit], selected: int) -> None:
|
||||||
|
"""Recent-commits panel, preselecting the commit already shown"""
|
||||||
items = commit_items(commits)
|
items = commit_items(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: int) -> None:
|
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: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None:
|
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: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
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
|
||||||
@@ -298,6 +348,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
def back() -> None:
|
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 = f"{info.short} · {info.subject}"
|
placeholder = f"{info.short} · {info.subject}"
|
||||||
@@ -311,31 +362,36 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
|
|
||||||
|
|
||||||
def _act_files(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
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: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
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: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Close the review view"""
|
||||||
view.close()
|
view.close()
|
||||||
|
|
||||||
|
|
||||||
def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
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: str, done_verb: str, operation: Callable[[str, FileChange], None],
|
def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None],
|
||||||
confirm: Optional[Callable[[FileChange], bool]] = None,
|
confirm: Optional[Callable[[FileChange], bool]] = None,
|
||||||
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> Action:
|
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> Action:
|
||||||
"""Run `operation(repo, change)` on the shown working-tree file, then re-list; `pick` goes to refresh_listing"""
|
"""Action running `operation(repo, change)` on the current working-tree file, then re-listing; `pick` is passed to refresh_listing"""
|
||||||
|
|
||||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
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"""
|
||||||
|
change = _current_change(view, st)
|
||||||
|
if st.in_commit or change is None:
|
||||||
status(f"{verb} works on uncommitted changes only")
|
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(
|
run_async(
|
||||||
@@ -347,8 +403,10 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange]
|
|||||||
|
|
||||||
|
|
||||||
def _confirm_discard(change: FileChange) -> bool:
|
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 = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
|
message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
|
||||||
else:
|
else:
|
||||||
@@ -372,12 +430,16 @@ 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"""
|
"""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):
|
if not settings().get("stage_and_move_next_file", True):
|
||||||
return None
|
return None
|
||||||
# the file just staged is staged now, so a scan starting on it moves off by itself
|
# scanning from the staged file's slot is safe: it is skipped as staged
|
||||||
following = _next_unstaged(files, index)
|
following = _next_unstaged(files, index)
|
||||||
return files[following] if following >= 0 else None
|
return files[following] if following >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> 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:
|
||||||
@@ -386,26 +448,92 @@ def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> N
|
|||||||
refresh_listing(view, st)
|
refresh_listing(view, st)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_change(view: sublime.View, st: ViewState) -> Optional[FileChange]:
|
||||||
|
"""File under the caret in the aggregate view (made current), else the shown file"""
|
||||||
|
if st.all_mode:
|
||||||
|
change = render.all_change_at(view, st)
|
||||||
|
if change is not None:
|
||||||
|
st.change = change
|
||||||
|
return st.change
|
||||||
|
|
||||||
|
|
||||||
def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if st.change is 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(f"{st.change.path} does not exist in the working tree")
|
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(f"{path}:{line}", sublime.ENCODED_POSITION)
|
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
|
||||||
|
|
||||||
|
|
||||||
def _step_file(delta: int) -> Action:
|
def _act_view(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Switch between the single-file and the aggregate view"""
|
||||||
|
if st.all_mode:
|
||||||
|
st.all_mode = False
|
||||||
|
if st.change is not None:
|
||||||
|
show_file(view, st, st.change)
|
||||||
|
else:
|
||||||
|
show_all(view, st)
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_file(delta: int) -> Action:
|
||||||
|
"""Action expanding (delta > 0) or collapsing the caret's file in the aggregate view"""
|
||||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
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
|
||||||
count = len(st.files)
|
count = len(st.files)
|
||||||
position = st.index()
|
position = st.index()
|
||||||
if position < 0:
|
if position < 0:
|
||||||
# nothing shown yet: n starts at the first file, p at the last. Not a wrap.
|
# nothing shown yet: n starts at the first file, p at the last, no wrap
|
||||||
index, wrapped = (0 if delta > 0 else count - 1), False
|
index, wrapped = (0 if delta > 0 else count - 1), False
|
||||||
else:
|
else:
|
||||||
index = position + delta
|
index = position + delta
|
||||||
@@ -421,7 +549,42 @@ def _step_file(delta: int) -> Action:
|
|||||||
|
|
||||||
|
|
||||||
def _step_change(delta: int) -> Action:
|
def _step_change(delta: int) -> Action:
|
||||||
|
"""Action moving the caret `delta` changes on, wrapping round"""
|
||||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
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:
|
if not starts:
|
||||||
status("no changes to step through")
|
status("no changes to step through")
|
||||||
@@ -431,6 +594,7 @@ def _step_change(delta: int) -> Action:
|
|||||||
if not 0 <= position < len(starts):
|
if not 0 <= position < len(starts):
|
||||||
render.flash(view)
|
render.flash(view)
|
||||||
position %= len(starts) # -1 is the last change, len(starts) the first
|
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))
|
||||||
@@ -450,6 +614,7 @@ def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> No
|
|||||||
return
|
return
|
||||||
|
|
||||||
def on_done(message: str) -> None:
|
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():
|
if not message.strip():
|
||||||
status("commit cancelled: empty message")
|
status("commit cancelled: empty message")
|
||||||
return
|
return
|
||||||
@@ -464,7 +629,7 @@ def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> No
|
|||||||
def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> 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
|
"""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.
|
The one-line panel offers only the subject; the body rides along verbatim, and nothing staged is a reword
|
||||||
"""
|
"""
|
||||||
if st.in_commit:
|
if st.in_commit:
|
||||||
status("amend works on uncommitted changes only")
|
status("amend works on uncommitted changes only")
|
||||||
@@ -472,9 +637,11 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
|
|||||||
staged = [c for c in st.files if c.staged]
|
staged = [c for c in st.files if c.staged]
|
||||||
|
|
||||||
def prompt(message: str) -> None:
|
def prompt(message: str) -> None:
|
||||||
|
"""Show the input panel prefilled with HEAD's subject"""
|
||||||
subject, newline, body = message.partition("\n")
|
subject, newline, body = message.partition("\n")
|
||||||
|
|
||||||
def on_done(text: str) -> None:
|
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():
|
if not text.strip():
|
||||||
status("amend cancelled: empty message")
|
status("amend cancelled: empty message")
|
||||||
return
|
return
|
||||||
@@ -482,7 +649,7 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
|
|||||||
if staged:
|
if staged:
|
||||||
done += f" with {file_count(len(staged))}"
|
done += f" with {file_count(len(staged))}"
|
||||||
run_async(
|
run_async(
|
||||||
# nothing staged makes this a pure reword, which git refuses on an empty commit
|
# a reword needs --allow-empty when HEAD itself is an empty commit
|
||||||
lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged),
|
lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged),
|
||||||
lambda _: refresh_listing(view, st, done),
|
lambda _: refresh_listing(view, st, done),
|
||||||
)
|
)
|
||||||
@@ -496,7 +663,9 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def _move_line(forward: bool) -> Action:
|
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:
|
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Move the caret one line"""
|
||||||
view.run_command("move", {"by": "lines", "forward": forward})
|
view.run_command("move", {"by": "lines", "forward": forward})
|
||||||
|
|
||||||
return act
|
return act
|
||||||
@@ -513,6 +682,10 @@ ACTIONS: Dict[str, Action] = {
|
|||||||
"commit": _act_commit,
|
"commit": _act_commit,
|
||||||
"amend": _act_amend,
|
"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),
|
||||||
@@ -527,9 +700,11 @@ 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: Optional[str] = None) -> bool:
|
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: sublime.Edit, action: str) -> None:
|
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(f"unknown action {action}")
|
status(f"unknown action {action}")
|
||||||
@@ -540,35 +715,50 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
def restore_view(view: sublime.View) -> ViewState:
|
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)
|
||||||
|
|
||||||
|
# a commit view re-lists the commit's files, a working-tree view the uncommitted changes
|
||||||
sha = st.sha
|
sha = st.sha
|
||||||
if sha is not None:
|
if sha is not None:
|
||||||
|
|
||||||
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
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, sha), show_commit)
|
run_async(lambda: git.commit_with_files(repo, sha), show_commit)
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def show_working_tree(files: List[FileChange]) -> None:
|
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, f"{path} has no uncommitted changes any more")
|
render.render_message(view, st, f"{path} has no uncommitted changes any more")
|
||||||
|
|||||||
41
vdiff/git.py
41
vdiff/git.py
@@ -1,4 +1,4 @@
|
|||||||
""" 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 re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -6,7 +6,7 @@ from typing import Dict, List, Optional, NamedTuple, Sequence, Tuple
|
|||||||
|
|
||||||
|
|
||||||
class FileChange(NamedTuple):
|
class FileChange(NamedTuple):
|
||||||
"""path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source"""
|
"""Path, one-letter status (see STATUS_NAMES), staged flag, numstat counts (None for binary), rename source"""
|
||||||
|
|
||||||
path: str
|
path: str
|
||||||
status: str
|
status: str
|
||||||
@@ -17,7 +17,7 @@ class FileChange(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class Commit(NamedTuple):
|
class Commit(NamedTuple):
|
||||||
"""shortstat totals; all three are None when git prints no diffstat (merges, empty commits)"""
|
"""One log entry, date relative; files/insertions/deletions are None without a diffstat (merges, empty commits)"""
|
||||||
|
|
||||||
sha: str
|
sha: str
|
||||||
short: str
|
short: str
|
||||||
@@ -30,6 +30,8 @@ class Commit(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class CommitInfo(NamedTuple):
|
class CommitInfo(NamedTuple):
|
||||||
|
"""One commit: its hashes, author, date, subject and body"""
|
||||||
|
|
||||||
sha: str
|
sha: str
|
||||||
short: str
|
short: str
|
||||||
author: str
|
author: str
|
||||||
@@ -69,9 +71,10 @@ _SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(
|
|||||||
|
|
||||||
|
|
||||||
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: str, code: int) -> None:
|
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()
|
||||||
@@ -91,9 +94,11 @@ 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):
|
||||||
@@ -116,13 +121,14 @@ def base_ref(repo: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _int_or_none(value: str) -> Optional[int]:
|
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: str) -> Dict[str, Numstat]:
|
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: Dict[str, Numstat] = {}
|
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")
|
||||||
@@ -141,6 +147,7 @@ def _parse_numstat(text: str) -> Dict[str, Numstat]:
|
|||||||
def _parse_name_status(text: str) -> List[Tuple[str, str, Optional[str]]]:
|
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: List[Tuple[str, str, Optional[str]]] = []
|
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:
|
||||||
@@ -157,11 +164,13 @@ 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:
|
||||||
@@ -170,6 +179,7 @@ def _count_lines(path: str) -> Optional[int]:
|
|||||||
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)
|
||||||
@@ -183,6 +193,7 @@ def changed_files(repo: str) -> List[FileChange]:
|
|||||||
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]))
|
||||||
|
|
||||||
|
# "1" ordinary, "u" unmerged, "?" untracked entries; anything else (branch headers) is skipped
|
||||||
changes: List[FileChange] = []
|
changes: List[FileChange] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
kind = entry[0]
|
kind = entry[0]
|
||||||
@@ -195,9 +206,11 @@ def changed_files(repo: str) -> List[FileChange]:
|
|||||||
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
|
||||||
@@ -212,8 +225,9 @@ 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 []
|
||||||
# --shortstat rides along on the same call: one subprocess, totals for free
|
# --shortstat adds per-commit totals to the same call
|
||||||
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
|
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] = []
|
commits: List[Commit] = []
|
||||||
for line in text.split("\n"):
|
for line in text.split("\n"):
|
||||||
parts = line.split("\x1f")
|
parts = line.split("\x1f")
|
||||||
@@ -233,14 +247,16 @@ 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 + [f"--format={_INFO_FORMAT}%x00", _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: List[FileChange] = []
|
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):
|
||||||
@@ -263,9 +279,10 @@ def full_diff(repo: str, path: str, sha: Optional[str] = None, untracked: bool =
|
|||||||
"""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 by default (staged and unstaged merged), a commit vs its first parent with
|
Working tree vs HEAD by default (staged and unstaged merged), a commit vs its first parent with
|
||||||
`sha`, the whole file as insertions when untracked.
|
`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:
|
||||||
@@ -279,19 +296,19 @@ def stage(repo: str, change: FileChange) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def commit(repo: str, message: str) -> None:
|
def commit(repo: str, message: str) -> None:
|
||||||
"""Commit what is staged; the message is one argv element, so it needs no quoting of its own"""
|
"""Commit what is staged; `message` goes as one argv element, unquoted"""
|
||||||
run(repo, ["commit", "-m", message])
|
run(repo, ["commit", "-m", message])
|
||||||
|
|
||||||
|
|
||||||
def head_message(repo: str) -> str:
|
def head_message(repo: str) -> str:
|
||||||
"""HEAD's whole message, exactly as git stores it"""
|
"""HEAD's full message, surrounding newlines stripped"""
|
||||||
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
||||||
|
|
||||||
|
|
||||||
def amend(repo: str, message: str, allow_empty: bool = False) -> None:
|
def amend(repo: str, message: str, allow_empty: bool = False) -> None:
|
||||||
"""Rewrite HEAD with `message`, folding in whatever is staged
|
"""Rewrite HEAD with `message`, folding in whatever is staged
|
||||||
|
|
||||||
`allow_empty` for a pure reword: git refuses an amend carrying no change, failing on an empty commit.
|
`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])
|
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
|
||||||
|
|
||||||
@@ -305,7 +322,7 @@ def unstage(repo: str, change: FileChange) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def discard(repo: str, change: FileChange) -> None:
|
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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .commands import refresh_if_changed, restore_view
|
|||||||
|
|
||||||
class VdiffEventListener(sublime_plugin.EventListener):
|
class VdiffEventListener(sublime_plugin.EventListener):
|
||||||
def on_pre_close(self, view: sublime.View) -> None:
|
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: sublime.View) -> None:
|
def on_activated(self, view: sublime.View) -> None:
|
||||||
|
|||||||
122
vdiff/palette.py
122
vdiff/palette.py
@@ -5,7 +5,7 @@ red/green background and the file's syntax colours. This module generates a scop
|
|||||||
kind, colour) pair carrying both, and render paints each run of same-coloured tokens with it.
|
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
|
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.
|
rather than a colour at a time
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -16,16 +16,28 @@ import sublime
|
|||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
LOG = logging.getLogger("vdiff")
|
||||||
|
|
||||||
# the full-line tint under the token layer, the scheme's own: the token layer repaints the text on
|
# tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text
|
||||||
# top so the tint's foreground never shows, and a scope the scheme defines needs no restart.
|
|
||||||
SCOPE_DEL = "diff.deleted"
|
SCOPE_DEL = "diff.deleted"
|
||||||
SCOPE_INS = "diff.inserted"
|
SCOPE_INS = "diff.inserted"
|
||||||
SCOPE_DEL_CHAR = "diff.deleted.char"
|
SCOPE_DEL_CHAR = "diff.deleted.char"
|
||||||
SCOPE_INS_CHAR = "diff.inserted.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, and the tint each one draws over
|
# background kinds (tinted ones paint changed lines, "ctx" the all-files context rows) and their tint scopes
|
||||||
KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
|
TINT_KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
|
||||||
KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR}
|
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)
|
# tints to provision when the active scheme defines none of its own (some third-party schemes)
|
||||||
FALLBACK_TINTS: Dict[str, str] = {
|
FALLBACK_TINTS: Dict[str, str] = {
|
||||||
@@ -35,9 +47,8 @@ FALLBACK_TINTS: Dict[str, str] = {
|
|||||||
SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)",
|
SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)",
|
||||||
}
|
}
|
||||||
|
|
||||||
# scope roots to probe for the scheme's palette: schemes colour by these standard names, so one pass
|
# standard scope roots probed for the palette; no API returns it whole, and parsing the scheme
|
||||||
# yields the working set. No API returns a scheme's palette — style_for_scope takes one scope at a
|
# file would mean re-doing sublime's merging
|
||||||
# time, and reading the file would mean re-implementing sublime's override and .tmTheme merging.
|
|
||||||
PALETTE_PROBES: Tuple[str, ...] = (
|
PALETTE_PROBES: Tuple[str, ...] = (
|
||||||
"source",
|
"source",
|
||||||
"comment",
|
"comment",
|
||||||
@@ -71,7 +82,7 @@ PALETTE_PROBES: Tuple[str, ...] = (
|
|||||||
_palettes: Dict[str, Dict[str, int]] = {}
|
_palettes: Dict[str, Dict[str, int]] = {}
|
||||||
# the override name the last ensure() resolved, so scope_for knows which palette to read
|
# the override name the last ensure() resolved, so scope_for knows which palette to read
|
||||||
_active: Optional[str] = None
|
_active: Optional[str] = None
|
||||||
# schemes the reader has already been told need a restart
|
# schemes the user has already been told need a restart
|
||||||
_notified: Set[Optional[str]] = set()
|
_notified: Set[Optional[str]] = set()
|
||||||
|
|
||||||
|
|
||||||
@@ -84,22 +95,23 @@ def reset() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def claim_restart_notice(view: sublime.View) -> bool:
|
def claim_restart_notice(view: sublime.View) -> bool:
|
||||||
"""Whether the reader still has to be told to restart, asked once per scheme
|
"""Whether to tell the user to restart; True at most once per scheme
|
||||||
|
|
||||||
True only when a restart helps — provisioned but not yet loaded; it cannot fix one we never wrote.
|
Only when the override is written but not yet loaded
|
||||||
"""
|
"""
|
||||||
if not (_active and _palettes.get(_active)) or tokens_resolved(view) or _active in _notified:
|
if not (_active and _palettes.get(_active)) or tokens_resolved(view, KINDS) or _active in _notified:
|
||||||
return False
|
return False
|
||||||
_notified.add(_active)
|
_notified.add(_active)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def scope_background(view: sublime.View, scope: str) -> Optional[str]:
|
def scope_background(view: sublime.View, scope: str) -> Optional[str]:
|
||||||
"""The scheme's background for a scope, or None; style_for_scope can fail on a closing view"""
|
"""The scheme's background for a scope, or None"""
|
||||||
return _style(view, scope).get("background")
|
return _style(view, scope).get("background")
|
||||||
|
|
||||||
|
|
||||||
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
|
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
|
||||||
|
"""The scheme's style for a scope, or {} when style_for_scope fails"""
|
||||||
try:
|
try:
|
||||||
return view.style_for_scope(scope)
|
return view.style_for_scope(scope)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -112,17 +124,24 @@ def foreground(view: sublime.View, scope: str) -> Optional[str]:
|
|||||||
return _style(view, scope).get("foreground")
|
return _style(view, scope).get("foreground")
|
||||||
|
|
||||||
|
|
||||||
def tokens_resolved(view: sublime.View) -> bool:
|
def tokens_resolved(view: sublime.View, kinds: Tuple[str, ...] = TINT_KINDS) -> bool:
|
||||||
"""Whether the generated (tint + colour) scopes are live
|
"""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
|
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.
|
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 {}
|
colours = (_active and _palettes.get(_active)) or {}
|
||||||
if not colours:
|
if not colours:
|
||||||
return False
|
return False
|
||||||
# they are all written by the same pass into the same file, so any one of them answers for all
|
# a kind added since the override was written loads only after a restart, so check each kind
|
||||||
return bool(scope_background(view, _scope_name(KINDS[0], next(iter(colours.values())))))
|
# 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]:
|
def scope_for(kind: str, colour: str) -> Optional[str]:
|
||||||
@@ -132,7 +151,7 @@ def scope_for(kind: str, colour: str) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def token_key(scope: str) -> str:
|
def token_key(scope: str) -> str:
|
||||||
"""The add_regions key for a generated scope; one naming rule, spelled here only"""
|
"""The add_regions key for a generated scope"""
|
||||||
return scope.replace("vdiff.", "vdiff.tok.", 1)
|
return scope.replace("vdiff.", "vdiff.tok.", 1)
|
||||||
|
|
||||||
|
|
||||||
@@ -143,28 +162,30 @@ def token_keys() -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _scope_name(kind: str, index: int) -> str:
|
def _scope_name(kind: str, index: int) -> str:
|
||||||
|
"""Generated scope name for a background kind and palette index"""
|
||||||
return f"vdiff.{kind}.c{index}"
|
return f"vdiff.{kind}.c{index}"
|
||||||
|
|
||||||
|
|
||||||
def ensure(view: sublime.View) -> None:
|
def ensure(view: sublime.View) -> None:
|
||||||
"""Provision the scopes for the active scheme, once per scheme per session
|
"""Provision the scopes for the active scheme, once per scheme per session
|
||||||
|
|
||||||
Whether they have taken effect is tokens_resolved()'s question: a new override loads only on a restart.
|
A new override loads only on a restart; tokens_resolved() says when it is live
|
||||||
"""
|
"""
|
||||||
global _active
|
global _active
|
||||||
name = override_name(view)
|
name = override_name(view)
|
||||||
_active = name
|
_active = name
|
||||||
if not name or name in _palettes:
|
if not name or name in _palettes:
|
||||||
return
|
return
|
||||||
|
# merge into the existing override, if any
|
||||||
path = os.path.join(sublime.packages_path(), "User", name)
|
path = os.path.join(sublime.packages_path(), "User", name)
|
||||||
try:
|
try:
|
||||||
scheme: Dict[str, Any] = {}
|
scheme: Dict[str, Any] = {}
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
# a hand-edited scheme may hold comments decode_value rejects: the raise leaves the
|
# invalid JSON raises here, leaving the file untouched; render still shows the changed lines
|
||||||
# file untouched, and render still shows the changed lines
|
|
||||||
scheme = sublime.decode_value(f.read()) or {}
|
scheme = sublime.decode_value(f.read()) or {}
|
||||||
rules = scheme.setdefault("rules", [])
|
rules = scheme.setdefault("rules", [])
|
||||||
|
# probe the scheme, record the palette, and write only when a rule changed
|
||||||
tints = _tints(view)
|
tints = _tints(view)
|
||||||
colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
|
colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
|
||||||
_palettes[name] = colours
|
_palettes[name] = colours
|
||||||
@@ -176,6 +197,7 @@ def ensure(view: sublime.View) -> None:
|
|||||||
LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path)
|
LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("could not write colour scheme override %s", path, exc_info=True)
|
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, {})
|
_palettes.setdefault(name, {})
|
||||||
|
|
||||||
|
|
||||||
@@ -192,7 +214,7 @@ def _probe_palette(view: sublime.View) -> List[str]:
|
|||||||
def _tints(view: sublime.View) -> Dict[str, str]:
|
def _tints(view: sublime.View) -> Dict[str, str]:
|
||||||
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
|
"""{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.
|
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own
|
||||||
"""
|
"""
|
||||||
tints: Dict[str, str] = {}
|
tints: Dict[str, str] = {}
|
||||||
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)):
|
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)):
|
||||||
@@ -200,23 +222,68 @@ def _tints(view: sublime.View) -> Dict[str, str]:
|
|||||||
char = scope_background(view, char_scope)
|
char = scope_background(view, char_scope)
|
||||||
tints[line_scope] = line if line and char else FALLBACK_TINTS[line_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]
|
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
|
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],
|
def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
|
||||||
view: sublime.View) -> bool:
|
view: sublime.View) -> bool:
|
||||||
"""Bring the scheme's rules up to date, in place; True when anything changed
|
"""Bring the scheme's rules up to date, in place; True when anything changed
|
||||||
|
|
||||||
add_regions draws text in the region scope's own foreground, so each (kind, colour) pair needs a
|
Each (kind, colour) rule carries a background and a foreground, as a background alone would flatten
|
||||||
rule carrying both; a background alone would flatten the line. Plain tints are written only when
|
the line's colours; plain tints are added only when missing and never overwritten, so a hand-tuned one survives
|
||||||
the scheme has none, and never overwritten, so a hand-tuned one survives.
|
|
||||||
"""
|
"""
|
||||||
present = {rule.get("scope"): rule for rule in rules}
|
present = {rule.get("scope"): rule for rule in rules}
|
||||||
changed = False
|
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():
|
for scope, background in tints.items():
|
||||||
if scope not in present and not scope_background(view, scope):
|
if scope != SCOPE_CTX and scope not in present and not scope_background(view, scope):
|
||||||
rules.append({"scope": scope, "background": background})
|
rules.append({"scope": scope, "background": background})
|
||||||
changed = True
|
changed = True
|
||||||
|
# one rule per (kind, colour) pair
|
||||||
for colour, index in colours.items():
|
for colour, index in colours.items():
|
||||||
for kind in KINDS:
|
for kind in KINDS:
|
||||||
wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]],
|
wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]],
|
||||||
@@ -240,6 +307,7 @@ def override_name(view: sublime.View) -> Optional[str]:
|
|||||||
name = sublime.ui_info()["color_scheme"]["resolved_value"]
|
name = sublime.ui_info()["color_scheme"]["resolved_value"]
|
||||||
except Exception:
|
except Exception:
|
||||||
name = view.settings().get("color_scheme") or ""
|
name = view.settings().get("color_scheme") or ""
|
||||||
|
# no usable scheme name: nothing to override
|
||||||
if not name or name == "auto":
|
if not name or name == "auto":
|
||||||
return None
|
return None
|
||||||
root, _ = os.path.splitext(os.path.basename(name))
|
root, _ = os.path.splitext(os.path.basename(name))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
""" 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
|
from collections import Counter
|
||||||
@@ -7,7 +7,7 @@ from typing import Callable, Iterator, List, NamedTuple, Optional, Sequence, Tup
|
|||||||
|
|
||||||
|
|
||||||
class Line(NamedTuple):
|
class Line(NamedTuple):
|
||||||
"""kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None"""
|
"""One diff row: kind " " (context), "-" (deleted) or "+" (inserted); the missing line number is None"""
|
||||||
|
|
||||||
kind: str
|
kind: str
|
||||||
old_no: Optional[int]
|
old_no: Optional[int]
|
||||||
@@ -16,6 +16,8 @@ class Line(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class Diff(NamedTuple):
|
class Diff(NamedTuple):
|
||||||
|
"""A parsed unified diff: its rows, and whether git reported the file as binary"""
|
||||||
|
|
||||||
lines: List[Line]
|
lines: List[Line]
|
||||||
binary: bool
|
binary: bool
|
||||||
|
|
||||||
@@ -23,12 +25,12 @@ class Diff(NamedTuple):
|
|||||||
# the value `runs` groups by, so a caller gets its own key type back rather than `object`
|
# the value `runs` groups by, so a caller gets its own key type back rather than `object`
|
||||||
Key = TypeVar("Key")
|
Key = TypeVar("Key")
|
||||||
|
|
||||||
# an (start, end) index range of consecutive changed lines
|
# a half-open (start, end) index range of consecutive changed lines
|
||||||
Block = Tuple[int, int]
|
Block = Tuple[int, int]
|
||||||
# a (start, end) character range inside one line's text
|
# a (start, end) character range inside one line's text
|
||||||
CharRange = Tuple[int, int]
|
CharRange = Tuple[int, int]
|
||||||
|
|
||||||
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
|
# 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
|
||||||
|
|
||||||
@@ -42,11 +44,13 @@ def parse_unified(text: str) -> Diff:
|
|||||||
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
|
||||||
@@ -54,6 +58,7 @@ def parse_unified(text: str) -> Diff:
|
|||||||
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))
|
||||||
@@ -80,7 +85,7 @@ def runs(items: Sequence[Line], key: Callable[[Line], Key]) -> Iterator[Tuple[Ke
|
|||||||
|
|
||||||
|
|
||||||
def change_blocks(lines: Sequence[Line]) -> List[Block]:
|
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]
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ def counts(lines: Sequence[Line]) -> Tuple[int, int]:
|
|||||||
|
|
||||||
|
|
||||||
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
|
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 == "+"]
|
||||||
@@ -107,6 +112,7 @@ def char_ranges(old: str, new: str) -> Tuple[List[CharRange], List[CharRange]]:
|
|||||||
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 [], []
|
||||||
|
# each opcode span becomes a range on whichever side it touches
|
||||||
old_ranges: List[CharRange] = []
|
old_ranges: List[CharRange] = []
|
||||||
new_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():
|
||||||
@@ -121,6 +127,7 @@ 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:
|
||||||
|
|||||||
364
vdiff/render.py
364
vdiff/render.py
@@ -1,9 +1,9 @@
|
|||||||
""" 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 bisect
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple
|
from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
@@ -22,19 +22,36 @@ 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: Tuple[str, ...] = (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"
|
||||||
|
|
||||||
# visual bell: a whole-buffer tint on a timer. Not in REGION_KEYS, so a wrap's re-render cannot wipe it early.
|
# visual bell: a timed whole-buffer tint, kept out of REGION_KEYS so a re-render cannot erase it early
|
||||||
BELL_KEY = "vdiff.bell"
|
BELL_KEY = "vdiff.bell"
|
||||||
BELL_MS = 90
|
BELL_MS = 90
|
||||||
|
|
||||||
# above this much changed text the colours cost more than they are worth: such a diff is scrolled, not read
|
# 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
|
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 the file starts at row 1; only index_point / caret_index know
|
# a block phantom at point 0 draws below row 0, so file text starts at row 1
|
||||||
_HEADER_ROWS = 1
|
_HEADER_ROWS = 1
|
||||||
|
|
||||||
# gutter dots use the scope's foreground
|
# gutter dots use the scope's foreground
|
||||||
@@ -42,12 +59,13 @@ 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
|
# ST4 defines region.<colour>ish in every bundled scheme for exactly this kind of transient marking
|
||||||
SCOPE_BELL = "region.yellowish"
|
SCOPE_BELL = "region.yellowish"
|
||||||
|
SCOPE_FILE_HEADER = palette.SCOPE_FILE_HEADER
|
||||||
|
|
||||||
# sublime only loads an override it has not seen before on a restart
|
# sublime loads a newly created scheme override only after a restart
|
||||||
NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines"
|
NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines"
|
||||||
|
|
||||||
# actions: the ACTIONS names the row covers, a single one making it a link. short: status-bar
|
|
||||||
# wording, "" keeps the row out. The group flag is False for groups needing a working-tree file.
|
# actions: the ACTIONS names the row runs, a link when there is one; short: status-bar label, "" omits it
|
||||||
class Key(NamedTuple):
|
class Key(NamedTuple):
|
||||||
"""One row of the status-bar hints and the ? popup"""
|
"""One row of the status-bar hints and the ? popup"""
|
||||||
|
|
||||||
@@ -57,7 +75,7 @@ class Key(NamedTuple):
|
|||||||
actions: Tuple[str, ...]
|
actions: Tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
# (group title, applies in a commit, rows)
|
# (group title, applies in a commit (False: needs a working-tree file), rows)
|
||||||
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
||||||
(
|
(
|
||||||
"Navigate",
|
"Navigate",
|
||||||
@@ -69,14 +87,6 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
|||||||
Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
|
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",
|
"Actions",
|
||||||
False,
|
False,
|
||||||
@@ -92,8 +102,12 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
|||||||
"View",
|
"View",
|
||||||
True,
|
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",)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -123,9 +137,8 @@ a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-
|
|||||||
.body { color: color(var(--foreground) alpha(0.6)); }
|
.body { color: color(var(--foreground) alpha(0.6)); }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# minihtml drops the rest of a stylesheet at a selector it cannot parse, and documents properties
|
# minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class
|
||||||
# only, not selectors: every rule sticks to one bare element or class, the forms HEADER_CSS proves.
|
# .group's top margin replaces body's top padding; .kdim inherits its colour from .dim
|
||||||
# The first group's margin stands in for body's top padding; .kdim takes its colour from .dim.
|
|
||||||
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; }
|
||||||
@@ -142,12 +155,24 @@ 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: sublime.Edit, text: str) -> None:
|
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 _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:
|
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)
|
||||||
@@ -158,12 +183,20 @@ def init_view(view: sublime.View) -> None:
|
|||||||
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
|
# a read-only viewer, so vim's insert mode is meaningless: command mode, block caret. vdiff's
|
||||||
# own keys carry no command_mode context, so they fire in either mode regardless.
|
# own keys carry no command_mode context, so they fire in either mode
|
||||||
s.set("command_mode", True)
|
s.set("command_mode", True)
|
||||||
s.set("inverse_caret_state", 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():
|
||||||
@@ -191,6 +224,7 @@ def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float
|
|||||||
|
|
||||||
|
|
||||||
def _mtime(path: str) -> Optional[float]:
|
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:
|
||||||
@@ -198,14 +232,25 @@ def _mtime(path: str) -> Optional[float]:
|
|||||||
|
|
||||||
|
|
||||||
def flash(view: sublime.View) -> None:
|
def flash(view: sublime.View) -> None:
|
||||||
"""Visual bell: tint the buffer for a moment, for feedback that has nothing to say in words"""
|
"""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)
|
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
|
# 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)
|
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:
|
def _bell_scope(view: sublime.View) -> str:
|
||||||
"""region.yellowish, or a scope with a background we know of when a scheme lacks it"""
|
"""region.yellowish, or the insert tint when the scheme lacks it"""
|
||||||
if palette.scope_background(view, SCOPE_BELL):
|
if palette.scope_background(view, SCOPE_BELL):
|
||||||
return SCOPE_BELL
|
return SCOPE_BELL
|
||||||
# an invisible bell is a silent failure, so borrow the insert tint
|
# an invisible bell is a silent failure, so borrow the insert tint
|
||||||
@@ -223,12 +268,219 @@ def render_message(view: sublime.View, st: ViewState, message: str) -> None:
|
|||||||
_finish(view, st, 0, 0, "vDiff")
|
_finish(view, st, 0, 0, "vDiff")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
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
|
change = st.change
|
||||||
# every caller with nothing to show goes to render_message instead, so there is a file here
|
# every caller with nothing to show goes to render_message instead, so there is a file here
|
||||||
assert change is not None
|
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:
|
||||||
@@ -236,6 +488,7 @@ def render(view: sublime.View, st: ViewState) -> None:
|
|||||||
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, 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)
|
||||||
@@ -247,6 +500,7 @@ def render(view: sublime.View, st: ViewState) -> None:
|
|||||||
if palette.claim_restart_notice(view):
|
if palette.claim_restart_notice(view):
|
||||||
status(NEEDS_RESTART)
|
status(NEEDS_RESTART)
|
||||||
|
|
||||||
|
# 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, f"⟐ {os.path.basename(change.path)}")
|
_finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}")
|
||||||
view.sel().clear()
|
view.sel().clear()
|
||||||
@@ -257,40 +511,45 @@ def render(view: sublime.View, st: ViewState) -> None:
|
|||||||
def _paint_regions(view: sublime.View, st: ViewState) -> None:
|
def _paint_regions(view: sublime.View, st: ViewState) -> None:
|
||||||
"""The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
|
"""The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
|
||||||
lines = st.lines
|
lines = st.lines
|
||||||
|
# full-line tint
|
||||||
starts = _row_starts(lines)
|
starts = _row_starts(lines)
|
||||||
deleted, inserted = _line_regions(lines, starts)
|
deleted, inserted = _line_regions(lines, starts)
|
||||||
view.add_regions(KEY_DEL, deleted, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
|
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)
|
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
|
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 == "-"]
|
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 == "+"]
|
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_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)
|
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] = []
|
del_chars: List[sublime.Region] = []
|
||||||
ins_chars: List[sublime.Region] = []
|
ins_chars: List[sublime.Region] = []
|
||||||
if settings().get("word_diff", True):
|
if settings().get("word_diff", True):
|
||||||
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
|
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_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)
|
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)
|
_paint_tokens(view, lines, starts, del_chars + ins_chars)
|
||||||
|
|
||||||
|
|
||||||
def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int],
|
def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int], char_regions: Sequence[sublime.Region]) -> None:
|
||||||
char_regions: Sequence[sublime.Region]) -> None:
|
|
||||||
"""Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour
|
"""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
|
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.
|
flattened every changed line; only handing each run its own colour gives the highlighting back
|
||||||
"""
|
"""
|
||||||
if not palette.tokens_resolved(view):
|
if not palette.tokens_resolved(view):
|
||||||
return
|
return
|
||||||
changed = [i for i, line in enumerate(lines) if line.kind != " "]
|
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:
|
if sum(len(lines[i].text) for i in changed) > TOKEN_COLOUR_MAX_CHARS:
|
||||||
# a huge diff would spend longer being tokenised than read; the flat tint still shows it
|
# too big to colour; the flat tint still shows it
|
||||||
return
|
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)
|
stronger = sorted((r.a, r.b) for r in char_regions)
|
||||||
starts_of = [a for a, _ in stronger]
|
starts_of = [a for a, _ in stronger]
|
||||||
|
# group every changed line's colour runs by scope
|
||||||
groups: Dict[str, List[sublime.Region]] = {}
|
groups: Dict[str, List[sublime.Region]] = {}
|
||||||
colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render
|
colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render
|
||||||
for i in changed:
|
for i in changed:
|
||||||
@@ -305,6 +564,7 @@ def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[in
|
|||||||
scope = palette.scope_for(kind + ".char" if inside else kind, colour)
|
scope = palette.scope_for(kind + ".char" if inside else kind, colour)
|
||||||
if scope:
|
if scope:
|
||||||
groups.setdefault(scope, []).append(part)
|
groups.setdefault(scope, []).append(part)
|
||||||
|
# one region set per scope
|
||||||
for scope, regions in groups.items():
|
for scope, regions in groups.items():
|
||||||
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
|
||||||
@@ -313,6 +573,7 @@ def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List
|
|||||||
"""(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
|
"""(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
|
||||||
if not intervals:
|
if not intervals:
|
||||||
return [(region, False)]
|
return [(region, False)]
|
||||||
|
# walk the intervals, emitting the gap before each and its overlap with the region
|
||||||
parts: List[Tuple[sublime.Region, bool]] = []
|
parts: List[Tuple[sublime.Region, bool]] = []
|
||||||
pos = region.a
|
pos = region.a
|
||||||
for a, b in intervals:
|
for a, b in intervals:
|
||||||
@@ -325,21 +586,33 @@ def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List
|
|||||||
parts.append((sublime.Region(pos, a), False))
|
parts.append((sublime.Region(pos, a), False))
|
||||||
parts.append((sublime.Region(a, b), True))
|
parts.append((sublime.Region(a, b), True))
|
||||||
pos = b
|
pos = b
|
||||||
|
# the tail after the last interval, or the whole region when none overlapped
|
||||||
if pos < region.b or not parts:
|
if pos < region.b or not parts:
|
||||||
parts.append((sublime.Region(pos, region.b), False))
|
parts.append((sublime.Region(pos, region.b), False))
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def _colour_runs(view: sublime.View, start: int, text: str,
|
def _colour_runs(
|
||||||
colours: Dict[str, Optional[str]]) -> List[Tuple[sublime.Region, str]]:
|
view: sublime.View, start: int, text: str, colours: Dict[str, Optional[str]], scheme_view: Optional[sublime.View] = None
|
||||||
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged"""
|
) -> 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]] = []
|
runs: List[Tuple[sublime.Region, str]] = []
|
||||||
for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, start + len(text))):
|
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:
|
if scope not in colours:
|
||||||
colours[scope] = palette.foreground(view, scope)
|
colours[scope] = palette.foreground(scheme_view or view, scope)
|
||||||
colour = colours[scope]
|
colour = colours[scope]
|
||||||
if not colour:
|
if not colour:
|
||||||
continue
|
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:
|
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)
|
runs[-1] = (sublime.Region(runs[-1][0].a, region.b), colour)
|
||||||
else:
|
else:
|
||||||
@@ -348,7 +621,7 @@ def _colour_runs(view: sublime.View, start: int, text: str,
|
|||||||
|
|
||||||
|
|
||||||
def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -> None:
|
def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -> None:
|
||||||
"""Header, tab name, status bar and bookkeeping shared by render and render_message"""
|
"""Header, 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)
|
||||||
@@ -357,6 +630,7 @@ def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -
|
|||||||
|
|
||||||
|
|
||||||
def _replace(view: sublime.View, text: str) -> None:
|
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})
|
||||||
@@ -389,8 +663,7 @@ def _row_starts(lines: Sequence[Line]) -> List[int]:
|
|||||||
return starts
|
return starts
|
||||||
|
|
||||||
|
|
||||||
def _line_regions(lines: Sequence[Line],
|
def _line_regions(lines: Sequence[Line], starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
|
||||||
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: Dict[str, List[sublime.Region]] = {"-": [], "+": []}
|
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):
|
||||||
@@ -400,12 +673,14 @@ def _line_regions(lines: Sequence[Line],
|
|||||||
return regions["-"], regions["+"]
|
return regions["-"], regions["+"]
|
||||||
|
|
||||||
|
|
||||||
def _char_regions(lines: Sequence[Line], blocks: Sequence[Block],
|
def _char_regions(
|
||||||
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
|
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: List[sublime.Region] = []
|
deleted: List[sublime.Region] = []
|
||||||
inserted: List[sublime.Region] = []
|
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)
|
||||||
@@ -419,7 +694,7 @@ def _totals(files: Sequence[FileChange]) -> Tuple[int, int]:
|
|||||||
|
|
||||||
|
|
||||||
def _summary_html(st: ViewState) -> str:
|
def _summary_html(st: ViewState) -> str:
|
||||||
"""Line above the header: the whole commit on one line, or how much of the tree is staged"""
|
"""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:
|
if not st.files and not st.commit:
|
||||||
return ""
|
return ""
|
||||||
if st.in_commit:
|
if st.in_commit:
|
||||||
@@ -436,9 +711,10 @@ def _summary_html(st: ViewState) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _commit_parts(st: ViewState) -> List[str]:
|
def _commit_parts(st: ViewState) -> List[str]:
|
||||||
"""hash, author, date, totals and message — everything about the reviewed commit, in that order"""
|
"""Hash, author, date, totals and message of the reviewed commit, in order"""
|
||||||
info = st.commit
|
info = st.commit
|
||||||
parts: List[str] = []
|
parts: List[str] = []
|
||||||
|
# the commit's identity: hash, author, date
|
||||||
if info:
|
if info:
|
||||||
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
|
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
|
||||||
parts.append(html.escape(info.author))
|
parts.append(html.escape(info.author))
|
||||||
@@ -446,6 +722,7 @@ def _commit_parts(st: ViewState) -> List[str]:
|
|||||||
# binary files add nothing to the line totals but still count toward the file total
|
# binary files add nothing to the line totals but still count toward the file total
|
||||||
insertions, deletions = _totals(st.files)
|
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>')
|
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:
|
if info:
|
||||||
message = html.escape(info.subject)
|
message = html.escape(info.subject)
|
||||||
body = info.body.strip().splitlines()
|
body = info.body.strip().splitlines()
|
||||||
@@ -458,6 +735,7 @@ def _commit_parts(st: ViewState) -> List[str]:
|
|||||||
def header_html(st: ViewState, ins: int, dele: int) -> str:
|
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"""
|
"""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:
|
||||||
@@ -470,6 +748,7 @@ def header_html(st: ViewState, ins: int, dele: int) -> str:
|
|||||||
if state:
|
if state:
|
||||||
title += f' <span class="state">{state}</span>'
|
title += f' <span class="state">{state}</span>'
|
||||||
|
|
||||||
|
# action links: stage, unstage and discard need a working-tree file, open needs any file
|
||||||
actions: List[str] = []
|
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>')
|
||||||
@@ -496,6 +775,7 @@ 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: str) -> None:
|
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)
|
||||||
@@ -508,6 +788,7 @@ def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None:
|
|||||||
def help_html(st: ViewState) -> str:
|
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: List[str] = []
|
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)
|
||||||
@@ -516,6 +797,7 @@ def help_html(st: ViewState) -> str:
|
|||||||
parts.append(f'<div class="group">{heading}</div>')
|
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 = (
|
row = (
|
||||||
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
|
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
|
||||||
@@ -534,10 +816,10 @@ def show_help(view: sublime.View, st: ViewState) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def navigate(href: str) -> None:
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -50,21 +50,33 @@ class ViewState:
|
|||||||
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
|
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
|
||||||
# (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable
|
# (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable
|
||||||
fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None
|
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) -> bool:
|
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) -> int:
|
def index(self) -> int:
|
||||||
"""Position in `files` of the file being shown or loaded, or -1
|
"""Position in `files` of the file being shown or loaded, or -1
|
||||||
|
|
||||||
`requested` leads: a diff arrives asynchronously, so counting from `change` would make n / p
|
`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.
|
step from what was on screen before the pending file landed
|
||||||
"""
|
"""
|
||||||
change = self.requested or self.change
|
change = self.requested or self.change
|
||||||
return find_change(self.files, change.path) if change else -1
|
return find_change(self.files, change.path) if change else -1
|
||||||
|
|
||||||
def kept(self) -> Kept:
|
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: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
|
def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
|
||||||
@@ -76,12 +88,21 @@ class ViewState:
|
|||||||
return kept
|
return kept
|
||||||
|
|
||||||
def restore(self, kept: Kept) -> None:
|
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
|
self.requested = kept.change
|
||||||
|
|
||||||
|
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:
|
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: Dict[str, Any] = {"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
|
||||||
@@ -93,6 +114,7 @@ STATES: Dict[int, "ViewState"] = {}
|
|||||||
|
|
||||||
|
|
||||||
def get(view: "sublime.View") -> Optional[ViewState]:
|
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())
|
||||||
|
|
||||||
|
|
||||||
@@ -102,13 +124,16 @@ def tracked() -> List[Tuple[int, ViewState]]:
|
|||||||
|
|
||||||
|
|
||||||
def put(view: "sublime.View", st: ViewState) -> ViewState:
|
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: "sublime.View") -> None:
|
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: Optional["sublime.View"]) -> bool:
|
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))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ 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() -> None:
|
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