type hints across vdiff
This commit is contained in:
@@ -36,6 +36,10 @@ class Region:
|
|||||||
return f"Region({self.a}, {self.b})"
|
return f"Region({self.a}, {self.b})"
|
||||||
|
|
||||||
|
|
||||||
|
class Edit:
|
||||||
|
"""Opaque edit token; only ever passed through, so the annotations have something to name"""
|
||||||
|
|
||||||
|
|
||||||
class Settings(dict):
|
class Settings(dict):
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return dict.get(self, key, default)
|
return dict.get(self, key, default)
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
""" Commands: the two quick panels (changed files, recent commits) and the actions of a review view """
|
""" Commands: the two quick panels (changed files, recent commits) and the actions of a review view """
|
||||||
import bisect
|
import bisect
|
||||||
import os
|
import os
|
||||||
|
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from . import git, parse, render, state
|
from . import git, parse, render, state
|
||||||
|
from .git import Commit, FileChange
|
||||||
|
from .state import Kept, ViewState
|
||||||
from .utils import file_count, run_async, settings, status
|
from .utils import file_count, run_async, settings, status
|
||||||
|
|
||||||
|
# what an ACTIONS entry does: act(view, window, state) for the review view it was fired in
|
||||||
|
Action = Callable[[sublime.View, sublime.Window, ViewState], None]
|
||||||
|
|
||||||
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES
|
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES
|
||||||
STATUS_COLOURS = {
|
STATUS_COLOURS: Dict[str, int] = {
|
||||||
"M": sublime.KIND_ID_COLOR_ORANGISH,
|
"M": sublime.KIND_ID_COLOR_ORANGISH,
|
||||||
"A": sublime.KIND_ID_COLOR_GREENISH,
|
"A": sublime.KIND_ID_COLOR_GREENISH,
|
||||||
"?": sublime.KIND_ID_COLOR_GREENISH,
|
"?": sublime.KIND_ID_COLOR_GREENISH,
|
||||||
@@ -30,20 +36,22 @@ MIN_SUBJECT_WIDTH = 24
|
|||||||
PREVIEW_DELAY_MS = 60
|
PREVIEW_DELAY_MS = 60
|
||||||
|
|
||||||
# directory -> repository root; roots do not move, and resolving runs on the UI thread
|
# directory -> repository root; roots do not move, and resolving runs on the UI thread
|
||||||
_roots = {}
|
_roots: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def resolve_repo(window):
|
def resolve_repo(window: sublime.Window) -> Optional[str]:
|
||||||
"""Repository of the review view or active file, else of the first project folder that is one"""
|
"""Repository of the review view or active file, else of the first project folder that is one"""
|
||||||
view = window.active_view()
|
view = window.active_view()
|
||||||
candidates = []
|
candidates: List[str] = []
|
||||||
if view is not None:
|
if view is not None:
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
st = state.get(view) or restore_view(view)
|
st = state.get(view) or restore_view(view)
|
||||||
if st.repo:
|
if st.repo:
|
||||||
return st.repo
|
return st.repo
|
||||||
elif view.file_name():
|
else:
|
||||||
candidates.append(os.path.dirname(view.file_name()))
|
name = view.file_name()
|
||||||
|
if name:
|
||||||
|
candidates.append(os.path.dirname(name))
|
||||||
candidates.extend(window.folders())
|
candidates.extend(window.folders())
|
||||||
for directory in candidates:
|
for directory in candidates:
|
||||||
root = _roots.get(directory)
|
root = _roots.get(directory)
|
||||||
@@ -56,14 +64,14 @@ def resolve_repo(window):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_review_view(window):
|
def find_review_view(window: sublime.Window) -> Optional[sublime.View]:
|
||||||
for view in window.views():
|
for view in window.views():
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
return view
|
return view
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def open_review(window):
|
def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]:
|
||||||
"""(view, state) of the window's single review view for the resolved repo, focused; None without a repo"""
|
"""(view, state) of the window's single review view for the resolved repo, focused; None without a repo"""
|
||||||
repo = resolve_repo(window)
|
repo = resolve_repo(window)
|
||||||
if not repo:
|
if not repo:
|
||||||
@@ -81,7 +89,7 @@ def open_review(window):
|
|||||||
return view, st
|
return view, st
|
||||||
|
|
||||||
|
|
||||||
def show_file(view, st, change):
|
def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
||||||
"""Fetch and parse the file's diff on the worker thread, then render it"""
|
"""Fetch and parse the file's diff on the worker thread, then render it"""
|
||||||
st.token += 1
|
st.token += 1
|
||||||
# set before the worker starts: n / p count from here while the diff is still loading
|
# set before the worker starts: n / p count from here while the diff is still loading
|
||||||
@@ -89,14 +97,14 @@ def show_file(view, st, change):
|
|||||||
token = st.token
|
token = st.token
|
||||||
repo, sha = st.repo, st.sha
|
repo, sha = st.repo, st.sha
|
||||||
|
|
||||||
def work():
|
def work() -> Optional[Tuple[parse.Diff, List[parse.Block]]]:
|
||||||
if token != st.token:
|
if token != st.token:
|
||||||
return None # superseded before it started: skip the subprocess
|
return None # superseded before it started: skip the subprocess
|
||||||
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
|
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
|
||||||
diff = parse.parse_unified(text)
|
diff = parse.parse_unified(text)
|
||||||
return diff, parse.change_blocks(diff.lines)
|
return diff, parse.change_blocks(diff.lines)
|
||||||
|
|
||||||
def done(result):
|
def done(result: Optional[Tuple[parse.Diff, List[parse.Block]]]) -> None:
|
||||||
if result is None or token != st.token or not view.is_valid():
|
if result is None or token != st.token or not view.is_valid():
|
||||||
return
|
return
|
||||||
diff, blocks = result
|
diff, blocks = result
|
||||||
@@ -108,8 +116,8 @@ def show_file(view, st, change):
|
|||||||
run_async(work, done)
|
run_async(work, done)
|
||||||
|
|
||||||
|
|
||||||
def file_items(files):
|
def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
||||||
items = []
|
items: List[sublime.QuickPanelItem] = []
|
||||||
for change in files:
|
for change in files:
|
||||||
description = git.STATUS_NAMES.get(change.status, "")
|
description = git.STATUS_NAMES.get(change.status, "")
|
||||||
colour = STATUS_COLOURS.get(change.status)
|
colour = STATUS_COLOURS.get(change.status)
|
||||||
@@ -125,9 +133,9 @@ def file_items(files):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def commit_items(commits):
|
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
||||||
"""Quick-panel rows: subject on the left, its totals and the hash right-aligned"""
|
"""Quick-panel rows: subject on the left, its totals and the hash right-aligned"""
|
||||||
items = []
|
items: List[sublime.QuickPanelItem] = []
|
||||||
for c in commits:
|
for c in commits:
|
||||||
annotation = c.short
|
annotation = c.short
|
||||||
if c.files is not None:
|
if c.files is not None:
|
||||||
@@ -141,13 +149,15 @@ def commit_items(commits):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder="Changed files"):
|
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`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
|
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
|
||||||
st.files = files
|
st.files = files
|
||||||
selected = max(0, st.index())
|
selected = max(0, st.index())
|
||||||
window.focus_view(view)
|
window.focus_view(view)
|
||||||
|
|
||||||
def on_select(index):
|
def on_select(index: int) -> None:
|
||||||
st.highlight += 1 # cancel a pending preview
|
st.highlight += 1 # cancel a pending preview
|
||||||
if index == -1:
|
if index == -1:
|
||||||
if on_cancel is not None:
|
if on_cancel is not None:
|
||||||
@@ -164,17 +174,23 @@ def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder=
|
|||||||
return # the preview already shows it
|
return # the preview already shows it
|
||||||
show_file(view, st, change)
|
show_file(view, st, change)
|
||||||
|
|
||||||
def on_highlight(index):
|
def on_highlight(index: int) -> None:
|
||||||
if not 0 <= index < len(files):
|
if not 0 <= index < len(files):
|
||||||
return
|
return
|
||||||
st.highlight += 1
|
st.highlight += 1
|
||||||
ticket = st.highlight
|
ticket = st.highlight
|
||||||
sublime.set_timeout(lambda: ticket == st.highlight and show_file(view, st, files[index]), PREVIEW_DELAY_MS)
|
|
||||||
|
def preview() -> None:
|
||||||
|
if ticket == st.highlight:
|
||||||
|
show_file(view, st, files[index])
|
||||||
|
|
||||||
|
sublime.set_timeout(preview, PREVIEW_DELAY_MS)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
def refresh_listing(view, st, message=None, pick=None):
|
def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] = None,
|
||||||
|
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> None:
|
||||||
"""Re-list the working tree changes and show the same file, or the next one when it is gone
|
"""Re-list the working tree changes and show the same file, or the next one when it is gone
|
||||||
|
|
||||||
`pick(files, index)` may name another file to show instead; `index` is where the shown file
|
`pick(files, index)` may name another file to show instead; `index` is where the shown file
|
||||||
@@ -185,7 +201,7 @@ def refresh_listing(view, st, message=None, pick=None):
|
|||||||
previous_index = max(0, st.index())
|
previous_index = max(0, st.index())
|
||||||
path = st.change.path if st.change else None
|
path = st.change.path if st.change else None
|
||||||
|
|
||||||
def done(files):
|
def done(files: List[FileChange]) -> None:
|
||||||
if not view.is_valid():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
st.files = files
|
st.files = files
|
||||||
@@ -202,7 +218,7 @@ def refresh_listing(view, st, message=None, pick=None):
|
|||||||
run_async(lambda: git.changed_files(st.repo), done)
|
run_async(lambda: git.changed_files(st.repo), done)
|
||||||
|
|
||||||
|
|
||||||
def refresh_if_changed(view, st):
|
def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
|
||||||
"""Re-render a working-tree view when the file or the index changed since it was drawn"""
|
"""Re-render a working-tree view when the file or the index changed since it was drawn"""
|
||||||
if st.in_commit or st.change is None:
|
if st.in_commit or st.change is None:
|
||||||
return
|
return
|
||||||
@@ -213,14 +229,14 @@ def refresh_if_changed(view, st):
|
|||||||
class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
||||||
"""List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
|
"""List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
window = self.window
|
window = self.window
|
||||||
opened = open_review(window)
|
opened = open_review(window)
|
||||||
if not opened:
|
if not opened:
|
||||||
return
|
return
|
||||||
view, st = opened
|
view, st = opened
|
||||||
|
|
||||||
def done(files):
|
def done(files: List[FileChange]) -> None:
|
||||||
if not view.is_valid():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
kept = st.enter()
|
kept = st.enter()
|
||||||
@@ -237,7 +253,7 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
|||||||
class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||||
"""List recent commits, then the files of the chosen commit; `sha` skips straight to one commit"""
|
"""List recent commits, then the files of the chosen commit; `sha` skips straight to one commit"""
|
||||||
|
|
||||||
def run(self, selected=0, sha=None):
|
def run(self, selected: int = 0, sha: Optional[str] = None) -> None:
|
||||||
opened = open_review(self.window)
|
opened = open_review(self.window)
|
||||||
if not opened:
|
if not opened:
|
||||||
return
|
return
|
||||||
@@ -247,7 +263,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
return
|
return
|
||||||
count = int(settings().get("commits_count", 30) or 30)
|
count = int(settings().get("commits_count", 30) or 30)
|
||||||
|
|
||||||
def done(commits):
|
def done(commits: List[Commit]) -> None:
|
||||||
if not view.is_valid():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
if not commits:
|
if not commits:
|
||||||
@@ -257,23 +273,23 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
|
|
||||||
run_async(lambda: git.recent_commits(st.repo, count), done)
|
run_async(lambda: git.recent_commits(st.repo, count), done)
|
||||||
|
|
||||||
def show_commits(self, view, st, commits, selected):
|
def show_commits(self, view: sublime.View, st: ViewState, commits: Sequence[Commit], selected: int) -> None:
|
||||||
items = commit_items(commits)
|
items = commit_items(commits)
|
||||||
for i, c in enumerate(commits):
|
for i, c in enumerate(commits):
|
||||||
if c.sha == st.sha:
|
if c.sha == st.sha:
|
||||||
selected = i
|
selected = i
|
||||||
|
|
||||||
def on_select(index):
|
def on_select(index: int) -> None:
|
||||||
if index != -1:
|
if index != -1:
|
||||||
self.open_commit(view, st, commits[index].sha, index)
|
self.open_commit(view, st, commits[index].sha, index)
|
||||||
|
|
||||||
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
|
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
|
||||||
|
|
||||||
def open_commit(self, view, st, sha, index):
|
def open_commit(self, view: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None:
|
||||||
"""Files panel of one commit; Esc goes back to the commit list when it came from there (index given)"""
|
"""Files panel of one commit; Esc goes back to the commit list when it came from there (index given)"""
|
||||||
window = self.window
|
window = self.window
|
||||||
|
|
||||||
def done(result):
|
def done(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
||||||
if not view.is_valid():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
info, files = result
|
info, files = result
|
||||||
@@ -282,7 +298,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
render.render_message(view, st, f"Commit {info.short} touches no files")
|
render.render_message(view, st, f"Commit {info.short} touches no files")
|
||||||
return
|
return
|
||||||
|
|
||||||
def back():
|
def back() -> None:
|
||||||
window.run_command("vdiff_commits", {"selected": index})
|
window.run_command("vdiff_commits", {"selected": index})
|
||||||
|
|
||||||
placeholder = f"{info.short} · {info.subject}"
|
placeholder = f"{info.short} · {info.subject}"
|
||||||
@@ -295,29 +311,31 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
# --- review view actions ----------------------------------------------------
|
# --- review view actions ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _act_files(view, window, st):
|
def _act_files(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
window.run_command("vdiff_files")
|
window.run_command("vdiff_files")
|
||||||
|
|
||||||
|
|
||||||
def _act_commits(view, window, st):
|
def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
window.run_command("vdiff_commits")
|
window.run_command("vdiff_commits")
|
||||||
|
|
||||||
|
|
||||||
def _act_close(view, window, st):
|
def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
view.close()
|
view.close()
|
||||||
|
|
||||||
|
|
||||||
def _act_help(view, window, st):
|
def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
render.show_help(view, st)
|
render.show_help(view, st)
|
||||||
|
|
||||||
|
|
||||||
def _git_action(verb, done_verb, operation, confirm=None, pick=None):
|
def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None],
|
||||||
|
confirm: Optional[Callable[[FileChange], bool]] = None,
|
||||||
|
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> Action:
|
||||||
"""Action running `operation(repo, change)` on the shown working-tree file, then re-listing
|
"""Action running `operation(repo, change)` on the shown working-tree file, then re-listing
|
||||||
|
|
||||||
`pick` is handed to refresh_listing to choose which file to show afterwards.
|
`pick` is handed to refresh_listing to choose which file to show afterwards.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def act(view, window, st):
|
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if st.in_commit or st.change is None:
|
if st.in_commit or st.change is None:
|
||||||
status(f"{verb} works on uncommitted changes only")
|
status(f"{verb} works on uncommitted changes only")
|
||||||
return
|
return
|
||||||
@@ -332,7 +350,7 @@ def _git_action(verb, done_verb, operation, confirm=None, pick=None):
|
|||||||
return act
|
return act
|
||||||
|
|
||||||
|
|
||||||
def _confirm_discard(change):
|
def _confirm_discard(change: FileChange) -> bool:
|
||||||
if not settings().get("confirm_discard", True):
|
if not settings().get("confirm_discard", True):
|
||||||
return True
|
return True
|
||||||
if change.status == "?":
|
if change.status == "?":
|
||||||
@@ -345,7 +363,7 @@ def _confirm_discard(change):
|
|||||||
return sublime.ok_cancel_dialog(message, "Discard")
|
return sublime.ok_cancel_dialog(message, "Discard")
|
||||||
|
|
||||||
|
|
||||||
def _next_unstaged(files, start):
|
def _next_unstaged(files: Sequence[FileChange], start: int) -> int:
|
||||||
"""Index of the first unstaged file from `start` on, wrapping round; -1 when every file is staged"""
|
"""Index of the first unstaged file from `start` on, wrapping round; -1 when every file is staged"""
|
||||||
for offset in range(len(files)):
|
for offset in range(len(files)):
|
||||||
index = (start + offset) % len(files)
|
index = (start + offset) % len(files)
|
||||||
@@ -354,7 +372,7 @@ def _next_unstaged(files, start):
|
|||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
|
||||||
def _stage_next(files, index):
|
def _stage_next(files: Sequence[FileChange], index: int) -> Optional[FileChange]:
|
||||||
"""After staging, the next unstaged file to show; None keeps the file the refresh would show"""
|
"""After staging, the next unstaged file to show; None keeps the file the refresh would show"""
|
||||||
if not settings().get("stage_and_move_next_file", True):
|
if not settings().get("stage_and_move_next_file", True):
|
||||||
return None
|
return None
|
||||||
@@ -363,7 +381,7 @@ def _stage_next(files, index):
|
|||||||
return files[following] if following >= 0 else None
|
return files[following] if following >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
def _act_refresh(view, window, st):
|
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if st.change is None:
|
if st.change is None:
|
||||||
return
|
return
|
||||||
if st.in_commit:
|
if st.in_commit:
|
||||||
@@ -372,7 +390,7 @@ def _act_refresh(view, window, st):
|
|||||||
refresh_listing(view, st)
|
refresh_listing(view, st)
|
||||||
|
|
||||||
|
|
||||||
def _act_open(view, window, st):
|
def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if st.change is None:
|
if st.change is None:
|
||||||
return
|
return
|
||||||
path = os.path.join(st.repo, st.change.path)
|
path = os.path.join(st.repo, st.change.path)
|
||||||
@@ -383,8 +401,8 @@ def _act_open(view, window, st):
|
|||||||
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
|
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
|
||||||
|
|
||||||
|
|
||||||
def _step_file(delta):
|
def _step_file(delta: int) -> Action:
|
||||||
def act(view, window, st):
|
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if not st.files:
|
if not st.files:
|
||||||
status("no file list, press f or c")
|
status("no file list, press f or c")
|
||||||
return
|
return
|
||||||
@@ -406,8 +424,8 @@ def _step_file(delta):
|
|||||||
return act
|
return act
|
||||||
|
|
||||||
|
|
||||||
def _step_change(delta):
|
def _step_change(delta: int) -> Action:
|
||||||
def act(view, window, st):
|
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
starts = [start for start, _ in st.blocks]
|
starts = [start for start, _ in st.blocks]
|
||||||
if not starts:
|
if not starts:
|
||||||
status("no changes to step through")
|
status("no changes to step through")
|
||||||
@@ -425,7 +443,7 @@ def _step_change(delta):
|
|||||||
return act
|
return act
|
||||||
|
|
||||||
|
|
||||||
def _act_commit(view, window, st):
|
def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
"""Commit the staged files with a message typed into an input panel; Esc there cancels"""
|
"""Commit the staged files with a message typed into an input panel; Esc there cancels"""
|
||||||
if st.in_commit:
|
if st.in_commit:
|
||||||
status("commit works on uncommitted changes only")
|
status("commit works on uncommitted changes only")
|
||||||
@@ -435,7 +453,7 @@ def _act_commit(view, window, st):
|
|||||||
status("no staged files")
|
status("no staged files")
|
||||||
return
|
return
|
||||||
|
|
||||||
def on_done(message):
|
def on_done(message: str) -> None:
|
||||||
if not message.strip():
|
if not message.strip():
|
||||||
status("commit cancelled: empty message")
|
status("commit cancelled: empty message")
|
||||||
return
|
return
|
||||||
@@ -447,7 +465,7 @@ def _act_commit(view, window, st):
|
|||||||
window.show_input_panel("Commit message", "", on_done, None, None)
|
window.show_input_panel("Commit message", "", on_done, None, None)
|
||||||
|
|
||||||
|
|
||||||
def _act_amend(view, window, st):
|
def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
"""Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels
|
"""Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels
|
||||||
|
|
||||||
The panel is one line, so only the subject is offered; the rest of the message rides along
|
The panel is one line, so only the subject is offered; the rest of the message rides along
|
||||||
@@ -458,10 +476,10 @@ def _act_amend(view, window, st):
|
|||||||
return
|
return
|
||||||
staged = [c for c in st.files if c.staged]
|
staged = [c for c in st.files if c.staged]
|
||||||
|
|
||||||
def prompt(message):
|
def prompt(message: str) -> None:
|
||||||
subject, newline, body = message.partition("\n")
|
subject, newline, body = message.partition("\n")
|
||||||
|
|
||||||
def on_done(text):
|
def on_done(text: str) -> None:
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
status("amend cancelled: empty message")
|
status("amend cancelled: empty message")
|
||||||
return
|
return
|
||||||
@@ -482,14 +500,14 @@ def _act_amend(view, window, st):
|
|||||||
run_async(lambda: git.head_message(st.repo), prompt)
|
run_async(lambda: git.head_message(st.repo), prompt)
|
||||||
|
|
||||||
|
|
||||||
def _move_line(forward):
|
def _move_line(forward: bool) -> Action:
|
||||||
def act(view, window, st):
|
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
view.run_command("move", {"by": "lines", "forward": forward})
|
view.run_command("move", {"by": "lines", "forward": forward})
|
||||||
|
|
||||||
return act
|
return act
|
||||||
|
|
||||||
|
|
||||||
ACTIONS = {
|
ACTIONS: Dict[str, Action] = {
|
||||||
"files": _act_files,
|
"files": _act_files,
|
||||||
"commits": _act_commits,
|
"commits": _act_commits,
|
||||||
"close": _act_close,
|
"close": _act_close,
|
||||||
@@ -513,10 +531,10 @@ ACTIONS = {
|
|||||||
class VdiffActionCommand(sublime_plugin.TextCommand):
|
class VdiffActionCommand(sublime_plugin.TextCommand):
|
||||||
"""Every key/link action of a review view, dispatched by name through ACTIONS"""
|
"""Every key/link action of a review view, dispatched by name through ACTIONS"""
|
||||||
|
|
||||||
def is_enabled(self, action=None):
|
def is_enabled(self, action: Optional[str] = None) -> bool:
|
||||||
return state.is_review_view(self.view)
|
return state.is_review_view(self.view)
|
||||||
|
|
||||||
def run(self, edit, action):
|
def run(self, edit: sublime.Edit, action: str) -> None:
|
||||||
act = ACTIONS.get(action)
|
act = ACTIONS.get(action)
|
||||||
if act is None:
|
if act is None:
|
||||||
status(f"unknown action {action}")
|
status(f"unknown action {action}")
|
||||||
@@ -525,7 +543,7 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
|
|||||||
act(self.view, self.view.window(), st)
|
act(self.view, self.view.window(), st)
|
||||||
|
|
||||||
|
|
||||||
def restore_view(view):
|
def restore_view(view: sublime.View) -> ViewState:
|
||||||
"""Rebuild the state of a review view restored from a previous session; re-renders asynchronously"""
|
"""Rebuild the state of a review view restored from a previous session; re-renders asynchronously"""
|
||||||
data = view.settings().get(state.VIEW_DATA) or {}
|
data = view.settings().get(state.VIEW_DATA) or {}
|
||||||
repo = data.get("repo")
|
repo = data.get("repo")
|
||||||
@@ -542,16 +560,17 @@ def restore_view(view):
|
|||||||
return st
|
return st
|
||||||
change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None)
|
change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None)
|
||||||
|
|
||||||
if st.in_commit:
|
sha = st.sha
|
||||||
|
if sha is not None:
|
||||||
|
|
||||||
def done(result):
|
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
||||||
st.commit, st.files = result
|
st.commit, st.files = result
|
||||||
show_file(view, st, change)
|
show_file(view, st, change)
|
||||||
|
|
||||||
run_async(lambda: git.commit_with_files(repo, st.sha), done)
|
run_async(lambda: git.commit_with_files(repo, sha), show_commit)
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def done(files):
|
def show_working_tree(files: List[FileChange]) -> None:
|
||||||
st.files = files
|
st.files = files
|
||||||
index = state.find_change(files, path)
|
index = state.find_change(files, path)
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
@@ -559,5 +578,5 @@ def restore_view(view):
|
|||||||
else:
|
else:
|
||||||
render.render_message(view, st, f"{path} has no uncommitted changes any more")
|
render.render_message(view, st, f"{path} has no uncommitted changes any more")
|
||||||
|
|
||||||
run_async(lambda: git.changed_files(repo), done)
|
run_async(lambda: git.changed_files(repo), show_working_tree)
|
||||||
return st
|
return st
|
||||||
|
|||||||
109
vdiff/git.py
109
vdiff/git.py
@@ -2,13 +2,44 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from collections import namedtuple
|
from typing import Dict, List, Optional, NamedTuple, Sequence, Tuple
|
||||||
|
|
||||||
# path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source
|
|
||||||
FileChange = namedtuple("FileChange", "path status staged insertions deletions old_path")
|
class FileChange(NamedTuple):
|
||||||
# shortstat totals; all three are None when git prints no diffstat (merges, empty commits)
|
"""path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source"""
|
||||||
Commit = namedtuple("Commit", "sha short author date subject files insertions deletions")
|
|
||||||
CommitInfo = namedtuple("CommitInfo", "sha short author date subject body")
|
path: str
|
||||||
|
status: str
|
||||||
|
staged: bool
|
||||||
|
insertions: Optional[int]
|
||||||
|
deletions: Optional[int]
|
||||||
|
old_path: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class Commit(NamedTuple):
|
||||||
|
"""shortstat totals; all three are None when git prints no diffstat (merges, empty commits)"""
|
||||||
|
|
||||||
|
sha: str
|
||||||
|
short: str
|
||||||
|
author: str
|
||||||
|
date: str
|
||||||
|
subject: str
|
||||||
|
files: Optional[int]
|
||||||
|
insertions: Optional[int]
|
||||||
|
deletions: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class CommitInfo(NamedTuple):
|
||||||
|
sha: str
|
||||||
|
short: str
|
||||||
|
author: str
|
||||||
|
date: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
|
||||||
|
|
||||||
|
# a numstat entry: insertions, deletions (both None for a binary file) and the rename source
|
||||||
|
Numstat = Tuple[Optional[int], Optional[int], Optional[str]]
|
||||||
|
|
||||||
# hash of the empty tree: the diff base for a repository without commits
|
# hash of the empty tree: the diff base for a repository without commits
|
||||||
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
||||||
@@ -17,9 +48,9 @@ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|||||||
FULL_CONTEXT = "-U999999"
|
FULL_CONTEXT = "-U999999"
|
||||||
|
|
||||||
# overridable from settings (sublime's PATH may not have a user-installed git)
|
# overridable from settings (sublime's PATH may not have a user-installed git)
|
||||||
GIT_BINARY = "git"
|
GIT_BINARY: str = "git"
|
||||||
|
|
||||||
STATUS_NAMES = {
|
STATUS_NAMES: Dict[str, str] = {
|
||||||
"M": "modified",
|
"M": "modified",
|
||||||
"A": "added",
|
"A": "added",
|
||||||
"D": "deleted",
|
"D": "deleted",
|
||||||
@@ -40,14 +71,14 @@ _SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(
|
|||||||
class GitError(Exception):
|
class GitError(Exception):
|
||||||
"""git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it"""
|
"""git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it"""
|
||||||
|
|
||||||
def __init__(self, stderr, code):
|
def __init__(self, stderr: str, code: int) -> None:
|
||||||
self.stderr = stderr
|
self.stderr = stderr
|
||||||
self.code = code
|
self.code = code
|
||||||
first = stderr.strip().splitlines()
|
first = stderr.strip().splitlines()
|
||||||
super().__init__(first[0] if first else f"git exited with {code}")
|
super().__init__(first[0] if first else f"git exited with {code}")
|
||||||
|
|
||||||
|
|
||||||
def run(repo, args, ok_codes=(0,)):
|
def run(repo: str, args: Sequence[str], ok_codes: Tuple[int, ...] = (0,)) -> str:
|
||||||
"""Run git in `repo`, return stdout as text"""
|
"""Run git in `repo`, return stdout as text"""
|
||||||
cmd = [GIT_BINARY, "-c", "core.quotepath=off", "--no-pager"] + list(args)
|
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")
|
||||||
@@ -56,7 +87,7 @@ def run(repo, args, ok_codes=(0,)):
|
|||||||
return proc.stdout
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
def repo_root(path):
|
def repo_root(path: Optional[str]) -> Optional[str]:
|
||||||
"""Top-level directory of the repository containing `path`, or None"""
|
"""Top-level directory of the repository containing `path`, or None"""
|
||||||
if not path:
|
if not path:
|
||||||
return None
|
return None
|
||||||
@@ -70,7 +101,7 @@ def repo_root(path):
|
|||||||
return root or None
|
return root or None
|
||||||
|
|
||||||
|
|
||||||
def has_head(repo):
|
def has_head(repo: str) -> bool:
|
||||||
"""False in a repository without commits"""
|
"""False in a repository without commits"""
|
||||||
try:
|
try:
|
||||||
run(repo, ["rev-parse", "--verify", "-q", "HEAD"])
|
run(repo, ["rev-parse", "--verify", "-q", "HEAD"])
|
||||||
@@ -79,19 +110,19 @@ def has_head(repo):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def base_ref(repo):
|
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
|
||||||
|
|
||||||
|
|
||||||
def _int_or_none(value):
|
def _int_or_none(value: str) -> Optional[int]:
|
||||||
"""A numstat count: "-" marks a binary file"""
|
"""A numstat count: "-" marks a binary file"""
|
||||||
return None if value == "-" else int(value)
|
return None if value == "-" else int(value)
|
||||||
|
|
||||||
|
|
||||||
def _parse_numstat(text):
|
def _parse_numstat(text: str) -> Dict[str, Numstat]:
|
||||||
"""`--numstat -z` output -> {path: (insertions, deletions, old_path)}"""
|
"""`--numstat -z` output -> {path: (insertions, deletions, old_path)}"""
|
||||||
result = {}
|
result: Dict[str, Numstat] = {}
|
||||||
fields = iter(text.split("\0"))
|
fields = iter(text.split("\0"))
|
||||||
for entry in fields:
|
for entry in fields:
|
||||||
parts = entry.split("\t")
|
parts = entry.split("\t")
|
||||||
@@ -107,9 +138,9 @@ def _parse_numstat(text):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _parse_name_status(text):
|
def _parse_name_status(text: str) -> List[Tuple[str, str, Optional[str]]]:
|
||||||
"""`--name-status -z` output -> [(status letter, path, old_path)]"""
|
"""`--name-status -z` output -> [(status letter, path, old_path)]"""
|
||||||
result = []
|
result: List[Tuple[str, str, Optional[str]]] = []
|
||||||
fields = iter(text.split("\0"))
|
fields = iter(text.split("\0"))
|
||||||
for status in fields:
|
for status in fields:
|
||||||
if not status:
|
if not status:
|
||||||
@@ -122,7 +153,7 @@ def _parse_name_status(text):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _count_lines(path):
|
def _count_lines(path: str) -> Optional[int]:
|
||||||
"""Line count of a working-tree file, None when binary or unreadable"""
|
"""Line count of a working-tree file, None when binary or unreadable"""
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
@@ -144,7 +175,7 @@ def _count_lines(path):
|
|||||||
return count + (0 if last == b"\n" else 1)
|
return count + (0 if last == b"\n" else 1)
|
||||||
|
|
||||||
|
|
||||||
def changed_files(repo):
|
def changed_files(repo: str) -> List[FileChange]:
|
||||||
"""Working tree changes vs HEAD: staged, unstaged and untracked, sorted by path"""
|
"""Working tree changes vs HEAD: staged, unstaged and untracked, sorted by path"""
|
||||||
status = run(repo, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--no-renames"])
|
status = run(repo, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--no-renames"])
|
||||||
entries = [e for e in status.split("\0") if e]
|
entries = [e for e in status.split("\0") if e]
|
||||||
@@ -152,7 +183,7 @@ def changed_files(repo):
|
|||||||
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
|
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
|
||||||
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base]))
|
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base]))
|
||||||
|
|
||||||
changes = []
|
changes: List[FileChange] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
kind = entry[0]
|
kind = entry[0]
|
||||||
if kind == "1":
|
if kind == "1":
|
||||||
@@ -177,26 +208,28 @@ def changed_files(repo):
|
|||||||
return changes
|
return changes
|
||||||
|
|
||||||
|
|
||||||
def recent_commits(repo, count):
|
def recent_commits(repo: str, count: int) -> List[Commit]:
|
||||||
"""Last `count` commits, newest first; empty list without commits"""
|
"""Last `count` commits, newest first; empty list without commits"""
|
||||||
if not has_head(repo):
|
if not has_head(repo):
|
||||||
return []
|
return []
|
||||||
# --shortstat rides along on the same call: one subprocess, totals for free
|
# --shortstat rides along on the same call: one subprocess, totals for free
|
||||||
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
|
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
|
||||||
commits = []
|
commits: List[Commit] = []
|
||||||
for line in text.split("\n"):
|
for line in text.split("\n"):
|
||||||
parts = line.split("\x1f")
|
parts = line.split("\x1f")
|
||||||
if len(parts) == 5:
|
if len(parts) == 5:
|
||||||
commits.append(Commit(*parts, files=None, insertions=None, deletions=None))
|
sha, short, author, date, subject = parts
|
||||||
|
commits.append(Commit(sha, short, author, date, subject, files=None, insertions=None, deletions=None))
|
||||||
continue
|
continue
|
||||||
stat = commits and commits[-1].files is None and _SHORTSTAT.match(line)
|
if commits and commits[-1].files is None:
|
||||||
if stat:
|
stat = _SHORTSTAT.match(line)
|
||||||
files, ins, dele = (int(g or 0) for g in stat.groups())
|
if stat:
|
||||||
commits[-1] = commits[-1]._replace(files=files, insertions=ins, deletions=dele)
|
files, ins, dele = (int(g or 0) for g in stat.groups())
|
||||||
|
commits[-1] = commits[-1]._replace(files=files, insertions=ins, deletions=dele)
|
||||||
return commits
|
return commits
|
||||||
|
|
||||||
|
|
||||||
def commit_with_files(repo, sha):
|
def commit_with_files(repo: str, sha: str) -> Tuple[CommitInfo, List[FileChange]]:
|
||||||
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
|
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
|
||||||
show = ["show", "--first-parent", "-m", "-z"]
|
show = ["show", "--first-parent", "-m", "-z"]
|
||||||
text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
|
text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
|
||||||
@@ -209,7 +242,7 @@ def commit_with_files(repo, sha):
|
|||||||
info = CommitInfo(*parts[:6])
|
info = CommitInfo(*parts[:6])
|
||||||
|
|
||||||
counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha]))
|
counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha]))
|
||||||
changes = []
|
changes: List[FileChange] = []
|
||||||
for status, path, old_path in _parse_name_status(files_text):
|
for status, path, old_path in _parse_name_status(files_text):
|
||||||
ins, dele, _ = counts.get(path, (None, None, None))
|
ins, dele, _ = counts.get(path, (None, None, None))
|
||||||
changes.append(FileChange(path, status, False, ins, dele, old_path))
|
changes.append(FileChange(path, status, False, ins, dele, old_path))
|
||||||
@@ -217,7 +250,7 @@ def commit_with_files(repo, sha):
|
|||||||
return info, changes
|
return info, changes
|
||||||
|
|
||||||
|
|
||||||
def _in_tree(repo, ref, path):
|
def _in_tree(repo: str, ref: str, path: str) -> bool:
|
||||||
"""Whether `path` exists in the tree of `ref`"""
|
"""Whether `path` exists in the tree of `ref`"""
|
||||||
try:
|
try:
|
||||||
run(repo, ["cat-file", "-e", f"{ref}:{path}"])
|
run(repo, ["cat-file", "-e", f"{ref}:{path}"])
|
||||||
@@ -226,7 +259,7 @@ def _in_tree(repo, ref, path):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def full_diff(repo, path, sha=None, untracked=False):
|
def full_diff(repo: str, path: str, sha: Optional[str] = None, untracked: bool = False) -> str:
|
||||||
"""Unified diff of one file with the whole file as context
|
"""Unified diff of one file with the whole file as context
|
||||||
|
|
||||||
Working tree vs HEAD (staged and unstaged merged) by default; a commit vs its
|
Working tree vs HEAD (staged and unstaged merged) by default; a commit vs its
|
||||||
@@ -240,22 +273,22 @@ def full_diff(repo, path, sha=None, untracked=False):
|
|||||||
return run(repo, ["diff"] + common + [base_ref(repo), "--", path])
|
return run(repo, ["diff"] + common + [base_ref(repo), "--", path])
|
||||||
|
|
||||||
|
|
||||||
def stage(repo, change):
|
def stage(repo: str, change: FileChange) -> None:
|
||||||
"""Stage the whole file, including its deletion"""
|
"""Stage the whole file, including its deletion"""
|
||||||
run(repo, ["add", "-A", "--", change.path])
|
run(repo, ["add", "-A", "--", change.path])
|
||||||
|
|
||||||
|
|
||||||
def commit(repo, message):
|
def commit(repo: str, message: str) -> None:
|
||||||
"""Commit what is staged; the message is one argv element, so it needs no quoting of its own"""
|
"""Commit what is staged; the message is one argv element, so it needs no quoting of its own"""
|
||||||
run(repo, ["commit", "-m", message])
|
run(repo, ["commit", "-m", message])
|
||||||
|
|
||||||
|
|
||||||
def head_message(repo):
|
def head_message(repo: str) -> str:
|
||||||
"""HEAD's whole message, exactly as git stores it"""
|
"""HEAD's whole message, exactly as git stores it"""
|
||||||
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
||||||
|
|
||||||
|
|
||||||
def amend(repo, message, allow_empty=False):
|
def amend(repo: str, message: str, allow_empty: bool = False) -> None:
|
||||||
"""Rewrite HEAD with `message`, folding in whatever is staged
|
"""Rewrite HEAD with `message`, folding in whatever is staged
|
||||||
|
|
||||||
`allow_empty` for a pure reword: git otherwise refuses an amend whose result carries no change,
|
`allow_empty` for a pure reword: git otherwise refuses an amend whose result carries no change,
|
||||||
@@ -264,7 +297,7 @@ def amend(repo, message, allow_empty=False):
|
|||||||
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
|
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
|
||||||
|
|
||||||
|
|
||||||
def unstage(repo, change):
|
def unstage(repo: str, change: FileChange) -> None:
|
||||||
"""Move the file's staged changes back to the working tree"""
|
"""Move the file's staged changes back to the working tree"""
|
||||||
if has_head(repo):
|
if has_head(repo):
|
||||||
run(repo, ["reset", "-q", "--", change.path])
|
run(repo, ["reset", "-q", "--", change.path])
|
||||||
@@ -272,7 +305,7 @@ def unstage(repo, change):
|
|||||||
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
|
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
|
||||||
|
|
||||||
|
|
||||||
def discard(repo, change):
|
def discard(repo: str, change: FileChange) -> None:
|
||||||
"""Throw away the file's changes, staged and unstaged, restoring the HEAD version"""
|
"""Throw away the file's changes, staged and unstaged, restoring the HEAD version"""
|
||||||
if change.status == "?":
|
if change.status == "?":
|
||||||
run(repo, ["clean", "-f", "--", change.path])
|
run(repo, ["clean", "-f", "--", change.path])
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ from .commands import refresh_if_changed, restore_view
|
|||||||
|
|
||||||
|
|
||||||
class VdiffEventListener(sublime_plugin.EventListener):
|
class VdiffEventListener(sublime_plugin.EventListener):
|
||||||
def on_pre_close(self, view):
|
def on_pre_close(self, view: sublime.View) -> None:
|
||||||
state.drop(view)
|
state.drop(view)
|
||||||
|
|
||||||
def on_activated(self, view):
|
def on_activated(self, view: sublime.View) -> None:
|
||||||
"""Re-render when the tab comes back and the file or index changed meanwhile (agent edits)"""
|
"""Re-render when the tab comes back and the file or index changed meanwhile (agent edits)"""
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
refresh_if_changed(view, state.get(view) or restore_view(view))
|
refresh_if_changed(view, state.get(view) or restore_view(view))
|
||||||
|
|
||||||
def on_post_save(self, view):
|
def on_post_save(self, view: sublime.View) -> None:
|
||||||
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
|
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
|
||||||
for view_id, st in state.tracked():
|
for view_id, st in state.tracked():
|
||||||
review = sublime.View(view_id)
|
review = sublime.View(view_id)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ probed and written in one pass rather than growing a colour at a time.
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
|
|
||||||
@@ -25,11 +26,11 @@ SCOPE_DEL_CHAR = "diff.deleted.char"
|
|||||||
SCOPE_INS_CHAR = "diff.inserted.char"
|
SCOPE_INS_CHAR = "diff.inserted.char"
|
||||||
|
|
||||||
# background kinds, and the tint each one draws over
|
# background kinds, and the tint each one draws over
|
||||||
KINDS = ("del", "ins", "del.char", "ins.char")
|
KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
|
||||||
KIND_TINT = {"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}
|
||||||
|
|
||||||
# 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 = {
|
FALLBACK_TINTS: Dict[str, str] = {
|
||||||
SCOPE_DEL: "hsla(357, 45%, 60%, 0.15)",
|
SCOPE_DEL: "hsla(357, 45%, 60%, 0.15)",
|
||||||
SCOPE_DEL_CHAR: "hsla(357, 60%, 60%, 0.30)",
|
SCOPE_DEL_CHAR: "hsla(357, 60%, 60%, 0.30)",
|
||||||
SCOPE_INS: "hsla(114, 31%, 68%, 0.15)",
|
SCOPE_INS: "hsla(114, 31%, 68%, 0.15)",
|
||||||
@@ -40,7 +41,7 @@ FALLBACK_TINTS = {
|
|||||||
# one pass over them yields the working set in one write. There is no API that returns a scheme's own
|
# one pass over them yields the working set in one write. There is no API that returns a scheme's own
|
||||||
# palette: style_for_scope resolves one scope at a time, and reading the scheme file would mean
|
# palette: style_for_scope resolves one scope at a time, and reading the scheme file would mean
|
||||||
# re-implementing sublime's merging of overrides and .tmTheme inheritance.
|
# re-implementing sublime's merging of overrides and .tmTheme inheritance.
|
||||||
PALETTE_PROBES = (
|
PALETTE_PROBES: Tuple[str, ...] = (
|
||||||
"source",
|
"source",
|
||||||
"comment",
|
"comment",
|
||||||
"string",
|
"string",
|
||||||
@@ -70,14 +71,14 @@ PALETTE_PROBES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# {override file name: {colour: index}} for every scheme provisioned this session
|
# {override file name: {colour: index}} for every scheme provisioned this session
|
||||||
_palettes = {}
|
_palettes: Dict[str, Dict[str, int]] = {}
|
||||||
# the override name the last ensure() resolved, so scope_for knows which palette to read
|
# the override name the last ensure() resolved, so scope_for knows which palette to read
|
||||||
_active = None
|
_active: Optional[str] = None
|
||||||
# schemes the reader has already been told need a restart
|
# schemes the reader has already been told need a restart
|
||||||
_notified = set()
|
_notified: Set[Optional[str]] = set()
|
||||||
|
|
||||||
|
|
||||||
def reset():
|
def reset() -> None:
|
||||||
"""Drop the caches, so the next ensure() re-reads and re-probes. Only the tests need this"""
|
"""Drop the caches, so the next ensure() re-reads and re-probes. Only the tests need this"""
|
||||||
global _active
|
global _active
|
||||||
_palettes.clear()
|
_palettes.clear()
|
||||||
@@ -85,24 +86,24 @@ def reset():
|
|||||||
_active = None
|
_active = None
|
||||||
|
|
||||||
|
|
||||||
def claim_restart_notice(view):
|
def claim_restart_notice(view: sublime.View) -> bool:
|
||||||
"""Whether the reader still has to be told to restart, asked once per scheme
|
"""Whether the reader still has to be told to restart, asked once per scheme
|
||||||
|
|
||||||
True only when a restart would actually help: the palette is provisioned but sublime has not
|
True only when a restart would actually help: the palette is provisioned but sublime has not
|
||||||
loaded it yet. A scheme we failed to provision at all is not something restarting fixes.
|
loaded it yet. A scheme we failed to provision at all is not something restarting fixes.
|
||||||
"""
|
"""
|
||||||
if not _palettes.get(_active) or tokens_resolved(view) or _active in _notified:
|
if not (_active and _palettes.get(_active)) or tokens_resolved(view) or _active in _notified:
|
||||||
return False
|
return False
|
||||||
_notified.add(_active)
|
_notified.add(_active)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def scope_background(view, scope):
|
def scope_background(view: sublime.View, scope: str) -> Optional[str]:
|
||||||
"""The scheme's background for a scope, or None; style_for_scope can fail on a closing view"""
|
"""The scheme's background for a scope, or None; style_for_scope can fail on a closing view"""
|
||||||
return _style(view, scope).get("background")
|
return _style(view, scope).get("background")
|
||||||
|
|
||||||
|
|
||||||
def _style(view, scope):
|
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return view.style_for_scope(scope)
|
return view.style_for_scope(scope)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -110,47 +111,47 @@ def _style(view, scope):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def foreground(view, scope):
|
def foreground(view: sublime.View, scope: str) -> Optional[str]:
|
||||||
"""The scheme's foreground for a scope, normalised by sublime to #rrggbb"""
|
"""The scheme's foreground for a scope, normalised by sublime to #rrggbb"""
|
||||||
return _style(view, scope).get("foreground")
|
return _style(view, scope).get("foreground")
|
||||||
|
|
||||||
|
|
||||||
def tokens_resolved(view):
|
def tokens_resolved(view: sublime.View) -> bool:
|
||||||
"""Whether the generated (tint + colour) scopes are live
|
"""Whether the generated (tint + colour) scopes are live
|
||||||
|
|
||||||
The token layer must never paint with a scope sublime does not know: it would draw that text in
|
The token layer must never paint with a scope sublime does not know: it would draw that text in
|
||||||
the bright green it uses for an unknown scope. Sublime loads an override it has not seen before
|
the bright green it uses for an unknown scope. Sublime loads an override it has not seen before
|
||||||
only on a restart, so this stays false for the session that first wrote one.
|
only on a restart, so this stays false for the session that first wrote one.
|
||||||
"""
|
"""
|
||||||
colours = _palettes.get(_active) or {}
|
colours = (_active and _palettes.get(_active)) or {}
|
||||||
if not colours:
|
if not colours:
|
||||||
return False
|
return False
|
||||||
# they are all written by the same pass into the same file, so any one of them answers for all
|
# they are all written by the same pass into the same file, so any one of them answers for all
|
||||||
return bool(scope_background(view, _scope_name(KINDS[0], next(iter(colours.values())))))
|
return bool(scope_background(view, _scope_name(KINDS[0], next(iter(colours.values())))))
|
||||||
|
|
||||||
|
|
||||||
def scope_for(kind, colour):
|
def scope_for(kind: str, colour: str) -> Optional[str]:
|
||||||
"""The generated scope carrying `kind`'s background and `colour`, or None if never provisioned"""
|
"""The generated scope carrying `kind`'s background and `colour`, or None if never provisioned"""
|
||||||
index = _palettes.get(_active, {}).get(colour)
|
index = _palettes.get(_active or "", {}).get(colour)
|
||||||
return None if index is None else _scope_name(kind, index)
|
return None if index is None else _scope_name(kind, index)
|
||||||
|
|
||||||
|
|
||||||
def token_key(scope):
|
def token_key(scope: str) -> str:
|
||||||
"""The add_regions key for a generated scope; one naming rule, spelled here only"""
|
"""The add_regions key for a generated scope; one naming rule, spelled here only"""
|
||||||
return scope.replace("vdiff.", "vdiff.tok.", 1)
|
return scope.replace("vdiff.", "vdiff.tok.", 1)
|
||||||
|
|
||||||
|
|
||||||
def token_keys():
|
def token_keys() -> List[str]:
|
||||||
"""Every region key the token layer can use, so a re-render can erase them all"""
|
"""Every region key the token layer can use, so a re-render can erase them all"""
|
||||||
colours = _palettes.get(_active, {})
|
colours = _palettes.get(_active or "", {})
|
||||||
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 _scope_name(kind, index):
|
def _scope_name(kind: str, index: int) -> str:
|
||||||
return f"vdiff.{kind}.c{index}"
|
return f"vdiff.{kind}.c{index}"
|
||||||
|
|
||||||
|
|
||||||
def ensure(view):
|
def ensure(view: sublime.View) -> None:
|
||||||
"""Provision the scopes for the active scheme, once per scheme per session
|
"""Provision the scopes for the active scheme, once per scheme per session
|
||||||
|
|
||||||
Whether the rules have taken effect is a separate question, asked by tokens_resolved(), because
|
Whether the rules have taken effect is a separate question, asked by tokens_resolved(), because
|
||||||
@@ -163,7 +164,7 @@ def ensure(view):
|
|||||||
return
|
return
|
||||||
path = os.path.join(sublime.packages_path(), "User", name)
|
path = os.path.join(sublime.packages_path(), "User", name)
|
||||||
try:
|
try:
|
||||||
scheme = {}
|
scheme: Dict[str, Any] = {}
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
# a scheme the user has hand-edited may hold comments decode_value rejects; the raise
|
# a scheme the user has hand-edited may hold comments decode_value rejects; the raise
|
||||||
@@ -184,9 +185,9 @@ def ensure(view):
|
|||||||
_palettes.setdefault(name, {})
|
_palettes.setdefault(name, {})
|
||||||
|
|
||||||
|
|
||||||
def _probe_palette(view):
|
def _probe_palette(view: sublime.View) -> List[str]:
|
||||||
"""The distinct foregrounds the active scheme uses, in probe order so indices stay meaningful"""
|
"""The distinct foregrounds the active scheme uses, in probe order so indices stay meaningful"""
|
||||||
seen = []
|
seen: List[str] = []
|
||||||
for scope in PALETTE_PROBES:
|
for scope in PALETTE_PROBES:
|
||||||
colour = foreground(view, scope)
|
colour = foreground(view, scope)
|
||||||
if colour and colour not in seen:
|
if colour and colour not in seen:
|
||||||
@@ -194,13 +195,13 @@ def _probe_palette(view):
|
|||||||
return seen
|
return seen
|
||||||
|
|
||||||
|
|
||||||
def _tints(view):
|
def _tints(view: sublime.View) -> Dict[str, str]:
|
||||||
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
|
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
|
||||||
|
|
||||||
All or nothing per pair: a scheme that defines diff.deleted but not diff.deleted.char would
|
All or nothing per pair: a scheme that defines diff.deleted but not diff.deleted.char would
|
||||||
otherwise get its line tint next to our char tint, in a different hue.
|
otherwise get its line tint next to our char tint, in a different hue.
|
||||||
"""
|
"""
|
||||||
tints = {}
|
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)):
|
||||||
line = scope_background(view, line_scope)
|
line = scope_background(view, line_scope)
|
||||||
char = scope_background(view, char_scope)
|
char = scope_background(view, char_scope)
|
||||||
@@ -209,7 +210,8 @@ def _tints(view):
|
|||||||
return tints
|
return tints
|
||||||
|
|
||||||
|
|
||||||
def _merge_rules(rules, tints, colours, view):
|
def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
|
||||||
|
view: sublime.View) -> bool:
|
||||||
"""Bring the scheme's rules up to date, in place; True when anything changed
|
"""Bring the scheme's rules up to date, in place; True when anything changed
|
||||||
|
|
||||||
Each (background kind, palette colour) pair needs a rule carrying both, because add_regions draws
|
Each (background kind, palette colour) pair needs a rule carrying both, because add_regions draws
|
||||||
@@ -239,7 +241,7 @@ def _merge_rules(rules, tints, colours, view):
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def override_name(view):
|
def override_name(view: sublime.View) -> Optional[str]:
|
||||||
"""File name in Packages/User that overrides the active scheme (same base name, .sublime-color-scheme)"""
|
"""File name in Packages/User that overrides the active scheme (same base name, .sublime-color-scheme)"""
|
||||||
try:
|
try:
|
||||||
# already resolved through "auto" and dark/light settings by sublime
|
# already resolved through "auto" and dark/light settings by sublime
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
""" Unified diff -> lines with old/new numbers, change blocks, word-level ranges; no sublime imports """
|
""" Unified diff -> lines with old/new numbers, change blocks, word-level ranges; no sublime imports """
|
||||||
import difflib
|
import difflib
|
||||||
import re
|
import re
|
||||||
from collections import Counter, namedtuple
|
from collections import Counter
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
from typing import Callable, Iterator, List, NamedTuple, Optional, Sequence, Tuple, TypeVar
|
||||||
|
|
||||||
# kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None
|
|
||||||
Line = namedtuple("Line", "kind old_no new_no text")
|
class Line(NamedTuple):
|
||||||
Diff = namedtuple("Diff", "lines binary")
|
"""kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None"""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
old_no: Optional[int]
|
||||||
|
new_no: Optional[int]
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class Diff(NamedTuple):
|
||||||
|
lines: List[Line]
|
||||||
|
binary: bool
|
||||||
|
|
||||||
|
|
||||||
|
# the value `runs` groups by, so a caller gets its own key type back rather than `object`
|
||||||
|
Key = TypeVar("Key")
|
||||||
|
|
||||||
|
# an (start, end) index range of consecutive changed lines
|
||||||
|
Block = Tuple[int, int]
|
||||||
|
# a (start, end) character range inside one line's text
|
||||||
|
CharRange = Tuple[int, int]
|
||||||
|
|
||||||
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
|
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
|
||||||
CHAR_DIFF_MIN_RATIO = 0.5
|
CHAR_DIFF_MIN_RATIO = 0.5
|
||||||
@@ -15,9 +35,9 @@ CHAR_DIFF_MAX_CHARS = 2000
|
|||||||
_HUNK = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)")
|
_HUNK = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)")
|
||||||
|
|
||||||
|
|
||||||
def parse_unified(text):
|
def parse_unified(text: str) -> Diff:
|
||||||
"""Parse one file's unified diff (any number of hunks) into a Diff"""
|
"""Parse one file's unified diff (any number of hunks) into a Diff"""
|
||||||
lines = []
|
lines: List[Line] = []
|
||||||
binary = False
|
binary = False
|
||||||
old_no = new_no = 0
|
old_no = new_no = 0
|
||||||
in_hunk = False
|
in_hunk = False
|
||||||
@@ -52,25 +72,25 @@ def parse_unified(text):
|
|||||||
return Diff(lines, binary)
|
return Diff(lines, binary)
|
||||||
|
|
||||||
|
|
||||||
def runs(items, key):
|
def runs(items: Sequence[Line], key: Callable[[Line], Key]) -> Iterator[Tuple[Key, int, int]]:
|
||||||
"""(key value, start, end) for each maximal run of consecutive items with the same key"""
|
"""(key value, start, end) for each maximal run of consecutive items with the same key"""
|
||||||
for value, group in groupby(enumerate(items), key=lambda pair: key(pair[1])):
|
for value, group in groupby(enumerate(items), key=lambda pair: key(pair[1])):
|
||||||
indexes = [i for i, _ in group]
|
indexes = [i for i, _ in group]
|
||||||
yield value, indexes[0], indexes[-1] + 1
|
yield value, indexes[0], indexes[-1] + 1
|
||||||
|
|
||||||
|
|
||||||
def change_blocks(lines):
|
def change_blocks(lines: Sequence[Line]) -> List[Block]:
|
||||||
"""[(start, end)) index ranges of consecutive changed lines"""
|
"""[(start, end)) index ranges of consecutive changed lines"""
|
||||||
return [(start, end) for changed, start, end in runs(lines, lambda line: line.kind != " ") if changed]
|
return [(start, end) for changed, start, end in runs(lines, lambda line: line.kind != " ") if changed]
|
||||||
|
|
||||||
|
|
||||||
def counts(lines):
|
def counts(lines: Sequence[Line]) -> Tuple[int, int]:
|
||||||
"""(insertions, deletions)"""
|
"""(insertions, deletions)"""
|
||||||
tally = Counter(line.kind for line in lines)
|
tally = Counter(line.kind for line in lines)
|
||||||
return tally["+"], tally["-"]
|
return tally["+"], tally["-"]
|
||||||
|
|
||||||
|
|
||||||
def paired_lines(lines, block):
|
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
|
||||||
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
|
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
|
||||||
start, end = block
|
start, end = block
|
||||||
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
|
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
|
||||||
@@ -80,15 +100,15 @@ def paired_lines(lines, block):
|
|||||||
return list(zip(deleted, inserted))
|
return list(zip(deleted, inserted))
|
||||||
|
|
||||||
|
|
||||||
def char_ranges(old, new):
|
def char_ranges(old: str, new: str) -> Tuple[List[CharRange], List[CharRange]]:
|
||||||
"""Character ranges that differ: ([(a, b) in old], [(a, b) in new]); empty when too different or too long"""
|
"""Character ranges that differ: ([(a, b) in old], [(a, b) in new]); empty when too different or too long"""
|
||||||
if len(old) + len(new) > CHAR_DIFF_MAX_CHARS:
|
if len(old) + len(new) > CHAR_DIFF_MAX_CHARS:
|
||||||
return [], []
|
return [], []
|
||||||
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
|
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
|
||||||
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
|
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
|
||||||
return [], []
|
return [], []
|
||||||
old_ranges = []
|
old_ranges: List[CharRange] = []
|
||||||
new_ranges = []
|
new_ranges: List[CharRange] = []
|
||||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
if tag in ("replace", "delete") and i2 > i1:
|
if tag in ("replace", "delete") and i2 > i1:
|
||||||
old_ranges.append((i1, i2))
|
old_ranges.append((i1, i2))
|
||||||
@@ -97,7 +117,7 @@ def char_ranges(old, new):
|
|||||||
return old_ranges, new_ranges
|
return old_ranges, new_ranges
|
||||||
|
|
||||||
|
|
||||||
def line_for_open(lines, index):
|
def line_for_open(lines: Sequence[Line], index: int) -> int:
|
||||||
"""New-file line number to open for a row: a deleted row maps to the next surviving line"""
|
"""New-file line number to open for a row: a deleted row maps to the next surviving line"""
|
||||||
if not lines:
|
if not lines:
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
119
vdiff/render.py
119
vdiff/render.py
@@ -3,14 +3,15 @@ import bisect
|
|||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections import namedtuple
|
from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from . import palette, parse
|
from . import palette, parse
|
||||||
from .git import STATUS_NAMES
|
from .git import FileChange, STATUS_NAMES
|
||||||
from .state import VIEW_FLAG
|
from .parse import Block, Line
|
||||||
|
from .state import VIEW_FLAG, ViewState
|
||||||
from .utils import file_count, settings, status
|
from .utils import file_count, settings, status
|
||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
LOG = logging.getLogger("vdiff")
|
||||||
@@ -21,7 +22,7 @@ KEY_DEL_GUTTER = "vdiff.del.gutter"
|
|||||||
KEY_INS_GUTTER = "vdiff.ins.gutter"
|
KEY_INS_GUTTER = "vdiff.ins.gutter"
|
||||||
KEY_DEL_CHAR = "vdiff.del.char"
|
KEY_DEL_CHAR = "vdiff.del.char"
|
||||||
KEY_INS_CHAR = "vdiff.ins.char"
|
KEY_INS_CHAR = "vdiff.ins.char"
|
||||||
REGION_KEYS = (KEY_DEL, KEY_INS, KEY_DEL_GUTTER, KEY_INS_GUTTER, KEY_DEL_CHAR, KEY_INS_CHAR)
|
REGION_KEYS: Tuple[str, ...] = (KEY_DEL, KEY_INS, KEY_DEL_GUTTER, KEY_INS_GUTTER, KEY_DEL_CHAR, KEY_INS_CHAR)
|
||||||
|
|
||||||
PHANTOM_KEY = "vdiff.header"
|
PHANTOM_KEY = "vdiff.header"
|
||||||
STATUS_KEY = "vdiff"
|
STATUS_KEY = "vdiff"
|
||||||
@@ -51,9 +52,17 @@ NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on
|
|||||||
# one source for the status-bar hints and the ? popup.
|
# one source for the status-bar hints and the ? popup.
|
||||||
# actions: the ACTIONS names the row covers; a single one makes the row a link. short: status-bar
|
# actions: the ACTIONS names the row covers; a single one makes the row a link. short: status-bar
|
||||||
# wording, "" keeps the row out of it. The group flag is False for groups needing a working-tree file.
|
# wording, "" keeps the row out of it. The group flag is False for groups needing a working-tree file.
|
||||||
Key = namedtuple("Key", "keys label short actions")
|
class Key(NamedTuple):
|
||||||
|
"""One row of the status-bar hints and the ? popup"""
|
||||||
|
|
||||||
KEY_GROUPS = [
|
keys: str
|
||||||
|
label: str
|
||||||
|
short: str
|
||||||
|
actions: Tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
# (group title, applies in a commit, rows)
|
||||||
|
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
||||||
(
|
(
|
||||||
"Navigate",
|
"Navigate",
|
||||||
True,
|
True,
|
||||||
@@ -94,7 +103,7 @@ KEY_GROUPS = [
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
KEYS = [key for _, _, group in KEY_GROUPS for key in group]
|
KEYS: List[Key] = [key for _, _, group in KEY_GROUPS for key in group]
|
||||||
|
|
||||||
KEY_HINTS = " ".join(f"{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)
|
||||||
|
|
||||||
@@ -137,11 +146,11 @@ a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
|
|||||||
class VdiffReplaceCommand(sublime_plugin.TextCommand):
|
class VdiffReplaceCommand(sublime_plugin.TextCommand):
|
||||||
"""Replace the whole buffer; the only writer of a review view"""
|
"""Replace the whole buffer; the only writer of a review view"""
|
||||||
|
|
||||||
def run(self, edit, text):
|
def run(self, edit: sublime.Edit, text: str) -> None:
|
||||||
self.view.replace(edit, sublime.Region(0, self.view.size()), text)
|
self.view.replace(edit, sublime.Region(0, self.view.size()), text)
|
||||||
|
|
||||||
|
|
||||||
def init_view(view):
|
def init_view(view: sublime.View) -> None:
|
||||||
"""Settings a review view needs, applied once when it is created or restored"""
|
"""Settings a review view needs, applied once when it is created or restored"""
|
||||||
s = view.settings()
|
s = view.settings()
|
||||||
s.set(VIEW_FLAG, True)
|
s.set(VIEW_FLAG, True)
|
||||||
@@ -167,40 +176,40 @@ def init_view(view):
|
|||||||
view.set_status(STATUS_KEY, KEY_HINTS)
|
view.set_status(STATUS_KEY, KEY_HINTS)
|
||||||
|
|
||||||
|
|
||||||
def index_point(view, index, col=0):
|
def index_point(view: sublime.View, index: int, col: int = 0) -> int:
|
||||||
"""Text point of column `col` on the row showing st.lines[index]"""
|
"""Text point of column `col` on the row showing st.lines[index]"""
|
||||||
return view.text_point(index + _HEADER_ROWS, col)
|
return view.text_point(index + _HEADER_ROWS, col)
|
||||||
|
|
||||||
|
|
||||||
def caret_index(view):
|
def caret_index(view: sublime.View) -> int:
|
||||||
"""Index into st.lines of the caret's row (the header row maps to -1)"""
|
"""Index into st.lines of the caret's row (the header row maps to -1)"""
|
||||||
if not len(view.sel()):
|
if not len(view.sel()):
|
||||||
return 0
|
return 0
|
||||||
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
|
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(st):
|
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"""
|
"""Cheap "did anything change" key for working-tree mode: mtimes of the file 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")))
|
return (_mtime(os.path.join(st.repo, st.change.path)), _mtime(os.path.join(st.repo, ".git", "index")))
|
||||||
|
|
||||||
|
|
||||||
def _mtime(path):
|
def _mtime(path: str) -> Optional[float]:
|
||||||
try:
|
try:
|
||||||
return os.stat(path).st_mtime
|
return os.stat(path).st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def flash(view):
|
def flash(view: sublime.View) -> None:
|
||||||
"""Visual bell: tint the buffer for a moment, for feedback that has nothing to say in words"""
|
"""Visual bell: tint the buffer for a moment, for feedback that has nothing to say in words"""
|
||||||
view.add_regions(BELL_KEY, [sublime.Region(0, view.size())], _bell_scope(view), flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(BELL_KEY, [sublime.Region(0, view.size())], _bell_scope(view), flags=sublime.DRAW_NO_OUTLINE)
|
||||||
# unconditional erase: overlapping flashes may cut one short, but none can leave the tint stuck
|
# unconditional erase: overlapping flashes may cut one short, but none can leave the tint stuck
|
||||||
sublime.set_timeout(lambda: view.is_valid() and view.erase_regions(BELL_KEY), BELL_MS)
|
sublime.set_timeout(lambda: view.is_valid() and view.erase_regions(BELL_KEY), BELL_MS)
|
||||||
|
|
||||||
|
|
||||||
def _bell_scope(view):
|
def _bell_scope(view: sublime.View) -> str:
|
||||||
"""region.yellowish, or a scope with a background we know of when a scheme lacks it"""
|
"""region.yellowish, or a scope with a background we know of when a scheme lacks it"""
|
||||||
if palette.scope_background(view, SCOPE_BELL):
|
if palette.scope_background(view, SCOPE_BELL):
|
||||||
return SCOPE_BELL
|
return SCOPE_BELL
|
||||||
@@ -208,7 +217,7 @@ def _bell_scope(view):
|
|||||||
return palette.SCOPE_INS
|
return palette.SCOPE_INS
|
||||||
|
|
||||||
|
|
||||||
def render_message(view, st, message):
|
def render_message(view: sublime.View, st: ViewState, message: str) -> None:
|
||||||
"""Show a one-line notice instead of a file (nothing to review, binary, ...)"""
|
"""Show a one-line notice instead of a file (nothing to review, binary, ...)"""
|
||||||
st.change = None
|
st.change = None
|
||||||
st.lines = []
|
st.lines = []
|
||||||
@@ -219,8 +228,11 @@ def render_message(view, st, message):
|
|||||||
_finish(view, st, 0, 0, "vDiff")
|
_finish(view, st, 0, 0, "vDiff")
|
||||||
|
|
||||||
|
|
||||||
def render(view, st):
|
def render(view: sublime.View, st: ViewState) -> None:
|
||||||
"""Draw st.lines for st.change into the view"""
|
"""Draw st.lines for st.change into the view"""
|
||||||
|
change = st.change
|
||||||
|
# every caller with nothing to show goes to render_message instead, so there is a file here
|
||||||
|
assert change is not None
|
||||||
lines = st.lines
|
lines = st.lines
|
||||||
if st.binary:
|
if st.binary:
|
||||||
text = "(binary file)"
|
text = "(binary file)"
|
||||||
@@ -230,7 +242,7 @@ def render(view, st):
|
|||||||
text = "\n".join(line.text for line in lines)
|
text = "\n".join(line.text for line in lines)
|
||||||
|
|
||||||
_replace(view, text)
|
_replace(view, text)
|
||||||
path = os.path.join(st.repo, st.change.path)
|
path = os.path.join(st.repo, change.path)
|
||||||
_apply_syntax(view, path if lines and not st.binary else None)
|
_apply_syntax(view, path if lines and not st.binary else None)
|
||||||
|
|
||||||
if lines and not st.binary:
|
if lines and not st.binary:
|
||||||
@@ -242,13 +254,13 @@ def render(view, st):
|
|||||||
status(NEEDS_RESTART)
|
status(NEEDS_RESTART)
|
||||||
|
|
||||||
ins, dele = parse.counts(lines)
|
ins, dele = parse.counts(lines)
|
||||||
_finish(view, st, ins, dele, f"⟐ {os.path.basename(st.change.path)}")
|
_finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}")
|
||||||
view.sel().clear()
|
view.sel().clear()
|
||||||
view.sel().add(sublime.Region(index_point(view, 0)))
|
view.sel().add(sublime.Region(index_point(view, 0)))
|
||||||
view.set_viewport_position((0, 0), False)
|
view.set_viewport_position((0, 0), False)
|
||||||
|
|
||||||
|
|
||||||
def _paint_regions(view, st):
|
def _paint_regions(view: sublime.View, st: ViewState) -> None:
|
||||||
"""The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
|
"""The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
|
||||||
lines = st.lines
|
lines = st.lines
|
||||||
starts = _row_starts(lines)
|
starts = _row_starts(lines)
|
||||||
@@ -260,7 +272,8 @@ def _paint_regions(view, st):
|
|||||||
ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"]
|
ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"]
|
||||||
view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags)
|
view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags)
|
||||||
view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags)
|
view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags)
|
||||||
del_chars, ins_chars = [], []
|
del_chars: List[sublime.Region] = []
|
||||||
|
ins_chars: List[sublime.Region] = []
|
||||||
if settings().get("word_diff", True):
|
if settings().get("word_diff", True):
|
||||||
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
|
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
|
||||||
view.add_regions(KEY_DEL_CHAR, del_chars, palette.SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(KEY_DEL_CHAR, del_chars, palette.SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
@@ -268,7 +281,8 @@ def _paint_regions(view, st):
|
|||||||
_paint_tokens(view, lines, starts, del_chars + ins_chars)
|
_paint_tokens(view, lines, starts, del_chars + ins_chars)
|
||||||
|
|
||||||
|
|
||||||
def _paint_tokens(view, lines, starts, char_regions):
|
def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int],
|
||||||
|
char_regions: Sequence[sublime.Region]) -> None:
|
||||||
"""Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour
|
"""Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour
|
||||||
|
|
||||||
add_regions draws the text it covers in the region scope's own foreground, so the tints above have
|
add_regions draws the text it covers in the region scope's own foreground, so the tints above have
|
||||||
@@ -284,8 +298,8 @@ def _paint_tokens(view, lines, starts, char_regions):
|
|||||||
|
|
||||||
stronger = sorted((r.a, r.b) for r in char_regions)
|
stronger = sorted((r.a, r.b) for r in char_regions)
|
||||||
starts_of = [a for a, _ in stronger]
|
starts_of = [a for a, _ in stronger]
|
||||||
groups = {}
|
groups: Dict[str, List[sublime.Region]] = {}
|
||||||
colours = {} # scope name -> colour, resolved once per render
|
colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render
|
||||||
for i in changed:
|
for i in changed:
|
||||||
kind = "del" if lines[i].kind == "-" else "ins"
|
kind = "del" if lines[i].kind == "-" else "ins"
|
||||||
end = starts[i] + len(lines[i].text)
|
end = starts[i] + len(lines[i].text)
|
||||||
@@ -302,11 +316,11 @@ def _paint_tokens(view, lines, starts, char_regions):
|
|||||||
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)
|
||||||
|
|
||||||
|
|
||||||
def _split(region, intervals):
|
def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List[Tuple[sublime.Region, bool]]:
|
||||||
"""(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
|
"""(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
|
||||||
if not intervals:
|
if not intervals:
|
||||||
return [(region, False)]
|
return [(region, False)]
|
||||||
parts = []
|
parts: List[Tuple[sublime.Region, bool]] = []
|
||||||
pos = region.a
|
pos = region.a
|
||||||
for a, b in intervals:
|
for a, b in intervals:
|
||||||
a, b = max(a, region.a), min(b, region.b)
|
a, b = max(a, region.a), min(b, region.b)
|
||||||
@@ -323,9 +337,10 @@ def _split(region, intervals):
|
|||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def _colour_runs(view, start, text, colours):
|
def _colour_runs(view: sublime.View, start: int, text: str,
|
||||||
|
colours: Dict[str, Optional[str]]) -> List[Tuple[sublime.Region, str]]:
|
||||||
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged"""
|
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged"""
|
||||||
runs = []
|
runs: List[Tuple[sublime.Region, str]] = []
|
||||||
for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, start + len(text))):
|
for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, start + len(text))):
|
||||||
if scope not in colours:
|
if scope not in colours:
|
||||||
colours[scope] = palette.foreground(view, scope)
|
colours[scope] = palette.foreground(view, scope)
|
||||||
@@ -339,7 +354,7 @@ def _colour_runs(view, start, text, colours):
|
|||||||
return runs
|
return runs
|
||||||
|
|
||||||
|
|
||||||
def _finish(view, st, ins, dele, name):
|
def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -> None:
|
||||||
"""Header, tab name, status bar and bookkeeping shared by render and render_message"""
|
"""Header, tab name, status bar and bookkeeping shared by render and render_message"""
|
||||||
_header(view, st, ins, dele)
|
_header(view, st, ins, dele)
|
||||||
view.set_name(name)
|
view.set_name(name)
|
||||||
@@ -348,7 +363,7 @@ def _finish(view, st, ins, dele, name):
|
|||||||
st.fingerprint = fingerprint(st)
|
st.fingerprint = fingerprint(st)
|
||||||
|
|
||||||
|
|
||||||
def _replace(view, text):
|
def _replace(view: sublime.View, text: str) -> None:
|
||||||
view.set_read_only(False)
|
view.set_read_only(False)
|
||||||
try:
|
try:
|
||||||
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
|
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
|
||||||
@@ -359,7 +374,7 @@ def _replace(view, text):
|
|||||||
view.erase_regions(key)
|
view.erase_regions(key)
|
||||||
|
|
||||||
|
|
||||||
def _apply_syntax(view, path):
|
def _apply_syntax(view: sublime.View, path: Optional[str]) -> None:
|
||||||
"""The real file's syntax, so the diff is highlighted like the file itself"""
|
"""The real file's syntax, so the diff is highlighted like the file itself"""
|
||||||
syntax = None
|
syntax = None
|
||||||
if path:
|
if path:
|
||||||
@@ -370,9 +385,9 @@ def _apply_syntax(view, path):
|
|||||||
view.assign_syntax(syntax.path if syntax else "scope:text.plain")
|
view.assign_syntax(syntax.path if syntax else "scope:text.plain")
|
||||||
|
|
||||||
|
|
||||||
def _row_starts(lines):
|
def _row_starts(lines: Sequence[Line]) -> List[int]:
|
||||||
"""Text point where each line starts; computed from the text we wrote, no view round-trips"""
|
"""Text point where each line starts; computed from the text we wrote, no view round-trips"""
|
||||||
starts = []
|
starts: List[int] = []
|
||||||
point = _HEADER_ROWS
|
point = _HEADER_ROWS
|
||||||
for line in lines:
|
for line in lines:
|
||||||
starts.append(point)
|
starts.append(point)
|
||||||
@@ -381,9 +396,10 @@ def _row_starts(lines):
|
|||||||
return starts
|
return starts
|
||||||
|
|
||||||
|
|
||||||
def _line_regions(lines, starts):
|
def _line_regions(lines: Sequence[Line],
|
||||||
|
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
|
||||||
"""Full-line regions of deleted and inserted rows, consecutive rows merged"""
|
"""Full-line regions of deleted and inserted rows, consecutive rows merged"""
|
||||||
regions = {"-": [], "+": []}
|
regions: Dict[str, List[sublime.Region]] = {"-": [], "+": []}
|
||||||
for kind, start, end in parse.runs(lines, lambda line: line.kind):
|
for kind, start, end in parse.runs(lines, lambda line: line.kind):
|
||||||
if kind in regions:
|
if kind in regions:
|
||||||
# starts has a sentinel at len(lines): the last line stops at the buffer end
|
# starts has a sentinel at len(lines): the last line stops at the buffer end
|
||||||
@@ -391,10 +407,11 @@ def _line_regions(lines, starts):
|
|||||||
return regions["-"], regions["+"]
|
return regions["-"], regions["+"]
|
||||||
|
|
||||||
|
|
||||||
def _char_regions(lines, blocks, starts):
|
def _char_regions(lines: Sequence[Line], blocks: Sequence[Block],
|
||||||
|
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
|
||||||
"""Changed characters inside paired deleted/inserted lines"""
|
"""Changed characters inside paired deleted/inserted lines"""
|
||||||
deleted = []
|
deleted: List[sublime.Region] = []
|
||||||
inserted = []
|
inserted: List[sublime.Region] = []
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
for del_row, ins_row in parse.paired_lines(lines, block):
|
for del_row, ins_row in parse.paired_lines(lines, block):
|
||||||
old_ranges, new_ranges = parse.char_ranges(lines[del_row].text, lines[ins_row].text)
|
old_ranges, new_ranges = parse.char_ranges(lines[del_row].text, lines[ins_row].text)
|
||||||
@@ -403,12 +420,12 @@ def _char_regions(lines, blocks, starts):
|
|||||||
return deleted, inserted
|
return deleted, inserted
|
||||||
|
|
||||||
|
|
||||||
def _totals(files):
|
def _totals(files: Sequence[FileChange]) -> Tuple[int, int]:
|
||||||
"""Summed numstat counts; binary files carry None counts and add nothing"""
|
"""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)
|
return sum(c.insertions or 0 for c in files), sum(c.deletions or 0 for c in files)
|
||||||
|
|
||||||
|
|
||||||
def _summary_html(st):
|
def _summary_html(st: ViewState) -> str:
|
||||||
"""Line above the header: the whole commit on one line, or how much of the tree is staged"""
|
"""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:
|
if not st.files and not st.commit:
|
||||||
return ""
|
return ""
|
||||||
@@ -425,10 +442,10 @@ def _summary_html(st):
|
|||||||
return f'<div class="summary">{text}</div>'
|
return f'<div class="summary">{text}</div>'
|
||||||
|
|
||||||
|
|
||||||
def _commit_parts(st):
|
def _commit_parts(st: ViewState) -> List[str]:
|
||||||
"""hash, author, date, totals and message — everything about the reviewed commit, in that order"""
|
"""hash, author, date, totals and message — everything about the reviewed commit, in that order"""
|
||||||
info = st.commit
|
info = st.commit
|
||||||
parts = []
|
parts: List[str] = []
|
||||||
if info:
|
if info:
|
||||||
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
|
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
|
||||||
parts.append(html.escape(info.author))
|
parts.append(html.escape(info.author))
|
||||||
@@ -445,7 +462,7 @@ def _commit_parts(st):
|
|||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def header_html(st, ins, dele):
|
def header_html(st: ViewState, ins: int, dele: int) -> str:
|
||||||
"""The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
|
"""The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
|
||||||
change = st.change
|
change = st.change
|
||||||
if change is None:
|
if change is None:
|
||||||
@@ -460,7 +477,7 @@ def header_html(st, ins, dele):
|
|||||||
if state:
|
if state:
|
||||||
title += f' <span class="state">{state}</span>'
|
title += f' <span class="state">{state}</span>'
|
||||||
|
|
||||||
actions = []
|
actions: List[str] = []
|
||||||
if change is not None and not st.in_commit:
|
if change is not None and not st.in_commit:
|
||||||
actions.append('<a href="stage">Stage</a>')
|
actions.append('<a href="stage">Stage</a>')
|
||||||
actions.append('<a href="unstage">Unstage</a>')
|
actions.append('<a href="unstage">Unstage</a>')
|
||||||
@@ -477,15 +494,15 @@ def header_html(st, ins, dele):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_action(view, href):
|
def _run_action(view: sublime.View, href: str) -> None:
|
||||||
"""Dispatch a header or popup link through the same command the keymap uses"""
|
"""Dispatch a header or popup link through the same command the keymap uses"""
|
||||||
view.run_command("vdiff_action", {"action": href})
|
view.run_command("vdiff_action", {"action": href})
|
||||||
|
|
||||||
|
|
||||||
def _header(view, st, ins, dele):
|
def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None:
|
||||||
"""One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep"""
|
"""One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep"""
|
||||||
|
|
||||||
def navigate(href):
|
def navigate(href: str) -> None:
|
||||||
_run_action(view, href)
|
_run_action(view, href)
|
||||||
|
|
||||||
view.erase_phantoms(PHANTOM_KEY)
|
view.erase_phantoms(PHANTOM_KEY)
|
||||||
@@ -495,9 +512,9 @@ def _header(view, st, ins, dele):
|
|||||||
# --- key help popup ---------------------------------------------------------
|
# --- key help popup ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def help_html(st):
|
def help_html(st: ViewState) -> str:
|
||||||
"""The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown"""
|
"""The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown"""
|
||||||
parts = []
|
parts: List[str] = []
|
||||||
for title, applies, group in KEY_GROUPS:
|
for title, applies, group in KEY_GROUPS:
|
||||||
dim = st.in_commit and not applies
|
dim = st.in_commit and not applies
|
||||||
heading = html.escape(title)
|
heading = html.escape(title)
|
||||||
@@ -517,13 +534,13 @@ def help_html(st):
|
|||||||
return f'<body id="vdiff-help"><style>{HELP_CSS}</style>{"".join(parts)}</body>'
|
return f'<body id="vdiff-help"><style>{HELP_CSS}</style>{"".join(parts)}</body>'
|
||||||
|
|
||||||
|
|
||||||
def show_help(view, st):
|
def show_help(view: sublime.View, st: ViewState) -> None:
|
||||||
"""Toggle the key list popup at the caret"""
|
"""Toggle the key list popup at the caret"""
|
||||||
if view.is_popup_visible():
|
if view.is_popup_visible():
|
||||||
view.hide_popup()
|
view.hide_popup()
|
||||||
return
|
return
|
||||||
|
|
||||||
def navigate(href):
|
def navigate(href: str) -> None:
|
||||||
view.hide_popup()
|
view.hide_popup()
|
||||||
_run_action(view, href)
|
_run_action(view, href)
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
""" Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore """
|
""" Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore """
|
||||||
from collections import namedtuple
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# annotation-only, so this module keeps importing neither sublime nor the rest of the package
|
||||||
|
import sublime
|
||||||
|
|
||||||
|
from .git import CommitInfo, FileChange
|
||||||
|
from .parse import Block, Line
|
||||||
|
|
||||||
# view setting marking a review view (keymap context "setting.vdiff_view")
|
# view setting marking a review view (keymap context "setting.vdiff_view")
|
||||||
VIEW_FLAG = "vdiff_view"
|
VIEW_FLAG = "vdiff_view"
|
||||||
# view setting holding what the view shows, so a restored tab can re-render
|
# view setting holding what the view shows, so a restored tab can re-render
|
||||||
VIEW_DATA = "vdiff"
|
VIEW_DATA = "vdiff"
|
||||||
|
|
||||||
# what a quick panel goes back to on Esc
|
|
||||||
Kept = namedtuple("Kept", "sha commit change files")
|
class Kept(NamedTuple):
|
||||||
|
"""What a quick panel goes back to on Esc"""
|
||||||
|
|
||||||
|
sha: Optional[str]
|
||||||
|
commit: Optional["CommitInfo"]
|
||||||
|
change: Optional["FileChange"]
|
||||||
|
files: List["FileChange"]
|
||||||
|
|
||||||
|
|
||||||
def find_change(files, path):
|
def find_change(files: Sequence["FileChange"], path: Optional[str]) -> int:
|
||||||
"""Index of the FileChange with `path`, or -1"""
|
"""Index of the FileChange with `path`, or -1"""
|
||||||
for i, change in enumerate(files):
|
for i, change in enumerate(files):
|
||||||
if change.path == path:
|
if change.path == path:
|
||||||
@@ -27,24 +40,25 @@ class ViewState:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
repo: str
|
repo: str
|
||||||
sha: object = None # None in working-tree mode, the reviewed commit's hash otherwise
|
sha: Optional[str] = None # None in working-tree mode, the reviewed commit's hash otherwise
|
||||||
commit: object = None # git.CommitInfo when sha is set
|
commit: Optional["CommitInfo"] = None # set when sha is
|
||||||
change: object = None # git.FileChange currently shown
|
change: Optional["FileChange"] = None # file currently shown
|
||||||
requested: object = None # file asked for, maybe still loading; navigation counts from here
|
requested: Optional["FileChange"] = None # file asked for, maybe still loading; navigation counts from here
|
||||||
files: list = field(default_factory=list) # git.FileChange list the user is browsing (next/prev file)
|
files: List["FileChange"] = field(default_factory=list) # what the user is browsing (next/prev file)
|
||||||
lines: list = field(default_factory=list) # parse.Line list currently rendered
|
lines: List["Line"] = field(default_factory=list) # currently rendered
|
||||||
blocks: list = field(default_factory=list) # parse.change_blocks(lines)
|
blocks: List["Block"] = field(default_factory=list) # parse.change_blocks(lines)
|
||||||
binary: bool = False
|
binary: bool = False
|
||||||
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
|
||||||
fingerprint: object = None # (file mtime, index mtime) at render time, working-tree mode
|
# (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable
|
||||||
|
fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def in_commit(self):
|
def in_commit(self) -> bool:
|
||||||
return self.sha is not None
|
return self.sha is not None
|
||||||
|
|
||||||
def index(self):
|
def index(self) -> int:
|
||||||
"""Position in `files` of the file being shown or loaded, or -1
|
"""Position in `files` of the file being shown or loaded, or -1
|
||||||
|
|
||||||
`requested` leads: a diff arrives asynchronously, so counting from `change` alone would make
|
`requested` leads: a diff arrives asynchronously, so counting from `change` alone would make
|
||||||
@@ -53,10 +67,10 @@ class ViewState:
|
|||||||
change = self.requested or self.change
|
change = self.requested or self.change
|
||||||
return find_change(self.files, change.path) if change else -1
|
return find_change(self.files, change.path) if change else -1
|
||||||
|
|
||||||
def kept(self):
|
def kept(self) -> Kept:
|
||||||
return Kept(self.sha, self.commit, self.change, list(self.files))
|
return Kept(self.sha, self.commit, self.change, list(self.files))
|
||||||
|
|
||||||
def enter(self, sha=None, commit=None):
|
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"""
|
||||||
kept = self.kept()
|
kept = self.kept()
|
||||||
if sha != self.sha:
|
if sha != self.sha:
|
||||||
@@ -64,13 +78,13 @@ class ViewState:
|
|||||||
self.sha, self.commit = sha, commit
|
self.sha, self.commit = sha, commit
|
||||||
return kept
|
return kept
|
||||||
|
|
||||||
def restore(self, kept):
|
def restore(self, kept: Kept) -> None:
|
||||||
self.sha, self.commit, self.change, self.files = kept
|
self.sha, self.commit, self.change, self.files = kept
|
||||||
self.requested = kept.change
|
self.requested = kept.change
|
||||||
|
|
||||||
def persist(self, view):
|
def persist(self, view: "sublime.View") -> None:
|
||||||
"""Mirror the essentials into view settings (survives a restart)"""
|
"""Mirror the essentials into view settings (survives a restart)"""
|
||||||
data = {"repo": self.repo, "sha": self.sha}
|
data: Dict[str, Any] = {"repo": self.repo, "sha": self.sha}
|
||||||
if self.change:
|
if self.change:
|
||||||
data["path"] = self.change.path
|
data["path"] = self.change.path
|
||||||
data["status"] = self.change.status
|
data["status"] = self.change.status
|
||||||
@@ -78,26 +92,26 @@ class ViewState:
|
|||||||
view.settings().set(VIEW_DATA, data)
|
view.settings().set(VIEW_DATA, data)
|
||||||
|
|
||||||
|
|
||||||
STATES = {}
|
STATES: Dict[int, "ViewState"] = {}
|
||||||
|
|
||||||
|
|
||||||
def get(view):
|
def get(view: "sublime.View") -> Optional[ViewState]:
|
||||||
return STATES.get(view.id())
|
return STATES.get(view.id())
|
||||||
|
|
||||||
|
|
||||||
def tracked():
|
def tracked() -> List[Tuple[int, ViewState]]:
|
||||||
"""Snapshot of (view id, state) for every tracked review view"""
|
"""Snapshot of (view id, state) for every tracked review view"""
|
||||||
return list(STATES.items())
|
return list(STATES.items())
|
||||||
|
|
||||||
|
|
||||||
def put(view, st):
|
def put(view: "sublime.View", st: ViewState) -> ViewState:
|
||||||
STATES[view.id()] = st
|
STATES[view.id()] = st
|
||||||
return st
|
return st
|
||||||
|
|
||||||
|
|
||||||
def drop(view):
|
def drop(view: "sublime.View") -> None:
|
||||||
STATES.pop(view.id(), None)
|
STATES.pop(view.id(), None)
|
||||||
|
|
||||||
|
|
||||||
def is_review_view(view):
|
def is_review_view(view: Optional["sublime.View"]) -> bool:
|
||||||
return bool(view and view.settings().get(VIEW_FLAG))
|
return bool(view and view.settings().get(VIEW_FLAG))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
""" Small helpers shared across vdiff modules """
|
""" Small helpers shared across vdiff modules """
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
|
|
||||||
@@ -9,10 +10,10 @@ SETTINGS_FILE = "vDiff.sublime-settings"
|
|||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
LOG = logging.getLogger("vdiff")
|
||||||
|
|
||||||
_settings = None
|
_settings: Optional[sublime.Settings] = None
|
||||||
|
|
||||||
|
|
||||||
def settings():
|
def settings() -> sublime.Settings:
|
||||||
"""Cached settings handle; a sublime Settings object stays live"""
|
"""Cached settings handle; a sublime Settings object stays live"""
|
||||||
global _settings
|
global _settings
|
||||||
if _settings is None:
|
if _settings is None:
|
||||||
@@ -22,25 +23,25 @@ def settings():
|
|||||||
return _settings
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
def apply_git_binary():
|
def apply_git_binary() -> None:
|
||||||
"""Push the configured git executable into the (sublime-free) git module"""
|
"""Push the configured git executable into the (sublime-free) git module"""
|
||||||
git.GIT_BINARY = settings().get("git_binary") or "git"
|
git.GIT_BINARY = settings().get("git_binary") or "git"
|
||||||
|
|
||||||
|
|
||||||
def file_count(count):
|
def file_count(count: int) -> str:
|
||||||
"""'1 file' / '3 files' for a message"""
|
"""'1 file' / '3 files' for a message"""
|
||||||
return f'{count} {"file" if count == 1 else "files"}'
|
return f'{count} {"file" if count == 1 else "files"}'
|
||||||
|
|
||||||
|
|
||||||
def status(message):
|
def status(message: str) -> None:
|
||||||
"""Status-bar message with the plugin prefix"""
|
"""Status-bar message with the plugin prefix"""
|
||||||
sublime.status_message(f"vDiff: {message}")
|
sublime.status_message(f"vDiff: {message}")
|
||||||
|
|
||||||
|
|
||||||
def run_async(work, done):
|
def run_async(work: Callable[[], Any], done: Callable[[Any], None]) -> None:
|
||||||
"""Run `work()` on sublime's worker thread, hand the result to `done` on the main thread"""
|
"""Run `work()` on sublime's worker thread, hand the result to `done` on the main thread"""
|
||||||
|
|
||||||
def job():
|
def job() -> None:
|
||||||
try:
|
try:
|
||||||
result = work()
|
result = work()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
Reference in New Issue
Block a user