Compare commits
3 Commits
v1.1.0
...
f18bad79cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18bad79cd | ||
|
|
2d9db745b0 | ||
|
|
904b941be7 |
@@ -7,6 +7,10 @@
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["S"], "command": "vdiff_action", "args": {"action": "stage_all"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["U"], "command": "vdiff_action", "args": {"action": "unstage_all"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
|
||||
|
||||
@@ -52,6 +52,7 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov
|
||||
|---|---|
|
||||
| `f` / `c` | changed files / recent commits panel |
|
||||
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file |
|
||||
| `S` / `U` | stage all unstaged files / unstage all staged files |
|
||||
| `C` | commit the staged files, typing the message in an input panel |
|
||||
| `m` | amend the last commit: edit its subject, keep its body, fold in anything staged |
|
||||
| `o` or Enter | open the real file at this line |
|
||||
@@ -65,7 +66,7 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov
|
||||
| `?` | show this key list in a popup |
|
||||
| Esc | close the view |
|
||||
|
||||
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 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 same file sets the background of deleted and inserted lines (`deleted_line_background`, `inserted_line_background`) and the colours of the all-files line-number column (`line_numbers_foreground`, `line_numbers_background`); left empty, they follow the colour scheme. The all-files file heading takes its colours from `file_header_background`, `file_header_deleted_foreground` and `file_header_inserted_foreground`.
|
||||
|
||||
## The channel
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import sublime # the stub, via conftest
|
||||
@@ -92,6 +94,64 @@ def test_enter_skips_refetch_when_preview_is_current(monkeypatch):
|
||||
assert shown == ["b.py", "a.py"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode, all_mode", [("file_by_file", False), ("all_in_one", True), (None, False)])
|
||||
def test_a_new_review_view_starts_in_the_default_view_mode(monkeypatch, mode, all_mode):
|
||||
option(monkeypatch, "default_view_mode", mode)
|
||||
monkeypatch.setattr(commands, "resolve_repo", lambda window: "/repo")
|
||||
monkeypatch.setattr(commands, "find_review_view", lambda window: sublime.View())
|
||||
_, st = commands.open_review(sublime.Window())
|
||||
assert st.all_mode is all_mode
|
||||
|
||||
|
||||
def test_panel_in_all_in_one_mode_scrolls_instead_of_loading_files(monkeypatch):
|
||||
loads, focused = [], []
|
||||
|
||||
def fake_show_all(view, st, focus=None):
|
||||
# load at once: what show_all's done does once the diffs land
|
||||
st.token += 1
|
||||
st.rendered_token = st.token
|
||||
loads.append(st.change.path)
|
||||
|
||||
monkeypatch.setattr(commands, "show_all", fake_show_all)
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: pytest.fail("loaded a single file"))
|
||||
monkeypatch.setattr(commands.render, "render_all", lambda view, st, focus=None: focused.append(focus))
|
||||
st = state.ViewState("/repo", all_mode=True)
|
||||
st.change = change("kept.py")
|
||||
st.files = [st.change]
|
||||
files = [change("a.py"), change("b.py")]
|
||||
st.collapsed = {"b.py"}
|
||||
window = sublime.Window()
|
||||
commands.show_files_panel(window, sublime.View(), st, files, st.kept())
|
||||
_, on_select, _, on_highlight, _ = window.panel
|
||||
assert loads == ["a.py"]
|
||||
|
||||
# a highlight unfolds and scrolls to its file; Enter on it needs no redraw
|
||||
on_highlight(1)
|
||||
assert focused == ["b.py"] and st.change.path == "b.py" and "b.py" not in st.collapsed
|
||||
on_select(1)
|
||||
assert focused == ["b.py"] and loads == ["a.py"]
|
||||
|
||||
# Esc goes back to the kept file, still in the all-in-one view
|
||||
on_select(-1)
|
||||
assert st.all_mode and st.change.path == "kept.py" and loads == ["a.py", "kept.py"]
|
||||
|
||||
|
||||
def test_esc_goes_back_to_the_kept_view_mode(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: calls.append(("file", c.path)))
|
||||
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: calls.append(("all", st.change.path)))
|
||||
st = state.ViewState("/repo")
|
||||
st.change = change("kept.py")
|
||||
st.files = [st.change]
|
||||
kept = st.kept()
|
||||
# the view switched to all-in-one after the snapshot; Esc still restores file-by-file
|
||||
st.all_mode = True
|
||||
window = sublime.Window()
|
||||
commands.show_files_panel(window, sublime.View(), st, [change("a.py")], kept)
|
||||
window.panel[1](-1)
|
||||
assert calls[-1] == ("file", "kept.py") and not st.all_mode
|
||||
|
||||
|
||||
def test_on_cancel_goes_back_to_commit_list(monkeypatch):
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
|
||||
st = state.ViewState("/repo")
|
||||
@@ -578,6 +638,66 @@ def test_unstage_and_discard_do_not_move(monkeypatch):
|
||||
assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
|
||||
|
||||
|
||||
def test_stage_all_and_unstage_all_stay_on_the_file(monkeypatch):
|
||||
messages = []
|
||||
monkeypatch.setattr(commands, "status", messages.append)
|
||||
assert press(monkeypatch, "stage_all", flagged(True, False, False), "f1.py", flagged(True, True, True)) == ["f1.py"]
|
||||
assert messages[0] == "staged 2 files"
|
||||
assert press(monkeypatch, "unstage_all", flagged(True, False, True), "f1.py", flagged(False, False, False)) == ["f1.py"]
|
||||
assert messages[-1] == "unstaged 2 files"
|
||||
|
||||
|
||||
def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch):
|
||||
messages = []
|
||||
monkeypatch.setattr(commands, "status", messages.append)
|
||||
assert press(monkeypatch, "stage_all", flagged(True, True), "f0.py", []) == []
|
||||
assert press(monkeypatch, "unstage_all", flagged(False, False), "f0.py", []) == []
|
||||
assert messages == ["nothing to stage", "nothing to unstage"]
|
||||
view, window, st = at_file(flagged(False), "f0.py")
|
||||
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", ""))
|
||||
commands.ACTIONS["stage_all"](view, window, st)
|
||||
assert messages[-1] == "stage all works on uncommitted changes only"
|
||||
|
||||
|
||||
def watched(monkeypatch, tmp_path, all_mode):
|
||||
"""A working-tree view of a.py and b.py on disk with its fingerprint taken; returns (st, refreshes)"""
|
||||
for name in ("a.py", "b.py", "index"):
|
||||
(tmp_path / name).write_text(name)
|
||||
monkeypatch.setattr(commands.git, "index_path", lambda repo: str(tmp_path / "index"))
|
||||
refreshes = []
|
||||
monkeypatch.setattr(commands, "refresh_listing", lambda view, st: refreshes.append(st))
|
||||
st = state.ViewState(str(tmp_path))
|
||||
st.files = [change("a.py"), change("b.py")]
|
||||
st.change, st.all_mode = st.files[0], all_mode
|
||||
st.fingerprint = commands.render.fingerprint(st)
|
||||
return st, refreshes
|
||||
|
||||
|
||||
def touch(path):
|
||||
"""Push the file's mtime forward, as an edit would"""
|
||||
stat = path.stat()
|
||||
os.utime(path, (stat.st_atime, stat.st_mtime + 5))
|
||||
|
||||
|
||||
def test_refresh_if_changed_watches_every_file_of_the_all_files_view(monkeypatch, tmp_path):
|
||||
st, refreshes = watched(monkeypatch, tmp_path, all_mode=True)
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == []
|
||||
touch(tmp_path / "b.py") # not the file under the caret
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == [st]
|
||||
|
||||
|
||||
def test_refresh_if_changed_watches_the_shown_file_and_the_index(monkeypatch, tmp_path):
|
||||
st, refreshes = watched(monkeypatch, tmp_path, all_mode=False)
|
||||
touch(tmp_path / "b.py") # another file: the single-file view does not show it
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == []
|
||||
touch(tmp_path / "index")
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == [st]
|
||||
|
||||
|
||||
def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
|
||||
messages = []
|
||||
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
||||
|
||||
@@ -84,6 +84,29 @@ def test_stage_unstage_discard(repo):
|
||||
assert git.changed_files(repo) == []
|
||||
|
||||
|
||||
def test_stage_all_unstage_all(repo):
|
||||
write(repo, "a.py", "changed\n")
|
||||
os.remove(os.path.join(repo, "gone.py"))
|
||||
write(repo, "sub/n.py", "new\n")
|
||||
git.stage_all(repo)
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and all(c.staged for c in changes.values())
|
||||
git.unstage_all(repo)
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and not any(c.staged for c in changes.values())
|
||||
assert changes["sub/n.py"].status == "?"
|
||||
|
||||
|
||||
def test_index_path_finds_a_worktrees_index(repo, tmp_path_factory):
|
||||
assert git.index_path(repo) == os.path.join(repo, ".git", "index")
|
||||
# a worktree's .git is a file pointing into the main repository
|
||||
tree = str(tmp_path_factory.mktemp("tree") / "wt")
|
||||
sh(repo, "worktree", "add", "-q", tree)
|
||||
index = git.index_path(tree)
|
||||
assert os.path.isfile(os.path.join(tree, ".git")) and os.path.isfile(index)
|
||||
assert os.path.realpath(index).startswith(os.path.realpath(os.path.join(repo, ".git", "worktrees")))
|
||||
|
||||
|
||||
def test_commits(repo):
|
||||
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
|
||||
write(repo, "c.py", "c\n")
|
||||
@@ -154,6 +177,10 @@ def test_no_head_repo(tmp_path):
|
||||
assert [l.kind for l in diff.lines] == ["+"]
|
||||
git.unstage(repo, changes[0])
|
||||
assert git.changed_files(repo)[0].status == "?"
|
||||
git.stage_all(repo)
|
||||
assert git.changed_files(repo)[0].staged
|
||||
git.unstage_all(repo)
|
||||
assert git.changed_files(repo)[0].status == "?"
|
||||
|
||||
|
||||
def test_rename_in_commit(repo):
|
||||
|
||||
@@ -84,3 +84,66 @@ def test_a_hand_edited_override_is_never_clobbered(tmp_path):
|
||||
assert "// mine" in path.read_text()
|
||||
|
||||
|
||||
|
||||
|
||||
def colour_option(monkeypatch, name, value):
|
||||
"""Set one of the colour settings for this test"""
|
||||
from vdiff import utils
|
||||
monkeypatch.setitem(sublime.load_settings(utils.SETTINGS_FILE), name, value)
|
||||
|
||||
|
||||
def test_user_line_colours_get_rules_and_tint_the_token_layer(tmp_path, monkeypatch):
|
||||
colour_option(monkeypatch, "deleted_line_background", "#ff000040")
|
||||
colour_option(monkeypatch, "line_numbers_foreground", "#888888")
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
|
||||
rules = by_scope(tmp_path)
|
||||
assert rules[palette.SCOPE_LINE_DEL] == {"scope": palette.SCOPE_LINE_DEL, "background": "#ff000040"}
|
||||
assert palette.SCOPE_LINE_INS not in rules
|
||||
# the token layer repaints deleted text, so it carries the same background
|
||||
colour = view.styles["comment"]["foreground"]
|
||||
assert rules[palette.scope_for("del", colour)]["background"] == "#ff000040"
|
||||
# a foreground alone still gets a background, or sublime would fill the region with the foreground
|
||||
numbers = rules[palette.SCOPE_LINE_NUMBERS]
|
||||
assert numbers["foreground"] == "#888888" and numbers["background"]
|
||||
|
||||
|
||||
def test_changing_a_colour_setting_rewrites_the_override(tmp_path, monkeypatch):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
assert palette.SCOPE_LINE_INS not in by_scope(tmp_path)
|
||||
colour_option(monkeypatch, "inserted_line_background", "#00ff0040")
|
||||
palette.ensure(view)
|
||||
assert by_scope(tmp_path)[palette.SCOPE_LINE_INS]["background"] == "#00ff0040"
|
||||
|
||||
|
||||
def test_render_uses_the_user_scopes_only_once_set_and_live(monkeypatch):
|
||||
view = sublime.View()
|
||||
# unset: the scheme's diff tint and the plain-text line numbers, as before
|
||||
assert palette.line_scope(view, "del") == palette.SCOPE_DEL
|
||||
assert palette.line_numbers_scope(view) == "text.plain"
|
||||
colour_option(monkeypatch, "deleted_line_background", "#ff000040")
|
||||
colour_option(monkeypatch, "line_numbers_background", "#222222")
|
||||
# set, but the override is not loaded yet: an unknown scope would paint nothing
|
||||
assert palette.line_scope(view, "del") == palette.SCOPE_DEL
|
||||
view.override_loaded = True
|
||||
assert palette.line_scope(view, "del") == palette.SCOPE_LINE_DEL
|
||||
assert palette.line_scope(view, "ins") == palette.SCOPE_INS
|
||||
assert palette.line_numbers_scope(view) == palette.SCOPE_LINE_NUMBERS
|
||||
|
||||
|
||||
def test_file_header_colours_come_from_settings(tmp_path, monkeypatch):
|
||||
palette.ensure(sublime.View())
|
||||
rules = by_scope(tmp_path)
|
||||
# unset: the built-in defaults
|
||||
assert rules[palette.SCOPE_FILE_HEADER]["background"] == palette.FILE_HEADER_BACKGROUND
|
||||
colour_option(monkeypatch, "file_header_background", "#202040")
|
||||
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
|
||||
palette.ensure(sublime.View())
|
||||
rules = by_scope(tmp_path)
|
||||
assert rules[palette.SCOPE_FILE_HEADER]["background"] == "#202040"
|
||||
# the counts sit on the heading, so they share its background
|
||||
assert rules[palette.SCOPE_FILE_INS_COUNT]["background"] == "#202040"
|
||||
assert rules[palette.SCOPE_FILE_INS_COUNT]["foreground"] == "#00ff00"
|
||||
assert rules[palette.SCOPE_FILE_DEL_COUNT]["foreground"] == palette.FILE_DEL_FOREGROUND
|
||||
|
||||
@@ -154,14 +154,14 @@ def test_help_popup_lists_every_key():
|
||||
assert view.is_popup_visible()
|
||||
for group in ("Navigate", "Actions", "View"):
|
||||
assert f">{group}<" in html
|
||||
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "z", "esc"):
|
||||
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s / S", "u / U", "d", "C", "m", "r", "z", "esc"):
|
||||
assert f">{keys}<" in html
|
||||
assert "next / previous change" in html and "open the real file at this line" in html
|
||||
# working-tree mode: nothing dimmed, every single-action row is a link
|
||||
assert 'class="dim"' not in html and "uncommitted only" not in html
|
||||
assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html
|
||||
assert 'href="discard"' in html and 'href="files"' in html and 'href="close"' in html
|
||||
# rows covering two actions stay plain text
|
||||
assert 'href="next_change"' not in html
|
||||
assert 'href="next_change"' not in html and 'href="stage"' not in html
|
||||
# the popup does not list the key that opens it
|
||||
assert ">?<" not in html
|
||||
|
||||
@@ -173,6 +173,8 @@ def test_help_popup_dims_file_actions_in_commit_mode():
|
||||
|
||||
assert "uncommitted only" in html
|
||||
assert html.count('class="dim"') == 5
|
||||
# dimmed rows keep their keys orange
|
||||
assert 'class="kdim"' not in html and '<span class="k">C</span>' in html
|
||||
assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html
|
||||
assert 'href="commit"' not in html and 'href="amend"' not in html
|
||||
# everything that still applies stays clickable
|
||||
@@ -215,7 +217,7 @@ def test_keymap_matches_help_table():
|
||||
|
||||
# rebinding a key without updating the table would leave the popup and status bar lying
|
||||
bound_keys = {DISPLAY_KEYS.get(key, key) for b in bindings for key in b["keys"]}
|
||||
assert bound_keys == {token for k in render.KEYS for token in k.keys.split()} | {"?"}
|
||||
assert bound_keys == {token for k in render.KEYS for token in k.keys.split() if token != "/"} | {"?"}
|
||||
|
||||
|
||||
def test_no_binding_yields_to_vintage_command_mode():
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
// how a review view first shows its files: "file_by_file" or "all_in_one"; v switches between them
|
||||
"default_view_mode": "file_by_file",
|
||||
|
||||
// number of commits listed by "vDiff: Recent Commits"
|
||||
"commits_count": 30,
|
||||
|
||||
@@ -14,6 +17,19 @@
|
||||
// number of unchanged lines shown around each change in the all-files view
|
||||
"all_files_context": 5,
|
||||
|
||||
// background of deleted and inserted lines, e.g. "#d6464633"; empty uses the colour scheme's diff colours
|
||||
"deleted_line_background": "",
|
||||
"inserted_line_background": "",
|
||||
|
||||
// line-number column of the all-files view, e.g. "#888888" on "#1e1e1e"; empty keeps the plain-text look
|
||||
"line_numbers_foreground": "",
|
||||
"line_numbers_background": "",
|
||||
|
||||
// file heading of the all-files view: its background and the colours of its -/+ counts
|
||||
"file_header_background": "#0000EE",
|
||||
"file_header_deleted_foreground": "#d64646",
|
||||
"file_header_inserted_foreground": "#46d646",
|
||||
|
||||
// git executable; sublime's PATH may not include a user-installed git
|
||||
"git_binary": "git",
|
||||
|
||||
|
||||
@@ -86,13 +86,17 @@ def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewStat
|
||||
st = state.get(view)
|
||||
if st is None or st.repo != repo:
|
||||
# a different repository: browsing state no longer applies
|
||||
st = state.put(view, state.ViewState(repo))
|
||||
all_mode = settings().get("default_view_mode") == "all_in_one"
|
||||
st = state.put(view, state.ViewState(repo, all_mode=all_mode))
|
||||
window.focus_view(view)
|
||||
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"""
|
||||
"""Load all file diffs off the UI thread and switch to the aggregate view, on `focus`'s heading
|
||||
|
||||
Without `focus` the caret goes to the current file's heading, as it is when the diffs land
|
||||
"""
|
||||
st.all_mode = True
|
||||
# bump the token so earlier loads drop their results
|
||||
st.token += 1
|
||||
@@ -110,7 +114,8 @@ def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) ->
|
||||
st.set_all_diffs(result)
|
||||
if st.change is None and files:
|
||||
st.change = files[0]
|
||||
render.render_all(view, st, focus=focus)
|
||||
render.render_all(view, st, focus=focus or (st.change.path if st.change else None))
|
||||
st.rendered_token = token
|
||||
run_async(work, done)
|
||||
|
||||
|
||||
@@ -183,12 +188,26 @@ def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
||||
def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
|
||||
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
|
||||
placeholder: str = "Changed files") -> None:
|
||||
"""Quick panel over `files`: highlight previews, Enter shows, Esc restores `kept` (then runs on_cancel)"""
|
||||
"""Quick panel over `files`: highlight previews, Enter shows, Esc restores `kept` (then runs on_cancel)
|
||||
|
||||
In the aggregate view every diff is loaded once as the panel opens, and a highlight only scrolls to its file
|
||||
"""
|
||||
# the panel opens on the shown file, the view focused behind it
|
||||
st.files = files
|
||||
selected = max(0, st.index())
|
||||
window.focus_view(view)
|
||||
|
||||
def show(change: FileChange) -> None:
|
||||
"""Show `change`: its own diff, or its heading in the aggregate view"""
|
||||
if not st.all_mode:
|
||||
show_file(view, st, change)
|
||||
return
|
||||
st.change = change
|
||||
st.collapsed.discard(change.path)
|
||||
# still loading: show_all lands on st.change when the diffs arrive
|
||||
if st.rendered_token == st.token:
|
||||
render.render_all(view, st, focus=change.path)
|
||||
|
||||
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
|
||||
@@ -197,15 +216,18 @@ def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState,
|
||||
st.restore(kept)
|
||||
on_cancel()
|
||||
elif kept.change is not None:
|
||||
# reshow the kept file
|
||||
# reshow the kept file in the kept mode
|
||||
st.restore(kept)
|
||||
show_file(view, st, kept.change)
|
||||
if st.all_mode:
|
||||
show_all(view, st)
|
||||
else:
|
||||
show_file(view, st, kept.change)
|
||||
# nothing was kept before: the last previewed file stays
|
||||
return
|
||||
change = files[index]
|
||||
if st.change is change and st.rendered_token == st.token:
|
||||
return # the preview already shows it
|
||||
show_file(view, st, change)
|
||||
show(change)
|
||||
|
||||
def on_highlight(index: int) -> None:
|
||||
"""Schedule a debounced preview of the highlighted file"""
|
||||
@@ -217,10 +239,13 @@ def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState,
|
||||
def preview() -> None:
|
||||
"""Show the file unless another highlight came since"""
|
||||
if ticket == st.highlight:
|
||||
show_file(view, st, files[index])
|
||||
show(files[index])
|
||||
|
||||
sublime.set_timeout(preview, PREVIEW_DELAY_MS)
|
||||
|
||||
if st.all_mode and files:
|
||||
st.change = files[selected]
|
||||
show_all(view, st)
|
||||
window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)
|
||||
|
||||
|
||||
@@ -402,6 +427,27 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange]
|
||||
return act
|
||||
|
||||
|
||||
def _bulk_action(verb: str, done_verb: str, operation: Callable[[str], None],
|
||||
wanted: Callable[[FileChange], bool]) -> Action:
|
||||
"""Action running `operation(repo)` ("<verb> all") when some listed file is `wanted`, then re-listing on the same file"""
|
||||
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Run the operation off the UI thread, then refresh the listing"""
|
||||
if st.in_commit:
|
||||
status(f"{verb} all works on uncommitted changes only")
|
||||
return
|
||||
count = sum(1 for c in st.files if wanted(c))
|
||||
if not count:
|
||||
status(f"nothing to {verb}")
|
||||
return
|
||||
run_async(
|
||||
lambda: operation(st.repo),
|
||||
lambda _: refresh_listing(view, st, f"{done_verb} {file_count(count)}"),
|
||||
)
|
||||
|
||||
return act
|
||||
|
||||
|
||||
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):
|
||||
@@ -678,6 +724,8 @@ ACTIONS: Dict[str, Action] = {
|
||||
"help": _act_help,
|
||||
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
|
||||
"unstage": _git_action("unstage", "unstaged", git.unstage),
|
||||
"stage_all": _bulk_action("stage", "staged", git.stage_all, lambda c: not c.staged),
|
||||
"unstage_all": _bulk_action("unstage", "unstaged", git.unstage_all, lambda c: c.staged),
|
||||
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
|
||||
"commit": _act_commit,
|
||||
"amend": _act_amend,
|
||||
|
||||
36
vdiff/git.py
36
vdiff/git.py
@@ -1,4 +1,5 @@
|
||||
"""Git access via subprocess; results are plain tuples, no sublime imports (testable)"""
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
@@ -115,6 +116,18 @@ def has_head(repo: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def index_path(repo: str) -> Optional[str]:
|
||||
"""Absolute path of the repository's index file, None outside a repository
|
||||
|
||||
A worktree or submodule keeps it outside <repo>/.git, which there is a file, not a directory
|
||||
"""
|
||||
try:
|
||||
return os.path.join(repo, run(repo, ["rev-parse", "--git-path", "index"]).strip())
|
||||
except (GitError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def base_ref(repo: str) -> str:
|
||||
"""HEAD, or the empty tree when there is no commit yet"""
|
||||
return "HEAD" if has_head(repo) else EMPTY_TREE
|
||||
@@ -295,6 +308,11 @@ def stage(repo: str, change: FileChange) -> None:
|
||||
run(repo, ["add", "-A", "--", change.path])
|
||||
|
||||
|
||||
def stage_all(repo: str) -> None:
|
||||
"""Stage every change in the working tree, including deletions and untracked files"""
|
||||
run(repo, ["add", "-A"])
|
||||
|
||||
|
||||
def commit(repo: str, message: str) -> None:
|
||||
"""Commit what is staged; `message` goes as one argv element, unquoted"""
|
||||
run(repo, ["commit", "-m", message])
|
||||
@@ -313,12 +331,22 @@ def amend(repo: str, message: str, allow_empty: bool = False) -> None:
|
||||
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
|
||||
|
||||
|
||||
def _unstage_path(repo: str, path: str) -> None:
|
||||
"""Move the staged changes under `path` back to the working tree; before the first commit, drop them from the index"""
|
||||
if has_head(repo):
|
||||
run(repo, ["reset", "-q", "--", path])
|
||||
else:
|
||||
run(repo, ["rm", "-r", "-q", "--cached", "--", path])
|
||||
|
||||
|
||||
def unstage(repo: str, change: FileChange) -> None:
|
||||
"""Move the file's staged changes back to the working tree"""
|
||||
if has_head(repo):
|
||||
run(repo, ["reset", "-q", "--", change.path])
|
||||
else:
|
||||
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
|
||||
_unstage_path(repo, change.path)
|
||||
|
||||
|
||||
def unstage_all(repo: str) -> None:
|
||||
"""Move every staged change back to the working tree"""
|
||||
_unstage_path(repo, ".")
|
||||
|
||||
|
||||
def discard(repo: str, change: FileChange) -> None:
|
||||
|
||||
123
vdiff/palette.py
123
vdiff/palette.py
@@ -14,6 +14,8 @@ from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import sublime
|
||||
|
||||
from .utils import settings
|
||||
|
||||
LOG = logging.getLogger("vdiff")
|
||||
|
||||
# tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text
|
||||
@@ -30,7 +32,7 @@ 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
|
||||
# all-files file heading and its -/+ counts; their colours come from settings, these when one is empty
|
||||
SCOPE_FILE_HEADER = "vdiff.file.header"
|
||||
SCOPE_FILE_DEL_COUNT = "vdiff.file.del.count"
|
||||
SCOPE_FILE_INS_COUNT = "vdiff.file.ins.count"
|
||||
@@ -39,6 +41,16 @@ FILE_HEADER_BACKGROUND = "#0000FF"
|
||||
FILE_DEL_FOREGROUND = "#d64646"
|
||||
FILE_INS_FOREGROUND = "#46d646"
|
||||
|
||||
# colours the user may set; an empty line colour keeps the colour scheme's tint or the plain-text look
|
||||
COLOUR_SETTINGS: Tuple[str, ...] = ("deleted_line_background", "inserted_line_background",
|
||||
"line_numbers_foreground", "line_numbers_background",
|
||||
"file_header_background", "file_header_deleted_foreground",
|
||||
"file_header_inserted_foreground")
|
||||
# the scopes those colours go into: the changed-line tints and the all-files line-number column
|
||||
SCOPE_LINE_DEL = "vdiff.line.del"
|
||||
SCOPE_LINE_INS = "vdiff.line.ins"
|
||||
SCOPE_LINE_NUMBERS = "vdiff.line.numbers"
|
||||
|
||||
# tints to provision when the active scheme defines none of its own (some third-party schemes)
|
||||
FALLBACK_TINTS: Dict[str, str] = {
|
||||
SCOPE_DEL: "hsla(357, 45%, 60%, 0.15)",
|
||||
@@ -84,6 +96,8 @@ _palettes: Dict[str, Dict[str, int]] = {}
|
||||
_active: Optional[str] = None
|
||||
# schemes the user has already been told need a restart
|
||||
_notified: Set[Optional[str]] = set()
|
||||
# {override file name: the user colours it was provisioned with}, so changing one re-provisions
|
||||
_provisioned_with: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
@@ -91,6 +105,7 @@ def reset() -> None:
|
||||
global _active
|
||||
_palettes.clear()
|
||||
_notified.clear()
|
||||
_provisioned_with.clear()
|
||||
_active = None
|
||||
|
||||
|
||||
@@ -161,6 +176,26 @@ def token_keys() -> List[str]:
|
||||
return [token_key(_scope_name(kind, i)) for kind in KINDS for i in sorted(colours.values())]
|
||||
|
||||
|
||||
def user_colours() -> Dict[str, str]:
|
||||
"""{setting: colour} for COLOUR_SETTINGS, "" where unset"""
|
||||
return {name: settings().get(name) or "" for name in COLOUR_SETTINGS}
|
||||
|
||||
|
||||
def line_scope(view: sublime.View, kind: str) -> str:
|
||||
"""Tint scope for a changed line, "del" or "ins": the user's colour once its rule is live, else the scheme's"""
|
||||
own, setting, scheme = ((SCOPE_LINE_DEL, "deleted_line_background", SCOPE_DEL) if kind == "del" else
|
||||
(SCOPE_LINE_INS, "inserted_line_background", SCOPE_INS))
|
||||
return own if settings().get(setting) and scope_background(view, own) else scheme
|
||||
|
||||
|
||||
def line_numbers_scope(view: sublime.View) -> str:
|
||||
"""Scope for the all-files line-number column: the user's colours once their rule is live, else plain text"""
|
||||
if (settings().get("line_numbers_foreground") or settings().get("line_numbers_background")) \
|
||||
and scope_background(view, SCOPE_LINE_NUMBERS):
|
||||
return SCOPE_LINE_NUMBERS
|
||||
return "text.plain"
|
||||
|
||||
|
||||
def _scope_name(kind: str, index: int) -> str:
|
||||
"""Generated scope name for a background kind and palette index"""
|
||||
return f"vdiff.{kind}.c{index}"
|
||||
@@ -174,8 +209,10 @@ def ensure(view: sublime.View) -> None:
|
||||
global _active
|
||||
name = override_name(view)
|
||||
_active = name
|
||||
if not name or name in _palettes:
|
||||
user = user_colours()
|
||||
if not name or (name in _palettes and _provisioned_with.get(name) == user):
|
||||
return
|
||||
_provisioned_with[name] = user
|
||||
# merge into the existing override, if any
|
||||
path = os.path.join(sublime.packages_path(), "User", name)
|
||||
try:
|
||||
@@ -186,10 +223,10 @@ def ensure(view: sublime.View) -> None:
|
||||
scheme = sublime.decode_value(f.read()) or {}
|
||||
rules = scheme.setdefault("rules", [])
|
||||
# probe the scheme, record the palette, and write only when a rule changed
|
||||
tints = _tints(view)
|
||||
tints = _tints(view, user)
|
||||
colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
|
||||
_palettes[name] = colours
|
||||
if not _merge_rules(rules, tints, colours, view):
|
||||
if not _merge_rules(rules, tints, colours, view, user):
|
||||
return
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
@@ -211,10 +248,11 @@ def _probe_palette(view: sublime.View) -> List[str]:
|
||||
return seen
|
||||
|
||||
|
||||
def _tints(view: sublime.View) -> Dict[str, str]:
|
||||
def _tints(view: sublime.View, user: Dict[str, str]) -> Dict[str, str]:
|
||||
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
|
||||
|
||||
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own
|
||||
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own.
|
||||
A line colour the user set wins over both
|
||||
"""
|
||||
tints: Dict[str, str] = {}
|
||||
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)):
|
||||
@@ -222,6 +260,9 @@ def _tints(view: sublime.View) -> Dict[str, str]:
|
||||
char = scope_background(view, char_scope)
|
||||
tints[line_scope] = line if line and char else FALLBACK_TINTS[line_scope]
|
||||
tints[char_scope] = char if line and char else FALLBACK_TINTS[char_scope]
|
||||
for scope, setting in ((SCOPE_DEL, "deleted_line_background"), (SCOPE_INS, "inserted_line_background")):
|
||||
if user[setting]:
|
||||
tints[scope] = user[setting]
|
||||
# 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))
|
||||
@@ -246,57 +287,55 @@ def _nudge(colour: str) -> str:
|
||||
return f"{colour[:5]}{blue + 1 if blue < 128 else blue - 1:02x}{colour[7:]}"
|
||||
|
||||
|
||||
def _put_rule(rules: List[Dict[str, str]], present: Dict[Optional[str], Dict[str, str]], wanted: Dict[str, str]) -> bool:
|
||||
"""Add `wanted`, or make the rule with its scope equal to it; True when anything changed"""
|
||||
rule = present.get(wanted["scope"])
|
||||
if rule is None:
|
||||
rules.append(wanted)
|
||||
elif rule != wanted:
|
||||
rule.clear()
|
||||
rule.update(wanted)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
|
||||
view: sublime.View) -> bool:
|
||||
view: sublime.View, user: Dict[str, str]) -> bool:
|
||||
"""Bring the scheme's rules up to date, in place; True when anything changed
|
||||
|
||||
Each (kind, colour) rule carries a background and a foreground, as a background alone would flatten
|
||||
the line's colours; plain tints are added only when missing and never overwritten, so a hand-tuned one survives
|
||||
"""
|
||||
present = {rule.get("scope"): rule for rule in rules}
|
||||
# the all-files file heading and its deletion and insertion counts
|
||||
header = user["file_header_background"] or FILE_HEADER_BACKGROUND
|
||||
wanted = [{"scope": SCOPE_FILE_HEADER, "background": header}]
|
||||
for scope, colour in ((SCOPE_FILE_DEL_COUNT, user["file_header_deleted_foreground"] or FILE_DEL_FOREGROUND),
|
||||
(SCOPE_FILE_INS_COUNT, user["file_header_inserted_foreground"] or FILE_INS_FOREGROUND)):
|
||||
wanted.append({"scope": scope, "background": header, "foreground": colour, "font_style": "bold"})
|
||||
# the user's line colours; an unset one keeps any old rule, which render then ignores
|
||||
for scope, setting in ((SCOPE_LINE_DEL, "deleted_line_background"), (SCOPE_LINE_INS, "inserted_line_background")):
|
||||
if user[setting]:
|
||||
wanted.append({"scope": scope, "background": user[setting]})
|
||||
if user["line_numbers_foreground"] or user["line_numbers_background"]:
|
||||
# both halves, as a region without a background is filled with its foreground
|
||||
wanted.append({"scope": SCOPE_LINE_NUMBERS,
|
||||
"foreground": user["line_numbers_foreground"] or foreground(view, "text.plain") or "#ffffff",
|
||||
"background": user["line_numbers_background"] or _nudge(_global_background(view))})
|
||||
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
|
||||
for rule in wanted:
|
||||
changed = _put_rule(rules, present, rule) or changed
|
||||
# plain tints the scheme lacks; the context tint gets no rule of its own
|
||||
for scope, background in tints.items():
|
||||
if scope != SCOPE_CTX and scope not in present and not scope_background(view, scope):
|
||||
rules.append({"scope": scope, "background": background})
|
||||
changed = True
|
||||
# one rule per (kind, colour) pair
|
||||
# one rule per (kind, colour) pair; a palette shifted under an earlier session's rule is ours to correct
|
||||
for colour, index in colours.items():
|
||||
for kind in KINDS:
|
||||
wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]],
|
||||
"foreground": colour}
|
||||
rule = present.get(wanted["scope"])
|
||||
if rule is None:
|
||||
rules.append(wanted)
|
||||
changed = True
|
||||
elif rule != wanted:
|
||||
# the palette shifted under a rule an earlier session wrote; ours to keep correct
|
||||
rule.clear()
|
||||
rule.update(wanted)
|
||||
changed = True
|
||||
pair = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], "foreground": colour}
|
||||
changed = _put_rule(rules, present, pair) or changed
|
||||
return changed
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
|
||||
from . import palette, parse
|
||||
from . import git, palette, parse
|
||||
from .git import FileChange, STATUS_NAMES
|
||||
from .parse import Block, Line
|
||||
from .state import VIEW_FLAG, ViewState
|
||||
@@ -91,8 +91,8 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
||||
"Actions",
|
||||
False,
|
||||
[
|
||||
Key("s", "stage", "stage", ("stage",)),
|
||||
Key("u", "unstage", "unstage", ("unstage",)),
|
||||
Key("s / S", "stage / stage all", "stage", ("stage", "stage_all")),
|
||||
Key("u / U", "unstage / unstage all", "unstage", ("unstage", "unstage_all")),
|
||||
Key("d", "discard", "discard", ("discard",)),
|
||||
Key("C", "commit the staged files", "commit", ("commit",)),
|
||||
Key("m", "amend the last commit", "amend", ("amend",)),
|
||||
@@ -138,7 +138,7 @@ a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-
|
||||
"""
|
||||
|
||||
# minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class
|
||||
# .group's top margin replaces body's top padding; .kdim inherits its colour from .dim
|
||||
# .group's top margin replaces body's top padding
|
||||
HELP_CSS = """
|
||||
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
||||
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
|
||||
@@ -146,7 +146,6 @@ body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
||||
.row { color: color(var(--foreground) alpha(0.85)); }
|
||||
.dim { color: color(var(--foreground) alpha(0.35)); }
|
||||
.k { color: var(--orangish); font-weight: bold; }
|
||||
.kdim { font-weight: bold; }
|
||||
a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
|
||||
"""
|
||||
|
||||
@@ -216,11 +215,13 @@ def caret_index(view: sublime.View) -> int:
|
||||
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
|
||||
|
||||
|
||||
def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float]]]:
|
||||
"""Cheap "did anything change" key for working-tree mode: mtimes of the file and of the git index"""
|
||||
def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], ...]]:
|
||||
"""Cheap "did anything change" key for working-tree mode: mtimes of the shown files and of the git index"""
|
||||
if st.in_commit or st.change is None:
|
||||
return None
|
||||
return (_mtime(os.path.join(st.repo, st.change.path)), _mtime(os.path.join(st.repo, ".git", "index")))
|
||||
files = st.files if st.all_mode else [st.change]
|
||||
index = git.index_path(st.repo)
|
||||
return tuple(_mtime(os.path.join(st.repo, c.path)) for c in files) + (_mtime(index) if index else None,)
|
||||
|
||||
|
||||
def _mtime(path: str) -> Optional[float]:
|
||||
@@ -387,12 +388,12 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
|
||||
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_DEL, del_regions, palette.line_scope(view, "del"), flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_INS, ins_regions, palette.line_scope(view, "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)
|
||||
view.add_regions(KEY_FILE_PREFIX, prefix_regions, palette.line_numbers_scope(view), 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)
|
||||
@@ -514,8 +515,8 @@ def _paint_regions(view: sublime.View, st: ViewState) -> None:
|
||||
# full-line tint
|
||||
starts = _row_starts(lines)
|
||||
deleted, inserted = _line_regions(lines, starts)
|
||||
view.add_regions(KEY_DEL, deleted, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_INS, inserted, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_DEL, deleted, palette.line_scope(view, "del"), flags=sublime.DRAW_NO_OUTLINE)
|
||||
view.add_regions(KEY_INS, inserted, palette.line_scope(view, "ins"), flags=sublime.DRAW_NO_OUTLINE)
|
||||
# gutter dots
|
||||
mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
|
||||
del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"]
|
||||
@@ -795,14 +796,12 @@ def help_html(st: ViewState) -> str:
|
||||
if dim:
|
||||
heading += ' <span class="note">uncommitted only</span>'
|
||||
parts.append(f'<div class="group">{heading}</div>')
|
||||
# one class per element: a compound selector would silently drop the rest of HELP_CSS
|
||||
row_class, key_class = ("dim", "kdim") if dim else ("row", "k")
|
||||
# keys stay orange in a dimmed group; only the text dims
|
||||
row_class = "dim" if dim else "row"
|
||||
# a row per key with a padded key column; a single-action row becomes a link
|
||||
for key in group:
|
||||
row = (
|
||||
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
|
||||
f'{" " * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
|
||||
)
|
||||
pad = " " * (_KEY_WIDTH - len(key.keys) + 2)
|
||||
row = f'<span class="k">{html.escape(key.keys)}</span>{pad}{html.escape(key.label)}'
|
||||
if len(key.actions) == 1 and not dim:
|
||||
row = f'<a href="{key.actions[0]}">{row}</a>'
|
||||
parts.append(f'<div class="{row_class}">{row}</div>')
|
||||
|
||||
@@ -22,6 +22,7 @@ class Kept(NamedTuple):
|
||||
commit: Optional["CommitInfo"]
|
||||
change: Optional["FileChange"]
|
||||
files: List["FileChange"]
|
||||
all_mode: bool
|
||||
|
||||
|
||||
def find_change(files: Sequence["FileChange"], path: Optional[str]) -> int:
|
||||
@@ -48,12 +49,9 @@ class ViewState:
|
||||
token: int = 0 # bumped per show request; a stale async result is dropped
|
||||
rendered_token: int = -1 # token of the request that produced the current render
|
||||
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
|
||||
# (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable
|
||||
fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None
|
||||
# all-files view: diffs cached so collapse/expand is instant
|
||||
fingerprint: Optional[Tuple[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)
|
||||
@@ -77,7 +75,7 @@ class ViewState:
|
||||
|
||||
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), self.all_mode)
|
||||
|
||||
def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
|
||||
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
|
||||
@@ -89,13 +87,16 @@ class ViewState:
|
||||
|
||||
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, self.all_mode = kept
|
||||
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_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:
|
||||
|
||||
Reference in New Issue
Block a user