Compare commits

2 Commits

Author SHA1 Message Date
Vaclav Tvrdik
f18bad79cd default file view mode and some colors now configurable in settings 2026-10-10 14:27:25 +02:00
Vaclav Tvrdik
2d9db745b0 stage/unstage all actions added 2026-10-10 14:27:25 +02:00
10 changed files with 336 additions and 68 deletions

View File

@@ -66,7 +66,7 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov
| `?` | 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. 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 ## The channel

View File

@@ -1,3 +1,5 @@
import os
import pytest import pytest
import sublime # the stub, via conftest 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"] 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): def test_on_cancel_goes_back_to_commit_list(monkeypatch):
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None) monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
st = state.ViewState("/repo") st = state.ViewState("/repo")
@@ -599,6 +659,45 @@ def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch):
assert messages[-1] == "stage all works on uncommitted changes only" 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): def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
messages = [] messages = []
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))

View File

@@ -97,6 +97,16 @@ def test_stage_all_unstage_all(repo):
assert changes["sub/n.py"].status == "?" 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): def test_commits(repo):
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n") write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
write(repo, "c.py", "c\n") write(repo, "c.py", "c\n")

View File

@@ -84,3 +84,66 @@ def test_a_hand_edited_override_is_never_clobbered(tmp_path):
assert "// mine" in path.read_text() 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

View File

@@ -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" // number of commits listed by "vDiff: Recent Commits"
"commits_count": 30, "commits_count": 30,
@@ -14,6 +17,19 @@
// number of unchanged lines shown around each change in the all-files view // number of unchanged lines shown around each change in the all-files view
"all_files_context": 5, "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 executable; sublime's PATH may not include a user-installed git
"git_binary": "git", "git_binary": "git",

View File

