diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py
index 0c3df1f..4e95118 100644
--- a/tests/stubs/sublime.py
+++ b/tests/stubs/sublime.py
@@ -32,7 +32,7 @@ class Region:
return (self.a, self.b) == (other.a, other.b)
def __repr__(self):
- return "Region({}, {})".format(self.a, self.b)
+ return f"Region({self.a}, {self.b})"
class Settings(dict):
@@ -240,7 +240,13 @@ def find_syntax_for_file(path, first_line=""):
def ui_info():
- return {"system": {"style": "dark"}, "color_scheme": {"value": "Mariana.sublime-color-scheme", "resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme"}}
+ return {
+ "system": {"style": "dark"},
+ "color_scheme": {
+ "value": "Mariana.sublime-color-scheme",
+ "resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme",
+ },
+ }
def decode_value(text):
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 5055ba8..39d1519 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -11,7 +11,7 @@ def change(path, status="M"):
def flagged(*flags):
"""f0.py, f1.py ... with the given staged flags"""
- return [git.FileChange("f{}.py".format(i), "M", flag, 1, 1, None) for i, flag in enumerate(flags)]
+ return [git.FileChange(f"f{i}.py", "M", flag, 1, 1, None) for i, flag in enumerate(flags)]
def option(monkeypatch, name, value):
@@ -28,7 +28,7 @@ def at_change(blocks, row):
view = sublime.View()
st = state.ViewState("/repo")
st.blocks = blocks
- view.text = "\n" + "\n".join("l{}".format(i) for i in range(20))
+ view.text = "\n" + "\n".join(f"l{i}" for i in range(20))
view.sel().add(sublime.Region(commands.render.index_point(view, row)))
return view, st
diff --git a/tests/test_render.py b/tests/test_render.py
index c14a511..8360b5e 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -133,9 +133,9 @@ def test_help_popup_lists_every_key():
assert view.is_popup_visible()
for group in ("Navigate", "Panels", "Actions", "View"):
- assert ">{}<".format(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"):
- assert ">{}<".format(keys) in html
+ assert f">{keys}<" in html
assert "next / previous change" in html and "open the real file at this line" in html
# working-tree mode: nothing dimmed, every single-action row is a link
assert 'class="dim"' not in html and "uncommitted only" not in html
@@ -222,7 +222,7 @@ def test_minihtml_stylesheets_reject_unsupported_selectors():
for rule in css.split("}"):
head = rule.split("{")[0].strip()
for selector in filter(None, (s.strip() for s in head.split(","))):
- assert not unsupported.search(selector), "{}: {}".format(name, selector)
+ assert not unsupported.search(selector), f"{name}: {selector}"
# --- header summary line ----------------------------------------------------
@@ -238,7 +238,26 @@ def test_summary_counts_staged_and_unstaged_files():
git.FileChange("c.py", "?", False, 1, 0, None),
]
render.render(view, st)
- assert '
1 staged, 2 unstaged
' in header(view)
+ summary = header(view).split('')[1].split("
")[0]
+
+ # the untracked file's whole content counts as insertions, the way git.changed_files records it
+ assert summary.startswith("1 staged, 2 unstaged · ")
+ assert '-2' in summary and '+3' in summary
+
+
+def test_summary_totals_skip_binary_files_but_still_count_them():
+ view = sublime.View()
+ render.init_view(view)
+ st = make_state()
+ st.files = [
+ git.FileChange("a.py", "M", False, 4, 1, None),
+ git.FileChange("logo.png", "M", False, None, None, None), # binary: no counts, still a file
+ ]
+ render.render(view, st)
+ summary = header(view).split('')[1].split("
")[0]
+
+ assert summary.startswith("0 staged, 2 unstaged · ")
+ assert '-1' in summary and '+4' in summary
def test_summary_totals_a_commit_and_owns_the_date():
diff --git a/vdiff/commands.py b/vdiff/commands.py
index d607201..92a454d 100644
--- a/vdiff/commands.py
+++ b/vdiff/commands.py
@@ -120,7 +120,7 @@ def file_items(files):
if None in (change.insertions, change.deletions):
annotation = "binary"
else:
- annotation = "-{} +{}".format(change.deletions, change.insertions)
+ annotation = f"-{change.deletions} +{change.insertions}"
items.append(sublime.QuickPanelItem(change.path, details, annotation, kind))
return items
@@ -132,14 +132,12 @@ def commit_items(commits):
annotation = c.short
if c.files is not None:
# merges and empty commits have no diffstat, so they show the hash alone
- annotation = "{} changed -{} +{} {}".format(
- file_count(c.files), c.deletions, c.insertions, c.short
- )
+ annotation = f"{file_count(c.files)} changed -{c.deletions} +{c.insertions} {c.short}"
subject = c.subject or "(no subject)"
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
if len(subject) > budget:
subject = subject[: budget - 2] + ".."
- items.append(sublime.QuickPanelItem(subject, "{} · {}".format(c.author, c.date), annotation, COMMIT_KIND))
+ items.append(sublime.QuickPanelItem(subject, f"{c.author} · {c.date}", annotation, COMMIT_KIND))
return items
@@ -192,7 +190,7 @@ def refresh_listing(view, st, message=None, pick=None):
return
st.files = files
if not files:
- render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
+ render.render_message(view, st, f"No uncommitted changes in {st.repo}")
return
index = state.find_change(files, path)
if index < 0:
@@ -227,10 +225,10 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
return
kept = st.enter()
if not files:
- render.render_message(view, st, "No uncommitted changes in {}".format(st.repo))
+ render.render_message(view, st, f"No uncommitted changes in {st.repo}")
status("no uncommitted changes")
return
- placeholder = "Changed files in {}".format(os.path.basename(st.repo))
+ placeholder = f"Changed files in {os.path.basename(st.repo)}"
show_files_panel(window, view, st, files, kept, placeholder=placeholder)
run_async(lambda: git.changed_files(st.repo), done)
@@ -281,13 +279,13 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
info, files = result
kept = st.enter(info.sha, info)
if not files:
- render.render_message(view, st, "Commit {} touches no files".format(info.short))
+ render.render_message(view, st, f"Commit {info.short} touches no files")
return
def back():
window.run_command("vdiff_commits", {"selected": index})
- placeholder = "{} · {}".format(info.short, info.subject)
+ placeholder = f"{info.short} · {info.subject}"
on_cancel = back if index is not None else None
show_files_panel(window, view, st, files, kept, on_cancel=on_cancel, placeholder=placeholder)
@@ -321,14 +319,14 @@ def _git_action(verb, done_verb, operation, confirm=None, pick=None):
def act(view, window, st):
if st.in_commit or st.change is None:
- status("{} works on uncommitted changes only".format(verb))
+ status(f"{verb} works on uncommitted changes only")
return
change = st.change
if confirm is not None and not confirm(change):
return
run_async(
lambda: operation(st.repo, change),
- lambda _: refresh_listing(view, st, "{} {}".format(done_verb, change.path), pick=pick),
+ lambda _: refresh_listing(view, st, f"{done_verb} {change.path}", pick=pick),
)
return act
@@ -338,10 +336,11 @@ def _confirm_discard(change):
if not settings().get("confirm_discard", True):
return True
if change.status == "?":
- message = "Delete the untracked file {}?\n\nThis cannot be undone.".format(change.path)
+ message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
else:
- message = "Discard all changes to {}?\n\nThe file goes back to its last committed version. This cannot be undone.".format(
- change.path
+ message = (
+ f"Discard all changes to {change.path}?\n\n"
+ "The file goes back to its last committed version. This cannot be undone."
)
return sublime.ok_cancel_dialog(message, "Discard")
@@ -378,10 +377,10 @@ def _act_open(view, window, st):
return
path = os.path.join(st.repo, st.change.path)
if not os.path.exists(path):
- status("{} does not exist in the working tree".format(st.change.path))
+ status(f"{st.change.path} does not exist in the working tree")
return
line = parse.line_for_open(st.lines, render.caret_index(view))
- window.open_file("{}:{}".format(path, line), sublime.ENCODED_POSITION)
+ window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
def _step_file(delta):
@@ -442,7 +441,7 @@ def _act_commit(view, window, st):
return
run_async(
lambda: git.commit(st.repo, message),
- lambda _: refresh_listing(view, st, "committed {}".format(file_count(len(staged)))),
+ lambda _: refresh_listing(view, st, f"committed {file_count(len(staged))}"),
)
window.show_input_panel("Commit message", "", on_done, None, None)
@@ -468,7 +467,7 @@ def _act_amend(view, window, st):
return
done = "amended the last commit"
if staged:
- done += " with {}".format(file_count(len(staged)))
+ done += f" with {file_count(len(staged))}"
run_async(
# nothing staged makes this a pure reword, which git refuses on an empty commit
lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged),
@@ -520,7 +519,7 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
def run(self, edit, action):
act = ACTIONS.get(action)
if act is None:
- status("unknown action {}".format(action))
+ status(f"unknown action {action}")
return
st = state.get(self.view) or restore_view(self.view)
act(self.view, self.view.window(), st)
@@ -558,7 +557,7 @@ def restore_view(view):
if index >= 0:
show_file(view, st, files[index])
else:
- render.render_message(view, st, "{} has no uncommitted changes any more".format(path))
+ render.render_message(view, st, f"{path} has no uncommitted changes any more")
run_async(lambda: git.changed_files(repo), done)
return st
diff --git a/vdiff/git.py b/vdiff/git.py
index 14182f7..f3b6a12 100644
--- a/vdiff/git.py
+++ b/vdiff/git.py
@@ -44,15 +44,13 @@ class GitError(Exception):
self.stderr = stderr
self.code = code
first = stderr.strip().splitlines()
- super().__init__(first[0] if first else "git exited with {}".format(code))
+ super().__init__(first[0] if first else f"git exited with {code}")
def run(repo, args, ok_codes=(0,)):
"""Run git in `repo`, return stdout as text"""
cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args)
- proc = subprocess.run(
- cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace"
- )
+ proc = subprocess.run(cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", errors="replace")
if proc.returncode not in ok_codes:
raise GitError(proc.stderr, proc.returncode)
return proc.stdout
@@ -201,7 +199,7 @@ def recent_commits(repo, count):
def commit_with_files(repo, sha):
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
show = ["show", "--first-parent", "-m", "-z"]
- text = run(repo, show + ["--format={}%x00".format(_INFO_FORMAT), _DATE_FORMAT, "--name-status", sha])
+ text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
# git terminates the header with its own NUL and a newline before the file list
info_text, _, files_text = text.partition("\0")
files_text = files_text.lstrip("\0\n")
@@ -222,7 +220,7 @@ def commit_with_files(repo, sha):
def _in_tree(repo, ref, path):
"""Whether `path` exists in the tree of `ref`"""
try:
- run(repo, ["cat-file", "-e", "{}:{}".format(ref, path)])
+ run(repo, ["cat-file", "-e", f"{ref}:{path}"])
return True
except GitError:
return False
diff --git a/vdiff/render.py b/vdiff/render.py
index 70c11d3..08aefc2 100644
--- a/vdiff/render.py
+++ b/vdiff/render.py
@@ -60,33 +60,49 @@ FALLBACK_RULES = [
Key = namedtuple("Key", "keys label short actions")
KEY_GROUPS = [
- ("Navigate", True, [
- Key("] [", "next / previous change (wraps)", "change", ("next_change", "prev_change")),
- Key("n p", "next / previous file (wraps)", "file", ("next_file", "prev_file")),
- Key("o \u23ce", "open the real file at this line", "open", ("open",)),
- Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
- ]),
- ("Panels", True, [
- Key("f", "changed files", "files", ("files",)),
- Key("c", "recent commits", "commits", ("commits",)),
- ]),
- ("Actions", False, [
- Key("s", "stage", "stage", ("stage",)),
- Key("u", "unstage", "unstage", ("unstage",)),
- Key("d", "discard", "discard", ("discard",)),
- Key("C", "commit the staged files", "commit", ("commit",)),
- Key("m", "amend the last commit", "amend", ("amend",)),
- ]),
- ("View", True, [
- Key("r", "refresh", "refresh", ("refresh",)),
- Key("?", "this list", "keys", ("help",)),
- Key("esc", "close the view", "", ("close",)),
- ]),
+ (
+ "Navigate",
+ True,
+ [
+ Key("] [", "next / previous change (wraps)", "change", ("next_change", "prev_change")),
+ Key("n p", "next / previous file (wraps)", "file", ("next_file", "prev_file")),
+ Key("o \u23ce", "open the real file at this line", "open", ("open",)),
+ Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
+ ],
+ ),
+ (
+ "Panels",
+ True,
+ [
+ Key("f", "changed files", "files", ("files",)),
+ Key("c", "recent commits", "commits", ("commits",)),
+ ],
+ ),
+ (
+ "Actions",
+ False,
+ [
+ Key("s", "stage", "stage", ("stage",)),
+ Key("u", "unstage", "unstage", ("unstage",)),
+ Key("d", "discard", "discard", ("discard",)),
+ Key("C", "commit the staged files", "commit", ("commit",)),
+ Key("m", "amend the last commit", "amend", ("amend",)),
+ ],
+ ),
+ (
+ "View",
+ True,
+ [
+ Key("r", "refresh", "refresh", ("refresh",)),
+ Key("?", "this list", "keys", ("help",)),
+ Key("esc", "close the view", "", ("close",)),
+ ],
+ ),
]
KEYS = [key for _, _, group in KEY_GROUPS for key in group]
-KEY_HINTS = " ".join("{} {}".format(k.keys, k.short) for k in KEYS if k.short)
+KEY_HINTS = " ".join(f"{k.keys} {k.short}" for k in KEYS if k.short)
# the popup font is the editor's, so the key column is padded to a fixed width in characters
_KEY_WIDTH = max(len(k.keys) for k in KEYS)
@@ -242,7 +258,7 @@ def render(view, st):
view.add_regions(KEY_INS_CHAR, ins_chars, SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE)
ins, dele = parse.counts(lines)
- _finish(view, st, ins, dele, "⟐ {}".format(os.path.basename(st.change.path)))
+ _finish(view, st, ins, dele, f"⟐ {os.path.basename(st.change.path)}")
view.sel().clear()
view.sel().add(sublime.Region(index_point(view, 0)))
view.set_viewport_position((0, 0), False)
@@ -311,6 +327,11 @@ def _char_regions(lines, blocks, starts):
return deleted, inserted
+def _totals(files):
+ """Summed numstat counts; binary files carry None counts and add nothing"""
+ return sum(c.insertions or 0 for c in files), sum(c.deletions or 0 for c in files)
+
+
def _summary_html(st):
"""Line above the header: the whole commit on one line, or how much of the tree is staged"""
if not st.files and not st.commit:
@@ -320,8 +341,12 @@ def _summary_html(st):
else:
# staged means "has something staged": a partially staged file counts as staged only
staged = sum(1 for c in st.files if c.staged)
- text = "{} staged, {} unstaged".format(staged, len(st.files) - staged)
- return '{}
'.format(text)
+ insertions, deletions = _totals(st.files)
+ text = (
+ f"{staged} staged, {len(st.files) - staged} unstaged"
+ f' · -{deletions} +{insertions}'
+ )
+ return f'{text}
'
def _commit_parts(st):
@@ -329,22 +354,17 @@ def _commit_parts(st):
info = st.commit
parts = []
if info:
- parts.append('{}'.format(html.escape(info.short)))
+ parts.append(f'{html.escape(info.short)}')
parts.append(html.escape(info.author))
- parts.append('{}'.format(html.escape(info.date)))
- # binary files carry None counts but still changed, so they count toward the file total
- insertions = sum(c.insertions or 0 for c in st.files)
- deletions = sum(c.deletions or 0 for c in st.files)
- parts.append(
- '{} changed -{} +{}'.format(
- file_count(len(st.files)), deletions, insertions
- )
- )
+ parts.append(f'{html.escape(info.date)}')
+ # binary files add nothing to the line totals but still count toward the file total
+ insertions, deletions = _totals(st.files)
+ parts.append(f"{file_count(len(st.files))} changed" f' -{deletions} +{insertions}')
if info:
message = html.escape(info.subject)
body = info.body.strip().splitlines()
if body:
- message += ' — {}'.format(html.escape(body[0]))
+ message += f' — {html.escape(body[0])}'
parts.append(message)
return parts
@@ -355,14 +375,14 @@ def header_html(st, ins, dele):
if change is None:
title = 'vDiff'
else:
- title = '{}'.format(html.escape(change.path))
+ title = f'{html.escape(change.path)}'
if change.old_path:
- title += ' ← {}'.format(html.escape(change.old_path))
- title += ' -{} +{}'.format(dele, ins)
+ title += f' ← {html.escape(change.old_path)}'
+ title += f' -{dele} +{ins}'
if not st.in_commit:
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
if state:
- title += ' {}'.format(state)
+ title += f' {state}'
actions = []
if change is not None and not st.in_commit:
@@ -375,8 +395,9 @@ def header_html(st, ins, dele):
actions.append('Commits')
actions.append('?')
- return '{}{}{}
'.format(
- HEADER_CSS, _summary_html(st), title, "".join(actions)
+ return (
+ f'{_summary_html(st)}'
+ f'{title}{"".join(actions)}
'
)
@@ -406,17 +427,18 @@ def help_html(st):
heading = html.escape(title)
if dim:
heading += ' uncommitted only'
- parts.append('{}
'.format(heading))
+ parts.append(f'{heading}
')
# 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")
for key in group:
- row = '{}{}{}'.format(
- key_class, html.escape(key.keys), " " * (_KEY_WIDTH - len(key.keys) + 2), html.escape(key.label)
+ row = (
+ f'{html.escape(key.keys)}'
+ f'{" " * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
)
if len(key.actions) == 1 and not dim:
- row = '{}'.format(key.actions[0], row)
- parts.append('{}
'.format(row_class, row))
- return '{}'.format(HELP_CSS, "".join(parts))
+ row = f'{row}'
+ parts.append(f'{row}
')
+ return f'{"".join(parts)}'
def show_help(view, st):
diff --git a/vdiff/utils.py b/vdiff/utils.py
index 94da653..d434df9 100644
--- a/vdiff/utils.py
+++ b/vdiff/utils.py
@@ -29,12 +29,12 @@ def apply_git_binary():
def file_count(count):
"""'1 file' / '3 files' for a message"""
- return "{} {}".format(count, "file" if count == 1 else "files")
+ return f'{count} {"file" if count == 1 else "files"}'
def status(message):
"""Status-bar message with the plugin prefix"""
- sublime.status_message("vDiff: {}".format(message))
+ sublime.status_message(f"vDiff: {message}")
def run_async(work, done):