Compare commits
13 Commits
af8c1dd6df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c894d925a1 | ||
|
|
cf120991bc | ||
|
|
f18bad79cd | ||
|
|
2d9db745b0 | ||
|
|
904b941be7 | ||
|
|
fe915178c5 | ||
|
|
a2900682a0 | ||
|
|
9d04d8c711 | ||
|
|
413731df47 | ||
|
|
d6f0ed3009 | ||
|
|
54c69484d5 | ||
|
|
e24922828c | ||
|
|
725d04aeed |
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Keep the release archive to what Sublime actually loads. Package Control
|
||||
# installs whatever the tag's zip contains, and GitHub builds that zip with
|
||||
# git archive, which honours export-ignore.
|
||||
.claude/ export-ignore
|
||||
tests/ export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
diff.sublime-project export-ignore
|
||||
TODO.txt export-ignore
|
||||
channel.json export-ignore
|
||||
repository.json export-ignore
|
||||
@@ -1 +1 @@
|
||||
3.8
|
||||
3.14
|
||||
|
||||
@@ -3,34 +3,10 @@
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
|
||||
{ "keys": ["b"], "command": "vdiff_action", "args": {"action": "branches"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["j"], "command": "vdiff_action", "args": {"action": "line_down"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "line_up"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["m"], "command": "vdiff_action", "args": {"action": "amend"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
|
||||
"context": [{"key": "setting.vdiff_view"}] },
|
||||
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
|
||||
@@ -41,5 +17,69 @@
|
||||
{"key": "popup_visible", "operand": false},
|
||||
{"key": "auto_complete_visible", "operand": false},
|
||||
{"key": "num_selections", "operand": 1}
|
||||
] }
|
||||
] },
|
||||
|
||||
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["u"], "command": "vdiff_action", "args": {"action": "unstage"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["S"], "command": "vdiff_action", "args": {"action": "stage_all"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["U"], "command": "vdiff_action", "args": {"action": "unstage_all"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "discard"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "open"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["v"], "command": "vdiff_action", "args": {"action": "view"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["right"], "command": "vdiff_action", "args": {"action": "expand"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["left"], "command": "vdiff_action", "args": {"action": "collapse"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["z"], "command": "vdiff_action", "args": {"action": "fold_all"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["]"], "command": "vdiff_action", "args": {"action": "next_change"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["["], "command": "vdiff_action", "args": {"action": "prev_change"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["n"], "command": "vdiff_action", "args": {"action": "next_file"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["p"], "command": "vdiff_action", "args": {"action": "prev_file"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["j"], "command": "vdiff_action", "args": {"action": "line_down"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "line_up"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["C"], "command": "vdiff_action", "args": {"action": "commit"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
{ "keys": ["m"], "command": "vdiff_action", "args": {"action": "amend"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "diff"}] },
|
||||
|
||||
{ "keys": ["J"], "command": "vdiff_action", "args": {"action": "next_branch"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["K"], "command": "vdiff_action", "args": {"action": "prev_branch"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["j"], "command": "vdiff_action", "args": {"action": "next_commit"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["k"], "command": "vdiff_action", "args": {"action": "prev_commit"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["o"], "command": "vdiff_action", "args": {"action": "open_commit"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["v"], "command": "vdiff_action", "args": {"action": "open_commit"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["enter"], "command": "vdiff_action", "args": {"action": "checkout"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["shift+enter"], "command": "vdiff_action", "args": {"action": "checkout_detached"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["L"], "command": "vdiff_action", "args": {"action": "pull"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["P"], "command": "vdiff_action", "args": {"action": "push"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["F"], "command": "vdiff_action", "args": {"action": "fetch"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] },
|
||||
{ "keys": ["d"], "command": "vdiff_action", "args": {"action": "delete_branch"},
|
||||
"context": [{"key": "setting.vdiff_view"}, {"key": "vdiff_mode", "operand": "branches"}] }
|
||||
]
|
||||
|
||||
93
README.md
93
README.md
@@ -4,29 +4,114 @@ Review what a coding agent changed without leaving Sublime Text: the whole file,
|
||||
insertions in green, in the file's own syntax highlighting. Stage, unstage or discard the file, or jump
|
||||
to the real file at the caret's line.
|
||||
|
||||
Install: symlink this directory into `Packages` as `vDiff` (Python 3.8 plugin host, no dependencies).
|
||||
Needs Sublime Text build 4213 or later, the build that replaced the Python 3.8 plugin host with
|
||||
Python 3.14. No dependencies.
|
||||
|
||||
Command Palette:
|
||||
## Install with Package Control
|
||||
|
||||
vDiff is distributed through the shared channel at
|
||||
[vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not the default one. Open
|
||||
**Preferences → Package Settings → Package Control → Settings** and put the channel URL in the
|
||||
`channels` array of the right-hand (User) pane:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": [
|
||||
"https://packagecontrol.io/channel_v3.json",
|
||||
"https://raw.githubusercontent.com/vaclavt/sublime-chanel/main/channel.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Keep `https://packagecontrol.io/channel_v3.json` in the list — dropping it hides every default
|
||||
package. Then run **Package Control: Install Package** from the Command Palette and pick **vDiff**.
|
||||
Upgrades arrive through **Package Control: Upgrade Package** like any other package.
|
||||
|
||||
## Install from source
|
||||
|
||||
Clone or symlink the repository into the Sublime Text `Packages` directory as `vDiff`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vaclavt/vdiff.git \
|
||||
"$HOME/Library/Application Support/Sublime Text/Packages/vDiff"
|
||||
```
|
||||
|
||||
Use one method or the other, not both: an unpacked `Packages/vDiff` directory overrides the
|
||||
`vDiff.sublime-package` that Package Control installs.
|
||||
|
||||
## Command Palette
|
||||
|
||||
- **vDiff: Changed Files** – uncommitted changes (staged, unstaged, untracked). Arrowing through
|
||||
the list previews each file; Enter keeps it, Esc goes back to what was shown.
|
||||
- **vDiff: Recent Commits** – last N commits, then the files of the chosen commit. A key binding to
|
||||
`vdiff_commits` with `{"sha": "HEAD"}` skips the list and reviews the last commit directly.
|
||||
- **vDiff: Branches** – local branches and remotes on the left, the commits of the selected branch on
|
||||
the right; checkout, pull, push, fetch and delete from the keyboard.
|
||||
|
||||
Keys inside the review view:
|
||||
## Keys inside the review view
|
||||
|
||||
| key | action |
|
||||
|---|---|
|
||||
| `f` / `c` | changed files / recent commits panel |
|
||||
| `b` | the branches view |
|
||||
| `s` / `u` / `d` | stage / unstage / discard the file (uncommitted mode); `s` then moves on to the next unstaged file |
|
||||
| `S` / `U` | stage all unstaged files / unstage all staged files |
|
||||
| `C` | commit the staged files, typing the message in an input panel |
|
||||
| `m` | amend the last commit: edit its subject, keep its body, fold in anything staged |
|
||||
| `o` or Enter | open the real file at this line |
|
||||
| `]` / `[` | next / previous change (wraps at the ends) |
|
||||
| `n` / `p` | next / previous file of the current list (wraps at the ends) |
|
||||
| `v` | switch between the file-by-file and all-files views |
|
||||
| `left` / `right` | collapse / expand the file under the caret in the all-files view |
|
||||
| `z` | collapse all files in the all-files view, or expand them all when every one is collapsed |
|
||||
| `j` / `k` | move down / up one line (vim keys) |
|
||||
| `r` | refresh |
|
||||
| `?` | show this key list in a popup |
|
||||
| Esc | close the view |
|
||||
|
||||
The header links do the same with the mouse. Settings: `vDiff.sublime-settings`.
|
||||
The header links do the same with the mouse.
|
||||
|
||||
## Keys inside the branches view
|
||||
|
||||
`b` turns the review view into the branches view: local branches and remotes in the left column, the
|
||||
last N commits (`commits_count`) of the selected branch in the right one, each with its author, date,
|
||||
tags and diffstat. A local branch shows how it stands against its upstream on the right of its cell:
|
||||
`↑2` commits ahead, `↓1` behind, `gone` when the upstream was deleted, nothing when level or untracked.
|
||||
The right column starts with the working tree: how many files are staged and unstaged (a partially
|
||||
staged file counts in both) and their diffstat, or `No Changes`. The selected branch is the tinted cell;
|
||||
the caret marks a commit, or that entry.
|
||||
|
||||
| key | action |
|
||||
|---|---|
|
||||
| `J` / `K` | select the next / previous branch, local ones first, then each remote's |
|
||||
| `j` / `k` | move to the next / previous commit |
|
||||
| Enter | checkout the selected branch; a remote branch becomes a local branch of the same name tracking it (an existing one is reused) |
|
||||
| `shift+enter` | checkout a remote branch detached |
|
||||
| `L` | pull: `git pull` on the checked-out branch, a fast-forward from the upstream for any other local branch |
|
||||
| `P` | push to the upstream, or with `-u` to the branch's remote (origin by default) when it has none |
|
||||
| `F` | fetch all remotes and prune the branches they dropped |
|
||||
| `d` | delete the selected local branch, asking first (and again when it is not fully merged) |
|
||||
| `o` or `v` | review the commit under the caret, or the changed files when it is on the working-tree entry |
|
||||
| `f` / `c` | back to the changed files / recent commits; Esc in that panel returns here |
|
||||
| `r` / `b` | reload |
|
||||
| `?` | show this key list in a popup |
|
||||
| Esc | close the view |
|
||||
|
||||
Pull, push and fetch run without a terminal, so credentials must come from a credential helper or an
|
||||
ssh agent: git fails at once instead of waiting for a password. The selected branch takes its tint from
|
||||
`file_header_background`; the -/+ counts and tags use `file_header_deleted_foreground` and
|
||||
`file_header_inserted_foreground`, after the restart that loads their colour-scheme rules. The view reloads when it comes back into focus after a checkout, commit,
|
||||
fetch or branch change was made elsewhere. A remote whose name contains a slash is listed wrongly. The all-files view shows five context lines around each change by default; configure this with `all_files_context` in `vDiff.sublime-settings`. The same file sets the background of deleted and inserted lines (`deleted_line_background`, `inserted_line_background`) and the colours of the all-files line-number column (`line_numbers_foreground`, `line_numbers_background`); left empty, they follow the colour scheme. Each all-files file heading shows the file's status letter (`s` when anything is staged, else `m`, `d`, `a`, `?` ...) before its -/+ counts, and takes its colours from `file_header_background`, `file_header_status_foreground`, `file_header_deleted_foreground` and `file_header_inserted_foreground`.
|
||||
|
||||
## The channel
|
||||
|
||||
`repository.json` in this repository is a Package Control **repository** file: its top-level key
|
||||
is `packages`, and it describes vDiff itself — where the code lives and which releases exist.
|
||||
`"tags": true` means Package Control reads the repository's git tags, so publishing a release is
|
||||
just `git tag v1.0.1 && git push --tags`. A package whose tags are all unpushed resolves to zero
|
||||
releases, and Package Control drops it from the list without an error — if vDiff is missing from
|
||||
**Install Package**, check the tags reached GitHub.
|
||||
|
||||
The channel itself — the `channels`-array file listing `repository.json` for vDiff and vaclavt's
|
||||
other packages — lives in [vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not
|
||||
here. Add a package there instead of in this repository.
|
||||
|
||||
28
TODO.txt
28
TODO.txt
@@ -1,30 +1,18 @@
|
||||
macbook: claude --resume 275357c1-ce75-41de-a85a-4e06288bc8f0
|
||||
|
||||
macstudio: claude --resume ca7e7e5d-0ad3-45e5-bd67-e5dbd3af1b4c
|
||||
## Bugs
|
||||
- In Commits list commit message can be too long to move changes stats too far to the right so its not visible or is partially visible only
|
||||
|
||||
|
||||
## New functionality
|
||||
- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?)
|
||||
|
||||
Add amend action. Similar to commit but commit --amend. When user has chosen amend open commit message input field with last commit message and cursor on the end of message and user can change commit message or confirm it by pressing Enter. When User presses Esc the commmit amend is canceled.
|
||||
Amend action is available under key "m"
|
||||
|
||||
|
||||
|
||||
- in Files in first line there is 2 staged, 2 unstaged - add there deletions and insertions
|
||||
|
||||
- Add tags into commits list (the branches view shows them already)
|
||||
- Add stash action ..
|
||||
|
||||
- Add (commit) summary view (just changes across files with 5 lines before and after change?)
|
||||
|
||||
## Improvements
|
||||
- commit messages are oneline. What are options for multiline editing?
|
||||
- visual bell blinking time setting
|
||||
|
||||
- When reviewing changes in python files the deleted and inserted lines have red and green background but the text on these lines has no syntax highlighting (it's white on darker theme). How difficult would be to add syntax highlighting to changed lines and keep red and green background?
|
||||
(tady full syntax coloring je hard, nabizi se barevnej gutter, nebo barevne podtrzeni)
|
||||
|
||||
- v detailu komitu when pressing n and p the second line is nonsence
|
||||
|
||||
- add type hints
|
||||
|
||||
- add tags into commits list (and branches?)
|
||||
|
||||
- commit messages are oneline. What are options for multiline editing?
|
||||
|
||||
## Code
|
||||
|
||||
11
main.py
11
main.py
@@ -1,20 +1,20 @@
|
||||
""" vDiff plugin entry point: review uncommitted changes and recent commits inside Sublime Text
|
||||
"""vDiff plugin entry point: review uncommitted changes, recent commits and branches inside Sublime Text
|
||||
|
||||
Sublime reloads a plugin only when this file changes; after editing a submodule, save this file to reload everything.
|
||||
Sublime reloads a plugin only when this file changes, so save it after editing a submodule
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import sublime
|
||||
|
||||
# sublime reloads only this file: drop the submodules so an edited one is re-imported below.
|
||||
# Open review views lose their in-memory state and are rebuilt from their settings by _restore_all.
|
||||
# drop the submodules so an edited one is re-imported; open views are rebuilt from settings by _restore_all
|
||||
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
|
||||
del sys.modules[_name]
|
||||
|
||||
# imported for sublime's command discovery, not called here
|
||||
# command and listener classes are imported for sublime's discovery
|
||||
from .vdiff.commands import (
|
||||
VdiffActionCommand,
|
||||
VdiffBranchesCommand,
|
||||
VdiffCommitsCommand,
|
||||
VdiffFilesCommand,
|
||||
restore_view,
|
||||
@@ -35,6 +35,7 @@ def plugin_loaded():
|
||||
|
||||
|
||||
def _restore_all():
|
||||
"""Rebuild every review view, in every window, that has no in-memory state yet"""
|
||||
for window in sublime.windows():
|
||||
for view in window.views():
|
||||
if state.is_review_view(view) and state.get(view) is None:
|
||||
|
||||
21
repository.json
Normal file
21
repository.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "sublime://packagecontrol.io/schemas/repository",
|
||||
"schema_version": "4.0.0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "vDiff",
|
||||
"details": "https://github.com/vaclavt/vdiff",
|
||||
"description": "Review uncommitted changes and commits in Sublime Text: whole files with deletions in red and insertions in green, in the file's own syntax highlighting",
|
||||
"author": "vaclavt",
|
||||
"labels": ["git", "diff", "review", "vcs"],
|
||||
"releases": [
|
||||
{
|
||||
"sublime_text": ">=4213",
|
||||
"platforms": ["*"],
|
||||
"python_versions": ["3.14"],
|
||||
"tags": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,47 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# tests import the package directly; sublime/sublime_plugin come from tests/stubs
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
sys.path.insert(0, os.path.join(HERE, "stubs"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scheme_override_sandbox(tmp_path, monkeypatch):
|
||||
"""Keep the colour-scheme override palette.py writes inside the test's own tmp_path
|
||||
|
||||
Both caches are per session and every stub view shares one id, so they would otherwise leak and
|
||||
make override assertions depend on test order.
|
||||
"""
|
||||
import sublime
|
||||
|
||||
from vdiff import palette, state
|
||||
|
||||
palette.reset()
|
||||
monkeypatch.setattr(state, "STATES", {})
|
||||
monkeypatch.setattr(sublime, "packages_path", lambda: str(tmp_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rings(monkeypatch):
|
||||
"""Record visual bells instead of drawing them"""
|
||||
import sublime # noqa: F401 (the stub must be importable before vdiff.render)
|
||||
|
||||
from vdiff import render
|
||||
|
||||
bells = []
|
||||
monkeypatch.setattr(render, "flash", lambda view: bells.append(view))
|
||||
return bells
|
||||
|
||||
|
||||
def put_caret(view, row):
|
||||
"""Move the stub view's caret to the start of `row` (0 is the first row below the header)"""
|
||||
import sublime
|
||||
|
||||
from vdiff import render
|
||||
|
||||
view.sel().clear()
|
||||
view.sel().add(sublime.Region(render.index_point(view, row)))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
""" Minimal sublime API stub so the plugin modules import and header/regions logic runs offline """
|
||||
import re
|
||||
|
||||
OP_EQUAL = 0
|
||||
OP_NOT_EQUAL = 1
|
||||
@@ -35,6 +36,10 @@ class Region:
|
||||
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):
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, key, default)
|
||||
@@ -65,6 +70,16 @@ def status_message(text):
|
||||
pass
|
||||
|
||||
|
||||
# what ok_cancel_dialog answers; a test flips it to model Cancel
|
||||
dialog_answer = True
|
||||
dialogs = []
|
||||
|
||||
|
||||
def ok_cancel_dialog(message, ok_title="", title=""):
|
||||
dialogs.append((message, ok_title))
|
||||
return dialog_answer
|
||||
|
||||
|
||||
def packages_path():
|
||||
return "/tmp/Packages"
|
||||
|
||||
@@ -109,6 +124,28 @@ class Window:
|
||||
return self.input_view
|
||||
|
||||
|
||||
# a bundled scheme's styles: diff.* and region.* defined, the plugin's vdiff.* ones not. Nothing
|
||||
# simulates sublime merging an override, so a test wanting those to resolve adds them itself.
|
||||
DEFAULT_FG = "#ffffff"
|
||||
DEFAULT_STYLES = {
|
||||
"diff.deleted": {"foreground": DEFAULT_FG, "background": "#4a2b2b"},
|
||||
"diff.deleted.char": {"foreground": DEFAULT_FG, "background": "#6a3535"},
|
||||
"diff.inserted": {"foreground": DEFAULT_FG, "background": "#2b4a3a"},
|
||||
"diff.inserted.char": {"foreground": DEFAULT_FG, "background": "#356a4a"},
|
||||
"region.yellowish": {"foreground": "#e6db74", "background": "#4a4a2b"},
|
||||
"markup.deleted": {"foreground": "#d64646"},
|
||||
"markup.inserted": {"foreground": "#46d646"},
|
||||
# a few of palette.PALETTE_PROBES, so the palette probe finds a working set of colours
|
||||
"source": {"foreground": DEFAULT_FG},
|
||||
"comment": {"foreground": "#7c8595"},
|
||||
"string": {"foreground": "#99c794"},
|
||||
"keyword": {"foreground": "#c594c5"},
|
||||
"constant.numeric": {"foreground": "#f99157"},
|
||||
"entity.name.function": {"foreground": "#6699cc"},
|
||||
"punctuation.definition": {"foreground": "#5fb3b3"},
|
||||
}
|
||||
|
||||
|
||||
class View:
|
||||
"""Text buffer with just the calls render.py makes"""
|
||||
|
||||
@@ -119,12 +156,17 @@ class View:
|
||||
self.regions = {}
|
||||
self.syntax = None
|
||||
self.read_only = True
|
||||
self.changes = 0
|
||||
self.name_ = ""
|
||||
self.sel_ = []
|
||||
self.status = {}
|
||||
self.commands = []
|
||||
self.popup = None
|
||||
self.popup_navigate = None
|
||||
# what style_for_scope answers; a test reshapes it to model another colour scheme
|
||||
self.styles = {scope: dict(style) for scope, style in DEFAULT_STYLES.items()}
|
||||
# whether sublime has merged the override vdiff writes; it only does so on a restart
|
||||
self.override_loaded = False
|
||||
|
||||
def id(self):
|
||||
return 1
|
||||
@@ -137,6 +179,10 @@ class View:
|
||||
|
||||
def replace(self, edit, region, text):
|
||||
self.text = self.text[: region.begin()] + text + self.text[region.end() :]
|
||||
self.changes += 1
|
||||
|
||||
def change_count(self):
|
||||
return self.changes
|
||||
|
||||
def run_command(self, name, args=None):
|
||||
if name == "vdiff_replace":
|
||||
@@ -193,9 +239,19 @@ class View:
|
||||
def set_status(self, key, value):
|
||||
self.status[key] = value
|
||||
|
||||
def window(self):
|
||||
# detached: the all-files view then skips tokenising in a hidden panel
|
||||
return None
|
||||
|
||||
def viewport_position(self):
|
||||
return (0, 0)
|
||||
|
||||
def set_viewport_position(self, xy, animate=True):
|
||||
pass
|
||||
|
||||
def show(self, point):
|
||||
pass
|
||||
|
||||
def show_at_center(self, point):
|
||||
pass
|
||||
|
||||
@@ -211,8 +267,38 @@ class View:
|
||||
def em_width(self):
|
||||
return 8.0
|
||||
|
||||
def style(self):
|
||||
# the scheme's globals; no background here, so palette falls back as it did before this was stubbed
|
||||
return {"line_highlight": "#3b4350"}
|
||||
|
||||
def line(self, point):
|
||||
region = self.full_line(point)
|
||||
end = region.b - 1 if self.text[region.b - 1:region.b] == "\n" else region.b
|
||||
return Region(region.a, end)
|
||||
|
||||
def viewport_extent(self):
|
||||
# 100 columns at the stub's em width
|
||||
return (800.0, 600.0)
|
||||
|
||||
def extract_tokens_with_scopes(self, region):
|
||||
"""One token per run of word characters and one per run of anything else, scoped by its shape
|
||||
|
||||
Enough for the colouring tests: real sublime returns the syntax's tokens, but the painter only
|
||||
needs adjacent tokens to differ and each to map to a coloured scope.
|
||||
"""
|
||||
tokens = []
|
||||
text = self.text[region.a:region.b]
|
||||
for match in re.finditer(r"\w+|\s+|[^\w\s]+", text):
|
||||
scope = "keyword" if match.group().isidentifier() else "punctuation.definition"
|
||||
tokens.append((Region(region.a + match.start(), region.a + match.end()), scope))
|
||||
return tokens
|
||||
|
||||
def style_for_scope(self, scope):
|
||||
return {"foreground": "#ffffff", "background": "#333333"}
|
||||
# a fresh dict per call, and no background for an unknown scope, like the real API
|
||||
if self.override_loaded and scope.startswith("vdiff.") and scope not in self.styles:
|
||||
# sublime has merged the override, so every rule vdiff wrote in it resolves
|
||||
return {"foreground": DEFAULT_FG, "background": "#333333"}
|
||||
return dict(self.styles.get(scope, {"foreground": DEFAULT_FG}))
|
||||
|
||||
def is_valid(self):
|
||||
return True
|
||||
|
||||
763
tests/test_branches.py
Normal file
763
tests/test_branches.py
Normal file
@@ -0,0 +1,763 @@
|
||||
import pytest
|
||||
|
||||
import sublime # the stub, via conftest
|
||||
|
||||
from conftest import put_caret # noqa: E402
|
||||
from test_render import header, with_loaded_override # noqa: E402
|
||||
from vdiff import branches, commands, git, listeners, palette, render, state
|
||||
|
||||
|
||||
def ref(name, remote=None, upstream=None, ahead=0, behind=0, gone=False, current=False, sha="abc1234", upstream_remote=""):
|
||||
"""A Ref; the upstream's remote is read off its name unless given (None for a local upstream)"""
|
||||
branch = name.partition("/")[2] if remote else name
|
||||
if upstream_remote == "":
|
||||
upstream_remote = upstream.partition("/")[0] if upstream else None
|
||||
return git.Ref(name, branch, remote, sha, upstream, ahead, behind, gone, current, upstream_remote)
|
||||
|
||||
|
||||
def commit(subject, tags=(), files=8, insertions=260, deletions=58, sha="1" * 40):
|
||||
return git.Commit(sha, sha[:7], "vtvrdik", "20.12.2025", subject, files, insertions, deletions, tuple(tags))
|
||||
|
||||
|
||||
REFS = [
|
||||
ref("feature"),
|
||||
ref("main", upstream="origin/main", ahead=1, current=True),
|
||||
ref("github/main", remote="github"),
|
||||
ref("origin/main", remote="origin"),
|
||||
]
|
||||
COMMITS = [
|
||||
commit("default file view mode and some colors now configurable in settings", tags=("v1.1.0",), sha="a" * 40),
|
||||
commit("next commit message", files=None, insertions=None, deletions=None, sha="b" * 40),
|
||||
]
|
||||
|
||||
|
||||
def make_state(refs=REFS, commits=COMMITS, selected="main", remotes=("origin", "github")):
|
||||
st = state.ViewState("/repo/vdiff")
|
||||
st.mode = state.MODE_BRANCHES
|
||||
st.branch_refs = list(refs)
|
||||
st.branch_remotes = list(remotes)
|
||||
st.branch_commits = list(commits)
|
||||
st.branch_ref = selected
|
||||
st.branch_head_sha = "abc1234"
|
||||
return st
|
||||
|
||||
|
||||
def rendered(st=None, live=False):
|
||||
"""A drawn branches view; `live` models a scheme that has picked up the override, as after a restart"""
|
||||
view = with_loaded_override(sublime.View()) if live else sublime.View()
|
||||
render.init_view(view)
|
||||
st = st or make_state()
|
||||
branches.render_branches(view, st)
|
||||
return view, st
|
||||
|
||||
|
||||
def rows(view):
|
||||
return view.text.split("\n")[1:]
|
||||
|
||||
|
||||
class Calls:
|
||||
"""Records the git helper calls an action makes; `answer` is what a worker returns"""
|
||||
|
||||
def __init__(self, monkeypatch, answer=None):
|
||||
self.calls = []
|
||||
self.answer = answer
|
||||
for name in ("checkout", "checkout_detached", "pull", "fetch_into", "push", "fetch_all", "delete_branch"):
|
||||
monkeypatch.setattr(branches.git, name, self._record(name))
|
||||
self.loads = record_loads(monkeypatch)
|
||||
|
||||
def _record(self, name):
|
||||
def call(*args, **kwargs):
|
||||
self.calls.append((name, args[1:], kwargs))
|
||||
if isinstance(self.answer, Exception):
|
||||
raise self.answer
|
||||
return self.answer
|
||||
|
||||
return call
|
||||
|
||||
|
||||
def record_loads(monkeypatch):
|
||||
"""Replace branches.load with a recorder of (branch, mode, message, keep_caret) per call"""
|
||||
loads = []
|
||||
|
||||
def fake_load(view, st, message=None, keep_caret=False):
|
||||
loads.append((st.branch_ref, st.mode, message, keep_caret))
|
||||
|
||||
monkeypatch.setattr(branches, "load", fake_load)
|
||||
return loads
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calls(monkeypatch):
|
||||
return Calls(monkeypatch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loads(monkeypatch):
|
||||
return record_loads(monkeypatch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialogs(monkeypatch):
|
||||
monkeypatch.setattr(sublime, "dialogs", [])
|
||||
monkeypatch.setattr(sublime, "dialog_answer", True)
|
||||
return sublime.dialogs
|
||||
|
||||
|
||||
# --- drawing ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_two_columns_are_laid_out_as_in_the_mockup():
|
||||
view, st = rendered()
|
||||
lines = rows(view)
|
||||
# the left column is as wide as its longest cell plus the padding; the gap follows it
|
||||
width = len(" feature") + branches.LEFT_PAD
|
||||
assert all(line[width:width + 2] == ("├─" if line.endswith("─") else "│ ") for line in lines)
|
||||
right = 100 - width - 2
|
||||
# the working tree comes first, five rows like a commit; nothing changed here
|
||||
assert lines[0] == f"{'BRANCHES':<{width}}│ No Changes"
|
||||
assert lines[1] == f"{' feature':<{width}}│ "
|
||||
# main is one commit ahead of its upstream: the count sits on the right edge of its cell
|
||||
assert lines[2] == f"{'* main':<{width - 3}}↑1 │ "
|
||||
assert lines[3] == f"{'REMOTES':<{width}}│ "
|
||||
assert lines[4] == f"{'origin':<{width}}├─" + "─" * right
|
||||
assert lines[5] == f"{' main':<{width}}│ default file view mode and some colors now configurable in settings"
|
||||
assert lines[6].startswith(f"{'github':<{width}}│ Author: vtvrdik")
|
||||
# the tag sits on the right edge of the author row
|
||||
assert lines[6].endswith("v1.1.0") and len(lines[6]) == width + 2 + right
|
||||
assert lines[7] == f"{' main':<{width}}│ Date: 20.12.2025"
|
||||
# the left column ran out, so the cells are blank
|
||||
assert lines[8] == f"{'':<{width}}│ Stats: 8 files changed -58 +260"
|
||||
assert lines[9] == f"{'':<{width}}├─" + "─" * right
|
||||
assert lines[10] == f"{'':<{width}}│ next commit message"
|
||||
assert lines[11].rstrip() == f"{'':<{width}}│ Author: vtvrdik"
|
||||
# no diffstat for a merge or an empty commit
|
||||
assert lines[13] == f"{'':<{width}}│ Stats: (no diffstat)"
|
||||
assert lines[14] == f"{'':<{width}}├─" + "─" * right
|
||||
assert len(lines) == 15
|
||||
assert view.syntax == "scope:text.plain"
|
||||
assert view.name_ == "⟐ branches"
|
||||
|
||||
|
||||
CHANGES = [
|
||||
git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True),
|
||||
git.FileChange("b.txt", "M", True, 1, 0, None),
|
||||
git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True), # partially staged: counted on both sides
|
||||
git.FileChange("img.png", "M", True, None, None, None), # binary: no counts
|
||||
]
|
||||
|
||||
|
||||
def test_the_working_tree_entry_counts_the_staged_and_unstaged_files_and_their_diffstat():
|
||||
st = make_state()
|
||||
st.branch_changes = list(CHANGES)
|
||||
view, st = rendered(st)
|
||||
lines = rows(view)
|
||||
width = len(" feature") + branches.LEFT_PAD
|
||||
assert lines[0] == f"{'BRANCHES':<{width}}│ 3 staged files, 2 unstaged files"
|
||||
assert lines[1] == f"{' feature':<{width}}│ Stats: 4 files changed -1 +4"
|
||||
assert lines[2] == f"{'* main':<{width - 3}}↑1 │ " and lines[3] == f"{'REMOTES':<{width}}│ "
|
||||
assert lines[4].endswith("─" * 10) and lines[5].endswith("│ default file view mode and some colors now configurable in settings")
|
||||
# its counts are coloured like the commits', in row order
|
||||
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_DEL][0]] == ["-1", "-58"]
|
||||
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["+4", "v1.1.0", "+260"]
|
||||
# the singular
|
||||
st.branch_changes = [CHANGES[0]]
|
||||
branches.render_branches(view, st)
|
||||
assert rows(view)[0] == f"{'BRANCHES':<{width}}│ 0 staged files, 1 unstaged file"
|
||||
st.branch_changes = [CHANGES[1]]
|
||||
branches.render_branches(view, st)
|
||||
assert rows(view)[0] == f"{'BRANCHES':<{width}}│ 1 staged file, 0 unstaged files"
|
||||
|
||||
|
||||
def test_local_branches_show_their_standing_against_the_upstream():
|
||||
refs = [ref("level", upstream="origin/level"), ref("ahead", upstream="origin/ahead", ahead=3, current=True),
|
||||
ref("both", upstream="origin/both", ahead=12, behind=4), ref("lost", upstream="origin/lost", gone=True),
|
||||
ref("untracked"), ref("origin/level", remote="origin")]
|
||||
view, st = rendered(make_state(refs=refs, selected="ahead", remotes=("origin",)))
|
||||
width = len(" both ↑12 ↓4") + branches.LEFT_PAD
|
||||
cells = [line[:width] for line in rows(view)]
|
||||
# in the listing's order; level and untracked branches carry nothing, nor do remote ones
|
||||
assert cells[1:6] == [f"{' level':<{width}}", f"{'* ahead':<{width - 3}}↑3 ", f"{' both':<{width - 7}}↑12 ↓4 ",
|
||||
f"{' lost':<{width - 5}}gone ", f"{' untracked':<{width}}"]
|
||||
assert cells[7] == f"{'origin':<{width}}" and cells[8] == f"{' level':<{width}}"
|
||||
# ahead is green, behind and gone red; the selected branch's standing is painted apart, on its background
|
||||
def painted(key):
|
||||
"""The coloured texts under a key"""
|
||||
return [view.text[r.a:r.b] for r in view.regions[key][0]]
|
||||
|
||||
assert painted(render.KEY_BRANCH_STANDING_INS) == ["↑12"] and painted(render.KEY_BRANCH_STANDING_DEL) == ["↓4", "gone"]
|
||||
assert painted(render.KEY_BRANCH_SEL_INS) == ["↑3"] and painted(render.KEY_BRANCH_SEL_DEL) == []
|
||||
# moving the selection moves the standing between the keys
|
||||
branches.ACTIONS["next_branch"](view, None, st)
|
||||
assert st.branch_ref == "both"
|
||||
assert painted(render.KEY_BRANCH_STANDING_INS) == ["↑3"] and painted(render.KEY_BRANCH_STANDING_DEL) == ["gone"]
|
||||
assert painted(render.KEY_BRANCH_SEL_INS) == ["↑12"] and painted(render.KEY_BRANCH_SEL_DEL) == ["↓4"]
|
||||
# a long name gives way to its counts, never the other way round
|
||||
long = ref("feature/" + "x" * 60, upstream="origin/x", behind=2)
|
||||
view, st = rendered(make_state(refs=[ref("main", current=True), long], selected="main"))
|
||||
cell = rows(view)[2][:branches.LEFT_MAX]
|
||||
assert cell.endswith("↓2 ") and "…" in cell and len(cell) == branches.LEFT_MAX
|
||||
|
||||
|
||||
def test_rows_know_their_branch_and_commit():
|
||||
view, st = rendered()
|
||||
# item 0 is the working tree, the commits follow from 1
|
||||
assert [(r.ref and r.ref.name, r.commit) for r in st.branch_rows] == [
|
||||
(None, 0), ("feature", 0), ("main", 0), (None, 0), (None, 0),
|
||||
("origin/main", 1), (None, 1), ("github/main", 1), (None, 1), (None, 1),
|
||||
(None, 2), (None, 2), (None, 2), (None, 2), (None, 2),
|
||||
]
|
||||
|
||||
|
||||
def test_a_remote_without_fetched_branches_keeps_its_row():
|
||||
view, st = rendered(make_state(refs=[ref("main", current=True)], remotes=("origin", "gitea")))
|
||||
names = [line[:st.branch_width].rstrip() for line in rows(view)]
|
||||
assert names[:5] == ["BRANCHES", "* main", "REMOTES", "origin", "gitea"]
|
||||
|
||||
|
||||
def test_long_branch_names_are_clipped_at_the_cap():
|
||||
long = "feature/" + "x" * 60
|
||||
view, st = rendered(make_state(refs=[ref("main", current=True), ref(long)], selected="main"))
|
||||
width = branches.LEFT_MAX
|
||||
assert rows(view)[2].startswith(f"{' ' + long[:width - 4]}…" + " │ ") or rows(view)[2][width:width + 2] == "│ "
|
||||
assert rows(view)[2][:width].rstrip().endswith("…")
|
||||
|
||||
|
||||
def test_selection_headings_tags_and_counts_are_painted():
|
||||
view, st = rendered()
|
||||
width = len(" feature") + branches.LEFT_PAD
|
||||
starts = render._row_starts_for_text(rows(view))
|
||||
selected, scope, _, _ = view.regions[render.KEY_BRANCH_SEL]
|
||||
assert selected == [sublime.Region(starts[2], starts[2] + width)]
|
||||
assert scope == render._file_header_scope(view)
|
||||
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_DEL][0]] == ["-58"]
|
||||
# the tags are green like the insertions, in row order; main's ↑1 sits on the selected row
|
||||
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["v1.1.0", "+260"]
|
||||
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_SEL_INS][0]] == ["↑1"]
|
||||
assert view.regions[render.KEY_BRANCH_STANDING_INS][0] == []
|
||||
# filled regions, since only those take the scope's text colour; the scheme's tint stands in until the
|
||||
# override's red/green-on-background rules are live, after which the text is red / green on the editor
|
||||
# background, or on the heading background along the selected row so its tint runs through the standing
|
||||
assert not view.regions[render.KEY_BRANCH_DEL][3] & sublime.DRAW_NO_FILL
|
||||
assert view.regions[render.KEY_BRANCH_DEL][1] == palette.SCOPE_DEL
|
||||
assert view.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_INS
|
||||
assert view.regions[render.KEY_BRANCH_SEL_INS][1] == palette.SCOPE_INS
|
||||
live, _ = rendered(live=True)
|
||||
assert live.regions[render.KEY_BRANCH_DEL][1] == palette.SCOPE_TEXT_DEL
|
||||
assert live.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_TEXT_INS
|
||||
assert live.regions[render.KEY_BRANCH_STANDING_DEL][1] == palette.SCOPE_TEXT_DEL
|
||||
assert live.regions[render.KEY_BRANCH_SEL_DEL][1] == palette.SCOPE_FILE_DEL_COUNT
|
||||
assert live.regions[render.KEY_BRANCH_SEL_INS][1] == palette.SCOPE_FILE_INS_COUNT
|
||||
|
||||
|
||||
def test_caret_starts_on_the_working_tree_entry_and_stays_on_its_item_across_a_refresh():
|
||||
view, st = rendered()
|
||||
assert view.sel()[0].a == render.index_point(view, 0) and st.branch_rows[0].commit == branches.WORKTREE
|
||||
branches.ACTIONS["next_commit"](view, None, st)
|
||||
assert render.caret_index(view) == branches.ROWS_PER_COMMIT
|
||||
branches.render_branches(view, st, keep_caret=True)
|
||||
assert render.caret_index(view) == branches.ROWS_PER_COMMIT
|
||||
# a plain redraw goes back to the top
|
||||
branches.render_branches(view, st)
|
||||
assert render.caret_index(view) == 0
|
||||
|
||||
|
||||
def test_no_commits_and_no_branches_still_draw():
|
||||
view, st = rendered(make_state(refs=[], commits=[], selected=None, remotes=()))
|
||||
rule = "─" * (100 - len("BRANCHES │ "))
|
||||
assert rows(view) == ["BRANCHES │ No Changes", "REMOTES │ ", " │ ", " │ ", " ├─" + rule,
|
||||
" │ (no commits)"]
|
||||
# the one item to step through is the working tree
|
||||
assert view.sel()[0].a == render.index_point(view, 0)
|
||||
branches.ACTIONS["next_commit"](view, None, st)
|
||||
assert render.caret_index(view) == 0
|
||||
assert view.regions[render.KEY_BRANCH_SEL][0] == []
|
||||
assert "no branch selected" in header(view)
|
||||
|
||||
|
||||
def test_render_sets_the_mode_and_persists_the_branch():
|
||||
view, st = rendered()
|
||||
assert view.settings().get(state.MODE_SETTING) == "branches"
|
||||
assert view.settings().get(state.VIEW_DATA) == {"repo": "/repo/vdiff", "sha": None, "mode": "branches", "ref": "main"}
|
||||
assert view.status[render.STATUS_KEY] == render.key_hints("branches")
|
||||
assert st.fingerprint == ((), (None,)) # no ref paths and no index outside a repository
|
||||
# a diff render switches back
|
||||
st.change = git.FileChange("x.py", "M", False, 1, 1, None)
|
||||
render.render_message(view, st, "nothing")
|
||||
assert view.settings().get(state.MODE_SETTING) == "diff"
|
||||
assert "mode" not in view.settings().get(state.VIEW_DATA)
|
||||
assert view.status[render.STATUS_KEY] == render.key_hints()
|
||||
assert render.KEY_BRANCH_SEL not in view.regions
|
||||
|
||||
|
||||
def test_an_unchanged_header_is_not_redrawn():
|
||||
view, st = rendered()
|
||||
first = view.phantoms[render.PHANTOM_KEY][0]
|
||||
# the same header over the same text keeps the phantom in place
|
||||
render._header(view, st, st.header[0])
|
||||
assert view.phantoms[render.PHANTOM_KEY] == [first]
|
||||
# new text may have dropped the phantom, so it is drawn again even for the same header
|
||||
branches.render_branches(view, st, keep_caret=True)
|
||||
assert view.phantoms[render.PHANTOM_KEY][0] is not first and len(view.phantoms[render.PHANTOM_KEY]) == 1
|
||||
|
||||
|
||||
def test_header_shows_head_upstream_and_the_links_of_the_selected_kind():
|
||||
view, st = rendered()
|
||||
text = header(view)
|
||||
assert "Branches · vdiff" in text and ">main<" in text and "on <span" in text
|
||||
# the standing as the cell shows it: a zero count is left out
|
||||
assert "→ origin/main" in text and '<span class="ins">↑1</span>' in text and "↓0" not in text
|
||||
st.branch_refs[1] = ref("main", upstream="origin/main", behind=2, current=True)
|
||||
branches.render_branches(view, st)
|
||||
assert '<span class="del">↓2</span>' in header(view) and "↑0" not in header(view)
|
||||
st.branch_refs[1] = ref("main", upstream="origin/main", current=True)
|
||||
branches.render_branches(view, st)
|
||||
assert "up to date" in header(view)
|
||||
st.branch_refs[1] = REFS[1]
|
||||
branches.render_branches(view, st)
|
||||
for link in ("checkout", "pull", "push", "fetch", "delete_branch", "files", "commits", "branches", "help"):
|
||||
assert f'href="{link}"' in text
|
||||
assert 'href="checkout_detached"' not in text
|
||||
|
||||
st.branch_ref = "origin/main"
|
||||
branches.render_branches(view, st)
|
||||
text = header(view)
|
||||
assert "remote branch of origin" in text and 'href="checkout_detached"' in text
|
||||
assert 'href="delete_branch"' not in text and 'href="pull"' not in text
|
||||
|
||||
st.branch_ref = "feature"
|
||||
st.branch_refs[1] = ref("main", upstream="origin/main", ahead=1) # nothing checked out: detached
|
||||
branches.render_branches(view, st)
|
||||
text = header(view)
|
||||
assert "detached at" in text and "abc1234" in text and "→" not in text
|
||||
st.branch_head_sha = ""
|
||||
branches.render_branches(view, st)
|
||||
assert "no commits" in header(view)
|
||||
|
||||
st.branch_refs[0] = ref("feature", upstream="origin/feature", gone=True)
|
||||
branches.render_branches(view, st)
|
||||
assert ">gone<" in header(view)
|
||||
|
||||
|
||||
# --- navigation -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_j_and_k_step_commits_and_ring_at_the_ends(rings):
|
||||
view, st = rendered()
|
||||
# the working tree, then the two commits
|
||||
branches.ACTIONS["prev_commit"](view, None, st)
|
||||
assert len(rings) == 1 and render.caret_index(view) == 0
|
||||
branches.ACTIONS["next_commit"](view, None, st)
|
||||
assert render.caret_index(view) == 5
|
||||
branches.ACTIONS["next_commit"](view, None, st)
|
||||
assert render.caret_index(view) == 10
|
||||
branches.ACTIONS["next_commit"](view, None, st)
|
||||
assert len(rings) == 2 and render.caret_index(view) == 10
|
||||
branches.ACTIONS["prev_commit"](view, None, st)
|
||||
assert render.caret_index(view) == 5
|
||||
# off the items (the caret moved by the arrows onto a left-column-only row): k goes to the last, j to the first
|
||||
view, st = rendered(make_state(refs=REFS + [ref("x"), ref("y"), ref("z")], commits=COMMITS[:1]))
|
||||
assert st.branch_rows[10].commit == -1
|
||||
put_caret(view, 10)
|
||||
branches.ACTIONS["prev_commit"](view, None, st)
|
||||
assert render.caret_index(view) == 5
|
||||
put_caret(view, 10)
|
||||
branches.ACTIONS["next_commit"](view, None, st)
|
||||
assert render.caret_index(view) == 0
|
||||
|
||||
|
||||
def test_J_and_K_select_branches_skipping_headings_and_reload_the_log(monkeypatch, rings):
|
||||
logged = []
|
||||
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: logged.append(name) or [COMMITS[1]])
|
||||
view, st = rendered()
|
||||
assert st.branch_ref == "main"
|
||||
branches.ACTIONS["prev_branch"](view, None, st)
|
||||
assert st.branch_ref == "feature" and logged == ["feature"]
|
||||
branches.ACTIONS["prev_branch"](view, None, st)
|
||||
assert st.branch_ref == "feature" and len(rings) == 1
|
||||
for expected in ("main", "github/main", "origin/main"):
|
||||
branches.ACTIONS["next_branch"](view, None, st)
|
||||
assert st.branch_ref == expected
|
||||
branches.ACTIONS["next_branch"](view, None, st)
|
||||
assert st.branch_ref == "origin/main" and len(rings) == 2
|
||||
# the stub runs the worker at once, so the log was redrawn with the new selection
|
||||
assert rows(view)[5].endswith("│ next commit message")
|
||||
selected = view.regions[render.KEY_BRANCH_SEL][0]
|
||||
assert view.text[selected[0].a:selected[0].b].strip() == "main"
|
||||
# the caret went back to the working-tree entry
|
||||
assert render.caret_index(view) == 0 and st.branch_rows[0].commit == branches.WORKTREE
|
||||
|
||||
|
||||
def test_a_stale_log_is_dropped(monkeypatch):
|
||||
pending = []
|
||||
monkeypatch.setattr(branches, "run_async", lambda work, done: pending.append((work, done)))
|
||||
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: [commit(name)])
|
||||
view, st = rendered()
|
||||
branches.ACTIONS["prev_branch"](view, None, st) # feature
|
||||
branches.ACTIONS["next_branch"](view, None, st) # main
|
||||
assert [name for name in (st.branch_ref,)] == ["main"] and len(pending) == 2
|
||||
# the older answer lands first, but the token moved on
|
||||
work, done = pending[0]
|
||||
done(work())
|
||||
assert "│ feature" not in view.text
|
||||
work, done = pending[1]
|
||||
done(work())
|
||||
assert rows(view)[5].endswith("│ main")
|
||||
|
||||
|
||||
def test_load_lists_everything_and_picks_the_branch(monkeypatch):
|
||||
monkeypatch.setattr(branches.git, "head_sha", lambda repo: "abc1234")
|
||||
monkeypatch.setattr(branches.git, "ref_paths", lambda repo: ())
|
||||
monkeypatch.setattr(branches.git, "branches", lambda repo: list(REFS))
|
||||
monkeypatch.setattr(branches.git, "remotes", lambda repo: ["origin", "github"])
|
||||
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: [commit(f"log of {name}")])
|
||||
monkeypatch.setattr(branches.git, "index_path", lambda repo: None)
|
||||
monkeypatch.setattr(branches.git, "changed_files", lambda repo: [CHANGES[2]])
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
st = state.ViewState("/repo/vdiff")
|
||||
branches.load(view, st)
|
||||
# nothing wanted: the checked-out branch; the working tree above its log
|
||||
assert st.branch_ref == "main" and rows(view)[5].endswith("│ log of main")
|
||||
assert rows(view)[0].endswith("│ 1 staged file, 1 unstaged file") and rows(view)[1].endswith("│ Stats: 1 file changed -0 +2")
|
||||
assert st.branch_changes == [CHANGES[2]]
|
||||
assert st.branch_remotes == ["origin", "github"] and st.branch_head_sha == "abc1234"
|
||||
assert st.token == st.rendered_token
|
||||
# a wanted branch that vanished falls back the same way
|
||||
st.branch_ref = "deleted"
|
||||
branches.load(view, st)
|
||||
assert st.branch_ref == "main"
|
||||
st.branch_ref = "origin/main"
|
||||
branches.load(view, st)
|
||||
assert st.branch_ref == "origin/main" and rows(view)[5].endswith("│ log of origin/main")
|
||||
|
||||
|
||||
def test_pick_prefers_the_wanted_then_the_current_then_the_first_local_then_the_first():
|
||||
assert branches._pick(REFS, "origin/main").name == "origin/main"
|
||||
assert branches._pick(REFS, "gone").name == "main"
|
||||
detached = [ref("feature"), ref("zeta"), ref("origin/x", remote="origin")]
|
||||
assert branches._pick(detached, None).name == "feature"
|
||||
assert branches._pick([ref("origin/x", remote="origin")], None).name == "origin/x"
|
||||
assert branches._pick([], None) is None
|
||||
|
||||
|
||||
# --- actions ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_checkout_routes_local_current_and_remote_branches(calls):
|
||||
view, st = rendered()
|
||||
branches.ACTIONS["checkout"](view, None, st)
|
||||
assert calls.calls == [] and calls.loads == [] # already on main
|
||||
|
||||
st.branch_ref = "feature"
|
||||
branches.ACTIONS["checkout"](view, None, st)
|
||||
assert calls.calls[-1] == ("checkout", (REFS[0], ["feature", "main"]), {})
|
||||
assert calls.loads[-1] == ("feature", "branches", "checked out feature", False)
|
||||
|
||||
st.branch_ref = "github/main"
|
||||
branches.ACTIONS["checkout"](view, None, st)
|
||||
assert calls.calls[-1][1][0].name == "github/main"
|
||||
assert st.branch_ref == "main" # the local branch of that name is what got checked out
|
||||
|
||||
st.branch_ref = "origin/main"
|
||||
branches.ACTIONS["checkout_detached"](view, None, st)
|
||||
assert calls.calls[-1] == ("checkout_detached", ("origin/main",), {})
|
||||
assert calls.loads[-1][2] == "checked out origin/main detached"
|
||||
st.branch_ref = "feature"
|
||||
branches.ACTIONS["checkout_detached"](view, None, st)
|
||||
assert calls.calls[-1][0] == "checkout_detached" and len(calls.calls) == 3 # refused for a local branch
|
||||
|
||||
|
||||
def test_pull_pulls_the_current_branch_and_fast_forwards_another(calls):
|
||||
view, st = rendered(make_state(refs=REFS + [ref("tracked", upstream="origin/tracked", behind=2)]))
|
||||
branches.ACTIONS["pull"](view, None, st)
|
||||
assert calls.calls[-1] == ("pull", (), {}) and calls.loads[-1][2] == "pulled main"
|
||||
st.branch_ref = "tracked"
|
||||
branches.ACTIONS["pull"](view, None, st)
|
||||
assert calls.calls[-1] == ("fetch_into", ("origin", "tracked", "tracked"), {})
|
||||
assert calls.loads[-1][2] == "fast-forwarded tracked to origin/tracked"
|
||||
# the upstream's own name on its remote, not the local one
|
||||
st.branch_refs.append(ref("foo", upstream="origin/bar", behind=1))
|
||||
st.branch_ref = "foo"
|
||||
branches.ACTIONS["pull"](view, None, st)
|
||||
assert calls.calls[-1] == ("fetch_into", ("origin", "bar", "foo"), {})
|
||||
st.branch_refs.append(ref("local", upstream="main", upstream_remote=None, behind=1))
|
||||
for name in ("feature", "origin/main", "local"):
|
||||
st.branch_ref = name
|
||||
branches.ACTIONS["pull"](view, None, st)
|
||||
assert len(calls.calls) == 3 # no upstream, a remote row and a local upstream: all refused
|
||||
|
||||
|
||||
def test_push_uses_the_upstream_or_sets_one_on_origin(calls):
|
||||
view, st = rendered()
|
||||
branches.ACTIONS["push"](view, None, st)
|
||||
assert calls.calls[-1] == ("push", ("origin", "main"), {"remote_branch": "main"}) and calls.loads[-1][2] == "pushed main to origin"
|
||||
# to the upstream itself when it is called differently there
|
||||
st.branch_refs.append(ref("foo", upstream="gitea/bar", ahead=2))
|
||||
st.branch_ref = "foo"
|
||||
branches.ACTIONS["push"](view, None, st)
|
||||
assert calls.calls[-1] == ("push", ("gitea", "foo"), {"remote_branch": "bar"})
|
||||
st.branch_ref = "feature"
|
||||
branches.ACTIONS["push"](view, None, st)
|
||||
assert calls.calls[-1] == ("push", ("origin", "feature"), {"set_upstream": True})
|
||||
# without origin the first remote takes it; without any, nothing happens; nor for a local upstream
|
||||
st.branch_remotes = ["gitea"]
|
||||
branches.ACTIONS["push"](view, None, st)
|
||||
assert calls.calls[-1] == ("push", ("gitea", "feature"), {"set_upstream": True})
|
||||
st.branch_remotes = []
|
||||
branches.ACTIONS["push"](view, None, st)
|
||||
st.branch_ref = "origin/main"
|
||||
st.branch_remotes = ["origin"]
|
||||
branches.ACTIONS["push"](view, None, st)
|
||||
st.branch_refs.append(ref("local", upstream="main", upstream_remote=None, ahead=1))
|
||||
st.branch_ref = "local"
|
||||
branches.ACTIONS["push"](view, None, st)
|
||||
assert len(calls.calls) == 4
|
||||
|
||||
|
||||
def test_fetch_fetches_everything_and_keeps_the_cursor(calls):
|
||||
view, st = rendered()
|
||||
branches.ACTIONS["fetch"](view, None, st)
|
||||
assert calls.calls == [("fetch_all", (), {})] and calls.loads == [("main", "branches", "fetched all remotes", True)]
|
||||
|
||||
|
||||
def test_delete_asks_then_deletes_and_asks_again_for_an_unmerged_branch(calls, dialogs, monkeypatch):
|
||||
view, st = rendered()
|
||||
st.branch_ref = "feature"
|
||||
branches.ACTIONS["delete_branch"](view, None, st)
|
||||
assert dialogs == [("Delete the branch feature?", "Delete")]
|
||||
assert calls.calls == [("delete_branch", ("feature",), {})] and calls.loads == [("feature", "branches", "deleted feature", False)]
|
||||
|
||||
# not fully merged: a second question, then the forced deletion
|
||||
calls.answer = git.GitError("error: The branch 'feature' is not fully merged.\n", 1)
|
||||
del dialogs[:]
|
||||
branches.ACTIONS["delete_branch"](view, None, st)
|
||||
assert [d[1] for d in dialogs] == ["Delete", "Delete anyway"]
|
||||
assert calls.calls[-1] == ("delete_branch", ("feature",), {"force": True})
|
||||
|
||||
# Cancel on the first question does nothing; so does a remote or the checked-out branch
|
||||
monkeypatch.setattr(sublime, "dialog_answer", False)
|
||||
count = len(calls.calls)
|
||||
branches.ACTIONS["delete_branch"](view, None, st)
|
||||
st.branch_ref = "main"
|
||||
branches.ACTIONS["delete_branch"](view, None, st)
|
||||
st.branch_ref = "origin/main"
|
||||
branches.ACTIONS["delete_branch"](view, None, st)
|
||||
assert len(calls.calls) == count
|
||||
|
||||
|
||||
def test_other_git_errors_from_delete_still_reach_the_status_bar(calls, dialogs, monkeypatch):
|
||||
messages = []
|
||||
monkeypatch.setattr(branches, "status", lambda m: messages.append(m))
|
||||
monkeypatch.setattr("vdiff.utils.status", lambda m: messages.append(m))
|
||||
calls.answer = git.GitError("fatal: something else\n", 1)
|
||||
view, st = rendered()
|
||||
st.branch_ref = "feature"
|
||||
branches.ACTIONS["delete_branch"](view, None, st)
|
||||
assert messages[-1] == "fatal: something else" and calls.loads == []
|
||||
|
||||
|
||||
def test_o_reviews_the_commit_under_the_caret():
|
||||
view, st = rendered()
|
||||
window = sublime.Window()
|
||||
# the working-tree entry opens the files view, which itself stays put when there is nothing to list
|
||||
branches.ACTIONS["open_commit"](view, window, st)
|
||||
assert window.commands == [("vdiff_files", None)]
|
||||
branches.ACTIONS["next_commit"](view, window, st)
|
||||
branches.ACTIONS["open_commit"](view, window, st)
|
||||
branches.ACTIONS["next_commit"](view, window, st)
|
||||
branches.ACTIONS["open_commit"](view, window, st)
|
||||
assert window.commands[1:] == [("vdiff_commits", {"sha": "a" * 40}), ("vdiff_commits", {"sha": "b" * 40})]
|
||||
# a row without an item (the left column outran the log) opens nothing
|
||||
view, st = rendered(make_state(refs=REFS + [ref("x"), ref("y"), ref("z")], commits=COMMITS[:1]))
|
||||
put_caret(view, 10)
|
||||
branches.ACTIONS["open_commit"](view, window, st)
|
||||
assert len(window.commands) == 3
|
||||
|
||||
|
||||
def test_branch_actions_refuse_a_diff_view(calls):
|
||||
view, st = rendered()
|
||||
st.mode = state.MODE_DIFF
|
||||
for name in branches.ACTIONS:
|
||||
branches.ACTIONS[name](view, sublime.Window(), st)
|
||||
assert calls.calls == [] and calls.loads == []
|
||||
|
||||
|
||||
# --- wiring -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_b_opens_the_branches_command_and_refresh_reloads_in_place(loads):
|
||||
view, st = rendered()
|
||||
window = sublime.Window()
|
||||
commands.ACTIONS["branches"](view, window, st)
|
||||
assert window.commands == [("vdiff_branches", None)]
|
||||
commands.ACTIONS["refresh"](view, window, st)
|
||||
assert loads == [("main", "branches", None, True)]
|
||||
|
||||
|
||||
def test_restore_view_comes_back_in_branches_mode(loads, tmp_path):
|
||||
view = sublime.View()
|
||||
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "mode": "branches", "ref": "feature"})
|
||||
st = commands.restore_view(view)
|
||||
assert loads == [("feature", "branches", None, False)] and view.settings().get(state.MODE_SETTING) == "branches"
|
||||
# while the load (stubbed here) is in flight an activation does not start a second one
|
||||
st.token += 1
|
||||
commands.refresh_if_changed(view, st)
|
||||
assert len(loads) == 1
|
||||
|
||||
|
||||
def test_esc_from_the_files_panel_goes_back_to_the_branches(monkeypatch, loads):
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
|
||||
view, st = rendered()
|
||||
kept = st.enter()
|
||||
assert kept.mode == "branches"
|
||||
window = sublime.Window()
|
||||
files = [git.FileChange("a.py", "M", False, 1, 1, None)]
|
||||
commands.show_files_panel(window, view, st, files, kept, placeholder="x")
|
||||
_, on_select, _, on_highlight, _ = window.panel
|
||||
on_highlight(0)
|
||||
st.mode = state.MODE_DIFF # the preview drew a diff
|
||||
on_select(-1)
|
||||
assert [m for _, m, _, _ in loads] == ["branches"] and st.in_branches
|
||||
|
||||
|
||||
def test_keymap_context_answers_the_mode():
|
||||
listener = listeners.VdiffEventListener()
|
||||
view, st = rendered()
|
||||
assert view.settings().get(state.MODE_SETTING) == "branches"
|
||||
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_EQUAL, "branches", False) is True
|
||||
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_EQUAL, "diff", False) is False
|
||||
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_NOT_EQUAL, "diff", False) is True
|
||||
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_NOT_EQUAL, "branches", False) is False
|
||||
# other keys and other views are someone else's business
|
||||
assert listener.on_query_context(view, "setting.vdiff_mode", sublime.OP_EQUAL, "branches", False) is None
|
||||
assert listener.on_query_context(sublime.View(), "vdiff_mode", sublime.OP_EQUAL, "diff", False) is None
|
||||
# a review view without the setting (an older session) is a diff view
|
||||
plain = sublime.View()
|
||||
plain.settings().set(state.VIEW_FLAG, True)
|
||||
assert listener.on_query_context(plain, "vdiff_mode", sublime.OP_EQUAL, "diff", False) is True
|
||||
|
||||
|
||||
def test_branches_view_reloads_when_the_refs_changed(monkeypatch):
|
||||
mtimes = {"/repo/.git/HEAD": 1.0, "/repo/.git/refs/heads": 2.0, "/repo/vdiff/a.py": 5.0}
|
||||
monkeypatch.setattr(branches.git, "ref_paths", lambda repo: ("/repo/.git/HEAD", "/repo/.git/refs/heads"))
|
||||
monkeypatch.setattr(branches.git, "index_path", lambda repo: "/repo/.git/index")
|
||||
monkeypatch.setattr(git, "mtime", lambda path: mtimes.get(path))
|
||||
loads = record_loads(monkeypatch)
|
||||
worktree_loads = []
|
||||
monkeypatch.setattr(branches, "load_worktree", lambda view, st: worktree_loads.append(st.branch_ref))
|
||||
st = make_state()
|
||||
st.branch_changes = [CHANGES[0]]
|
||||
view, st = rendered(st)
|
||||
st.rendered_token = st.token
|
||||
# the refs, then the changed files of the working-tree entry and the index they are staged in
|
||||
assert st.fingerprint == ((1.0, 2.0), (5.0, None))
|
||||
commands.refresh_if_changed(view, st)
|
||||
assert loads == [] and worktree_loads == []
|
||||
mtimes["/repo/.git/HEAD"] = 3.0 # a checkout elsewhere: everything is relisted
|
||||
commands.refresh_if_changed(view, st)
|
||||
assert loads == [("main", "branches", None, True)]
|
||||
st.rendered_token = st.token
|
||||
st.fingerprint = branches.fingerprint(st)
|
||||
# the changed file saved, something staged: only the working tree is relisted
|
||||
mtimes["/repo/vdiff/a.py"] = 6.0
|
||||
commands.refresh_if_changed(view, st)
|
||||
assert len(loads) == 1 and worktree_loads == ["main"]
|
||||
st.fingerprint = branches.fingerprint(st)
|
||||
mtimes["/repo/.git/index"] = 7.0
|
||||
commands.refresh_if_changed(view, st)
|
||||
assert len(loads) == 1 and worktree_loads == ["main", "main"]
|
||||
# not while a load is in flight
|
||||
st.token += 1
|
||||
mtimes["/repo/.git/HEAD"] = 4.0
|
||||
commands.refresh_if_changed(view, st)
|
||||
assert len(loads) == 1 and len(worktree_loads) == 2
|
||||
|
||||
|
||||
def test_load_worktree_relists_only_the_changes_and_keeps_the_caret(monkeypatch):
|
||||
listed = []
|
||||
monkeypatch.setattr(branches.git, "changed_files", lambda repo: listed.append(repo) or [CHANGES[1]])
|
||||
monkeypatch.setattr(branches.git, "index_path", lambda repo: None)
|
||||
view, st = rendered()
|
||||
branches.ACTIONS["next_commit"](view, None, st)
|
||||
branches.load_worktree(view, st)
|
||||
assert listed == ["/repo/vdiff"] and st.branch_changes == [CHANGES[1]]
|
||||
assert rows(view)[0].endswith("│ 1 staged file, 0 unstaged files") and rows(view)[5].endswith("│ default file view mode and some colors now configurable in settings")
|
||||
assert render.caret_index(view) == 5 and st.token == st.rendered_token
|
||||
# a later load wins over a slower relisting
|
||||
pending = []
|
||||
monkeypatch.setattr(branches, "run_async", lambda work, done: pending.append((work, done)))
|
||||
branches.load_worktree(view, st)
|
||||
st.token += 1
|
||||
work, done = pending[0]
|
||||
done([])
|
||||
assert st.branch_changes == [CHANGES[1]]
|
||||
|
||||
|
||||
def test_the_commit_cursor_tints_only_the_right_column_once_its_rule_is_live():
|
||||
# before the restart: Sublime's line highlight, no cursor regions
|
||||
view, st = rendered()
|
||||
assert view.settings().get("highlight_line") is True
|
||||
assert render.KEY_BRANCH_CURSOR not in view.regions
|
||||
# after it: the four cells of the caret's commit, from the gap to the end of the row
|
||||
view, st = rendered(live=True)
|
||||
assert view.settings().get("highlight_line") is False
|
||||
regions, scope, _, _ = view.regions[render.KEY_BRANCH_CURSOR]
|
||||
assert scope == palette.SCOPE_CURSOR
|
||||
width = len(" feature") + branches.LEFT_PAD + 2
|
||||
lines = rows(view)
|
||||
assert [view.text[r.a:r.b] for r in regions] == [line[width:] for line in lines[:4]]
|
||||
# the commits' counts and tags keep the plain red / green
|
||||
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["v1.1.0", "+260"]
|
||||
assert view.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_TEXT_INS
|
||||
assert view.regions[render.KEY_BRANCH_CURSOR_INS][0] == [] and view.regions[render.KEY_BRANCH_CURSOR_DEL][0] == []
|
||||
branches.ACTIONS["next_commit"](view, None, st)
|
||||
regions = view.regions[render.KEY_BRANCH_CURSOR][0]
|
||||
assert regions[0].a == render.index_point(view, 5) + width
|
||||
# the tint leaves out the counts and tags, which get the cursor's background with their own colour instead:
|
||||
# overlapping regions would let the later one recolour the text
|
||||
text = lambda key: [view.text[r.a:r.b] for r in view.regions[key][0]] # noqa: E731
|
||||
assert text(render.KEY_BRANCH_CURSOR_INS) == ["v1.1.0", "+260"] and text(render.KEY_BRANCH_CURSOR_DEL) == ["-58"]
|
||||
assert view.regions[render.KEY_BRANCH_CURSOR_INS][1] == palette.SCOPE_CURSOR_INS
|
||||
assert text(render.KEY_BRANCH_INS) == [] and text(render.KEY_BRANCH_DEL) == []
|
||||
pieces = sorted(sum((list(view.regions[key][0]) for key in (render.KEY_BRANCH_CURSOR, render.KEY_BRANCH_CURSOR_INS,
|
||||
render.KEY_BRANCH_CURSOR_DEL)), []), key=lambda r: r.a)
|
||||
assert all(a.b <= b.a for a, b in zip(pieces, pieces[1:]))
|
||||
by_row = {}
|
||||
for r in pieces:
|
||||
by_row.setdefault(view.rowcol(r.a)[0] - 1, []).append(view.text[r.a:r.b])
|
||||
assert {row: "".join(parts) for row, parts in by_row.items()} == {i: lines[i][width:] for i in range(5, 9)}
|
||||
# back in a diff the line highlight returns
|
||||
st.change = git.FileChange("x.py", "M", False, 1, 1, None)
|
||||
render.render_message(view, st, "nothing")
|
||||
assert view.settings().get("highlight_line") is True
|
||||
|
||||
|
||||
def test_holding_J_fetches_the_log_once_the_key_rests(monkeypatch):
|
||||
timers = []
|
||||
monkeypatch.setattr(sublime, "set_timeout", lambda fn, delay=0: timers.append((fn, delay)))
|
||||
logged = []
|
||||
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: logged.append(name) or [])
|
||||
view, st = rendered()
|
||||
branches.ACTIONS["prev_branch"](view, None, st)
|
||||
branches.ACTIONS["next_branch"](view, None, st)
|
||||
branches.ACTIONS["next_branch"](view, None, st)
|
||||
assert st.branch_ref == "github/main" and logged == []
|
||||
assert [delay for _, delay in timers] == [branches.DEBOUNCE_MS] * 3
|
||||
for fn, _ in timers:
|
||||
fn()
|
||||
# only the last selection's fetch ran; the cell moved at once each time
|
||||
assert logged == ["github/main"]
|
||||
selected = view.regions[render.KEY_BRANCH_SEL][0]
|
||||
assert view.text[selected[0].a:selected[0].b].strip() == "main"
|
||||
|
||||
|
||||
def test_entering_the_branches_view_vacates_the_diff_state(monkeypatch):
|
||||
monkeypatch.setattr(branches, "run_async", lambda work, done: None)
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
st = state.ViewState("/repo/vdiff")
|
||||
st.sha, st.change = "abc", git.FileChange("x.py", "M", False, 1, 1, None)
|
||||
st.files = [st.change]
|
||||
branches.load(view, st)
|
||||
assert st.in_branches and st.sha is None and st.change is None and st.requested is None and st.lines == []
|
||||
assert st.files == [st.change] or st.files == [git.FileChange("x.py", "M", False, 1, 1, None)]
|
||||
@@ -1,7 +1,10 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import sublime # the stub, via conftest
|
||||
|
||||
from conftest import put_caret # noqa: E402
|
||||
from vdiff import commands, git, state, utils
|
||||
|
||||
|
||||
@@ -92,6 +95,64 @@ def test_enter_skips_refetch_when_preview_is_current(monkeypatch):
|
||||
assert shown == ["b.py", "a.py"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode, all_mode", [("file_by_file", False), ("all_in_one", True), (None, False)])
|
||||
def test_a_new_review_view_starts_in_the_default_view_mode(monkeypatch, mode, all_mode):
|
||||
option(monkeypatch, "default_view_mode", mode)
|
||||
monkeypatch.setattr(commands, "resolve_repo", lambda window: "/repo")
|
||||
monkeypatch.setattr(commands, "find_review_view", lambda window: sublime.View())
|
||||
_, st = commands.open_review(sublime.Window())
|
||||
assert st.all_mode is all_mode
|
||||
|
||||
|
||||
def test_panel_in_all_in_one_mode_scrolls_instead_of_loading_files(monkeypatch):
|
||||
loads, focused = [], []
|
||||
|
||||
def fake_show_all(view, st, focus=None):
|
||||
# load at once: what show_all's done does once the diffs land
|
||||
st.token += 1
|
||||
st.rendered_token = st.token
|
||||
loads.append(st.change.path)
|
||||
|
||||
monkeypatch.setattr(commands, "show_all", fake_show_all)
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: pytest.fail("loaded a single file"))
|
||||
monkeypatch.setattr(commands.render, "render_all", lambda view, st, focus=None: focused.append(focus))
|
||||
st = state.ViewState("/repo", all_mode=True)
|
||||
st.change = change("kept.py")
|
||||
st.files = [st.change]
|
||||
files = [change("a.py"), change("b.py")]
|
||||
st.collapsed = {"b.py"}
|
||||
window = sublime.Window()
|
||||
commands.show_files_panel(window, sublime.View(), st, files, st.kept())
|
||||
_, on_select, _, on_highlight, _ = window.panel
|
||||
assert loads == ["a.py"]
|
||||
|
||||
# a highlight unfolds and scrolls to its file; Enter on it needs no redraw
|
||||
on_highlight(1)
|
||||
assert focused == ["b.py"] and st.change.path == "b.py" and "b.py" not in st.collapsed
|
||||
on_select(1)
|
||||
assert focused == ["b.py"] and loads == ["a.py"]
|
||||
|
||||
# Esc goes back to the kept file, still in the all-in-one view
|
||||
on_select(-1)
|
||||
assert st.all_mode and st.change.path == "kept.py" and loads == ["a.py", "kept.py"]
|
||||
|
||||
|
||||
def test_esc_goes_back_to_the_kept_view_mode(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: calls.append(("file", c.path)))
|
||||
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: calls.append(("all", st.change.path)))
|
||||
st = state.ViewState("/repo")
|
||||
st.change = change("kept.py")
|
||||
st.files = [st.change]
|
||||
kept = st.kept()
|
||||
# the view switched to all-in-one after the snapshot; Esc still restores file-by-file
|
||||
st.all_mode = True
|
||||
window = sublime.Window()
|
||||
commands.show_files_panel(window, sublime.View(), st, [change("a.py")], kept)
|
||||
window.panel[1](-1)
|
||||
assert calls[-1] == ("file", "kept.py") and not st.all_mode
|
||||
|
||||
|
||||
def test_on_cancel_goes_back_to_commit_list(monkeypatch):
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
|
||||
st = state.ViewState("/repo")
|
||||
@@ -103,6 +164,58 @@ def test_on_cancel_goes_back_to_commit_list(monkeypatch):
|
||||
assert back == [1] and st.sha is None
|
||||
|
||||
|
||||
def test_esc_from_the_reopened_commit_list_returns_to_the_branches_view(monkeypatch):
|
||||
loads = []
|
||||
monkeypatch.setattr(commands.branches, "load", lambda view, st, keep_caret=False: loads.append(st.mode))
|
||||
window = sublime.Window()
|
||||
view = sublime.View()
|
||||
st = state.ViewState("/repo")
|
||||
st.mode = state.MODE_BRANCHES
|
||||
commit = git.Commit("a" * 40, "a" * 7, "me", "today", "subject", 1, 1, 0)
|
||||
# Esc on the list opened straight over the branches view: nothing to put back
|
||||
st.drawn, st.rendered_token = st.describe(), st.token
|
||||
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
|
||||
window.panel[1](-1)
|
||||
assert loads == []
|
||||
# reopened after a commit's files were cancelled: the state says branches, the screen still shows the commit
|
||||
st.drawn = (state.MODE_DIFF, "a" * 40, False, "x.py")
|
||||
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
|
||||
window.panel[1](-1)
|
||||
assert loads == [state.MODE_BRANCHES]
|
||||
# the same holds for a diff the list was reopened over: the kept working-tree file is shown again
|
||||
shown = []
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(c.path))
|
||||
st.mode, st.change = state.MODE_DIFF, change("kept.py")
|
||||
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
|
||||
window.panel[1](-1)
|
||||
assert shown == ["kept.py"]
|
||||
|
||||
|
||||
def test_files_on_a_clean_tree_leave_the_branches_view_in_place(monkeypatch):
|
||||
view, st = sublime.View(), state.ViewState("/repo")
|
||||
messages, notices = [], []
|
||||
monkeypatch.setattr(commands, "open_review", lambda window: (view, st))
|
||||
monkeypatch.setattr(commands, "status", messages.append)
|
||||
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: notices.append(text))
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
|
||||
window = sublime.Window()
|
||||
st.mode = state.MODE_BRANCHES
|
||||
commands.VdiffFilesCommand(window).run()
|
||||
assert messages == ["no uncommitted changes"] and notices == [] and st.in_branches and window.panel is None
|
||||
# with changes the panel comes up as usual; in a diff view the notice is shown
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py")])
|
||||
commands.VdiffFilesCommand(window).run()
|
||||
assert window.panel is not None
|
||||
# nor does a commit under review give way to it
|
||||
st.mode, st.sha = state.MODE_DIFF, "a" * 40
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
|
||||
commands.VdiffFilesCommand(window).run()
|
||||
assert notices == [] and st.sha == "a" * 40
|
||||
st.sha = None
|
||||
commands.VdiffFilesCommand(window).run()
|
||||
assert notices == ["No uncommitted changes in /repo"]
|
||||
|
||||
|
||||
def test_step_change_uses_blocks(rings):
|
||||
view, st = at_change([(2, 4), (7, 8)], 0)
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
@@ -329,8 +442,8 @@ def test_commit_items_fall_back_for_an_empty_subject():
|
||||
def loading(monkeypatch):
|
||||
"""Queue run_async instead of running it inline, so a test can act mid-load like a user can
|
||||
|
||||
The stub runs set_timeout synchronously, which is why the real plugin's async window - the one
|
||||
n / p used to read stale state through - is invisible to every other test in the suite.
|
||||
The stub runs set_timeout synchronously, so the real async window — the one n / p read stale
|
||||
state through — is invisible to every other test.
|
||||
"""
|
||||
queue = []
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: queue.append((work, done)))
|
||||
@@ -416,14 +529,6 @@ def test_esc_rewinds_the_file_navigation_counts_from(loading):
|
||||
# --- cycling past the ends --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rings(monkeypatch):
|
||||
"""Record visual bells instead of drawing them"""
|
||||
bells = []
|
||||
monkeypatch.setattr(commands.render, "flash", lambda view: bells.append(view))
|
||||
return bells
|
||||
|
||||
|
||||
def at_file(files, path):
|
||||
"""A loaded working-tree view sitting on `path`"""
|
||||
view, window = sublime.View(), sublime.Window()
|
||||
@@ -522,8 +627,8 @@ def test_a_file_without_changes_neither_moves_nor_rings(rings):
|
||||
def press(monkeypatch, action, before, path, after):
|
||||
"""Run `action` on `path` of `before`; git then reports `after`. Returns the paths shown
|
||||
|
||||
ACTIONS captured git.stage and friends when the table was built, so the subprocess is stubbed
|
||||
one level down, at git.run; that keeps the real wiring of the action under test.
|
||||
ACTIONS captured git.stage and friends at build time, so the stub goes one level down at git.run,
|
||||
keeping the action's real wiring under test.
|
||||
"""
|
||||
shown = []
|
||||
monkeypatch.setattr(commands.git, "run", lambda repo, args, ok_codes=(0,): "")
|
||||
@@ -540,6 +645,9 @@ def test_next_unstaged_scans_forward_and_wraps():
|
||||
assert commands._next_unstaged(files, 2) == 2 # the slot we count from counts itself
|
||||
assert commands._next_unstaged(files, 3) == 0 # wraps past the end
|
||||
assert commands._next_unstaged(flagged(True, True), 0) == -1
|
||||
# a partially staged file still has hunks to stage
|
||||
partial = git.FileChange("p.py", "M", True, 1, 1, None, unstaged=True)
|
||||
assert commands._next_unstaged(flagged(True, True) + [partial], 0) == 2
|
||||
|
||||
|
||||
def test_staging_moves_to_the_next_unstaged_file(monkeypatch):
|
||||
@@ -578,8 +686,321 @@ def test_unstage_and_discard_do_not_move(monkeypatch):
|
||||
assert press(monkeypatch, "discard", files, "f1.py", flagged(True, False, False)) == ["f1.py"]
|
||||
|
||||
|
||||
def test_stage_all_and_unstage_all_stay_on_the_file(monkeypatch):
|
||||
messages = []
|
||||
monkeypatch.setattr(commands, "status", messages.append)
|
||||
assert press(monkeypatch, "stage_all", flagged(True, False, False), "f1.py", flagged(True, True, True)) == ["f1.py"]
|
||||
assert messages[0] == "staged 2 files"
|
||||
assert press(monkeypatch, "unstage_all", flagged(True, False, True), "f1.py", flagged(False, False, False)) == ["f1.py"]
|
||||
assert messages[-1] == "unstaged 2 files"
|
||||
|
||||
|
||||
def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch):
|
||||
messages = []
|
||||
monkeypatch.setattr(commands, "status", messages.append)
|
||||
assert press(monkeypatch, "stage_all", flagged(True, True), "f0.py", []) == []
|
||||
assert press(monkeypatch, "unstage_all", flagged(False, False), "f0.py", []) == []
|
||||
assert messages == ["nothing to stage", "nothing to unstage"]
|
||||
# a partially staged file is something to stage
|
||||
partial = git.FileChange("p.py", "M", True, 1, 1, None, unstaged=True)
|
||||
assert press(monkeypatch, "stage_all", flagged(True) + [partial], "f0.py", flagged(True, True)) == ["f0.py"]
|
||||
assert messages[-1] == "staged 1 file"
|
||||
view, window, st = at_file(flagged(False), "f0.py")
|
||||
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", ""))
|
||||
commands.ACTIONS["stage_all"](view, window, st)
|
||||
assert messages[-1] == "stage all works on uncommitted changes only"
|
||||
|
||||
|
||||
def watched(monkeypatch, tmp_path, all_mode):
|
||||
"""A working-tree view of a.py and b.py on disk with its fingerprint taken; returns (st, refreshes)"""
|
||||
for name in ("a.py", "b.py", "index"):
|
||||
(tmp_path / name).write_text(name)
|
||||
monkeypatch.setattr(commands.git, "index_path", lambda repo: str(tmp_path / "index"))
|
||||
refreshes = []
|
||||
monkeypatch.setattr(commands, "refresh_listing", lambda view, st: refreshes.append(st))
|
||||
st = state.ViewState(str(tmp_path))
|
||||
st.files = [change("a.py"), change("b.py")]
|
||||
st.change, st.all_mode = st.files[0], all_mode
|
||||
st.fingerprint = commands.render.fingerprint(st)
|
||||
# git status lists what the view lists, unless a test says otherwise
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: list(st.files))
|
||||
return st, refreshes
|
||||
|
||||
|
||||
def touch(path):
|
||||
"""Push the file's mtime forward, as an edit would"""
|
||||
stat = path.stat()
|
||||
os.utime(path, (stat.st_atime, stat.st_mtime + 5))
|
||||
|
||||
|
||||
def test_refresh_if_changed_watches_every_file_of_the_all_files_view(monkeypatch, tmp_path):
|
||||
st, refreshes = watched(monkeypatch, tmp_path, all_mode=True)
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == []
|
||||
touch(tmp_path / "b.py") # not the file under the caret
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == [st]
|
||||
|
||||
|
||||
def test_refresh_if_changed_watches_the_shown_file_and_the_index(monkeypatch, tmp_path):
|
||||
st, refreshes = watched(monkeypatch, tmp_path, all_mode=False)
|
||||
touch(tmp_path / "b.py") # another file: the single-file view does not show it
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == []
|
||||
touch(tmp_path / "index")
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == [st]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("all_mode", [False, True])
|
||||
def test_refresh_if_changed_sees_a_file_changed_outside_the_list(monkeypatch, tmp_path, all_mode):
|
||||
st, refreshes = watched(monkeypatch, tmp_path, all_mode=all_mode)
|
||||
# no listed file and not the index moved, but git status now lists c.py too
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: st.files + [change("c.py")])
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
assert refreshes == [st]
|
||||
|
||||
|
||||
def test_a_stale_git_status_does_not_refresh(monkeypatch, tmp_path):
|
||||
st, refreshes = watched(monkeypatch, tmp_path, all_mode=False)
|
||||
pending = []
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: pending.append(done))
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
|
||||
commands.refresh_if_changed(sublime.View(), st)
|
||||
# the user moved on to another file before git status answered
|
||||
st.token += 1
|
||||
pending[0]([change("c.py")])
|
||||
assert refreshes == []
|
||||
|
||||
|
||||
def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
|
||||
messages = []
|
||||
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
||||
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
|
||||
assert messages == ["No uncommitted changes in /repo"]
|
||||
|
||||
|
||||
def all_files_view():
|
||||
"""An all-files view of a.py (two hunks) and b.py (one), rendered with the caret at the top"""
|
||||
from vdiff import parse
|
||||
lines_a = [parse.Line(" ", i, i, f"a{i}") for i in range(1, 30)]
|
||||
lines_a[2] = parse.Line("+", None, 3, "new a3")
|
||||
lines_a[25] = parse.Line("+", None, 26, "new a26")
|
||||
lines_b = [parse.Line(" ", 1, 1, "b1"), parse.Line("-", 2, None, "old b2")]
|
||||
view = sublime.View()
|
||||
st = state.ViewState("/repo")
|
||||
st.all_mode = True
|
||||
st.files = [change("a.py"), change("b.py")]
|
||||
st.all_diffs = {path: (lines, parse.change_blocks(lines), False)
|
||||
for path, lines in (("a.py", lines_a), ("b.py", lines_b))}
|
||||
commands.render.render_all(view, st)
|
||||
return view, st
|
||||
|
||||
|
||||
def test_all_files_headings_show_the_status_letter_before_the_counts():
|
||||
from vdiff import parse
|
||||
view = sublime.View()
|
||||
st = state.ViewState("/repo")
|
||||
st.all_mode = True
|
||||
st.files = [git.FileChange("a - b.py", "M", True, 1, 0, None), change("m.py"), change("gone.py", "D")]
|
||||
lines = [parse.Line("+", None, 1, "x")]
|
||||
st.all_diffs = {c.path: (lines, parse.change_blocks(lines), False) for c in st.files}
|
||||
commands.render.render_all(view, st)
|
||||
|
||||
headings = [row for row in view.text.split("\n") if row.startswith("▼")]
|
||||
# staged wins over the git letter; a path containing " -" must not confuse the letter's position
|
||||
assert headings == ["▼ a - b.py s -0 +1", "▼ m.py m -0 +1", "▼ gone.py d -0 +1"]
|
||||
regions, scope, _, _ = view.regions[commands.render.KEY_FILE_STATUS]
|
||||
assert scope == commands.render.palette.SCOPE_FILE_STATUS
|
||||
assert [view.text[r.a:r.b] for r in regions] == ["s", "m", "d"]
|
||||
|
||||
|
||||
def heading_row(st, path):
|
||||
return next(i for i, (c, _, section) in enumerate(st.all_rows) if section and c.path == path)
|
||||
|
||||
|
||||
def test_the_header_follows_the_caret_from_file_to_file():
|
||||
from vdiff import listeners
|
||||
view, st = all_files_view()
|
||||
st.files[1] = git.FileChange("b.py", "M", False, 0, 4, None)
|
||||
commands.render.render_all(view, st)
|
||||
st.rendered_token = st.token
|
||||
commands.render.init_view(view)
|
||||
state.put(view, st)
|
||||
title = lambda: view.phantoms[commands.render.PHANTOM_KEY][0].content.split("</div>", 1)[1] # noqa: E731
|
||||
listener = listeners.VdiffEventListener()
|
||||
put_caret(view, heading_row(st, "b.py") + 1)
|
||||
listener.on_selection_modified(view)
|
||||
assert st.change.path == "b.py" and ">b.py<" in title() and '<span class="del">-4</span> <span class="ins">+0</span>' in title()
|
||||
# within the same file nothing is redrawn; back in a.py the header is a.py's again
|
||||
shown = view.phantoms[commands.render.PHANTOM_KEY]
|
||||
listener.on_selection_modified(view)
|
||||
assert view.phantoms[commands.render.PHANTOM_KEY] is shown
|
||||
put_caret(view, heading_row(st, "a.py"))
|
||||
listener.on_selection_modified(view)
|
||||
assert ">a.py<" in title() and '<span class="del">-1</span> <span class="ins">+1</span>' in title()
|
||||
# not while a render is pending: the rows describe the old text
|
||||
st.token += 1
|
||||
put_caret(view, heading_row(st, "b.py"))
|
||||
listener.on_selection_modified(view)
|
||||
assert st.change.path == "a.py"
|
||||
|
||||
|
||||
def test_all_files_step_change_from_a_heading_lands_on_its_first_hunk():
|
||||
view, st = all_files_view()
|
||||
rows = dict(zip(st.all_change_row_targets, st.all_change_rows))
|
||||
|
||||
# from the top, outside any file: the very first hunk, not the second
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == rows[("a.py", 0)]
|
||||
|
||||
# from a.py's heading: its first hunk
|
||||
put_caret(view, heading_row(st, "a.py"))
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == rows[("a.py", 0)]
|
||||
|
||||
# from b.py's heading back: the hunk before b.py's first, a.py's last
|
||||
put_caret(view, heading_row(st, "b.py"))
|
||||
commands.ACTIONS["prev_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == rows[("a.py", 1)]
|
||||
|
||||
# from a hunk: the next one, across files
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == rows[("b.py", 0)]
|
||||
|
||||
|
||||
def test_arrows_do_nothing_in_the_single_file_view():
|
||||
view, st = at_change([(2, 4)], 2)
|
||||
st.change = change("a.py")
|
||||
commands.ACTIONS["collapse"](view, None, st)
|
||||
assert st.collapsed == set() and not st.all_mode and st.all_rows == []
|
||||
|
||||
|
||||
def test_git_action_refresh_stays_in_the_all_files_view(monkeypatch):
|
||||
view, st = all_files_view()
|
||||
st.change = st.files[1]
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py"), change("b.py")])
|
||||
shown = []
|
||||
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(focus))
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(("file", c.path)))
|
||||
commands.refresh_listing(view, st, pick=None)
|
||||
assert shown == ["b.py"] and st.change.path == "b.py"
|
||||
|
||||
|
||||
def test_all_files_step_file_from_nothing_and_with_no_files():
|
||||
view, st = all_files_view()
|
||||
commands.ACTIONS["next_file"](view, None, st)
|
||||
assert st.change.path == "a.py"
|
||||
st.change = None
|
||||
put_caret(view, 0)
|
||||
commands.ACTIONS["prev_file"](view, None, st)
|
||||
assert st.change.path == "b.py"
|
||||
st.files = []
|
||||
commands.ACTIONS["next_file"](view, None, st) # no ZeroDivisionError
|
||||
|
||||
|
||||
def test_all_files_context_zero_shows_only_changed_lines(monkeypatch):
|
||||
option(monkeypatch, "all_files_context", 0)
|
||||
view, st = all_files_view()
|
||||
shown = [line for _, line, _ in st.all_rows if line is not None]
|
||||
assert shown and all(line.kind != " " for line in shown)
|
||||
|
||||
|
||||
def test_refresh_in_the_all_files_view_relists_the_working_tree(monkeypatch):
|
||||
view, st = all_files_view()
|
||||
st.change = st.files[0]
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
|
||||
# b.py was reverted and c.py is new since the view was drawn
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py"), change("c.py")])
|
||||
shown = []
|
||||
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(focus))
|
||||
commands.ACTIONS["refresh"](view, None, st)
|
||||
assert shown == ["a.py"] and [c.path for c in st.files] == ["a.py", "c.py"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("all_mode, expected", [(False, ("file", "b.py")), (True, ("all", "b.py"))])
|
||||
def test_restore_falls_back_to_the_first_changed_file(monkeypatch, tmp_path, all_mode, expected):
|
||||
view = sublime.View()
|
||||
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "path": "gone.py", "all_mode": all_mode})
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("b.py"), change("c.py")])
|
||||
shown = []
|
||||
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(("all", st.change.path)))
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(("file", c.path)))
|
||||
commands.restore_view(view)
|
||||
assert shown == [expected]
|
||||
|
||||
|
||||
def test_restore_with_no_changes_left_says_so(monkeypatch, tmp_path):
|
||||
view = sublime.View()
|
||||
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "path": "gone.py"})
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
|
||||
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
|
||||
messages = []
|
||||
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
||||
commands.restore_view(view)
|
||||
assert messages == [f"No uncommitted changes in {tmp_path}"]
|
||||
|
||||
|
||||
def test_open_in_the_all_files_view_maps_a_deleted_row_like_the_single_file_view(tmp_path):
|
||||
from vdiff import parse
|
||||
view, st = all_files_view()
|
||||
st.repo = str(tmp_path)
|
||||
(tmp_path / "a.py").write_text("")
|
||||
(tmp_path / "b.py").write_text("")
|
||||
lines_b = [parse.Line(" ", i, i, f"b{i}") for i in range(1, 5)] + [parse.Line("-", 5, None, "old b5"),
|
||||
parse.Line(" ", 6, 5, "b6")]
|
||||
st.all_diffs["b.py"] = (lines_b, parse.change_blocks(lines_b), False)
|
||||
commands.render.render_all(view, st)
|
||||
opened = []
|
||||
|
||||
class Window:
|
||||
def open_file(self, name, flags=0):
|
||||
opened.append(name)
|
||||
|
||||
def open_at(match):
|
||||
put_caret(view, next(i for i, (_, line, _) in enumerate(st.all_rows) if line is not None and match(line)))
|
||||
commands.ACTIONS["open"](view, Window(), st)
|
||||
|
||||
# the deleted row opens at the line that took its place, as in the single-file view, not at line 1
|
||||
open_at(lambda line: line.kind == "-")
|
||||
# a surviving row keeps its own number
|
||||
open_at(lambda line: line.new_no == 26)
|
||||
assert opened == [f"{tmp_path}/b.py:5", f"{tmp_path}/a.py:26"]
|
||||
|
||||
|
||||
def test_a_blank_row_in_the_all_files_view_is_no_file(monkeypatch):
|
||||
view, st = all_files_view()
|
||||
messages, ran = [], []
|
||||
monkeypatch.setattr(commands, "status", messages.append)
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: ran.append(work))
|
||||
# the caret was on a.py, then moved to the blank row above b.py: git actions and open act on nothing
|
||||
put_caret(view, heading_row(st, "a.py") + 1)
|
||||
commands.render.follow_caret(view, st)
|
||||
put_caret(view, heading_row(st, "b.py") - 1)
|
||||
commands.ACTIONS["discard"](view, None, st)
|
||||
commands.ACTIONS["stage"](view, None, st)
|
||||
commands.ACTIONS["open"](view, None, st)
|
||||
assert ran == [] and messages == ["no file under the caret"] * 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action, path", [("next_file", "b.py"), ("prev_file", "a.py")])
|
||||
def test_a_blank_row_steps_files_as_the_gap_above_the_file_below(action, path):
|
||||
view, st = all_files_view()
|
||||
st.change = st.files[0]
|
||||
put_caret(view, heading_row(st, "b.py") - 1)
|
||||
commands.ACTIONS[action](view, None, st)
|
||||
assert st.change.path == path
|
||||
|
||||
|
||||
def test_a_blank_row_steps_changes_as_the_gap_above_the_file_below():
|
||||
view, st = all_files_view()
|
||||
put_caret(view, heading_row(st, "b.py") - 1)
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert st.change.path == "b.py"
|
||||
put_caret(view, heading_row(st, "b.py") - 1)
|
||||
commands.ACTIONS["prev_change"](view, None, st)
|
||||
# a.py's last hunk, not the first or last of the whole list from nowhere
|
||||
assert st.change.path == "a.py" and commands.render.caret_index(view) == st.all_change_rows[1]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
@@ -43,12 +44,25 @@ def test_changed_files_all_kinds(repo):
|
||||
sh(repo, "add", "b.txt") # staged M
|
||||
os.remove(os.path.join(repo, "gone.py")) # unstaged D
|
||||
write(repo, "sub/new.py", "x = 1\ny = 2") # untracked, no trailing newline
|
||||
write(repo, "c.txt", "c\n")
|
||||
sh(repo, "add", "c.txt")
|
||||
write(repo, "c.txt", "c\nd\n") # partially staged: staged A, unstaged M
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
assert set(changes) == {"a.py", "b.txt", "gone.py", "sub/new.py"}
|
||||
assert changes["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None)
|
||||
assert set(changes) == {"a.py", "b.txt", "c.txt", "gone.py", "sub/new.py"}
|
||||
assert changes["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True)
|
||||
assert changes["b.txt"] == git.FileChange("b.txt", "M", True, 1, 0, None)
|
||||
assert changes["c.txt"] == git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True)
|
||||
assert changes["gone.py"].status == "D" and changes["gone.py"].deletions == 1
|
||||
assert changes["sub/new.py"] == git.FileChange("sub/new.py", "?", False, 2, 0, None)
|
||||
assert changes["sub/new.py"] == git.FileChange("sub/new.py", "?", False, 2, 0, None, unstaged=True)
|
||||
|
||||
|
||||
def test_worktree_stats_counts_a_partially_staged_file_in_both_and_a_binary_in_neither_total():
|
||||
changes = [git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True),
|
||||
git.FileChange("b.txt", "M", True, 1, 0, None),
|
||||
git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True),
|
||||
git.FileChange("img.png", "M", True, None, None, None)]
|
||||
assert git.worktree_stats(changes) == git.WorkTree(staged=3, unstaged=2, files=4, insertions=4, deletions=1)
|
||||
assert git.worktree_stats([]) == git.WorkTree(0, 0, 0, 0, 0)
|
||||
|
||||
|
||||
def test_full_diff_working_tree(repo):
|
||||
@@ -84,6 +98,29 @@ def test_stage_unstage_discard(repo):
|
||||
assert git.changed_files(repo) == []
|
||||
|
||||
|
||||
def test_stage_all_unstage_all(repo):
|
||||
write(repo, "a.py", "changed\n")
|
||||
os.remove(os.path.join(repo, "gone.py"))
|
||||
write(repo, "sub/n.py", "new\n")
|
||||
git.stage_all(repo)
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and all(c.staged for c in changes.values())
|
||||
git.unstage_all(repo)
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
assert set(changes) == {"a.py", "gone.py", "sub/n.py"} and not any(c.staged for c in changes.values())
|
||||
assert changes["sub/n.py"].status == "?"
|
||||
|
||||
|
||||
def test_index_path_finds_a_worktrees_index(repo, tmp_path_factory):
|
||||
assert git.index_path(repo) == os.path.join(repo, ".git", "index")
|
||||
# a worktree's .git is a file pointing into the main repository
|
||||
tree = str(tmp_path_factory.mktemp("tree") / "wt")
|
||||
sh(repo, "worktree", "add", "-q", tree)
|
||||
index = git.index_path(tree)
|
||||
assert os.path.isfile(os.path.join(tree, ".git")) and os.path.isfile(index)
|
||||
assert os.path.realpath(index).startswith(os.path.realpath(os.path.join(repo, ".git", "worktrees")))
|
||||
|
||||
|
||||
def test_commits(repo):
|
||||
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
|
||||
write(repo, "c.py", "c\n")
|
||||
@@ -154,6 +191,10 @@ def test_no_head_repo(tmp_path):
|
||||
assert [l.kind for l in diff.lines] == ["+"]
|
||||
git.unstage(repo, changes[0])
|
||||
assert git.changed_files(repo)[0].status == "?"
|
||||
git.stage_all(repo)
|
||||
assert git.changed_files(repo)[0].staged
|
||||
git.unstage_all(repo)
|
||||
assert git.changed_files(repo)[0].status == "?"
|
||||
|
||||
|
||||
def test_rename_in_commit(repo):
|
||||
@@ -269,3 +310,250 @@ def test_head_message_without_a_commit_raises(tmp_path):
|
||||
sh(fresh, "init", "-q", "-b", "main")
|
||||
with pytest.raises(git.GitError):
|
||||
git.head_message(fresh)
|
||||
|
||||
|
||||
# --- branches ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote(repo, tmp_path_factory):
|
||||
"""A bare `origin` holding main, with the repo's main tracking it"""
|
||||
bare = str(tmp_path_factory.mktemp("origin"))
|
||||
sh(bare, "init", "-q", "--bare", "-b", "main")
|
||||
sh(repo, "remote", "add", "origin", bare)
|
||||
sh(repo, "push", "-q", "-u", "origin", "main")
|
||||
return bare
|
||||
|
||||
|
||||
def clone(bare, tmp_path_factory):
|
||||
"""A second working copy of `bare`, to push foreign commits from"""
|
||||
other = str(tmp_path_factory.mktemp("other"))
|
||||
sh(other, "clone", "-q", bare, ".")
|
||||
sh(other, "config", "user.email", "o@example.com")
|
||||
sh(other, "config", "user.name", "Other")
|
||||
return other
|
||||
|
||||
|
||||
def commit_file(repo, name, text, message):
|
||||
write(repo, name, text)
|
||||
sh(repo, "add", "-A")
|
||||
sh(repo, "commit", "-q", "-m", message)
|
||||
|
||||
|
||||
def by_name(repo):
|
||||
return {r.name: r for r in git.branches(repo)}
|
||||
|
||||
|
||||
def test_branches_lists_locals_remotes_and_tracking(repo, remote, tmp_path_factory):
|
||||
sh(repo, "branch", "feature/x")
|
||||
sh(repo, "push", "-q", "-u", "origin", "feature/x")
|
||||
sh(repo, "branch", "local-only")
|
||||
commit_file(repo, "ahead.txt", "a\n", "ahead")
|
||||
other = clone(remote, tmp_path_factory)
|
||||
commit_file(other, "behind.txt", "b\n", "behind")
|
||||
sh(other, "push", "-q", "origin", "main")
|
||||
sh(other, "push", "-q", "origin", "--delete", "feature/x")
|
||||
sh(repo, "fetch", "-q", "--prune")
|
||||
# the remote's symbolic HEAD is a row of for-each-ref too
|
||||
sh(repo, "remote", "set-head", "origin", "main")
|
||||
|
||||
refs = by_name(repo)
|
||||
assert set(refs) == {"main", "feature/x", "local-only", "origin/main"}
|
||||
main = refs["main"]
|
||||
assert main.current and main.remote is None and main.upstream == "origin/main"
|
||||
assert (main.ahead, main.behind, main.gone) == (1, 1, False)
|
||||
assert refs["feature/x"].gone and refs["feature/x"].upstream == "origin/feature/x"
|
||||
assert refs["local-only"].upstream is None and not refs["local-only"].current
|
||||
assert (main.upstream_remote, main.upstream_branch) == ("origin", "main")
|
||||
assert refs["local-only"].upstream_remote is None and refs["local-only"].upstream_branch is None
|
||||
origin_main = refs["origin/main"]
|
||||
assert (origin_main.remote, origin_main.branch, origin_main.current) == ("origin", "main", False)
|
||||
assert len(origin_main.sha) >= 7
|
||||
# locals come before remotes, in git's own order
|
||||
assert [r.name for r in git.branches(repo)] == ["feature/x", "local-only", "main", "origin/main"]
|
||||
|
||||
|
||||
def test_remotes_lists_a_remote_without_refs(repo, remote):
|
||||
sh(repo, "remote", "add", "github", "/nowhere")
|
||||
assert git.remotes(repo) == ["github", "origin"]
|
||||
|
||||
|
||||
def test_head_sha_on_a_branch_detached_and_without_commits(repo, tmp_path):
|
||||
sha = git.head_sha(repo)
|
||||
assert len(sha) >= 7 and by_name(repo)["main"].current
|
||||
sh(repo, "checkout", "-q", "--detach")
|
||||
assert git.head_sha(repo) == sha and not any(r.current for r in git.branches(repo))
|
||||
empty = str(tmp_path / "empty")
|
||||
os.makedirs(empty)
|
||||
sh(empty, "init", "-q", "-b", "main")
|
||||
assert git.head_sha(empty) == "" and git.branches(empty) == []
|
||||
|
||||
|
||||
def test_ref_log_carries_tags_dates_and_stats(repo, remote):
|
||||
commit_file(repo, "new.py", "one\ntwo\n", "added a file")
|
||||
sh(repo, "tag", "v1.0.0")
|
||||
sh(repo, "tag", "-a", "release", "-m", "annotated")
|
||||
sh(repo, "commit", "-q", "--allow-empty", "-m", "empty")
|
||||
|
||||
empty, added, initial = git.ref_log(repo, "main", 10)
|
||||
assert (empty.subject, empty.tags, empty.files) == ("empty", (), None)
|
||||
assert added.tags == ("release", "v1.0.0") or added.tags == ("v1.0.0", "release")
|
||||
assert (added.files, added.insertions, added.deletions) == (1, 2, 0)
|
||||
assert re.fullmatch(r"\d\d\.\d\d\.\d{4}", added.date) and added.author == "Test"
|
||||
assert initial.tags == () and initial.files == 3
|
||||
# a remote ref logs too, and the count caps the list
|
||||
assert [c.subject for c in git.ref_log(repo, "origin/main", 1)] == ["initial"]
|
||||
# the recent-commits panel keeps its relative dates and carries the same tags
|
||||
recent = git.recent_commits(repo, 2)
|
||||
assert recent[0].tags == () and recent[1].tags == added.tags and recent[0].date.endswith("ago")
|
||||
|
||||
|
||||
def test_checkout_tracks_a_remote_branch_once_and_reuses_the_local(repo, remote, tmp_path_factory):
|
||||
other = clone(remote, tmp_path_factory)
|
||||
sh(other, "checkout", "-q", "-b", "topic")
|
||||
commit_file(other, "t.txt", "t\n", "topic work")
|
||||
sh(other, "push", "-q", "-u", "origin", "topic")
|
||||
sh(repo, "fetch", "-q")
|
||||
|
||||
refs = by_name(repo)
|
||||
assert "topic" not in refs
|
||||
git.checkout(repo, refs["origin/topic"], local_names=[])
|
||||
refs = by_name(repo)
|
||||
assert refs["topic"].current and refs["topic"].upstream == "origin/topic"
|
||||
assert os.path.exists(os.path.join(repo, "t.txt"))
|
||||
# back on main, the remote row now checks out the existing local branch rather than failing
|
||||
git.checkout(repo, refs["main"])
|
||||
assert by_name(repo)["main"].current
|
||||
git.checkout(repo, refs["origin/topic"], local_names=["topic"])
|
||||
assert by_name(repo)["topic"].current
|
||||
|
||||
|
||||
def test_checkout_detached(repo, remote):
|
||||
git.checkout_detached(repo, "origin/main")
|
||||
refs = by_name(repo)
|
||||
assert not any(r.current for r in refs.values()) and git.head_sha(repo) == refs["origin/main"].sha
|
||||
|
||||
|
||||
def test_delete_branch_refuses_unmerged_then_forces(repo):
|
||||
sh(repo, "checkout", "-q", "-b", "side")
|
||||
commit_file(repo, "s.txt", "s\n", "side work")
|
||||
sh(repo, "checkout", "-q", "main")
|
||||
with pytest.raises(git.GitError) as caught:
|
||||
git.delete_branch(repo, "side")
|
||||
assert "not fully merged" in caught.value.stderr
|
||||
assert "side" in by_name(repo)
|
||||
git.delete_branch(repo, "side", force=True)
|
||||
assert "side" not in by_name(repo)
|
||||
# a merged branch goes without force
|
||||
sh(repo, "branch", "merged")
|
||||
git.delete_branch(repo, "merged")
|
||||
assert "merged" not in by_name(repo)
|
||||
|
||||
|
||||
def test_fetch_into_fast_forwards_and_refuses_non_ff(repo, remote, tmp_path_factory):
|
||||
sh(repo, "checkout", "-q", "-b", "work")
|
||||
other = clone(remote, tmp_path_factory)
|
||||
commit_file(other, "n.txt", "n\n", "upstream work")
|
||||
sh(other, "push", "-q", "origin", "main")
|
||||
|
||||
git.fetch_into(repo, "origin", "main", "main")
|
||||
refs = by_name(repo)
|
||||
assert refs["main"].sha == refs["origin/main"].sha and refs["main"].ahead == 0
|
||||
# diverge main, then the fast-forward is refused
|
||||
sh(repo, "checkout", "-q", "main")
|
||||
commit_file(repo, "m.txt", "m\n", "local work")
|
||||
sh(repo, "checkout", "-q", "work")
|
||||
commit_file(other, "o.txt", "o\n", "more upstream work")
|
||||
sh(other, "push", "-q", "origin", "main")
|
||||
with pytest.raises(git.GitError):
|
||||
git.fetch_into(repo, "origin", "main", "main")
|
||||
# nor does git update the checked-out branch this way
|
||||
with pytest.raises(git.GitError):
|
||||
git.fetch_into(repo, "origin", "main", "work")
|
||||
|
||||
|
||||
def test_pull_fast_forwards_the_current_branch(repo, remote, tmp_path_factory):
|
||||
other = clone(remote, tmp_path_factory)
|
||||
commit_file(other, "n.txt", "n\n", "upstream work")
|
||||
sh(other, "push", "-q", "origin", "main")
|
||||
git.pull(repo)
|
||||
assert os.path.exists(os.path.join(repo, "n.txt"))
|
||||
assert by_name(repo)["main"].behind == 0
|
||||
|
||||
|
||||
def test_push_sets_the_upstream_once(repo, remote):
|
||||
sh(repo, "checkout", "-q", "-b", "topic")
|
||||
commit_file(repo, "t.txt", "t\n", "topic work")
|
||||
assert by_name(repo)["topic"].upstream is None
|
||||
git.push(repo, "origin", "topic", set_upstream=True)
|
||||
topic = by_name(repo)["topic"]
|
||||
assert topic.upstream == "origin/topic" and (topic.ahead, topic.behind) == (0, 0)
|
||||
commit_file(repo, "t.txt", "tt\n", "more topic work")
|
||||
assert by_name(repo)["topic"].ahead == 1
|
||||
git.push(repo, "origin", "topic")
|
||||
assert by_name(repo)["topic"].ahead == 0
|
||||
|
||||
|
||||
def test_push_goes_to_a_differently_named_upstream(repo, remote):
|
||||
sh(repo, "checkout", "-q", "-b", "foo")
|
||||
commit_file(repo, "f.txt", "f\n", "foo work")
|
||||
sh(repo, "push", "-q", "-u", "origin", "foo:bar")
|
||||
foo = by_name(repo)["foo"]
|
||||
assert (foo.upstream, foo.upstream_remote, foo.upstream_branch) == ("origin/bar", "origin", "bar")
|
||||
commit_file(repo, "f.txt", "ff\n", "more foo work")
|
||||
git.push(repo, "origin", "foo", remote_branch="bar")
|
||||
refs = by_name(repo)
|
||||
assert refs["foo"].ahead == 0 and "origin/foo" not in refs
|
||||
# an upstream that is a local branch has no remote
|
||||
sh(repo, "branch", "--set-upstream-to=main", "foo")
|
||||
foo = by_name(repo)["foo"]
|
||||
assert (foo.upstream, foo.upstream_remote, foo.upstream_branch) == ("main", None, None)
|
||||
|
||||
|
||||
def test_fetch_all_prunes_a_deleted_remote_branch(repo, remote, tmp_path_factory):
|
||||
other = clone(remote, tmp_path_factory)
|
||||
sh(other, "checkout", "-q", "-b", "gone")
|
||||
commit_file(other, "g.txt", "g\n", "to be deleted")
|
||||
sh(other, "push", "-q", "origin", "gone")
|
||||
git.fetch_all(repo)
|
||||
assert "origin/gone" in by_name(repo)
|
||||
sh(other, "push", "-q", "origin", "--delete", "gone")
|
||||
git.fetch_all(repo)
|
||||
assert "origin/gone" not in by_name(repo)
|
||||
|
||||
|
||||
def test_run_passes_env_and_closes_stdin(repo):
|
||||
assert git.run(repo, ["var", "GIT_EDITOR"], env={"GIT_EDITOR": "xyz-editor"}).strip() == "xyz-editor"
|
||||
# the inherited environment stays: git still finds its config and the user's PATH
|
||||
assert git.run(repo, ["config", "user.name"]).strip() == "Test"
|
||||
|
||||
|
||||
def test_ref_paths_point_into_the_git_dir(repo, tmp_path_factory):
|
||||
paths = git.ref_paths(repo)
|
||||
assert [os.path.relpath(p, repo) for p in paths] == [".git/" + name for name in git.REF_PATH_NAMES]
|
||||
assert os.path.exists(paths[0]) and os.path.isdir(paths[4])
|
||||
assert git.ref_paths(str(tmp_path_factory.mktemp("not-a-repo"))) == ()
|
||||
# the directories a slashed branch and a remote put their refs in: those are the ones their updates touch
|
||||
sh(repo, "branch", "feature/x")
|
||||
sh(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
|
||||
nested = [os.path.relpath(p, repo) for p in git.ref_paths(repo)[len(git.REF_PATH_NAMES):]]
|
||||
assert nested == [".git/refs/heads/feature", ".git/refs/remotes/origin"]
|
||||
|
||||
|
||||
def test_ref_paths_walk_again_only_when_a_ref_directory_moved(repo, monkeypatch):
|
||||
sh(repo, "branch", "feature/x")
|
||||
git.ref_paths(repo)
|
||||
walks = []
|
||||
real_walk = os.walk
|
||||
monkeypatch.setattr(git.os, "walk", lambda root, *args: walks.append(root) or real_walk(root, *args))
|
||||
git.ref_paths(repo)
|
||||
assert walks == [] # nothing moved: the directories found last time stand
|
||||
# a branch two levels down adds a directory inside a nested one, whose mtime moves
|
||||
sh(repo, "branch", "feature/deep/y")
|
||||
nested = [os.path.relpath(p, repo) for p in git.ref_paths(repo)[len(git.REF_PATH_NAMES):]]
|
||||
assert walks and nested == [".git/refs/heads/feature", ".git/refs/heads/feature/deep"]
|
||||
|
||||
|
||||
def test_run_keeps_gits_messages_in_english(repo):
|
||||
# a localized git would translate the refusal delete_branch tells apart by its text
|
||||
assert "LC_ALL=C" in git.run(repo, ["-c", "alias.showenv=!env", "showenv"]).split("\n")
|
||||
|
||||
228
tests/test_palette.py
Normal file
228
tests/test_palette.py
Normal file
@@ -0,0 +1,228 @@
|
||||
import json
|
||||
|
||||
import sublime # the stub, via conftest
|
||||
|
||||
from vdiff import palette
|
||||
|
||||
|
||||
def scheme_file(tmp_path):
|
||||
"""The override of whichever scheme the stub reports as active"""
|
||||
return tmp_path / "User" / palette.override_name(sublime.View())
|
||||
|
||||
|
||||
def override(tmp_path):
|
||||
"""The rules palette.py wrote into that override"""
|
||||
path = scheme_file(tmp_path)
|
||||
return json.loads(path.read_text())["rules"] if path.exists() else None
|
||||
|
||||
|
||||
def by_scope(tmp_path):
|
||||
return {rule["scope"]: rule for rule in override(tmp_path)}
|
||||
|
||||
|
||||
def test_a_scheme_with_diff_colours_needs_no_tint_rules(tmp_path):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
|
||||
# the tints are the scheme's own scopes, so only the generated colour rules are ours to write
|
||||
assert not [r for r in override(tmp_path) if r["scope"].startswith("diff.")]
|
||||
|
||||
|
||||
def test_a_scheme_without_diff_colours_gets_our_tints(tmp_path):
|
||||
view = sublime.View()
|
||||
# all or nothing per pair: taking only the half a scheme has would put our hue next to its own
|
||||
del view.styles["diff.deleted.char"]
|
||||
del view.styles["diff.inserted"]
|
||||
del view.styles["diff.inserted.char"]
|
||||
palette.ensure(view)
|
||||
|
||||
rules = by_scope(tmp_path)
|
||||
assert rules[palette.SCOPE_INS]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_INS]
|
||||
assert rules[palette.SCOPE_DEL_CHAR]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_DEL_CHAR]
|
||||
# the one colour it does define is left to the scheme
|
||||
assert palette.SCOPE_DEL not in rules
|
||||
|
||||
|
||||
def test_every_palette_colour_gets_a_rule_per_background_kind(tmp_path):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
|
||||
rules = by_scope(tmp_path)
|
||||
colours = {view.styles[scope]["foreground"] for scope in ("comment", "string", "keyword")}
|
||||
for colour in colours:
|
||||
scope = palette.scope_for("ins", colour)
|
||||
assert scope is not None
|
||||
# both properties, which is the whole point: the tint alone would flatten the text
|
||||
assert rules[scope] == {"scope": scope, "background": view.styles["diff.inserted"]["background"],
|
||||
"foreground": colour}
|
||||
for kind in palette.KINDS:
|
||||
assert palette.scope_for(kind, colour) in rules
|
||||
|
||||
|
||||
def test_a_colour_the_scheme_does_not_use_has_no_scope():
|
||||
palette.ensure(sublime.View())
|
||||
assert palette.scope_for("ins", "#123456") is None
|
||||
|
||||
|
||||
def test_the_override_is_written_once_per_scheme(tmp_path):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
scheme_file(tmp_path).write_text(json.dumps({"rules": []}))
|
||||
|
||||
# sublime reloads the whole scheme every time we touch the file, so nothing must rewrite it
|
||||
palette.ensure(view)
|
||||
assert override(tmp_path) == []
|
||||
|
||||
|
||||
def test_a_hand_edited_override_is_never_clobbered(tmp_path):
|
||||
(tmp_path / "User").mkdir()
|
||||
path = tmp_path / "User" / "Mariana.sublime-color-scheme"
|
||||
# comments are legal in a .sublime-color-scheme but decode_value here is plain json
|
||||
path.write_text('{\n // mine\n "rules": []\n}')
|
||||
|
||||
palette.ensure(sublime.View())
|
||||
assert "// mine" in path.read_text()
|
||||
|
||||
|
||||
|
||||
|
||||
def colour_option(monkeypatch, name, value):
|
||||
"""Set one of the colour settings for this test"""
|
||||
from vdiff import utils
|
||||
monkeypatch.setitem(sublime.load_settings(utils.SETTINGS_FILE), name, value)
|
||||
|
||||
|
||||
def test_user_line_colours_get_rules_and_tint_the_token_layer(tmp_path, monkeypatch):
|
||||
colour_option(monkeypatch, "deleted_line_background", "#ff000040")
|
||||
colour_option(monkeypatch, "line_numbers_foreground", "#888888")
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
|
||||
rules = by_scope(tmp_path)
|
||||
assert rules[palette.SCOPE_LINE_DEL] == {"scope": palette.SCOPE_LINE_DEL, "background": "#ff000040"}
|
||||
assert palette.SCOPE_LINE_INS not in rules
|
||||
# the token layer repaints deleted text, so it carries the same background
|
||||
colour = view.styles["comment"]["foreground"]
|
||||
assert rules[palette.scope_for("del", colour)]["background"] == "#ff000040"
|
||||
# a foreground alone still gets a background, or sublime would fill the region with the foreground
|
||||
numbers = rules[palette.SCOPE_LINE_NUMBERS]
|
||||
assert numbers["foreground"] == "#888888" and numbers["background"]
|
||||
|
||||
|
||||
def test_changing_a_colour_setting_rewrites_the_override(tmp_path, monkeypatch):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
assert palette.SCOPE_LINE_INS not in by_scope(tmp_path)
|
||||
colour_option(monkeypatch, "inserted_line_background", "#00ff0040")
|
||||
palette.ensure(view)
|
||||
assert by_scope(tmp_path)[palette.SCOPE_LINE_INS]["background"] == "#00ff0040"
|
||||
|
||||
|
||||
def test_render_uses_the_user_scopes_only_once_set_and_live(monkeypatch):
|
||||
view = sublime.View()
|
||||
# unset: the scheme's diff tint and the plain-text line numbers, as before
|
||||
assert palette.line_scope(view, "del") == palette.SCOPE_DEL
|
||||
assert palette.line_numbers_scope(view) == "text.plain"
|
||||
colour_option(monkeypatch, "deleted_line_background", "#ff000040")
|
||||
colour_option(monkeypatch, "line_numbers_background", "#222222")
|
||||
# set, but the override is not loaded yet: an unknown scope would paint nothing
|
||||
assert palette.line_scope(view, "del") == palette.SCOPE_DEL
|
||||
view.override_loaded = True
|
||||
assert palette.line_scope(view, "del") == palette.SCOPE_LINE_DEL
|
||||
assert palette.line_scope(view, "ins") == palette.SCOPE_INS
|
||||
assert palette.line_numbers_scope(view) == palette.SCOPE_LINE_NUMBERS
|
||||
|
||||
|
||||
def test_file_header_colours_come_from_settings(tmp_path, monkeypatch):
|
||||
palette.ensure(sublime.View())
|
||||
rules = by_scope(tmp_path)
|
||||
# unset: the built-in defaults
|
||||
assert rules[palette.SCOPE_FILE_HEADER]["background"] == palette.FILE_HEADER_BACKGROUND
|
||||
colour_option(monkeypatch, "file_header_background", "#202040")
|
||||
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
|
||||
palette.ensure(sublime.View())
|
||||
rules = by_scope(tmp_path)
|
||||
assert rules[palette.SCOPE_FILE_HEADER]["background"] == "#202040"
|
||||
# the counts sit on the heading, so they share its background
|
||||
assert rules[palette.SCOPE_FILE_INS_COUNT]["background"] == "#202040"
|
||||
assert rules[palette.SCOPE_FILE_INS_COUNT]["foreground"] == "#00ff00"
|
||||
assert rules[palette.SCOPE_FILE_DEL_COUNT]["foreground"] == palette.FILE_DEL_FOREGROUND
|
||||
|
||||
|
||||
def test_the_status_letter_is_white_on_the_heading_unless_set(tmp_path, monkeypatch):
|
||||
palette.ensure(sublime.View())
|
||||
rule = by_scope(tmp_path)[palette.SCOPE_FILE_STATUS]
|
||||
assert rule["foreground"] == palette.FILE_STATUS_FOREGROUND == "#ffffff"
|
||||
assert rule["background"] == by_scope(tmp_path)[palette.SCOPE_FILE_HEADER]["background"]
|
||||
colour_option(monkeypatch, "file_header_status_foreground", "#ffff00")
|
||||
palette.ensure(sublime.View())
|
||||
assert by_scope(tmp_path)[palette.SCOPE_FILE_STATUS]["foreground"] == "#ffff00"
|
||||
|
||||
|
||||
def test_red_and_green_text_rules_share_the_heading_colours_on_the_editor_background(tmp_path, monkeypatch):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
rules = by_scope(tmp_path)
|
||||
plain = palette._nudge(palette._global_background(view))
|
||||
assert rules[palette.SCOPE_TEXT_DEL] == {"scope": palette.SCOPE_TEXT_DEL, "background": plain,
|
||||
"foreground": palette.FILE_DEL_FOREGROUND, "font_style": "bold"}
|
||||
assert rules[palette.SCOPE_TEXT_INS]["foreground"] == palette.FILE_INS_FOREGROUND
|
||||
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
|
||||
palette.ensure(sublime.View())
|
||||
assert by_scope(tmp_path)[palette.SCOPE_TEXT_INS]["foreground"] == "#00ff00"
|
||||
# until the override is live the scheme's diff tints stand in
|
||||
assert palette.text_scope(view, "del") == palette.SCOPE_DEL
|
||||
assert palette.text_scope(view, "ins") == palette.SCOPE_INS
|
||||
assert palette.count_scope(view, "del") == palette.SCOPE_DEL
|
||||
view.override_loaded = True
|
||||
assert palette.text_scope(view, "del") == palette.SCOPE_TEXT_DEL
|
||||
assert palette.text_scope(view, "ins") == palette.SCOPE_TEXT_INS
|
||||
assert palette.count_scope(view, "del") == palette.SCOPE_FILE_DEL_COUNT
|
||||
assert palette.count_scope(view, "ins") == palette.SCOPE_FILE_INS_COUNT
|
||||
|
||||
|
||||
def test_the_cursor_count_rules_share_the_cursor_background_and_the_heading_colours(tmp_path, monkeypatch):
|
||||
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
rules = by_scope(tmp_path)
|
||||
assert rules[palette.SCOPE_CURSOR_INS] == {"scope": palette.SCOPE_CURSOR_INS, "background": rules[palette.SCOPE_CURSOR]["background"],
|
||||
"foreground": "#00ff00", "font_style": "bold"}
|
||||
assert rules[palette.SCOPE_CURSOR_DEL]["foreground"] == palette.FILE_DEL_FOREGROUND
|
||||
# the text scope stands in until the rule is live
|
||||
assert palette.cursor_text_scope(view, "ins") == palette.SCOPE_INS
|
||||
view.override_loaded = True
|
||||
assert palette.cursor_text_scope(view, "ins") == palette.SCOPE_CURSOR_INS
|
||||
|
||||
|
||||
def test_the_restart_notice_is_claimed_per_view_kind(tmp_path):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
# nothing live yet: each kind of view asks once
|
||||
assert palette.claim_restart_notice(view, palette.DIFF_SCOPES)
|
||||
assert not palette.claim_restart_notice(view, palette.DIFF_SCOPES)
|
||||
assert palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
|
||||
assert not palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
|
||||
# an upgrade that adds only the branches rules: a diff view, whose rules are live, says nothing
|
||||
palette.reset()
|
||||
palette.ensure(view)
|
||||
view.override_loaded = True
|
||||
for scope in palette.BRANCH_SCOPES[3:]:
|
||||
view.styles[scope] = {"foreground": sublime.DEFAULT_FG} # known to the scheme, but without a background: not live
|
||||
assert not palette.claim_restart_notice(view, palette.DIFF_SCOPES)
|
||||
assert palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
|
||||
|
||||
|
||||
def test_the_commit_cursor_rule_uses_the_line_highlight_colour(tmp_path, monkeypatch):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
rule = by_scope(tmp_path)[palette.SCOPE_CURSOR]
|
||||
assert rule["background"] == "#3b4350" and rule["foreground"] == sublime.DEFAULT_FG
|
||||
assert palette.cursor_scope(view) is None
|
||||
view.override_loaded = True
|
||||
assert palette.cursor_scope(view) == palette.SCOPE_CURSOR
|
||||
# a scheme without a line highlight: the background nudged towards the foreground
|
||||
assert palette._blend("#000000", "#ffffff", 0.12) == "#1f1f1f"
|
||||
assert palette._blend("#303841", "#d8dee9", 0.12) == "#444c55"
|
||||
monkeypatch.setattr(sublime.View, "style", lambda self: {})
|
||||
assert palette._line_highlight(view) == palette._blend("#000000", sublime.DEFAULT_FG, palette.CURSOR_BLEND)
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
|
||||
import sublime # the stub, via conftest
|
||||
|
||||
from vdiff import commands, git, listeners, parse, render, state # noqa: F401
|
||||
from vdiff import commands, git, listeners, palette, parse, render, state # noqa: F401
|
||||
|
||||
DIFF = """--- a/x.py
|
||||
+++ b/x.py
|
||||
@@ -34,13 +34,33 @@ def header(view):
|
||||
return view.phantoms[render.PHANTOM_KEY][0].content
|
||||
|
||||
|
||||
def with_loaded_override(view):
|
||||
"""Model a view whose scheme has picked up the override palette.py writes
|
||||
|
||||
The stub no more simulates that merge than sublime does before a restart, so a test says so itself.
|
||||
"""
|
||||
view.override_loaded = True
|
||||
return view
|
||||
|
||||
|
||||
def test_render_text_regions_and_header():
|
||||
view = sublime.View()
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
st = make_state()
|
||||
render.render(view, st)
|
||||
|
||||
assert view.text.split("\n") == ["", "def f():", " return 1", "", " return 10", " # added", " # more", "", "def g():", " pass"]
|
||||
assert view.text.split("\n") == [
|
||||
"",
|
||||
"def f():",
|
||||
" return 1",
|
||||
"",
|
||||
" return 10",
|
||||
" # added",
|
||||
" # more",
|
||||
"",
|
||||
"def g():",
|
||||
" pass",
|
||||
]
|
||||
assert view.is_read_only()
|
||||
assert view.syntax.endswith("Python.sublime-syntax")
|
||||
|
||||
@@ -132,18 +152,18 @@ def test_help_popup_lists_every_key():
|
||||
view, html = help_popup(st)
|
||||
|
||||
assert view.is_popup_visible()
|
||||
for group in ("Navigate", "Panels", "Actions", "View"):
|
||||
for group in ("Navigate", "Actions", "View"):
|
||||
assert f">{group}<" in html
|
||||
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "?", "esc"):
|
||||
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s / S", "u / U", "d", "C", "m", "r", "z", "esc"):
|
||||
assert f">{keys}<" in html
|
||||
assert "next / previous change" in html and "open the real file at this line" in html
|
||||
# working-tree mode: nothing dimmed, every single-action row is a link
|
||||
assert 'class="dim"' not in html and "uncommitted only" not in html
|
||||
assert 'href="stage"' in html and 'href="files"' in html and 'href="close"' in html
|
||||
assert 'href="discard"' in html and 'href="files"' in html and 'href="branches"' in html and 'href="close"' in html
|
||||
# rows covering two actions stay plain text
|
||||
assert 'href="next_change"' not in html
|
||||
# the status bar advertises it too
|
||||
assert "? keys" in view.status[render.STATUS_KEY]
|
||||
assert 'href="next_change"' not in html and 'href="stage"' not in html
|
||||
# the popup does not list the key that opens it
|
||||
assert ">?<" not in html
|
||||
|
||||
|
||||
def test_help_popup_dims_file_actions_in_commit_mode():
|
||||
@@ -153,6 +173,8 @@ def test_help_popup_dims_file_actions_in_commit_mode():
|
||||
|
||||
assert "uncommitted only" in html
|
||||
assert html.count('class="dim"') == 5
|
||||
# dimmed rows keep their keys orange
|
||||
assert 'class="kdim"' not in html and '<span class="k">C</span>' in html
|
||||
assert 'href="stage"' not in html and 'href="unstage"' not in html and 'href="discard"' not in html
|
||||
assert 'href="commit"' not in html and 'href="amend"' not in html
|
||||
# everything that still applies stays clickable
|
||||
@@ -174,7 +196,7 @@ def test_help_popup_toggles_and_navigates():
|
||||
|
||||
|
||||
# keymap spellings that the popup shows as a symbol
|
||||
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc"}
|
||||
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc", "shift+enter": "shift+⏎"}
|
||||
|
||||
|
||||
def keymap_bindings():
|
||||
@@ -183,25 +205,44 @@ def keymap_bindings():
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def binding_mode(binding):
|
||||
"""The vdiff_mode a binding is limited to, None when it applies in every mode"""
|
||||
modes = [c["operand"] for c in binding["context"] if c["key"] == "vdiff_mode"]
|
||||
assert len(modes) <= 1, binding["keys"]
|
||||
# a setting context does not compare strings; the plugin answers vdiff_mode itself
|
||||
assert not any(c["key"] == "setting.vdiff_mode" for c in binding["context"]), binding["keys"]
|
||||
return modes[0] if modes else None
|
||||
|
||||
|
||||
def test_keymap_matches_help_table():
|
||||
"""The keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
|
||||
"""Per mode, the keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
|
||||
bindings = keymap_bindings()
|
||||
|
||||
assert all(b["command"] == "vdiff_action" for b in bindings)
|
||||
bound = {b["args"]["action"] for b in bindings}
|
||||
listed = {action for k in render.KEYS for action in k.actions}
|
||||
assert bound == set(commands.ACTIONS) == listed
|
||||
|
||||
# rebinding a key without updating the table would leave the popup and status bar lying
|
||||
bound_keys = {DISPLAY_KEYS.get(key, key) for b in bindings for key in b["keys"]}
|
||||
assert bound_keys == {token for k in render.KEYS for token in k.keys.split()}
|
||||
assert all({c["key"] for c in b["context"]} >= {"setting.vdiff_view"} for b in bindings)
|
||||
assert {binding_mode(b) for b in bindings} == {None, "diff", "branches"}
|
||||
bound_all = set()
|
||||
for mode, table in render.MODE_KEY_GROUPS.items():
|
||||
applicable = [b for b in bindings if binding_mode(b) in (None, mode)]
|
||||
# one meaning per key within a mode
|
||||
keys = [key for b in applicable for key in b["keys"]]
|
||||
assert len(keys) == len(set(keys)), mode
|
||||
bound = {b["args"]["action"] for b in applicable}
|
||||
listed = {action for k in render.keys(mode) for action in k.actions}
|
||||
# "?" opens the popup, so the popup leaves it out
|
||||
assert bound == listed | {"help"}, mode
|
||||
# rebinding a key without updating the table would leave the popup and status bar lying
|
||||
bound_keys = {DISPLAY_KEYS.get(key, key) for key in keys}
|
||||
assert bound_keys == {token for k in render.keys(mode) for token in k.keys.split() if token != "/"} | {"?"}, mode
|
||||
bound_all |= bound
|
||||
assert bound_all == set(commands.ACTIONS)
|
||||
|
||||
|
||||
def test_no_binding_yields_to_vintage_command_mode():
|
||||
"""The review view is read-only, so its letters are vdiff commands in either vim mode
|
||||
|
||||
init_view opens the view in command mode; a binding that still carried the old
|
||||
`command_mode: false` context would go dead there, which is what broke n / p.
|
||||
init_view opens in command mode; a binding still carrying the old `command_mode: false` context
|
||||
would go dead there, which is what broke n / p.
|
||||
"""
|
||||
guard = {"key": "setting.command_mode", "operand": False}
|
||||
for binding in keymap_bindings():
|
||||
@@ -212,8 +253,7 @@ def test_no_binding_yields_to_vintage_command_mode():
|
||||
|
||||
|
||||
def test_minihtml_stylesheets_reject_unsupported_selectors():
|
||||
"""minihtml drops the rest of a stylesheet at a selector it cannot parse; these are the forms
|
||||
that silently broke HELP_CSS, so no stylesheet constant may use them"""
|
||||
"""minihtml drops the rest of a stylesheet at an unparsable selector; these forms broke HELP_CSS"""
|
||||
# descendant/child/sibling combinators, pseudo-classes, attribute selectors, compound classes
|
||||
unsupported = re.compile(r"[\s:>~+\[]|[.#][^.#]*[.#]")
|
||||
sheets = [(name, css) for name, css in vars(render).items() if name.endswith("_CSS") and isinstance(css, str)]
|
||||
@@ -236,13 +276,14 @@ def test_summary_counts_staged_and_unstaged_files():
|
||||
git.FileChange("a.py", "M", True, 1, 1, None),
|
||||
git.FileChange("b.py", "M", False, 1, 1, None),
|
||||
git.FileChange("c.py", "?", False, 1, 0, None),
|
||||
git.FileChange("d.py", "M", True, 2, 0, None, unstaged=True), # partially staged: on both sides, as in the branches view
|
||||
]
|
||||
render.render(view, st)
|
||||
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
|
||||
|
||||
# the untracked file's whole content counts as insertions, the way git.changed_files records it
|
||||
assert summary.startswith("1 staged, 2 unstaged · ")
|
||||
assert '<span class="del">-2</span>' in summary and '<span class="ins">+3</span>' in summary
|
||||
assert summary.startswith("2 staged, 3 unstaged · ")
|
||||
assert '<span class="del">-2</span>' in summary and '<span class="ins">+5</span>' in summary
|
||||
|
||||
|
||||
def test_summary_totals_skip_binary_files_but_still_count_them():
|
||||
@@ -283,6 +324,23 @@ def test_summary_totals_a_commit_and_owns_the_date():
|
||||
assert 'class="commit"' not in html
|
||||
|
||||
|
||||
def test_the_all_files_title_shows_the_current_files_counts_not_the_totals():
|
||||
st = state.ViewState("/repo", all_mode=True)
|
||||
st.files = [git.FileChange("x.py", "M", False, 3, 2, None), git.FileChange("y.py", "M", True, 7, 0, None),
|
||||
git.FileChange("logo.png", "M", False, None, None, None)]
|
||||
st.change = st.files[1]
|
||||
# every file's totals belong on the summary line only
|
||||
html = render._all_header_html(st)
|
||||
title = html.split("</div>", 1)[1]
|
||||
assert '<span class="del">-0</span> <span class="ins">+7</span>' in title and "+10" not in title
|
||||
assert '<span class="del">-2</span> <span class="ins">+10</span>' in html.split("</div>", 1)[0]
|
||||
st.change = st.files[2]
|
||||
assert '<span class="state">binary</span>' in render._all_header_html(st).split("</div>", 1)[1]
|
||||
# file by file, the counts handed in are the shown file's own
|
||||
st.all_mode, st.change = False, st.files[0]
|
||||
assert '<span class="del">-4</span> <span class="ins">+5</span>' in render.header_html(st, 5, 4)
|
||||
|
||||
|
||||
def test_summary_is_omitted_without_a_file_list():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
@@ -316,9 +374,144 @@ def test_flash_survives_a_re_render(monkeypatch):
|
||||
assert render.BELL_KEY in view.regions
|
||||
|
||||
|
||||
def test_bell_scope_falls_back_when_the_scheme_lacks_it(monkeypatch):
|
||||
view = sublime.View()
|
||||
def test_bell_scope_falls_back_when_the_scheme_lacks_it():
|
||||
view = with_loaded_override(sublime.View())
|
||||
assert render._bell_scope(view) == render.SCOPE_BELL
|
||||
monkeypatch.setattr(view, "style_for_scope", lambda scope: {"foreground": "#fff"})
|
||||
# an invisible bell is a silent failure, so fall back to a scope the plugin guarantees
|
||||
assert render._bell_scope(view) == render.SCOPE_INS
|
||||
del view.styles["region.yellowish"]
|
||||
# an invisible bell is a silent failure, so fall back to a scope that is resolving
|
||||
assert render._bell_scope(view) == palette.SCOPE_INS
|
||||
# ... and when the plugin's own scopes are not resolving either, to the scheme's
|
||||
view.override_loaded = False
|
||||
assert render._bell_scope(view) == "diff.inserted"
|
||||
|
||||
|
||||
def token_regions(view, kind, colour):
|
||||
"""The regions painted for one (background kind, colour) pair of the token layer"""
|
||||
scope = palette.scope_for(kind, colour)
|
||||
entry = view.regions.get(scope and scope.replace("vdiff.", "vdiff.tok.", 1))
|
||||
return entry[0] if entry else []
|
||||
|
||||
|
||||
def test_changed_lines_are_repainted_in_the_files_own_colours():
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
|
||||
keyword = view.styles["keyword"]["foreground"]
|
||||
punctuation = view.styles["punctuation.definition"]["foreground"]
|
||||
# " return 10" is inserted: its words carry the keyword colour, its spaces and digits their
|
||||
# own runs. Without this layer all of them would be the tint's flat foreground.
|
||||
inserted = token_regions(view, "ins", keyword)
|
||||
assert sublime.Region(view.text_point(4, 4), view.text_point(4, 10)) in inserted
|
||||
# and a deleted line is painted from the deleted palette, not the inserted one
|
||||
assert sublime.Region(view.text_point(2, 4), view.text_point(2, 10)) in token_regions(view, "del", keyword)
|
||||
assert token_regions(view, "del", punctuation)
|
||||
|
||||
# the plain tint still covers the whole line underneath, so gaps and line ends stay coloured
|
||||
assert view.regions[render.KEY_INS][1] == palette.SCOPE_INS
|
||||
|
||||
|
||||
def test_token_runs_merge_but_stop_at_a_word_diff_range():
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1 +1 @@\n-foo = bar\n+foo = baz\n"))
|
||||
|
||||
keyword = view.styles["keyword"]["foreground"]
|
||||
# only the changed character sits in the stronger tint; the rest of the word keeps the line tint
|
||||
assert token_regions(view, "ins.char", keyword) == [sublime.Region(view.text_point(2, 8), view.text_point(2, 9))]
|
||||
plain = token_regions(view, "ins", keyword)
|
||||
assert sublime.Region(view.text_point(2, 6), view.text_point(2, 8)) in plain
|
||||
assert sublime.Region(view.text_point(2, 0), view.text_point(2, 3)) in plain
|
||||
|
||||
|
||||
def test_token_layer_waits_for_the_scopes_to_resolve():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
|
||||
# the override is not loaded yet, so the lines keep the flat tint and no token key is painted:
|
||||
# an unknown scope would draw its text in sublime's marker green
|
||||
assert view.regions[render.KEY_DEL][1] == "diff.deleted"
|
||||
assert view.regions[render.KEY_INS][1] == "diff.inserted"
|
||||
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||
|
||||
|
||||
def test_token_layer_is_skipped_for_a_diff_too_big_to_read(monkeypatch):
|
||||
monkeypatch.setattr(render, "TOKEN_COLOUR_MAX_CHARS", 4)
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
|
||||
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||
|
||||
|
||||
def test_token_regions_are_dropped_when_a_re_render_no_longer_needs_them():
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
assert [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||
|
||||
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n"))
|
||||
keyword = view.styles["keyword"]["foreground"]
|
||||
# the new diff paints far fewer runs; the keys the old one used must not linger
|
||||
assert token_regions(view, "ins", keyword) == [sublime.Region(view.text_point(3, 0), view.text_point(3, 1))]
|
||||
|
||||
|
||||
def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
|
||||
said = []
|
||||
monkeypatch.setattr(sublime, "status_message", said.append)
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
|
||||
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
||||
# said once per scheme, not once per render
|
||||
render.render(view, make_state())
|
||||
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
||||
|
||||
|
||||
# --- the branches view's keys ----------------------------------------------
|
||||
|
||||
|
||||
def test_help_popup_follows_the_mode():
|
||||
st = make_state()
|
||||
st.mode = state.MODE_BRANCHES
|
||||
_, html = help_popup(st)
|
||||
for keys in ("J K", "j k", "o v", "⏎", "shift+⏎", "L", "P", "F", "d", "f", "c", "b", "r", "esc"):
|
||||
assert f">{keys}<" in html, keys
|
||||
assert "next / previous branch" in html and "fetch all remotes" in html
|
||||
assert ">s / S<" not in html and ">n p<" not in html and ">v<" not in html
|
||||
# a commit shown before switching dims nothing here: the branch keys do not depend on it
|
||||
st.sha = "abc"
|
||||
_, html = help_popup(st)
|
||||
assert 'class="dim"' not in html and 'href="checkout"' in html and 'href="pull"' in html
|
||||
# the diff popup lists the way in
|
||||
st.mode = state.MODE_DIFF
|
||||
_, html = help_popup(st)
|
||||
assert ">b<" in html and 'href="branches"' in html and ">J K<" not in html
|
||||
|
||||
|
||||
def test_status_hints_follow_the_mode():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
assert view.status[render.STATUS_KEY] == render.key_hints() and "b branches" in render.key_hints()
|
||||
hints = render.key_hints(state.MODE_BRANCHES)
|
||||
assert "J K branch" in hints and "L pull" in hints and "s / S" not in hints
|
||||
render.init_view(view, state.MODE_BRANCHES)
|
||||
assert view.status[render.STATUS_KEY] == hints and view.settings().get(state.MODE_SETTING) == "branches"
|
||||
|
||||
|
||||
def test_all_files_view_tokenises_only_once_the_colours_can_show(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(render, "_all_colours", lambda view, st, paths: calls.append(list(paths)))
|
||||
st = make_state()
|
||||
st.all_mode = True
|
||||
st.files = [st.change]
|
||||
st.all_diffs = {st.change.path: (st.lines, st.blocks, False)}
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
# before the restart that loads the scheme rules the colours could not be painted: no tokenising
|
||||
render.render_all(view, st)
|
||||
assert calls == []
|
||||
render.render_all(with_loaded_override(view), st)
|
||||
assert calls == [[st.change.path]]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
{ "caption": "vDiff: Changed Files", "command": "vdiff_files" },
|
||||
{ "caption": "vDiff: Recent Commits", "command": "vdiff_commits" }
|
||||
{ "caption": "vDiff: Recent Commits", "command": "vdiff_commits" },
|
||||
{ "caption": "vDiff: Branches", "command": "vdiff_branches" }
|
||||
]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
// how a review view first shows its files: "file_by_file" or "all_in_one"; v switches between them
|
||||
"default_view_mode": "file_by_file",
|
||||
|
||||
// number of commits listed by "vDiff: Recent Commits"
|
||||
"commits_count": 30,
|
||||
|
||||
@@ -11,6 +14,23 @@
|
||||
// mark the changed characters inside paired deleted/inserted lines
|
||||
"word_diff": true,
|
||||
|
||||
// number of unchanged lines shown around each change in the all-files view
|
||||
"all_files_context": 5,
|
||||
|
||||
// background of deleted and inserted lines, e.g. "#d6464633"; empty uses the colour scheme's diff colours
|
||||
"deleted_line_background": "",
|
||||
"inserted_line_background": "",
|
||||
|
||||
// line-number column of the all-files view, e.g. "#888888" on "#1e1e1e"; empty keeps the plain-text look
|
||||
"line_numbers_foreground": "",
|
||||
"line_numbers_background": "",
|
||||
|
||||
// file heading of the all-files view: its background and the colours of its status letter and -/+ counts
|
||||
"file_header_background": "#0000EE",
|
||||
"file_header_status_foreground": "#ffffff",
|
||||
"file_header_deleted_foreground": "#d64646",
|
||||
"file_header_inserted_foreground": "#46d646",
|
||||
|
||||
// git executable; sublime's PATH may not include a user-installed git
|
||||
"git_binary": "git",
|
||||
|
||||
|
||||
713
vdiff/branches.py
Normal file
713
vdiff/branches.py
Normal file
@@ -0,0 +1,713 @@
|
||||
"""The branches view: local branches and remotes in a left column, the selected branch's log on the right
|
||||
|
||||
Drawn into the same review view as the diffs, as plain text rows of two padded columns. The selected
|
||||
branch is a tinted cell moved by J / K; the caret, and with it the tinted commit, is the commit cursor
|
||||
moved by j / k
|
||||
"""
|
||||
import html
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypeVar
|
||||
|
||||
import sublime
|
||||
|
||||
from . import git, palette, render
|
||||
from .git import Commit, FileChange, GitError, Ref
|
||||
from .state import MODE_BRANCHES, BranchRow, ViewState
|
||||
from .utils import DEBOUNCE_MS, commits_count, file_count, run_async, status
|
||||
|
||||
# an ACTIONS entry: act(view, window, state) on the review view it fired in
|
||||
Action = Callable[[sublime.View, sublime.Window, ViewState], None]
|
||||
|
||||
# the left column: its padding after the longest cell, and the width it never exceeds
|
||||
LEFT_PAD = 2
|
||||
LEFT_MAX = 32
|
||||
# the right column: "Author: " and friends, and the bounds of its width when the viewport is odd
|
||||
LABEL_WIDTH = 13
|
||||
RIGHT_MIN = 60
|
||||
RIGHT_MAX = 200
|
||||
# a commit is its subject, author, date and stats rows, then a rule
|
||||
ROWS_PER_COMMIT = 5
|
||||
# box drawing: the column line, the rule under each item, and the column line's branch into a rule on the rule rows
|
||||
COLUMN_GAP = "│ "
|
||||
RULE = "─"
|
||||
JUNCTION = "├─"
|
||||
HEADING_BRANCHES = "BRANCHES"
|
||||
HEADING_REMOTES = "REMOTES"
|
||||
|
||||
NOT_HERE = "works in the branches view, press b"
|
||||
|
||||
# the result of a load's work, handed to its adopt step
|
||||
T = TypeVar("T")
|
||||
# a coloured part of a cell: (start, end, "del" | "ins")
|
||||
Span = Tuple[int, int, str]
|
||||
# a right-column cell: its text, the index of its item (0 the working tree, i + 1 commit i, -1 none) and its spans
|
||||
Cell = Tuple[str, int, List[Span]]
|
||||
# the right column's first item: the working tree
|
||||
WORKTREE = 0
|
||||
NO_CHANGES = "No Changes"
|
||||
# a left-column cell: its text, its branch (None for headings and remote names) and the branch's
|
||||
# standing against its upstream ("↑1 ↓2", "gone" or ""), shown right-aligned in the cell
|
||||
LeftCell = Tuple[str, Optional[Ref], str]
|
||||
|
||||
|
||||
# --- loading ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _pick(refs: List[Ref], wanted: Optional[str]) -> Optional[Ref]:
|
||||
"""The branch to select: `wanted` while it exists, else the checked-out one, else the first local, else the first"""
|
||||
for ref in refs:
|
||||
if ref.name == wanted:
|
||||
return ref
|
||||
return next((r for r in refs if r.current), next((r for r in refs if r.remote is None), refs[0] if refs else None))
|
||||
|
||||
|
||||
def load(view: sublime.View, st: ViewState, message: Optional[str] = None, keep_caret: bool = False) -> None:
|
||||
"""List the branches and log the selected one off the UI thread, then draw the view
|
||||
|
||||
`message` goes to the status bar first; `keep_caret` keeps the commit cursor where it is. The diff
|
||||
state is vacated: what was shown before is not what persist records or the refresh checks any more
|
||||
"""
|
||||
if message:
|
||||
status(message)
|
||||
st.mode = MODE_BRANCHES
|
||||
st.sha = st.commit = st.change = st.requested = None
|
||||
st.lines, st.blocks, st.binary = [], [], False
|
||||
repo, wanted, count = st.repo, st.branch_ref, commits_count()
|
||||
|
||||
def work() -> Tuple[str, List[Ref], List[str], Optional[Ref], List[Commit], List[FileChange]]:
|
||||
"""HEAD's hash, the branches, the remotes, the branch to select and its log, and the working tree's changes
|
||||
|
||||
The calls that do not wait on the branch list run alongside it; only the log needs to know the branch
|
||||
"""
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
# git status first, the slowest; the caches are warmed too, so the fingerprint after the render costs
|
||||
# no git call on the UI thread (leaving the block waits for them)
|
||||
changes, sha, remotes = pool.submit(git.changed_files, repo), pool.submit(git.head_sha, repo), pool.submit(git.remotes, repo)
|
||||
pool.submit(git.ref_paths, repo)
|
||||
git.index_path(repo)
|
||||
refs = git.branches(repo)
|
||||
ref = _pick(refs, wanted)
|
||||
commits = git.ref_log(repo, ref.name, count) if ref else []
|
||||
return sha.result(), refs, remotes.result(), ref, commits, changes.result()
|
||||
|
||||
def adopt(result: Tuple[str, List[Ref], List[str], Optional[Ref], List[Commit], List[FileChange]]) -> None:
|
||||
"""Take the listing over"""
|
||||
st.branch_head_sha, st.branch_refs, st.branch_remotes, ref, st.branch_commits, st.branch_changes = result
|
||||
st.branch_ref = ref.name if ref else None
|
||||
|
||||
_reload(view, st, work, adopt, keep_caret)
|
||||
|
||||
|
||||
def _reload(view: sublime.View, st: ViewState, work: Callable[[], T], adopt: Callable[[T], None], keep_caret: bool) -> None:
|
||||
"""Run `work` off the UI thread, then `adopt` its result and redraw, unless a later load superseded it or the view closed"""
|
||||
st.token += 1
|
||||
token = st.token
|
||||
|
||||
def done(result: T) -> None:
|
||||
"""Adopt and draw the result of the newest load only"""
|
||||
if token != st.token or not view.is_valid():
|
||||
return
|
||||
adopt(result)
|
||||
render_branches(view, st, keep_caret=keep_caret)
|
||||
st.rendered_token = token
|
||||
|
||||
run_async(work, done)
|
||||
|
||||
|
||||
def _load_log(view: sublime.View, st: ViewState) -> None:
|
||||
"""Log the selected branch off the UI thread and redraw with the cursor back on the working-tree entry"""
|
||||
repo, name, count = st.repo, st.branch_ref, commits_count()
|
||||
|
||||
def adopt(commits: List[Commit]) -> None:
|
||||
"""Take the log over"""
|
||||
st.branch_commits = commits
|
||||
|
||||
_reload(view, st, lambda: git.ref_log(repo, name, count) if name is not None else [], adopt, keep_caret=False)
|
||||
|
||||
|
||||
def load_worktree(view: sublime.View, st: ViewState) -> None:
|
||||
"""Relist the working tree's changes off the UI thread and redraw its entry; the branches and the log stand"""
|
||||
repo = st.repo
|
||||
|
||||
def adopt(changes: List[FileChange]) -> None:
|
||||
"""Take the working tree's changes over"""
|
||||
st.branch_changes = changes
|
||||
|
||||
_reload(view, st, lambda: git.changed_files(repo), adopt, keep_caret=True)
|
||||
|
||||
|
||||
def _step(view: sublime.View, position: int, count: int, delta: int) -> Optional[int]:
|
||||
"""Where a step of `delta` from `position` among `count` items lands: off them (-1) the first for a step forward
|
||||
and the last for one back; None, after a flash, past either end"""
|
||||
if position < 0:
|
||||
return 0 if delta > 0 else count - 1
|
||||
index = position + delta
|
||||
if not 0 <= index < count:
|
||||
render.flash(view)
|
||||
return None
|
||||
return index
|
||||
|
||||
|
||||
def select(view: sublime.View, st: ViewState, delta: int) -> None:
|
||||
"""Move the branch selection `delta` branches on, ringing at the ends, and log the new one
|
||||
|
||||
The cell and the header move at once; the log is fetched once the key rests, so holding J does
|
||||
not start one git process per branch passed
|
||||
"""
|
||||
# git lists the local branches before the remote ones, the order J / K step in
|
||||
names = [r.name for r in st.branch_refs]
|
||||
if not names:
|
||||
status("no branches")
|
||||
return
|
||||
position = names.index(st.branch_ref) if st.branch_ref in names else -1
|
||||
index = _step(view, position, len(names), delta)
|
||||
if index is None or index == position:
|
||||
return
|
||||
st.branch_ref = names[index]
|
||||
_paint_selection(view, st)
|
||||
render._header(view, st, header_html(st))
|
||||
st.highlight += 1
|
||||
ticket = st.highlight
|
||||
|
||||
def fetch() -> None:
|
||||
"""Load the log unless another selection came since"""
|
||||
if ticket == st.highlight and view.is_valid():
|
||||
_load_log(view, st)
|
||||
|
||||
sublime.set_timeout(fetch, DEBOUNCE_MS)
|
||||
|
||||
|
||||
# --- drawing ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _standing_parts(ref: Ref) -> List[Tuple[str, str]]:
|
||||
"""How a local branch stands against its upstream, as (text, "ins" | "del") parts: ahead green, behind or gone red;
|
||||
zero counts are left out, and a level or untracked branch has none"""
|
||||
if ref.gone:
|
||||
return [("gone", "del")]
|
||||
parts = [(f"\u2191{ref.ahead}", "ins")] if ref.ahead else []
|
||||
if ref.behind:
|
||||
parts.append((f"\u2193{ref.behind}", "del"))
|
||||
return parts
|
||||
|
||||
|
||||
def _standing(ref: Ref) -> str:
|
||||
"""The standing as the left column shows it: "↑1 ↓2", "gone", or """""
|
||||
return " ".join(text for text, _ in _standing_parts(ref))
|
||||
|
||||
|
||||
def _standing_html(ref: Ref) -> str:
|
||||
"""The standing for the header, or "up to date" when there is nothing to say"""
|
||||
parts = _standing_parts(ref)
|
||||
if not parts:
|
||||
return '<span class="state">up to date</span>'
|
||||
return " ".join(f'<span class="{kind}">{text}</span>' for text, kind in parts)
|
||||
|
||||
|
||||
def _left_cells(st: ViewState) -> List[LeftCell]:
|
||||
"""(text, branch, standing) per row of the left column; headings and remote names carry no branch"""
|
||||
cells: List[LeftCell] = [(HEADING_BRANCHES, None, "")]
|
||||
for ref in st.branch_refs:
|
||||
if ref.remote is None:
|
||||
cells.append((("* " if ref.current else " ") + ref.name, ref, _standing(ref)))
|
||||
cells.append((HEADING_REMOTES, None, ""))
|
||||
# a remote keeps its row even before anything was fetched from it
|
||||
remotes = list(dict.fromkeys(list(st.branch_remotes) + [r.remote for r in st.branch_refs if r.remote]))
|
||||
for remote in remotes:
|
||||
cells.append((remote, None, ""))
|
||||
cells.extend((" " + ref.branch, ref, "") for ref in st.branch_refs if ref.remote == remote)
|
||||
return cells
|
||||
|
||||
|
||||
def _left_width(cells: List[LeftCell]) -> int:
|
||||
"""Width of the left column for its cells: the longest (with its standing and a space) plus padding, capped"""
|
||||
return min(LEFT_MAX, max(len(text) + (len(standing) + 1 if standing else 0) for text, _, standing in cells) + LEFT_PAD)
|
||||
|
||||
|
||||
def _clip(text: str, width: int) -> str:
|
||||
"""`text` cut to `width` characters with an ellipsis"""
|
||||
return text if len(text) <= width else text[: width - 1] + "\u2026"
|
||||
|
||||
|
||||
def _standing_spans(ref: Ref, width: int) -> List[Span]:
|
||||
"""The spans colouring a local branch's standing on the right edge of its cell `width` wide"""
|
||||
spans: List[Span] = []
|
||||
at = _standing_room(width, _standing(ref))
|
||||
for text, kind in _standing_parts(ref):
|
||||
spans.append((at, at + len(text), kind))
|
||||
at += len(text) + 1
|
||||
return spans
|
||||
|
||||
|
||||
def _standing_room(width: int, standing: str) -> int:
|
||||
"""Where a standing starts in a cell `width` wide: on the right edge, before the trailing space"""
|
||||
return width - 1 - len(standing)
|
||||
|
||||
|
||||
def _left_text(cell: LeftCell, width: int) -> str:
|
||||
"""The cell padded to `width` with its standing on the right edge"""
|
||||
text, _, standing = cell
|
||||
if not standing:
|
||||
return f"{_clip(text, width - 1):<{width}}"
|
||||
# the name gives way first: the counts are what the column is for
|
||||
room = _standing_room(width, standing)
|
||||
return f"{_clip(text, room - 1):<{room}}{standing} "
|
||||
|
||||
|
||||
def _right_width(view: sublime.View, left_width: int) -> int:
|
||||
"""Columns left for the right column after the left one and the gap, within sane bounds"""
|
||||
try:
|
||||
columns = int(view.viewport_extent()[0] / (view.em_width() or 8))
|
||||
except Exception:
|
||||
columns = 0
|
||||
return max(RIGHT_MIN, min(RIGHT_MAX, columns - left_width - len(COLUMN_GAP)))
|
||||
|
||||
|
||||
def _stats_cell(files: Optional[int], insertions: Optional[int], deletions: Optional[int]) -> Tuple[str, List[Span]]:
|
||||
"""A Stats row ("Stats: 3 files changed -5 +12") with the spans colouring its counts; no diffstat when `files` is None"""
|
||||
stats = f"{'Stats:':<{LABEL_WIDTH}}"
|
||||
spans: List[Span] = []
|
||||
if files is None:
|
||||
return stats + "(no diffstat)", spans
|
||||
stats += f"{file_count(files)} changed "
|
||||
dele, ins = f"-{deletions}", f"+{insertions}"
|
||||
spans.append((len(stats), len(stats) + len(dele), "del"))
|
||||
stats += dele + " "
|
||||
spans.append((len(stats), len(stats) + len(ins), "ins"))
|
||||
return stats + ins, spans
|
||||
|
||||
|
||||
def _worktree_cells(changes: List[FileChange], width: int) -> List[Cell]:
|
||||
"""The working-tree entry: the staged / unstaged file counts and their diffstat, or "No Changes", padded to five rows"""
|
||||
if not changes:
|
||||
head, stats, spans = NO_CHANGES, "", []
|
||||
else:
|
||||
stat = git.worktree_stats(changes)
|
||||
head = f"{file_count(stat.staged, 'staged')}, {file_count(stat.unstaged, 'unstaged')}"
|
||||
stats, spans = _stats_cell(stat.files, stat.insertions, stat.deletions)
|
||||
return [(head, WORKTREE, []), (stats, WORKTREE, spans), ("", WORKTREE, []), ("", WORKTREE, []), (RULE * width, WORKTREE, [])]
|
||||
|
||||
|
||||
def _right_cells(changes: List[FileChange], commits: List[Commit], width: int) -> List[Cell]:
|
||||
"""The right column: the working-tree entry, then five rows per commit, with the spans of the tags and counts to colour"""
|
||||
cells = _worktree_cells(changes, width)
|
||||
for i, c in enumerate(commits, 1):
|
||||
author = f"{'Author:':<{LABEL_WIDTH}}{c.author}"
|
||||
author_spans: List[Span] = []
|
||||
if c.tags:
|
||||
# the tags on the right edge, one space at least from the author
|
||||
tags = " ".join(c.tags)
|
||||
author = f"{author:<{max(len(author) + 1, width - len(tags))}}{tags}"
|
||||
author_spans.append((len(author) - len(tags), len(author), "ins"))
|
||||
stats, stats_spans = _stats_cell(c.files, c.insertions, c.deletions)
|
||||
cells.append((c.subject or "(no subject)", i, []))
|
||||
cells.append((author, i, author_spans))
|
||||
cells.append((f"{'Date:':<{LABEL_WIDTH}}{c.date}", i, []))
|
||||
cells.append((stats, i, stats_spans))
|
||||
cells.append((RULE * width, i, []))
|
||||
if not commits:
|
||||
cells.append(("(no commits)", -1, []))
|
||||
return cells
|
||||
|
||||
|
||||
def _item_count(st: ViewState) -> int:
|
||||
"""How many items the right column holds: the working tree and the commits"""
|
||||
return len(st.branch_commits) + 1
|
||||
|
||||
|
||||
def _caret_commit(view: sublime.View, st: ViewState) -> int:
|
||||
"""Index of the item whose rows hold the caret (WORKTREE or a commit's), -1 off them"""
|
||||
row = render.caret_index(view)
|
||||
if 0 <= row < len(st.branch_rows):
|
||||
return st.branch_rows[row].commit
|
||||
return -1
|
||||
|
||||
|
||||
class Paint(NamedTuple):
|
||||
"""What the painters need from the last render, worked out once so a j / k or J / K repaint asks Sublime nothing
|
||||
|
||||
`starts` / `ends` are each row's text points, `spans` the right column's coloured (start, end, kind) points
|
||||
per row, `standings` the left column's, and `scopes` the scope names resolved for this render
|
||||
"""
|
||||
|
||||
starts: List[int]
|
||||
ends: List[int]
|
||||
spans: List[List[Span]]
|
||||
standings: List[List[Span]]
|
||||
scopes: Dict[str, Optional[str]]
|
||||
|
||||
|
||||
def _paint(view: sublime.View, st: ViewState, rows: List[str]) -> Paint:
|
||||
"""The geometry of `rows` as drawn, and the scopes the scheme answers for now"""
|
||||
starts = render._row_starts_for_text(rows)
|
||||
spans = [[(start + a, start + b, kind) for a, b, kind in row.spans] for start, row in zip(starts, st.branch_rows)]
|
||||
standings = [[(start + a, start + b, kind) for a, b, kind in _standing_spans(row.ref, st.branch_width)]
|
||||
if row.ref is not None and row.ref.remote is None else []
|
||||
for start, row in zip(starts, st.branch_rows)]
|
||||
scopes: Dict[str, Optional[str]] = {
|
||||
"sel": render._file_header_scope(view), "cursor": palette.cursor_scope(view),
|
||||
"del": palette.text_scope(view, "del"), "ins": palette.text_scope(view, "ins"),
|
||||
"sel.del": palette.count_scope(view, "del"), "sel.ins": palette.count_scope(view, "ins"),
|
||||
"cursor.del": palette.cursor_text_scope(view, "del"), "cursor.ins": palette.cursor_text_scope(view, "ins"),
|
||||
}
|
||||
return Paint(starts, [start + len(row) for start, row in zip(starts, rows)], spans, standings, scopes)
|
||||
|
||||
|
||||
def _add(view: sublime.View, key: str, regions: List[sublime.Region], scope: Optional[str]) -> None:
|
||||
"""Fill `regions` under `key` with `scope`'s colours"""
|
||||
view.add_regions(key, regions, scope or "", flags=sublime.DRAW_NO_OUTLINE)
|
||||
|
||||
|
||||
def _paint_cursor(view: sublime.View, st: ViewState, index: int) -> None:
|
||||
"""Tint the right-column cells of item `index` (subject to stats) as the commit cursor, once the scope is live,
|
||||
and colour the counts and tags of every row: on the cursor's rows on its background, elsewhere on the editor's
|
||||
|
||||
No two regions overlap, as a region painted later repaints the text under it in its own colour. Until the
|
||||
cursor scope is live Sublime's own line highlight marks the caret row across both columns, so it stays on
|
||||
"""
|
||||
paint = st.branch_paint
|
||||
assert paint is not None
|
||||
scope = paint.scopes["cursor"]
|
||||
view.settings().set("highlight_line", scope is None)
|
||||
first, last = index * ROWS_PER_COMMIT, index * ROWS_PER_COMMIT + ROWS_PER_COMMIT - 1 # the rule row stays untinted
|
||||
cursor = scope is not None and 0 <= index < _item_count(st)
|
||||
gap = st.branch_width + len(COLUMN_GAP)
|
||||
tint: List[sublime.Region] = []
|
||||
coloured: Dict[str, List[sublime.Region]] = {"del": [], "ins": [], "cursor.del": [], "cursor.ins": []}
|
||||
for i, spans in enumerate(paint.spans):
|
||||
on_cursor = cursor and first <= i < last
|
||||
at = paint.starts[i] + gap
|
||||
for a, b, kind in spans:
|
||||
if on_cursor:
|
||||
tint.append(sublime.Region(at, a))
|
||||
coloured["cursor." + kind if on_cursor else kind].append(sublime.Region(a, b))
|
||||
at = b
|
||||
if on_cursor:
|
||||
tint.append(sublime.Region(at, paint.ends[i]))
|
||||
_add(view, render.KEY_BRANCH_DEL, coloured["del"], paint.scopes["del"])
|
||||
_add(view, render.KEY_BRANCH_INS, coloured["ins"], paint.scopes["ins"])
|
||||
if not cursor:
|
||||
for key in (render.KEY_BRANCH_CURSOR, render.KEY_BRANCH_CURSOR_DEL, render.KEY_BRANCH_CURSOR_INS):
|
||||
view.erase_regions(key)
|
||||
return
|
||||
_add(view, render.KEY_BRANCH_CURSOR, tint, scope)
|
||||
_add(view, render.KEY_BRANCH_CURSOR_DEL, coloured["cursor.del"], paint.scopes["cursor.del"])
|
||||
_add(view, render.KEY_BRANCH_CURSOR_INS, coloured["cursor.ins"], paint.scopes["cursor.ins"])
|
||||
|
||||
|
||||
def _paint_selection(view: sublime.View, st: ViewState) -> None:
|
||||
"""Tint the selected branch's cell, or nothing when none is listed, and colour the branches' standings
|
||||
|
||||
The standings are filled regions, as only those take a scope's text colour, so the selected row's
|
||||
carry its background rather than the editor's; a change of selection repaints them all
|
||||
"""
|
||||
paint = st.branch_paint
|
||||
assert paint is not None
|
||||
selected: List[sublime.Region] = []
|
||||
standings: Dict[str, List[sublime.Region]] = {"del": [], "ins": [], "sel.del": [], "sel.ins": []}
|
||||
for i, row in enumerate(st.branch_rows):
|
||||
chosen = row.ref is not None and row.ref.name == st.branch_ref
|
||||
if chosen:
|
||||
selected.append(sublime.Region(paint.starts[i], paint.starts[i] + st.branch_width))
|
||||
for a, b, kind in paint.standings[i]:
|
||||
standings["sel." + kind if chosen else kind].append(sublime.Region(a, b))
|
||||
_add(view, render.KEY_BRANCH_SEL, selected, paint.scopes["sel"])
|
||||
_add(view, render.KEY_BRANCH_STANDING_DEL, standings["del"], paint.scopes["del"])
|
||||
_add(view, render.KEY_BRANCH_STANDING_INS, standings["ins"], paint.scopes["ins"])
|
||||
_add(view, render.KEY_BRANCH_SEL_DEL, standings["sel.del"], paint.scopes["sel.del"])
|
||||
_add(view, render.KEY_BRANCH_SEL_INS, standings["sel.ins"], paint.scopes["sel.ins"])
|
||||
|
||||
|
||||
def _put_caret(view: sublime.View, st: ViewState, index: int, show: bool) -> None:
|
||||
"""Put the caret on item `index`'s first row and tint the item; `show` scrolls it into view"""
|
||||
point = render.index_point(view, index * ROWS_PER_COMMIT)
|
||||
view.sel().clear()
|
||||
view.sel().add(sublime.Region(point))
|
||||
_paint_cursor(view, st, index)
|
||||
if show:
|
||||
view.show(point)
|
||||
|
||||
|
||||
def render_branches(view: sublime.View, st: ViewState, keep_caret: bool = False) -> None:
|
||||
"""Draw the two columns, tint the selection, and put the caret on an item's first row
|
||||
|
||||
`keep_caret` leaves the cursor on the item it was on and keeps the scroll; otherwise the
|
||||
working-tree entry is the cursor and the view scrolls to the top
|
||||
"""
|
||||
commit = _caret_commit(view, st) if keep_caret else -1
|
||||
viewport = view.viewport_position() if keep_caret else (0, 0)
|
||||
# the cells of both columns, then rows of the longer column's length
|
||||
left = _left_cells(st)
|
||||
width = st.branch_width = _left_width(left)
|
||||
right_width = _right_width(view, width)
|
||||
right = _right_cells(st.branch_changes, st.branch_commits, right_width)
|
||||
rows: List[str] = []
|
||||
metadata: List[BranchRow] = []
|
||||
gap = width + len(COLUMN_GAP)
|
||||
for i in range(max(len(left), len(right))):
|
||||
left_text = _left_text(left[i], width) if i < len(left) else " " * width
|
||||
cell, index, spans = right[i] if i < len(right) else ("", -1, [])
|
||||
rows.append(left_text + (JUNCTION if cell == RULE * right_width else COLUMN_GAP) + cell)
|
||||
# the spans relative to the row's start, for the cursor painter to colour
|
||||
metadata.append(BranchRow(left[i][1] if i < len(left) else None, index, tuple((gap + a, gap + b, kind) for a, b, kind in spans)))
|
||||
st.branch_rows = metadata
|
||||
render._replace(view, "\n".join(rows))
|
||||
view.assign_syntax("scope:text.plain")
|
||||
palette.ensure(view)
|
||||
if palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False):
|
||||
status(render.NEEDS_RESTART_BRANCHES)
|
||||
st.branch_paint = _paint(view, st, rows)
|
||||
# the selection and, with it, the standings; the tags and counts are painted with the cursor, by _put_caret
|
||||
_paint_selection(view, st)
|
||||
render._finish(view, st, MODE_BRANCHES, "⟐ branches", header_html(st), fingerprint)
|
||||
# the caret: on the kept item's first row, else on the working-tree entry
|
||||
if not 0 <= commit < _item_count(st):
|
||||
commit = WORKTREE
|
||||
_put_caret(view, st, commit, show=False)
|
||||
view.set_viewport_position(viewport, False)
|
||||
if keep_caret:
|
||||
view.show(render.index_point(view, commit * ROWS_PER_COMMIT))
|
||||
|
||||
|
||||
def fingerprint(st: ViewState) -> Tuple[Any, ...]:
|
||||
"""Cheap "did anything change" key: the refs' mtimes (what a checkout, commit, fetch, push or branch change
|
||||
touches), then the working-tree entry's, so a refresh can tell which half moved"""
|
||||
refs = tuple(git.mtime(path) for path in git.ref_paths(st.repo))
|
||||
return refs, render.worktree_fingerprint(st.repo, st.branch_changes)
|
||||
|
||||
|
||||
def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
|
||||
"""Reload when the refs changed since the view was drawn, or relist only the working tree when only it did"""
|
||||
# not while a load is in flight: its result would only be thrown away
|
||||
if st.token != st.rendered_token:
|
||||
return
|
||||
now = fingerprint(st)
|
||||
if now == st.fingerprint:
|
||||
return
|
||||
if st.fingerprint and now[0] == st.fingerprint[0]:
|
||||
# a save, something staged: the branches and the log stand
|
||||
load_worktree(view, st)
|
||||
else:
|
||||
load(view, st, keep_caret=True)
|
||||
|
||||
|
||||
def header_html(st: ViewState) -> str:
|
||||
"""The minihtml header: the repository and HEAD, then the selected branch, its upstream and the action links"""
|
||||
current = st.current_branch()
|
||||
if current is not None:
|
||||
where = f'on <span class="hash">{html.escape(current.name)}</span>'
|
||||
elif st.branch_head_sha:
|
||||
where = f'detached at <span class="hash">{html.escape(st.branch_head_sha)}</span>'
|
||||
else:
|
||||
where = "no commits"
|
||||
summary = f'<div class="summary">Branches · {html.escape(os.path.basename(st.repo))} · {where}</div>'
|
||||
ref = st.selected_ref()
|
||||
if ref is None:
|
||||
title = '<span class="path">no branch selected</span>'
|
||||
elif ref.remote is not None:
|
||||
title = f'<span class="path">{html.escape(ref.name)}</span> <span class="when">remote branch of {html.escape(ref.remote)}</span>'
|
||||
else:
|
||||
title = f'<span class="path">{html.escape(ref.name)}</span>'
|
||||
if ref.upstream:
|
||||
title += f' <span class="when">→ {html.escape(ref.upstream)}</span> {_standing_html(ref)}'
|
||||
actions: List[str] = []
|
||||
if ref is not None:
|
||||
actions.append('<a href="checkout">Checkout</a>')
|
||||
if ref.remote is not None:
|
||||
actions.append('<a href="checkout_detached">Detached</a>')
|
||||
else:
|
||||
actions.append('<a href="pull">Pull</a>')
|
||||
actions.append('<a href="push">Push</a>')
|
||||
actions.append('<a href="fetch">Fetch</a>')
|
||||
if ref is not None and ref.remote is None:
|
||||
actions.append('<a href="delete_branch" class="danger">Delete</a>')
|
||||
return render.header_frame(summary, title, actions)
|
||||
|
||||
|
||||
# --- actions ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _selected(st: ViewState, local_verb: Optional[str] = None) -> Optional[Ref]:
|
||||
"""The selected branch, with a status message when there is none; with `local_verb`, a remote one does not count either"""
|
||||
ref = st.selected_ref()
|
||||
if ref is None:
|
||||
status("no branch selected")
|
||||
elif local_verb and ref.remote is not None:
|
||||
status(f"{local_verb} works on a local branch, select one")
|
||||
return None
|
||||
return ref
|
||||
|
||||
|
||||
def _act_checkout(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Check out the selected branch; a remote one as the same-named local branch, created tracking it"""
|
||||
ref = _selected(st)
|
||||
if ref is None:
|
||||
return
|
||||
if ref.current:
|
||||
status(f"already on {ref.name}")
|
||||
return
|
||||
locals_ = [r.branch for r in st.branch_refs if r.remote is None]
|
||||
|
||||
def done(_: None) -> None:
|
||||
"""Select the branch now checked out and relist"""
|
||||
st.branch_ref = ref.branch
|
||||
load(view, st, f"checked out {ref.branch}")
|
||||
|
||||
run_async(lambda: git.checkout(st.repo, ref, locals_), done)
|
||||
|
||||
|
||||
def _act_checkout_detached(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Check out the selected remote branch with HEAD detached"""
|
||||
ref = _selected(st)
|
||||
if ref is None:
|
||||
return
|
||||
if ref.remote is None:
|
||||
status("detached checkout is for remote branches, press Enter for a local one")
|
||||
return
|
||||
run_async(lambda: git.checkout_detached(st.repo, ref.name), lambda _: load(view, st, f"checked out {ref.name} detached"))
|
||||
|
||||
|
||||
def _act_pull(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Pull the checked-out branch, or fast-forward another local branch from its upstream"""
|
||||
ref = _selected(st, "pull")
|
||||
if ref is None:
|
||||
return
|
||||
if ref.current:
|
||||
status(f"pulling {ref.name}…")
|
||||
run_async(lambda: git.pull(st.repo), lambda _: load(view, st, f"pulled {ref.name}"))
|
||||
return
|
||||
if not ref.upstream or ref.gone:
|
||||
status(f"{ref.name} has no upstream to pull from")
|
||||
return
|
||||
remote, remote_branch = ref.upstream_remote, ref.upstream_branch
|
||||
if remote is None or remote_branch is None:
|
||||
status(f"{ref.name} tracks the local branch {ref.upstream}, pull works with a remote upstream")
|
||||
return
|
||||
status(f"fast-forwarding {ref.name} from {ref.upstream}…")
|
||||
run_async(lambda: git.fetch_into(st.repo, remote, remote_branch, ref.name),
|
||||
lambda _: load(view, st, f"fast-forwarded {ref.name} to {ref.upstream}"))
|
||||
|
||||
|
||||
def _act_push(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Push the selected local branch to its upstream, or to its remote (origin by default), recording that as the upstream"""
|
||||
ref = _selected(st, "push")
|
||||
if ref is None:
|
||||
return
|
||||
if ref.upstream and not ref.gone:
|
||||
# to the upstream itself, whatever it is called there
|
||||
remote, remote_branch = ref.upstream_remote, ref.upstream_branch
|
||||
if remote is None:
|
||||
status(f"{ref.name} tracks the local branch {ref.upstream}, there is no remote to push to")
|
||||
return
|
||||
work = lambda: git.push(st.repo, remote, ref.name, remote_branch=remote_branch) # noqa: E731
|
||||
else:
|
||||
remotes = st.branch_remotes
|
||||
if not remotes:
|
||||
status("no remote to push to")
|
||||
return
|
||||
remote = "origin" if "origin" in remotes else remotes[0]
|
||||
work = lambda: git.push(st.repo, remote, ref.name, set_upstream=True) # noqa: E731
|
||||
status(f"pushing {ref.name} to {remote}…")
|
||||
run_async(work, lambda _: load(view, st, f"pushed {ref.name} to {remote}"))
|
||||
|
||||
|
||||
def _act_fetch(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Fetch every remote, pruning, and relist"""
|
||||
status("fetching all remotes…")
|
||||
run_async(lambda: git.fetch_all(st.repo), lambda _: load(view, st, "fetched all remotes", keep_caret=True))
|
||||
|
||||
|
||||
def _act_delete(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Delete the selected local branch after a confirmation; a second one forces an unmerged branch"""
|
||||
ref = _selected(st, "delete")
|
||||
if ref is None:
|
||||
return
|
||||
if ref.current:
|
||||
status(f"{ref.name} is checked out, switch away before deleting it")
|
||||
return
|
||||
if not sublime.ok_cancel_dialog(f"Delete the branch {ref.name}?", "Delete"):
|
||||
return
|
||||
|
||||
def work() -> Optional[GitError]:
|
||||
"""Delete, handing back the refusal of an unmerged branch for the second question"""
|
||||
try:
|
||||
git.delete_branch(st.repo, ref.name)
|
||||
except GitError as error:
|
||||
if "not fully merged" not in error.stderr:
|
||||
raise
|
||||
return error
|
||||
return None
|
||||
|
||||
def done(refusal: Optional[GitError]) -> None:
|
||||
"""Relist, or offer to force the deletion"""
|
||||
if refusal is None:
|
||||
load(view, st, f"deleted {ref.name}")
|
||||
return
|
||||
if sublime.ok_cancel_dialog(f"{ref.name} is not fully merged.\n\nDelete it anyway? This cannot be undone.", "Delete anyway"):
|
||||
run_async(lambda: git.delete_branch(st.repo, ref.name, force=True), lambda _: load(view, st, f"deleted {ref.name}"))
|
||||
|
||||
run_async(work, done)
|
||||
|
||||
|
||||
def _step_branch(delta: int) -> Action:
|
||||
"""Action moving the branch selection `delta` branches on"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Select the neighbouring branch"""
|
||||
select(view, st, delta)
|
||||
|
||||
return act
|
||||
|
||||
|
||||
def _step_commit(delta: int) -> Action:
|
||||
"""Action moving the caret `delta` items on, ringing at the ends"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Put the caret on the neighbouring item's first row"""
|
||||
# off the items (a left-column or blank row) j goes to the first, k to the last
|
||||
index = _step(view, _caret_commit(view, st), _item_count(st), delta)
|
||||
if index is not None:
|
||||
_put_caret(view, st, index, show=True)
|
||||
|
||||
return act
|
||||
|
||||
|
||||
def _act_open_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Review the commit under the caret in the commit view, or the changed files from the working-tree entry"""
|
||||
index = _caret_commit(view, st)
|
||||
if not 0 <= index < _item_count(st):
|
||||
status("no commit under the caret")
|
||||
return
|
||||
if index == WORKTREE:
|
||||
window.run_command("vdiff_files")
|
||||
return
|
||||
window.run_command("vdiff_commits", {"sha": st.branch_commits[index - 1].sha})
|
||||
|
||||
|
||||
def _in_branches(act: Action) -> Action:
|
||||
"""`act` guarded to the branches view: header links and the popup can fire it over a diff too"""
|
||||
def guarded(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Run the action, or say where it works"""
|
||||
if st.in_branches:
|
||||
act(view, window, st)
|
||||
else:
|
||||
status(NOT_HERE)
|
||||
|
||||
return guarded
|
||||
|
||||
|
||||
ACTIONS: Dict[str, Action] = {
|
||||
name: _in_branches(act)
|
||||
for name, act in {
|
||||
"checkout": _act_checkout,
|
||||
"checkout_detached": _act_checkout_detached,
|
||||
"pull": _act_pull,
|
||||
"push": _act_push,
|
||||
"fetch": _act_fetch,
|
||||
"delete_branch": _act_delete,
|
||||
"next_branch": _step_branch(1),
|
||||
"prev_branch": _step_branch(-1),
|
||||
"next_commit": _step_commit(1),
|
||||
"prev_commit": _step_commit(-1),
|
||||
"open_commit": _act_open_commit,
|
||||
}.items()
|
||||
}
|
||||
@@ -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 os
|
||||
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
|
||||
from . import git, parse, render, state
|
||||
from .utils import file_count, run_async, settings, status
|
||||
from . import branches, git, parse, render, state
|
||||
from .git import Commit, FileChange
|
||||
from .state import MODE_BRANCHES, Kept, ViewState
|
||||
from .utils import DEBOUNCE_MS, commits_count, file_count, run_async, settings, status
|
||||
|
||||
# an ACTIONS entry: act(view, window, state) on the review view it 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
|
||||
STATUS_COLOURS = {
|
||||
STATUS_COLOURS: Dict[str, int] = {
|
||||
"M": sublime.KIND_ID_COLOR_ORANGISH,
|
||||
"A": sublime.KIND_ID_COLOR_GREENISH,
|
||||
"?": sublime.KIND_ID_COLOR_GREENISH,
|
||||
@@ -21,30 +27,30 @@ STATUS_COLOURS = {
|
||||
}
|
||||
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
|
||||
|
||||
# a quick-panel row draws the annotation hard right on the subject's line; past this budget the
|
||||
# subject would run into it, so it is clipped. The floor keeps a readable stub on a narrow panel.
|
||||
# row width shared by subject and annotation; the subject is clipped to fit, never below MIN_SUBJECT_WIDTH
|
||||
COMMIT_ROW_WIDTH = 120
|
||||
MIN_SUBJECT_WIDTH = 24
|
||||
|
||||
# highlight-to-preview delay: holding an arrow key must not spawn a git process per row
|
||||
PREVIEW_DELAY_MS = 60
|
||||
|
||||
# directory -> repository root; roots do not move, and resolving runs on the UI thread
|
||||
_roots = {}
|
||||
# cache directory -> repo root: resolve_repo runs git on the UI thread
|
||||
_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"""
|
||||
view = window.active_view()
|
||||
candidates = []
|
||||
candidates: List[str] = []
|
||||
# a review view already knows its repo; a file's directory is tried before the project folders
|
||||
if view is not None:
|
||||
if state.is_review_view(view):
|
||||
st = state.get(view) or restore_view(view)
|
||||
if st.repo:
|
||||
return st.repo
|
||||
elif view.file_name():
|
||||
candidates.append(os.path.dirname(view.file_name()))
|
||||
else:
|
||||
name = view.file_name()
|
||||
if name:
|
||||
candidates.append(os.path.dirname(name))
|
||||
candidates.extend(window.folders())
|
||||
# first candidate in a repo wins; only hits are cached
|
||||
for directory in candidates:
|
||||
root = _roots.get(directory)
|
||||
if root is None:
|
||||
@@ -56,15 +62,16 @@ def resolve_repo(window):
|
||||
return None
|
||||
|
||||
|
||||
def find_review_view(window):
|
||||
def find_review_view(window: sublime.Window) -> Optional[sublime.View]:
|
||||
"""The window's first review view, if it has one"""
|
||||
for view in window.views():
|
||||
if state.is_review_view(view):
|
||||
return view
|
||||
return None
|
||||
|
||||
|
||||
def open_review(window):
|
||||
"""(view, state) of the window's single review view for the resolved repo, focused; None without a repo"""
|
||||
def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]:
|
||||
"""Focus the window's review view (created if missing) on the resolved repo; (view, state), or None without a repo"""
|
||||
repo = resolve_repo(window)
|
||||
if not repo:
|
||||
status("no git repository for the active file or project folders")
|
||||
@@ -76,27 +83,58 @@ def open_review(window):
|
||||
st = state.get(view)
|
||||
if st is None or st.repo != repo:
|
||||
# a different repository: browsing state no longer applies
|
||||
st = state.put(view, state.ViewState(repo))
|
||||
all_mode = settings().get("default_view_mode") == "all_in_one"
|
||||
st = state.put(view, state.ViewState(repo, all_mode=all_mode))
|
||||
window.focus_view(view)
|
||||
return view, st
|
||||
|
||||
|
||||
def show_file(view, st, change):
|
||||
def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) -> None:
|
||||
"""Load all file diffs off the UI thread and switch to the aggregate view, on `focus`'s heading
|
||||
|
||||
Without `focus` the caret goes to the current file's heading, as it is when the diffs land
|
||||
"""
|
||||
st.all_mode = True
|
||||
# bump the token so earlier loads drop their results
|
||||
st.token += 1
|
||||
token, files = st.token, list(st.files)
|
||||
def work() -> Dict[str, Tuple[List[parse.Line], List[parse.Block], bool]]:
|
||||
"""Parse every listed file's diff and its change blocks, keyed by path"""
|
||||
result = {}
|
||||
for change in files:
|
||||
diff = parse.parse_unified(git.full_diff(st.repo, change.path, sha=st.sha, untracked=(change.status == "?")))
|
||||
result[change.path] = (diff.lines, parse.change_blocks(diff.lines), diff.binary)
|
||||
return result
|
||||
def done(result: Dict[str, Tuple[List[parse.Line], List[parse.Block], bool]]) -> None:
|
||||
"""Store the diffs and draw the aggregate view unless superseded or closed"""
|
||||
if token == st.token and view.is_valid():
|
||||
st.set_all_diffs(result)
|
||||
if st.change is None and files:
|
||||
st.change = files[0]
|
||||
render.render_all(view, st, focus=focus or (st.change.path if st.change else None))
|
||||
st.rendered_token = token
|
||||
run_async(work, done)
|
||||
|
||||
|
||||
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"""
|
||||
st.all_mode = False
|
||||
st.token += 1
|
||||
# set before the worker starts: n / p count from here while the diff is still loading
|
||||
st.requested = change
|
||||
token = st.token
|
||||
repo, sha = st.repo, st.sha
|
||||
|
||||
def work():
|
||||
def work() -> Optional[Tuple[parse.Diff, List[parse.Block]]]:
|
||||
"""Run git diff and parse it, unless a newer request already superseded this one"""
|
||||
if token != st.token:
|
||||
return None # superseded before it started: skip the subprocess
|
||||
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
|
||||
diff = parse.parse_unified(text)
|
||||
return diff, parse.change_blocks(diff.lines)
|
||||
|
||||
def done(result):
|
||||
def done(result: Optional[Tuple[parse.Diff, List[parse.Block]]]) -> None:
|
||||
"""Make the file current and render it if this is still the latest request"""
|
||||
if result is None or token != st.token or not view.is_valid():
|
||||
return
|
||||
diff, blocks = result
|
||||
@@ -108,8 +146,9 @@ def show_file(view, st, change):
|
||||
run_async(work, done)
|
||||
|
||||
|
||||
def file_items(files):
|
||||
items = []
|
||||
def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
||||
"""Quick-panel rows: path; status, staged flag, rename source; line totals or binary"""
|
||||
items: List[sublime.QuickPanelItem] = []
|
||||
for change in files:
|
||||
description = git.STATUS_NAMES.get(change.status, "")
|
||||
colour = STATUS_COLOURS.get(change.status)
|
||||
@@ -117,6 +156,7 @@ def file_items(files):
|
||||
details = ", ".join(filter(None, (description, "staged" if change.staged else "")))
|
||||
if change.old_path:
|
||||
details += " ← " + change.old_path
|
||||
# no line counts means git saw a binary file
|
||||
if None in (change.insertions, change.deletions):
|
||||
annotation = "binary"
|
||||
else:
|
||||
@@ -125,14 +165,15 @@ def file_items(files):
|
||||
return items
|
||||
|
||||
|
||||
def commit_items(commits):
|
||||
"""Quick-panel rows: subject on the left, its totals and the hash right-aligned"""
|
||||
items = []
|
||||
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
||||
"""Quick-panel rows: subject, author · date, and diffstat plus short hash right-aligned"""
|
||||
items: List[sublime.QuickPanelItem] = []
|
||||
for c in commits:
|
||||
# merges and empty commits have no diffstat, so they show the hash alone
|
||||
annotation = c.short
|
||||
if c.files is not None:
|
||||
# merges and empty commits have no diffstat, so they show the hash alone
|
||||
annotation = f"{file_count(c.files)} changed -{c.deletions} +{c.insertions} {c.short}"
|
||||
# clip the subject so the annotation keeps its room
|
||||
subject = c.subject or "(no subject)"
|
||||
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
|
||||
if len(subject) > budget:
|
||||
@@ -141,51 +182,97 @@ def commit_items(commits):
|
||||
return items
|
||||
|
||||
|
||||
def show_files_panel(window, view, st, files, kept, on_cancel=None, placeholder="Changed files"):
|
||||
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
|
||||
def redisplay(view: sublime.View, st: ViewState, keep_caret: bool = False) -> None:
|
||||
"""Redraw whatever the state describes: the branches view, all diffs, or the current file"""
|
||||
if st.in_branches:
|
||||
branches.load(view, st, keep_caret=keep_caret)
|
||||
elif st.all_mode:
|
||||
show_all(view, st)
|
||||
elif st.change is not None:
|
||||
show_file(view, st, st.change)
|
||||
|
||||
|
||||
def redisplay_if_stale(view: sublime.View, st: ViewState) -> None:
|
||||
"""Redraw when the screen shows something else than the state describes, or a render is still on its way"""
|
||||
if st.drawn != st.describe() or st.rendered_token != st.token:
|
||||
redisplay(view, st)
|
||||
|
||||
|
||||
def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
|
||||
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
|
||||
placeholder: str = "Changed files") -> None:
|
||||
"""Quick panel over `files`: highlight previews, Enter shows, Esc restores `kept` (then runs on_cancel)
|
||||
|
||||
In the aggregate view every diff is loaded once as the panel opens, and a highlight only scrolls to its file
|
||||
"""
|
||||
# the panel opens on the shown file, the view focused behind it
|
||||
st.files = files
|
||||
selected = max(0, st.index())
|
||||
window.focus_view(view)
|
||||
|
||||
def on_select(index):
|
||||
def show(change: FileChange) -> None:
|
||||
"""Show `change`: its own diff, or its heading in the aggregate view"""
|
||||
if not st.all_mode:
|
||||
show_file(view, st, change)
|
||||
return
|
||||
st.change = change
|
||||
st.collapsed.discard(change.path)
|
||||
# still loading: show_all lands on st.change when the diffs arrive
|
||||
if st.rendered_token == st.token:
|
||||
render.render_all(view, st, focus=change.path)
|
||||
|
||||
def on_select(index: int) -> None:
|
||||
"""Enter shows the chosen file; Esc restores `kept`, then runs on_cancel if given"""
|
||||
st.highlight += 1 # cancel a pending preview
|
||||
if index == -1:
|
||||
if on_cancel is not None:
|
||||
st.restore(kept)
|
||||
on_cancel()
|
||||
elif kept.change is not None:
|
||||
# go back to the file that was kept before the panel opened
|
||||
elif kept.mode == MODE_BRANCHES or kept.change is not None:
|
||||
# back to the branches view or the kept file in the kept mode, unless the screen still shows it
|
||||
st.restore(kept)
|
||||
show_file(view, st, kept.change)
|
||||
redisplay_if_stale(view, st)
|
||||
# nothing was kept before: the last previewed file stays
|
||||
return
|
||||
change = files[index]
|
||||
if st.change is change and st.rendered_token == st.token:
|
||||
return # the preview already shows it
|
||||
show_file(view, st, change)
|
||||
show(change)
|
||||
|
||||
def on_highlight(index):
|
||||
def on_highlight(index: int) -> None:
|
||||
"""Schedule a debounced preview of the highlighted file"""
|
||||
if not 0 <= index < len(files):
|
||||
return
|
||||
st.highlight += 1
|
||||
ticket = st.highlight
|
||||
sublime.set_timeout(lambda: ticket == st.highlight and show_file(view, st, files[index]), PREVIEW_DELAY_MS)
|
||||
|
||||
def preview() -> None:
|
||||
"""Show the file unless another highlight came since"""
|
||||
if ticket == st.highlight:
|
||||
show(files[index])
|
||||
|
||||
sublime.set_timeout(preview, DEBOUNCE_MS)
|
||||
|
||||
if st.all_mode and files:
|
||||
st.change = files[selected]
|
||||
show_all(view, st)
|
||||
window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)
|
||||
|
||||
|
||||
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
|
||||
|
||||
`pick(files, index)` may name another file to show instead; `index` is where the shown file
|
||||
sits in the new list, or the slot it left behind when it is gone.
|
||||
`pick(files, index)` may choose another file; `index` is the shown file's new slot, or its old one if it left the list
|
||||
"""
|
||||
if message:
|
||||
status(message)
|
||||
# remember the shown file and its slot before the list changes
|
||||
previous_index = max(0, st.index())
|
||||
path = st.change.path if st.change else None
|
||||
|
||||
def done(files):
|
||||
def done(files: List[FileChange]) -> None:
|
||||
"""Show the same file again, its slot's successor, or what `pick` chooses"""
|
||||
if not view.is_valid():
|
||||
return
|
||||
st.files = files
|
||||
@@ -197,32 +284,58 @@ def refresh_listing(view, st, message=None, pick=None):
|
||||
# the file left the list: whatever moved up into its slot takes over
|
||||
index = min(previous_index, len(files) - 1)
|
||||
chosen = pick(files, index) if pick else None
|
||||
show_file(view, st, chosen if chosen is not None else files[index])
|
||||
change = chosen if chosen is not None else files[index]
|
||||
if st.all_mode:
|
||||
# stay in the aggregate view, re-diffed over the new list, on the same file
|
||||
st.change = change
|
||||
show_all(view, st, focus=change.path)
|
||||
else:
|
||||
show_file(view, st, change)
|
||||
|
||||
run_async(lambda: git.changed_files(st.repo), done)
|
||||
|
||||
|
||||
def refresh_if_changed(view, st):
|
||||
"""Re-render a working-tree view when the file or the index changed since it was drawn"""
|
||||
def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
|
||||
"""Re-render a working-tree view when a shown file, the index or the list of changed files moved since it was drawn;
|
||||
the branches view keeps its own check"""
|
||||
if st.in_branches:
|
||||
branches.refresh_if_changed(view, st)
|
||||
return
|
||||
if st.in_commit or st.change is None:
|
||||
return
|
||||
if render.fingerprint(st) != st.fingerprint:
|
||||
refresh_listing(view, st)
|
||||
return
|
||||
# the mtimes watch only the listed files; git status, off the UI thread, sees a file newly changed outside the list
|
||||
token = st.token
|
||||
|
||||
def done(files: List[FileChange]) -> None:
|
||||
"""Re-list when the working tree's changes are no longer the listed ones, unless a newer render took over"""
|
||||
if view.is_valid() and token == st.token and files != st.files:
|
||||
refresh_listing(view, st)
|
||||
|
||||
run_async(lambda: git.changed_files(st.repo), done)
|
||||
|
||||
|
||||
class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
||||
"""List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
"""Open the review view, then list the changed files off the UI thread"""
|
||||
window = self.window
|
||||
opened = open_review(window)
|
||||
if not opened:
|
||||
return
|
||||
view, st = opened
|
||||
|
||||
def done(files):
|
||||
def done(files: List[FileChange]) -> None:
|
||||
"""Show the files panel, or a notice when there is nothing uncommitted"""
|
||||
if not view.is_valid():
|
||||
return
|
||||
if not files and (st.in_branches or st.in_commit):
|
||||
# the notice would replace a view that still holds: the branches, or a commit under review
|
||||
status("no uncommitted changes")
|
||||
return
|
||||
kept = st.enter()
|
||||
if not files:
|
||||
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
|
||||
@@ -237,7 +350,8 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
||||
class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||
"""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:
|
||||
"""Open the review view, then the given commit or the recent-commits panel"""
|
||||
opened = open_review(self.window)
|
||||
if not opened:
|
||||
return
|
||||
@@ -245,9 +359,10 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||
if sha:
|
||||
self.open_commit(view, st, sha, None)
|
||||
return
|
||||
count = int(settings().get("commits_count", 30) or 30)
|
||||
count = commits_count()
|
||||
|
||||
def done(commits):
|
||||
def done(commits: List[Commit]) -> None:
|
||||
"""Show the commits panel unless the repository has none"""
|
||||
if not view.is_valid():
|
||||
return
|
||||
if not commits:
|
||||
@@ -257,23 +372,29 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||
|
||||
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:
|
||||
"""Recent-commits panel, preselecting the commit already shown"""
|
||||
items = commit_items(commits)
|
||||
for i, c in enumerate(commits):
|
||||
if c.sha == st.sha:
|
||||
selected = i
|
||||
|
||||
def on_select(index):
|
||||
def on_select(index: int) -> None:
|
||||
"""Open the chosen commit's files, remembering its row for Esc; Esc itself redraws what the state went
|
||||
back to when the list was reopened from a commit's files, whose diff is still on screen"""
|
||||
if index != -1:
|
||||
self.open_commit(view, st, commits[index].sha, index)
|
||||
else:
|
||||
redisplay_if_stale(view, st)
|
||||
|
||||
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
|
||||
|
||||
def open_commit(self, view, st, sha, index):
|
||||
"""Files panel of one commit; Esc goes back to the commit list when it came from there (index given)"""
|
||||
def open_commit(self, view: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None:
|
||||
"""Files panel of one commit; with `index`, Esc reopens the commit list on that row"""
|
||||
window = self.window
|
||||
|
||||
def done(result):
|
||||
def done(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
||||
"""Enter the commit and show its files panel, or a notice when it touches none"""
|
||||
if not view.is_valid():
|
||||
return
|
||||
info, files = result
|
||||
@@ -282,7 +403,8 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||
render.render_message(view, st, f"Commit {info.short} touches no files")
|
||||
return
|
||||
|
||||
def back():
|
||||
def back() -> None:
|
||||
"""Reopen the commit list on the row this commit came from"""
|
||||
window.run_command("vdiff_commits", {"selected": index})
|
||||
|
||||
placeholder = f"{info.short} · {info.subject}"
|
||||
@@ -292,36 +414,58 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
||||
run_async(lambda: git.commit_with_files(st.repo, sha), done)
|
||||
|
||||
|
||||
class VdiffBranchesCommand(sublime_plugin.WindowCommand):
|
||||
"""Show the branches view: local branches and remotes, and the log of the selected one"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Open the review view and list the branches off the UI thread"""
|
||||
opened = open_review(self.window)
|
||||
if opened:
|
||||
branches.load(*opened)
|
||||
|
||||
|
||||
# --- review view actions ----------------------------------------------------
|
||||
|
||||
|
||||
def _act_files(view, window, st):
|
||||
def _act_files(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Open the changed-files panel"""
|
||||
window.run_command("vdiff_files")
|
||||
|
||||
|
||||
def _act_commits(view, window, st):
|
||||
def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Open the recent-commits panel"""
|
||||
window.run_command("vdiff_commits")
|
||||
|
||||
|
||||
def _act_close(view, window, st):
|
||||
def _act_branches(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Show (or reload) the branches view"""
|
||||
window.run_command("vdiff_branches")
|
||||
|
||||
|
||||
def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Close the review view"""
|
||||
view.close()
|
||||
|
||||
|
||||
def _act_help(view, window, st):
|
||||
def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Show the help popup"""
|
||||
render.show_help(view, st)
|
||||
|
||||
|
||||
def _git_action(verb, done_verb, operation, confirm=None, pick=None):
|
||||
"""Action running `operation(repo, change)` on the shown working-tree file, then re-listing
|
||||
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 current working-tree file, then re-listing; `pick` is passed to refresh_listing"""
|
||||
|
||||
`pick` is handed to refresh_listing to choose which file to show afterwards.
|
||||
"""
|
||||
|
||||
def act(view, window, st):
|
||||
if st.in_commit or st.change is None:
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Confirm if asked, run the operation off the UI thread, then refresh the listing"""
|
||||
change = _current_change(view, st)
|
||||
if st.in_commit:
|
||||
status(f"{verb} works on uncommitted changes only")
|
||||
return
|
||||
change = st.change
|
||||
if change is None:
|
||||
status("no file under the caret")
|
||||
return
|
||||
if confirm is not None and not confirm(change):
|
||||
return
|
||||
run_async(
|
||||
@@ -332,9 +476,32 @@ def _git_action(verb, done_verb, operation, confirm=None, pick=None):
|
||||
return act
|
||||
|
||||
|
||||
def _confirm_discard(change):
|
||||
def _bulk_action(verb: str, done_verb: str, operation: Callable[[str], None],
|
||||
wanted: Callable[[FileChange], bool]) -> Action:
|
||||
"""Action running `operation(repo)` ("<verb> all") when some listed file is `wanted`, then re-listing on the same file"""
|
||||
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Run the operation off the UI thread, then refresh the listing"""
|
||||
if st.in_commit:
|
||||
status(f"{verb} all works on uncommitted changes only")
|
||||
return
|
||||
count = sum(1 for c in st.files if wanted(c))
|
||||
if not count:
|
||||
status(f"nothing to {verb}")
|
||||
return
|
||||
run_async(
|
||||
lambda: operation(st.repo),
|
||||
lambda _: refresh_listing(view, st, f"{done_verb} {file_count(count)}"),
|
||||
)
|
||||
|
||||
return act
|
||||
|
||||
|
||||
def _confirm_discard(change: FileChange) -> bool:
|
||||
"""Ask before discarding or deleting, unless the confirm_discard setting is off"""
|
||||
if not settings().get("confirm_discard", True):
|
||||
return True
|
||||
# untracked files are deleted outright, tracked ones reset
|
||||
if change.status == "?":
|
||||
message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
|
||||
else:
|
||||
@@ -345,53 +512,127 @@ def _confirm_discard(change):
|
||||
return sublime.ok_cancel_dialog(message, "Discard")
|
||||
|
||||
|
||||
def _next_unstaged(files, start):
|
||||
"""Index of the first unstaged file from `start` on, wrapping round; -1 when every file is staged"""
|
||||
def _next_unstaged(files: Sequence[FileChange], start: int) -> int:
|
||||
"""Index of the first file with something to stage from `start` on, wrapping round; -1 when everything is staged"""
|
||||
for offset in range(len(files)):
|
||||
index = (start + offset) % len(files)
|
||||
if not files[index].staged:
|
||||
if files[index].has_unstaged:
|
||||
return index
|
||||
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"""
|
||||
if not settings().get("stage_and_move_next_file", True):
|
||||
return None
|
||||
# the file just staged is staged now, so a scan starting on it moves off by itself
|
||||
# scanning from the staged file's slot is safe: it is skipped as staged
|
||||
following = _next_unstaged(files, index)
|
||||
return files[following] if following >= 0 else None
|
||||
|
||||
|
||||
def _act_refresh(view, window, st):
|
||||
if st.change is None:
|
||||
return
|
||||
if st.in_commit:
|
||||
show_file(view, st, st.change)
|
||||
else:
|
||||
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Reload what is shown: the branches, the commit's diffs, or the working tree re-listed (new and reverted files too)"""
|
||||
if st.in_branches or st.in_commit:
|
||||
redisplay(view, st, keep_caret=True)
|
||||
elif st.all_mode or st.change is not None:
|
||||
refresh_listing(view, st)
|
||||
|
||||
|
||||
def _act_open(view, window, st):
|
||||
if st.change is None:
|
||||
def _current_change(view: sublime.View, st: ViewState) -> Optional[FileChange]:
|
||||
"""File under the caret in the aggregate view (made current), None on a blank row or the top row; else the shown file"""
|
||||
if st.all_mode:
|
||||
change = render.all_change_at(view, st)
|
||||
if change is not None:
|
||||
st.change = change
|
||||
return change
|
||||
return st.change
|
||||
|
||||
|
||||
def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Open the working-tree file at the line under the caret"""
|
||||
change = _current_change(view, st)
|
||||
if change is None:
|
||||
status("no file under the caret")
|
||||
return
|
||||
path = os.path.join(st.repo, st.change.path)
|
||||
path = os.path.join(st.repo, change.path)
|
||||
if not os.path.exists(path):
|
||||
status(f"{st.change.path} does not exist in the working tree")
|
||||
status(f"{change.path} does not exist in the working tree")
|
||||
return
|
||||
line = parse.line_for_open(st.lines, render.caret_index(view))
|
||||
# map the caret row to a line number of the new side
|
||||
if st.all_mode:
|
||||
row = render.caret_index(view)
|
||||
displayed = st.all_rows[row][1] if 0 <= row < len(st.all_rows) else None
|
||||
# the row's slot in the file's whole diff (a heading is its start), mapped as the single-file view maps it
|
||||
lines = st.all_diffs.get(change.path, ([], [], False))[0]
|
||||
index = next((i for i, line in enumerate(lines) if line is displayed), 0)
|
||||
line = parse.line_for_open(lines, index)
|
||||
else:
|
||||
line = parse.line_for_open(st.lines, render.caret_index(view))
|
||||
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
|
||||
|
||||
|
||||
def _step_file(delta):
|
||||
def act(view, window, st):
|
||||
def _act_view(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Switch between the single-file and the aggregate view"""
|
||||
if st.all_mode:
|
||||
st.all_mode = False
|
||||
if st.change is not None:
|
||||
show_file(view, st, st.change)
|
||||
else:
|
||||
show_all(view, st)
|
||||
|
||||
|
||||
def _toggle_file(delta: int) -> Action:
|
||||
"""Action expanding (delta > 0) or collapsing the caret's file in the aggregate view"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Expand or collapse the file under the caret and redraw focused on it"""
|
||||
if not st.all_mode:
|
||||
return
|
||||
change = _current_change(view, st)
|
||||
if change is not None:
|
||||
(st.collapsed.discard if delta > 0 else st.collapsed.add)(change.path)
|
||||
render.render_all(view, st, focus=change.path)
|
||||
return act
|
||||
|
||||
|
||||
def _act_fold_all(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Collapse every file while any is expanded, otherwise expand them all"""
|
||||
if not st.all_mode:
|
||||
return
|
||||
change = _current_change(view, st)
|
||||
paths = {c.path for c in st.files}
|
||||
st.collapsed = set() if st.collapsed >= paths else paths
|
||||
render.render_all(view, st, focus=change.path if change else None)
|
||||
|
||||
|
||||
def _step_file(delta: int) -> Action:
|
||||
"""Action moving `delta` files through the list, wrapping round"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Move to the neighbouring file: scroll to it in the aggregate view, else show its diff"""
|
||||
# aggregate view: unfold the neighbouring file and scroll to it
|
||||
if st.all_mode:
|
||||
if not st.files:
|
||||
status("no files to step through")
|
||||
return
|
||||
change = _current_change(view, st)
|
||||
if change is not None:
|
||||
index = (state.find_change(st.files, change.path) + delta) % len(st.files)
|
||||
else:
|
||||
# a blank row or the top row sits above a file: n goes to that file, p to the one before it
|
||||
below = render.all_change_below(view, st)
|
||||
index = state.find_change(st.files, below.path) if below else 0
|
||||
index = (index if delta > 0 else index - 1) % len(st.files)
|
||||
st.change = st.files[index]
|
||||
st.collapsed.discard(st.change.path)
|
||||
render.render_all(view, st, focus=st.change.path, center=True)
|
||||
return
|
||||
# single-file view: show the neighbouring file, ringing the bell on a wrap
|
||||
if not st.files:
|
||||
status("no file list, press f or c")
|
||||
return
|
||||
count = len(st.files)
|
||||
position = st.index()
|
||||
if position < 0:
|
||||
# nothing shown yet: n starts at the first file, p at the last. Not a wrap.
|
||||
# nothing shown yet: n starts at the first file, p at the last, no wrap
|
||||
index, wrapped = (0 if delta > 0 else count - 1), False
|
||||
else:
|
||||
index = position + delta
|
||||
@@ -406,8 +647,44 @@ def _step_file(delta):
|
||||
return act
|
||||
|
||||
|
||||
def _step_change(delta):
|
||||
def act(view, window, st):
|
||||
def _step_change(delta: int) -> Action:
|
||||
"""Action moving the caret `delta` changes on, wrapping round"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Jump to the next or previous change, across files in the aggregate view"""
|
||||
if st.all_mode:
|
||||
# aggregate view: the hunks of all files are the targets
|
||||
# a blank row or the top row counts as above the file below it: ] goes to its first hunk, [ before it
|
||||
current_change = _current_change(view, st) or render.all_change_below(view, st)
|
||||
rows = st.all_change_rows
|
||||
targets = st.all_change_targets
|
||||
if not targets:
|
||||
return
|
||||
current = render.caret_index(view)
|
||||
current_path = current_change.path if current_change else None
|
||||
# anchor: the caret file's last shown hunk at or above the caret
|
||||
current_target = None
|
||||
for row, target in zip(rows, st.all_change_row_targets):
|
||||
if target[0] == current_path and row <= current:
|
||||
current_target = target
|
||||
if current_target in targets:
|
||||
# step from the anchor, wrapping round
|
||||
index = targets.index(current_target) + delta
|
||||
else:
|
||||
# above the file's first hunk (heading, folded) or outside any file: its first hunk
|
||||
# is next and the one before it previous; outside a file, the list's first or last
|
||||
first = next((i for i, t in enumerate(targets) if t[0] == current_path), None)
|
||||
if first is None:
|
||||
index = 0 if delta > 0 else -1
|
||||
else:
|
||||
index = first if delta > 0 else first - 1
|
||||
target = targets[index % len(targets)]
|
||||
# make the target's file current, unfold it and centre on the hunk
|
||||
target_change = next(c for c in st.files if c.path == target[0])
|
||||
st.change = target_change
|
||||
st.collapsed.discard(target_change.path)
|
||||
render.render_all(view, st, focus=target, center=True)
|
||||
return
|
||||
# single-file view: step between change starts from the caret row
|
||||
starts = [start for start, _ in st.blocks]
|
||||
if not starts:
|
||||
status("no changes to step through")
|
||||
@@ -417,6 +694,7 @@ def _step_change(delta):
|
||||
if not 0 <= position < len(starts):
|
||||
render.flash(view)
|
||||
position %= len(starts) # -1 is the last change, len(starts) the first
|
||||
# move the caret onto the change and centre it
|
||||
point = render.index_point(view, starts[position])
|
||||
view.sel().clear()
|
||||
view.sel().add(sublime.Region(point))
|
||||
@@ -425,7 +703,7 @@ def _step_change(delta):
|
||||
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"""
|
||||
if st.in_commit:
|
||||
status("commit works on uncommitted changes only")
|
||||
@@ -435,7 +713,8 @@ def _act_commit(view, window, st):
|
||||
status("no staged files")
|
||||
return
|
||||
|
||||
def on_done(message):
|
||||
def on_done(message: str) -> None:
|
||||
"""Commit with the typed message off the UI thread, or cancel on an empty one"""
|
||||
if not message.strip():
|
||||
status("commit cancelled: empty message")
|
||||
return
|
||||
@@ -447,21 +726,22 @@ def _act_commit(view, window, st):
|
||||
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
|
||||
|
||||
The panel is one line, so only the subject is offered; the rest of the message rides along
|
||||
verbatim. Nothing staged is fine - then the amend is a pure reword.
|
||||
The one-line panel offers only the subject; the body rides along verbatim, and nothing staged is a reword
|
||||
"""
|
||||
if st.in_commit:
|
||||
status("amend works on uncommitted changes only")
|
||||
return
|
||||
staged = [c for c in st.files if c.staged]
|
||||
|
||||
def prompt(message):
|
||||
def prompt(message: str) -> None:
|
||||
"""Show the input panel prefilled with HEAD's subject"""
|
||||
subject, newline, body = message.partition("\n")
|
||||
|
||||
def on_done(text):
|
||||
def on_done(text: str) -> None:
|
||||
"""Amend with the typed subject and the original body, or cancel on an empty one"""
|
||||
if not text.strip():
|
||||
status("amend cancelled: empty message")
|
||||
return
|
||||
@@ -469,7 +749,7 @@ def _act_amend(view, window, st):
|
||||
if staged:
|
||||
done += f" with {file_count(len(staged))}"
|
||||
run_async(
|
||||
# nothing staged makes this a pure reword, which git refuses on an empty commit
|
||||
# a reword needs --allow-empty when HEAD itself is an empty commit
|
||||
lambda: git.amend(st.repo, text + newline + body, allow_empty=not staged),
|
||||
lambda _: refresh_listing(view, st, done),
|
||||
)
|
||||
@@ -482,24 +762,33 @@ def _act_amend(view, window, st):
|
||||
run_async(lambda: git.head_message(st.repo), prompt)
|
||||
|
||||
|
||||
def _move_line(forward):
|
||||
def act(view, window, st):
|
||||
def _move_line(forward: bool) -> Action:
|
||||
"""Action moving the caret one line down (forward) or up"""
|
||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||
"""Move the caret one line"""
|
||||
view.run_command("move", {"by": "lines", "forward": forward})
|
||||
|
||||
return act
|
||||
|
||||
|
||||
ACTIONS = {
|
||||
ACTIONS: Dict[str, Action] = {
|
||||
"files": _act_files,
|
||||
"commits": _act_commits,
|
||||
"branches": _act_branches,
|
||||
"close": _act_close,
|
||||
"help": _act_help,
|
||||
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
|
||||
"unstage": _git_action("unstage", "unstaged", git.unstage),
|
||||
"stage_all": _bulk_action("stage", "staged", git.stage_all, lambda c: c.has_unstaged),
|
||||
"unstage_all": _bulk_action("unstage", "unstaged", git.unstage_all, lambda c: c.staged),
|
||||
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
|
||||
"commit": _act_commit,
|
||||
"amend": _act_amend,
|
||||
"refresh": _act_refresh,
|
||||
"view": _act_view,
|
||||
"expand": _toggle_file(1),
|
||||
"collapse": _toggle_file(-1),
|
||||
"fold_all": _act_fold_all,
|
||||
"open": _act_open,
|
||||
"next_file": _step_file(1),
|
||||
"prev_file": _step_file(-1),
|
||||
@@ -508,15 +797,19 @@ ACTIONS = {
|
||||
"line_down": _move_line(True),
|
||||
"line_up": _move_line(False),
|
||||
}
|
||||
# the branches view's actions: the keymap reaches them through the vdiff_mode context
|
||||
ACTIONS.update(branches.ACTIONS)
|
||||
|
||||
|
||||
class VdiffActionCommand(sublime_plugin.TextCommand):
|
||||
"""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:
|
||||
"""Only in review views"""
|
||||
return state.is_review_view(self.view)
|
||||
|
||||
def run(self, edit, action):
|
||||
def run(self, edit: sublime.Edit, action: str) -> None:
|
||||
"""Run the named action with the view's state, rebuilt if lost (e.g. after a restart)"""
|
||||
act = ACTIONS.get(action)
|
||||
if act is None:
|
||||
status(f"unknown action {action}")
|
||||
@@ -525,39 +818,53 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
|
||||
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"""
|
||||
# the repo, commit and file saved with the view
|
||||
data = view.settings().get(state.VIEW_DATA) or {}
|
||||
repo = data.get("repo")
|
||||
render.init_view(view)
|
||||
mode = data.get("mode") or state.MODE_DIFF
|
||||
render.init_view(view, mode)
|
||||
# repository gone: show a notice
|
||||
if not repo or not os.path.isdir(repo):
|
||||
st = state.put(view, state.ViewState(""))
|
||||
render.render_message(view, st, "Repository not found. Press f for changed files, c for commits.")
|
||||
render.render_message(view, st, "Repository not found. Press f for changed files, c for commits, b for branches.")
|
||||
return st
|
||||
st = state.put(view, state.ViewState(repo))
|
||||
st.sha = data.get("sha")
|
||||
st.all_mode = bool(data.get("all_mode", False))
|
||||
# the branches view comes back on the same branch
|
||||
if mode == MODE_BRANCHES:
|
||||
st.mode, st.branch_ref = mode, data.get("ref")
|
||||
redisplay(view, st)
|
||||
return st
|
||||
path = data.get("path")
|
||||
if not path:
|
||||
render.render_message(view, st, "Press f for changed files, c for commits.")
|
||||
render.render_message(view, st, "Press f for changed files, c for commits, b for branches.")
|
||||
return st
|
||||
# stand-in FileChange: no line counts or rename source
|
||||
change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None)
|
||||
|
||||
if st.in_commit:
|
||||
# a commit view re-lists the commit's files, a working-tree view the uncommitted changes
|
||||
sha = st.sha
|
||||
if sha is not None:
|
||||
|
||||
def done(result):
|
||||
st.commit, st.files = result
|
||||
show_file(view, st, change)
|
||||
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
||||
"""Adopt the commit's files and redraw the saved file or the aggregate view"""
|
||||
st.commit, st.files, st.change = result[0], result[1], change
|
||||
redisplay(view, st)
|
||||
|
||||
run_async(lambda: git.commit_with_files(repo, st.sha), done)
|
||||
run_async(lambda: git.commit_with_files(repo, sha), show_commit)
|
||||
else:
|
||||
|
||||
def done(files):
|
||||
def show_working_tree(files: List[FileChange]) -> None:
|
||||
"""Redraw the saved file, or the first changed one when it has no changes any more"""
|
||||
st.files = files
|
||||
index = state.find_change(files, path)
|
||||
if index >= 0:
|
||||
show_file(view, st, files[index])
|
||||
else:
|
||||
render.render_message(view, st, f"{path} has no uncommitted changes any more")
|
||||
if not files:
|
||||
render.render_message(view, st, f"No uncommitted changes in {repo}")
|
||||
return
|
||||
st.change = files[max(0, state.find_change(files, path))]
|
||||
redisplay(view, st)
|
||||
|
||||
run_async(lambda: git.changed_files(repo), done)
|
||||
run_async(lambda: git.changed_files(repo), show_working_tree)
|
||||
return st
|
||||
|
||||
438
vdiff/git.py
438
vdiff/git.py
@@ -1,14 +1,105 @@
|
||||
""" Git access: every call is one subprocess; results are plain tuples, no sublime imports (testable) """
|
||||
"""Git access via subprocess; results are plain tuples, no sublime imports (testable)"""
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
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")
|
||||
# shortstat totals; all three are None when git prints no diffstat (merges, empty commits)
|
||||
Commit = namedtuple("Commit", "sha short author date subject files insertions deletions")
|
||||
CommitInfo = namedtuple("CommitInfo", "sha short author date subject body")
|
||||
|
||||
class FileChange(NamedTuple):
|
||||
"""Path, one-letter status (see STATUS_NAMES), staged flag, numstat counts (None for binary), rename source
|
||||
|
||||
`staged` means something of the file is in the index, `unstaged` that the working tree differs from the
|
||||
index (an untracked file is unstaged); a partially staged file carries both
|
||||
"""
|
||||
|
||||
path: str
|
||||
status: str
|
||||
staged: bool
|
||||
insertions: Optional[int]
|
||||
deletions: Optional[int]
|
||||
old_path: Optional[str]
|
||||
unstaged: bool = False
|
||||
|
||||
@property
|
||||
def has_unstaged(self) -> bool:
|
||||
"""Whether something of the file is left to stage: a file with nothing staged is, whatever its flag says"""
|
||||
return self.unstaged or not self.staged
|
||||
|
||||
|
||||
class WorkTree(NamedTuple):
|
||||
"""The working tree's changes summed up: how many files are staged / unstaged, and the diffstat of them all"""
|
||||
|
||||
staged: int
|
||||
unstaged: int
|
||||
files: int
|
||||
insertions: int
|
||||
deletions: int
|
||||
|
||||
|
||||
def worktree_stats(changes: Sequence[FileChange]) -> WorkTree:
|
||||
"""Sum the working tree listing: a partially staged file counts on both sides; binary files carry None counts and add nothing"""
|
||||
return WorkTree(sum(1 for c in changes if c.staged), sum(1 for c in changes if c.has_unstaged), len(changes),
|
||||
sum(c.insertions or 0 for c in changes), sum(c.deletions or 0 for c in changes))
|
||||
|
||||
|
||||
class Commit(NamedTuple):
|
||||
"""One log entry; files/insertions/deletions are None without a diffstat (merges, empty commits)
|
||||
|
||||
`date` is relative from recent_commits and dd.mm.yyyy from ref_log, which is also what fills `tags`
|
||||
"""
|
||||
|
||||
sha: str
|
||||
short: str
|
||||
author: str
|
||||
date: str
|
||||
subject: str
|
||||
files: Optional[int]
|
||||
insertions: Optional[int]
|
||||
deletions: Optional[int]
|
||||
tags: Tuple[str, ...] = ()
|
||||
|
||||
|
||||
class Ref(NamedTuple):
|
||||
"""One local or remote branch
|
||||
|
||||
`name` is what git calls it ("main", "origin/main"), `branch` the part after the remote; ahead/behind
|
||||
count against the upstream, `gone` says the upstream was deleted. `upstream_remote` is the remote the
|
||||
upstream lives on, None without an upstream or when it is a local branch
|
||||
"""
|
||||
|
||||
name: str
|
||||
branch: str
|
||||
remote: Optional[str]
|
||||
sha: str
|
||||
upstream: Optional[str]
|
||||
ahead: int
|
||||
behind: int
|
||||
gone: bool
|
||||
current: bool
|
||||
upstream_remote: Optional[str] = None
|
||||
|
||||
@property
|
||||
def upstream_branch(self) -> Optional[str]:
|
||||
"""The upstream's name on its remote ("bar" for origin/bar), None without a remote upstream"""
|
||||
if self.upstream and self.upstream_remote:
|
||||
return self.upstream[len(self.upstream_remote) + 1:]
|
||||
return None
|
||||
|
||||
|
||||
class CommitInfo(NamedTuple):
|
||||
"""One commit: its hashes, author, date, subject and body"""
|
||||
|
||||
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
|
||||
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
||||
@@ -17,9 +108,26 @@ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
||||
FULL_CONTEXT = "-U999999"
|
||||
|
||||
# overridable from settings (sublime's PATH may not have a user-installed git)
|
||||
GIT_BINARY = "git"
|
||||
GIT_BINARY: str = "git"
|
||||
|
||||
STATUS_NAMES = {
|
||||
# environment of the network commands: no terminal to answer a credential prompt or edit a merge
|
||||
# message in, so git fails fast instead of hanging the worker thread
|
||||
NO_PROMPT: Dict[str, str] = {"GIT_TERMINAL_PROMPT": "0", "GIT_EDITOR": "true"}
|
||||
# git's messages in English whatever the user's locale: delete_branch's refusal is told apart by its text
|
||||
PLAIN_MESSAGES: Dict[str, str] = {"LC_ALL": "C", "LANGUAGE": "C"}
|
||||
|
||||
# the ref namespaces listed by branches(); remotes carry a symbolic HEAD that is skipped
|
||||
_HEADS = "refs/heads/"
|
||||
_REMOTES = "refs/remotes/"
|
||||
# for-each-ref knows no %x1f escape, so the separator goes in literally
|
||||
_REF_FORMAT = "\x1f".join(("%(refname)", "%(objectname:short)", "%(upstream:short)", "%(upstream:track,nobracket)", "%(HEAD)",
|
||||
"%(symref)", "%(upstream:remotename)"))
|
||||
# sha, short, author, date (as --date says), subject and the decorations the tags are read from
|
||||
_LOG_FORMAT = "--format=%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%D"
|
||||
_RELATIVE_DATE = "--date=relative"
|
||||
_DAY_DATE = "--date=format:%d.%m.%Y"
|
||||
|
||||
STATUS_NAMES: Dict[str, str] = {
|
||||
"M": "modified",
|
||||
"A": "added",
|
||||
"D": "deleted",
|
||||
@@ -38,31 +146,39 @@ _SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(
|
||||
|
||||
|
||||
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:
|
||||
"""Keep stderr and the exit code"""
|
||||
self.stderr = stderr
|
||||
self.code = code
|
||||
first = stderr.strip().splitlines()
|
||||
super().__init__(first[0] if first else f"git exited with {code}")
|
||||
|
||||
|
||||
def run(repo, args, ok_codes=(0,)):
|
||||
"""Run git in `repo`, return stdout as text"""
|
||||
def run(repo: str, args: Sequence[str], ok_codes: Tuple[int, ...] = (0,), env: Optional[Dict[str, str]] = None) -> str:
|
||||
"""Run git in `repo`, return stdout as text; `env` adds to or overrides the inherited environment
|
||||
|
||||
stdin is closed: nothing git or its helpers could ask would get an answer from the worker thread
|
||||
"""
|
||||
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")
|
||||
environment = dict(os.environ, **PLAIN_MESSAGES, **(env or {}))
|
||||
proc = subprocess.run(cmd, cwd=repo, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
encoding="utf-8", errors="replace", env=environment)
|
||||
if proc.returncode not in ok_codes:
|
||||
raise GitError(proc.stderr, proc.returncode)
|
||||
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"""
|
||||
if not path:
|
||||
return None
|
||||
# start from the directory itself, or the file's parent
|
||||
directory = path if os.path.isdir(path) else os.path.dirname(path)
|
||||
if not os.path.isdir(directory):
|
||||
return None
|
||||
# outside a repository git fails, which reads as None
|
||||
try:
|
||||
root = run(directory, ["rev-parse", "--show-toplevel"]).strip()
|
||||
except (GitError, OSError):
|
||||
@@ -70,7 +186,7 @@ def repo_root(path):
|
||||
return root or None
|
||||
|
||||
|
||||
def has_head(repo):
|
||||
def has_head(repo: str) -> bool:
|
||||
"""False in a repository without commits"""
|
||||
try:
|
||||
run(repo, ["rev-parse", "--verify", "-q", "HEAD"])
|
||||
@@ -79,19 +195,85 @@ def has_head(repo):
|
||||
return False
|
||||
|
||||
|
||||
def base_ref(repo):
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _git_paths(repo: str, names: Tuple[str, ...]) -> Tuple[str, ...]:
|
||||
"""Absolute paths of files of the repository's git directory, () outside a repository
|
||||
|
||||
A worktree or submodule keeps them outside <repo>/.git, which there is a file, not a directory
|
||||
"""
|
||||
try:
|
||||
# one --git-path per name, answered one per line
|
||||
text = run(repo, ["rev-parse"] + [arg for name in names for arg in ("--git-path", name)])
|
||||
except (GitError, OSError):
|
||||
return ()
|
||||
return tuple(os.path.join(repo, line) for line in text.split("\n") if line)
|
||||
|
||||
|
||||
def index_path(repo: str) -> Optional[str]:
|
||||
"""Absolute path of the repository's index file, None outside a repository"""
|
||||
paths = _git_paths(repo, ("index",))
|
||||
return paths[0] if paths else None
|
||||
|
||||
|
||||
# what any checkout, commit, fetch, push or branch change touches: refs are written through a lock-file
|
||||
# rename (bumping the directory's mtime) and every HEAD move appends to logs/HEAD
|
||||
REF_PATH_NAMES: Tuple[str, ...] = ("HEAD", "logs/HEAD", "packed-refs", "FETCH_HEAD", "refs/heads", "refs/remotes")
|
||||
REF_DIRS: Tuple[str, ...] = ("refs/heads", "refs/remotes")
|
||||
|
||||
|
||||
# {repo: (the ref directories walked, roots first, and their mtimes then)}: a directory's mtime moves when an
|
||||
# entry is added to or removed from it, so the walk is only redone when one of them moved
|
||||
_nested_refs: Dict[str, Tuple[Tuple[str, ...], Tuple[Optional[float], ...]]] = {}
|
||||
|
||||
|
||||
def mtime(path: str) -> Optional[float]:
|
||||
"""A file's or directory's mtime, None when it is gone"""
|
||||
try:
|
||||
return os.stat(path).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def ref_paths(repo: str) -> Tuple[str, ...]:
|
||||
"""Absolute paths whose mtimes change when the branches do; () outside a repository
|
||||
|
||||
REF_PATH_NAMES, then every directory below the ref directories: a ref's rename bumps only the directory
|
||||
it is in, and remote-tracking refs and slashed branches live one or more levels down
|
||||
"""
|
||||
paths = _git_paths(repo, REF_PATH_NAMES)
|
||||
if not paths:
|
||||
return paths
|
||||
roots = tuple(paths[REF_PATH_NAMES.index(name)] for name in REF_DIRS)
|
||||
cached = _nested_refs.get(repo)
|
||||
if cached is not None:
|
||||
walked, mtimes = cached
|
||||
if tuple(mtime(d) for d in walked) == mtimes:
|
||||
return paths + walked[len(roots):]
|
||||
found: List[str] = []
|
||||
for root in roots:
|
||||
for directory, dirs, _ in os.walk(root):
|
||||
dirs.sort()
|
||||
if directory != root:
|
||||
found.append(directory)
|
||||
walked = roots + tuple(found)
|
||||
_nested_refs[repo] = (walked, tuple(mtime(d) for d in walked))
|
||||
return paths + tuple(found)
|
||||
|
||||
|
||||
def base_ref(repo: str) -> str:
|
||||
"""HEAD, or the empty tree when there is no commit yet"""
|
||||
return "HEAD" if has_head(repo) else EMPTY_TREE
|
||||
|
||||
|
||||
def _int_or_none(value):
|
||||
"""A numstat count: "-" marks a binary file"""
|
||||
def _int_or_none(value: str) -> Optional[int]:
|
||||
"""A numstat count as int, None for "-" (binary)"""
|
||||
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)}"""
|
||||
result = {}
|
||||
result: Dict[str, Numstat] = {}
|
||||
# each NUL field is one "insertions<TAB>deletions<TAB>path" entry
|
||||
fields = iter(text.split("\0"))
|
||||
for entry in fields:
|
||||
parts = entry.split("\t")
|
||||
@@ -107,9 +289,10 @@ def _parse_numstat(text):
|
||||
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)]"""
|
||||
result = []
|
||||
result: List[Tuple[str, str, Optional[str]]] = []
|
||||
# a status field, then its path; a rename or copy adds the new path after the old one
|
||||
fields = iter(text.split("\0"))
|
||||
for status in fields:
|
||||
if not status:
|
||||
@@ -122,15 +305,17 @@ def _parse_name_status(text):
|
||||
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"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
# a NUL in the first 8000 bytes marks a binary file
|
||||
head = f.read(8000)
|
||||
if b"\0" in head:
|
||||
return None
|
||||
count = head.count(b"\n")
|
||||
last = head[-1:]
|
||||
# the rest in 1 MiB chunks, remembering the final byte
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
@@ -139,12 +324,13 @@ def _count_lines(path):
|
||||
last = chunk[-1:]
|
||||
except OSError:
|
||||
return None
|
||||
# an empty file has no lines; a last line without a newline still counts
|
||||
if not last:
|
||||
return 0
|
||||
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"""
|
||||
status = run(repo, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--no-renames"])
|
||||
entries = [e for e in status.split("\0") if e]
|
||||
@@ -152,7 +338,8 @@ def changed_files(repo):
|
||||
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
|
||||
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base]))
|
||||
|
||||
changes = []
|
||||
# "1" ordinary, "u" unmerged, "?" untracked entries; anything else (branch headers) is skipped
|
||||
changes: List[FileChange] = []
|
||||
for entry in entries:
|
||||
kind = entry[0]
|
||||
if kind == "1":
|
||||
@@ -164,52 +351,162 @@ def changed_files(repo):
|
||||
xy, path = ".?", entry[2:]
|
||||
else:
|
||||
continue
|
||||
# any index change means staged, any worktree one unstaged; the worktree letter wins over the index letter
|
||||
x, y = xy[0], xy[1]
|
||||
staged = x not in (".", "?")
|
||||
staged, unstaged = x not in (".", "?"), y != "."
|
||||
letter = y if y != "." else x
|
||||
# untracked files have no numstat: their lines count as insertions
|
||||
if letter == "?":
|
||||
lines = _count_lines(os.path.join(repo, path))
|
||||
ins, dele, old = lines, (None if lines is None else 0), None
|
||||
else:
|
||||
ins, dele, old = counts.get(path, (0, 0, None))
|
||||
changes.append(FileChange(path, letter, staged, ins, dele, old))
|
||||
changes.append(FileChange(path, letter, staged, ins, dele, old, unstaged))
|
||||
changes.sort(key=lambda c: c.path)
|
||||
return changes
|
||||
|
||||
|
||||
def recent_commits(repo, count):
|
||||
"""Last `count` commits, newest first; empty list without commits"""
|
||||
if not has_head(repo):
|
||||
return []
|
||||
# --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"])
|
||||
commits = []
|
||||
for line in text.split("\n"):
|
||||
def _log(repo: str, args: Sequence[str]) -> List[Commit]:
|
||||
"""`log <args>` in _LOG_FORMAT with --shortstat -> commits, newest first
|
||||
|
||||
A six-field line starts a commit (its tags come from the %D decoration list); the shortstat line
|
||||
after it fills in its totals
|
||||
"""
|
||||
commits: List[Commit] = []
|
||||
for line in run(repo, ["log", _LOG_FORMAT, "--shortstat"] + list(args)).split("\n"):
|
||||
parts = line.split("\x1f")
|
||||
if len(parts) == 5:
|
||||
commits.append(Commit(*parts, files=None, insertions=None, deletions=None))
|
||||
if len(parts) == 6:
|
||||
sha, short, author, date, subject, decorations = parts
|
||||
tags = tuple(d[5:] for d in decorations.split(", ") if d.startswith("tag: "))
|
||||
commits.append(Commit(sha, short, author, date, subject, None, None, None, tags))
|
||||
continue
|
||||
stat = commits and commits[-1].files is None and _SHORTSTAT.match(line)
|
||||
if stat:
|
||||
files, ins, dele = (int(g or 0) for g in stat.groups())
|
||||
commits[-1] = commits[-1]._replace(files=files, insertions=ins, deletions=dele)
|
||||
if commits and commits[-1].files is None:
|
||||
stat = _SHORTSTAT.match(line)
|
||||
if stat:
|
||||
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
|
||||
|
||||
|
||||
def commit_with_files(repo, sha):
|
||||
def recent_commits(repo: str, count: int) -> List[Commit]:
|
||||
"""Last `count` commits, newest first, dates relative; empty list without commits"""
|
||||
if not has_head(repo):
|
||||
return []
|
||||
return _log(repo, ["-n", str(count), _RELATIVE_DATE])
|
||||
|
||||
|
||||
def ref_log(repo: str, ref: str, count: int) -> List[Commit]:
|
||||
"""Last `count` commits reachable from `ref`, newest first, with dd.mm.yyyy dates"""
|
||||
return _log(repo, [ref, "-n", str(count), _DAY_DATE, "--"])
|
||||
|
||||
|
||||
# --- branches ---------------------------------------------------------------
|
||||
|
||||
|
||||
def remotes(repo: str) -> List[str]:
|
||||
"""Configured remote names, fetched or not"""
|
||||
return [line for line in run(repo, ["remote"]).split("\n") if line]
|
||||
|
||||
|
||||
def head_sha(repo: str) -> str:
|
||||
"""HEAD's short hash, "" before the first commit; which branch it is on, if any, branches() marks"""
|
||||
# -q --verify: exit 1 and no output without a commit
|
||||
return run(repo, ["rev-parse", "-q", "--verify", "--short", "HEAD"], ok_codes=(0, 1)).strip()
|
||||
|
||||
|
||||
def _track(text: str) -> Tuple[int, int, bool]:
|
||||
"""`%(upstream:track,nobracket)` -> (ahead, behind, gone)"""
|
||||
ahead = re.search(r"ahead (\d+)", text)
|
||||
behind = re.search(r"behind (\d+)", text)
|
||||
return int(ahead.group(1)) if ahead else 0, int(behind.group(1)) if behind else 0, text.strip() == "gone"
|
||||
|
||||
|
||||
def branches(repo: str) -> List[Ref]:
|
||||
"""Local branches, then remote ones, in git's ref order; a remote's symbolic HEAD is left out"""
|
||||
refs: List[Ref] = []
|
||||
for line in run(repo, ["for-each-ref", f"--format={_REF_FORMAT}", _HEADS, _REMOTES]).split("\n"):
|
||||
parts = line.split("\x1f")
|
||||
if len(parts) != 7:
|
||||
continue
|
||||
refname, sha, upstream, track, marker, symref, remotename = parts
|
||||
if symref:
|
||||
continue
|
||||
ahead, behind, gone = _track(track)
|
||||
if refname.startswith(_HEADS):
|
||||
name = refname[len(_HEADS):]
|
||||
# "." is git's name for the local repository: an upstream that is a local branch
|
||||
upstream_remote = remotename if upstream and remotename != "." else None
|
||||
refs.append(Ref(name, name, None, sha, upstream or None, ahead, behind, gone, marker == "*", upstream_remote))
|
||||
elif refname.startswith(_REMOTES):
|
||||
name = refname[len(_REMOTES):]
|
||||
# the remote is the first path segment; a remote named with a slash is not supported
|
||||
remote, _, branch = name.partition("/")
|
||||
refs.append(Ref(name, branch, remote, sha, None, 0, 0, False, False))
|
||||
return refs
|
||||
|
||||
|
||||
def checkout(repo: str, ref: Ref, local_names: Sequence[str] = ()) -> None:
|
||||
"""Check out a local branch, or a remote one as a same-named local branch tracking it
|
||||
|
||||
An existing local branch of that name is checked out as it is. The remote is named explicitly:
|
||||
git's own guess fails when two remotes carry the branch
|
||||
"""
|
||||
if ref.remote is None or ref.branch in local_names:
|
||||
run(repo, ["checkout", "-q", ref.branch, "--"])
|
||||
else:
|
||||
run(repo, ["checkout", "-q", "--track", f"{ref.remote}/{ref.branch}", "--"])
|
||||
|
||||
|
||||
def checkout_detached(repo: str, name: str) -> None:
|
||||
"""Check out `name` (a remote branch, say) with HEAD detached"""
|
||||
run(repo, ["checkout", "-q", "--detach", name, "--"])
|
||||
|
||||
|
||||
def pull(repo: str) -> None:
|
||||
"""Pull the current branch from its upstream, as the user's pull.* configuration says"""
|
||||
run(repo, ["pull", "-q"], env=NO_PROMPT)
|
||||
|
||||
|
||||
def fetch_into(repo: str, remote: str, remote_branch: str, branch: str) -> None:
|
||||
"""Fast-forward the local `branch` to `remote`'s `remote_branch` without checking it out
|
||||
|
||||
git refuses a non-fast-forward update and a branch that is checked out, which the error says
|
||||
"""
|
||||
run(repo, ["fetch", "-q", remote, f"{remote_branch}:{branch}"], env=NO_PROMPT)
|
||||
|
||||
|
||||
def push(repo: str, remote: str, branch: str, set_upstream: bool = False, remote_branch: Optional[str] = None) -> None:
|
||||
"""Push `branch` to `remote`, as `remote_branch` there when that differs, recording it as the upstream when asked"""
|
||||
refspec = f"{branch}:{remote_branch}" if remote_branch else branch
|
||||
run(repo, ["push", "-q"] + (["-u"] if set_upstream else []) + [remote, refspec], env=NO_PROMPT)
|
||||
|
||||
|
||||
def fetch_all(repo: str) -> None:
|
||||
"""Fetch every remote, dropping remote branches that no longer exist"""
|
||||
run(repo, ["fetch", "-q", "--all", "--prune"], env=NO_PROMPT)
|
||||
|
||||
|
||||
def delete_branch(repo: str, branch: str, force: bool = False) -> None:
|
||||
"""Delete a local branch; without `force` git refuses one that is not fully merged ("not fully merged" in stderr)"""
|
||||
run(repo, ["branch", "-D" if force else "-d", branch])
|
||||
|
||||
|
||||
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"""
|
||||
show = ["show", "--first-parent", "-m", "-z"]
|
||||
text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
|
||||
# git terminates the header with its own NUL and a newline before the file list
|
||||
# our %x00 ends the header; strip the separator git adds before the file list
|
||||
info_text, _, files_text = text.partition("\0")
|
||||
files_text = files_text.lstrip("\0\n")
|
||||
# pad missing trailing fields so CommitInfo always gets six
|
||||
parts = info_text.split("\x1f")
|
||||
while len(parts) < 6:
|
||||
parts.append("")
|
||||
info = CommitInfo(*parts[:6])
|
||||
|
||||
# second call: per-file counts, matched to the name-status list by path
|
||||
counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha]))
|
||||
changes = []
|
||||
changes: List[FileChange] = []
|
||||
for status, path, old_path in _parse_name_status(files_text):
|
||||
ins, dele, _ = counts.get(path, (None, None, None))
|
||||
changes.append(FileChange(path, status, False, ins, dele, old_path))
|
||||
@@ -217,7 +514,7 @@ def commit_with_files(repo, sha):
|
||||
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`"""
|
||||
try:
|
||||
run(repo, ["cat-file", "-e", f"{ref}:{path}"])
|
||||
@@ -226,13 +523,14 @@ def _in_tree(repo, ref, path):
|
||||
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
|
||||
|
||||
Working tree vs HEAD (staged and unstaged merged) by default; a commit vs its
|
||||
first parent when `sha` is given; the whole file as insertions when untracked.
|
||||
Working tree vs HEAD by default (staged and unstaged merged), a commit vs its first parent with
|
||||
`sha`, the whole file as insertions when untracked
|
||||
"""
|
||||
common = ["--no-color", "--no-ext-diff", FULL_CONTEXT]
|
||||
# untracked: against /dev/null, where exit code 1 only means the files differ
|
||||
if untracked:
|
||||
return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1))
|
||||
if sha:
|
||||
@@ -240,40 +538,54 @@ def full_diff(repo, path, sha=None, untracked=False):
|
||||
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"""
|
||||
run(repo, ["add", "-A", "--", change.path])
|
||||
|
||||
|
||||
def commit(repo, message):
|
||||
"""Commit what is staged; the message is one argv element, so it needs no quoting of its own"""
|
||||
def stage_all(repo: str) -> None:
|
||||
"""Stage every change in the working tree, including deletions and untracked files"""
|
||||
run(repo, ["add", "-A"])
|
||||
|
||||
|
||||
def commit(repo: str, message: str) -> None:
|
||||
"""Commit what is staged; `message` goes as one argv element, unquoted"""
|
||||
run(repo, ["commit", "-m", message])
|
||||
|
||||
|
||||
def head_message(repo):
|
||||
"""HEAD's whole message, exactly as git stores it"""
|
||||
def head_message(repo: str) -> str:
|
||||
"""HEAD's full message, surrounding newlines stripped"""
|
||||
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
|
||||
|
||||
`allow_empty` for a pure reword: git otherwise refuses an amend whose result carries no change,
|
||||
which would fail on a commit that happens to be empty.
|
||||
`allow_empty` lets a reword through when HEAD itself is empty (no diff vs its parent)
|
||||
"""
|
||||
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
|
||||
|
||||
|
||||
def unstage(repo, change):
|
||||
"""Move the file's staged changes back to the working tree"""
|
||||
def _unstage_path(repo: str, path: str) -> None:
|
||||
"""Move the staged changes under `path` back to the working tree; before the first commit, drop them from the index"""
|
||||
if has_head(repo):
|
||||
run(repo, ["reset", "-q", "--", change.path])
|
||||
run(repo, ["reset", "-q", "--", path])
|
||||
else:
|
||||
run(repo, ["rm", "-r", "-q", "--cached", "--", change.path])
|
||||
run(repo, ["rm", "-r", "-q", "--cached", "--", path])
|
||||
|
||||
|
||||
def discard(repo, change):
|
||||
"""Throw away the file's changes, staged and unstaged, restoring the HEAD version"""
|
||||
def unstage(repo: str, change: FileChange) -> None:
|
||||
"""Move the file's staged changes back to the working tree"""
|
||||
_unstage_path(repo, change.path)
|
||||
|
||||
|
||||
def unstage_all(repo: str) -> None:
|
||||
"""Move every staged change back to the working tree"""
|
||||
_unstage_path(repo, ".")
|
||||
|
||||
|
||||
def discard(repo: str, change: FileChange) -> None:
|
||||
"""Throw away the file's staged and unstaged changes: restore HEAD's version, or delete a new file"""
|
||||
if change.status == "?":
|
||||
run(repo, ["clean", "-f", "--", change.path])
|
||||
return
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
""" Event listener: state cleanup, refresh when the tab comes back or a file is saved """
|
||||
"""Event listener: state cleanup, refresh when the tab comes back or a file is saved, the keymap's mode context"""
|
||||
from typing import Optional
|
||||
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
|
||||
from . import state
|
||||
from . import render, state
|
||||
from .commands import refresh_if_changed, restore_view
|
||||
|
||||
# the keymap context naming the review view's mode; a setting context would not compare strings reliably
|
||||
MODE_CONTEXT = "vdiff_mode"
|
||||
|
||||
|
||||
class VdiffEventListener(sublime_plugin.EventListener):
|
||||
def on_pre_close(self, view):
|
||||
def on_pre_close(self, view: sublime.View) -> None:
|
||||
"""Forget the closing view's state"""
|
||||
state.drop(view)
|
||||
|
||||
def on_activated(self, view):
|
||||
"""Re-render when the tab comes back and the file or index changed meanwhile (agent edits)"""
|
||||
def on_activated(self, view: sublime.View) -> None:
|
||||
"""Re-render when the tab comes back and the file, the index or the branches changed meanwhile"""
|
||||
if state.is_review_view(view):
|
||||
refresh_if_changed(view, state.get(view) or restore_view(view))
|
||||
|
||||
def on_post_save(self, view):
|
||||
def on_selection_modified(self, view: sublime.View) -> None:
|
||||
"""In the all-files view the header follows the caret from file to file"""
|
||||
if state.is_review_view(view):
|
||||
st = state.get(view)
|
||||
if st is not None:
|
||||
render.follow_caret(view, st)
|
||||
|
||||
def on_post_save(self, view: sublime.View) -> None:
|
||||
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
|
||||
for view_id, st in state.tracked():
|
||||
review = sublime.View(view_id)
|
||||
if review.is_valid():
|
||||
refresh_if_changed(review, st)
|
||||
|
||||
def on_query_context(self, view: sublime.View, key: str, operator: int, operand: object, match_all: bool) -> Optional[bool]:
|
||||
"""Answer the keymap's `vdiff_mode` context: whether the review view shows "diff" or "branches"
|
||||
|
||||
Anything else is left to other packages (None)
|
||||
"""
|
||||
if key != MODE_CONTEXT or not state.is_review_view(view):
|
||||
return None
|
||||
mode = view.settings().get(state.MODE_SETTING) or state.MODE_DIFF
|
||||
if operator == sublime.OP_EQUAL:
|
||||
return mode == operand
|
||||
if operator == sublime.OP_NOT_EQUAL:
|
||||
return mode != operand
|
||||
return None
|
||||
|
||||
431
vdiff/palette.py
Normal file
431
vdiff/palette.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""The colour-scheme scopes vDiff paints changed lines with, provisioned into a scheme override
|
||||
|
||||
view.add_regions repaints text with the region scope's foreground, so one scope cannot give both a
|
||||
red/green background and the file's syntax colours. This module generates a scope per (background
|
||||
kind, colour) pair carrying both, and render paints each run of same-coloured tokens with it.
|
||||
|
||||
Sublime picks up a new override only on a restart, so the palette is probed and written in one pass
|
||||
rather than a colour at a time
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import sublime
|
||||
|
||||
from .utils import settings
|
||||
|
||||
LOG = logging.getLogger("vdiff")
|
||||
|
||||
# tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text
|
||||
SCOPE_DEL = "diff.deleted"
|
||||
SCOPE_INS = "diff.inserted"
|
||||
SCOPE_DEL_CHAR = "diff.deleted.char"
|
||||
SCOPE_INS_CHAR = "diff.inserted.char"
|
||||
# context rows of the all-files view: the editor background, nudged so the colour still paints
|
||||
SCOPE_CTX = "vdiff.context"
|
||||
|
||||
# background kinds (tinted ones paint changed lines, "ctx" the all-files context rows) and their tint scopes
|
||||
TINT_KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
|
||||
KINDS: Tuple[str, ...] = TINT_KINDS + ("ctx",)
|
||||
KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR,
|
||||
"ctx": SCOPE_CTX}
|
||||
|
||||
# all-files file heading, its status letter and its -/+ counts; their colours come from settings, these when one is empty
|
||||
SCOPE_FILE_HEADER = "vdiff.file.header"
|
||||
SCOPE_FILE_DEL_COUNT = "vdiff.file.del.count"
|
||||
SCOPE_FILE_INS_COUNT = "vdiff.file.ins.count"
|
||||
SCOPE_FILE_STATUS = "vdiff.file.status"
|
||||
FILE_HEADER_BACKGROUND = "#0000FF"
|
||||
FILE_STATUS_FOREGROUND = "#ffffff"
|
||||
# sublime's former markup.deleted / markup.inserted colours
|
||||
FILE_DEL_FOREGROUND = "#d64646"
|
||||
FILE_INS_FOREGROUND = "#46d646"
|
||||
# red / green text on the plain editor background (the branches view's -/+ counts and tags): a region
|
||||
# only recolours its text when it is filled, so these carry the nudged editor background like SCOPE_CTX
|
||||
SCOPE_TEXT_DEL = "vdiff.text.del"
|
||||
SCOPE_TEXT_INS = "vdiff.text.ins"
|
||||
# the commit under the caret in the branches view: the scheme's own line-highlight colour, on its cells only,
|
||||
# and the red / green text of its counts and tags on that colour
|
||||
SCOPE_CURSOR = "vdiff.commit.cursor"
|
||||
SCOPE_CURSOR_DEL = "vdiff.commit.cursor.del"
|
||||
SCOPE_CURSOR_INS = "vdiff.commit.cursor.ins"
|
||||
# blend towards the foreground when the scheme names no line highlight
|
||||
CURSOR_BLEND = 0.12
|
||||
# the rules written whatever the settings say, by the view that needs them, so the restart hint names what is pending
|
||||
DIFF_SCOPES: Tuple[str, ...] = (SCOPE_FILE_HEADER, SCOPE_FILE_STATUS, SCOPE_FILE_DEL_COUNT, SCOPE_FILE_INS_COUNT)
|
||||
BRANCH_SCOPES: Tuple[str, ...] = (SCOPE_FILE_HEADER, SCOPE_FILE_DEL_COUNT, SCOPE_FILE_INS_COUNT, SCOPE_TEXT_DEL, SCOPE_TEXT_INS,
|
||||
SCOPE_CURSOR, SCOPE_CURSOR_DEL, SCOPE_CURSOR_INS)
|
||||
|
||||
# colours the user may set; an empty line colour keeps the colour scheme's tint or the plain-text look
|
||||
COLOUR_SETTINGS: Tuple[str, ...] = ("deleted_line_background", "inserted_line_background",
|
||||
"line_numbers_foreground", "line_numbers_background",
|
||||
"file_header_background", "file_header_deleted_foreground",
|
||||
"file_header_inserted_foreground", "file_header_status_foreground")
|
||||
# the scopes those colours go into: the changed-line tints and the all-files line-number column
|
||||
SCOPE_LINE_DEL = "vdiff.line.del"
|
||||
SCOPE_LINE_INS = "vdiff.line.ins"
|
||||
SCOPE_LINE_NUMBERS = "vdiff.line.numbers"
|
||||
|
||||
# tints to provision when the active scheme defines none of its own (some third-party schemes)
|
||||
FALLBACK_TINTS: Dict[str, str] = {
|
||||
SCOPE_DEL: "hsla(357, 45%, 60%, 0.15)",
|
||||
SCOPE_DEL_CHAR: "hsla(357, 60%, 60%, 0.30)",
|
||||
SCOPE_INS: "hsla(114, 31%, 68%, 0.15)",
|
||||
SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)",
|
||||
}
|
||||
|
||||
# standard scope roots probed for the palette; no API returns it whole, and parsing the scheme
|
||||
# file would mean re-doing sublime's merging
|
||||
PALETTE_PROBES: Tuple[str, ...] = (
|
||||
"source",
|
||||
"comment",
|
||||
"string",
|
||||
"constant.numeric",
|
||||
"constant.language",
|
||||
"constant.character.escape",
|
||||
"variable",
|
||||
"variable.parameter",
|
||||
"variable.function",
|
||||
"keyword",
|
||||
"keyword.operator",
|
||||
"storage.type",
|
||||
"storage.modifier",
|
||||
"entity.name.function",
|
||||
"entity.name.class",
|
||||
"entity.name.tag",
|
||||
"entity.other.attribute-name",
|
||||
"entity.other.inherited-class",
|
||||
"support.function",
|
||||
"support.class",
|
||||
"support.type",
|
||||
"support.constant",
|
||||
"punctuation.definition",
|
||||
"punctuation.separator",
|
||||
"meta.function-call",
|
||||
"invalid",
|
||||
)
|
||||
|
||||
# {override file name: {colour: index}} for every scheme provisioned this session
|
||||
_palettes: Dict[str, Dict[str, int]] = {}
|
||||
# the override name the last ensure() resolved, so scope_for knows which palette to read
|
||||
_active: Optional[str] = None
|
||||
# (scheme, scopes) settled for this session: the user was told they need a restart, or they were found live
|
||||
_notified: Set[Tuple[Optional[str], Tuple[str, ...]]] = set()
|
||||
# {override file name: the user colours it was provisioned with}, so changing one re-provisions
|
||||
_provisioned_with: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Drop the caches, so the next ensure() re-reads and re-probes. Only the tests need this"""
|
||||
global _active
|
||||
_palettes.clear()
|
||||
_notified.clear()
|
||||
_provisioned_with.clear()
|
||||
_active = None
|
||||
|
||||
|
||||
def claim_restart_notice(view: sublime.View, scopes: Tuple[str, ...], tokens: bool = True) -> bool:
|
||||
"""Whether to tell the user to restart for `scopes` (and the token layer, with `tokens`); True at most once per scheme and set
|
||||
|
||||
Only when the override is written but the rules a view needs are not loaded yet; a view that needs
|
||||
only rules already live after an upgrade is not sent to restart for the others. Either answer is
|
||||
settled until the override is written again, so a render does not probe the scheme each time
|
||||
"""
|
||||
key = (_active, scopes)
|
||||
if not (_active and _palettes.get(_active)) or key in _notified:
|
||||
return False
|
||||
_notified.add(key)
|
||||
live = (not tokens or tokens_resolved(view, KINDS)) and all(scope_background(view, scope) for scope in scopes)
|
||||
return not live
|
||||
|
||||
|
||||
def scope_background(view: sublime.View, scope: str) -> Optional[str]:
|
||||
"""The scheme's background for a scope, or None"""
|
||||
return _style(view, scope).get("background")
|
||||
|
||||
|
||||
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
|
||||
"""The scheme's style for a scope, or {} when style_for_scope fails"""
|
||||
try:
|
||||
return view.style_for_scope(scope)
|
||||
except Exception:
|
||||
LOG.debug("style_for_scope failed for %s", scope, exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def foreground(view: sublime.View, scope: str) -> Optional[str]:
|
||||
"""The scheme's foreground for a scope, normalised by sublime to #rrggbb"""
|
||||
return _style(view, scope).get("foreground")
|
||||
|
||||
|
||||
def tokens_resolved(view: sublime.View, kinds: Tuple[str, ...] = TINT_KINDS) -> bool:
|
||||
"""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
|
||||
sublime's unknown-scope green. A new override loads only on a restart, so the writing session sees false
|
||||
"""
|
||||
colours = (_active and _palettes.get(_active)) or {}
|
||||
if not colours:
|
||||
return False
|
||||
# a kind added since the override was written loads only after a restart, so check each kind
|
||||
# probe a colour unlike the default foreground (an unknown scope falls back to it);
|
||||
# ctx's background may go unreported
|
||||
default = foreground(view, "text.plain")
|
||||
colour, index = next(((c, i) for c, i in colours.items() if c != default), next(iter(colours.items())))
|
||||
# live when the rule's background shows, or its distinct foreground does
|
||||
return all(scope_background(view, _scope_name(kind, index)) or
|
||||
(colour != default and foreground(view, _scope_name(kind, index)) == colour)
|
||||
for kind in kinds)
|
||||
|
||||
|
||||
def scope_for(kind: str, colour: str) -> Optional[str]:
|
||||
"""The generated scope carrying `kind`'s background and `colour`, or None if never provisioned"""
|
||||
index = _palettes.get(_active or "", {}).get(colour)
|
||||
return None if index is None else _scope_name(kind, index)
|
||||
|
||||
|
||||
def token_key(scope: str) -> str:
|
||||
"""The add_regions key for a generated scope"""
|
||||
return scope.replace("vdiff.", "vdiff.tok.", 1)
|
||||
|
||||
|
||||
def token_keys() -> List[str]:
|
||||
"""Every region key the token layer can use, so a re-render can erase them all"""
|
||||
colours = _palettes.get(_active or "", {})
|
||||
return [token_key(_scope_name(kind, i)) for kind in KINDS for i in sorted(colours.values())]
|
||||
|
||||
|
||||
def user_colours() -> Dict[str, str]:
|
||||
"""{setting: colour} for COLOUR_SETTINGS, "" where unset"""
|
||||
return {name: settings().get(name) or "" for name in COLOUR_SETTINGS}
|
||||
|
||||
|
||||
def line_scope(view: sublime.View, kind: str) -> str:
|
||||
"""Tint scope for a changed line, "del" or "ins": the user's colour once its rule is live, else the scheme's"""
|
||||
own, setting, scheme = ((SCOPE_LINE_DEL, "deleted_line_background", SCOPE_DEL) if kind == "del" else
|
||||
(SCOPE_LINE_INS, "inserted_line_background", SCOPE_INS))
|
||||
return own if settings().get(setting) and scope_background(view, own) else scheme
|
||||
|
||||
|
||||
def text_scope(view: sublime.View, kind: str) -> str:
|
||||
"""Scope painting text red ("del") or green ("ins") on the editor background, once its rule is live
|
||||
|
||||
Until the restart that loads it, the scheme's diff tint stands in: a coloured background rather than plain text
|
||||
"""
|
||||
own, scheme = (SCOPE_TEXT_DEL, SCOPE_DEL) if kind == "del" else (SCOPE_TEXT_INS, SCOPE_INS)
|
||||
return own if scope_background(view, own) else scheme
|
||||
|
||||
|
||||
def count_scope(view: sublime.View, kind: str) -> str:
|
||||
"""Scope painting text red ("del") or green ("ins") on the file-heading background, once both rules are live
|
||||
|
||||
Before that, text_scope's stand-in: the heading tint is then a scheme fallback with no matching text rule anyway
|
||||
"""
|
||||
own = SCOPE_FILE_DEL_COUNT if kind == "del" else SCOPE_FILE_INS_COUNT
|
||||
return own if scope_background(view, SCOPE_FILE_HEADER) and scope_background(view, own) else text_scope(view, kind)
|
||||
|
||||
|
||||
def cursor_scope(view: sublime.View) -> Optional[str]:
|
||||
"""Scope tinting the commit under the caret like a highlighted line, or None until its rule is live"""
|
||||
return SCOPE_CURSOR if scope_background(view, SCOPE_CURSOR) else None
|
||||
|
||||
|
||||
def cursor_text_scope(view: sublime.View, kind: str) -> str:
|
||||
"""Scope painting text red ("del") or green ("ins") on the cursor's background, once live, else text_scope's stand-in"""
|
||||
own = SCOPE_CURSOR_DEL if kind == "del" else SCOPE_CURSOR_INS
|
||||
return own if scope_background(view, own) else text_scope(view, kind)
|
||||
|
||||
|
||||
def line_numbers_scope(view: sublime.View) -> str:
|
||||
"""Scope for the all-files line-number column: the user's colours once their rule is live, else plain text"""
|
||||
if (settings().get("line_numbers_foreground") or settings().get("line_numbers_background")) \
|
||||
and scope_background(view, SCOPE_LINE_NUMBERS):
|
||||
return SCOPE_LINE_NUMBERS
|
||||
return "text.plain"
|
||||
|
||||
|
||||
def _scope_name(kind: str, index: int) -> str:
|
||||
"""Generated scope name for a background kind and palette index"""
|
||||
return f"vdiff.{kind}.c{index}"
|
||||
|
||||
|
||||
def ensure(view: sublime.View) -> None:
|
||||
"""Provision the scopes for the active scheme, once per scheme per session
|
||||
|
||||
A new override loads only on a restart; tokens_resolved() says when it is live
|
||||
"""
|
||||
global _active
|
||||
name = override_name(view)
|
||||
_active = name
|
||||
user = user_colours()
|
||||
if not name or (name in _palettes and _provisioned_with.get(name) == user):
|
||||
return
|
||||
_provisioned_with[name] = user
|
||||
# merge into the existing override, if any
|
||||
path = os.path.join(sublime.packages_path(), "User", name)
|
||||
try:
|
||||
scheme: Dict[str, Any] = {}
|
||||
if os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
# invalid JSON raises here, leaving the file untouched; render still shows the changed lines
|
||||
scheme = sublime.decode_value(f.read()) or {}
|
||||
rules = scheme.setdefault("rules", [])
|
||||
# probe the scheme, record the palette, and write only when a rule changed
|
||||
tints = _tints(view, user)
|
||||
colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
|
||||
_palettes[name] = colours
|
||||
if not _merge_rules(rules, tints, colours, view, user):
|
||||
return
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(scheme, f, indent=4)
|
||||
# new rules: whether they are live is an open question again
|
||||
_notified.difference_update({key for key in _notified if key[0] == name})
|
||||
LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path)
|
||||
except Exception:
|
||||
LOG.warning("could not write colour scheme override %s", path, exc_info=True)
|
||||
# mark the scheme as handled so this session does not retry it
|
||||
_palettes.setdefault(name, {})
|
||||
|
||||
|
||||
def _probe_palette(view: sublime.View) -> List[str]:
|
||||
"""The distinct foregrounds the active scheme uses, in probe order so indices stay meaningful"""
|
||||
seen: List[str] = []
|
||||
for scope in PALETTE_PROBES:
|
||||
colour = foreground(view, scope)
|
||||
if colour and colour not in seen:
|
||||
seen.append(colour)
|
||||
return seen
|
||||
|
||||
|
||||
def _tints(view: sublime.View, user: Dict[str, str]) -> Dict[str, str]:
|
||||
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
|
||||
|
||||
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own.
|
||||
A line colour the user set wins over both
|
||||
"""
|
||||
tints: Dict[str, str] = {}
|
||||
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)):
|
||||
line = scope_background(view, line_scope)
|
||||
char = scope_background(view, char_scope)
|
||||
tints[line_scope] = line if line and char else FALLBACK_TINTS[line_scope]
|
||||
tints[char_scope] = char if line and char else FALLBACK_TINTS[char_scope]
|
||||
for scope, setting in ((SCOPE_DEL, "deleted_line_background"), (SCOPE_INS, "inserted_line_background")):
|
||||
if user[setting]:
|
||||
tints[scope] = user[setting]
|
||||
# a region whose background is missing, transparent or equal to the editor's is filled with its
|
||||
# foreground, so context colours carry the editor background nudged by an invisible step
|
||||
tints[SCOPE_CTX] = _nudge(_global_background(view))
|
||||
return tints
|
||||
|
||||
|
||||
def _global(view: sublime.View, key: str) -> Optional[str]:
|
||||
"""One of the scheme's global colours (background, line_highlight, ...), None when unset or unreadable"""
|
||||
try:
|
||||
return view.style().get(key)
|
||||
except Exception:
|
||||
LOG.debug("view.style failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _global_background(view: sublime.View) -> str:
|
||||
"""The scheme's editor background; style_for_scope reports none for scopes that do not set one"""
|
||||
return _global(view, "background") or "#000000"
|
||||
|
||||
|
||||
def _line_highlight(view: sublime.View) -> str:
|
||||
"""The scheme's line-highlight colour, else the editor background blended a little towards the foreground"""
|
||||
return _global(view, "line_highlight") or _blend(_global_background(view), foreground(view, "text.plain") or "#ffffff", CURSOR_BLEND)
|
||||
|
||||
|
||||
def _blend(colour: str, towards: str, fraction: float) -> str:
|
||||
"""#rrggbb moved `fraction` of the way towards `towards`; a colour in another form is returned as it is"""
|
||||
if not (colour.startswith("#") and len(colour) >= 7 and towards.startswith("#") and len(towards) >= 7):
|
||||
return colour
|
||||
mixed = (round(int(colour[i:i + 2], 16) * (1 - fraction) + int(towards[i:i + 2], 16) * fraction) for i in (1, 3, 5))
|
||||
return "#" + "".join(f"{min(255, max(0, c)):02x}" for c in mixed)
|
||||
|
||||
|
||||
def _nudge(colour: str) -> str:
|
||||
"""#rrggbb[aa] with blue moved one step, towards the middle so it never overflows"""
|
||||
if not (colour.startswith("#") and len(colour) in (7, 9)):
|
||||
return colour
|
||||
blue = int(colour[5:7], 16)
|
||||
return f"{colour[:5]}{blue + 1 if blue < 128 else blue - 1:02x}{colour[7:]}"
|
||||
|
||||
|
||||
def _put_rule(rules: List[Dict[str, str]], present: Dict[Optional[str], Dict[str, str]], wanted: Dict[str, str]) -> bool:
|
||||
"""Add `wanted`, or make the rule with its scope equal to it; True when anything changed"""
|
||||
rule = present.get(wanted["scope"])
|
||||
if rule is None:
|
||||
rules.append(wanted)
|
||||
elif rule != wanted:
|
||||
rule.clear()
|
||||
rule.update(wanted)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
|
||||
view: sublime.View, user: Dict[str, str]) -> bool:
|
||||
"""Bring the scheme's rules up to date, in place; True when anything changed
|
||||
|
||||
Each (kind, colour) rule carries a background and a foreground, as a background alone would flatten
|
||||
the line's colours; plain tints are added only when missing and never overwritten, so a hand-tuned one survives
|
||||
"""
|
||||
present = {rule.get("scope"): rule for rule in rules}
|
||||
# the all-files file heading, the commit cursor of the branches view in the line-highlight colour (with the
|
||||
# foreground, so the text keeps its colour), then bold coloured text on the heading, on the editor background
|
||||
# (the branches view's counts and tags) and on the cursor, all in the heading counts' red and green
|
||||
header = user["file_header_background"] or FILE_HEADER_BACKGROUND
|
||||
plain = _nudge(_global_background(view))
|
||||
highlight = _line_highlight(view)
|
||||
red = user["file_header_deleted_foreground"] or FILE_DEL_FOREGROUND
|
||||
green = user["file_header_inserted_foreground"] or FILE_INS_FOREGROUND
|
||||
wanted = [{"scope": SCOPE_FILE_HEADER, "background": header},
|
||||
{"scope": SCOPE_CURSOR, "background": highlight, "foreground": foreground(view, "text.plain") or "#ffffff"}]
|
||||
for scope, background, colour in ((SCOPE_FILE_STATUS, header, user["file_header_status_foreground"] or FILE_STATUS_FOREGROUND),
|
||||
(SCOPE_FILE_DEL_COUNT, header, red), (SCOPE_FILE_INS_COUNT, header, green),
|
||||
(SCOPE_TEXT_DEL, plain, red), (SCOPE_TEXT_INS, plain, green),
|
||||
(SCOPE_CURSOR_DEL, highlight, red), (SCOPE_CURSOR_INS, highlight, green)):
|
||||
wanted.append({"scope": scope, "background": background, "foreground": colour, "font_style": "bold"})
|
||||
# the user's line colours; an unset one keeps any old rule, which render then ignores
|
||||
for scope, setting in ((SCOPE_LINE_DEL, "deleted_line_background"), (SCOPE_LINE_INS, "inserted_line_background")):
|
||||
if user[setting]:
|
||||
wanted.append({"scope": scope, "background": user[setting]})
|
||||
if user["line_numbers_foreground"] or user["line_numbers_background"]:
|
||||
# both halves, as a region without a background is filled with its foreground
|
||||
wanted.append({"scope": SCOPE_LINE_NUMBERS,
|
||||
"foreground": user["line_numbers_foreground"] or foreground(view, "text.plain") or "#ffffff",
|
||||
"background": user["line_numbers_background"] or _nudge(_global_background(view))})
|
||||
changed = False
|
||||
for rule in wanted:
|
||||
changed = _put_rule(rules, present, rule) or changed
|
||||
# plain tints the scheme lacks; the context tint gets no rule of its own
|
||||
for scope, background in tints.items():
|
||||
if scope != SCOPE_CTX and scope not in present and not scope_background(view, scope):
|
||||
rules.append({"scope": scope, "background": background})
|
||||
changed = True
|
||||
# one rule per (kind, colour) pair; a palette shifted under an earlier session's rule is ours to correct
|
||||
for colour, index in colours.items():
|
||||
for kind in KINDS:
|
||||
pair = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], "foreground": colour}
|
||||
changed = _put_rule(rules, present, pair) or changed
|
||||
return changed
|
||||
|
||||
|
||||
def override_name(view: sublime.View) -> Optional[str]:
|
||||
"""File name in Packages/User that overrides the active scheme (same base name, .sublime-color-scheme)"""
|
||||
try:
|
||||
# already resolved through "auto" and dark/light settings by sublime
|
||||
name = sublime.ui_info()["color_scheme"]["resolved_value"]
|
||||
except Exception:
|
||||
name = view.settings().get("color_scheme") or ""
|
||||
# no usable scheme name: nothing to override
|
||||
if not name or name == "auto":
|
||||
return None
|
||||
root, _ = os.path.splitext(os.path.basename(name))
|
||||
return root + ".sublime-color-scheme"
|
||||
@@ -1,32 +1,56 @@
|
||||
""" Unified diff -> lines with old/new numbers, change blocks, word-level ranges; no sublime imports """
|
||||
"""Unified diff -> lines with old/new numbers, change blocks, character-level ranges; no sublime imports"""
|
||||
import difflib
|
||||
import re
|
||||
from collections import Counter, namedtuple
|
||||
from collections import Counter
|
||||
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")
|
||||
Diff = namedtuple("Diff", "lines binary")
|
||||
|
||||
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
|
||||
class Line(NamedTuple):
|
||||
"""One diff row: kind " " (context), "-" (deleted) or "+" (inserted); the missing line number is None"""
|
||||
|
||||
kind: str
|
||||
old_no: Optional[int]
|
||||
new_no: Optional[int]
|
||||
text: str
|
||||
|
||||
|
||||
class Diff(NamedTuple):
|
||||
"""A parsed unified diff: its rows, and whether git reported the file as binary"""
|
||||
|
||||
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")
|
||||
|
||||
# a half-open (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]
|
||||
|
||||
# character-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_MAX_CHARS = 2000
|
||||
|
||||
_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"""
|
||||
lines = []
|
||||
lines: List[Line] = []
|
||||
binary = False
|
||||
old_no = new_no = 0
|
||||
in_hunk = False
|
||||
for raw in text.split("\n"):
|
||||
# a hunk header resets both line counters
|
||||
if raw.startswith("@@"):
|
||||
match = _HUNK.match(raw)
|
||||
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
|
||||
in_hunk = True
|
||||
continue
|
||||
# before the first hunk only headers: watch for a binary marker
|
||||
if not in_hunk:
|
||||
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
|
||||
binary = True
|
||||
@@ -34,6 +58,7 @@ def parse_unified(text):
|
||||
if raw == "":
|
||||
# only the trailing split remainder; an empty context line is " "
|
||||
continue
|
||||
# hunk body: the first character is the line kind
|
||||
marker, body = raw[0], raw[1:]
|
||||
if marker == " ":
|
||||
lines.append(Line(" ", old_no, new_no, body))
|
||||
@@ -52,26 +77,26 @@ def parse_unified(text):
|
||||
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"""
|
||||
for value, group in groupby(enumerate(items), key=lambda pair: key(pair[1])):
|
||||
indexes = [i for i, _ in group]
|
||||
yield value, indexes[0], indexes[-1] + 1
|
||||
|
||||
|
||||
def change_blocks(lines):
|
||||
"""[(start, end)) index ranges of consecutive changed lines"""
|
||||
def change_blocks(lines: Sequence[Line]) -> List[Block]:
|
||||
"""Half-open (start, end) index ranges of consecutive changed lines"""
|
||||
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)"""
|
||||
tally = Counter(line.kind for line in lines)
|
||||
return tally["+"], tally["-"]
|
||||
|
||||
|
||||
def paired_lines(lines, block):
|
||||
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
|
||||
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
|
||||
"""(deleted, inserted) index pairs of a block; [] unless its - and + counts match"""
|
||||
start, end = block
|
||||
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
|
||||
inserted = [i for i in range(start, end) if lines[i].kind == "+"]
|
||||
@@ -80,15 +105,16 @@ def paired_lines(lines, block):
|
||||
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"""
|
||||
if len(old) + len(new) > CHAR_DIFF_MAX_CHARS:
|
||||
return [], []
|
||||
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
|
||||
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
|
||||
return [], []
|
||||
old_ranges = []
|
||||
new_ranges = []
|
||||
# each opcode span becomes a range on whichever side it touches
|
||||
old_ranges: List[CharRange] = []
|
||||
new_ranges: List[CharRange] = []
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag in ("replace", "delete") and i2 > i1:
|
||||
old_ranges.append((i1, i2))
|
||||
@@ -97,10 +123,11 @@ def char_ranges(old, new):
|
||||
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"""
|
||||
if not lines:
|
||||
return 1
|
||||
# the first numbered line at or after the row, else the nearest one before it
|
||||
index = max(0, min(index, len(lines) - 1))
|
||||
for line in lines[index:]:
|
||||
if line.new_no is not None:
|
||||
|
||||
839
vdiff/render.py
839
vdiff/render.py
File diff suppressed because it is too large
Load Diff
156
vdiff/state.py
156
vdiff/state.py
@@ -1,17 +1,47 @@
|
||||
""" Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore """
|
||||
from collections import namedtuple
|
||||
"""Per-view state of review views, keyed by view id; the essentials are mirrored into view settings for restore"""
|
||||
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 .branches import Paint
|
||||
from .git import Commit, CommitInfo, FileChange, Ref
|
||||
from .parse import Block, Line
|
||||
|
||||
# view setting marking a review view (keymap context "setting.vdiff_view")
|
||||
VIEW_FLAG = "vdiff_view"
|
||||
# view setting naming what the view shows, "diff" or "branches", so the keymap can bind a key per mode
|
||||
MODE_SETTING = "vdiff_mode"
|
||||
MODE_DIFF = "diff"
|
||||
MODE_BRANCHES = "branches"
|
||||
# view setting holding what the view shows, so a restored tab can re-render
|
||||
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"]
|
||||
all_mode: bool
|
||||
mode: str = MODE_DIFF
|
||||
|
||||
|
||||
def find_change(files, path):
|
||||
class BranchRow(NamedTuple):
|
||||
"""What one row of the branches view holds: the branch of its left cell, the index of its right-column
|
||||
item (0 the working tree, i + 1 the i-th commit, -1 none) and the coloured spans of its right cell
|
||||
as (start, end, "del" | "ins") offsets from the row's start"""
|
||||
|
||||
ref: Optional["Ref"]
|
||||
commit: int
|
||||
spans: Tuple[Tuple[int, int, str], ...] = ()
|
||||
|
||||
|
||||
def find_change(files: Sequence["FileChange"], path: Optional[str]) -> int:
|
||||
"""Index of the FileChange with `path`, or -1"""
|
||||
for i, change in enumerate(files):
|
||||
if change.path == path:
|
||||
@@ -21,42 +51,81 @@ def find_change(files, path):
|
||||
|
||||
@dataclass(eq=False)
|
||||
class ViewState:
|
||||
"""Everything a review view needs to re-render itself and act on its file
|
||||
|
||||
`sha` is None in working-tree mode and the reviewed commit's hash otherwise.
|
||||
"""
|
||||
"""Everything a review view needs to re-render itself and act on its file"""
|
||||
|
||||
repo: str
|
||||
sha: object = None # None in working-tree mode, the reviewed commit's hash otherwise
|
||||
commit: object = None # git.CommitInfo when sha is set
|
||||
change: object = None # git.FileChange currently shown
|
||||
requested: object = 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)
|
||||
lines: list = field(default_factory=list) # parse.Line list currently rendered
|
||||
blocks: list = field(default_factory=list) # parse.change_blocks(lines)
|
||||
sha: Optional[str] = None # None in working-tree mode, the reviewed commit's hash otherwise
|
||||
commit: Optional["CommitInfo"] = None # set when sha is
|
||||
change: Optional["FileChange"] = None # file currently shown
|
||||
requested: Optional["FileChange"] = None # file asked for, maybe still loading; navigation counts from here
|
||||
files: List["FileChange"] = field(default_factory=list) # what the user is browsing (next/prev file)
|
||||
lines: List["Line"] = field(default_factory=list) # currently rendered
|
||||
blocks: List["Block"] = field(default_factory=list) # parse.change_blocks(lines)
|
||||
binary: bool = False
|
||||
token: int = 0 # bumped per show request; a stale async result is dropped
|
||||
rendered_token: int = -1 # token of the request that produced the current render
|
||||
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
|
||||
fingerprint: object = None # (file mtime, index mtime) at render time, working-tree mode
|
||||
fingerprint: Optional[Tuple[Any, ...]] = None # what the last render saw; see render.fingerprint
|
||||
drawn: Optional[Tuple[Any, ...]] = None # what the last render drew, as describe() put it
|
||||
all_mode: bool = False
|
||||
all_diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]] = field(default_factory=dict)
|
||||
all_colours: Dict[str, Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]] = field(default_factory=dict)
|
||||
collapsed: set = field(default_factory=set)
|
||||
all_rows: List[Tuple[Optional["FileChange"], Optional["Line"], bool]] = field(default_factory=list)
|
||||
all_change_rows: List[int] = field(default_factory=list)
|
||||
all_change_targets: List[Tuple[str, int]] = field(default_factory=list)
|
||||
all_change_row_targets: List[Tuple[str, int]] = field(default_factory=list)
|
||||
# the branches view: what it lists, which branch is selected and what each row holds
|
||||
mode: str = MODE_DIFF
|
||||
branch_ref: Optional[str] = None # name of the selected branch, e.g. "main" or "origin/main"
|
||||
branch_head_sha: str = "" # HEAD's short hash, "" before the first commit
|
||||
branch_refs: List["Ref"] = field(default_factory=list)
|
||||
branch_remotes: List[str] = field(default_factory=list)
|
||||
branch_commits: List["Commit"] = field(default_factory=list)
|
||||
branch_changes: List["FileChange"] = field(default_factory=list) # the working tree, listed with the branches
|
||||
branch_rows: List[BranchRow] = field(default_factory=list)
|
||||
branch_width: int = 0 # the left column's width in the last render
|
||||
branch_paint: Optional["Paint"] = None # the last render's row geometry and scopes, see branches.Paint
|
||||
header: Optional[Tuple[str, int]] = None # the header phantom last drawn: its html and the buffer's change_count then
|
||||
|
||||
@property
|
||||
def in_commit(self):
|
||||
def in_commit(self) -> bool:
|
||||
"""Whether a commit is being reviewed rather than the working tree"""
|
||||
return self.sha is not None
|
||||
|
||||
def index(self):
|
||||
def describe(self) -> Tuple[Any, ...]:
|
||||
"""What the state says is shown: the branches view, or a diff's commit, mode and file"""
|
||||
if self.in_branches:
|
||||
return (self.mode,)
|
||||
return self.mode, self.sha, self.all_mode, self.change.path if self.change else None
|
||||
|
||||
@property
|
||||
def in_branches(self) -> bool:
|
||||
"""Whether the branches view is shown rather than a diff"""
|
||||
return self.mode == MODE_BRANCHES
|
||||
|
||||
def selected_ref(self) -> Optional["Ref"]:
|
||||
"""The selected branch, or None when it is not listed (any more)"""
|
||||
return next((r for r in self.branch_refs if r.name == self.branch_ref), None)
|
||||
|
||||
def current_branch(self) -> Optional["Ref"]:
|
||||
"""The checked-out branch, None when detached or before the first commit"""
|
||||
return next((r for r in self.branch_refs if r.current), None)
|
||||
|
||||
def index(self) -> int:
|
||||
"""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
|
||||
n / p step from whatever was on screen before the pending file landed.
|
||||
`requested` leads: a diff arrives asynchronously, so counting from `change` would make n / p
|
||||
step from what was on screen before the pending file landed
|
||||
"""
|
||||
change = self.requested or self.change
|
||||
return find_change(self.files, change.path) if change else -1
|
||||
|
||||
def kept(self):
|
||||
return Kept(self.sha, self.commit, self.change, list(self.files))
|
||||
def kept(self) -> Kept:
|
||||
"""Snapshot of what to go back to on Esc"""
|
||||
return Kept(self.sha, self.commit, self.change, list(self.files), self.all_mode, self.mode)
|
||||
|
||||
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"""
|
||||
kept = self.kept()
|
||||
if sha != self.sha:
|
||||
@@ -64,13 +133,28 @@ class ViewState:
|
||||
self.sha, self.commit = sha, commit
|
||||
return kept
|
||||
|
||||
def restore(self, kept):
|
||||
self.sha, self.commit, self.change, self.files = kept
|
||||
def restore(self, kept: Kept) -> None:
|
||||
"""Go back to a snapshot; its file becomes the requested one"""
|
||||
self.sha, self.commit, self.change, self.files, self.all_mode, self.mode = kept
|
||||
self.requested = kept.change
|
||||
|
||||
def persist(self, view):
|
||||
def set_all_diffs(self, diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]]) -> None:
|
||||
"""Replace the all-files diffs; a file's cached colours survive only if its diff is unchanged"""
|
||||
self.all_colours = {
|
||||
path: colours
|
||||
for path, colours in self.all_colours.items()
|
||||
if path in diffs and path in self.all_diffs and diffs[path][0] == self.all_diffs[path][0]
|
||||
}
|
||||
self.all_diffs = diffs
|
||||
|
||||
def persist(self, view: "sublime.View") -> None:
|
||||
"""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.in_branches:
|
||||
data["mode"] = MODE_BRANCHES
|
||||
data["ref"] = self.branch_ref
|
||||
if self.all_mode:
|
||||
data["all_mode"] = True
|
||||
if self.change:
|
||||
data["path"] = self.change.path
|
||||
data["status"] = self.change.status
|
||||
@@ -78,26 +162,30 @@ class ViewState:
|
||||
view.settings().set(VIEW_DATA, data)
|
||||
|
||||
|
||||
STATES = {}
|
||||
STATES: Dict[int, "ViewState"] = {}
|
||||
|
||||
|
||||
def get(view):
|
||||
def get(view: "sublime.View") -> Optional[ViewState]:
|
||||
"""State of a review view, or None when not tracked"""
|
||||
return STATES.get(view.id())
|
||||
|
||||
|
||||
def tracked():
|
||||
def tracked() -> List[Tuple[int, ViewState]]:
|
||||
"""Snapshot of (view id, state) for every tracked review view"""
|
||||
return list(STATES.items())
|
||||
|
||||
|
||||
def put(view, st):
|
||||
def put(view: "sublime.View", st: ViewState) -> ViewState:
|
||||
"""Track `st` as the view's state and return it"""
|
||||
STATES[view.id()] = st
|
||||
return st
|
||||
|
||||
|
||||
def drop(view):
|
||||
def drop(view: "sublime.View") -> None:
|
||||
"""Forget the view's state, if any"""
|
||||
STATES.pop(view.id(), None)
|
||||
|
||||
|
||||
def is_review_view(view):
|
||||
def is_review_view(view: Optional["sublime.View"]) -> bool:
|
||||
"""Whether the view carries the review-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
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import sublime
|
||||
|
||||
@@ -7,12 +8,14 @@ from . import git
|
||||
|
||||
SETTINGS_FILE = "vDiff.sublime-settings"
|
||||
|
||||
DEBOUNCE_MS = 60
|
||||
|
||||
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"""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
@@ -22,25 +25,32 @@ def settings():
|
||||
return _settings
|
||||
|
||||
|
||||
def apply_git_binary():
|
||||
def apply_git_binary() -> None:
|
||||
"""Push the configured git executable into the (sublime-free) git module"""
|
||||
git.GIT_BINARY = settings().get("git_binary") or "git"
|
||||
|
||||
|
||||
def file_count(count):
|
||||
"""'1 file' / '3 files' for a message"""
|
||||
return f'{count} {"file" if count == 1 else "files"}'
|
||||
def commits_count() -> int:
|
||||
"""How many commits the commits panel and the branches view list"""
|
||||
return int(settings().get("commits_count", 30) or 30)
|
||||
|
||||
|
||||
def status(message):
|
||||
def file_count(count: int, qualifier: str = "") -> str:
|
||||
"""'1 file' / '3 files' for a message; a qualifier goes before the noun ('1 staged file')"""
|
||||
noun = "file" if count == 1 else "files"
|
||||
return f"{count} {qualifier} {noun}" if qualifier else f"{count} {noun}"
|
||||
|
||||
|
||||
def status(message: str) -> None:
|
||||
"""Status-bar message with the plugin prefix"""
|
||||
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"""
|
||||
|
||||
def job():
|
||||
def job() -> None:
|
||||
"""Worker-thread body: run `work`, pass on its result or report its error"""
|
||||
try:
|
||||
result = work()
|
||||
except Exception as error:
|
||||
|
||||
Reference in New Issue
Block a user