@@ -86,13 +86,17 @@ def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewStat
st = state.get(view) st = state.get(view)
if st is None or st.repo != repo: if st is None or st.repo != repo:
# a different repository: browsing state no longer applies # 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) window.focus_view(view)
return view, st return view, st
def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) -> None: 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 st.all_mode = True
# bump the token so earlier loads drop their results # bump the token so earlier loads drop their results
st.token += 1 st.token += 1
@@ -110,7 +114,8 @@ def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) ->
st.set_all_diffs(result) st.set_all_diffs(result)
if st.change is None and files: if st.change is None and files:
st.change = files[0] 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) 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], 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`: 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 # 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 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: def on_select(index: int) -> None:
"""Enter shows the chosen file; Esc restores `kept`, then runs on_cancel if given""" """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
@@ -197,15 +216,18 @@ def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState,
st.restore(kept) st.restore(kept)
on_cancel() on_cancel()
elif kept.change is not None: elif kept.change is not None:
# reshow the kept file # reshow the kept file in the kept mode
st.restore(kept) st.restore(kept)
if st.all_mode:
show_all(view, st)
else:
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
return return
change = files[index] change = files[index]
if st.change is change and st.rendered_token == st.token: if st.change is change and st.rendered_token == st.token:
return # the preview already shows it return # the preview already shows it
show_file(view, st, change) show(change)
def on_highlight(index: int) -> None: def on_highlight(index: int) -> None:
"""Schedule a debounced preview of the highlighted file""" """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: def preview() -> None:
"""Show the file unless another highlight came since""" """Show the file unless another highlight came since"""
if ticket == st.highlight: if ticket == st.highlight:
show_file(view, st, files[index]) show(files[index])
sublime.set_timeout(preview, PREVIEW_DELAY_MS) 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) window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)

View File

@@ -1,4 +1,5 @@
"""Git access via subprocess; results are plain tuples, no sublime imports (testable)""" """Git access via subprocess; results are plain tuples, no sublime imports (testable)"""
import functools
import os import os
import re import re
import subprocess import subprocess
@@ -115,6 +116,18 @@ def has_head(repo: str) -> bool:
return False 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: def base_ref(repo: str) -> str:
"""HEAD, or the empty tree when there is no commit yet""" """HEAD, or the empty tree when there is no commit yet"""
return "HEAD" if has_head(repo) else EMPTY_TREE return "HEAD" if has_head(repo) else EMPTY_TREE

View File

@@ -14,6 +14,8 @@ from typing import Any, Dict, List, Optional, Set, Tuple
import sublime import sublime
from .utils import settings
LOG = logging.getLogger("vdiff") 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 # 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, KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR,
"ctx": SCOPE_CTX} "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_HEADER = "vdiff.file.header"
SCOPE_FILE_DEL_COUNT = "vdiff.file.del.count" SCOPE_FILE_DEL_COUNT = "vdiff.file.del.count"
SCOPE_FILE_INS_COUNT = "vdiff.file.ins.count" SCOPE_FILE_INS_COUNT = "vdiff.file.ins.count"
@@ -39,6 +41,16 @@ FILE_HEADER_BACKGROUND = "#0000FF"
FILE_DEL_FOREGROUND = "#d64646" FILE_DEL_FOREGROUND = "#d64646"
FILE_INS_FOREGROUND = "#46d646" 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) # 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] = {
SCOPE_DEL: "hsla(357, 45%, 60%, 0.15)", SCOPE_DEL: "hsla(357, 45%, 60%, 0.15)",
@@ -84,6 +96,8 @@ _palettes: Dict[str, Dict[str, int]] = {}
_active: Optional[str] = None _active: Optional[str] = None
# schemes the user 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()
# {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: def reset() -> None:
@@ -91,6 +105,7 @@ def reset() -> None:
global _active global _active
_palettes.clear() _palettes.clear()
_notified.clear() _notified.clear()
_provisioned_with.clear()
_active = None _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())] 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: def _scope_name(kind: str, index: int) -> str:
"""Generated scope name for a background kind and palette index""" """Generated scope name for a background kind and palette index"""
return f"vdiff.{kind}.c{index}" return f"vdiff.{kind}.c{index}"
@@ -174,8 +209,10 @@ def ensure(view: sublime.View) -> None:
global _active global _active
name = override_name(view) name = override_name(view)
_active = name _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 return
_provisioned_with[name] = user
# merge into the existing override, if any # 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:
@@ -186,10 +223,10 @@ def ensure(view: sublime.View) -> None:
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 # 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))} colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
_palettes[name] = colours _palettes[name] = colours
if not _merge_rules(rules, tints, colours, view): if not _merge_rules(rules, tints, colours, view, user):
return return
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
@@ -211,10 +248,11 @@ def _probe_palette(view: sublime.View) -> List[str]:
return seen 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 """{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] = {} 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)):
@@ -222,6 +260,9 @@ 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]
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 # 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 # foreground, so context colours carry the editor background nudged by an invisible step
tints[SCOPE_CTX] = _nudge(_global_background(view)) 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:]}" 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], 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 """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 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 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} 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 changed = False
# the all-files file heading for rule in wanted:
header = present.get(SCOPE_FILE_HEADER) changed = _put_rule(rules, present, rule) or changed
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 # 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 != SCOPE_CTX and 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 # 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 colour, index in colours.items():
for kind in KINDS: for kind in KINDS:
wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], pair = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], "foreground": colour}
"foreground": colour} changed = _put_rule(rules, present, pair) or changed
rule = present.get(wanted["scope"])
if rule is None:
rules.append(wanted)
changed = True
elif rule != wanted:
# the palette shifted under a rule an earlier session wrote; ours to keep correct
rule.clear()
rule.update(wanted)
changed = True
return changed return changed

View File

@@ -8,7 +8,7 @@ from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
import sublime import sublime
import sublime_plugin import sublime_plugin
from . import palette, parse from . import git, palette, parse
from .git import FileChange, STATUS_NAMES from .git import FileChange, STATUS_NAMES
from .parse import Block, Line from .parse import Block, Line
from .state import VIEW_FLAG, ViewState from .state import VIEW_FLAG, ViewState
@@ -215,11 +215,13 @@ def caret_index(view: sublime.View) -> int:
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float]]]: def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], ...]]:
"""Cheap "did anything change" key for working-tree mode: mtimes of the file and of the git index""" """Cheap "did anything change" key for working-tree mode: mtimes of the shown files and of the git index"""
if st.in_commit or st.change is None: if st.in_commit or st.change is None:
return None return None
return (_mtime(os.path.join(st.repo, st.change.path)), _mtime(os.path.join(st.repo, ".git", "index"))) 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]: def _mtime(path: str) -> Optional[float]:
@@ -386,12 +388,12 @@ def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[
elif line.kind == "+": elif line.kind == "+":
ins_regions.append(region) ins_regions.append(region)
# paint everything # paint everything
view.add_regions(KEY_DEL, del_regions, palette.SCOPE_DEL, 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.SCOPE_INS, 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_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_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_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(): for scope, regions in token_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)
insertions, deletions = _totals(st.files) insertions, deletions = _totals(st.files)
@@ -513,8 +515,8 @@ def _paint_regions(view: sublime.View, st: ViewState) -> None:
# full-line tint # 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.line_scope(view, "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.line_scope(view, "ins"), flags=sublime.DRAW_NO_OUTLINE)
# gutter dots # 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 == "-"]

View File

@@ -22,6 +22,7 @@ class Kept(NamedTuple):
commit: Optional["CommitInfo"] commit: Optional["CommitInfo"]
change: Optional["FileChange"] change: Optional["FileChange"]
files: List["FileChange"] files: List["FileChange"]
all_mode: bool
def find_change(files: Sequence["FileChange"], path: Optional[str]) -> int: 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 token: int = 0 # bumped per show request; a stale async result is dropped
rendered_token: int = -1 # token of the request that produced the current render rendered_token: int = -1 # token of the request that produced the current render
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
# (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable fingerprint: Optional[Tuple[Optional[float], ...]] = None # all-files view: diffs cached so collapse/expand is instant
fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None
# all-files view: diffs cached so collapse/expand is instant
all_mode: bool = False all_mode: bool = False
all_diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]] = field(default_factory=dict) 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) 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) collapsed: set = field(default_factory=set)
all_rows: List[Tuple[Optional["FileChange"], Optional["Line"], bool]] = field(default_factory=list) all_rows: List[Tuple[Optional["FileChange"], Optional["Line"], bool]] = field(default_factory=list)
@@ -77,7 +75,7 @@ class ViewState:
def kept(self) -> Kept: def kept(self) -> Kept:
"""Snapshot of what to go back to on Esc""" """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: def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc""" """Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
@@ -89,13 +87,16 @@ class ViewState:
def restore(self, kept: Kept) -> None: def restore(self, kept: Kept) -> None:
"""Go back to a snapshot; its file becomes the requested one""" """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 self.requested = kept.change
def set_all_diffs(self, diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]]) -> None: 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""" """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() self.all_colours = {
if path in diffs and path in self.all_diffs and diffs[path][0] == self.all_diffs[path][0]} 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 self.all_diffs = diffs
def persist(self, view: "sublime.View") -> None: def persist(self, view: "sublime.View") -> None: