Compare commits
7 Commits
9d04d8c711
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c894d925a1 | ||
|
|
cf120991bc | ||
|
|
f18bad79cd | ||
|
|
2d9db745b0 | ||
|
|
904b941be7 | ||
|
|
fe915178c5 | ||
|
|
a2900682a0 |
@@ -3,34 +3,10 @@
|
|||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
|
{ "keys": ["c"], "command": "vdiff_action", "args": {"action": "commits"},
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["s"], "command": "vdiff_action", "args": {"action": "stage"},
|
{ "keys": ["b"], "command": "vdiff_action", "args": {"action": "branches"},
|
||||||
"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"},
|
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
|
{ "keys": ["r"], "command": "vdiff_action", "args": {"action": "refresh"},
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"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"},
|
{ "keys": ["?"], "command": "vdiff_action", "args": {"action": "help"},
|
||||||
"context": [{"key": "setting.vdiff_view"}] },
|
"context": [{"key": "setting.vdiff_view"}] },
|
||||||
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
|
{ "keys": ["escape"], "command": "vdiff_action", "args": {"action": "close"},
|
||||||
@@ -41,5 +17,69 @@
|
|||||||
{"key": "popup_visible", "operand": false},
|
{"key": "popup_visible", "operand": false},
|
||||||
{"key": "auto_complete_visible", "operand": false},
|
{"key": "auto_complete_visible", "operand": false},
|
||||||
{"key": "num_selections", "operand": 1}
|
{"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"}] }
|
||||||
]
|
]
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -9,7 +9,8 @@ Python 3.14. No dependencies.
|
|||||||
|
|
||||||
## Install with Package Control
|
## Install with Package Control
|
||||||
|
|
||||||
vDiff is not on the default channel, so add this one first. Open
|
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
|
**Preferences → Package Settings → Package Control → Settings** and put the channel URL in the
|
||||||
`channels` array of the right-hand (User) pane:
|
`channels` array of the right-hand (User) pane:
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ vDiff is not on the default channel, so add this one first. Open
|
|||||||
{
|
{
|
||||||
"channels": [
|
"channels": [
|
||||||
"https://packagecontrol.io/channel_v3.json",
|
"https://packagecontrol.io/channel_v3.json",
|
||||||
"https://raw.githubusercontent.com/vaclavt/vdiff/main/channel.json"
|
"https://raw.githubusercontent.com/vaclavt/sublime-chanel/main/channel.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -44,64 +45,73 @@ Use one method or the other, not both: an unpacked `Packages/vDiff` directory ov
|
|||||||
the list previews each file; Enter keeps it, Esc goes back to what was shown.
|
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: 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_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 |
|
| key | action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `f` / `c` | changed files / recent commits panel |
|
| `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` / `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 |
|
| `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 |
|
| `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 |
|
| `o` or Enter | open the real file at this line |
|
||||||
| `]` / `[` | next / previous change (wraps at the ends) |
|
| `]` / `[` | next / previous change (wraps at the ends) |
|
||||||
| `n` / `p` | next / previous file of the current list (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) |
|
| `j` / `k` | move down / up one line (vim keys) |
|
||||||
| `r` | refresh |
|
| `r` | refresh |
|
||||||
| `?` | show this key list in a popup |
|
| `?` | show this key list in a popup |
|
||||||
| Esc | close the view |
|
| Esc | close the view |
|
||||||
|
|
||||||
The header links do the same with the mouse. 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
|
## The channel
|
||||||
|
|
||||||
Two files in this repository feed Package Control, and the distinction between them matters:
|
`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.
|
||||||
|
|
||||||
- `repository.json` — a **repository** file. Its top-level key is `packages`, and it describes
|
The channel itself — the `channels`-array file listing `repository.json` for vDiff and vaclavt's
|
||||||
vDiff itself: where the code lives and which releases exist. `"tags": true` means Package
|
other packages — lives in [vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not
|
||||||
Control reads the repository's git tags, so publishing a release is just
|
here. Add a package there instead of in this repository.
|
||||||
`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.
|
|
||||||
- `channel.json` — a **channel** file. Its top-level key is `repositories`, and it is nothing but
|
|
||||||
a list of repository-file URLs. This is the one users add to `channels`.
|
|
||||||
|
|
||||||
The indirection is what makes a channel worth having: users add one URL once, and every package
|
|
||||||
you later append to `repositories` shows up in their **Install Package** list without them
|
|
||||||
changing anything.
|
|
||||||
|
|
||||||
### Adding another package to the channel
|
|
||||||
|
|
||||||
Append the URL of that package's *repository* file to `repositories` in `channel.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "sublime://packagecontrol.io/schemas/channel",
|
|
||||||
"schema_version": "4.0.0",
|
|
||||||
"repositories": [
|
|
||||||
"https://raw.githubusercontent.com/vaclavt/vdiff/main/repository.json",
|
|
||||||
"https://raw.githubusercontent.com/vaclavt/vAI/main/channel.json"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Two things to watch:
|
|
||||||
|
|
||||||
- The URL must be a repository file (top-level `packages`), whatever it is named. vAI's
|
|
||||||
`channel.json` is a repository file despite its name, so it belongs in a `repositories` array —
|
|
||||||
Package Control rejects any file without a `repositories` key when it is listed under `channels`.
|
|
||||||
- Package Control fetches anonymously over HTTPS, so the URL has to be publicly readable. A
|
|
||||||
package on a private host is not installable this way.
|
|
||||||
|
|
||||||
If you would rather skip the channel for a single package, a repository file can be added directly
|
|
||||||
to the `repositories` array of Package Control's settings instead.
|
|
||||||
|
|||||||
10
TODO.txt
10
TODO.txt
@@ -1,18 +1,18 @@
|
|||||||
|
|
||||||
|
## 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
|
## New functionality
|
||||||
|
|
||||||
- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?)
|
- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?)
|
||||||
|
|
||||||
- Add pull and push action
|
- Add tags into commits list (the branches view shows them already)
|
||||||
- Add tags into commits list (and branches?)
|
|
||||||
- Add stash action ..
|
- Add stash action ..
|
||||||
|
|
||||||
|
|
||||||
## Improvements
|
## Improvements
|
||||||
|
|
||||||
- commit messages are oneline. What are options for multiline editing?
|
- commit messages are oneline. What are options for multiline editing?
|
||||||
- visual bell blinking time setting
|
- visual bell blinking time setting
|
||||||
|
|
||||||
|
|
||||||
## Code
|
## Code
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "sublime://packagecontrol.io/schemas/channel",
|
|
||||||
"schema_version": "4.0.0",
|
|
||||||
"repositories": [
|
|
||||||
"https://raw.githubusercontent.com/vaclavt/vdiff/main/repository.json"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
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, so save it after editing a submodule.
|
Sublime reloads a plugin only when this file changes, so save it after editing a submodule
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
|
|
||||||
# sublime reloads only this file: drop the submodules so an edited one is re-imported below.
|
# drop the submodules so an edited one is re-imported; open views are rebuilt from settings by _restore_all
|
||||||
# Open views lose in-memory state and are rebuilt from their settings by _restore_all.
|
|
||||||
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
|
for _name in [n for n in sys.modules if n.startswith(__package__ + ".vdiff")]:
|
||||||
del sys.modules[_name]
|
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 (
|
from .vdiff.commands import (
|
||||||
VdiffActionCommand,
|
VdiffActionCommand,
|
||||||
|
VdiffBranchesCommand,
|
||||||
VdiffCommitsCommand,
|
VdiffCommitsCommand,
|
||||||
VdiffFilesCommand,
|
VdiffFilesCommand,
|
||||||
restore_view,
|
restore_view,
|
||||||
@@ -35,6 +35,7 @@ def plugin_loaded():
|
|||||||
|
|
||||||
|
|
||||||
def _restore_all():
|
def _restore_all():
|
||||||
|
"""Rebuild every review view, in every window, that has no in-memory state yet"""
|
||||||
for window in sublime.windows():
|
for window in sublime.windows():
|
||||||
for view in window.views():
|
for view in window.views():
|
||||||
if state.is_review_view(view) and state.get(view) is None:
|
if state.is_review_view(view) and state.get(view) is None:
|
||||||
|
|||||||
@@ -23,3 +23,25 @@ def scheme_override_sandbox(tmp_path, monkeypatch):
|
|||||||
palette.reset()
|
palette.reset()
|
||||||
monkeypatch.setattr(state, "STATES", {})
|
monkeypatch.setattr(state, "STATES", {})
|
||||||
monkeypatch.setattr(sublime, "packages_path", lambda: str(tmp_path))
|
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)))
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ def status_message(text):
|
|||||||
pass
|
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():
|
def packages_path():
|
||||||
return "/tmp/Packages"
|
return "/tmp/Packages"
|
||||||
|
|
||||||
@@ -146,6 +156,7 @@ class View:
|
|||||||
self.regions = {}
|
self.regions = {}
|
||||||
self.syntax = None
|
self.syntax = None
|
||||||
self.read_only = True
|
self.read_only = True
|
||||||
|
self.changes = 0
|
||||||
self.name_ = ""
|
self.name_ = ""
|
||||||
self.sel_ = []
|
self.sel_ = []
|
||||||
self.status = {}
|
self.status = {}
|
||||||
@@ -168,6 +179,10 @@ class View:
|
|||||||
|
|
||||||
def replace(self, edit, region, text):
|
def replace(self, edit, region, text):
|
||||||
self.text = self.text[: region.begin()] + text + self.text[region.end() :]
|
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):
|
def run_command(self, name, args=None):
|
||||||
if name == "vdiff_replace":
|
if name == "vdiff_replace":
|
||||||
@@ -224,9 +239,19 @@ class View:
|
|||||||
def set_status(self, key, value):
|
def set_status(self, key, value):
|
||||||
self.status[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):
|
def set_viewport_position(self, xy, animate=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def show(self, point):
|
||||||
|
pass
|
||||||
|
|
||||||
def show_at_center(self, point):
|
def show_at_center(self, point):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -242,6 +267,19 @@ class View:
|
|||||||
def em_width(self):
|
def em_width(self):
|
||||||
return 8.0
|
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):
|
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
|
"""One token per run of word characters and one per run of anything else, scoped by its shape
|
||||||
|
|
||||||
|
|||||||
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 pytest
|
||||||
|
|
||||||
import sublime # the stub, via conftest
|
import sublime # the stub, via conftest
|
||||||
|
|
||||||
|
from conftest import put_caret # noqa: E402
|
||||||
from vdiff import commands, git, state, utils
|
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"]
|
assert shown == ["b.py", "a.py"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode, all_mode", [("file_by_file", False), ("all_in_one", True), (None, False)])
|
||||||
|
def test_a_new_review_view_starts_in_the_default_view_mode(monkeypatch, mode, all_mode):
|
||||||
|
option(monkeypatch, "default_view_mode", mode)
|
||||||
|
monkeypatch.setattr(commands, "resolve_repo", lambda window: "/repo")
|
||||||
|
monkeypatch.setattr(commands, "find_review_view", lambda window: sublime.View())
|
||||||
|
_, st = commands.open_review(sublime.Window())
|
||||||
|
assert st.all_mode is all_mode
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_in_all_in_one_mode_scrolls_instead_of_loading_files(monkeypatch):
|
||||||
|
loads, focused = [], []
|
||||||
|
|
||||||
|
def fake_show_all(view, st, focus=None):
|
||||||
|
# load at once: what show_all's done does once the diffs land
|
||||||
|
st.token += 1
|
||||||
|
st.rendered_token = st.token
|
||||||
|
loads.append(st.change.path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(commands, "show_all", fake_show_all)
|
||||||
|
monkeypatch.setattr(commands, "show_file", lambda view, st, c: pytest.fail("loaded a single file"))
|
||||||
|
monkeypatch.setattr(commands.render, "render_all", lambda view, st, focus=None: focused.append(focus))
|
||||||
|
st = state.ViewState("/repo", all_mode=True)
|
||||||
|
st.change = change("kept.py")
|
||||||
|
st.files = [st.change]
|
||||||
|
files = [change("a.py"), change("b.py")]
|
||||||
|
st.collapsed = {"b.py"}
|
||||||
|
window = sublime.Window()
|
||||||
|
commands.show_files_panel(window, sublime.View(), st, files, st.kept())
|
||||||
|
_, on_select, _, on_highlight, _ = window.panel
|
||||||
|
assert loads == ["a.py"]
|
||||||
|
|
||||||
|
# a highlight unfolds and scrolls to its file; Enter on it needs no redraw
|
||||||
|
on_highlight(1)
|
||||||
|
assert focused == ["b.py"] and st.change.path == "b.py" and "b.py" not in st.collapsed
|
||||||
|
on_select(1)
|
||||||
|
assert focused == ["b.py"] and loads == ["a.py"]
|
||||||
|
|
||||||
|
# Esc goes back to the kept file, still in the all-in-one view
|
||||||
|
on_select(-1)
|
||||||
|
assert st.all_mode and st.change.path == "kept.py" and loads == ["a.py", "kept.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_esc_goes_back_to_the_kept_view_mode(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(commands, "show_file", lambda view, st, c: calls.append(("file", c.path)))
|
||||||
|
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: calls.append(("all", st.change.path)))
|
||||||
|
st = state.ViewState("/repo")
|
||||||
|
st.change = change("kept.py")
|
||||||
|
st.files = [st.change]
|
||||||
|
kept = st.kept()
|
||||||
|
# the view switched to all-in-one after the snapshot; Esc still restores file-by-file
|
||||||
|
st.all_mode = True
|
||||||
|
window = sublime.Window()
|
||||||
|
commands.show_files_panel(window, sublime.View(), st, [change("a.py")], kept)
|
||||||
|
window.panel[1](-1)
|
||||||
|
assert calls[-1] == ("file", "kept.py") and not st.all_mode
|
||||||
|
|
||||||
|
|
||||||
def test_on_cancel_goes_back_to_commit_list(monkeypatch):
|
def test_on_cancel_goes_back_to_commit_list(monkeypatch):
|
||||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
|
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
|
||||||
st = state.ViewState("/repo")
|
st = state.ViewState("/repo")
|
||||||
@@ -103,6 +164,58 @@ def test_on_cancel_goes_back_to_commit_list(monkeypatch):
|
|||||||
assert back == [1] and st.sha is None
|
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):
|
def test_step_change_uses_blocks(rings):
|
||||||
view, st = at_change([(2, 4), (7, 8)], 0)
|
view, st = at_change([(2, 4), (7, 8)], 0)
|
||||||
commands.ACTIONS["next_change"](view, None, st)
|
commands.ACTIONS["next_change"](view, None, st)
|
||||||
@@ -416,14 +529,6 @@ def test_esc_rewinds_the_file_navigation_counts_from(loading):
|
|||||||
# --- cycling past the ends --------------------------------------------------
|
# --- 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):
|
def at_file(files, path):
|
||||||
"""A loaded working-tree view sitting on `path`"""
|
"""A loaded working-tree view sitting on `path`"""
|
||||||
view, window = sublime.View(), sublime.Window()
|
view, window = sublime.View(), sublime.Window()
|
||||||
@@ -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, 2) == 2 # the slot we count from counts itself
|
||||||
assert commands._next_unstaged(files, 3) == 0 # wraps past the end
|
assert commands._next_unstaged(files, 3) == 0 # wraps past the end
|
||||||
assert commands._next_unstaged(flagged(True, True), 0) == -1
|
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):
|
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"]
|
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):
|
def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch):
|
||||||
messages = []
|
messages = []
|
||||||
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
|
||||||
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
|
assert press(monkeypatch, "stage", flagged(False), "f0.py", []) == []
|
||||||
assert messages == ["No uncommitted changes in /repo"]
|
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 os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -43,12 +44,25 @@ def test_changed_files_all_kinds(repo):
|
|||||||
sh(repo, "add", "b.txt") # staged M
|
sh(repo, "add", "b.txt") # staged M
|
||||||
os.remove(os.path.join(repo, "gone.py")) # unstaged D
|
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, "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)}
|
changes = {c.path: c for c in git.changed_files(repo)}
|
||||||
assert set(changes) == {"a.py", "b.txt", "gone.py", "sub/new.py"}
|
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)
|
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["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["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):
|
def test_full_diff_working_tree(repo):
|
||||||
@@ -84,6 +98,29 @@ def test_stage_unstage_discard(repo):
|
|||||||
assert git.changed_files(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):
|
def test_commits(repo):
|
||||||
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
|
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
|
||||||
write(repo, "c.py", "c\n")
|
write(repo, "c.py", "c\n")
|
||||||
@@ -154,6 +191,10 @@ def test_no_head_repo(tmp_path):
|
|||||||
assert [l.kind for l in diff.lines] == ["+"]
|
assert [l.kind for l in diff.lines] == ["+"]
|
||||||
git.unstage(repo, changes[0])
|
git.unstage(repo, changes[0])
|
||||||
assert git.changed_files(repo)[0].status == "?"
|
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):
|
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")
|
sh(fresh, "init", "-q", "-b", "main")
|
||||||
with pytest.raises(git.GitError):
|
with pytest.raises(git.GitError):
|
||||||
git.head_message(fresh)
|
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")
|
||||||
|
|||||||
@@ -84,3 +84,145 @@ def test_a_hand_edited_override_is_never_clobbered(tmp_path):
|
|||||||
assert "// mine" in path.read_text()
|
assert "// mine" in path.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def colour_option(monkeypatch, name, value):
|
||||||
|
"""Set one of the colour settings for this test"""
|
||||||
|
from vdiff import utils
|
||||||
|
monkeypatch.setitem(sublime.load_settings(utils.SETTINGS_FILE), name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_line_colours_get_rules_and_tint_the_token_layer(tmp_path, monkeypatch):
|
||||||
|
colour_option(monkeypatch, "deleted_line_background", "#ff000040")
|
||||||
|
colour_option(monkeypatch, "line_numbers_foreground", "#888888")
|
||||||
|
view = sublime.View()
|
||||||
|
palette.ensure(view)
|
||||||
|
|
||||||
|
rules = by_scope(tmp_path)
|
||||||
|
assert rules[palette.SCOPE_LINE_DEL] == {"scope": palette.SCOPE_LINE_DEL, "background": "#ff000040"}
|
||||||
|
assert palette.SCOPE_LINE_INS not in rules
|
||||||
|
# the token layer repaints deleted text, so it carries the same background
|
||||||
|
colour = view.styles["comment"]["foreground"]
|
||||||
|
assert rules[palette.scope_for("del", colour)]["background"] == "#ff000040"
|
||||||
|
# a foreground alone still gets a background, or sublime would fill the region with the foreground
|
||||||
|
numbers = rules[palette.SCOPE_LINE_NUMBERS]
|
||||||
|
assert numbers["foreground"] == "#888888" and numbers["background"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_changing_a_colour_setting_rewrites_the_override(tmp_path, monkeypatch):
|
||||||
|
view = sublime.View()
|
||||||
|
palette.ensure(view)
|
||||||
|
assert palette.SCOPE_LINE_INS not in by_scope(tmp_path)
|
||||||
|
colour_option(monkeypatch, "inserted_line_background", "#00ff0040")
|
||||||
|
palette.ensure(view)
|
||||||
|
assert by_scope(tmp_path)[palette.SCOPE_LINE_INS]["background"] == "#00ff0040"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_uses_the_user_scopes_only_once_set_and_live(monkeypatch):
|
||||||
|
view = sublime.View()
|
||||||
|
# unset: the scheme's diff tint and the plain-text line numbers, as before
|
||||||
|
assert palette.line_scope(view, "del") == palette.SCOPE_DEL
|
||||||
|
assert palette.line_numbers_scope(view) == "text.plain"
|
||||||
|
colour_option(monkeypatch, "deleted_line_background", "#ff000040")
|
||||||
|
colour_option(monkeypatch, "line_numbers_background", "#222222")
|
||||||
|
# set, but the override is not loaded yet: an unknown scope would paint nothing
|
||||||
|
assert palette.line_scope(view, "del") == palette.SCOPE_DEL
|
||||||
|
view.override_loaded = True
|
||||||
|
assert palette.line_scope(view, "del") == palette.SCOPE_LINE_DEL
|
||||||
|
assert palette.line_scope(view, "ins") == palette.SCOPE_INS
|
||||||
|
assert palette.line_numbers_scope(view) == palette.SCOPE_LINE_NUMBERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_header_colours_come_from_settings(tmp_path, monkeypatch):
|
||||||
|
palette.ensure(sublime.View())
|
||||||
|
rules = by_scope(tmp_path)
|
||||||
|
# unset: the built-in defaults
|
||||||
|
assert rules[palette.SCOPE_FILE_HEADER]["background"] == palette.FILE_HEADER_BACKGROUND
|
||||||
|
colour_option(monkeypatch, "file_header_background", "#202040")
|
||||||
|
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
|
||||||
|
palette.ensure(sublime.View())
|
||||||
|
rules = by_scope(tmp_path)
|
||||||
|
assert rules[palette.SCOPE_FILE_HEADER]["background"] == "#202040"
|
||||||
|
# the counts sit on the heading, so they share its background
|
||||||
|
assert rules[palette.SCOPE_FILE_INS_COUNT]["background"] == "#202040"
|
||||||
|
assert rules[palette.SCOPE_FILE_INS_COUNT]["foreground"] == "#00ff00"
|
||||||
|
assert rules[palette.SCOPE_FILE_DEL_COUNT]["foreground"] == palette.FILE_DEL_FOREGROUND
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -49,7 +49,18 @@ def test_render_text_regions_and_header():
|
|||||||
st = make_state()
|
st = make_state()
|
||||||
render.render(view, st)
|
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.is_read_only()
|
||||||
assert view.syntax.endswith("Python.sublime-syntax")
|
assert view.syntax.endswith("Python.sublime-syntax")
|
||||||
|
|
||||||
@@ -141,18 +152,18 @@ def test_help_popup_lists_every_key():
|
|||||||
view, html = help_popup(st)
|
view, html = help_popup(st)
|
||||||
|
|
||||||
assert view.is_popup_visible()
|
assert view.is_popup_visible()
|
||||||
for group in ("Navigate", "Panels", "Actions", "View"):
|
for group in ("Navigate", "Actions", "View"):
|
||||||
assert f">{group}<" in html
|
assert f">{group}<" in html
|
||||||
for keys in ("] [", "n p", "o ⏎", "j k", "f", "c", "s", "u", "d", "C", "m", "r", "?", "esc"):
|
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 f">{keys}<" in html
|
||||||
assert "next / previous change" in html and "open the real file at this line" 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
|
# 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 '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
|
# rows covering two actions stay plain text
|
||||||
assert 'href="next_change"' not in html
|
assert 'href="next_change"' not in html and 'href="stage"' not in html
|
||||||
# the status bar advertises it too
|
# the popup does not list the key that opens it
|
||||||
assert "? keys" in view.status[render.STATUS_KEY]
|
assert ">?<" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_help_popup_dims_file_actions_in_commit_mode():
|
def test_help_popup_dims_file_actions_in_commit_mode():
|
||||||
@@ -162,6 +173,8 @@ def test_help_popup_dims_file_actions_in_commit_mode():
|
|||||||
|
|
||||||
assert "uncommitted only" in html
|
assert "uncommitted only" in html
|
||||||
assert html.count('class="dim"') == 5
|
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="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
|
assert 'href="commit"' not in html and 'href="amend"' not in html
|
||||||
# everything that still applies stays clickable
|
# everything that still applies stays clickable
|
||||||
@@ -183,7 +196,7 @@ def test_help_popup_toggles_and_navigates():
|
|||||||
|
|
||||||
|
|
||||||
# keymap spellings that the popup shows as a symbol
|
# 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():
|
def keymap_bindings():
|
||||||
@@ -192,18 +205,37 @@ def keymap_bindings():
|
|||||||
return json.load(f)
|
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():
|
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()
|
bindings = keymap_bindings()
|
||||||
|
|
||||||
assert all(b["command"] == "vdiff_action" for b in bindings)
|
assert all(b["command"] == "vdiff_action" for b in bindings)
|
||||||
bound = {b["args"]["action"] for b in bindings}
|
assert all({c["key"] for c in b["context"]} >= {"setting.vdiff_view"} for b in bindings)
|
||||||
listed = {action for k in render.KEYS for action in k.actions}
|
assert {binding_mode(b) for b in bindings} == {None, "diff", "branches"}
|
||||||
assert bound == set(commands.ACTIONS) == listed
|
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
|
# 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"]}
|
bound_keys = {DISPLAY_KEYS.get(key, key) for key in keys}
|
||||||
assert bound_keys == {token for k in render.KEYS for token in k.keys.split()}
|
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():
|
def test_no_binding_yields_to_vintage_command_mode():
|
||||||
@@ -244,13 +276,14 @@ def test_summary_counts_staged_and_unstaged_files():
|
|||||||
git.FileChange("a.py", "M", True, 1, 1, None),
|
git.FileChange("a.py", "M", True, 1, 1, None),
|
||||||
git.FileChange("b.py", "M", False, 1, 1, None),
|
git.FileChange("b.py", "M", False, 1, 1, None),
|
||||||
git.FileChange("c.py", "?", False, 1, 0, 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)
|
render.render(view, st)
|
||||||
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
|
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
|
# the untracked file's whole content counts as insertions, the way git.changed_files records it
|
||||||
assert summary.startswith("1 staged, 2 unstaged · ")
|
assert summary.startswith("2 staged, 3 unstaged · ")
|
||||||
assert '<span class="del">-2</span>' in summary and '<span class="ins">+3</span>' in summary
|
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():
|
def test_summary_totals_skip_binary_files_but_still_count_them():
|
||||||
@@ -291,6 +324,23 @@ def test_summary_totals_a_commit_and_owns_the_date():
|
|||||||
assert 'class="commit"' not in html
|
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():
|
def test_summary_is_omitted_without_a_file_list():
|
||||||
view = sublime.View()
|
view = sublime.View()
|
||||||
render.init_view(view)
|
render.init_view(view)
|
||||||
@@ -420,3 +470,48 @@ def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
|
|||||||
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
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: 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"
|
// number of commits listed by "vDiff: Recent Commits"
|
||||||
"commits_count": 30,
|
"commits_count": 30,
|
||||||
|
|
||||||
@@ -11,6 +14,23 @@
|
|||||||
// mark the changed characters inside paired deleted/inserted lines
|
// mark the changed characters inside paired deleted/inserted lines
|
||||||
"word_diff": true,
|
"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 executable; sublime's PATH may not include a user-installed git
|
||||||
"git_binary": "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()
|
||||||
|
}
|
||||||
@@ -6,12 +6,12 @@ from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
|||||||
import sublime
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from . import git, parse, render, state
|
from . import branches, git, parse, render, state
|
||||||
from .git import Commit, FileChange
|
from .git import Commit, FileChange
|
||||||
from .state import Kept, ViewState
|
from .state import MODE_BRANCHES, Kept, ViewState
|
||||||
from .utils import file_count, run_async, settings, status
|
from .utils import DEBOUNCE_MS, commits_count, file_count, run_async, settings, status
|
||||||
|
|
||||||
# what an ACTIONS entry does: act(view, window, state) for the review view it was fired in
|
# an ACTIONS entry: act(view, window, state) on the review view it fired in
|
||||||
Action = Callable[[sublime.View, sublime.Window, ViewState], None]
|
Action = Callable[[sublime.View, sublime.Window, ViewState], None]
|
||||||
|
|
||||||
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES
|
# quick-panel kind per status letter: colour + the letter; the description comes from git.STATUS_NAMES
|
||||||
@@ -27,15 +27,11 @@ STATUS_COLOURS: Dict[str, int] = {
|
|||||||
}
|
}
|
||||||
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
|
COMMIT_KIND = (sublime.KIND_ID_COLOR_BLUISH, "c", "commit")
|
||||||
|
|
||||||
# the annotation is drawn hard right of the subject, so past this budget the subject is clipped.
|
# row width shared by subject and annotation; the subject is clipped to fit, never below MIN_SUBJECT_WIDTH
|
||||||
# The floor keeps a readable stub on a narrow panel.
|
|
||||||
COMMIT_ROW_WIDTH = 120
|
COMMIT_ROW_WIDTH = 120
|
||||||
MIN_SUBJECT_WIDTH = 24
|
MIN_SUBJECT_WIDTH = 24
|
||||||
|
|
||||||
# highlight-to-preview delay: holding an arrow key must not spawn a git process per row
|
# cache directory -> repo root: resolve_repo runs git on the UI thread
|
||||||
PREVIEW_DELAY_MS = 60
|
|
||||||
|
|
||||||
# directory -> repository root; roots do not move, and resolving runs on the UI thread
|
|
||||||
_roots: Dict[str, str] = {}
|
_roots: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +39,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]:
|
|||||||
"""Repository of the review view or active file, else of the first project folder that is one"""
|
"""Repository of the review view or active file, else of the first project folder that is one"""
|
||||||
view = window.active_view()
|
view = window.active_view()
|
||||||
candidates: List[str] = []
|
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 view is not None:
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
st = state.get(view) or restore_view(view)
|
st = state.get(view) or restore_view(view)
|
||||||
@@ -53,6 +50,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]:
|
|||||||
if name:
|
if name:
|
||||||
candidates.append(os.path.dirname(name))
|
candidates.append(os.path.dirname(name))
|
||||||
candidates.extend(window.folders())
|
candidates.extend(window.folders())
|
||||||
|
# first candidate in a repo wins; only hits are cached
|
||||||
for directory in candidates:
|
for directory in candidates:
|
||||||
root = _roots.get(directory)
|
root = _roots.get(directory)
|
||||||
if root is None:
|
if root is None:
|
||||||
@@ -65,6 +63,7 @@ def resolve_repo(window: sublime.Window) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def find_review_view(window: sublime.Window) -> Optional[sublime.View]:
|
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():
|
for view in window.views():
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
return view
|
return view
|
||||||
@@ -72,7 +71,7 @@ def find_review_view(window: sublime.Window) -> Optional[sublime.View]:
|
|||||||
|
|
||||||
|
|
||||||
def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]:
|
def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewState]]:
|
||||||
"""(view, state) of the window's single review view for the resolved repo, focused; None without a repo"""
|
"""Focus the window's review view (created if missing) on the resolved repo; (view, state), or None without a repo"""
|
||||||
repo = resolve_repo(window)
|
repo = resolve_repo(window)
|
||||||
if not repo:
|
if not repo:
|
||||||
status("no git repository for the active file or project folders")
|
status("no git repository for the active file or project folders")
|
||||||
@@ -84,13 +83,42 @@ def open_review(window: sublime.Window) -> Optional[Tuple[sublime.View, ViewStat
|
|||||||
st = state.get(view)
|
st = state.get(view)
|
||||||
if st is None or st.repo != repo:
|
if st is None or st.repo != repo:
|
||||||
# a different repository: browsing state no longer applies
|
# a different repository: browsing state no longer applies
|
||||||
st = state.put(view, state.ViewState(repo))
|
all_mode = settings().get("default_view_mode") == "all_in_one"
|
||||||
|
st = state.put(view, state.ViewState(repo, all_mode=all_mode))
|
||||||
window.focus_view(view)
|
window.focus_view(view)
|
||||||
return view, st
|
return view, st
|
||||||
|
|
||||||
|
|
||||||
|
def show_all(view: sublime.View, st: ViewState, focus: Optional[str] = None) -> None:
|
||||||
|
"""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:
|
def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
||||||
"""Fetch and parse the file's diff on the worker thread, then render it"""
|
"""Fetch and parse the file's diff on the worker thread, then render it"""
|
||||||
|
st.all_mode = False
|
||||||
st.token += 1
|
st.token += 1
|
||||||
# set before the worker starts: n / p count from here while the diff is still loading
|
# set before the worker starts: n / p count from here while the diff is still loading
|
||||||
st.requested = change
|
st.requested = change
|
||||||
@@ -98,6 +126,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
|||||||
repo, sha = st.repo, st.sha
|
repo, sha = st.repo, st.sha
|
||||||
|
|
||||||
def work() -> Optional[Tuple[parse.Diff, List[parse.Block]]]:
|
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:
|
if token != st.token:
|
||||||
return None # superseded before it started: skip the subprocess
|
return None # superseded before it started: skip the subprocess
|
||||||
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
|
text = git.full_diff(repo, change.path, sha=sha, untracked=(change.status == "?"))
|
||||||
@@ -105,6 +134,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
|||||||
return diff, parse.change_blocks(diff.lines)
|
return diff, parse.change_blocks(diff.lines)
|
||||||
|
|
||||||
def done(result: Optional[Tuple[parse.Diff, List[parse.Block]]]) -> None:
|
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():
|
if result is None or token != st.token or not view.is_valid():
|
||||||
return
|
return
|
||||||
diff, blocks = result
|
diff, blocks = result
|
||||||
@@ -117,6 +147,7 @@ def show_file(view: sublime.View, st: ViewState, change: FileChange) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
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] = []
|
items: List[sublime.QuickPanelItem] = []
|
||||||
for change in files:
|
for change in files:
|
||||||
description = git.STATUS_NAMES.get(change.status, "")
|
description = git.STATUS_NAMES.get(change.status, "")
|
||||||
@@ -125,6 +156,7 @@ def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
|||||||
details = ", ".join(filter(None, (description, "staged" if change.staged else "")))
|
details = ", ".join(filter(None, (description, "staged" if change.staged else "")))
|
||||||
if change.old_path:
|
if change.old_path:
|
||||||
details += " ← " + change.old_path
|
details += " ← " + change.old_path
|
||||||
|
# no line counts means git saw a binary file
|
||||||
if None in (change.insertions, change.deletions):
|
if None in (change.insertions, change.deletions):
|
||||||
annotation = "binary"
|
annotation = "binary"
|
||||||
else:
|
else:
|
||||||
@@ -134,13 +166,14 @@ def file_items(files: Sequence[FileChange]) -> List[sublime.QuickPanelItem]:
|
|||||||
|
|
||||||
|
|
||||||
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
||||||
"""Quick-panel rows: subject on the left, its totals and the hash right-aligned"""
|
"""Quick-panel rows: subject, author · date, and diffstat plus short hash right-aligned"""
|
||||||
items: List[sublime.QuickPanelItem] = []
|
items: List[sublime.QuickPanelItem] = []
|
||||||
for c in commits:
|
for c in commits:
|
||||||
|
# merges and empty commits have no diffstat, so they show the hash alone
|
||||||
annotation = c.short
|
annotation = c.short
|
||||||
if c.files is not None:
|
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}"
|
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)"
|
subject = c.subject or "(no subject)"
|
||||||
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
|
budget = max(COMMIT_ROW_WIDTH - len(annotation), MIN_SUBJECT_WIDTH)
|
||||||
if len(subject) > budget:
|
if len(subject) > budget:
|
||||||
@@ -149,43 +182,80 @@ def commit_items(commits: Sequence[Commit]) -> List[sublime.QuickPanelItem]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
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],
|
def show_files_panel(window: sublime.Window, view: sublime.View, st: ViewState, files: List[FileChange],
|
||||||
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
|
kept: Kept, on_cancel: Optional[Callable[[], None]] = None,
|
||||||
placeholder: str = "Changed files") -> None:
|
placeholder: str = "Changed files") -> None:
|
||||||
"""Quick panel over `files`; highlighting previews, Enter keeps, Esc restores `kept` (or runs on_cancel)"""
|
"""Quick panel over `files`: highlight previews, Enter shows, Esc restores `kept` (then runs on_cancel)
|
||||||
|
|
||||||
|
In the aggregate view every diff is loaded once as the panel opens, and a highlight only scrolls to its file
|
||||||
|
"""
|
||||||
|
# the panel opens on the shown file, the view focused behind it
|
||||||
st.files = files
|
st.files = files
|
||||||
selected = max(0, st.index())
|
selected = max(0, st.index())
|
||||||
window.focus_view(view)
|
window.focus_view(view)
|
||||||
|
|
||||||
|
def show(change: FileChange) -> None:
|
||||||
|
"""Show `change`: its own diff, or its heading in the aggregate view"""
|
||||||
|
if not st.all_mode:
|
||||||
|
show_file(view, st, change)
|
||||||
|
return
|
||||||
|
st.change = change
|
||||||
|
st.collapsed.discard(change.path)
|
||||||
|
# still loading: show_all lands on st.change when the diffs arrive
|
||||||
|
if st.rendered_token == st.token:
|
||||||
|
render.render_all(view, st, focus=change.path)
|
||||||
|
|
||||||
def on_select(index: int) -> None:
|
def on_select(index: int) -> None:
|
||||||
|
"""Enter shows the chosen file; Esc restores `kept`, then runs on_cancel if given"""
|
||||||
st.highlight += 1 # cancel a pending preview
|
st.highlight += 1 # cancel a pending preview
|
||||||
if index == -1:
|
if index == -1:
|
||||||
if on_cancel is not None:
|
if on_cancel is not None:
|
||||||
st.restore(kept)
|
st.restore(kept)
|
||||||
on_cancel()
|
on_cancel()
|
||||||
elif kept.change is not None:
|
elif kept.mode == MODE_BRANCHES or kept.change is not None:
|
||||||
# go back to the file that was kept before the panel opened
|
# back to the branches view or the kept file in the kept mode, unless the screen still shows it
|
||||||
st.restore(kept)
|
st.restore(kept)
|
||||||
show_file(view, st, kept.change)
|
redisplay_if_stale(view, st)
|
||||||
# nothing was kept before: the last previewed file stays
|
# nothing was kept before: the last previewed file stays
|
||||||
return
|
return
|
||||||
change = files[index]
|
change = files[index]
|
||||||
if st.change is change and st.rendered_token == st.token:
|
if st.change is change and st.rendered_token == st.token:
|
||||||
return # the preview already shows it
|
return # the preview already shows it
|
||||||
show_file(view, st, change)
|
show(change)
|
||||||
|
|
||||||
def on_highlight(index: int) -> None:
|
def on_highlight(index: int) -> None:
|
||||||
|
"""Schedule a debounced preview of the highlighted file"""
|
||||||
if not 0 <= index < len(files):
|
if not 0 <= index < len(files):
|
||||||
return
|
return
|
||||||
st.highlight += 1
|
st.highlight += 1
|
||||||
ticket = st.highlight
|
ticket = st.highlight
|
||||||
|
|
||||||
def preview() -> None:
|
def preview() -> None:
|
||||||
|
"""Show the file unless another highlight came since"""
|
||||||
if ticket == st.highlight:
|
if ticket == st.highlight:
|
||||||
show_file(view, st, files[index])
|
show(files[index])
|
||||||
|
|
||||||
sublime.set_timeout(preview, PREVIEW_DELAY_MS)
|
sublime.set_timeout(preview, 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)
|
window.show_quick_panel(file_items(files), on_select, 0, selected, on_highlight, placeholder)
|
||||||
|
|
||||||
|
|
||||||
@@ -193,14 +263,16 @@ def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] =
|
|||||||
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> None:
|
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> None:
|
||||||
"""Re-list the working tree changes and show the same file, or the next one when it is gone
|
"""Re-list the working tree changes and show the same file, or the next one when it is gone
|
||||||
|
|
||||||
`pick(files, index)` may name another file instead; `index` is the shown file's slot, new or vacated.
|
`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:
|
if message:
|
||||||
status(message)
|
status(message)
|
||||||
|
# remember the shown file and its slot before the list changes
|
||||||
previous_index = max(0, st.index())
|
previous_index = max(0, st.index())
|
||||||
path = st.change.path if st.change else None
|
path = st.change.path if st.change else None
|
||||||
|
|
||||||
def done(files: List[FileChange]) -> None:
|
def done(files: List[FileChange]) -> None:
|
||||||
|
"""Show the same file again, its slot's successor, or what `pick` chooses"""
|
||||||
if not view.is_valid():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
st.files = files
|
st.files = files
|
||||||
@@ -212,23 +284,44 @@ def refresh_listing(view: sublime.View, st: ViewState, message: Optional[str] =
|
|||||||
# the file left the list: whatever moved up into its slot takes over
|
# the file left the list: whatever moved up into its slot takes over
|
||||||
index = min(previous_index, len(files) - 1)
|
index = min(previous_index, len(files) - 1)
|
||||||
chosen = pick(files, index) if pick else None
|
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)
|
run_async(lambda: git.changed_files(st.repo), done)
|
||||||
|
|
||||||
|
|
||||||
def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
|
def refresh_if_changed(view: sublime.View, st: ViewState) -> None:
|
||||||
"""Re-render a working-tree view when the file or the index changed since it was drawn"""
|
"""Re-render a working-tree view when 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:
|
if st.in_commit or st.change is None:
|
||||||
return
|
return
|
||||||
if render.fingerprint(st) != st.fingerprint:
|
if render.fingerprint(st) != st.fingerprint:
|
||||||
refresh_listing(view, st)
|
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):
|
class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
||||||
"""List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
|
"""List uncommitted changes (staged, unstaged, untracked) in a quick panel"""
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
"""Open the review view, then list the changed files off the UI thread"""
|
||||||
window = self.window
|
window = self.window
|
||||||
opened = open_review(window)
|
opened = open_review(window)
|
||||||
if not opened:
|
if not opened:
|
||||||
@@ -236,8 +329,13 @@ class VdiffFilesCommand(sublime_plugin.WindowCommand):
|
|||||||
view, st = opened
|
view, st = opened
|
||||||
|
|
||||||
def done(files: List[FileChange]) -> None:
|
def done(files: List[FileChange]) -> None:
|
||||||
|
"""Show the files panel, or a notice when there is nothing uncommitted"""
|
||||||
if not view.is_valid():
|
if not view.is_valid():
|
||||||
return
|
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()
|
kept = st.enter()
|
||||||
if not files:
|
if not files:
|
||||||
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
|
render.render_message(view, st, f"No uncommitted changes in {st.repo}")
|
||||||
@@ -253,6 +351,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
"""List recent commits, then the files of the chosen commit; `sha` skips straight to one commit"""
|
"""List recent commits, then the files of the chosen commit; `sha` skips straight to one commit"""
|
||||||
|
|
||||||
def run(self, selected: int = 0, sha: Optional[str] = None) -> 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)
|
opened = open_review(self.window)
|
||||||
if not opened:
|
if not opened:
|
||||||
return
|
return
|
||||||
@@ -260,9 +359,10 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
if sha:
|
if sha:
|
||||||
self.open_commit(view, st, sha, None)
|
self.open_commit(view, st, sha, None)
|
||||||
return
|
return
|
||||||
count = int(settings().get("commits_count", 30) or 30)
|
count = commits_count()
|
||||||
|
|
||||||
def done(commits: List[Commit]) -> None:
|
def done(commits: List[Commit]) -> None:
|
||||||
|
"""Show the commits panel unless the repository has none"""
|
||||||
if not view.is_valid():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
if not commits:
|
if not commits:
|
||||||
@@ -273,22 +373,28 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
run_async(lambda: git.recent_commits(st.repo, count), done)
|
run_async(lambda: git.recent_commits(st.repo, count), done)
|
||||||
|
|
||||||
def show_commits(self, view: sublime.View, st: ViewState, commits: Sequence[Commit], selected: int) -> None:
|
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)
|
items = commit_items(commits)
|
||||||
for i, c in enumerate(commits):
|
for i, c in enumerate(commits):
|
||||||
if c.sha == st.sha:
|
if c.sha == st.sha:
|
||||||
selected = i
|
selected = i
|
||||||
|
|
||||||
def on_select(index: int) -> None:
|
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:
|
if index != -1:
|
||||||
self.open_commit(view, st, commits[index].sha, index)
|
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")
|
self.window.show_quick_panel(items, on_select, 0, selected, None, "Recent commits")
|
||||||
|
|
||||||
def open_commit(self, view: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None:
|
def open_commit(self, view: sublime.View, st: ViewState, sha: str, index: Optional[int]) -> None:
|
||||||
"""Files panel of one commit; Esc goes back to the commit list when it came from there (index given)"""
|
"""Files panel of one commit; with `index`, Esc reopens the commit list on that row"""
|
||||||
window = self.window
|
window = self.window
|
||||||
|
|
||||||
def done(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
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():
|
if not view.is_valid():
|
||||||
return
|
return
|
||||||
info, files = result
|
info, files = result
|
||||||
@@ -298,6 +404,7 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
def back() -> None:
|
def back() -> None:
|
||||||
|
"""Reopen the commit list on the row this commit came from"""
|
||||||
window.run_command("vdiff_commits", {"selected": index})
|
window.run_command("vdiff_commits", {"selected": index})
|
||||||
|
|
||||||
placeholder = f"{info.short} · {info.subject}"
|
placeholder = f"{info.short} · {info.subject}"
|
||||||
@@ -307,35 +414,58 @@ class VdiffCommitsCommand(sublime_plugin.WindowCommand):
|
|||||||
run_async(lambda: git.commit_with_files(st.repo, sha), done)
|
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 ----------------------------------------------------
|
# --- review view actions ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _act_files(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
def _act_files(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Open the changed-files panel"""
|
||||||
window.run_command("vdiff_files")
|
window.run_command("vdiff_files")
|
||||||
|
|
||||||
|
|
||||||
def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
def _act_commits(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Open the recent-commits panel"""
|
||||||
window.run_command("vdiff_commits")
|
window.run_command("vdiff_commits")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _act_close(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Close the review view"""
|
||||||
view.close()
|
view.close()
|
||||||
|
|
||||||
|
|
||||||
def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
def _act_help(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Show the help popup"""
|
||||||
render.show_help(view, st)
|
render.show_help(view, st)
|
||||||
|
|
||||||
|
|
||||||
def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None],
|
def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange], None],
|
||||||
confirm: Optional[Callable[[FileChange], bool]] = None,
|
confirm: Optional[Callable[[FileChange], bool]] = None,
|
||||||
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> Action:
|
pick: Optional[Callable[[List[FileChange], int], Optional[FileChange]]] = None) -> Action:
|
||||||
"""Run `operation(repo, change)` on the shown working-tree file, then re-list; `pick` goes to refresh_listing"""
|
"""Action running `operation(repo, change)` on the current working-tree file, then re-listing; `pick` is passed to refresh_listing"""
|
||||||
|
|
||||||
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if st.in_commit or st.change is 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")
|
status(f"{verb} works on uncommitted changes only")
|
||||||
return
|
return
|
||||||
change = st.change
|
if change is None:
|
||||||
|
status("no file under the caret")
|
||||||
|
return
|
||||||
if confirm is not None and not confirm(change):
|
if confirm is not None and not confirm(change):
|
||||||
return
|
return
|
||||||
run_async(
|
run_async(
|
||||||
@@ -346,9 +476,32 @@ def _git_action(verb: str, done_verb: str, operation: Callable[[str, FileChange]
|
|||||||
return act
|
return act
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
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):
|
if not settings().get("confirm_discard", True):
|
||||||
return True
|
return True
|
||||||
|
# untracked files are deleted outright, tracked ones reset
|
||||||
if change.status == "?":
|
if change.status == "?":
|
||||||
message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
|
message = f"Delete the untracked file {change.path}?\n\nThis cannot be undone."
|
||||||
else:
|
else:
|
||||||
@@ -360,10 +513,10 @@ def _confirm_discard(change: FileChange) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _next_unstaged(files: Sequence[FileChange], start: int) -> int:
|
def _next_unstaged(files: Sequence[FileChange], start: int) -> int:
|
||||||
"""Index of the first unstaged file from `start` on, wrapping round; -1 when every file is staged"""
|
"""Index of the first file with something to stage from `start` on, wrapping round; -1 when everything is staged"""
|
||||||
for offset in range(len(files)):
|
for offset in range(len(files)):
|
||||||
index = (start + offset) % len(files)
|
index = (start + offset) % len(files)
|
||||||
if not files[index].staged:
|
if files[index].has_unstaged:
|
||||||
return index
|
return index
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
@@ -372,40 +525,114 @@ def _stage_next(files: Sequence[FileChange], index: int) -> Optional[FileChange]
|
|||||||
"""After staging, the next unstaged file to show; None keeps the file the refresh would show"""
|
"""After staging, the next unstaged file to show; None keeps the file the refresh would show"""
|
||||||
if not settings().get("stage_and_move_next_file", True):
|
if not settings().get("stage_and_move_next_file", True):
|
||||||
return None
|
return None
|
||||||
# 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)
|
following = _next_unstaged(files, index)
|
||||||
return files[following] if following >= 0 else None
|
return files[following] if following >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
def _act_refresh(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if st.change is None:
|
"""Reload what is shown: the branches, the commit's diffs, or the working tree re-listed (new and reverted files too)"""
|
||||||
return
|
if st.in_branches or st.in_commit:
|
||||||
if st.in_commit:
|
redisplay(view, st, keep_caret=True)
|
||||||
show_file(view, st, st.change)
|
elif st.all_mode or st.change is not None:
|
||||||
else:
|
|
||||||
refresh_listing(view, st)
|
refresh_listing(view, st)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _act_open(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
if st.change is 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
|
return
|
||||||
path = os.path.join(st.repo, st.change.path)
|
path = os.path.join(st.repo, change.path)
|
||||||
if not os.path.exists(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
|
return
|
||||||
|
# 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))
|
line = parse.line_for_open(st.lines, render.caret_index(view))
|
||||||
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
|
window.open_file(f"{path}:{line}", sublime.ENCODED_POSITION)
|
||||||
|
|
||||||
|
|
||||||
def _step_file(delta: int) -> Action:
|
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:
|
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:
|
if not st.files:
|
||||||
status("no file list, press f or c")
|
status("no file list, press f or c")
|
||||||
return
|
return
|
||||||
count = len(st.files)
|
count = len(st.files)
|
||||||
position = st.index()
|
position = st.index()
|
||||||
if position < 0:
|
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
|
index, wrapped = (0 if delta > 0 else count - 1), False
|
||||||
else:
|
else:
|
||||||
index = position + delta
|
index = position + delta
|
||||||
@@ -421,7 +648,43 @@ def _step_file(delta: int) -> Action:
|
|||||||
|
|
||||||
|
|
||||||
def _step_change(delta: int) -> Action:
|
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:
|
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]
|
starts = [start for start, _ in st.blocks]
|
||||||
if not starts:
|
if not starts:
|
||||||
status("no changes to step through")
|
status("no changes to step through")
|
||||||
@@ -431,6 +694,7 @@ def _step_change(delta: int) -> Action:
|
|||||||
if not 0 <= position < len(starts):
|
if not 0 <= position < len(starts):
|
||||||
render.flash(view)
|
render.flash(view)
|
||||||
position %= len(starts) # -1 is the last change, len(starts) the first
|
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])
|
point = render.index_point(view, starts[position])
|
||||||
view.sel().clear()
|
view.sel().clear()
|
||||||
view.sel().add(sublime.Region(point))
|
view.sel().add(sublime.Region(point))
|
||||||
@@ -450,6 +714,7 @@ def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> No
|
|||||||
return
|
return
|
||||||
|
|
||||||
def on_done(message: str) -> None:
|
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():
|
if not message.strip():
|
||||||
status("commit cancelled: empty message")
|
status("commit cancelled: empty message")
|
||||||
return
|
return
|
||||||
@@ -464,7 +729,7 @@ def _act_commit(view: sublime.View, window: sublime.Window, st: ViewState) -> No
|
|||||||
def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
"""Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels
|
"""Rewrite HEAD's message in an input panel, folding in whatever is staged; Esc there cancels
|
||||||
|
|
||||||
The one-line panel offers only the subject; the body rides along verbatim, and nothing staged is a reword.
|
The one-line panel offers only the subject; the body rides along verbatim, and nothing staged is a reword
|
||||||
"""
|
"""
|
||||||
if st.in_commit:
|
if st.in_commit:
|
||||||
status("amend works on uncommitted changes only")
|
status("amend works on uncommitted changes only")
|
||||||
@@ -472,9 +737,11 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
|
|||||||
staged = [c for c in st.files if c.staged]
|
staged = [c for c in st.files if c.staged]
|
||||||
|
|
||||||
def prompt(message: str) -> None:
|
def prompt(message: str) -> None:
|
||||||
|
"""Show the input panel prefilled with HEAD's subject"""
|
||||||
subject, newline, body = message.partition("\n")
|
subject, newline, body = message.partition("\n")
|
||||||
|
|
||||||
def on_done(text: str) -> None:
|
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():
|
if not text.strip():
|
||||||
status("amend cancelled: empty message")
|
status("amend cancelled: empty message")
|
||||||
return
|
return
|
||||||
@@ -482,7 +749,7 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
|
|||||||
if staged:
|
if staged:
|
||||||
done += f" with {file_count(len(staged))}"
|
done += f" with {file_count(len(staged))}"
|
||||||
run_async(
|
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: git.amend(st.repo, text + newline + body, allow_empty=not staged),
|
||||||
lambda _: refresh_listing(view, st, done),
|
lambda _: refresh_listing(view, st, done),
|
||||||
)
|
)
|
||||||
@@ -496,7 +763,9 @@ def _act_amend(view: sublime.View, window: sublime.Window, st: ViewState) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def _move_line(forward: bool) -> Action:
|
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:
|
def act(view: sublime.View, window: sublime.Window, st: ViewState) -> None:
|
||||||
|
"""Move the caret one line"""
|
||||||
view.run_command("move", {"by": "lines", "forward": forward})
|
view.run_command("move", {"by": "lines", "forward": forward})
|
||||||
|
|
||||||
return act
|
return act
|
||||||
@@ -505,14 +774,21 @@ def _move_line(forward: bool) -> Action:
|
|||||||
ACTIONS: Dict[str, Action] = {
|
ACTIONS: Dict[str, Action] = {
|
||||||
"files": _act_files,
|
"files": _act_files,
|
||||||
"commits": _act_commits,
|
"commits": _act_commits,
|
||||||
|
"branches": _act_branches,
|
||||||
"close": _act_close,
|
"close": _act_close,
|
||||||
"help": _act_help,
|
"help": _act_help,
|
||||||
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
|
"stage": _git_action("stage", "staged", git.stage, pick=_stage_next),
|
||||||
"unstage": _git_action("unstage", "unstaged", git.unstage),
|
"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),
|
"discard": _git_action("discard", "discarded", git.discard, confirm=_confirm_discard),
|
||||||
"commit": _act_commit,
|
"commit": _act_commit,
|
||||||
"amend": _act_amend,
|
"amend": _act_amend,
|
||||||
"refresh": _act_refresh,
|
"refresh": _act_refresh,
|
||||||
|
"view": _act_view,
|
||||||
|
"expand": _toggle_file(1),
|
||||||
|
"collapse": _toggle_file(-1),
|
||||||
|
"fold_all": _act_fold_all,
|
||||||
"open": _act_open,
|
"open": _act_open,
|
||||||
"next_file": _step_file(1),
|
"next_file": _step_file(1),
|
||||||
"prev_file": _step_file(-1),
|
"prev_file": _step_file(-1),
|
||||||
@@ -521,15 +797,19 @@ ACTIONS: Dict[str, Action] = {
|
|||||||
"line_down": _move_line(True),
|
"line_down": _move_line(True),
|
||||||
"line_up": _move_line(False),
|
"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):
|
class VdiffActionCommand(sublime_plugin.TextCommand):
|
||||||
"""Every key/link action of a review view, dispatched by name through ACTIONS"""
|
"""Every key/link action of a review view, dispatched by name through ACTIONS"""
|
||||||
|
|
||||||
def is_enabled(self, action: Optional[str] = None) -> bool:
|
def is_enabled(self, action: Optional[str] = None) -> bool:
|
||||||
|
"""Only in review views"""
|
||||||
return state.is_review_view(self.view)
|
return state.is_review_view(self.view)
|
||||||
|
|
||||||
def run(self, edit: sublime.Edit, action: str) -> None:
|
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)
|
act = ACTIONS.get(action)
|
||||||
if act is None:
|
if act is None:
|
||||||
status(f"unknown action {action}")
|
status(f"unknown action {action}")
|
||||||
@@ -540,38 +820,51 @@ class VdiffActionCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
def restore_view(view: sublime.View) -> ViewState:
|
def restore_view(view: sublime.View) -> ViewState:
|
||||||
"""Rebuild the state of a review view restored from a previous session; re-renders asynchronously"""
|
"""Rebuild the state of a review view restored from a previous session; re-renders asynchronously"""
|
||||||
|
# the repo, commit and file saved with the view
|
||||||
data = view.settings().get(state.VIEW_DATA) or {}
|
data = view.settings().get(state.VIEW_DATA) or {}
|
||||||
repo = data.get("repo")
|
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):
|
if not repo or not os.path.isdir(repo):
|
||||||
st = state.put(view, state.ViewState(""))
|
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
|
return st
|
||||||
st = state.put(view, state.ViewState(repo))
|
st = state.put(view, state.ViewState(repo))
|
||||||
st.sha = data.get("sha")
|
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")
|
path = data.get("path")
|
||||||
if not 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
|
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)
|
change = git.FileChange(path, data.get("status", "M"), bool(data.get("staged")), None, None, None)
|
||||||
|
|
||||||
|
# a commit view re-lists the commit's files, a working-tree view the uncommitted changes
|
||||||
sha = st.sha
|
sha = st.sha
|
||||||
if sha is not None:
|
if sha is not None:
|
||||||
|
|
||||||
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
def show_commit(result: Tuple[git.CommitInfo, List[FileChange]]) -> None:
|
||||||
st.commit, st.files = result
|
"""Adopt the commit's files and redraw the saved file or the aggregate view"""
|
||||||
show_file(view, st, change)
|
st.commit, st.files, st.change = result[0], result[1], change
|
||||||
|
redisplay(view, st)
|
||||||
|
|
||||||
run_async(lambda: git.commit_with_files(repo, sha), show_commit)
|
run_async(lambda: git.commit_with_files(repo, sha), show_commit)
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def show_working_tree(files: List[FileChange]) -> None:
|
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
|
st.files = files
|
||||||
index = state.find_change(files, path)
|
if not files:
|
||||||
if index >= 0:
|
render.render_message(view, st, f"No uncommitted changes in {repo}")
|
||||||
show_file(view, st, files[index])
|
return
|
||||||
else:
|
st.change = files[max(0, state.find_change(files, path))]
|
||||||
render.render_message(view, st, f"{path} has no uncommitted changes any more")
|
redisplay(view, st)
|
||||||
|
|
||||||
run_async(lambda: git.changed_files(repo), show_working_tree)
|
run_async(lambda: git.changed_files(repo), show_working_tree)
|
||||||
return st
|
return st
|
||||||
|
|||||||
340
vdiff/git.py
340
vdiff/git.py
@@ -1,4 +1,5 @@
|
|||||||
""" 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 os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -6,7 +7,11 @@ from typing import Dict, List, Optional, NamedTuple, Sequence, Tuple
|
|||||||
|
|
||||||
|
|
||||||
class FileChange(NamedTuple):
|
class FileChange(NamedTuple):
|
||||||
"""path, one-letter status (M A D R ?), staged flag, numstat counts (None for binary), rename source"""
|
"""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
|
path: str
|
||||||
status: str
|
status: str
|
||||||
@@ -14,10 +19,35 @@ class FileChange(NamedTuple):
|
|||||||
insertions: Optional[int]
|
insertions: Optional[int]
|
||||||
deletions: Optional[int]
|
deletions: Optional[int]
|
||||||
old_path: Optional[str]
|
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):
|
class Commit(NamedTuple):
|
||||||
"""shortstat totals; all three are None when git prints no diffstat (merges, empty commits)"""
|
"""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
|
sha: str
|
||||||
short: str
|
short: str
|
||||||
@@ -27,9 +57,39 @@ class Commit(NamedTuple):
|
|||||||
files: Optional[int]
|
files: Optional[int]
|
||||||
insertions: Optional[int]
|
insertions: Optional[int]
|
||||||
deletions: 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):
|
class CommitInfo(NamedTuple):
|
||||||
|
"""One commit: its hashes, author, date, subject and body"""
|
||||||
|
|
||||||
sha: str
|
sha: str
|
||||||
short: str
|
short: str
|
||||||
author: str
|
author: str
|
||||||
@@ -50,6 +110,23 @@ FULL_CONTEXT = "-U999999"
|
|||||||
# overridable from settings (sublime's PATH may not have a user-installed git)
|
# overridable from settings (sublime's PATH may not have a user-installed git)
|
||||||
GIT_BINARY: str = "git"
|
GIT_BINARY: str = "git"
|
||||||
|
|
||||||
|
# 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] = {
|
STATUS_NAMES: Dict[str, str] = {
|
||||||
"M": "modified",
|
"M": "modified",
|
||||||
"A": "added",
|
"A": "added",
|
||||||
@@ -69,19 +146,25 @@ _SHORTSTAT = re.compile(r"\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(
|
|||||||
|
|
||||||
|
|
||||||
class GitError(Exception):
|
class GitError(Exception):
|
||||||
"""git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it"""
|
"""Git exited with an unexpected code; str() is the first stderr line, `stderr` the whole of it"""
|
||||||
|
|
||||||
def __init__(self, stderr: str, code: int) -> None:
|
def __init__(self, stderr: str, code: int) -> None:
|
||||||
|
"""Keep stderr and the exit code"""
|
||||||
self.stderr = stderr
|
self.stderr = stderr
|
||||||
self.code = code
|
self.code = code
|
||||||
first = stderr.strip().splitlines()
|
first = stderr.strip().splitlines()
|
||||||
super().__init__(first[0] if first else f"git exited with {code}")
|
super().__init__(first[0] if first else f"git exited with {code}")
|
||||||
|
|
||||||
|
|
||||||
def run(repo: str, args: Sequence[str], ok_codes: Tuple[int, ...] = (0,)) -> str:
|
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"""
|
"""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)
|
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:
|
if proc.returncode not in ok_codes:
|
||||||
raise GitError(proc.stderr, proc.returncode)
|
raise GitError(proc.stderr, proc.returncode)
|
||||||
return proc.stdout
|
return proc.stdout
|
||||||
@@ -91,9 +174,11 @@ def repo_root(path: Optional[str]) -> Optional[str]:
|
|||||||
"""Top-level directory of the repository containing `path`, or None"""
|
"""Top-level directory of the repository containing `path`, or None"""
|
||||||
if not path:
|
if not path:
|
||||||
return None
|
return None
|
||||||
|
# start from the directory itself, or the file's parent
|
||||||
directory = path if os.path.isdir(path) else os.path.dirname(path)
|
directory = path if os.path.isdir(path) else os.path.dirname(path)
|
||||||
if not os.path.isdir(directory):
|
if not os.path.isdir(directory):
|
||||||
return None
|
return None
|
||||||
|
# outside a repository git fails, which reads as None
|
||||||
try:
|
try:
|
||||||
root = run(directory, ["rev-parse", "--show-toplevel"]).strip()
|
root = run(directory, ["rev-parse", "--show-toplevel"]).strip()
|
||||||
except (GitError, OSError):
|
except (GitError, OSError):
|
||||||
@@ -110,19 +195,85 @@ def has_head(repo: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
def base_ref(repo: str) -> str:
|
||||||
"""HEAD, or the empty tree when there is no commit yet"""
|
"""HEAD, or the empty tree when there is no commit yet"""
|
||||||
return "HEAD" if has_head(repo) else EMPTY_TREE
|
return "HEAD" if has_head(repo) else EMPTY_TREE
|
||||||
|
|
||||||
|
|
||||||
def _int_or_none(value: str) -> Optional[int]:
|
def _int_or_none(value: str) -> Optional[int]:
|
||||||
"""A numstat count: "-" marks a binary file"""
|
"""A numstat count as int, None for "-" (binary)"""
|
||||||
return None if value == "-" else int(value)
|
return None if value == "-" else int(value)
|
||||||
|
|
||||||
|
|
||||||
def _parse_numstat(text: str) -> Dict[str, Numstat]:
|
def _parse_numstat(text: str) -> Dict[str, Numstat]:
|
||||||
"""`--numstat -z` output -> {path: (insertions, deletions, old_path)}"""
|
"""`--numstat -z` output -> {path: (insertions, deletions, old_path)}"""
|
||||||
result: Dict[str, Numstat] = {}
|
result: Dict[str, Numstat] = {}
|
||||||
|
# each NUL field is one "insertions<TAB>deletions<TAB>path" entry
|
||||||
fields = iter(text.split("\0"))
|
fields = iter(text.split("\0"))
|
||||||
for entry in fields:
|
for entry in fields:
|
||||||
parts = entry.split("\t")
|
parts = entry.split("\t")
|
||||||
@@ -141,6 +292,7 @@ def _parse_numstat(text: str) -> Dict[str, Numstat]:
|
|||||||
def _parse_name_status(text: str) -> List[Tuple[str, str, Optional[str]]]:
|
def _parse_name_status(text: str) -> List[Tuple[str, str, Optional[str]]]:
|
||||||
"""`--name-status -z` output -> [(status letter, path, old_path)]"""
|
"""`--name-status -z` output -> [(status letter, path, old_path)]"""
|
||||||
result: List[Tuple[str, str, Optional[str]]] = []
|
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"))
|
fields = iter(text.split("\0"))
|
||||||
for status in fields:
|
for status in fields:
|
||||||
if not status:
|
if not status:
|
||||||
@@ -157,11 +309,13 @@ def _count_lines(path: str) -> Optional[int]:
|
|||||||
"""Line count of a working-tree file, None when binary or unreadable"""
|
"""Line count of a working-tree file, None when binary or unreadable"""
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
|
# a NUL in the first 8000 bytes marks a binary file
|
||||||
head = f.read(8000)
|
head = f.read(8000)
|
||||||
if b"\0" in head:
|
if b"\0" in head:
|
||||||
return None
|
return None
|
||||||
count = head.count(b"\n")
|
count = head.count(b"\n")
|
||||||
last = head[-1:]
|
last = head[-1:]
|
||||||
|
# the rest in 1 MiB chunks, remembering the final byte
|
||||||
while True:
|
while True:
|
||||||
chunk = f.read(1 << 20)
|
chunk = f.read(1 << 20)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
@@ -170,6 +324,7 @@ def _count_lines(path: str) -> Optional[int]:
|
|||||||
last = chunk[-1:]
|
last = chunk[-1:]
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
|
# an empty file has no lines; a last line without a newline still counts
|
||||||
if not last:
|
if not last:
|
||||||
return 0
|
return 0
|
||||||
return count + (0 if last == b"\n" else 1)
|
return count + (0 if last == b"\n" else 1)
|
||||||
@@ -183,6 +338,7 @@ def changed_files(repo: str) -> List[FileChange]:
|
|||||||
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
|
base = EMPTY_TREE if "# branch.oid (initial)" in entries else "HEAD"
|
||||||
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base]))
|
counts = _parse_numstat(run(repo, ["diff", "--numstat", "-z", "--no-renames", base]))
|
||||||
|
|
||||||
|
# "1" ordinary, "u" unmerged, "?" untracked entries; anything else (branch headers) is skipped
|
||||||
changes: List[FileChange] = []
|
changes: List[FileChange] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
kind = entry[0]
|
kind = entry[0]
|
||||||
@@ -195,31 +351,34 @@ def changed_files(repo: str) -> List[FileChange]:
|
|||||||
xy, path = ".?", entry[2:]
|
xy, path = ".?", entry[2:]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
# any index change means staged, any worktree one unstaged; the worktree letter wins over the index letter
|
||||||
x, y = xy[0], xy[1]
|
x, y = xy[0], xy[1]
|
||||||
staged = x not in (".", "?")
|
staged, unstaged = x not in (".", "?"), y != "."
|
||||||
letter = y if y != "." else x
|
letter = y if y != "." else x
|
||||||
|
# untracked files have no numstat: their lines count as insertions
|
||||||
if letter == "?":
|
if letter == "?":
|
||||||
lines = _count_lines(os.path.join(repo, path))
|
lines = _count_lines(os.path.join(repo, path))
|
||||||
ins, dele, old = lines, (None if lines is None else 0), None
|
ins, dele, old = lines, (None if lines is None else 0), None
|
||||||
else:
|
else:
|
||||||
ins, dele, old = counts.get(path, (0, 0, None))
|
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)
|
changes.sort(key=lambda c: c.path)
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
|
|
||||||
def recent_commits(repo: str, count: int) -> List[Commit]:
|
def _log(repo: str, args: Sequence[str]) -> List[Commit]:
|
||||||
"""Last `count` commits, newest first; empty list without commits"""
|
"""`log <args>` in _LOG_FORMAT with --shortstat -> commits, newest first
|
||||||
if not has_head(repo):
|
|
||||||
return []
|
A six-field line starts a commit (its tags come from the %D decoration list); the shortstat line
|
||||||
# --shortstat rides along on the same call: one subprocess, totals for free
|
after it fills in its totals
|
||||||
text = run(repo, ["log", "-n", str(count), "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", "--shortstat"])
|
"""
|
||||||
commits: List[Commit] = []
|
commits: List[Commit] = []
|
||||||
for line in text.split("\n"):
|
for line in run(repo, ["log", _LOG_FORMAT, "--shortstat"] + list(args)).split("\n"):
|
||||||
parts = line.split("\x1f")
|
parts = line.split("\x1f")
|
||||||
if len(parts) == 5:
|
if len(parts) == 6:
|
||||||
sha, short, author, date, subject = parts
|
sha, short, author, date, subject, decorations = parts
|
||||||
commits.append(Commit(sha, short, author, date, subject, files=None, insertions=None, deletions=None))
|
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
|
continue
|
||||||
if commits and commits[-1].files is None:
|
if commits and commits[-1].files is None:
|
||||||
stat = _SHORTSTAT.match(line)
|
stat = _SHORTSTAT.match(line)
|
||||||
@@ -229,18 +388,123 @@ def recent_commits(repo: str, count: int) -> List[Commit]:
|
|||||||
return commits
|
return commits
|
||||||
|
|
||||||
|
|
||||||
|
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]]:
|
def commit_with_files(repo: str, sha: str) -> Tuple[CommitInfo, List[FileChange]]:
|
||||||
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
|
"""(CommitInfo, [FileChange]) of one commit vs its first parent, files sorted by path; two git calls"""
|
||||||
show = ["show", "--first-parent", "-m", "-z"]
|
show = ["show", "--first-parent", "-m", "-z"]
|
||||||
text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
|
text = run(repo, show + [f"--format={_INFO_FORMAT}%x00", _DATE_FORMAT, "--name-status", sha])
|
||||||
# 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")
|
info_text, _, files_text = text.partition("\0")
|
||||||
files_text = files_text.lstrip("\0\n")
|
files_text = files_text.lstrip("\0\n")
|
||||||
|
# pad missing trailing fields so CommitInfo always gets six
|
||||||
parts = info_text.split("\x1f")
|
parts = info_text.split("\x1f")
|
||||||
while len(parts) < 6:
|
while len(parts) < 6:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
info = CommitInfo(*parts[:6])
|
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]))
|
counts = _parse_numstat(run(repo, show + ["--format=", "--numstat", sha]))
|
||||||
changes: List[FileChange] = []
|
changes: List[FileChange] = []
|
||||||
for status, path, old_path in _parse_name_status(files_text):
|
for status, path, old_path in _parse_name_status(files_text):
|
||||||
@@ -263,9 +527,10 @@ def full_diff(repo: str, path: str, sha: Optional[str] = None, untracked: bool =
|
|||||||
"""Unified diff of one file with the whole file as context
|
"""Unified diff of one file with the whole file as context
|
||||||
|
|
||||||
Working tree vs HEAD by default (staged and unstaged merged), a commit vs its first parent with
|
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.
|
`sha`, the whole file as insertions when untracked
|
||||||
"""
|
"""
|
||||||
common = ["--no-color", "--no-ext-diff", FULL_CONTEXT]
|
common = ["--no-color", "--no-ext-diff", FULL_CONTEXT]
|
||||||
|
# untracked: against /dev/null, where exit code 1 only means the files differ
|
||||||
if untracked:
|
if untracked:
|
||||||
return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1))
|
return run(repo, ["diff", "--no-index"] + common + ["--", os.devnull, path], ok_codes=(0, 1))
|
||||||
if sha:
|
if sha:
|
||||||
@@ -278,34 +543,49 @@ def stage(repo: str, change: FileChange) -> None:
|
|||||||
run(repo, ["add", "-A", "--", change.path])
|
run(repo, ["add", "-A", "--", change.path])
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def commit(repo: str, message: str) -> None:
|
||||||
"""Commit what is staged; the message is one argv element, so it needs no quoting of its own"""
|
"""Commit what is staged; `message` goes as one argv element, unquoted"""
|
||||||
run(repo, ["commit", "-m", message])
|
run(repo, ["commit", "-m", message])
|
||||||
|
|
||||||
|
|
||||||
def head_message(repo: str) -> str:
|
def head_message(repo: str) -> str:
|
||||||
"""HEAD's whole message, exactly as git stores it"""
|
"""HEAD's full message, surrounding newlines stripped"""
|
||||||
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
return run(repo, ["log", "-1", "--format=%B"]).strip("\n")
|
||||||
|
|
||||||
|
|
||||||
def amend(repo: str, message: str, allow_empty: bool = False) -> None:
|
def amend(repo: str, message: str, allow_empty: bool = False) -> None:
|
||||||
"""Rewrite HEAD with `message`, folding in whatever is staged
|
"""Rewrite HEAD with `message`, folding in whatever is staged
|
||||||
|
|
||||||
`allow_empty` for a pure reword: git refuses an amend carrying no change, failing on an empty commit.
|
`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])
|
run(repo, ["commit", "--amend"] + (["--allow-empty"] if allow_empty else []) + ["-m", message])
|
||||||
|
|
||||||
|
|
||||||
|
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", "--", path])
|
||||||
|
else:
|
||||||
|
run(repo, ["rm", "-r", "-q", "--cached", "--", path])
|
||||||
|
|
||||||
|
|
||||||
def unstage(repo: str, change: FileChange) -> None:
|
def unstage(repo: str, change: FileChange) -> None:
|
||||||
"""Move the file's staged changes back to the working tree"""
|
"""Move the file's staged changes back to the working tree"""
|
||||||
if has_head(repo):
|
_unstage_path(repo, change.path)
|
||||||
run(repo, ["reset", "-q", "--", change.path])
|
|
||||||
else:
|
|
||||||
run(repo, ["rm", "-r", "-q", "--cached", "--", 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:
|
def discard(repo: str, change: FileChange) -> None:
|
||||||
"""Throw away the file's changes, staged and unstaged, restoring the HEAD version"""
|
"""Throw away the file's staged and unstaged changes: restore HEAD's version, or delete a new file"""
|
||||||
if change.status == "?":
|
if change.status == "?":
|
||||||
run(repo, ["clean", "-f", "--", change.path])
|
run(repo, ["clean", "-f", "--", change.path])
|
||||||
return
|
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
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from . import state
|
from . import render, state
|
||||||
from .commands import refresh_if_changed, restore_view
|
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):
|
class VdiffEventListener(sublime_plugin.EventListener):
|
||||||
def on_pre_close(self, view: sublime.View) -> None:
|
def on_pre_close(self, view: sublime.View) -> None:
|
||||||
|
"""Forget the closing view's state"""
|
||||||
state.drop(view)
|
state.drop(view)
|
||||||
|
|
||||||
def on_activated(self, view: sublime.View) -> None:
|
def on_activated(self, view: sublime.View) -> None:
|
||||||
"""Re-render when the tab comes back and the file or index changed meanwhile (agent edits)"""
|
"""Re-render when the tab comes back and the file, the index or the branches changed meanwhile"""
|
||||||
if state.is_review_view(view):
|
if state.is_review_view(view):
|
||||||
refresh_if_changed(view, state.get(view) or restore_view(view))
|
refresh_if_changed(view, state.get(view) or restore_view(view))
|
||||||
|
|
||||||
|
def on_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:
|
def on_post_save(self, view: sublime.View) -> None:
|
||||||
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
|
"""Review views showing the saved file re-render; the fingerprint decides which ones"""
|
||||||
for view_id, st in state.tracked():
|
for view_id, st in state.tracked():
|
||||||
review = sublime.View(view_id)
|
review = sublime.View(view_id)
|
||||||
if review.is_valid():
|
if review.is_valid():
|
||||||
refresh_if_changed(review, st)
|
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
|
||||||
|
|||||||
283
vdiff/palette.py
283
vdiff/palette.py
@@ -5,7 +5,7 @@ red/green background and the file's syntax colours. This module generates a scop
|
|||||||
kind, colour) pair carrying both, and render paints each run of same-coloured tokens with it.
|
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
|
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.
|
rather than a colour at a time
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -14,18 +14,59 @@ from typing import Any, Dict, List, Optional, Set, Tuple
|
|||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
|
|
||||||
|
from .utils import settings
|
||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
LOG = logging.getLogger("vdiff")
|
||||||
|
|
||||||
# the full-line tint under the token layer, the scheme's own: the token layer repaints the text on
|
# tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text
|
||||||
# top so the tint's foreground never shows, and a scope the scheme defines needs no restart.
|
|
||||||
SCOPE_DEL = "diff.deleted"
|
SCOPE_DEL = "diff.deleted"
|
||||||
SCOPE_INS = "diff.inserted"
|
SCOPE_INS = "diff.inserted"
|
||||||
SCOPE_DEL_CHAR = "diff.deleted.char"
|
SCOPE_DEL_CHAR = "diff.deleted.char"
|
||||||
SCOPE_INS_CHAR = "diff.inserted.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, and the tint each one draws over
|
# background kinds (tinted ones paint changed lines, "ctx" the all-files context rows) and their tint scopes
|
||||||
KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
|
TINT_KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
|
||||||
KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_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)
|
# tints to provision when the active scheme defines none of its own (some third-party schemes)
|
||||||
FALLBACK_TINTS: Dict[str, str] = {
|
FALLBACK_TINTS: Dict[str, str] = {
|
||||||
@@ -35,9 +76,8 @@ FALLBACK_TINTS: Dict[str, str] = {
|
|||||||
SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)",
|
SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)",
|
||||||
}
|
}
|
||||||
|
|
||||||
# scope roots to probe for the scheme's palette: schemes colour by these standard names, so one pass
|
# standard scope roots probed for the palette; no API returns it whole, and parsing the scheme
|
||||||
# yields the working set. No API returns a scheme's palette — style_for_scope takes one scope at a
|
# file would mean re-doing sublime's merging
|
||||||
# time, and reading the file would mean re-implementing sublime's override and .tmTheme merging.
|
|
||||||
PALETTE_PROBES: Tuple[str, ...] = (
|
PALETTE_PROBES: Tuple[str, ...] = (
|
||||||
"source",
|
"source",
|
||||||
"comment",
|
"comment",
|
||||||
@@ -71,8 +111,10 @@ PALETTE_PROBES: Tuple[str, ...] = (
|
|||||||
_palettes: Dict[str, Dict[str, int]] = {}
|
_palettes: Dict[str, Dict[str, int]] = {}
|
||||||
# the override name the last ensure() resolved, so scope_for knows which palette to read
|
# the override name the last ensure() resolved, so scope_for knows which palette to read
|
||||||
_active: Optional[str] = None
|
_active: Optional[str] = None
|
||||||
# schemes the reader has already been told need a restart
|
# (scheme, scopes) settled for this session: the user was told they need a restart, or they were found live
|
||||||
_notified: Set[Optional[str]] = set()
|
_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:
|
def reset() -> None:
|
||||||
@@ -80,26 +122,32 @@ def reset() -> None:
|
|||||||
global _active
|
global _active
|
||||||
_palettes.clear()
|
_palettes.clear()
|
||||||
_notified.clear()
|
_notified.clear()
|
||||||
|
_provisioned_with.clear()
|
||||||
_active = None
|
_active = None
|
||||||
|
|
||||||
|
|
||||||
def claim_restart_notice(view: sublime.View) -> bool:
|
def claim_restart_notice(view: sublime.View, scopes: Tuple[str, ...], tokens: bool = True) -> bool:
|
||||||
"""Whether the reader still has to be told to restart, asked once per scheme
|
"""Whether to tell the user to restart for `scopes` (and the token layer, with `tokens`); True at most once per scheme and set
|
||||||
|
|
||||||
True only when a restart helps — provisioned but not yet loaded; it cannot fix one we never wrote.
|
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
|
||||||
"""
|
"""
|
||||||
if not (_active and _palettes.get(_active)) or tokens_resolved(view) or _active in _notified:
|
key = (_active, scopes)
|
||||||
|
if not (_active and _palettes.get(_active)) or key in _notified:
|
||||||
return False
|
return False
|
||||||
_notified.add(_active)
|
_notified.add(key)
|
||||||
return True
|
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]:
|
def scope_background(view: sublime.View, scope: str) -> Optional[str]:
|
||||||
"""The scheme's background for a scope, or None; style_for_scope can fail on a closing view"""
|
"""The scheme's background for a scope, or None"""
|
||||||
return _style(view, scope).get("background")
|
return _style(view, scope).get("background")
|
||||||
|
|
||||||
|
|
||||||
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
|
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
|
||||||
|
"""The scheme's style for a scope, or {} when style_for_scope fails"""
|
||||||
try:
|
try:
|
||||||
return view.style_for_scope(scope)
|
return view.style_for_scope(scope)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -112,17 +160,24 @@ def foreground(view: sublime.View, scope: str) -> Optional[str]:
|
|||||||
return _style(view, scope).get("foreground")
|
return _style(view, scope).get("foreground")
|
||||||
|
|
||||||
|
|
||||||
def tokens_resolved(view: sublime.View) -> bool:
|
def tokens_resolved(view: sublime.View, kinds: Tuple[str, ...] = TINT_KINDS) -> bool:
|
||||||
"""Whether the generated (tint + colour) scopes are live
|
"""Whether the generated (tint + colour) scopes are live
|
||||||
|
|
||||||
The token layer must never paint with a scope sublime does not know: it would draw that text in
|
The token layer must never paint with a scope sublime does not know: it would draw that text in
|
||||||
sublime's unknown-scope green. A new override loads only on a restart, so the writing session sees false.
|
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 {}
|
colours = (_active and _palettes.get(_active)) or {}
|
||||||
if not colours:
|
if not colours:
|
||||||
return False
|
return False
|
||||||
# they are all written by the same pass into the same file, so any one of them answers for all
|
# a kind added since the override was written loads only after a restart, so check each kind
|
||||||
return bool(scope_background(view, _scope_name(KINDS[0], next(iter(colours.values())))))
|
# 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]:
|
def scope_for(kind: str, colour: str) -> Optional[str]:
|
||||||
@@ -132,7 +187,7 @@ def scope_for(kind: str, colour: str) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def token_key(scope: str) -> str:
|
def token_key(scope: str) -> str:
|
||||||
"""The add_regions key for a generated scope; one naming rule, spelled here only"""
|
"""The add_regions key for a generated scope"""
|
||||||
return scope.replace("vdiff.", "vdiff.tok.", 1)
|
return scope.replace("vdiff.", "vdiff.tok.", 1)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,40 +197,96 @@ def token_keys() -> List[str]:
|
|||||||
return [token_key(_scope_name(kind, i)) for kind in KINDS for i in sorted(colours.values())]
|
return [token_key(_scope_name(kind, i)) for kind in KINDS for i in sorted(colours.values())]
|
||||||
|
|
||||||
|
|
||||||
|
def user_colours() -> Dict[str, str]:
|
||||||
|
"""{setting: colour} for COLOUR_SETTINGS, "" where unset"""
|
||||||
|
return {name: settings().get(name) or "" for name in COLOUR_SETTINGS}
|
||||||
|
|
||||||
|
|
||||||
|
def line_scope(view: sublime.View, kind: str) -> str:
|
||||||
|
"""Tint scope for a changed line, "del" or "ins": the user's colour once its rule is live, else the scheme's"""
|
||||||
|
own, setting, scheme = ((SCOPE_LINE_DEL, "deleted_line_background", SCOPE_DEL) if kind == "del" else
|
||||||
|
(SCOPE_LINE_INS, "inserted_line_background", SCOPE_INS))
|
||||||
|
return own if settings().get(setting) and scope_background(view, own) else scheme
|
||||||
|
|
||||||
|
|
||||||
|
def 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:
|
def _scope_name(kind: str, index: int) -> str:
|
||||||
|
"""Generated scope name for a background kind and palette index"""
|
||||||
return f"vdiff.{kind}.c{index}"
|
return f"vdiff.{kind}.c{index}"
|
||||||
|
|
||||||
|
|
||||||
def ensure(view: sublime.View) -> None:
|
def ensure(view: sublime.View) -> None:
|
||||||
"""Provision the scopes for the active scheme, once per scheme per session
|
"""Provision the scopes for the active scheme, once per scheme per session
|
||||||
|
|
||||||
Whether they have taken effect is tokens_resolved()'s question: a new override loads only on a restart.
|
A new override loads only on a restart; tokens_resolved() says when it is live
|
||||||
"""
|
"""
|
||||||
global _active
|
global _active
|
||||||
name = override_name(view)
|
name = override_name(view)
|
||||||
_active = name
|
_active = name
|
||||||
if not name or name in _palettes:
|
user = user_colours()
|
||||||
|
if not name or (name in _palettes and _provisioned_with.get(name) == user):
|
||||||
return
|
return
|
||||||
|
_provisioned_with[name] = user
|
||||||
|
# merge into the existing override, if any
|
||||||
path = os.path.join(sublime.packages_path(), "User", name)
|
path = os.path.join(sublime.packages_path(), "User", name)
|
||||||
try:
|
try:
|
||||||
scheme: Dict[str, Any] = {}
|
scheme: Dict[str, Any] = {}
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
# a hand-edited scheme may hold comments decode_value rejects: the raise leaves the
|
# invalid JSON raises here, leaving the file untouched; render still shows the changed lines
|
||||||
# file untouched, and render still shows the changed lines
|
|
||||||
scheme = sublime.decode_value(f.read()) or {}
|
scheme = sublime.decode_value(f.read()) or {}
|
||||||
rules = scheme.setdefault("rules", [])
|
rules = scheme.setdefault("rules", [])
|
||||||
tints = _tints(view)
|
# 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))}
|
colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
|
||||||
_palettes[name] = colours
|
_palettes[name] = colours
|
||||||
if not _merge_rules(rules, tints, colours, view):
|
if not _merge_rules(rules, tints, colours, view, user):
|
||||||
return
|
return
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(scheme, f, indent=4)
|
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)
|
LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("could not write colour scheme override %s", path, exc_info=True)
|
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, {})
|
_palettes.setdefault(name, {})
|
||||||
|
|
||||||
|
|
||||||
@@ -189,10 +300,11 @@ def _probe_palette(view: sublime.View) -> List[str]:
|
|||||||
return seen
|
return seen
|
||||||
|
|
||||||
|
|
||||||
def _tints(view: sublime.View) -> Dict[str, str]:
|
def _tints(view: sublime.View, user: Dict[str, str]) -> Dict[str, str]:
|
||||||
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
|
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
|
||||||
|
|
||||||
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own.
|
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own.
|
||||||
|
A line colour the user set wins over both
|
||||||
"""
|
"""
|
||||||
tints: Dict[str, str] = {}
|
tints: Dict[str, str] = {}
|
||||||
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)):
|
for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)):
|
||||||
@@ -200,36 +312,108 @@ def _tints(view: sublime.View) -> Dict[str, str]:
|
|||||||
char = scope_background(view, char_scope)
|
char = scope_background(view, char_scope)
|
||||||
tints[line_scope] = line if line and char else FALLBACK_TINTS[line_scope]
|
tints[line_scope] = line if line and char else FALLBACK_TINTS[line_scope]
|
||||||
tints[char_scope] = char if line and char else FALLBACK_TINTS[char_scope]
|
tints[char_scope] = char if line and char else FALLBACK_TINTS[char_scope]
|
||||||
|
for scope, setting in ((SCOPE_DEL, "deleted_line_background"), (SCOPE_INS, "inserted_line_background")):
|
||||||
|
if user[setting]:
|
||||||
|
tints[scope] = user[setting]
|
||||||
|
# a region whose background is missing, transparent or equal to the editor's is filled with its
|
||||||
|
# foreground, so context colours carry the editor background nudged by an invisible step
|
||||||
|
tints[SCOPE_CTX] = _nudge(_global_background(view))
|
||||||
return tints
|
return tints
|
||||||
|
|
||||||
|
|
||||||
def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
|
def _global(view: sublime.View, key: str) -> Optional[str]:
|
||||||
view: sublime.View) -> bool:
|
"""One of the scheme's global colours (background, line_highlight, ...), None when unset or unreadable"""
|
||||||
"""Bring the scheme's rules up to date, in place; True when anything changed
|
try:
|
||||||
|
return view.style().get(key)
|
||||||
|
except Exception:
|
||||||
|
LOG.debug("view.style failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
add_regions draws text in the region scope's own foreground, so each (kind, colour) pair needs a
|
|
||||||
rule carrying both; a background alone would flatten the line. Plain tints are written only when
|
def _global_background(view: sublime.View) -> str:
|
||||||
the scheme has none, and never overwritten, so a hand-tuned one survives.
|
"""The scheme's editor background; style_for_scope reports none for scopes that do not set one"""
|
||||||
"""
|
return _global(view, "background") or "#000000"
|
||||||
present = {rule.get("scope"): rule for rule in rules}
|
|
||||||
changed = False
|
|
||||||
for scope, background in tints.items():
|
def _line_highlight(view: sublime.View) -> str:
|
||||||
if scope not in present and not scope_background(view, scope):
|
"""The scheme's line-highlight colour, else the editor background blended a little towards the foreground"""
|
||||||
rules.append({"scope": scope, "background": background})
|
return _global(view, "line_highlight") or _blend(_global_background(view), foreground(view, "text.plain") or "#ffffff", CURSOR_BLEND)
|
||||||
changed = True
|
|
||||||
for colour, index in colours.items():
|
|
||||||
for kind in KINDS:
|
def _blend(colour: str, towards: str, fraction: float) -> str:
|
||||||
wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]],
|
"""#rrggbb moved `fraction` of the way towards `towards`; a colour in another form is returned as it is"""
|
||||||
"foreground": colour}
|
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"])
|
rule = present.get(wanted["scope"])
|
||||||
if rule is None:
|
if rule is None:
|
||||||
rules.append(wanted)
|
rules.append(wanted)
|
||||||
changed = True
|
|
||||||
elif rule != wanted:
|
elif rule != wanted:
|
||||||
# the palette shifted under a rule an earlier session wrote; ours to keep correct
|
|
||||||
rule.clear()
|
rule.clear()
|
||||||
rule.update(wanted)
|
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
|
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
|
return changed
|
||||||
|
|
||||||
|
|
||||||
@@ -240,6 +424,7 @@ def override_name(view: sublime.View) -> Optional[str]:
|
|||||||
name = sublime.ui_info()["color_scheme"]["resolved_value"]
|
name = sublime.ui_info()["color_scheme"]["resolved_value"]
|
||||||
except Exception:
|
except Exception:
|
||||||
name = view.settings().get("color_scheme") or ""
|
name = view.settings().get("color_scheme") or ""
|
||||||
|
# no usable scheme name: nothing to override
|
||||||
if not name or name == "auto":
|
if not name or name == "auto":
|
||||||
return None
|
return None
|
||||||
root, _ = os.path.splitext(os.path.basename(name))
|
root, _ = os.path.splitext(os.path.basename(name))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
""" 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 difflib
|
||||||
import re
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
@@ -7,7 +7,7 @@ from typing import Callable, Iterator, List, NamedTuple, Optional, Sequence, Tup
|
|||||||
|
|
||||||
|
|
||||||
class Line(NamedTuple):
|
class Line(NamedTuple):
|
||||||
"""kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None"""
|
"""One diff row: kind " " (context), "-" (deleted) or "+" (inserted); the missing line number is None"""
|
||||||
|
|
||||||
kind: str
|
kind: str
|
||||||
old_no: Optional[int]
|
old_no: Optional[int]
|
||||||
@@ -16,6 +16,8 @@ class Line(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class Diff(NamedTuple):
|
class Diff(NamedTuple):
|
||||||
|
"""A parsed unified diff: its rows, and whether git reported the file as binary"""
|
||||||
|
|
||||||
lines: List[Line]
|
lines: List[Line]
|
||||||
binary: bool
|
binary: bool
|
||||||
|
|
||||||
@@ -23,12 +25,12 @@ class Diff(NamedTuple):
|
|||||||
# the value `runs` groups by, so a caller gets its own key type back rather than `object`
|
# the value `runs` groups by, so a caller gets its own key type back rather than `object`
|
||||||
Key = TypeVar("Key")
|
Key = TypeVar("Key")
|
||||||
|
|
||||||
# an (start, end) index range of consecutive changed lines
|
# a half-open (start, end) index range of consecutive changed lines
|
||||||
Block = Tuple[int, int]
|
Block = Tuple[int, int]
|
||||||
# a (start, end) character range inside one line's text
|
# a (start, end) character range inside one line's text
|
||||||
CharRange = Tuple[int, int]
|
CharRange = Tuple[int, int]
|
||||||
|
|
||||||
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
|
# 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_MIN_RATIO = 0.5
|
||||||
CHAR_DIFF_MAX_CHARS = 2000
|
CHAR_DIFF_MAX_CHARS = 2000
|
||||||
|
|
||||||
@@ -42,11 +44,13 @@ def parse_unified(text: str) -> Diff:
|
|||||||
old_no = new_no = 0
|
old_no = new_no = 0
|
||||||
in_hunk = False
|
in_hunk = False
|
||||||
for raw in text.split("\n"):
|
for raw in text.split("\n"):
|
||||||
|
# a hunk header resets both line counters
|
||||||
if raw.startswith("@@"):
|
if raw.startswith("@@"):
|
||||||
match = _HUNK.match(raw)
|
match = _HUNK.match(raw)
|
||||||
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
|
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
|
||||||
in_hunk = True
|
in_hunk = True
|
||||||
continue
|
continue
|
||||||
|
# before the first hunk only headers: watch for a binary marker
|
||||||
if not in_hunk:
|
if not in_hunk:
|
||||||
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
|
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
|
||||||
binary = True
|
binary = True
|
||||||
@@ -54,6 +58,7 @@ def parse_unified(text: str) -> Diff:
|
|||||||
if raw == "":
|
if raw == "":
|
||||||
# only the trailing split remainder; an empty context line is " "
|
# only the trailing split remainder; an empty context line is " "
|
||||||
continue
|
continue
|
||||||
|
# hunk body: the first character is the line kind
|
||||||
marker, body = raw[0], raw[1:]
|
marker, body = raw[0], raw[1:]
|
||||||
if marker == " ":
|
if marker == " ":
|
||||||
lines.append(Line(" ", old_no, new_no, body))
|
lines.append(Line(" ", old_no, new_no, body))
|
||||||
@@ -80,7 +85,7 @@ def runs(items: Sequence[Line], key: Callable[[Line], Key]) -> Iterator[Tuple[Ke
|
|||||||
|
|
||||||
|
|
||||||
def change_blocks(lines: Sequence[Line]) -> List[Block]:
|
def change_blocks(lines: Sequence[Line]) -> List[Block]:
|
||||||
"""[(start, end)) index ranges of consecutive changed lines"""
|
"""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]
|
return [(start, end) for changed, start, end in runs(lines, lambda line: line.kind != " ") if changed]
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ def counts(lines: Sequence[Line]) -> Tuple[int, int]:
|
|||||||
|
|
||||||
|
|
||||||
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
|
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
|
||||||
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
|
"""(deleted, inserted) index pairs of a block; [] unless its - and + counts match"""
|
||||||
start, end = block
|
start, end = block
|
||||||
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
|
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
|
||||||
inserted = [i for i in range(start, end) if lines[i].kind == "+"]
|
inserted = [i for i in range(start, end) if lines[i].kind == "+"]
|
||||||
@@ -107,6 +112,7 @@ def char_ranges(old: str, new: str) -> Tuple[List[CharRange], List[CharRange]]:
|
|||||||
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
|
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
|
||||||
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
|
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
|
||||||
return [], []
|
return [], []
|
||||||
|
# each opcode span becomes a range on whichever side it touches
|
||||||
old_ranges: List[CharRange] = []
|
old_ranges: List[CharRange] = []
|
||||||
new_ranges: List[CharRange] = []
|
new_ranges: List[CharRange] = []
|
||||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
@@ -121,6 +127,7 @@ def line_for_open(lines: Sequence[Line], index: int) -> int:
|
|||||||
"""New-file line number to open for a row: a deleted row maps to the next surviving line"""
|
"""New-file line number to open for a row: a deleted row maps to the next surviving line"""
|
||||||
if not lines:
|
if not lines:
|
||||||
return 1
|
return 1
|
||||||
|
# the first numbered line at or after the row, else the nearest one before it
|
||||||
index = max(0, min(index, len(lines) - 1))
|
index = max(0, min(index, len(lines) - 1))
|
||||||
for line in lines[index:]:
|
for line in lines[index:]:
|
||||||
if line.new_no is not None:
|
if line.new_no is not None:
|
||||||
|
|||||||
603
vdiff/render.py
603
vdiff/render.py
@@ -1,17 +1,17 @@
|
|||||||
""" Draw a parsed diff into a review view: the file text, coloured line regions, gutter marks, header phantom """
|
"""Draw a parsed diff into a review view: file text, line tints, gutter marks, header phantom"""
|
||||||
import bisect
|
import bisect
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple
|
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
|
||||||
from . import palette, parse
|
from . import git, palette, parse
|
||||||
from .git import FileChange, STATUS_NAMES
|
from .git import FileChange, STATUS_NAMES
|
||||||
from .parse import Block, Line
|
from .parse import Block, Line
|
||||||
from .state import VIEW_FLAG, ViewState
|
from .state import MODE_BRANCHES, MODE_DIFF, MODE_SETTING, VIEW_FLAG, ViewState
|
||||||
from .utils import file_count, settings, status
|
from .utils import file_count, settings, status
|
||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
LOG = logging.getLogger("vdiff")
|
||||||
@@ -22,19 +22,58 @@ KEY_DEL_GUTTER = "vdiff.del.gutter"
|
|||||||
KEY_INS_GUTTER = "vdiff.ins.gutter"
|
KEY_INS_GUTTER = "vdiff.ins.gutter"
|
||||||
KEY_DEL_CHAR = "vdiff.del.char"
|
KEY_DEL_CHAR = "vdiff.del.char"
|
||||||
KEY_INS_CHAR = "vdiff.ins.char"
|
KEY_INS_CHAR = "vdiff.ins.char"
|
||||||
REGION_KEYS: Tuple[str, ...] = (KEY_DEL, KEY_INS, KEY_DEL_GUTTER, KEY_INS_GUTTER, KEY_DEL_CHAR, KEY_INS_CHAR)
|
KEY_FILE_HEADER = "vdiff.file.header"
|
||||||
|
KEY_FILE_DEL_COUNT = palette.SCOPE_FILE_DEL_COUNT
|
||||||
|
KEY_FILE_INS_COUNT = palette.SCOPE_FILE_INS_COUNT
|
||||||
|
KEY_FILE_STATUS = palette.SCOPE_FILE_STATUS
|
||||||
|
KEY_FILE_PREFIX = "vdiff.file.prefix"
|
||||||
|
KEY_BRANCH_SEL = "vdiff.branch.sel"
|
||||||
|
KEY_BRANCH_DEL = "vdiff.branch.del"
|
||||||
|
KEY_BRANCH_INS = "vdiff.branch.ins"
|
||||||
|
KEY_BRANCH_STANDING_DEL = "vdiff.branch.standing.del"
|
||||||
|
KEY_BRANCH_STANDING_INS = "vdiff.branch.standing.ins"
|
||||||
|
KEY_BRANCH_SEL_DEL = "vdiff.branch.sel.del"
|
||||||
|
KEY_BRANCH_SEL_INS = "vdiff.branch.sel.ins"
|
||||||
|
KEY_BRANCH_CURSOR = "vdiff.branch.cursor"
|
||||||
|
KEY_BRANCH_CURSOR_DEL = "vdiff.branch.cursor.del"
|
||||||
|
KEY_BRANCH_CURSOR_INS = "vdiff.branch.cursor.ins"
|
||||||
|
REGION_KEYS = (
|
||||||
|
KEY_DEL,
|
||||||
|
KEY_INS,
|
||||||
|
KEY_DEL_GUTTER,
|
||||||
|
KEY_INS_GUTTER,
|
||||||
|
KEY_DEL_CHAR,
|
||||||
|
KEY_INS_CHAR,
|
||||||
|
KEY_FILE_HEADER,
|
||||||
|
KEY_FILE_DEL_COUNT,
|
||||||
|
KEY_FILE_INS_COUNT,
|
||||||
|
KEY_FILE_STATUS,
|
||||||
|
KEY_FILE_PREFIX,
|
||||||
|
KEY_BRANCH_SEL,
|
||||||
|
KEY_BRANCH_DEL,
|
||||||
|
KEY_BRANCH_INS,
|
||||||
|
KEY_BRANCH_STANDING_DEL,
|
||||||
|
KEY_BRANCH_STANDING_INS,
|
||||||
|
KEY_BRANCH_SEL_DEL,
|
||||||
|
KEY_BRANCH_SEL_INS,
|
||||||
|
KEY_BRANCH_CURSOR,
|
||||||
|
KEY_BRANCH_CURSOR_DEL,
|
||||||
|
KEY_BRANCH_CURSOR_INS,
|
||||||
|
)
|
||||||
|
|
||||||
PHANTOM_KEY = "vdiff.header"
|
PHANTOM_KEY = "vdiff.header"
|
||||||
STATUS_KEY = "vdiff"
|
STATUS_KEY = "vdiff"
|
||||||
|
|
||||||
# visual bell: a whole-buffer tint on a timer. Not in REGION_KEYS, so a wrap's re-render cannot wipe it early.
|
# visual bell: a timed whole-buffer tint, kept out of REGION_KEYS so a re-render cannot erase it early
|
||||||
BELL_KEY = "vdiff.bell"
|
BELL_KEY = "vdiff.bell"
|
||||||
BELL_MS = 90
|
BELL_MS = 90
|
||||||
|
|
||||||
# above this much changed text the colours cost more than they are worth: such a diff is scrolled, not read
|
# above this much changed text the colours cost more than they are worth: such a diff is scrolled, not read
|
||||||
TOKEN_COLOUR_MAX_CHARS = 200000
|
TOKEN_COLOUR_MAX_CHARS = 200000
|
||||||
|
# the hidden output panel the all-files view tokenises each file in
|
||||||
|
TOKEN_PANEL = "vdiff_tokens"
|
||||||
|
|
||||||
# a block phantom at point 0 draws below row 0, so the file starts at row 1; only index_point / caret_index know
|
# a block phantom at point 0 draws below row 0, so file text starts at row 1
|
||||||
_HEADER_ROWS = 1
|
_HEADER_ROWS = 1
|
||||||
|
|
||||||
# gutter dots use the scope's foreground
|
# gutter dots use the scope's foreground
|
||||||
@@ -42,12 +81,14 @@ SCOPE_DEL_MARK = "markup.deleted"
|
|||||||
SCOPE_INS_MARK = "markup.inserted"
|
SCOPE_INS_MARK = "markup.inserted"
|
||||||
# ST4 defines region.<colour>ish in every bundled scheme for exactly this kind of transient marking
|
# ST4 defines region.<colour>ish in every bundled scheme for exactly this kind of transient marking
|
||||||
SCOPE_BELL = "region.yellowish"
|
SCOPE_BELL = "region.yellowish"
|
||||||
|
SCOPE_FILE_HEADER = palette.SCOPE_FILE_HEADER
|
||||||
|
|
||||||
# sublime only loads an override it has not seen before on a restart
|
# sublime loads a newly created scheme override only after a restart
|
||||||
NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines"
|
NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines"
|
||||||
|
NEEDS_RESTART_BRANCHES = "restart Sublime Text to finish enabling the colours of the branches view"
|
||||||
|
|
||||||
# actions: the ACTIONS names the row covers, a single one making it a link. short: status-bar
|
|
||||||
# wording, "" keeps the row out. The group flag is False for groups needing a working-tree file.
|
# actions: the ACTIONS names the row runs, a link when there is one; short: status-bar label, "" omits it
|
||||||
class Key(NamedTuple):
|
class Key(NamedTuple):
|
||||||
"""One row of the status-bar hints and the ? popup"""
|
"""One row of the status-bar hints and the ? popup"""
|
||||||
|
|
||||||
@@ -57,7 +98,7 @@ class Key(NamedTuple):
|
|||||||
actions: Tuple[str, ...]
|
actions: Tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
# (group title, applies in a commit, rows)
|
# (group title, applies in a commit (False: needs a working-tree file), rows)
|
||||||
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
||||||
(
|
(
|
||||||
"Navigate",
|
"Navigate",
|
||||||
@@ -69,20 +110,12 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
|||||||
Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
|
Key("j k", "down / up one line (vim)", "", ("line_down", "line_up")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"Panels",
|
|
||||||
True,
|
|
||||||
[
|
|
||||||
Key("f", "changed files", "files", ("files",)),
|
|
||||||
Key("c", "recent commits", "commits", ("commits",)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"Actions",
|
"Actions",
|
||||||
False,
|
False,
|
||||||
[
|
[
|
||||||
Key("s", "stage", "stage", ("stage",)),
|
Key("s / S", "stage / stage all", "stage", ("stage", "stage_all")),
|
||||||
Key("u", "unstage", "unstage", ("unstage",)),
|
Key("u / U", "unstage / unstage all", "unstage", ("unstage", "unstage_all")),
|
||||||
Key("d", "discard", "discard", ("discard",)),
|
Key("d", "discard", "discard", ("discard",)),
|
||||||
Key("C", "commit the staged files", "commit", ("commit",)),
|
Key("C", "commit the staged files", "commit", ("commit",)),
|
||||||
Key("m", "amend the last commit", "amend", ("amend",)),
|
Key("m", "amend the last commit", "amend", ("amend",)),
|
||||||
@@ -93,18 +126,70 @@ KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
|||||||
True,
|
True,
|
||||||
[
|
[
|
||||||
Key("r", "refresh", "refresh", ("refresh",)),
|
Key("r", "refresh", "refresh", ("refresh",)),
|
||||||
Key("?", "this list", "keys", ("help",)),
|
Key("f", "changed files", "files", ("files",)),
|
||||||
|
Key("c", "recent commits", "commits", ("commits",)),
|
||||||
|
Key("b", "branches", "branches", ("branches",)),
|
||||||
|
Key("v", "toggle all-files view", "view", ("view",)),
|
||||||
|
Key("left right", "collapse / expand file", "fold", ("collapse", "expand")),
|
||||||
|
Key("z", "collapse / expand all files", "", ("fold_all",)),
|
||||||
Key("esc", "close the view", "", ("close",)),
|
Key("esc", "close the view", "", ("close",)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
KEYS: List[Key] = [key for _, _, group in KEY_GROUPS for key in group]
|
# the keys of the branches view; the "applies in a commit" flag has no meaning there
|
||||||
|
BRANCH_KEY_GROUPS: List[Tuple[str, bool, List[Key]]] = [
|
||||||
|
(
|
||||||
|
"Navigate",
|
||||||
|
True,
|
||||||
|
[
|
||||||
|
Key("J K", "next / previous branch", "branch", ("next_branch", "prev_branch")),
|
||||||
|
Key("j k", "next / previous commit", "commit", ("next_commit", "prev_commit")),
|
||||||
|
Key("o v", "review the commit, or the changed files", "review", ("open_commit",)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Actions",
|
||||||
|
True,
|
||||||
|
[
|
||||||
|
Key("⏎", "checkout remote branch", "checkout", ("checkout",)),
|
||||||
|
Key("shift+⏎", "checkout a remote branch detached", "", ("checkout_detached",)),
|
||||||
|
Key("L", "pull", "pull", ("pull",)),
|
||||||
|
Key("P", "push, setting the upstream when missing", "push", ("push",)),
|
||||||
|
Key("F", "fetch all remotes and prune", "fetch", ("fetch",)),
|
||||||
|
Key("d", "delete the local branch", "delete", ("delete_branch",)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"View",
|
||||||
|
True,
|
||||||
|
[
|
||||||
|
Key("f", "changed files", "files", ("files",)),
|
||||||
|
Key("c", "recent commits", "commits", ("commits",)),
|
||||||
|
Key("b", "reload the branches", "", ("branches",)),
|
||||||
|
Key("r", "refresh", "refresh", ("refresh",)),
|
||||||
|
Key("esc", "close the view", "", ("close",)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
KEY_HINTS = " ".join(f"{k.keys} {k.short}" for k in KEYS if k.short)
|
MODE_KEY_GROUPS: Dict[str, List[Tuple[str, bool, List[Key]]]] = {MODE_DIFF: KEY_GROUPS, MODE_BRANCHES: BRANCH_KEY_GROUPS}
|
||||||
|
|
||||||
|
|
||||||
|
def keys(mode: str = MODE_DIFF) -> List[Key]:
|
||||||
|
"""Every key row of a mode's table"""
|
||||||
|
return [key for _, _, group in MODE_KEY_GROUPS[mode] for key in group]
|
||||||
|
|
||||||
|
|
||||||
|
def key_hints(mode: str = MODE_DIFF) -> str:
|
||||||
|
"""The status-bar line of a mode: its keys with a short label"""
|
||||||
|
return " ".join(f"{k.keys} {k.short}" for k in keys(mode) if k.short)
|
||||||
|
|
||||||
|
|
||||||
|
def _key_width(mode: str) -> int:
|
||||||
|
"""The popup font is the editor's, so the key column is padded to a fixed width in characters"""
|
||||||
|
return max(len(k.keys) for k in keys(mode))
|
||||||
|
|
||||||
# the popup font is the editor's, so the key column is padded to a fixed width in characters
|
|
||||||
_KEY_WIDTH = max(len(k.keys) for k in KEYS)
|
|
||||||
|
|
||||||
HEADER_CSS = """
|
HEADER_CSS = """
|
||||||
body { margin: 0; padding: 0.5rem 0.8rem; background-color: color(var(--background) blend(var(--foreground) 93%)); }
|
body { margin: 0; padding: 0.5rem 0.8rem; background-color: color(var(--background) blend(var(--foreground) 93%)); }
|
||||||
@@ -123,9 +208,8 @@ a { text-decoration: none; padding: 0.15rem 0.5rem; margin-left: 0.3rem; border-
|
|||||||
.body { color: color(var(--foreground) alpha(0.6)); }
|
.body { color: color(var(--foreground) alpha(0.6)); }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# minihtml drops the rest of a stylesheet at a selector it cannot parse, and documents properties
|
# minihtml drops the rest of a stylesheet at a selector it cannot parse, so each rule uses one bare element or class
|
||||||
# only, not selectors: every rule sticks to one bare element or class, the forms HEADER_CSS proves.
|
# .group's top margin replaces body's top padding
|
||||||
# The first group's margin stands in for body's top padding; .kdim takes its colour from .dim.
|
|
||||||
HELP_CSS = """
|
HELP_CSS = """
|
||||||
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
||||||
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
|
.group { margin-top: 0.6rem; color: var(--bluish); font-weight: bold; }
|
||||||
@@ -133,7 +217,6 @@ body { margin: 0; padding: 0 0.8rem 0.6rem 0.8rem; }
|
|||||||
.row { color: color(var(--foreground) alpha(0.85)); }
|
.row { color: color(var(--foreground) alpha(0.85)); }
|
||||||
.dim { color: color(var(--foreground) alpha(0.35)); }
|
.dim { color: color(var(--foreground) alpha(0.35)); }
|
||||||
.k { color: var(--orangish); font-weight: bold; }
|
.k { color: var(--orangish); font-weight: bold; }
|
||||||
.kdim { font-weight: bold; }
|
|
||||||
a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
|
a { text-decoration: none; color: color(var(--foreground) alpha(0.85)); }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -142,13 +225,26 @@ class VdiffReplaceCommand(sublime_plugin.TextCommand):
|
|||||||
"""Replace the whole buffer; the only writer of a review view"""
|
"""Replace the whole buffer; the only writer of a review view"""
|
||||||
|
|
||||||
def run(self, edit: sublime.Edit, text: str) -> None:
|
def run(self, edit: sublime.Edit, text: str) -> None:
|
||||||
|
"""Replace the buffer"""
|
||||||
self.view.replace(edit, sublime.Region(0, self.view.size()), text)
|
self.view.replace(edit, sublime.Region(0, self.view.size()), text)
|
||||||
|
|
||||||
|
|
||||||
def init_view(view: sublime.View) -> None:
|
def _clear_external_diagnostics(view: sublime.View) -> None:
|
||||||
|
"""Erase LSP diagnostic regions painted before the view became a widget"""
|
||||||
|
for prefix in ("lspLSP-pyright", "lspLSP-pylsp", "lspLSP"):
|
||||||
|
for severity in range(1, 5):
|
||||||
|
for mode in ("s", "m"):
|
||||||
|
key = f"{prefix}d{mode}{severity}"
|
||||||
|
for suffix in ("", "_underline", "_icon"):
|
||||||
|
view.erase_regions(key + suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def init_view(view: sublime.View, mode: str = MODE_DIFF) -> None:
|
||||||
"""Settings a review view needs, applied once when it is created or restored"""
|
"""Settings a review view needs, applied once when it is created or restored"""
|
||||||
s = view.settings()
|
s = view.settings()
|
||||||
|
# mark the view as vdiff's, say which keys apply, and turn off editor chrome that does not fit a diff
|
||||||
s.set(VIEW_FLAG, True)
|
s.set(VIEW_FLAG, True)
|
||||||
|
s.set(MODE_SETTING, mode)
|
||||||
# native line numbers would be wrong: deleted lines have none
|
# native line numbers would be wrong: deleted lines have none
|
||||||
s.set("line_numbers", False)
|
s.set("line_numbers", False)
|
||||||
s.set("word_wrap", False)
|
s.set("word_wrap", False)
|
||||||
@@ -158,17 +254,25 @@ def init_view(view: sublime.View) -> None:
|
|||||||
s.set("detect_indentation", False)
|
s.set("detect_indentation", False)
|
||||||
s.set("translate_tabs_to_spaces", False)
|
s.set("translate_tabs_to_spaces", False)
|
||||||
s.set("show_definitions", False)
|
s.set("show_definitions", False)
|
||||||
|
# LSP ignores widget views; is_scratch alone does not stop every LSP version
|
||||||
|
s.set("is_widget", True)
|
||||||
s.set("mini_diff", False)
|
s.set("mini_diff", False)
|
||||||
s.set("spell_check", False)
|
s.set("spell_check", False)
|
||||||
|
# keep linters and LSP off this synthetic buffer, whichever of these settings they read
|
||||||
|
s.set("syntax_checking", False)
|
||||||
|
s.set("sublime_linter_disable", True)
|
||||||
|
s.set("lsp_diagnostics", False)
|
||||||
|
_clear_external_diagnostics(view)
|
||||||
# a read-only viewer, so vim's insert mode is meaningless: command mode, block caret. vdiff's
|
# a read-only viewer, so vim's insert mode is meaningless: command mode, block caret. vdiff's
|
||||||
# own keys carry no command_mode context, so they fire in either mode regardless.
|
# own keys carry no command_mode context, so they fire in either mode
|
||||||
s.set("command_mode", True)
|
s.set("command_mode", True)
|
||||||
s.set("inverse_caret_state", True)
|
s.set("inverse_caret_state", True)
|
||||||
|
# a scratch, read-only buffer with the key hints in the status bar
|
||||||
view.set_scratch(True)
|
view.set_scratch(True)
|
||||||
view.set_read_only(True)
|
view.set_read_only(True)
|
||||||
if not view.name():
|
if not view.name():
|
||||||
view.set_name("vDiff")
|
view.set_name("vDiff")
|
||||||
view.set_status(STATUS_KEY, KEY_HINTS)
|
view.set_status(STATUS_KEY, key_hints(mode))
|
||||||
|
|
||||||
|
|
||||||
def index_point(view: sublime.View, index: int, col: int = 0) -> int:
|
def index_point(view: sublime.View, index: int, col: int = 0) -> int:
|
||||||
@@ -183,29 +287,40 @@ def caret_index(view: sublime.View) -> int:
|
|||||||
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
|
return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float]]]:
|
def fingerprint(st: ViewState) -> Optional[Tuple[Any, ...]]:
|
||||||
"""Cheap "did anything change" key for working-tree mode: mtimes of the file and of the git index"""
|
"""Cheap "did anything change" key of a working-tree diff: mtimes of the shown files and of the git index;
|
||||||
|
None for a commit or nothing shown (the branches view keeps its own, see branches.fingerprint)"""
|
||||||
if st.in_commit or st.change is None:
|
if st.in_commit or st.change is None:
|
||||||
return None
|
return None
|
||||||
return (_mtime(os.path.join(st.repo, st.change.path)), _mtime(os.path.join(st.repo, ".git", "index")))
|
return worktree_fingerprint(st.repo, st.files if st.all_mode else [st.change])
|
||||||
|
|
||||||
|
|
||||||
def _mtime(path: str) -> Optional[float]:
|
def worktree_fingerprint(repo: str, files: Sequence[FileChange]) -> Tuple[Optional[float], ...]:
|
||||||
try:
|
"""mtimes of `files` and of the index they are staged in"""
|
||||||
return os.stat(path).st_mtime
|
index = git.index_path(repo)
|
||||||
except OSError:
|
return tuple(git.mtime(os.path.join(repo, c.path)) for c in files) + (git.mtime(index) if index else None,)
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def flash(view: sublime.View) -> None:
|
def flash(view: sublime.View) -> None:
|
||||||
"""Visual bell: tint the buffer for a moment, for feedback that has nothing to say in words"""
|
"""Visual bell: briefly tint the whole buffer"""
|
||||||
view.add_regions(BELL_KEY, [sublime.Region(0, view.size())], _bell_scope(view), flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(BELL_KEY, [sublime.Region(0, view.size())], _bell_scope(view), flags=sublime.DRAW_NO_OUTLINE)
|
||||||
# unconditional erase: overlapping flashes may cut one short, but none can leave the tint stuck
|
# unconditional erase: overlapping flashes may cut one short, but none can leave the tint stuck
|
||||||
sublime.set_timeout(lambda: view.is_valid() and view.erase_regions(BELL_KEY), BELL_MS)
|
sublime.set_timeout(lambda: view.is_valid() and view.erase_regions(BELL_KEY), BELL_MS)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_header_scope(view: sublime.View) -> str:
|
||||||
|
"""Heading tint scope: vdiff's own, else the first fallback the scheme gives a background"""
|
||||||
|
# vdiff's own scope, once the generated scheme override has loaded
|
||||||
|
if palette.scope_background(view, SCOPE_FILE_HEADER):
|
||||||
|
return SCOPE_FILE_HEADER
|
||||||
|
for scope in ("region.blueish", "region.cyanish", SCOPE_BELL, "diff.inserted"):
|
||||||
|
if palette.scope_background(view, scope):
|
||||||
|
return scope
|
||||||
|
return SCOPE_FILE_HEADER
|
||||||
|
|
||||||
|
|
||||||
def _bell_scope(view: sublime.View) -> str:
|
def _bell_scope(view: sublime.View) -> str:
|
||||||
"""region.yellowish, or a scope with a background we know of when a scheme lacks it"""
|
"""region.yellowish, or the insert tint when the scheme lacks it"""
|
||||||
if palette.scope_background(view, SCOPE_BELL):
|
if palette.scope_background(view, SCOPE_BELL):
|
||||||
return SCOPE_BELL
|
return SCOPE_BELL
|
||||||
# an invisible bell is a silent failure, so borrow the insert tint
|
# an invisible bell is a silent failure, so borrow the insert tint
|
||||||
@@ -220,7 +335,248 @@ def render_message(view: sublime.View, st: ViewState, message: str) -> None:
|
|||||||
st.binary = False
|
st.binary = False
|
||||||
_replace(view, message)
|
_replace(view, message)
|
||||||
view.assign_syntax("scope:text.plain")
|
view.assign_syntax("scope:text.plain")
|
||||||
_finish(view, st, 0, 0, "vDiff")
|
_finish(view, st, MODE_DIFF, "vDiff", header_html(st, 0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_context(lines: Sequence[Line], context: int) -> List[Line]:
|
||||||
|
"""Changed lines plus up to `context` lines either side of each change"""
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
keep = set()
|
||||||
|
for start, end in parse.change_blocks(lines):
|
||||||
|
keep.update(range(max(0, start - context), min(len(lines), end + context)))
|
||||||
|
return [line for i, line in enumerate(lines) if i in keep]
|
||||||
|
|
||||||
|
|
||||||
|
def _all_line_text(line: Line) -> str:
|
||||||
|
"""One all-files diff row: old and new line numbers, then the line's text"""
|
||||||
|
old = f"{line.old_no:>4}" if line.old_no is not None else " "
|
||||||
|
new = f"{line.new_no:>4}" if line.new_no is not None else " "
|
||||||
|
return f"{old} {new} {line.text}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_all(view: sublime.View, st: ViewState, focus: Union[None, str, Tuple[str, int]] = None, center: bool = False) -> None:
|
||||||
|
"""Draw every file, with a compact collapsible file heading before its diff.
|
||||||
|
|
||||||
|
Without `focus` the caret goes to the top. A path puts it on that file's heading, a (path, hunk)
|
||||||
|
target on that change's first row; the scroll is kept unless the caret has left the screen, or
|
||||||
|
the caret is centred with `center`
|
||||||
|
"""
|
||||||
|
# keep the scroll only for a focused redraw; reset the rows and change-navigation tables
|
||||||
|
viewport = view.viewport_position() if focus is not None else (0, 0)
|
||||||
|
heading = 0
|
||||||
|
rows: List[str] = []
|
||||||
|
metadata: List[Tuple[Optional[FileChange], Optional[Line], bool]] = []
|
||||||
|
st.all_rows = metadata
|
||||||
|
st.all_change_rows = []
|
||||||
|
st.all_change_targets = []
|
||||||
|
st.all_change_row_targets = []
|
||||||
|
# build the rows: a blank line and heading per file, then its trimmed diff unless folded
|
||||||
|
for change in st.files:
|
||||||
|
rows.append("")
|
||||||
|
metadata.append((None, None, False))
|
||||||
|
lines, _, binary = st.all_diffs.get(change.path, ([], [], False))
|
||||||
|
context = settings().get("all_files_context", 5)
|
||||||
|
trimmed = _trim_context(lines, max(0, int(context)) if context is not None else 5)
|
||||||
|
# number hunks even when folded, so navigation can unfold to one
|
||||||
|
hunk_index = 0
|
||||||
|
in_change = False
|
||||||
|
for line in trimmed:
|
||||||
|
changed = line.kind != " "
|
||||||
|
if changed and not in_change:
|
||||||
|
st.all_change_targets.append((change.path, hunk_index))
|
||||||
|
hunk_index += 1
|
||||||
|
in_change = changed
|
||||||
|
# the heading: fold marker, path, lowercase status letter (s when anything is staged) and the file's -/+ counts
|
||||||
|
ins, dele = parse.counts(lines)
|
||||||
|
marker = "▶" if change.path in st.collapsed else "▼"
|
||||||
|
if change.path == focus:
|
||||||
|
heading = len(rows)
|
||||||
|
letter = "s" if change.staged else change.status.lower()
|
||||||
|
rows.append(f"{marker} {change.path} {letter} -{dele} +{ins}")
|
||||||
|
metadata.append((change, None, True))
|
||||||
|
# the diff rows, recording the row where each visible change starts
|
||||||
|
if change.path not in st.collapsed:
|
||||||
|
previous_changed = False
|
||||||
|
visible_hunk = -1
|
||||||
|
for line in trimmed:
|
||||||
|
rows.append(_all_line_text(line))
|
||||||
|
metadata.append((change, line, False))
|
||||||
|
changed = line.kind != " "
|
||||||
|
if changed and not previous_changed:
|
||||||
|
visible_hunk += 1
|
||||||
|
st.all_change_rows.append(len(rows) - 1)
|
||||||
|
st.all_change_row_targets.append((change.path, visible_hunk))
|
||||||
|
previous_changed = changed
|
||||||
|
# write the text and syntax
|
||||||
|
text = "\n".join(rows) if rows else "(no changes)"
|
||||||
|
_replace(view, text)
|
||||||
|
# one buffer syntax would parse prefixes and headings as code: tokenise each file alone,
|
||||||
|
# paint its colours as regions
|
||||||
|
view.assign_syntax("scope:text.plain")
|
||||||
|
palette.ensure(view)
|
||||||
|
if palette.claim_restart_notice(view, palette.DIFF_SCOPES):
|
||||||
|
status(NEEDS_RESTART)
|
||||||
|
# the token colours only once their scheme rules are live; tokenising before the restart would be thrown away
|
||||||
|
resolved = palette.tokens_resolved(view)
|
||||||
|
if resolved:
|
||||||
|
_all_colours(view, st, [c.path for c in st.files if c.path not in st.collapsed])
|
||||||
|
# per-row regions: heading counts, number prefix, tint, token colours
|
||||||
|
starts = _row_starts_for_text(rows)
|
||||||
|
del_regions, ins_regions, file_regions, prefix_regions = [], [], [], []
|
||||||
|
file_del_counts, file_ins_counts, file_statuses = [], [], []
|
||||||
|
token_groups: Dict[str, List[sublime.Region]] = {}
|
||||||
|
default_colour = palette.foreground(view, "text.plain")
|
||||||
|
# context colours come from a later scheme rule, so they may still await a restart
|
||||||
|
ctx_resolved = palette.tokens_resolved(view, ("ctx",))
|
||||||
|
for i, (change, line, section) in enumerate(metadata):
|
||||||
|
if section:
|
||||||
|
file_regions.append(sublime.Region(starts[i], starts[i] + len(rows[i])))
|
||||||
|
# colour just the status letter and counts over the heading tint; rfind, as a path may contain " -"
|
||||||
|
minus = rows[i].rfind(" -") + 1
|
||||||
|
plus = rows[i].rfind(" +") + 1
|
||||||
|
if minus >= 2:
|
||||||
|
# the letter sits just before the space before the deletion count
|
||||||
|
file_statuses.append(sublime.Region(starts[i] + minus - 2, starts[i] + minus - 1))
|
||||||
|
if minus and plus > minus:
|
||||||
|
file_del_counts.append(sublime.Region(starts[i] + minus, starts[i] + plus - 1))
|
||||||
|
if plus:
|
||||||
|
file_ins_counts.append(sublime.Region(starts[i] + plus, starts[i] + len(rows[i])))
|
||||||
|
if section or line is None:
|
||||||
|
continue
|
||||||
|
text_start = starts[i] + len(rows[i]) - len(line.text)
|
||||||
|
# tint only the file text, leaving the line-number columns plain
|
||||||
|
region = sublime.Region(text_start, starts[i] + len(rows[i]))
|
||||||
|
# the last separator column of _all_line_text keeps the view background: a gap before the text
|
||||||
|
prefix_regions.append(sublime.Region(starts[i], text_start - 1))
|
||||||
|
if resolved and change is not None:
|
||||||
|
kind = {"-": "del", "+": "ins"}.get(line.kind, "ctx")
|
||||||
|
for a, b, colour in st.all_colours.get(change.path, {}).get((line.old_no, line.new_no), []):
|
||||||
|
# context text in the default colour needs no region: plain text draws it already
|
||||||
|
if kind == "ctx" and (colour == default_colour or not ctx_resolved):
|
||||||
|
continue
|
||||||
|
scope = palette.scope_for(kind, colour)
|
||||||
|
if scope:
|
||||||
|
token_groups.setdefault(scope, []).append(sublime.Region(text_start + a, text_start + b))
|
||||||
|
if line.kind == "-":
|
||||||
|
del_regions.append(region)
|
||||||
|
elif line.kind == "+":
|
||||||
|
ins_regions.append(region)
|
||||||
|
# paint everything
|
||||||
|
view.add_regions(KEY_DEL, del_regions, palette.line_scope(view, "del"), flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
view.add_regions(KEY_INS, ins_regions, palette.line_scope(view, "ins"), flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
view.add_regions(KEY_FILE_HEADER, file_regions, _file_header_scope(view), flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
view.add_regions(KEY_FILE_DEL_COUNT, file_del_counts, KEY_FILE_DEL_COUNT, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
view.add_regions(KEY_FILE_INS_COUNT, file_ins_counts, KEY_FILE_INS_COUNT, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
view.add_regions(KEY_FILE_STATUS, file_statuses, KEY_FILE_STATUS, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
view.add_regions(KEY_FILE_PREFIX, prefix_regions, palette.line_numbers_scope(view), flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
for scope, regions in token_groups.items():
|
||||||
|
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
_finish(view, st, MODE_DIFF, "⟐ all files", _all_header_html(st))
|
||||||
|
# place the caret: on the focused change's first row, else on `heading` (a file's or the top)
|
||||||
|
if isinstance(focus, tuple):
|
||||||
|
heading = next((row for row, shown in zip(st.all_change_rows, st.all_change_row_targets) if shown == focus), 0)
|
||||||
|
point = index_point(view, heading)
|
||||||
|
view.sel().clear()
|
||||||
|
view.sel().add(sublime.Region(point))
|
||||||
|
if center:
|
||||||
|
view.show_at_center(point)
|
||||||
|
else:
|
||||||
|
view.set_viewport_position(viewport, False)
|
||||||
|
if focus is not None:
|
||||||
|
view.show(point)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_colours(view: sublime.View, st: ViewState, paths: Sequence[str]) -> None:
|
||||||
|
"""Fill st.all_colours for `paths`, tokenising each file with its own syntax in a hidden panel"""
|
||||||
|
missing = [path for path in paths if path not in st.all_colours and path in st.all_diffs]
|
||||||
|
window = view.window()
|
||||||
|
if not missing or window is None:
|
||||||
|
return
|
||||||
|
# one hidden panel tokenises every missing file, then is destroyed
|
||||||
|
panel = window.create_output_panel(TOKEN_PANEL, unlisted=True)
|
||||||
|
colours: Dict[str, Optional[str]] = {} # scope name -> colour, shared by every file
|
||||||
|
try:
|
||||||
|
for path in missing:
|
||||||
|
lines = st.all_diffs[path][0]
|
||||||
|
st.all_colours[path] = _file_colours(view, panel, os.path.join(st.repo, path), lines, colours)
|
||||||
|
finally:
|
||||||
|
window.destroy_output_panel(TOKEN_PANEL)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_colours(
|
||||||
|
view: sublime.View, panel: sublime.View, path: str, lines: Sequence[Line], colours: Dict[str, Optional[str]]
|
||||||
|
) -> Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]:
|
||||||
|
"""{(old_no, new_no): [(start, end, colour)]} for every line of one file's full-context diff
|
||||||
|
|
||||||
|
The new side (context and insertions) and the old side (context and deletions) are each a whole,
|
||||||
|
valid version of the file, so each is parsed on its own; interleaved they would confuse the parser
|
||||||
|
"""
|
||||||
|
result: Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]] = {}
|
||||||
|
if sum(len(line.text) + 1 for line in lines) > TOKEN_COLOUR_MAX_CHARS:
|
||||||
|
# too big to colour; the flat tint still shows it
|
||||||
|
return result
|
||||||
|
_apply_syntax(panel, path)
|
||||||
|
for side in ("+", "-"):
|
||||||
|
if side == "-" and not any(line.kind == "-" for line in lines):
|
||||||
|
continue # nothing deleted: the old side would only repeat the context rows
|
||||||
|
stream = [line for line in lines if line.kind in (" ", side)]
|
||||||
|
panel.run_command("vdiff_replace", {"text": "\n".join(line.text for line in stream)})
|
||||||
|
point = 0
|
||||||
|
for line in stream:
|
||||||
|
# context rows come from the new side
|
||||||
|
if line.kind == side or side == "+":
|
||||||
|
runs = _colour_runs(panel, point, line.text, colours, view)
|
||||||
|
result[(line.old_no, line.new_no)] = [(r.a - point, r.b - point, c) for r, c in runs]
|
||||||
|
point += len(line.text) + 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _row_starts_for_text(rows: Sequence[str]) -> List[int]:
|
||||||
|
"""Text point where each all-files row starts, below the header rows"""
|
||||||
|
starts: List[int] = []
|
||||||
|
point = _HEADER_ROWS
|
||||||
|
for row in rows:
|
||||||
|
starts.append(point)
|
||||||
|
point += len(row) + 1
|
||||||
|
return starts
|
||||||
|
|
||||||
|
|
||||||
|
def all_change_at(view: sublime.View, st: ViewState) -> Optional[FileChange]:
|
||||||
|
"""File whose heading or diff row holds the caret, else None"""
|
||||||
|
index = caret_index(view)
|
||||||
|
if 0 <= index < len(st.all_rows):
|
||||||
|
return st.all_rows[index][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def all_change_below(view: sublime.View, st: ViewState) -> Optional[FileChange]:
|
||||||
|
"""File of the first heading or diff row at or below the caret: the file a blank row or the top row precedes"""
|
||||||
|
for change, _, _ in st.all_rows[max(0, caret_index(view)):]:
|
||||||
|
if change is not None:
|
||||||
|
return change
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def follow_caret(view: sublime.View, st: ViewState) -> None:
|
||||||
|
"""In the all-files view, make the file under the caret current and redraw the header for it
|
||||||
|
|
||||||
|
Only when the caret crossed into another file, and not while a render is pending: the rows then
|
||||||
|
describe the old text
|
||||||
|
"""
|
||||||
|
if st.in_branches or not st.all_mode or st.rendered_token != st.token:
|
||||||
|
return
|
||||||
|
change = all_change_at(view, st)
|
||||||
|
if change is None or (st.change is not None and change.path == st.change.path):
|
||||||
|
return
|
||||||
|
st.change = change
|
||||||
|
_header(view, st, _all_header_html(st))
|
||||||
|
|
||||||
|
|
||||||
|
def _all_header_html(st: ViewState) -> str:
|
||||||
|
"""The all-files header: the totals on the summary line, the current file's own counts in the title"""
|
||||||
|
change = st.change
|
||||||
|
return header_html(st, change.insertions if change else 0, change.deletions if change else 0)
|
||||||
|
|
||||||
|
|
||||||
def render(view: sublime.View, st: ViewState) -> None:
|
def render(view: sublime.View, st: ViewState) -> None:
|
||||||
@@ -229,6 +585,7 @@ def render(view: sublime.View, st: ViewState) -> None:
|
|||||||
# every caller with nothing to show goes to render_message instead, so there is a file here
|
# every caller with nothing to show goes to render_message instead, so there is a file here
|
||||||
assert change is not None
|
assert change is not None
|
||||||
lines = st.lines
|
lines = st.lines
|
||||||
|
# the text: a placeholder for a binary or empty diff, else the lines
|
||||||
if st.binary:
|
if st.binary:
|
||||||
text = "(binary file)"
|
text = "(binary file)"
|
||||||
elif not lines:
|
elif not lines:
|
||||||
@@ -236,6 +593,7 @@ def render(view: sublime.View, st: ViewState) -> None:
|
|||||||
else:
|
else:
|
||||||
text = "\n".join(line.text for line in lines)
|
text = "\n".join(line.text for line in lines)
|
||||||
|
|
||||||
|
# write the text and syntax
|
||||||
_replace(view, text)
|
_replace(view, text)
|
||||||
path = os.path.join(st.repo, change.path)
|
path = os.path.join(st.repo, change.path)
|
||||||
_apply_syntax(view, path if lines and not st.binary else None)
|
_apply_syntax(view, path if lines and not st.binary else None)
|
||||||
@@ -244,11 +602,12 @@ def render(view: sublime.View, st: ViewState) -> None:
|
|||||||
# here rather than init_view: during a session restore the scheme colours still read as absent
|
# here rather than init_view: during a session restore the scheme colours still read as absent
|
||||||
palette.ensure(view)
|
palette.ensure(view)
|
||||||
_paint_regions(view, st)
|
_paint_regions(view, st)
|
||||||
if palette.claim_restart_notice(view):
|
if palette.claim_restart_notice(view, palette.DIFF_SCOPES):
|
||||||
status(NEEDS_RESTART)
|
status(NEEDS_RESTART)
|
||||||
|
|
||||||
|
# header and bookkeeping, then the caret and scroll back to the top
|
||||||
ins, dele = parse.counts(lines)
|
ins, dele = parse.counts(lines)
|
||||||
_finish(view, st, ins, dele, f"⟐ {os.path.basename(change.path)}")
|
_finish(view, st, MODE_DIFF, f"⟐ {os.path.basename(change.path)}", header_html(st, ins, dele))
|
||||||
view.sel().clear()
|
view.sel().clear()
|
||||||
view.sel().add(sublime.Region(index_point(view, 0)))
|
view.sel().add(sublime.Region(index_point(view, 0)))
|
||||||
view.set_viewport_position((0, 0), False)
|
view.set_viewport_position((0, 0), False)
|
||||||
@@ -257,40 +616,45 @@ def render(view: sublime.View, st: ViewState) -> None:
|
|||||||
def _paint_regions(view: sublime.View, st: ViewState) -> None:
|
def _paint_regions(view: sublime.View, st: ViewState) -> None:
|
||||||
"""The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
|
"""The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them"""
|
||||||
lines = st.lines
|
lines = st.lines
|
||||||
|
# full-line tint
|
||||||
starts = _row_starts(lines)
|
starts = _row_starts(lines)
|
||||||
deleted, inserted = _line_regions(lines, starts)
|
deleted, inserted = _line_regions(lines, starts)
|
||||||
view.add_regions(KEY_DEL, deleted, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(KEY_DEL, deleted, palette.line_scope(view, "del"), flags=sublime.DRAW_NO_OUTLINE)
|
||||||
view.add_regions(KEY_INS, inserted, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(KEY_INS, inserted, palette.line_scope(view, "ins"), flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
# gutter dots
|
||||||
mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
|
mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
|
||||||
del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"]
|
del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"]
|
||||||
ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"]
|
ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"]
|
||||||
view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags)
|
view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags)
|
||||||
view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags)
|
view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags)
|
||||||
|
# word-diff marks, when enabled
|
||||||
del_chars: List[sublime.Region] = []
|
del_chars: List[sublime.Region] = []
|
||||||
ins_chars: List[sublime.Region] = []
|
ins_chars: List[sublime.Region] = []
|
||||||
if settings().get("word_diff", True):
|
if settings().get("word_diff", True):
|
||||||
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
|
del_chars, ins_chars = _char_regions(lines, st.blocks, starts)
|
||||||
view.add_regions(KEY_DEL_CHAR, del_chars, palette.SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(KEY_DEL_CHAR, del_chars, palette.SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
view.add_regions(KEY_INS_CHAR, ins_chars, palette.SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(KEY_INS_CHAR, ins_chars, palette.SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
# syntax colours on top of the tints
|
||||||
_paint_tokens(view, lines, starts, del_chars + ins_chars)
|
_paint_tokens(view, lines, starts, del_chars + ins_chars)
|
||||||
|
|
||||||
|
|
||||||
def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int],
|
def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[int], char_regions: Sequence[sublime.Region]) -> None:
|
||||||
char_regions: Sequence[sublime.Region]) -> None:
|
|
||||||
"""Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour
|
"""Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour
|
||||||
|
|
||||||
add_regions draws text in the region scope's own foreground, so the tints above have just
|
add_regions draws text in the region scope's own foreground, so the tints above have just
|
||||||
flattened every changed line; only handing each run its own colour gives the highlighting back.
|
flattened every changed line; only handing each run its own colour gives the highlighting back
|
||||||
"""
|
"""
|
||||||
if not palette.tokens_resolved(view):
|
if not palette.tokens_resolved(view):
|
||||||
return
|
return
|
||||||
changed = [i for i, line in enumerate(lines) if line.kind != " "]
|
changed = [i for i, line in enumerate(lines) if line.kind != " "]
|
||||||
if sum(len(lines[i].text) for i in changed) > TOKEN_COLOUR_MAX_CHARS:
|
if sum(len(lines[i].text) for i in changed) > TOKEN_COLOUR_MAX_CHARS:
|
||||||
# a huge diff would spend longer being tokenised than read; the flat tint still shows it
|
# too big to colour; the flat tint still shows it
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# word-diff ranges sorted by start, so each line can bisect for its own
|
||||||
stronger = sorted((r.a, r.b) for r in char_regions)
|
stronger = sorted((r.a, r.b) for r in char_regions)
|
||||||
starts_of = [a for a, _ in stronger]
|
starts_of = [a for a, _ in stronger]
|
||||||
|
# group every changed line's colour runs by scope
|
||||||
groups: Dict[str, List[sublime.Region]] = {}
|
groups: Dict[str, List[sublime.Region]] = {}
|
||||||
colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render
|
colours: Dict[str, Optional[str]] = {} # scope name -> colour, resolved once per render
|
||||||
for i in changed:
|
for i in changed:
|
||||||
@@ -305,6 +669,7 @@ def _paint_tokens(view: sublime.View, lines: Sequence[Line], starts: Sequence[in
|
|||||||
scope = palette.scope_for(kind + ".char" if inside else kind, colour)
|
scope = palette.scope_for(kind + ".char" if inside else kind, colour)
|
||||||
if scope:
|
if scope:
|
||||||
groups.setdefault(scope, []).append(part)
|
groups.setdefault(scope, []).append(part)
|
||||||
|
# one region set per scope
|
||||||
for scope, regions in groups.items():
|
for scope, regions in groups.items():
|
||||||
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
|
view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE)
|
||||||
|
|
||||||
@@ -313,6 +678,7 @@ def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List
|
|||||||
"""(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
|
"""(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends"""
|
||||||
if not intervals:
|
if not intervals:
|
||||||
return [(region, False)]
|
return [(region, False)]
|
||||||
|
# walk the intervals, emitting the gap before each and its overlap with the region
|
||||||
parts: List[Tuple[sublime.Region, bool]] = []
|
parts: List[Tuple[sublime.Region, bool]] = []
|
||||||
pos = region.a
|
pos = region.a
|
||||||
for a, b in intervals:
|
for a, b in intervals:
|
||||||
@@ -325,21 +691,33 @@ def _split(region: sublime.Region, intervals: Sequence[Tuple[int, int]]) -> List
|
|||||||
parts.append((sublime.Region(pos, a), False))
|
parts.append((sublime.Region(pos, a), False))
|
||||||
parts.append((sublime.Region(a, b), True))
|
parts.append((sublime.Region(a, b), True))
|
||||||
pos = b
|
pos = b
|
||||||
|
# the tail after the last interval, or the whole region when none overlapped
|
||||||
if pos < region.b or not parts:
|
if pos < region.b or not parts:
|
||||||
parts.append((sublime.Region(pos, region.b), False))
|
parts.append((sublime.Region(pos, region.b), False))
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def _colour_runs(view: sublime.View, start: int, text: str,
|
def _colour_runs(
|
||||||
colours: Dict[str, Optional[str]]) -> List[Tuple[sublime.Region, str]]:
|
view: sublime.View, start: int, text: str, colours: Dict[str, Optional[str]], scheme_view: Optional[sublime.View] = None
|
||||||
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged"""
|
) -> List[Tuple[sublime.Region, str]]:
|
||||||
|
"""(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged
|
||||||
|
|
||||||
|
`scheme_view` resolves the colours when `view` is only a tokenising buffer
|
||||||
|
"""
|
||||||
runs: List[Tuple[sublime.Region, str]] = []
|
runs: List[Tuple[sublime.Region, str]] = []
|
||||||
for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, start + len(text))):
|
end = start + len(text)
|
||||||
|
for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, end)):
|
||||||
|
# a token can run past the line's end, e.g. into the newline
|
||||||
|
region = sublime.Region(max(region.begin(), start), min(region.end(), end))
|
||||||
|
if region.b <= region.a:
|
||||||
|
continue
|
||||||
|
# resolve each scope's colour once, skipping tokens without one
|
||||||
if scope not in colours:
|
if scope not in colours:
|
||||||
colours[scope] = palette.foreground(view, scope)
|
colours[scope] = palette.foreground(scheme_view or view, scope)
|
||||||
colour = colours[scope]
|
colour = colours[scope]
|
||||||
if not colour:
|
if not colour:
|
||||||
continue
|
continue
|
||||||
|
# extend the previous run when the same colour continues it
|
||||||
if runs and runs[-1][1] == colour and runs[-1][0].b == region.a:
|
if runs and runs[-1][1] == colour and runs[-1][0].b == region.a:
|
||||||
runs[-1] = (sublime.Region(runs[-1][0].a, region.b), colour)
|
runs[-1] = (sublime.Region(runs[-1][0].a, region.b), colour)
|
||||||
else:
|
else:
|
||||||
@@ -347,16 +725,25 @@ def _colour_runs(view: sublime.View, start: int, text: str,
|
|||||||
return runs
|
return runs
|
||||||
|
|
||||||
|
|
||||||
def _finish(view: sublime.View, st: ViewState, ins: int, dele: int, name: str) -> None:
|
def _finish(view: sublime.View, st: ViewState, mode: str, name: str, header: str, key: Callable[[ViewState], Any] = fingerprint) -> None:
|
||||||
"""Header, tab name, status bar and bookkeeping shared by render and render_message"""
|
"""Mode, header, tab name, status bar and bookkeeping shared by every render: what was drawn is settled here
|
||||||
_header(view, st, ins, dele)
|
|
||||||
|
`key` is the mode's fingerprint, the branches view's own one there
|
||||||
|
"""
|
||||||
|
st.mode = mode
|
||||||
|
_header(view, st, header)
|
||||||
view.set_name(name)
|
view.set_name(name)
|
||||||
view.set_status(STATUS_KEY, KEY_HINTS)
|
view.set_status(STATUS_KEY, key_hints(st.mode))
|
||||||
|
view.settings().set(MODE_SETTING, st.mode)
|
||||||
|
# the caret row's highlight; the branches view turns it off itself once it tints the caret's commit
|
||||||
|
view.settings().set("highlight_line", True)
|
||||||
st.persist(view)
|
st.persist(view)
|
||||||
st.fingerprint = fingerprint(st)
|
st.fingerprint = key(st)
|
||||||
|
st.drawn = st.describe()
|
||||||
|
|
||||||
|
|
||||||
def _replace(view: sublime.View, text: str) -> None:
|
def _replace(view: sublime.View, text: str) -> None:
|
||||||
|
"""Write `text` below the header rows, then erase every vdiff region and token layer"""
|
||||||
view.set_read_only(False)
|
view.set_read_only(False)
|
||||||
try:
|
try:
|
||||||
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
|
view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text})
|
||||||
@@ -389,8 +776,7 @@ def _row_starts(lines: Sequence[Line]) -> List[int]:
|
|||||||
return starts
|
return starts
|
||||||
|
|
||||||
|
|
||||||
def _line_regions(lines: Sequence[Line],
|
def _line_regions(lines: Sequence[Line], starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
|
||||||
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
|
|
||||||
"""Full-line regions of deleted and inserted rows, consecutive rows merged"""
|
"""Full-line regions of deleted and inserted rows, consecutive rows merged"""
|
||||||
regions: Dict[str, List[sublime.Region]] = {"-": [], "+": []}
|
regions: Dict[str, List[sublime.Region]] = {"-": [], "+": []}
|
||||||
for kind, start, end in parse.runs(lines, lambda line: line.kind):
|
for kind, start, end in parse.runs(lines, lambda line: line.kind):
|
||||||
@@ -400,12 +786,14 @@ def _line_regions(lines: Sequence[Line],
|
|||||||
return regions["-"], regions["+"]
|
return regions["-"], regions["+"]
|
||||||
|
|
||||||
|
|
||||||
def _char_regions(lines: Sequence[Line], blocks: Sequence[Block],
|
def _char_regions(
|
||||||
starts: Sequence[int]) -> Tuple[List[sublime.Region], List[sublime.Region]]:
|
lines: Sequence[Line], blocks: Sequence[Block], starts: Sequence[int]
|
||||||
|
) -> Tuple[List[sublime.Region], List[sublime.Region]]:
|
||||||
"""Changed characters inside paired deleted/inserted lines"""
|
"""Changed characters inside paired deleted/inserted lines"""
|
||||||
deleted: List[sublime.Region] = []
|
deleted: List[sublime.Region] = []
|
||||||
inserted: List[sublime.Region] = []
|
inserted: List[sublime.Region] = []
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
|
# word-diff each deleted/inserted pair and offset its ranges to buffer points
|
||||||
for del_row, ins_row in parse.paired_lines(lines, block):
|
for del_row, ins_row in parse.paired_lines(lines, block):
|
||||||
old_ranges, new_ranges = parse.char_ranges(lines[del_row].text, lines[ins_row].text)
|
old_ranges, new_ranges = parse.char_ranges(lines[del_row].text, lines[ins_row].text)
|
||||||
deleted.extend(sublime.Region(starts[del_row] + a, starts[del_row] + b) for a, b in old_ranges)
|
deleted.extend(sublime.Region(starts[del_row] + a, starts[del_row] + b) for a, b in old_ranges)
|
||||||
@@ -413,39 +801,37 @@ def _char_regions(lines: Sequence[Line], blocks: Sequence[Block],
|
|||||||
return deleted, inserted
|
return deleted, inserted
|
||||||
|
|
||||||
|
|
||||||
def _totals(files: Sequence[FileChange]) -> Tuple[int, int]:
|
|
||||||
"""Summed numstat counts; binary files carry None counts and add nothing"""
|
|
||||||
return sum(c.insertions or 0 for c in files), sum(c.deletions or 0 for c in files)
|
|
||||||
|
|
||||||
|
|
||||||
def _summary_html(st: ViewState) -> str:
|
def _summary_html(st: ViewState) -> str:
|
||||||
"""Line above the header: the whole commit on one line, or how much of the tree is staged"""
|
"""Summary line above the title: the commit on one line, or the staged/unstaged split and totals"""
|
||||||
if not st.files and not st.commit:
|
if not st.files and not st.commit:
|
||||||
return ""
|
return ""
|
||||||
if st.in_commit:
|
if st.in_commit:
|
||||||
text = " · ".join(_commit_parts(st))
|
text = " · ".join(_commit_parts(st))
|
||||||
else:
|
else:
|
||||||
# staged means "has something staged": a partially staged file counts as staged only
|
# the same counts as the branches view's working-tree entry: a partially staged file is on both sides
|
||||||
staged = sum(1 for c in st.files if c.staged)
|
stat = git.worktree_stats(st.files)
|
||||||
insertions, deletions = _totals(st.files)
|
|
||||||
text = (
|
text = (
|
||||||
f"{staged} staged, {len(st.files) - staged} unstaged"
|
f"{stat.staged} staged, {stat.unstaged} unstaged"
|
||||||
f' · <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>'
|
f' · <span class="del">-{stat.deletions}</span> <span class="ins">+{stat.insertions}</span>'
|
||||||
)
|
)
|
||||||
return f'<div class="summary">{text}</div>'
|
return f'<div class="summary">{text}</div>'
|
||||||
|
|
||||||
|
|
||||||
def _commit_parts(st: ViewState) -> List[str]:
|
def _commit_parts(st: ViewState) -> List[str]:
|
||||||
"""hash, author, date, totals and message — everything about the reviewed commit, in that order"""
|
"""Hash, author, date, totals and message of the reviewed commit, in order"""
|
||||||
info = st.commit
|
info = st.commit
|
||||||
parts: List[str] = []
|
parts: List[str] = []
|
||||||
|
# the commit's identity: hash, author, date
|
||||||
if info:
|
if info:
|
||||||
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
|
parts.append(f'<span class="hash">{html.escape(info.short)}</span>')
|
||||||
parts.append(html.escape(info.author))
|
parts.append(html.escape(info.author))
|
||||||
parts.append(f'<span class="when">{html.escape(info.date)}</span>')
|
parts.append(f'<span class="when">{html.escape(info.date)}</span>')
|
||||||
# binary files add nothing to the line totals but still count toward the file total
|
# binary files add nothing to the line totals but still count toward the file total
|
||||||
insertions, deletions = _totals(st.files)
|
stat = git.worktree_stats(st.files)
|
||||||
parts.append(f"{file_count(len(st.files))} changed" f' <span class="del">-{deletions}</span> <span class="ins">+{insertions}</span>')
|
parts.append(
|
||||||
|
f"{file_count(stat.files)} changed" f' <span class="del">-{stat.deletions}</span> <span class="ins">+{stat.insertions}</span>'
|
||||||
|
)
|
||||||
|
# the subject, plus the body's first line
|
||||||
if info:
|
if info:
|
||||||
message = html.escape(info.subject)
|
message = html.escape(info.subject)
|
||||||
body = info.body.strip().splitlines()
|
body = info.body.strip().splitlines()
|
||||||
@@ -455,21 +841,26 @@ def _commit_parts(st: ViewState) -> List[str]:
|
|||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def header_html(st: ViewState, ins: int, dele: int) -> str:
|
def header_html(st: ViewState, ins: Optional[int], dele: Optional[int]) -> str:
|
||||||
"""The minihtml header: the summary line, then the shown file's path, counts, state and action links"""
|
"""The minihtml header: the summary line, then the shown file's path, its counts (None for a binary file), state and action links"""
|
||||||
change = st.change
|
change = st.change
|
||||||
|
# the title: path, rename source, counts and working-tree state
|
||||||
if change is None:
|
if change is None:
|
||||||
title = '<span class="path">vDiff</span>'
|
title = '<span class="path">vDiff</span>'
|
||||||
else:
|
else:
|
||||||
title = f'<span class="path">{html.escape(change.path)}</span>'
|
title = f'<span class="path">{html.escape(change.path)}</span>'
|
||||||
if change.old_path:
|
if change.old_path:
|
||||||
title += f' <span class="old">← {html.escape(change.old_path)}</span>'
|
title += f' <span class="old">← {html.escape(change.old_path)}</span>'
|
||||||
|
if ins is None or dele is None:
|
||||||
|
title += ' <span class="state">binary</span>'
|
||||||
|
else:
|
||||||
title += f' <span class="del">-{dele}</span> <span class="ins">+{ins}</span>'
|
title += f' <span class="del">-{dele}</span> <span class="ins">+{ins}</span>'
|
||||||
if not st.in_commit:
|
if not st.in_commit:
|
||||||
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
|
state = "staged" if change.staged else STATUS_NAMES.get(change.status, "")
|
||||||
if state:
|
if state:
|
||||||
title += f' <span class="state">{state}</span>'
|
title += f' <span class="state">{state}</span>'
|
||||||
|
|
||||||
|
# action links: stage, unstage and discard need a working-tree file, open needs any file
|
||||||
actions: List[str] = []
|
actions: List[str] = []
|
||||||
if change is not None and not st.in_commit:
|
if change is not None and not st.in_commit:
|
||||||
actions.append('<a href="stage">Stage</a>')
|
actions.append('<a href="stage">Stage</a>')
|
||||||
@@ -477,13 +868,20 @@ def header_html(st: ViewState, ins: int, dele: int) -> str:
|
|||||||
actions.append('<a href="discard" class="danger">Discard</a>')
|
actions.append('<a href="discard" class="danger">Discard</a>')
|
||||||
if change is not None:
|
if change is not None:
|
||||||
actions.append('<a href="open">Open</a>')
|
actions.append('<a href="open">Open</a>')
|
||||||
actions.append('<a href="files">Files</a>')
|
return header_frame(_summary_html(st), title, actions)
|
||||||
actions.append('<a href="commits">Commits</a>')
|
|
||||||
actions.append('<a href="help" title="keys">?</a>')
|
|
||||||
|
|
||||||
|
|
||||||
|
def header_frame(summary: str, title: str, actions: Sequence[str]) -> str:
|
||||||
|
"""The header's body: the summary line, then the title with `actions` and the links every view has"""
|
||||||
|
links = list(actions) + [
|
||||||
|
'<a href="files">Files</a>',
|
||||||
|
'<a href="commits">Commits</a>',
|
||||||
|
'<a href="branches">Branches</a>',
|
||||||
|
'<a href="help" title="keys">?</a>',
|
||||||
|
]
|
||||||
return (
|
return (
|
||||||
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{_summary_html(st)}'
|
f'<body id="vdiff-header"><style>{HEADER_CSS}</style>{summary}'
|
||||||
f'<div>{title}<span class="actions">{"".join(actions)}</span></div></body>'
|
f'<div>{title}<span class="actions">{"".join(links)}</span></div></body>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -492,35 +890,42 @@ def _run_action(view: sublime.View, href: str) -> None:
|
|||||||
view.run_command("vdiff_action", {"action": href})
|
view.run_command("vdiff_action", {"action": href})
|
||||||
|
|
||||||
|
|
||||||
def _header(view: sublime.View, st: ViewState, ins: int, dele: int) -> None:
|
def _header(view: sublime.View, st: ViewState, html_text: str) -> None:
|
||||||
"""One block phantom above the text; redrawn from scratch, so no PhantomSet state to keep"""
|
"""One block phantom above the text, redrawn from scratch unless the same one is still in place"""
|
||||||
|
# keyed on the buffer too: replacing the text can drop the phantom while query_phantom still answers for it
|
||||||
|
drawn = html_text, view.change_count()
|
||||||
|
if st.header == drawn:
|
||||||
|
return
|
||||||
|
|
||||||
def navigate(href: str) -> None:
|
def navigate(href: str) -> None:
|
||||||
|
"""Run the clicked header link's action"""
|
||||||
_run_action(view, href)
|
_run_action(view, href)
|
||||||
|
|
||||||
view.erase_phantoms(PHANTOM_KEY)
|
view.erase_phantoms(PHANTOM_KEY)
|
||||||
view.add_phantom(PHANTOM_KEY, sublime.Region(0), header_html(st, ins, dele), sublime.LAYOUT_BLOCK, navigate)
|
view.add_phantom(PHANTOM_KEY, sublime.Region(0), html_text, sublime.LAYOUT_BLOCK, navigate)
|
||||||
|
st.header = drawn
|
||||||
|
|
||||||
|
|
||||||
# --- key help popup ---------------------------------------------------------
|
# --- key help popup ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def help_html(st: ViewState) -> str:
|
def help_html(st: ViewState) -> str:
|
||||||
"""The minihtml key list; rows that need a working-tree file are dimmed while a commit is shown"""
|
"""The minihtml key list of the shown mode; rows that need a working-tree file are dimmed while a commit is shown"""
|
||||||
parts: List[str] = []
|
parts: List[str] = []
|
||||||
for title, applies, group in KEY_GROUPS:
|
width = _key_width(st.mode)
|
||||||
|
# per group a heading, dimmed with a note when it does not apply in a commit
|
||||||
|
for title, applies, group in MODE_KEY_GROUPS[st.mode]:
|
||||||
dim = st.in_commit and not applies
|
dim = st.in_commit and not applies
|
||||||
heading = html.escape(title)
|
heading = html.escape(title)
|
||||||
if dim:
|
if dim:
|
||||||
heading += ' <span class="note">uncommitted only</span>'
|
heading += ' <span class="note">uncommitted only</span>'
|
||||||
parts.append(f'<div class="group">{heading}</div>')
|
parts.append(f'<div class="group">{heading}</div>')
|
||||||
# one class per element: a compound selector would silently drop the rest of HELP_CSS
|
# keys stay orange in a dimmed group; only the text dims
|
||||||
row_class, key_class = ("dim", "kdim") if dim else ("row", "k")
|
row_class = "dim" if dim else "row"
|
||||||
|
# a row per key with a padded key column; a single-action row becomes a link
|
||||||
for key in group:
|
for key in group:
|
||||||
row = (
|
pad = " " * (width - len(key.keys) + 2)
|
||||||
f'<span class="{key_class}">{html.escape(key.keys)}</span>'
|
row = f'<span class="k">{html.escape(key.keys)}</span>{pad}{html.escape(key.label)}'
|
||||||
f'{" " * (_KEY_WIDTH - len(key.keys) + 2)}{html.escape(key.label)}'
|
|
||||||
)
|
|
||||||
if len(key.actions) == 1 and not dim:
|
if len(key.actions) == 1 and not dim:
|
||||||
row = f'<a href="{key.actions[0]}">{row}</a>'
|
row = f'<a href="{key.actions[0]}">{row}</a>'
|
||||||
parts.append(f'<div class="{row_class}">{row}</div>')
|
parts.append(f'<div class="{row_class}">{row}</div>')
|
||||||
@@ -534,10 +939,10 @@ def show_help(view: sublime.View, st: ViewState) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def navigate(href: str) -> None:
|
def navigate(href: str) -> None:
|
||||||
|
"""Close the popup, then run the clicked row's action"""
|
||||||
view.hide_popup()
|
view.hide_popup()
|
||||||
_run_action(view, href)
|
_run_action(view, href)
|
||||||
|
|
||||||
# minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size
|
# minihtml sizes follow the editor font, so fixed pixel bounds would clip at a large font size
|
||||||
em = view.em_width() or 8
|
em = view.em_width() or 8
|
||||||
view.show_popup(help_html(st), location=-1, max_width=int(em * 56), max_height=int(em * 44), on_navigate=navigate)
|
view.show_popup(help_html(st), location=-1, max_width=int(em * 56), max_height=int(em * 44), on_navigate=navigate)
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ if TYPE_CHECKING:
|
|||||||
# annotation-only, so this module keeps importing neither sublime nor the rest of the package
|
# annotation-only, so this module keeps importing neither sublime nor the rest of the package
|
||||||
import sublime
|
import sublime
|
||||||
|
|
||||||
from .git import CommitInfo, FileChange
|
from .branches import Paint
|
||||||
|
from .git import Commit, CommitInfo, FileChange, Ref
|
||||||
from .parse import Block, Line
|
from .parse import Block, Line
|
||||||
|
|
||||||
# view setting marking a review view (keymap context "setting.vdiff_view")
|
# view setting marking a review view (keymap context "setting.vdiff_view")
|
||||||
VIEW_FLAG = "vdiff_view"
|
VIEW_FLAG = "vdiff_view"
|
||||||
|
# view setting 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 setting holding what the view shows, so a restored tab can re-render
|
||||||
VIEW_DATA = "vdiff"
|
VIEW_DATA = "vdiff"
|
||||||
|
|
||||||
@@ -22,6 +27,18 @@ class Kept(NamedTuple):
|
|||||||
commit: Optional["CommitInfo"]
|
commit: Optional["CommitInfo"]
|
||||||
change: Optional["FileChange"]
|
change: Optional["FileChange"]
|
||||||
files: List["FileChange"]
|
files: List["FileChange"]
|
||||||
|
all_mode: bool
|
||||||
|
mode: str = MODE_DIFF
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def find_change(files: Sequence["FileChange"], path: Optional[str]) -> int:
|
||||||
@@ -48,24 +65,65 @@ class ViewState:
|
|||||||
token: int = 0 # bumped per show request; a stale async result is dropped
|
token: int = 0 # bumped per show request; a stale async result is dropped
|
||||||
rendered_token: int = -1 # token of the request that produced the current render
|
rendered_token: int = -1 # token of the request that produced the current render
|
||||||
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
|
highlight: int = 0 # bumped per quick-panel highlight, to debounce previews
|
||||||
# (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable
|
fingerprint: Optional[Tuple[Any, ...]] = None # what the last render saw; see render.fingerprint
|
||||||
fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None
|
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
|
@property
|
||||||
def in_commit(self) -> bool:
|
def in_commit(self) -> bool:
|
||||||
|
"""Whether a commit is being reviewed rather than the working tree"""
|
||||||
return self.sha is not None
|
return self.sha is not None
|
||||||
|
|
||||||
|
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:
|
def index(self) -> int:
|
||||||
"""Position in `files` of the file being shown or loaded, or -1
|
"""Position in `files` of the file being shown or loaded, or -1
|
||||||
|
|
||||||
`requested` leads: a diff arrives asynchronously, so counting from `change` would make n / p
|
`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.
|
step from what was on screen before the pending file landed
|
||||||
"""
|
"""
|
||||||
change = self.requested or self.change
|
change = self.requested or self.change
|
||||||
return find_change(self.files, change.path) if change else -1
|
return find_change(self.files, change.path) if change else -1
|
||||||
|
|
||||||
def kept(self) -> Kept:
|
def kept(self) -> Kept:
|
||||||
return Kept(self.sha, self.commit, self.change, list(self.files))
|
"""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: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
|
def enter(self, sha: Optional[str] = None, commit: Optional["CommitInfo"] = None) -> Kept:
|
||||||
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
|
"""Switch to browsing a commit (or the working tree); returns what to restore on Esc"""
|
||||||
@@ -76,12 +134,27 @@ class ViewState:
|
|||||||
return kept
|
return kept
|
||||||
|
|
||||||
def restore(self, kept: Kept) -> None:
|
def restore(self, kept: Kept) -> None:
|
||||||
self.sha, self.commit, self.change, self.files = kept
|
"""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
|
self.requested = kept.change
|
||||||
|
|
||||||
|
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:
|
def persist(self, view: "sublime.View") -> None:
|
||||||
"""Mirror the essentials into view settings (survives a restart)"""
|
"""Mirror the essentials into view settings (survives a restart)"""
|
||||||
data: Dict[str, Any] = {"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:
|
if self.change:
|
||||||
data["path"] = self.change.path
|
data["path"] = self.change.path
|
||||||
data["status"] = self.change.status
|
data["status"] = self.change.status
|
||||||
@@ -93,6 +166,7 @@ STATES: Dict[int, "ViewState"] = {}
|
|||||||
|
|
||||||
|
|
||||||
def get(view: "sublime.View") -> Optional[ViewState]:
|
def get(view: "sublime.View") -> Optional[ViewState]:
|
||||||
|
"""State of a review view, or None when not tracked"""
|
||||||
return STATES.get(view.id())
|
return STATES.get(view.id())
|
||||||
|
|
||||||
|
|
||||||
@@ -102,13 +176,16 @@ def tracked() -> List[Tuple[int, ViewState]]:
|
|||||||
|
|
||||||
|
|
||||||
def put(view: "sublime.View", st: ViewState) -> ViewState:
|
def put(view: "sublime.View", st: ViewState) -> ViewState:
|
||||||
|
"""Track `st` as the view's state and return it"""
|
||||||
STATES[view.id()] = st
|
STATES[view.id()] = st
|
||||||
return st
|
return st
|
||||||
|
|
||||||
|
|
||||||
def drop(view: "sublime.View") -> None:
|
def drop(view: "sublime.View") -> None:
|
||||||
|
"""Forget the view's state, if any"""
|
||||||
STATES.pop(view.id(), None)
|
STATES.pop(view.id(), None)
|
||||||
|
|
||||||
|
|
||||||
def is_review_view(view: Optional["sublime.View"]) -> bool:
|
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))
|
return bool(view and view.settings().get(VIEW_FLAG))
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from . import git
|
|||||||
|
|
||||||
SETTINGS_FILE = "vDiff.sublime-settings"
|
SETTINGS_FILE = "vDiff.sublime-settings"
|
||||||
|
|
||||||
|
DEBOUNCE_MS = 60
|
||||||
|
|
||||||
LOG = logging.getLogger("vdiff")
|
LOG = logging.getLogger("vdiff")
|
||||||
|
|
||||||
_settings: Optional[sublime.Settings] = None
|
_settings: Optional[sublime.Settings] = None
|
||||||
@@ -28,9 +30,15 @@ def apply_git_binary() -> None:
|
|||||||
git.GIT_BINARY = settings().get("git_binary") or "git"
|
git.GIT_BINARY = settings().get("git_binary") or "git"
|
||||||
|
|
||||||
|
|
||||||
def file_count(count: int) -> str:
|
def commits_count() -> int:
|
||||||
"""'1 file' / '3 files' for a message"""
|
"""How many commits the commits panel and the branches view list"""
|
||||||
return f'{count} {"file" if count == 1 else "files"}'
|
return int(settings().get("commits_count", 30) or 30)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def status(message: str) -> None:
|
||||||
@@ -42,6 +50,7 @@ def run_async(work: Callable[[], Any], done: Callable[[Any], None]) -> None:
|
|||||||
"""Run `work()` on sublime's worker thread, hand the result to `done` on the main thread"""
|
"""Run `work()` on sublime's worker thread, hand the result to `done` on the main thread"""
|
||||||
|
|
||||||
def job() -> None:
|
def job() -> None:
|
||||||
|
"""Worker-thread body: run `work`, pass on its result or report its error"""
|
||||||
try:
|
try:
|
||||||
result = work()
|
result = work()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
Reference in New Issue
Block a user