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):