Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898135121c | ||
|
|
bd7c129116 | ||
|
|
25308f5f0f | ||
|
|
7b902616fe | ||
|
|
bf6ae3e2bb | ||
|
|
5ef5c1ba94 | ||
|
|
ccf945a067 | ||
|
|
16b0363d07 | ||
|
|
48e5e8c8ce | ||
|
|
7fe5a70c69 | ||
|
|
121fe1bc2f | ||
|
|
157a4063c7 | ||
|
|
ddb05980e0 | ||
|
|
761e528e15 | ||
|
|
e4e597dc5c | ||
|
|
302fe4a4ae | ||
|
|
2b433fb650 | ||
|
|
394d92e756 | ||
|
|
e6c70554f3 | ||
|
|
432fe58563 | ||
|
|
52d6522075 | ||
|
|
d75afe0249 | ||
|
|
13f0fbcb39 | ||
|
|
983ac2cfa9 | ||
|
|
93e9cf94a5 | ||
|
|
788a373e17 | ||
|
|
80906a9bda |
@@ -14,6 +14,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
# The same tool cache as in release.yaml: the same key and the same folders, or
|
||||||
|
# neither job can use what the other saved.
|
||||||
- name: Restore tool cache
|
- name: Restore tool cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -24,7 +26,10 @@ jobs:
|
|||||||
~/.rustup
|
~/.rustup
|
||||||
~/.local/bin/mise
|
~/.local/bin/mise
|
||||||
~/.local/share/mise
|
~/.local/share/mise
|
||||||
key: tools-check-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
key: tools-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
||||||
|
# After a change to mise.toml, start from the last tools; mise installs only what changed.
|
||||||
|
restore-keys: |
|
||||||
|
tools-
|
||||||
- name: Restore build cache
|
- name: Restore build cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ jobs:
|
|||||||
CARGO_BUILD_JOBS: "2"
|
CARGO_BUILD_JOBS: "2"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
# The same tool cache as in ci.yaml: the same key and the same folders, or
|
||||||
|
# neither job can use what the other saved.
|
||||||
- name: Restore tool cache
|
- name: Restore tool cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
# ~/.cache/cargo-xwin holds the Windows SDK files cargo-xwin downloads.
|
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/bin
|
~/.cargo/bin
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -35,8 +36,22 @@ jobs:
|
|||||||
~/.rustup
|
~/.rustup
|
||||||
~/.local/bin/mise
|
~/.local/bin/mise
|
||||||
~/.local/share/mise
|
~/.local/share/mise
|
||||||
|
key: tools-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
||||||
|
# After a change to mise.toml, start from the last tools; mise installs only what changed.
|
||||||
|
restore-keys: |
|
||||||
|
tools-
|
||||||
|
# cargo-xwin, which takes minutes to compile, and the Windows SDK files it downloads.
|
||||||
|
# The Windows Rust target is left out: rustup lists its installed parts in a file in
|
||||||
|
# the tool cache, and refuses to install the target over files that list does not name.
|
||||||
|
- name: Restore cargo-xwin cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/cargo-xwin
|
||||||
~/.cache/cargo-xwin
|
~/.cache/cargo-xwin
|
||||||
key: tools-release-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
key: cargo-xwin-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-xwin-
|
||||||
- name: Restore build cache
|
- name: Restore build cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -58,10 +73,12 @@ jobs:
|
|||||||
'
|
'
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: mise run test
|
run: mise run test
|
||||||
|
- name: Remove packages left in the build cache
|
||||||
|
run: rm -rf src-tauri/target/release/bundle src-tauri/target/x86_64-pc-windows-msvc/release/bundle
|
||||||
- name: Build Linux
|
- name: Build Linux
|
||||||
run: pnpm tauri build --bundles deb,rpm,appimage
|
run: bun run tauri build --bundles deb,rpm,appimage
|
||||||
- name: Build Windows
|
- name: Build Windows
|
||||||
run: pnpm tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
run: bun run tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||||||
- name: Collect files
|
- name: Collect files
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release
|
mkdir -p release
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="logo.svg" width="96" alt="">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">gites</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
A colorful viewer for the history of a git repository.<br>
|
||||||
|
Linux and Windows desktop app, driven from the keyboard.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://git.wrobel.it/szaman/gites/releases">Download</a> ·
|
||||||
|
<a href="#keys">Keys</a> ·
|
||||||
|
<a href="#build-from-source">Build from source</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Draws the commit graph.** Every branch gets its own color. Labels on each commit show
|
||||||
|
local branches, remote branches and tags, and the checked-out commit is ringed.
|
||||||
|
- **Hides what you don't need.** Hide one branch with its eye button in the sidebar, or
|
||||||
|
with <kbd>h</kbd> on a commit. Type a pattern such as `prototypes/*` and choose
|
||||||
|
**hide** or **show only**. The eye next to **Tags** hides all tags, also tags added
|
||||||
|
later. gites remembers the filters of each repository.
|
||||||
|
- **Folds long straight stretches.** Commits in a row that have no branch, tag, fork or
|
||||||
|
merge fold into one dotted line. <kbd>c</kbd> folds all of them, <kbd>space</kbd>
|
||||||
|
opens or closes the one at the selected commit.
|
||||||
|
- **Shows the selected commit** in a panel at the bottom: the full message, the author
|
||||||
|
and committer, the hash (click to copy), the parents (click to go there), and the
|
||||||
|
files it changed with the number of added and removed lines.
|
||||||
|
- **Shows what changed in a file.** Click a file in the panel, or press <kbd>Enter</kbd>
|
||||||
|
for the first one. Choose one of three layouts with <kbd>v</kbd>:
|
||||||
|
- **One column**, as `git diff` prints it.
|
||||||
|
- **Side by side**, the old file on the left and the new one on the right.
|
||||||
|
- **Inline**, one line per changed line, with removed words struck out and added words
|
||||||
|
marked. Best for prose, such as Markdown.
|
||||||
|
|
||||||
|
In every layout, the words that changed inside a line are marked.
|
||||||
|
- **Opens other repositories** from a list of recent ones, or from a folder picker
|
||||||
|
(<kbd>o</kbd>).
|
||||||
|
- **Opens repositories on another machine.** Write the place as `[user@]host:path`, for
|
||||||
|
example `gites myserver:/srv/app`, or choose **Open on another machine…** in the
|
||||||
|
repository menu (<kbd>o</kbd>) or on the welcome screen. `host` can be any host name
|
||||||
|
from your `~/.ssh/config`; that file is also where a port other than 22 goes. gites
|
||||||
|
runs git on that machine through ssh, so git must be installed there. It logs in with
|
||||||
|
your ssh keys or the ssh agent and never asks for a password, so a login that needs a
|
||||||
|
password does not work. You see the history of the repository on that machine: its own
|
||||||
|
branches and its checked-out branch, as for a repository on this computer.
|
||||||
|
- **Stays current.** gites reloads the repository when its window gets focus again.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Download a package from the [releases page](https://git.wrobel.it/szaman/gites/releases):
|
||||||
|
|
||||||
|
| System | File |
|
||||||
|
|---|---|
|
||||||
|
| Debian, Ubuntu | `gites_<version>_amd64.deb` |
|
||||||
|
| Fedora, openSUSE | `gites-<version>-1.x86_64.rpm` |
|
||||||
|
| Any Linux | `gites_<version>_amd64.AppImage` |
|
||||||
|
| Windows, installer | `gites_<version>_x64-setup.exe` |
|
||||||
|
| Windows, no install | `gites_<version>_x64_portable.exe` |
|
||||||
|
|
||||||
|
gites runs the `git` command, so git must be installed and on your `PATH`.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gites # the repository in the current folder
|
||||||
|
gites ~/code/my-project # another repository
|
||||||
|
gites myserver:/srv/app # a repository on another machine, over ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
**In the commit graph**
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| <kbd>↑</kbd> <kbd>↓</kbd> or <kbd>k</kbd> <kbd>j</kbd> | Select the previous or next commit |
|
||||||
|
| <kbd>PgUp</kbd> <kbd>PgDn</kbd> | Move one screen up or down |
|
||||||
|
| <kbd>g</kbd> <kbd>G</kbd> or <kbd>Home</kbd> <kbd>End</kbd> | Go to the first or last commit |
|
||||||
|
| <kbd>Enter</kbd> | Show what changed in the first file of the commit |
|
||||||
|
| <kbd>space</kbd> | Fold or unfold the straight stretch at the selected commit |
|
||||||
|
| <kbd>c</kbd> | Fold or unfold all straight stretches |
|
||||||
|
| <kbd>h</kbd> | Hide the branch of the selected commit |
|
||||||
|
| <kbd>y</kbd> | Copy the short hash |
|
||||||
|
| <kbd>/</kbd> | Type a filter pattern |
|
||||||
|
| <kbd>p</kbd> | Show or hide the preview panel |
|
||||||
|
| <kbd>o</kbd> | Open another repository |
|
||||||
|
| <kbd>r</kbd> | Reload |
|
||||||
|
|
||||||
|
**In a file's diff**
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| <kbd>↑</kbd> <kbd>↓</kbd> or <kbd>k</kbd> <kbd>j</kbd> | Scroll by one line |
|
||||||
|
| <kbd>PgUp</kbd> <kbd>PgDn</kbd> | Scroll by one screen |
|
||||||
|
| <kbd>g</kbd> <kbd>G</kbd> or <kbd>Home</kbd> <kbd>End</kbd> | Go to the top or bottom |
|
||||||
|
| <kbd>n</kbd> <kbd>N</kbd> | Go to the next or previous change |
|
||||||
|
| <kbd>]</kbd> <kbd>[</kbd> | Show the next or previous file of the commit |
|
||||||
|
| <kbd>v</kbd> | Switch the layout: one column, side by side, inline |
|
||||||
|
| <kbd>Esc</kbd> | Go back to the commit graph |
|
||||||
|
|
||||||
|
## Where gites keeps its settings
|
||||||
|
|
||||||
|
The window layout, the diff layout and the recent repositories are kept in
|
||||||
|
`settings.json`, and the filters of each repository in `filters.json`. Both files are in
|
||||||
|
the `gites` folder of your data folder: `~/.local/share/gites` on Linux,
|
||||||
|
`%APPDATA%\gites` on Windows.
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
gites is a [Tauri](https://tauri.app) app: the window is Vue and TypeScript, and the part
|
||||||
|
that runs git is Rust. [mise](https://mise.jdx.dev) installs the right versions of Node,
|
||||||
|
Bun and Rust.
|
||||||
|
|
||||||
|
On Linux, Tauri also needs the WebKitGTK development packages. `scripts/ci-setup.sh`
|
||||||
|
lists them for Debian and Ubuntu.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mise install # Node, Bun and Rust
|
||||||
|
bun install # packages for the window code
|
||||||
|
|
||||||
|
mise run dev # run gites with live reload; add a path to open a repository
|
||||||
|
mise run test # unit tests for the window code and the Rust code
|
||||||
|
mise run check # type checks for both
|
||||||
|
mise run install # build a release binary and install it as ~/.local/bin/gites
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushing a tag such as `v1.2.3` builds the Linux and Windows packages and publishes them
|
||||||
|
on the releases page.
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "gites",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.12.4",
|
||||||
|
"@tauri-apps/plugin-clipboard-manager": "^2.5.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.8.3",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.12.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^5.0.4",
|
||||||
|
"vue-tsc": "^3.3.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||||
|
|
||||||
|
"@tauri-apps/api": ["@tauri-apps/api@2.12.4", "", {}, "sha512-pVmfis+4zT8H0Qhp3xPZx9Ju7kpzvA6rr/PSJPyuwfvucTQ8sVt61NYU9KMkK703UFlYadFmjKA4HhybOtN0gg=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli": ["@tauri-apps/cli@2.12.3", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.3", "@tauri-apps/cli-darwin-x64": "2.12.3", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.3", "@tauri-apps/cli-linux-arm64-gnu": "2.12.3", "@tauri-apps/cli-linux-arm64-musl": "2.12.3", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.3", "@tauri-apps/cli-linux-x64-gnu": "2.12.3", "@tauri-apps/cli-linux-x64-musl": "2.12.3", "@tauri-apps/cli-win32-arm64-msvc": "2.12.3", "@tauri-apps/cli-win32-ia32-msvc": "2.12.3", "@tauri-apps/cli-win32-x64-msvc": "2.12.3" }, "bin": { "tauri": "tauri.js" } }, "sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.3", "", { "os": "linux", "cpu": "arm" }, "sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.3", "", { "os": "linux", "cpu": "none" }, "sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.3", "", { "os": "win32", "cpu": "x64" }, "sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "sha512-dibUy1nKURYDXRMvjNk4deAbTZ5jPQ8wCE4nolt5xniryWgXQ4sK2GkFXoXlHbRdPzfZptZj95BRLfalA7m/2Q=="],
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.8.3", "", { "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "sha512-Hd/yBfk2R5v2MFrz/0VArSXeh8xKLLXGunqSiTtcHKn1zNZwaKujuA0VSwUvNfwC4hDoF1PveS+gBDNe3nHspw=="],
|
||||||
|
|
||||||
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
||||||
|
|
||||||
|
"@vitest/mocker": ["@vitest/mocker@5.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ=="],
|
||||||
|
|
||||||
|
"@vitest/spy": ["@vitest/spy@5.0.4", "", {}, "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw=="],
|
||||||
|
|
||||||
|
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||||
|
|
||||||
|
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||||
|
|
||||||
|
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||||
|
|
||||||
|
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
||||||
|
|
||||||
|
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="],
|
||||||
|
|
||||||
|
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="],
|
||||||
|
|
||||||
|
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
||||||
|
|
||||||
|
"@vue/language-core": ["@vue/language-core@3.3.13", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
||||||
|
|
||||||
|
"@vue/reactivity": ["@vue/reactivity@3.5.44", "", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
||||||
|
|
||||||
|
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
||||||
|
|
||||||
|
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="],
|
||||||
|
|
||||||
|
"@vue/server-renderer": ["@vue/server-renderer@3.5.44", "", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="],
|
||||||
|
|
||||||
|
"@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="],
|
||||||
|
|
||||||
|
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||||
|
|
||||||
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
|
"chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
|
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
|
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="],
|
||||||
|
|
||||||
|
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||||
|
|
||||||
|
"obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="],
|
||||||
|
|
||||||
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.30", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="],
|
||||||
|
|
||||||
|
"rolldown": ["rolldown@1.2.14", "", { "dependencies": { "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.14", "@rolldown/binding-android-arm64": "1.2.14", "@rolldown/binding-darwin-arm64": "1.2.14", "@rolldown/binding-darwin-x64": "1.2.14", "@rolldown/binding-freebsd-x64": "1.2.14", "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", "@rolldown/binding-linux-arm64-gnu": "1.2.14", "@rolldown/binding-linux-arm64-musl": "1.2.14", "@rolldown/binding-linux-ppc64-gnu": "1.2.14", "@rolldown/binding-linux-s390x-gnu": "1.2.14", "@rolldown/binding-linux-x64-gnu": "1.2.14", "@rolldown/binding-linux-x64-musl": "1.2.14", "@rolldown/binding-openharmony-arm64": "1.2.14", "@rolldown/binding-win32-arm64-msvc": "1.2.14", "@rolldown/binding-win32-x64-msvc": "1.2.14" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||||
|
|
||||||
|
"std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="],
|
||||||
|
|
||||||
|
"tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="],
|
||||||
|
|
||||||
|
"tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
|
"vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
|
||||||
|
|
||||||
|
"vitest": ["vitest@5.0.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.4", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.4", "@vitest/browser-preview": "5.0.4", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.4", "@vitest/coverage-v8": "5.0.4", "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||||
|
|
||||||
|
"vue": ["vue@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
||||||
|
|
||||||
|
"vue-tsc": ["vue-tsc@3.3.13", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
||||||
|
|
||||||
|
"why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="],
|
||||||
|
|
||||||
|
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
@@ -0,0 +1,142 @@
|
|||||||
|
# Repositories on another machine, over ssh: Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Open a git repository on another machine with `gites host:/path` or from the window, and show its history exactly as for a local repository, by running every git command through ssh.
|
||||||
|
|
||||||
|
**Architecture:** A new `Location` value (local folder, or ssh target plus path) replaces the folder that `git()`, `git::load`, `details::load` and `diff::load` take today. `git()` runs git directly for a local location, and `ssh … "git -C '<path>' '<arg>' …"` for a remote one, with a shared ssh connection. Nothing above `git()` changes except how the location is chosen at start-up and when opening a repository; the window gets a text field to type `host:/path`.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (Tauri 2 commands), Vue 3 + TypeScript, OpenSSH client, git.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Remote text form: `[user@]host:path`; `host` has no `/` or `\`, is longer than one character, and no local folder with exactly the whole text as its name exists (relative to the current folder). Everything else is local.
|
||||||
|
- A remote path starting with `~/` keeps the `~` outside the quotes.
|
||||||
|
- ssh options, in this order: `-o BatchMode=yes -o ConnectTimeout=10 -o ControlMaster=auto -o ControlPath=<socket> -o ControlPersist=10m`, then the target, then one command string. On Windows the three `Control…` options are left out.
|
||||||
|
- Socket: `<runtime folder>/gites-%C`, where the runtime folder is `dirs::runtime_dir()`, or `/tmp` when that is `None`.
|
||||||
|
- Quoting: each word in single quotes; a `'` inside becomes `'\''`.
|
||||||
|
- Remote repository name: `<target>:<output of git rev-parse --show-toplevel on the server>`.
|
||||||
|
- ssh exit code 255 means a connection error. Messages, exactly:
|
||||||
|
- `could not connect to <target>: <ssh stderr, trimmed>`
|
||||||
|
- if stderr contains `Host key verification failed`, add on a new line: ``Run `ssh <target>` once in a terminal to accept the server's key.``
|
||||||
|
- if stderr contains `Permission denied`, add on a new line: ``gites logs in with keys or your ssh agent only; check that `ssh <target>` works without a password.``
|
||||||
|
- The ssh program is `ssh`, unless the environment variable `GITES_SSH` names another program (used by tests).
|
||||||
|
- Window text: the menu and welcome entry reads `Open on another machine…`; the text field's placeholder is `user@host:/path/to/repo`.
|
||||||
|
- Commit messages describe the change only: no session links, no co-author lines.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- A remote path with spaces, such as `myserver:/srv/my app`, must open: covered in Task 2's end-to-end test.
|
||||||
|
- `ssh` missing on this machine: the error must say `could not run ssh: …`, not crash or hang; test in Task 2.
|
||||||
|
- A file name with `'`, `$`, `;` and a space must give the same diff remotely as locally: Task 2's end-to-end test.
|
||||||
|
- A `host:path` typed with surrounding spaces in the window's text field must open: the field trims it; test in Task 4 by hand (the window code has no unit-test surface for this component).
|
||||||
|
- A server whose login shell prints text for non-interactive sessions would corrupt git's output. This cannot be fixed in gites; it is listed so a reviewer checks that nothing in the code makes it worse (for example, no `-t` option is passed to ssh).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Location: reading `host:path`, quoting, the remote command
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/location.rs`
|
||||||
|
- Modify: `src-tauri/src/lib.rs` (add `mod location;`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `#[derive(Clone, Debug, PartialEq)] pub enum Location { Local(PathBuf), Ssh { target: String, path: String } }`
|
||||||
|
- `pub fn parse(text: &str, cwd: &Path) -> Location`
|
||||||
|
- `impl Location { pub fn name(&self) -> String }`: local → the path as text (`to_string_lossy`); remote → `"{target}:{path}"`.
|
||||||
|
- `impl Location { pub fn with_path(&self, path: &str) -> Location }`: same kind and target, another path (`Local(PathBuf::from(path))` for local).
|
||||||
|
- `pub fn quote(word: &str) -> String`
|
||||||
|
- `pub fn remote_command(path: &str, args: &[&str]) -> String`: `git -C <path> <args…>`, every word quoted with `quote`, except that a path starting with `~/` is written `~/` followed by the quoted rest.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** in `location.rs` under `#[cfg(test)] mod tests`:
|
||||||
|
- `reads_remote_locations`: `parse("myserver:/srv/app", cwd)` is `Ssh { target: "myserver", path: "/srv/app" }`; `parse("ada@myserver:~/x", cwd)` is `Ssh { target: "ada@myserver", path: "~/x" }`.
|
||||||
|
- `keeps_local_paths_local`: `C:\code\x`, `C:/code/x`, `./a:b`, `relative/dir` and `/abs/dir` all give `Local(PathBuf::from(text))`.
|
||||||
|
- `keeps_an_existing_local_folder_with_a_colon_local`: create `<temp>/notes:2024`; `parse("notes:2024", &temp)` is `Local(PathBuf::from("notes:2024"))`; with that folder removed it is `Ssh { target: "notes", path: "2024" }`.
|
||||||
|
- `names_locations`: `Ssh{…}.name()` is `"myserver:/srv/app"`.
|
||||||
|
- `quoted_words_survive_a_shell`: for each of `plain`, `with space`, `it's`, `"double"`, `$HOME`, `a;b`, `*`, `line\nbreak`: `sh -c "printf %s <quote(word)>"` prints exactly `word`.
|
||||||
|
- `builds_the_remote_command`: `remote_command("/srv/my app", &["log", "--format=%H"])` is `git -C '/srv/my app' 'log' '--format=%H'`; `remote_command("~/x y", &["status"])` is `git -C ~/'x y' 'status'`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add stubs** for every function in Interfaces (returning `Location::Local(PathBuf::new())` / `String::new()`) so the tests compile, then run `cargo test --manifest-path src-tauri/Cargo.toml location::` — Expected: the tests FAIL on their assertions.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** the functions in `location.rs` following Global Constraints.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run** `cargo test --manifest-path src-tauri/Cargo.toml location::` — Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit** `src-tauri/src/location.rs src-tauri/src/lib.rs` with message `Read host:path locations and quote words for a server's shell`.
|
||||||
|
|
||||||
|
### Task 2: Run git through ssh, and pass locations everywhere
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/src/git.rs` (`git`, `load`, `start_dir` and tests)
|
||||||
|
- Modify: `src-tauri/src/details.rs` (`load`)
|
||||||
|
- Modify: `src-tauri/src/diff.rs` (`load` and tests)
|
||||||
|
- Modify: `src-tauri/src/lib.rs` (`RepoDir`, every command that reads it, `open_repo`, `run`)
|
||||||
|
- Create: `src-tauri/src/test_ssh.rs` (`#[cfg(test)]` only; registered in `lib.rs` with `#[cfg(test)] mod test_ssh;`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `Location`, `parse`, `name`, `with_path`, `remote_command`.
|
||||||
|
- Produces:
|
||||||
|
- `pub fn git(loc: &Location, args: &[&str]) -> Result<String, String>`: calls `git_via` with the program from `GITES_SSH`, or `ssh`.
|
||||||
|
- `pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result<String, String>`: does the work; tests call it with another program without changing `GITES_SSH`, which other tests running at the same time rely on.
|
||||||
|
- `pub fn ssh_error(target: &str, stderr: &str) -> String` (in `git.rs`): the connection message from Global Constraints.
|
||||||
|
- `pub fn load(loc: &Location) -> Result<RepoData, String>`: `RepoData.root` is `loc.with_path(<toplevel>).name()`; later git calls in `load` use `loc.with_path(<toplevel>)`.
|
||||||
|
- `pub fn start_dir(arg: Option<String>, cwd: PathBuf) -> Option<Location>`: an argument goes through `location::parse(&arg, &cwd)` and is used as is; no argument keeps today's rule (current folder if inside a repository, as `Location::Local`).
|
||||||
|
- `details::load(loc: &Location, hash: &str)`, `diff::load(loc: &Location, hash, path, old_path, force)`: same behaviour, location instead of folder.
|
||||||
|
- `struct RepoDir(Mutex<Option<Location>>)`; `open_repo(path: String)` parses `path` with `location::parse` against the current folder.
|
||||||
|
- `test_ssh::fake_ssh() -> PathBuf`: writes once (guarded by `std::sync::Once`) an executable `sh` script to the temp folder that skips each `-o <value>` pair and the target, then runs `exec sh -c "$1"`; sets `GITES_SSH` to it; returns its path.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
- In `git.rs`: `explains_connection_errors`: `ssh_error("myserver", "Host key verification failed.\n")` contains `could not connect to myserver: Host key verification failed.` and ``Run `ssh myserver` once in a terminal``; with `"ada@myserver: Permission denied (publickey).\n"` it contains `gites logs in with keys or your ssh agent only`; with `"ssh: connect to host myserver port 22: Connection timed out"` it contains only the first line.
|
||||||
|
- In `git.rs`: `loads_a_repository_through_ssh`: make a repository at `<temp>/remote repo` with one commit, call `fake_ssh()`, then `load(&Location::Ssh { target: "myserver".into(), path: "<temp>/remote repo".into() })` returns one commit and `root == "myserver:<temp>/remote repo"`.
|
||||||
|
- In `git.rs`: `reports_a_missing_ssh_program`: `git_via(OsStr::new("/no/such/ssh"), &Location::Ssh{…}, &["status"])` is an `Err` starting with `could not run ssh:`.
|
||||||
|
- In `diff.rs`, module `with_a_repository`: `shows_the_same_diff_through_ssh`: a repository with a file named `it's $a; b.txt` changed in one commit; `load` through `Location::Local` and through `Location::Ssh` (with `fake_ssh()`) give equal `FileDiff`s with at least one hunk.
|
||||||
|
- Update the existing tests to pass `&Location::Local(…)` instead of a folder.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run** `cargo test --manifest-path src-tauri/Cargo.toml` — Expected: the new tests FAIL (compile errors are not acceptable: first add stubs for `ssh_error` and the new `git`/`load` signatures).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
- `git()`: local as today (keep the Windows no-console flag on both paths). Remote: program from `GITES_SSH` or `ssh`; arguments per Global Constraints; command from `remote_command`. A failure to start gives `could not run ssh: <error>`. Exit code 255 gives `ssh_error(target, stderr)`; other failures give the trimmed stderr, as today.
|
||||||
|
- Socket path: `dirs::runtime_dir().unwrap_or_else(|| PathBuf::from("/tmp")).join("gites-%C")`.
|
||||||
|
- Thread the `Location` through `load`, `details::load`, `diff::load`, `start_dir` and `lib.rs`.
|
||||||
|
- `run()`: pass the first argument as a `String` (`to_string_lossy`) to `start_dir`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run** `cargo test --manifest-path src-tauri/Cargo.toml` and `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets` — Expected: all tests PASS; clippy shows no new warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit** the files above with message `Run git through ssh for repositories on another machine`.
|
||||||
|
|
||||||
|
### Task 3: Open a remote repository from the window
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/RepoMenu.vue`
|
||||||
|
- Modify: `src/components/Welcome.vue`
|
||||||
|
- Modify: `src/App.vue` (`load`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the `open_repo` command from Task 2, which now accepts `host:path` text; App's existing `openRepo(path: string)`.
|
||||||
|
- Produces: both components emit the existing `pick` event with the trimmed text typed in the field.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the entry.** In `RepoMenu.vue`, below "Open folder…", an item `Open on another machine…` that, when chosen (click or <kbd>Enter</kbd> on it), turns into a text input with placeholder `user@host:/path/to/repo`. <kbd>Enter</kbd> in the input emits `pick` with the trimmed text when it is not empty; <kbd>Esc</kbd> turns it back into the item. Keys typed in the input must not reach the menu's own key handler (no `j`/`k`/`o` actions while typing). In `Welcome.vue`, the same input next to "Choose a folder…".
|
||||||
|
|
||||||
|
- [ ] **Step 2: Keep the graph when a reload fails.** In `App.vue`'s `load()`: when a repository is already on screen (`repo.value` is set), show the error with `showToast` and keep the graph; only when nothing is open yet, set `error.value` as today. The spec requires this for a connection that drops while a repository is open.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run** `bun run vue-tsc --noEmit` and `bun run vitest run` — Expected: no type errors, all tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Check in the browser** with the stand-in backend used for the earlier features (Vite dev server plus a mocked `__TAURI_INTERNALS__.invoke` whose `open_repo` records its argument): type ` myserver:/srv/app ` in each of the two places, press <kbd>Enter</kbd>, and confirm `open_repo` received `myserver:/srv/app`. Confirm <kbd>Esc</kbd> in the menu's field closes only the field. Then make the mocked `load_repo` throw and trigger a reload (focus the window): the graph stays and a message appears at the bottom.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit** with message `Open on another machine from the window; keep the graph when a reload fails`.
|
||||||
|
|
||||||
|
### Task 4: README and a check against a real server
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: README.** In "What it does", a bullet: repositories on another machine open with `gites myserver:/srv/app`, or from the repository menu; git runs there through ssh, which must log in without a password. In "Use", the line `gites myserver:/srv/app # a repository on another machine, over ssh`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Real server, by hand.** Build with `mise run dev <host>:<path>` against a server the user names. Check: the graph loads; a commit's details; a file's diff; reload with <kbd>r</kbd>; a wrong path shows git's "not a git repository" message; an unknown host shows the connection message. Report what was checked and what was not.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run** `mise run test` and `mise run check` — Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** `README.md` with message `README: repositories on another machine`.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Repositories on another machine, over ssh
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Open a git repository that lives on another machine and see its history exactly as
|
||||||
|
gites shows a local one: branches, tags, remote branches, commits, the details panel
|
||||||
|
and diffs. gites shows the state of that folder on that machine, including its local
|
||||||
|
branches and which branch is checked out there, not the state of its origin.
|
||||||
|
|
||||||
|
Only history is shown, as for local repositories today. Uncommitted changes are not
|
||||||
|
part of this work.
|
||||||
|
|
||||||
|
Linux and macOS are the platforms that matter. Windows must work, but may be slow.
|
||||||
|
|
||||||
|
## How the user opens one
|
||||||
|
|
||||||
|
- Command line: `gites myserver:/srv/app` or `gites user@myserver:/srv/app`.
|
||||||
|
`myserver` can be any host name from `~/.ssh/config`, so ports, user names and keys
|
||||||
|
set there apply.
|
||||||
|
- In the window: the repository menu (`o`) and the welcome screen get an entry
|
||||||
|
"Open on another machine…" with one text field for `[user@]host:path`.
|
||||||
|
- Remote repositories go into the recent list like local ones, written as
|
||||||
|
`myserver:/srv/app`.
|
||||||
|
|
||||||
|
## Where a repository is
|
||||||
|
|
||||||
|
The Rust side stores the open repository as one of two kinds:
|
||||||
|
|
||||||
|
- a local folder, as today;
|
||||||
|
- an ssh target (`myserver` or `user@myserver`) and a path on that machine.
|
||||||
|
|
||||||
|
One function turns text into a location. The text means a remote repository when it
|
||||||
|
has the form `[user@]host:path`, where `host` contains no `/` or `\` and is longer than
|
||||||
|
one character, and no local folder with exactly that name exists. Every other text is a
|
||||||
|
local folder. So `C:\code\x` and `C:/code/x` stay local, `./a:b` stays local, and a
|
||||||
|
folder in the current folder named `notes:2024` stays local when it exists.
|
||||||
|
|
||||||
|
A path on the server that is `~` or starts with `~/` is handed to the server's shell with the
|
||||||
|
`~` outside the quotes, so the shell expands it to the home folder there.
|
||||||
|
|
||||||
|
## Running git
|
||||||
|
|
||||||
|
`git()` in `src-tauri/src/git.rs` takes the repository's location instead of a folder.
|
||||||
|
|
||||||
|
- Local: `git -C <folder> <args>`, as today, with the Windows flag that keeps console
|
||||||
|
windows from opening.
|
||||||
|
- Remote: the program `ssh`, with these options, then the target, then one command
|
||||||
|
string for the server's shell:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10
|
||||||
|
-o ServerAliveInterval=5 -o ServerAliveCountMax=2
|
||||||
|
-o ControlMaster=auto -o ControlPath=<socket> -o ControlPersist=10m
|
||||||
|
user@myserver "git -C '/srv/app' 'log' '--format=…' …"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `BatchMode=yes`: ssh never asks for a password or a yes/no answer, because gites
|
||||||
|
has no terminal to ask in. Keys and the ssh agent work.
|
||||||
|
- `ConnectTimeout=10`: a server that does not answer gives an error after 10 seconds
|
||||||
|
instead of a window that waits for ever.
|
||||||
|
- `ServerAliveInterval=5`, `ServerAliveCountMax=2`: when a server stops answering
|
||||||
|
on a connection that is already open (also a shared one), ssh notices it in about
|
||||||
|
10 seconds and gives an error instead of waiting for ever.
|
||||||
|
- `ControlMaster`, `ControlPath`, `ControlPersist`: the first command opens one
|
||||||
|
connection and leaves it open; later commands go through it and cost about one
|
||||||
|
network round trip each instead of a new login. The connection closes 10 minutes
|
||||||
|
after the last command. The socket is `gites-%C` in the user's runtime folder
|
||||||
|
(`/run/user/<id>` on Linux). Where there is none (always on macOS), it is in a
|
||||||
|
`gites` folder in the user's cache folder, made so that only the user can open it;
|
||||||
|
never in `/tmp`. If neither folder can be found or made, these three options are
|
||||||
|
left out and every command opens a new connection. `%C` is a hash ssh makes from
|
||||||
|
the host, port and user, 40 characters long. macOS accepts a socket path of at most
|
||||||
|
103 characters (Linux 107), and while ssh sets up a shared connection it adds a
|
||||||
|
temporary end of up to 18 characters to the file name. So gites counts the folder's
|
||||||
|
length + 1 + 6 (`gites-`) + 40 + 18; if that is more than 103 (one limit on every
|
||||||
|
system), these three options are left out too. The folder is found, made and checked
|
||||||
|
once when gites first runs ssh, not for every command.
|
||||||
|
- On Windows, ssh cannot share connections, so these three options are left out and
|
||||||
|
every command opens a new connection.
|
||||||
|
- Quoting: every word of the git command is put in single quotes, and a `'` inside a
|
||||||
|
word is written as `'\''`. This works in sh, bash, zsh and fish, so a file name with
|
||||||
|
spaces, quotes, `$`, `;` or a line break reaches git unchanged and cannot run
|
||||||
|
anything else on the server.
|
||||||
|
|
||||||
|
Nothing above `git()` changes: loading the graph, the details panel, diffs and the
|
||||||
|
reload when the window gets focus all run through it.
|
||||||
|
|
||||||
|
## Names and saved data
|
||||||
|
|
||||||
|
A remote repository's name is `<target>:<top folder>`, where the top folder is what
|
||||||
|
`git rev-parse --show-toplevel` prints on the server. That name is used:
|
||||||
|
|
||||||
|
- in the window title and the header;
|
||||||
|
- in the recent list in `settings.json`;
|
||||||
|
- as the key of the repository's filters in `filters.json`, so filters are remembered
|
||||||
|
for remote repositories just like for local ones.
|
||||||
|
|
||||||
|
## Start-up
|
||||||
|
|
||||||
|
A remote location given on the command line becomes the open repository at once. gites
|
||||||
|
does not test the connection before the window appears, so a slow server never delays
|
||||||
|
the window. If the server cannot be reached, the first load fails and the window shows
|
||||||
|
the error screen, as it does today for a local path that is not a repository.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
ssh exits with code 255 when the connection fails, and with git's own exit code
|
||||||
|
otherwise. That tells a connection problem from a git error:
|
||||||
|
|
||||||
|
| What happened | What gites shows |
|
||||||
|
|---|---|
|
||||||
|
| Connection failed (exit code 255) | "could not connect to myserver:" and ssh's message |
|
||||||
|
| …and ssh says "Host key verification failed" | also: "Run `ssh myserver` once in a terminal to accept the server's key." |
|
||||||
|
| …and ssh says "Permission denied" | also: "gites logs in with keys or your ssh agent only; check that `ssh myserver` works without a password." |
|
||||||
|
| The server does not answer within 10 seconds | the connection error above, with ssh's timeout message |
|
||||||
|
| git fails on the server, for example "not a git repository" | git's message, as today |
|
||||||
|
|
||||||
|
If the connection drops while a repository is open, the next command shows its error
|
||||||
|
as a message at the bottom of the window and the graph that is already on screen
|
||||||
|
stays. The next reload connects again by itself.
|
||||||
|
|
||||||
|
A message that reports an error stays for 8 seconds, or until it is clicked, and each
|
||||||
|
hint line above stays on its own line. Other messages go after 1.8 seconds, as before.
|
||||||
|
|
||||||
|
The commands that run git run away from the thread that draws the window, so the
|
||||||
|
window does not freeze while it waits for a slow server.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Reading locations: `host:/path`, `user@host:/path`, `host:~/x`, `C:\x`, `C:/x`, a
|
||||||
|
plain relative path, and an existing local folder with a `:` in its name.
|
||||||
|
- Quoting: words with spaces, `'`, `"`, `$`, `;`, `*` and a line break are quoted, run
|
||||||
|
through a real `sh -c`, and come out unchanged.
|
||||||
|
- End to end without a server: for tests, the ssh program can be replaced by a small
|
||||||
|
script that drops the ssh options and runs the command string with `sh -c` on this
|
||||||
|
machine, the way a server's shell would. With it, the existing tests for loading a
|
||||||
|
repository, commit details and diffs also run against a "remote" location, including
|
||||||
|
files with awkward names. CI runs these.
|
||||||
|
- By hand, before release: open a repository on a real server, check the graph, a
|
||||||
|
commit's details and a diff, then wait for the shared connection to close and reload.
|
||||||
|
|
||||||
|
## Not part of this work
|
||||||
|
|
||||||
|
- Uncommitted changes on the remote machine.
|
||||||
|
- Passwords and other questions ssh might ask.
|
||||||
|
- `ssh://` addresses; ports and users go in `~/.ssh/config` or `user@host`.
|
||||||
|
- Closing the shared connection when gites quits; it closes itself after 10 minutes.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
|
||||||
|
|
||||||
|
lockfile_version = 1
|
||||||
|
|
||||||
|
[[tools.bun]]
|
||||||
|
version = "1.4.3"
|
||||||
|
backend = "core:bun"
|
||||||
|
specifiers = ["1.4"]
|
||||||
|
|
||||||
|
[tools.bun."platforms.linux-arm64"]
|
||||||
|
checksum = "sha256:efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-aarch64.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.linux-arm64-musl"]
|
||||||
|
checksum = "sha256:8de8a0003cc2707bb555887e3d450e24e1edf3f605f491393e89ed61a8853420"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-aarch64-musl.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.linux-x64"]
|
||||||
|
checksum = "sha256:3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.linux-x64-baseline"]
|
||||||
|
checksum = "sha256:1fc2edac843102909e3a1be1d8d9802cc6071cf074e67e88231f4ffe0f8b397b"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64-baseline.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.linux-x64-musl"]
|
||||||
|
checksum = "sha256:6e9ea98554ea0d05efda5840ae292618aaa3adefd66b0853520c5c86a37eac9c"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64-musl.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.linux-x64-musl-baseline"]
|
||||||
|
checksum = "sha256:4b5b0b5503f6ebf15161e8c56eb5a263134c05cd98a86dc11ee43ac5bda9862e"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64-musl-baseline.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.macos-arm64"]
|
||||||
|
checksum = "sha256:80afd4c069b46a6fa8f3ec520bebc750a7843e4a8d5b9583d29ad436c00a3403"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-darwin-aarch64.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.macos-x64"]
|
||||||
|
checksum = "sha256:62660e6438eb872278877acca77e1152d5d031324df368d4a81b428211489d2d"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-darwin-x64.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.macos-x64-baseline"]
|
||||||
|
checksum = "sha256:0fde04e8b092156b97fe6432e899baf16d9ed6ce52cbb4218d2d9c07a2f827ef"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-darwin-x64-baseline.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.windows-x64"]
|
||||||
|
checksum = "sha256:0bf84e808552beea67bf1202a287a35c6c825d837b287b3f8e9dd513dba35d3d"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-windows-x64.zip"
|
||||||
|
|
||||||
|
[tools.bun."platforms.windows-x64-baseline"]
|
||||||
|
checksum = "sha256:d7a7d3eb216614544aab0cde2d8d9f2e4e87839bf5a30a1cd11d077f02d84e0c"
|
||||||
|
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-windows-x64-baseline.zip"
|
||||||
|
|
||||||
|
[[tools.node]]
|
||||||
|
version = "24.21.0"
|
||||||
|
backend = "core:node"
|
||||||
|
specifiers = ["24"]
|
||||||
|
|
||||||
|
[tools.node."platforms.linux-arm64"]
|
||||||
|
checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
|
||||||
|
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.linux-arm64-musl"]
|
||||||
|
checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4"
|
||||||
|
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.linux-x64"]
|
||||||
|
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
|
||||||
|
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.linux-x64-baseline"]
|
||||||
|
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
|
||||||
|
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.linux-x64-musl"]
|
||||||
|
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
|
||||||
|
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.linux-x64-musl-baseline"]
|
||||||
|
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
|
||||||
|
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.macos-arm64"]
|
||||||
|
checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057"
|
||||||
|
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.macos-x64"]
|
||||||
|
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"
|
||||||
|
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.macos-x64-baseline"]
|
||||||
|
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"
|
||||||
|
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz"
|
||||||
|
|
||||||
|
[tools.node."platforms.windows-x64"]
|
||||||
|
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
|
||||||
|
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip"
|
||||||
|
|
||||||
|
[tools.node."platforms.windows-x64-baseline"]
|
||||||
|
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
|
||||||
|
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip"
|
||||||
|
|
||||||
|
[[tools.rust]]
|
||||||
|
version = "1.99.0"
|
||||||
|
backend = "core:rust"
|
||||||
|
specifiers = ["1.99"]
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
[tools]
|
[tools]
|
||||||
node = "24"
|
node = "24"
|
||||||
pnpm = "11"
|
bun = "1.4"
|
||||||
|
rust = "1.99"
|
||||||
|
|
||||||
[tasks.dev]
|
[tasks.dev]
|
||||||
description = "Run the app in development mode. Pass a repo path: mise run dev /path/to/repo"
|
description = "Run the app in development mode. Pass a repo path: mise run dev /path/to/repo"
|
||||||
run = "pnpm tauri dev -- --"
|
run = "bun run tauri dev -- --"
|
||||||
|
|
||||||
[tasks.test]
|
[tasks.test]
|
||||||
description = "Run unit tests"
|
description = "Run unit tests"
|
||||||
run = "pnpm vitest run"
|
run = ["bun run vitest run", "cargo test --manifest-path src-tauri/Cargo.toml"]
|
||||||
|
|
||||||
[tasks.check]
|
[tasks.check]
|
||||||
description = "Type-check the frontend and the Rust code"
|
description = "Type-check the frontend and the Rust code"
|
||||||
run = ["pnpm vue-tsc --noEmit", "cargo check --manifest-path src-tauri/Cargo.toml"]
|
run = ["bun run vue-tsc --noEmit", "cargo check --manifest-path src-tauri/Cargo.toml"]
|
||||||
|
|
||||||
[tasks.install]
|
[tasks.install]
|
||||||
description = "Build a release binary and install it as ~/.local/bin/gites"
|
description = "Build a release binary and install it as ~/.local/bin/gites"
|
||||||
run = [
|
run = [
|
||||||
"pnpm tauri build --no-bundle",
|
"bun run tauri build --no-bundle",
|
||||||
"install -Dm755 src-tauri/target/release/gites ~/.local/bin/gites",
|
"install -Dm755 src-tauri/target/release/gites ~/.local/bin/gites",
|
||||||
]
|
]
|
||||||
|
|||||||
+4
-4
@@ -10,13 +10,13 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2.12.4",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.4.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.5.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.8.2",
|
"@tauri-apps/plugin-dialog": "^2.8.3",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2.12.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
|
|||||||
Generated
-1037
File diff suppressed because it is too large
Load Diff
+11
-17
@@ -16,24 +16,18 @@ fi
|
|||||||
$SUDO apt-get update -q
|
$SUDO apt-get update -q
|
||||||
$SUDO apt-get install -yq --no-install-recommends "${packages[@]}"
|
$SUDO apt-get install -yq --no-install-recommends "${packages[@]}"
|
||||||
|
|
||||||
# Rust, cargo-xwin and mise may already be here from the CI cache.
|
# mise, and the Rust it installs through rustup, may already be here from the CI cache.
|
||||||
export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"
|
||||||
|
command -v mise >/dev/null || curl -fsSL https://mise.run | MISE_VERSION=v2026.9.1 sh
|
||||||
# Rust: not pinned in mise.toml, because mise needs rustup for it.
|
for dir in "$HOME/.cargo/bin" "$HOME/.local/bin" "$HOME/.local/share/mise/shims"; do
|
||||||
if ! command -v cargo >/dev/null; then
|
echo "$dir" >> "$GITHUB_PATH"
|
||||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
done
|
||||||
fi
|
# Node, Bun and Rust, at the versions in mise.toml.
|
||||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
mise trust && mise install
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
|
||||||
|
|
||||||
if [[ "${1:-}" == "--windows" ]]; then
|
if [[ "${1:-}" == "--windows" ]]; then
|
||||||
rustup target add x86_64-pc-windows-msvc
|
mise exec -- rustup target add x86_64-pc-windows-msvc
|
||||||
command -v cargo-xwin >/dev/null || cargo install --locked cargo-xwin
|
command -v cargo-xwin >/dev/null || mise exec -- cargo install --locked cargo-xwin
|
||||||
fi
|
fi
|
||||||
|
|
||||||
command -v mise >/dev/null || curl -fsSL https://mise.run | MISE_VERSION=v2026.9.1 sh
|
bun install --frozen-lockfile
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
||||||
echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH"
|
|
||||||
export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"
|
|
||||||
mise trust && mise install
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -3420,9 +3420,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-clipboard-manager"
|
name = "tauri-plugin-clipboard-manager"
|
||||||
version = "2.4.2"
|
version = "2.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "640d0789c9db02265a800fded60520df5a3baa4a1b5f40715b83d58842c24fcb"
|
checksum = "9be5ed8d94c33afe8377e885119069f6508780659271d0a25cfc54d441f2bc81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"core:window:allow-set-title",
|
"core:window:allow-set-title",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
"dialog:allow-open"
|
"dialog:allow-open"
|
||||||
]
|
],
|
||||||
|
"platforms": ["linux", "windows", "macOS"]
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use crate::git::git;
|
use crate::git::git;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::Path;
|
use crate::location::Location;
|
||||||
|
|
||||||
#[derive(Serialize, Debug, PartialEq)]
|
#[derive(Serialize, Debug, PartialEq)]
|
||||||
pub struct ChangedFile {
|
pub struct ChangedFile {
|
||||||
@@ -16,7 +16,7 @@ pub struct ChangedFile {
|
|||||||
pub deleted: Option<u32>,
|
pub deleted: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug, PartialEq)]
|
||||||
pub struct CommitDetails {
|
pub struct CommitDetails {
|
||||||
hash: String,
|
hash: String,
|
||||||
author: String,
|
author: String,
|
||||||
@@ -63,13 +63,13 @@ pub fn parse_files(name_status: &str, numstat: &str) -> Vec<ChangedFile> {
|
|||||||
files
|
files
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(dir: &Path, hash: &str) -> Result<CommitDetails, String> {
|
pub fn load(loc: &Location, hash: &str) -> Result<CommitDetails, String> {
|
||||||
// The hash comes from the window; refuse anything git could read as an option.
|
// The hash comes from the window; refuse anything git could read as an option.
|
||||||
if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
return Err(format!("not a commit hash: {hash}"));
|
return Err(format!("not a commit hash: {hash}"));
|
||||||
}
|
}
|
||||||
let info = git(
|
let info = git(
|
||||||
dir,
|
loc,
|
||||||
&["show", "-s", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B", hash, "--"],
|
&["show", "-s", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B", hash, "--"],
|
||||||
)?;
|
)?;
|
||||||
let f: Vec<&str> = info.splitn(9, '\x1f').collect();
|
let f: Vec<&str> = info.splitn(9, '\x1f').collect();
|
||||||
@@ -90,7 +90,7 @@ pub fn load(dir: &Path, hash: &str) -> Result<CommitDetails, String> {
|
|||||||
let with = |flag: &'static str| {
|
let with = |flag: &'static str| {
|
||||||
let mut a = args.clone();
|
let mut a = args.clone();
|
||||||
a.insert(1, flag);
|
a.insert(1, flag);
|
||||||
git(dir, &a)
|
git(loc, &a)
|
||||||
};
|
};
|
||||||
let files = parse_files(&with("--name-status")?, &with("--numstat")?);
|
let files = parse_files(&with("--name-status")?, &with("--numstat")?);
|
||||||
|
|
||||||
@@ -153,4 +153,37 @@ mod tests {
|
|||||||
fn parses_an_empty_diff() {
|
fn parses_an_empty_diff() {
|
||||||
assert_eq!(parse_files("", ""), vec![]);
|
assert_eq!(parse_files("", ""), vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_the_same_details_through_ssh() {
|
||||||
|
use std::process::Command;
|
||||||
|
let dir = std::env::temp_dir().join(format!("gites-details-ssh-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let run = |args: &[&str]| {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&dir)
|
||||||
|
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"])
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
};
|
||||||
|
run(&["init", "-q", "-b", "main"]);
|
||||||
|
std::fs::write(dir.join("it's $a; b.txt"), "one\ntwo\n").unwrap();
|
||||||
|
run(&["add", "-A"]);
|
||||||
|
run(&["commit", "-q", "-m", "first"]);
|
||||||
|
std::fs::write(dir.join("it's $a; b.txt"), "one\nthree\n").unwrap();
|
||||||
|
run(&["commit", "-q", "-am", "second\n\nwith a body"]);
|
||||||
|
let hash = run(&["rev-parse", "HEAD"]);
|
||||||
|
crate::test_ssh::fake_ssh();
|
||||||
|
let remote = Location::Ssh { target: "myserver".into(), path: dir.to_string_lossy().into_owned() };
|
||||||
|
let local = load(&Location::Local(dir.clone()), &hash).unwrap();
|
||||||
|
let through_ssh = load(&remote, &hash).unwrap();
|
||||||
|
assert_eq!(local.files, vec![file("M", "it's $a; b.txt", None, Some(1), Some(1))]);
|
||||||
|
assert_eq!(local, through_ssh);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
//! The changes to one file in one commit, for the diff view.
|
||||||
|
|
||||||
|
use crate::git::git;
|
||||||
|
use serde::Serialize;
|
||||||
|
use crate::location::Location;
|
||||||
|
|
||||||
|
/// Above this many changed lines, or this many bytes of patch, the diff is only sent
|
||||||
|
/// when the user asks for it again.
|
||||||
|
const MAX_CHANGED_LINES: u32 = 20_000;
|
||||||
|
const MAX_PATCH_BYTES: usize = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, PartialEq)]
|
||||||
|
pub struct Line {
|
||||||
|
/// ' ' unchanged, '+' added, '-' removed.
|
||||||
|
pub kind: char,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, PartialEq)]
|
||||||
|
pub struct Hunk {
|
||||||
|
/// The whole `@@ … @@` line, with the function name git puts after it.
|
||||||
|
pub header: String,
|
||||||
|
pub old_start: u32,
|
||||||
|
pub new_start: u32,
|
||||||
|
pub lines: Vec<Line>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, PartialEq, Default)]
|
||||||
|
pub struct FileDiff {
|
||||||
|
pub binary: bool,
|
||||||
|
/// File sizes before and after the commit; only filled for binary files.
|
||||||
|
pub old_size: Option<u64>,
|
||||||
|
pub new_size: Option<u64>,
|
||||||
|
/// Empty for a binary file, a rename without changes, or a diff that is too large.
|
||||||
|
pub hunks: Vec<Hunk>,
|
||||||
|
/// The number of changed lines, when the diff is too large and was not sent.
|
||||||
|
pub too_large: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `git diff-tree -p` output for one file.
|
||||||
|
pub fn parse_patch(patch: &str) -> FileDiff {
|
||||||
|
let mut diff = FileDiff::default();
|
||||||
|
let mut in_hunk = false;
|
||||||
|
for raw in patch.lines() {
|
||||||
|
if let Some(rest) = raw.strip_prefix("@@ ") {
|
||||||
|
let mut starts = rest.split_whitespace().take(2).map(|r| {
|
||||||
|
r[1..].split(',').next().and_then(|n| n.parse().ok()).unwrap_or(0)
|
||||||
|
});
|
||||||
|
let (old_start, new_start) = (starts.next().unwrap_or(0), starts.next().unwrap_or(0));
|
||||||
|
diff.hunks.push(Hunk { header: raw.to_string(), old_start, new_start, lines: Vec::new() });
|
||||||
|
in_hunk = true;
|
||||||
|
} else if raw.starts_with("diff --git ") {
|
||||||
|
// A second file: git did not pair the old and new path as a rename.
|
||||||
|
in_hunk = false;
|
||||||
|
} else if !in_hunk {
|
||||||
|
if raw.starts_with("Binary files ") || raw == "GIT binary patch" {
|
||||||
|
diff.binary = true;
|
||||||
|
}
|
||||||
|
} else if let (Some(kind @ (' ' | '+' | '-')), Some(hunk)) = (raw.chars().next(), diff.hunks.last_mut()) {
|
||||||
|
hunk.lines.push(Line { kind, text: raw[1..].to_string() });
|
||||||
|
} else if raw.is_empty() {
|
||||||
|
// Some tools strip the space that starts an empty unchanged line.
|
||||||
|
if let Some(hunk) = diff.hunks.last_mut() {
|
||||||
|
hunk.lines.push(Line { kind: ' ', text: String::new() });
|
||||||
|
}
|
||||||
|
} else if !raw.starts_with('\\') {
|
||||||
|
// "\ No newline at end of file" is skipped; anything else ends the hunks.
|
||||||
|
in_hunk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(loc: &Location, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result<FileDiff, String> {
|
||||||
|
// The hash comes from the window; refuse anything git could read as an option.
|
||||||
|
if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err(format!("not a commit hash: {hash}"));
|
||||||
|
}
|
||||||
|
let parents = git(loc, &["rev-list", "--parents", "-n", "1", hash, "--"])?;
|
||||||
|
// A merge is compared with its first parent, as in the preview panel's file list.
|
||||||
|
let parent = parents.split_whitespace().nth(1).map(String::from);
|
||||||
|
|
||||||
|
let mut args = vec!["diff-tree", "-p", "-M", "-U3", "--no-color", "--no-ext-diff", "--no-textconv"];
|
||||||
|
match &parent {
|
||||||
|
Some(p) => args.extend([p.as_str(), hash]),
|
||||||
|
None => args.extend(["--root", "--no-commit-id", hash]),
|
||||||
|
}
|
||||||
|
args.extend(["--", path]);
|
||||||
|
if let Some(old) = old_path {
|
||||||
|
args.push(old);
|
||||||
|
}
|
||||||
|
let patch = git(loc, &args)?;
|
||||||
|
let mut diff = parse_patch(&patch);
|
||||||
|
|
||||||
|
if diff.binary {
|
||||||
|
let size = |spec: String| git(loc, &["cat-file", "-s", &spec]).ok().and_then(|s| s.trim().parse().ok());
|
||||||
|
diff.old_size = parent.as_ref().and_then(|p| size(format!("{p}:{}", old_path.unwrap_or(path))));
|
||||||
|
diff.new_size = size(format!("{hash}:{path}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = diff.hunks.iter().flat_map(|h| &h.lines).filter(|l| l.kind != ' ').count() as u32;
|
||||||
|
if !force && (changed > MAX_CHANGED_LINES || patch.len() > MAX_PATCH_BYTES) {
|
||||||
|
diff.hunks.clear();
|
||||||
|
diff.too_large = Some(changed);
|
||||||
|
}
|
||||||
|
Ok(diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn line(kind: char, text: &str) -> Line {
|
||||||
|
Line { kind, text: text.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_modified_file() {
|
||||||
|
let patch = "\
|
||||||
|
diff --git a/src/a.ts b/src/a.ts
|
||||||
|
index 1111111..2222222 100644
|
||||||
|
--- a/src/a.ts
|
||||||
|
+++ b/src/a.ts
|
||||||
|
@@ -1,3 +1,3 @@ export function f() {
|
||||||
|
one
|
||||||
|
-two
|
||||||
|
+TWO
|
||||||
|
three
|
||||||
|
@@ -10 +10,2 @@
|
||||||
|
-- a list item
|
||||||
|
+- a list item, longer
|
||||||
|
+++ not a header
|
||||||
|
";
|
||||||
|
let d = parse_patch(patch);
|
||||||
|
assert!(!d.binary);
|
||||||
|
assert_eq!(d.hunks.len(), 2);
|
||||||
|
assert_eq!(d.hunks[0].header, "@@ -1,3 +1,3 @@ export function f() {");
|
||||||
|
assert_eq!((d.hunks[0].old_start, d.hunks[0].new_start), (1, 1));
|
||||||
|
assert_eq!(d.hunks[0].lines, vec![line(' ', "one"), line('-', "two"), line('+', "TWO"), line(' ', "three")]);
|
||||||
|
assert_eq!((d.hunks[1].old_start, d.hunks[1].new_start), (10, 10));
|
||||||
|
assert_eq!(
|
||||||
|
d.hunks[1].lines,
|
||||||
|
vec![line('-', "- a list item"), line('+', "- a list item, longer"), line('+', "++ not a header")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_an_added_file_without_a_final_newline() {
|
||||||
|
let patch = "\
|
||||||
|
diff --git a/new.txt b/new.txt
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..3333333
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/new.txt
|
||||||
|
@@ -0,0 +1,2 @@
|
||||||
|
+first\r
|
||||||
|
+second
|
||||||
|
\\ No newline at end of file
|
||||||
|
";
|
||||||
|
let d = parse_patch(patch);
|
||||||
|
assert_eq!((d.hunks[0].old_start, d.hunks[0].new_start), (0, 1));
|
||||||
|
assert_eq!(d.hunks[0].lines, vec![line('+', "first"), line('+', "second")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_rename_without_changes() {
|
||||||
|
let patch = "\
|
||||||
|
diff --git a/old.rs b/new.rs
|
||||||
|
similarity index 100%
|
||||||
|
rename from old.rs
|
||||||
|
rename to new.rs
|
||||||
|
";
|
||||||
|
assert_eq!(parse_patch(patch), FileDiff::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_binary_file() {
|
||||||
|
let patch = "\
|
||||||
|
diff --git a/logo.png b/logo.png
|
||||||
|
index 1111111..2222222 100644
|
||||||
|
Binary files a/logo.png and b/logo.png differ
|
||||||
|
";
|
||||||
|
let d = parse_patch(patch);
|
||||||
|
assert!(d.binary);
|
||||||
|
assert!(d.hunks.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_an_empty_patch() {
|
||||||
|
assert_eq!(parse_patch(""), FileDiff::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_a_hash_that_git_could_read_as_an_option() {
|
||||||
|
let err = load(&Location::Local(".".into()), "--output=x", "a", None, false).unwrap_err();
|
||||||
|
assert!(err.contains("not a commit hash"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs git in a new repository with a few commits.
|
||||||
|
mod with_a_repository {
|
||||||
|
use super::super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
struct Repo(PathBuf);
|
||||||
|
|
||||||
|
impl Drop for Repo {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Repo {
|
||||||
|
fn new(name: &str) -> Repo {
|
||||||
|
let dir = std::env::temp_dir().join(format!("gites-diff-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let repo = Repo(dir);
|
||||||
|
repo.run(&["init", "-q", "-b", "main"]);
|
||||||
|
repo
|
||||||
|
}
|
||||||
|
fn run(&self, args: &[&str]) -> String {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&self.0)
|
||||||
|
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"])
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
fn loc(&self) -> Location {
|
||||||
|
Location::Local(self.0.clone())
|
||||||
|
}
|
||||||
|
fn write(&self, path: &str, content: &[u8]) {
|
||||||
|
std::fs::write(self.0.join(path), content).unwrap();
|
||||||
|
}
|
||||||
|
fn commit(&self, message: &str) -> String {
|
||||||
|
self.run(&["add", "-A"]);
|
||||||
|
self.run(&["commit", "-q", "-m", message]);
|
||||||
|
self.run(&["rev-parse", "HEAD"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texts(d: &FileDiff) -> Vec<String> {
|
||||||
|
d.hunks.iter().flat_map(|h| h.lines.iter().map(|l| format!("{}{}", l.kind, l.text))).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_the_first_commit_as_added_lines() {
|
||||||
|
let r = Repo::new("first");
|
||||||
|
r.write("a.txt", b"one\ntwo\n");
|
||||||
|
let first = r.commit("first");
|
||||||
|
let d = load(&r.loc(), &first, "a.txt", None, false).unwrap();
|
||||||
|
assert_eq!(texts(&d), vec!["+one", "+two"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_a_change_and_a_rename_with_changes() {
|
||||||
|
let r = Repo::new("rename");
|
||||||
|
let body: String = (1..=20).map(|i| format!("line {i}\n")).collect();
|
||||||
|
r.write("a.txt", body.as_bytes());
|
||||||
|
r.commit("first");
|
||||||
|
r.write("a.txt", body.replace("line 5\n", "line five\n").as_bytes());
|
||||||
|
let second = r.commit("second");
|
||||||
|
assert_eq!(
|
||||||
|
texts(&load(&r.loc(), &second, "a.txt", None, false).unwrap()),
|
||||||
|
vec![" line 2", " line 3", " line 4", "-line 5", "+line five", " line 6", " line 7", " line 8"]
|
||||||
|
);
|
||||||
|
|
||||||
|
r.run(&["mv", "a.txt", "b.txt"]);
|
||||||
|
r.write("b.txt", body.replace("line 5\n", "line five\n").replace("line 20\n", "line twenty\n").as_bytes());
|
||||||
|
let third = r.commit("third");
|
||||||
|
let d = load(&r.loc(), &third, "b.txt", Some("a.txt"), false).unwrap();
|
||||||
|
assert_eq!(&texts(&d)[3..5], ["-line 20", "+line twenty"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_nothing_for_a_rename_without_changes() {
|
||||||
|
let r = Repo::new("pure-rename");
|
||||||
|
r.write("a.txt", b"same\n");
|
||||||
|
r.commit("first");
|
||||||
|
r.run(&["mv", "a.txt", "b.txt"]);
|
||||||
|
let second = r.commit("second");
|
||||||
|
assert_eq!(load(&r.loc(), &second, "b.txt", Some("a.txt"), false).unwrap(), FileDiff::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gives_sizes_for_a_binary_file() {
|
||||||
|
let r = Repo::new("binary");
|
||||||
|
r.write("x.bin", b"\0\x01\x02");
|
||||||
|
r.commit("first");
|
||||||
|
r.write("x.bin", b"\0\x01\x02\x03\x04");
|
||||||
|
let second = r.commit("second");
|
||||||
|
let d = load(&r.loc(), &second, "x.bin", None, false).unwrap();
|
||||||
|
assert!(d.binary);
|
||||||
|
assert_eq!((d.old_size, d.new_size), (Some(3), Some(5)));
|
||||||
|
|
||||||
|
std::fs::remove_file(r.0.join("x.bin")).unwrap();
|
||||||
|
let third = r.commit("third");
|
||||||
|
let d = load(&r.loc(), &third, "x.bin", None, false).unwrap();
|
||||||
|
assert_eq!((d.old_size, d.new_size), (Some(5), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compares_a_merge_with_its_first_parent() {
|
||||||
|
let r = Repo::new("merge");
|
||||||
|
r.write("a.txt", b"base\n");
|
||||||
|
r.commit("base");
|
||||||
|
r.run(&["checkout", "-q", "-b", "side"]);
|
||||||
|
r.write("side.txt", b"from side\n");
|
||||||
|
r.commit("side");
|
||||||
|
r.run(&["checkout", "-q", "main"]);
|
||||||
|
r.write("a.txt", b"main\n");
|
||||||
|
r.commit("main");
|
||||||
|
r.run(&["merge", "-q", "--no-edit", "side"]);
|
||||||
|
let merge = r.run(&["rev-parse", "HEAD"]);
|
||||||
|
let d = load(&r.loc(), &merge, "side.txt", None, false).unwrap();
|
||||||
|
assert_eq!(texts(&d), vec!["+from side"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn holds_back_a_diff_that_is_too_large_until_forced() {
|
||||||
|
let r = Repo::new("large");
|
||||||
|
let body: String = (0..MAX_CHANGED_LINES + 1).map(|i| format!("{i}\n")).collect();
|
||||||
|
r.write("big.txt", body.as_bytes());
|
||||||
|
let first = r.commit("first");
|
||||||
|
let d = load(&r.loc(), &first, "big.txt", None, false).unwrap();
|
||||||
|
assert_eq!(d.too_large, Some(MAX_CHANGED_LINES + 1));
|
||||||
|
assert!(d.hunks.is_empty());
|
||||||
|
let d = load(&r.loc(), &first, "big.txt", None, true).unwrap();
|
||||||
|
assert_eq!(d.too_large, None);
|
||||||
|
assert_eq!(texts(&d).len(), (MAX_CHANGED_LINES + 1) as usize);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_the_same_diff_through_ssh() {
|
||||||
|
let r = Repo::new("ssh");
|
||||||
|
r.write("it's $a; b.txt", b"one\ntwo\n");
|
||||||
|
r.commit("first");
|
||||||
|
r.write("it's $a; b.txt", b"one\nthree\n");
|
||||||
|
let second = r.commit("second");
|
||||||
|
crate::test_ssh::fake_ssh();
|
||||||
|
let remote = Location::Ssh { target: "myserver".into(), path: r.0.to_string_lossy().into_owned() };
|
||||||
|
let name = "it's $a; b.txt";
|
||||||
|
let local = load(&r.loc(), &second, name, None, false).unwrap();
|
||||||
|
let through_ssh = load(&remote, &second, name, None, false).unwrap();
|
||||||
|
assert!(!local.hunks.is_empty());
|
||||||
|
assert_eq!(local, through_ssh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+356
-12
@@ -1,5 +1,7 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::Path;
|
use crate::location::{Location, remote_command};
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -31,22 +33,148 @@ pub struct RepoData {
|
|||||||
head: Option<String>,
|
head: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
/// The text to show when ssh could not reach the server.
|
||||||
let out = Command::new("git")
|
pub fn ssh_error(target: &str, stderr: &str) -> String {
|
||||||
.arg("-C")
|
let stderr = stderr.trim();
|
||||||
.arg(dir)
|
let mut message = format!("could not connect to {target}: {stderr}");
|
||||||
.args(args)
|
if stderr.contains("Host key verification failed") {
|
||||||
.output()
|
message.push_str(&format!("\nRun `ssh {target}` once in a terminal to accept the server's key."));
|
||||||
.map_err(|e| format!("could not run git: {e}"))?;
|
}
|
||||||
|
if stderr.contains("Permission denied") {
|
||||||
|
message.push_str(&format!(
|
||||||
|
"\ngites logs in with keys or your ssh agent only; check that `ssh {target}` works without a password."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder for the sockets of shared connections: `runtime`, the user's runtime folder,
|
||||||
|
/// or else a `gites` folder inside `cache`, the user's cache folder, made if missing so that
|
||||||
|
/// only the user can open it. `None` when neither gives a folder; then every command opens
|
||||||
|
/// its own connection.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn socket_dir(runtime: Option<PathBuf>, cache: Option<PathBuf>) -> Option<PathBuf> {
|
||||||
|
if runtime.is_some() {
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
let dir = cache?.join("gites");
|
||||||
|
let mut builder = std::fs::DirBuilder::new();
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||||
|
match builder.create(&dir) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => {}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
// A folder left from before may let others in; only the user may reach the sockets.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).ok()?;
|
||||||
|
}
|
||||||
|
Some(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The start of every socket's file name; ssh puts `%C`, a hash of the connection, after it.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
const SOCKET_PREFIX: &str = "gites-";
|
||||||
|
|
||||||
|
/// The longest socket path macOS accepts (Linux accepts 107). One limit for every system
|
||||||
|
/// keeps one rule.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
const MAX_SOCKET_PATH: usize = 103;
|
||||||
|
|
||||||
|
/// True when sockets in `dir` stay short enough for the system. ssh puts in place of `%C` a
|
||||||
|
/// hash of 40 characters, and while it sets up a shared connection it adds a temporary end of
|
||||||
|
/// up to 18 characters to the file name.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn socket_path_fits(dir: &std::path::Path) -> bool {
|
||||||
|
const HASH: usize = 40;
|
||||||
|
const TEMPORARY_END: usize = 18;
|
||||||
|
// The 1 is the `/` between the folder and the file name.
|
||||||
|
dir.as_os_str().len() + 1 + SOCKET_PREFIX.len() + HASH + TEMPORARY_END <= MAX_SOCKET_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path ssh is given for the socket of shared connections, or `None` to share none: when
|
||||||
|
/// there is no folder for sockets, or the path would be too long.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn control_path(dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||||
|
dir.filter(|d| socket_path_fits(d)).map(|d| d.join(format!("{SOCKET_PREFIX}%C")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs git in the folder `loc` names: here, or on another machine through ssh.
|
||||||
|
pub fn git(loc: &Location, args: &[&str]) -> Result<String, String> {
|
||||||
|
// Tests swap in a script that acts like ssh.
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.with_borrow(|fake| fake.clone()) {
|
||||||
|
return git_via(&fake, loc, args);
|
||||||
|
}
|
||||||
|
git_via(&[OsString::from("ssh")], loc, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the work of `git`. For a remote location it starts `ssh`: the first word of `ssh` is
|
||||||
|
/// the program and the other words come before the ssh options (tests use them).
|
||||||
|
pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result<String, String> {
|
||||||
|
let mut cmd = match loc {
|
||||||
|
Location::Local(dir) => {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.arg("-C").arg(dir).args(args);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
Location::Ssh { target, path } => {
|
||||||
|
let mut cmd = Command::new(&ssh[0]);
|
||||||
|
cmd.args(&ssh[1..]);
|
||||||
|
// Never ask for a password: gites has no terminal to ask on.
|
||||||
|
cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]);
|
||||||
|
// If the server stops answering, ssh gives up after about 10 seconds instead of
|
||||||
|
// waiting for ever, also on a connection that is shared.
|
||||||
|
cmd.args(["-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2"]);
|
||||||
|
// Later commands to the same server reuse one connection for a while. Windows ssh
|
||||||
|
// does not support this.
|
||||||
|
// The folder is found, made and checked once per run, not for every command.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
static CONTROL_PATH: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
||||||
|
let path = CONTROL_PATH
|
||||||
|
.get_or_init(|| control_path(socket_dir(dirs::runtime_dir(), dirs::cache_dir())));
|
||||||
|
if let Some(path) = path {
|
||||||
|
cmd.args(["-o", "ControlMaster=auto", "-o"]);
|
||||||
|
cmd.arg(format!("ControlPath={}", path.display()));
|
||||||
|
cmd.args(["-o", "ControlPersist=10m"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `--` keeps a target from being read as an option.
|
||||||
|
cmd.arg("--").arg(target).arg(remote_command(path, args));
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// gites has no console, so Windows would open a console window for every program it
|
||||||
|
// starts. Each one flashes on screen and takes the focus, and gites reloads on focus.
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
let program = if matches!(loc, Location::Local(_)) { "git" } else { "ssh" };
|
||||||
|
let out = cmd.output().map_err(|e| format!("could not run {program}: {e}"))?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
if let Location::Ssh { target, .. } = loc
|
||||||
|
&& out.status.code() == Some(255)
|
||||||
|
{
|
||||||
|
return Err(ssh_error(target, &stderr));
|
||||||
|
}
|
||||||
|
return Err(stderr.trim().to_string());
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(dir: &Path) -> Result<RepoData, String> {
|
pub fn load(loc: &Location) -> Result<RepoData, String> {
|
||||||
let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string();
|
let top = git(loc, &["rev-parse", "--show-toplevel"])?.trim().to_string();
|
||||||
let root_path = Path::new(&root);
|
let root_loc = loc.with_path(&top);
|
||||||
|
let root_path = &root_loc;
|
||||||
|
let root = root_loc.name();
|
||||||
|
|
||||||
let head = git(root_path, &["rev-parse", "--verify", "-q", "HEAD"])
|
let head = git(root_path, &["rev-parse", "--verify", "-q", "HEAD"])
|
||||||
.ok()
|
.ok()
|
||||||
@@ -125,3 +253,219 @@ pub fn load(dir: &Path) -> Result<RepoData, String> {
|
|||||||
|
|
||||||
Ok(RepoData { root, commits, refs, head_ref, head })
|
Ok(RepoData { root, commits, refs, head_ref, head })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The repository to open at start: the path given on the command line, or else the
|
||||||
|
/// current folder if it is inside a repository. `None` means gites starts with its
|
||||||
|
/// welcome screen. A path given on the command line is used even if it is not a
|
||||||
|
/// repository, so the window can say what is wrong with it.
|
||||||
|
pub fn start_dir(arg: Option<std::ffi::OsString>, cwd: PathBuf) -> Option<Location> {
|
||||||
|
if let Some(arg) = arg {
|
||||||
|
// Text that is not valid UTF-8 cannot be a `host:path`; keep it exactly as given.
|
||||||
|
return Some(match arg.to_str() {
|
||||||
|
Some(text) => crate::location::parse(text, &cwd),
|
||||||
|
None => Location::Local(PathBuf::from(arg)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let here = Location::Local(cwd);
|
||||||
|
git(&here, &["rev-parse", "--git-dir"]).ok().map(|_| here)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("gites-start-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_the_path_given_on_the_command_line_even_if_it_is_not_a_repository() {
|
||||||
|
let given = PathBuf::from("/no/such/folder");
|
||||||
|
assert_eq!(start_dir(Some("/no/such/folder".into()), temp_dir("arg")), Some(Location::Local(given)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn keeps_a_command_line_path_that_is_not_valid_utf8_intact() {
|
||||||
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
let bytes = b"/tmp/caf\xe9".to_vec();
|
||||||
|
let got = start_dir(Some(std::ffi::OsString::from_vec(bytes.clone())), temp_dir("bytes"));
|
||||||
|
match got {
|
||||||
|
Some(Location::Local(p)) => assert_eq!(p.as_os_str().as_bytes(), &bytes[..]),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_the_current_folder_when_it_is_inside_a_repository() {
|
||||||
|
let dir = temp_dir("repo");
|
||||||
|
git(&Location::Local(dir.clone()), &["init", "-q"]).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||||
|
assert_eq!(start_dir(None, dir.join("sub")), Some(Location::Local(dir.join("sub"))));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_nothing_when_the_current_folder_is_not_in_a_repository() {
|
||||||
|
let dir = temp_dir("plain");
|
||||||
|
assert_eq!(start_dir(None, dir.clone()), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git(dir: &std::path::Path, args: &[&str]) {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(dir)
|
||||||
|
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"])
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explains_connection_errors() {
|
||||||
|
let e = ssh_error("myserver", "Host key verification failed.\n");
|
||||||
|
assert!(e.contains("could not connect to myserver: Host key verification failed."), "{e}");
|
||||||
|
assert!(e.contains("Run `ssh myserver` once in a terminal"), "{e}");
|
||||||
|
let e = ssh_error("myserver", "ada@myserver: Permission denied (publickey).\n");
|
||||||
|
assert!(e.contains("gites logs in with keys or your ssh agent only"), "{e}");
|
||||||
|
assert!(e.contains("check that `ssh myserver` works without a password."), "{e}");
|
||||||
|
let e = ssh_error("myserver", "ssh: connect to host myserver port 22: Connection timed out");
|
||||||
|
assert_eq!(e, "could not connect to myserver: ssh: connect to host myserver port 22: Connection timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_a_repository_through_ssh() {
|
||||||
|
let base = temp_dir("remote");
|
||||||
|
let dir = base.join("remote repo");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
run_git(&dir, &["init", "-q", "-b", "main"]);
|
||||||
|
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
|
||||||
|
run_git(&dir, &["add", "-A"]);
|
||||||
|
run_git(&dir, &["commit", "-q", "-m", "first"]);
|
||||||
|
crate::test_ssh::fake_ssh();
|
||||||
|
let path = dir.to_string_lossy().into_owned();
|
||||||
|
let data = load(&Location::Ssh { target: "myserver".into(), path: path.clone() }).unwrap();
|
||||||
|
assert_eq!(data.commits.len(), 1);
|
||||||
|
assert_eq!(data.root, format!("myserver:{path}"));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explains_a_failed_connection_when_ssh_exits_with_255() {
|
||||||
|
let fake = ["sh", "-c", "echo 'Host key verification failed.' >&2; exit 255", "fake-ssh"].map(OsString::from);
|
||||||
|
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
|
||||||
|
let err = git_via(&fake, &loc, &["status"]).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
"could not connect to myserver: Host key verification failed.\n\
|
||||||
|
Run `ssh myserver` once in a terminal to accept the server's key."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asks_ssh_to_notice_a_dead_connection() {
|
||||||
|
// This stand-in prints the words it was given, one per line, and fails.
|
||||||
|
let fake = ["sh", "-c", "printf '%s\\n' \"$@\" >&2; exit 1", "fake-ssh"].map(OsString::from);
|
||||||
|
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
|
||||||
|
let err = git_via(&fake, &loc, &["status"]).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("-o\nConnectTimeout=10\n-o\nServerAliveInterval=5\n-o\nServerAliveCountMax=2\n"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_a_path_in_the_home_folder_through_ssh() {
|
||||||
|
let home = temp_dir("home");
|
||||||
|
let dir = home.join("repo");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
run_git(&dir, &["init", "-q", "-b", "main"]);
|
||||||
|
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
|
||||||
|
run_git(&dir, &["add", "-A"]);
|
||||||
|
run_git(&dir, &["commit", "-q", "-m", "first"]);
|
||||||
|
crate::test_ssh::fake_ssh_with_home(&home);
|
||||||
|
let data = load(&Location::Ssh { target: "myserver".into(), path: "~/repo".into() }).unwrap();
|
||||||
|
assert_eq!(data.commits.len(), 1);
|
||||||
|
assert_eq!(data.root, format!("myserver:{}", dir.display()));
|
||||||
|
let _ = std::fs::remove_dir_all(&home);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn keeps_sockets_in_the_runtime_folder() {
|
||||||
|
let runtime = PathBuf::from("/run/user/1000");
|
||||||
|
assert_eq!(socket_dir(Some(runtime.clone()), Some(PathBuf::from("/c"))), Some(runtime));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn keeps_sockets_in_a_private_cache_folder_when_there_is_no_runtime_folder() {
|
||||||
|
let cache = temp_dir("cache");
|
||||||
|
let got = socket_dir(None, Some(cache.clone()));
|
||||||
|
assert_eq!(got, Some(cache.join("gites")));
|
||||||
|
assert!(cache.join("gites").is_dir());
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(cache.join("gites")).unwrap().permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o700);
|
||||||
|
}
|
||||||
|
// A second call finds the folder already there.
|
||||||
|
assert_eq!(socket_dir(None, Some(cache.clone())), Some(cache.join("gites")));
|
||||||
|
let _ = std::fs::remove_dir_all(&cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn shares_no_connection_when_there_is_no_folder_for_sockets() {
|
||||||
|
assert_eq!(socket_dir(None, None), None);
|
||||||
|
// The cache folder is a file here, so no folder can be made inside it.
|
||||||
|
let base = temp_dir("cache-file");
|
||||||
|
let file = base.join("file");
|
||||||
|
std::fs::write(&file, "").unwrap();
|
||||||
|
assert_eq!(socket_dir(None, Some(file)), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn shares_connections_through_a_socket_in_a_short_folder() {
|
||||||
|
assert!(socket_path_fits(&PathBuf::from("/run/user/1000")));
|
||||||
|
assert_eq!(
|
||||||
|
control_path(Some(PathBuf::from("/run/user/1000"))),
|
||||||
|
Some(PathBuf::from("/run/user/1000/gites-%C"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn shares_no_connection_when_the_socket_path_would_be_too_long() {
|
||||||
|
// The longest folder that fits: the rest of the path takes 1 + 6 + 40 + 18 characters.
|
||||||
|
let longest = 103 - 1 - SOCKET_PREFIX.len() - 40 - 18;
|
||||||
|
let too_long = PathBuf::from(format!("/{}", "a".repeat(longest)));
|
||||||
|
assert!(!socket_path_fits(&too_long));
|
||||||
|
assert_eq!(control_path(Some(too_long)), None);
|
||||||
|
assert_eq!(control_path(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn shares_connections_when_the_socket_path_is_exactly_at_the_limit() {
|
||||||
|
let longest = 103 - 1 - SOCKET_PREFIX.len() - 40 - 18;
|
||||||
|
let at_limit = PathBuf::from(format!("/{}", "a".repeat(longest - 1)));
|
||||||
|
assert_eq!(at_limit.as_os_str().len(), longest);
|
||||||
|
assert!(socket_path_fits(&at_limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_a_missing_ssh_program() {
|
||||||
|
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
|
||||||
|
let err = git_via(&[OsString::from("/no/such/ssh")], &loc, &["status"]).unwrap_err();
|
||||||
|
assert!(err.starts_with("could not run ssh:"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+44
-19
@@ -1,37 +1,62 @@
|
|||||||
mod details;
|
mod details;
|
||||||
|
mod diff;
|
||||||
mod filters;
|
mod filters;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod location;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_ssh;
|
||||||
|
|
||||||
|
use location::Location;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// The open repository folder. It starts as the first command line argument, or the
|
/// The open repository (a folder here or on another machine), from `git::start_dir`; it changes when the user opens
|
||||||
/// current folder, and changes when the user opens another one.
|
/// another one. `None` until a repository is open: the window shows its welcome screen.
|
||||||
struct RepoDir(Mutex<PathBuf>);
|
struct RepoDir(Mutex<Option<Location>>);
|
||||||
|
|
||||||
impl RepoDir {
|
impl RepoDir {
|
||||||
fn get(&self) -> PathBuf {
|
fn get(&self) -> Result<Location, String> {
|
||||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or_else(|| "no repository is open".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
// The commands that run git are marked `async` so Tauri runs them away from the thread that
|
||||||
fn load_repo(dir: tauri::State<RepoDir>) -> Result<git::RepoData, String> {
|
// draws the window: a command that waits for a slow server must not freeze the window.
|
||||||
git::load(&dir.get())
|
|
||||||
|
/// The open repository, or `None` when there is none yet.
|
||||||
|
#[tauri::command(async)]
|
||||||
|
fn load_repo(dir: tauri::State<'_, RepoDir>) -> Result<Option<git::RepoData>, String> {
|
||||||
|
match dir.get() {
|
||||||
|
Ok(d) => git::load(&d).map(Some),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to another repository. The current one stays open if `path` is not a repository.
|
/// Switches to another repository. The current one stays open if `path` is not a repository.
|
||||||
#[tauri::command]
|
#[tauri::command(async)]
|
||||||
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
|
fn open_repo(dir: tauri::State<'_, RepoDir>, path: String) -> Result<git::RepoData, String> {
|
||||||
let data = git::load(&PathBuf::from(&path))?;
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = PathBuf::from(&path);
|
let loc = location::parse(&path, &cwd);
|
||||||
|
let data = git::load(&loc)?;
|
||||||
|
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(loc);
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command(async)]
|
||||||
fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
|
fn commit_details(dir: tauri::State<'_, RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
|
||||||
details::load(&dir.get(), &hash)
|
details::load(&dir.get()?, &hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
fn file_diff(
|
||||||
|
dir: tauri::State<'_, RepoDir>,
|
||||||
|
hash: String,
|
||||||
|
path: String,
|
||||||
|
old_path: Option<String>,
|
||||||
|
force: bool,
|
||||||
|
) -> Result<diff::FileDiff, String> {
|
||||||
|
diff::load(&dir.get()?, &hash, &path, old_path.as_deref(), force)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -56,10 +81,9 @@ fn save_settings(value: serde_json::Value) -> Result<(), String> {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let path = std::env::args_os()
|
let arg = std::env::args_os().nth(1);
|
||||||
.nth(1)
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
.map(PathBuf::from)
|
let path = git::start_dir(arg, cwd);
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
@@ -69,6 +93,7 @@ pub fn run() {
|
|||||||
load_repo,
|
load_repo,
|
||||||
open_repo,
|
open_repo,
|
||||||
commit_details,
|
commit_details,
|
||||||
|
file_diff,
|
||||||
load_filters,
|
load_filters,
|
||||||
save_filters,
|
save_filters,
|
||||||
load_settings,
|
load_settings,
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Where a repository lives: a folder on this machine, or a folder on another machine
|
||||||
|
//! reached over ssh.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Location {
|
||||||
|
Local(PathBuf),
|
||||||
|
/// `target` is what is given to ssh, like `host` or `user@host`.
|
||||||
|
Ssh { target: String, path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `[user@]host:path` as a remote location. Everything else is local: text where the
|
||||||
|
/// part before the first `:` is a single character (a Windows drive like `C:`) or contains
|
||||||
|
/// `/` or `\`, text starting with `-` (ssh would read it as an option), and text that is the whole name of a folder that exists inside `cwd`.
|
||||||
|
pub fn parse(text: &str, cwd: &Path) -> Location {
|
||||||
|
let local = || Location::Local(PathBuf::from(text));
|
||||||
|
let Some((target, path)) = text.split_once(':') else { return local() };
|
||||||
|
if target.chars().count() <= 1 || target.starts_with('-') || target.contains('/') || target.contains('\\') || cwd.join(text).exists() {
|
||||||
|
return local();
|
||||||
|
}
|
||||||
|
Location::Ssh { target: target.to_string(), path: path.to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Location {
|
||||||
|
/// The text the user would type to open this location.
|
||||||
|
pub fn name(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Location::Local(path) => path.to_string_lossy().into_owned(),
|
||||||
|
Location::Ssh { target, path } => format!("{target}:{path}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same machine with another folder.
|
||||||
|
pub fn with_path(&self, path: &str) -> Location {
|
||||||
|
match self {
|
||||||
|
Location::Local(_) => Location::Local(PathBuf::from(path)),
|
||||||
|
Location::Ssh { target, .. } => Location::Ssh { target: target.clone(), path: path.to_string() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps one word in single quotes so a shell reads it back unchanged.
|
||||||
|
pub fn quote(word: &str) -> String {
|
||||||
|
format!("'{}'", word.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The command for a shell that reads quotes the standard way: `git -C <path>` followed by
|
||||||
|
/// `args`. A path that is `~` or starts with `~/` keeps the `~` outside the quotes so the
|
||||||
|
/// shell still replaces it with the home folder.
|
||||||
|
pub fn sh_command(path: &str, args: &[&str]) -> String {
|
||||||
|
let path = match path.strip_prefix("~/") {
|
||||||
|
Some(rest) => format!("~/{}", quote(rest)),
|
||||||
|
None if path == "~" => "~".to_string(),
|
||||||
|
None => quote(path),
|
||||||
|
};
|
||||||
|
let mut command = format!("git -C {path}");
|
||||||
|
for arg in args {
|
||||||
|
command.push(' ');
|
||||||
|
command.push_str("e(arg));
|
||||||
|
}
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a command so every login shell on the server (sh, bash, zsh, fish) runs it the same
|
||||||
|
/// way. Fish reads quotes differently from the others, so the command is not sent as written:
|
||||||
|
/// every byte becomes a backslash and three octal digits, which no shell changes inside single
|
||||||
|
/// quotes, and `printf` turns them back into the command for `sh` to run.
|
||||||
|
pub fn shell_neutral(sh_command: &str) -> String {
|
||||||
|
let mut wrapped = String::from("printf '");
|
||||||
|
for byte in sh_command.bytes() {
|
||||||
|
wrapped.push_str(&format!("\\{byte:03o}"));
|
||||||
|
}
|
||||||
|
wrapped.push_str("' | sh");
|
||||||
|
wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The command line to give to ssh to run `git -C <path> <args>` on the remote machine.
|
||||||
|
pub fn remote_command(path: &str, args: &[&str]) -> String {
|
||||||
|
shell_neutral(&sh_command(path, args))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn cwd() -> PathBuf {
|
||||||
|
std::env::temp_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_remote_locations() {
|
||||||
|
assert_eq!(
|
||||||
|
parse("myserver:/srv/app", &cwd()),
|
||||||
|
Location::Ssh { target: "myserver".into(), path: "/srv/app".into() }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse("ada@myserver:~/x", &cwd()),
|
||||||
|
Location::Ssh { target: "ada@myserver".into(), path: "~/x".into() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_local_paths_local() {
|
||||||
|
for text in [r"C:\code\x", "C:/code/x", "./a:b", "relative/dir", "/abs/dir"] {
|
||||||
|
assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_a_target_that_ssh_would_read_as_an_option_local() {
|
||||||
|
let text = "-oProxyCommand=x:y";
|
||||||
|
assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_an_existing_local_folder_with_a_colon_local() {
|
||||||
|
let temp = std::env::temp_dir().join(format!("gites-location-{}", std::process::id()));
|
||||||
|
let folder = temp.join("notes:2024");
|
||||||
|
std::fs::create_dir_all(&folder).unwrap();
|
||||||
|
assert_eq!(parse("notes:2024", &temp), Location::Local(PathBuf::from("notes:2024")));
|
||||||
|
std::fs::remove_dir_all(&folder).unwrap();
|
||||||
|
assert_eq!(parse("notes:2024", &temp), Location::Ssh { target: "notes".into(), path: "2024".into() });
|
||||||
|
std::fs::remove_dir_all(&temp).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_locations() {
|
||||||
|
let remote = Location::Ssh { target: "myserver".into(), path: "/srv/app".into() };
|
||||||
|
assert_eq!(remote.name(), "myserver:/srv/app");
|
||||||
|
assert_eq!(Location::Local(PathBuf::from("/abs/dir")).name(), "/abs/dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changes_only_the_path() {
|
||||||
|
let remote = Location::Ssh { target: "ada@myserver".into(), path: "/srv/app".into() };
|
||||||
|
assert_eq!(remote.with_path("/srv/other"), Location::Ssh { target: "ada@myserver".into(), path: "/srv/other".into() });
|
||||||
|
assert_eq!(Location::Local(PathBuf::from("/a")).with_path("/b"), Location::Local(PathBuf::from("/b")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quoted_words_survive_a_shell() {
|
||||||
|
for word in ["plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "line\nbreak"] {
|
||||||
|
let out = Command::new("sh").arg("-c").arg(format!("printf %s {}", quote(word))).output().unwrap();
|
||||||
|
assert_eq!(String::from_utf8(out.stdout).unwrap(), word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrapped_words_survive_every_login_shell() {
|
||||||
|
let words = [
|
||||||
|
"plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "%s", "line\nbreak", "a\\b", "\\",
|
||||||
|
"a\\'b", "x\\",
|
||||||
|
];
|
||||||
|
let mut shells_run = Vec::new();
|
||||||
|
for shell in ["sh", "bash", "zsh", "fish"] {
|
||||||
|
if Command::new(shell).arg("-c").arg("true").output().is_err() {
|
||||||
|
assert_ne!(shell, "sh", "sh must be installed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shells_run.push(shell);
|
||||||
|
for word in words {
|
||||||
|
let wrapper = shell_neutral(&format!("printf %s {}", quote(word)));
|
||||||
|
let out = Command::new(shell).arg("-c").arg(&wrapper).output().unwrap();
|
||||||
|
assert_eq!(String::from_utf8(out.stdout).unwrap(), word, "{shell} with {word:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(shells_run.contains(&"sh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_remote_command_uses_only_plain_characters() {
|
||||||
|
let command = remote_command("~/x y", &["status", "it's"]);
|
||||||
|
assert!(command.starts_with("printf '") && command.ends_with("' | sh"));
|
||||||
|
assert!(command.chars().all(|c| "printf'\\0123456789 |sh".contains(c)), "{command}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_the_remote_command() {
|
||||||
|
assert_eq!(
|
||||||
|
sh_command("/srv/my app", &["log", "--format=%H"]),
|
||||||
|
"git -C '/srv/my app' 'log' '--format=%H'"
|
||||||
|
);
|
||||||
|
assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'");
|
||||||
|
assert_eq!(sh_command("~", &["status"]), "git -C ~ 'status'");
|
||||||
|
// Only the home folder of the user logged in is left for the shell; `~other` is not.
|
||||||
|
assert_eq!(sh_command("~other", &["status"]), "git -C '~other' 'status'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! A stand-in for ssh, so tests can run the remote code path without a server.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// The words `git::git` starts instead of `ssh` in the test running on this thread: a
|
||||||
|
/// program, then its arguments. Each test runs on its own thread, so one test's stand-in
|
||||||
|
/// never reaches another test.
|
||||||
|
pub static SSH_OVERRIDE: RefCell<Option<Vec<OsString>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shell script that acts like ssh. It checks the arguments the way a real ssh would
|
||||||
|
/// need them (so a wrong argument list fails a test), skips the options and the target,
|
||||||
|
/// then runs the command on this machine.
|
||||||
|
const SCRIPT: &str = r#"
|
||||||
|
ok=
|
||||||
|
while [ "$1" = "-o" ]; do
|
||||||
|
[ "$2" = "BatchMode=yes" ] && ok=1
|
||||||
|
shift 2
|
||||||
|
done
|
||||||
|
[ -n "$ok" ] || { echo "fake ssh: BatchMode=yes is missing" >&2; exit 98; }
|
||||||
|
[ "$1" = "--" ] || { echo "fake ssh: expected -- before the target" >&2; exit 99; }
|
||||||
|
shift 2
|
||||||
|
exec sh -c "$1"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Makes `git::git` use the stand-in for the rest of the calling test. No file is written:
|
||||||
|
/// the script is given to `sh` on its command line.
|
||||||
|
pub fn fake_ssh() {
|
||||||
|
SSH_OVERRIDE.set(Some(["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `fake_ssh`, but the command runs with `home` as its home folder, the way a real
|
||||||
|
/// server would run it in the user's home. Only the stand-in's own shell sees the new home.
|
||||||
|
pub fn fake_ssh_with_home(home: &Path) {
|
||||||
|
let script = format!("HOME=\"$1\"; export HOME; shift\n{SCRIPT}");
|
||||||
|
let mut words: Vec<OsString> = ["sh", "-c", &script, "fake-ssh"].map(OsString::from).to_vec();
|
||||||
|
words.push(home.as_os_str().to_owned());
|
||||||
|
SSH_OVERRIDE.set(Some(words));
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "it.wrobel.gites",
|
"identifier": "it.wrobel.gites",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeBuildCommand": "pnpm build",
|
"beforeBuildCommand": "bun run build",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
+200
-22
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from "vue";
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { homeDir } from "@tauri-apps/api/path";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
@@ -9,13 +10,17 @@ import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/f
|
|||||||
import { layout } from "./lib/layout";
|
import { layout } from "./lib/layout";
|
||||||
import { textWidth } from "./lib/measure";
|
import { textWidth } from "./lib/measure";
|
||||||
import { colorOf } from "./lib/palette";
|
import { colorOf } from "./lib/palette";
|
||||||
import { addRecent } from "./lib/recents";
|
import { addRecent, mergeRecents } from "./lib/recents";
|
||||||
import type { CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
|
import { repoRequests, type Request } from "./lib/latest";
|
||||||
|
import type { FileDiff, Layout } from "./lib/diff";
|
||||||
|
import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
|
||||||
|
import DiffView from "./components/DiffView.vue";
|
||||||
import GraphCell from "./components/GraphCell.vue";
|
import GraphCell from "./components/GraphCell.vue";
|
||||||
import LabelStack from "./components/LabelStack.vue";
|
import LabelStack from "./components/LabelStack.vue";
|
||||||
import PreviewPanel from "./components/PreviewPanel.vue";
|
import PreviewPanel from "./components/PreviewPanel.vue";
|
||||||
import RepoMenu from "./components/RepoMenu.vue";
|
import RepoMenu from "./components/RepoMenu.vue";
|
||||||
import Sidebar from "./components/Sidebar.vue";
|
import Sidebar from "./components/Sidebar.vue";
|
||||||
|
import Welcome from "./components/Welcome.vue";
|
||||||
|
|
||||||
const ROW_H = 28;
|
const ROW_H = 28;
|
||||||
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
|
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
|
||||||
@@ -34,15 +39,17 @@ const sidebar = ref<InstanceType<typeof Sidebar>>();
|
|||||||
const SIDEBAR_MIN = 160;
|
const SIDEBAR_MIN = 160;
|
||||||
const SIDEBAR_MAX = 700;
|
const SIDEBAR_MAX = 700;
|
||||||
const PREVIEW_MIN = 80;
|
const PREVIEW_MIN = 80;
|
||||||
|
const LAYOUTS: Layout[] = ["unified", "split", "inline"];
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
previewHeight: number;
|
previewHeight: number;
|
||||||
previewShown: boolean;
|
previewShown: boolean;
|
||||||
|
diffLayout: Layout;
|
||||||
/** Repository roots, most recently opened first. */
|
/** Repository roots, most recently opened first. */
|
||||||
recents: string[];
|
recents: string[];
|
||||||
}
|
}
|
||||||
const settings = reactive<Settings>({ sidebarWidth: 270, previewHeight: 220, previewShown: true, recents: [] });
|
const settings = reactive<Settings>({ sidebarWidth: 270, previewHeight: 220, previewShown: true, diffLayout: "unified", recents: [] });
|
||||||
let settingsLoaded = false;
|
let settingsLoaded = false;
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
@@ -52,10 +59,8 @@ async function loadSettings() {
|
|||||||
}
|
}
|
||||||
if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight);
|
if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight);
|
||||||
if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown;
|
if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown;
|
||||||
if (Array.isArray(saved?.recents)) {
|
if (saved?.diffLayout && LAYOUTS.includes(saved.diffLayout)) settings.diffLayout = saved.diffLayout;
|
||||||
// A repository opened before the settings arrived is already at the front.
|
if (Array.isArray(saved?.recents)) settings.recents = mergeRecents(settings.recents, saved.recents);
|
||||||
settings.recents = saved.recents.reduceRight((list, p) => (list.includes(p) ? list : [...list, p]), settings.recents);
|
|
||||||
}
|
|
||||||
settingsLoaded = true;
|
settingsLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ watch(
|
|||||||
if (!settingsLoaded) return;
|
if (!settingsLoaded) return;
|
||||||
clearTimeout(settingsTimer);
|
clearTimeout(settingsTimer);
|
||||||
settingsTimer = window.setTimeout(() => {
|
settingsTimer = window.setTimeout(() => {
|
||||||
invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`));
|
invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`, { error: true }));
|
||||||
}, 300);
|
}, 300);
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
@@ -99,48 +104,87 @@ function startDrag(e: PointerEvent, axis: "x" | "y") {
|
|||||||
|
|
||||||
let filtersLoadedFor: string | null = null;
|
let filtersLoadedFor: string | null = null;
|
||||||
|
|
||||||
|
let loading = false;
|
||||||
|
/**
|
||||||
|
* Git runs away from the window, so replies can arrive in another order than the requests
|
||||||
|
* went out. Every reload and every open is noted here, and its reply is used only if
|
||||||
|
* `requests.isCurrent` allows it: opening a repository wins over a reload.
|
||||||
|
*/
|
||||||
|
const requests = repoRequests();
|
||||||
|
/** False until the first load answers, so the welcome screen does not flash up at start. */
|
||||||
|
const started = ref(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
// The window gets focus often; one reload at a time is enough.
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
const request = requests.startReload();
|
||||||
try {
|
try {
|
||||||
await applyRepo(await invoke<RepoData>("load_repo"));
|
// null: gites was started outside a repository and none is open yet.
|
||||||
|
const data = await invoke<RepoData | null>("load_repo");
|
||||||
|
if (data) await applyRepo(data, request);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = String(e);
|
// An open replaced this reload; its error no longer matters.
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
|
// A repository already on screen stays, for example when a connection drops.
|
||||||
|
if (repo.value) showToast(`could not reload: ${e}`, { error: true });
|
||||||
|
else error.value = String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
started.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openRepo(path: string) {
|
async function openRepo(path: string) {
|
||||||
menuOpen.value = false;
|
menuOpen.value = false;
|
||||||
|
const request = requests.startOpen();
|
||||||
try {
|
try {
|
||||||
await applyRepo(await invoke<RepoData>("open_repo", { path }));
|
await applyRepo(await invoke<RepoData>("open_repo", { path }), request);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// A newer open replaced this one; its error no longer matters.
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
// The repository that was open stays open.
|
// The repository that was open stays open.
|
||||||
showToast(`could not open ${path}: ${e}`);
|
showToast(`could not open ${path}: ${e}`, { error: true });
|
||||||
|
} finally {
|
||||||
|
requests.finish(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function browse() {
|
async function browse() {
|
||||||
menuOpen.value = false;
|
menuOpen.value = false;
|
||||||
const path = await openDialog({ directory: true, title: "Open a git repository" }).catch(() => null);
|
const home = await homeDir().catch(() => undefined);
|
||||||
|
const path = await openDialog({ directory: true, title: "Open a git repository", defaultPath: home }).catch(() => null);
|
||||||
if (typeof path === "string") await openRepo(path);
|
if (typeof path === "string") await openRepo(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuOpen = ref(false);
|
const menuOpen = ref(false);
|
||||||
|
|
||||||
async function applyRepo(data: RepoData) {
|
/** Puts `data` on screen, unless `request` was replaced (see `requests`). */
|
||||||
|
async function applyRepo(data: RepoData, request: Request) {
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
if (filtersLoadedFor !== data.root) {
|
if (filtersLoadedFor !== data.root) {
|
||||||
const saved = await invoke<Partial<FilterState & { collapseAll: boolean }> | null>("load_filters", {
|
const saved = await invoke<Partial<FilterState & { collapseAll: boolean }> | null>("load_filters", {
|
||||||
repo: data.root,
|
repo: data.root,
|
||||||
});
|
});
|
||||||
|
// Another repository may have been asked for while the filters were read.
|
||||||
|
if (!requests.isCurrent(request)) return;
|
||||||
filters.hidden = saved?.hidden ?? [];
|
filters.hidden = saved?.hidden ?? [];
|
||||||
filters.rules = saved?.rules ?? [];
|
filters.rules = saved?.rules ?? [];
|
||||||
|
filters.hideTags = saved?.hideTags ?? false;
|
||||||
collapseAll.value = saved?.collapseAll ?? false;
|
collapseAll.value = saved?.collapseAll ?? false;
|
||||||
filtersLoadedFor = data.root;
|
filtersLoadedFor = data.root;
|
||||||
toggled.value = new Set();
|
toggled.value = new Set();
|
||||||
selected.value = 0;
|
selected.value = 0;
|
||||||
detailsCache.clear();
|
detailsCache.clear();
|
||||||
|
diffCache.clear();
|
||||||
if (scroller.value) scroller.value.scrollTop = 0;
|
if (scroller.value) scroller.value.scrollTop = 0;
|
||||||
settings.recents = addRecent(settings.recents, data.root);
|
settings.recents = addRecent(settings.recents, data.root);
|
||||||
|
// The caches were emptied just above; the new root goes on screen in the same step, so a
|
||||||
|
// late reply for the old repository sees that the root changed and is not kept.
|
||||||
|
repo.value = data;
|
||||||
await getCurrentWindow().setTitle(`gites — ${data.root}`);
|
await getCurrentWindow().setTitle(`gites — ${data.root}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
repo.value = data;
|
repo.value = data;
|
||||||
}
|
}
|
||||||
@@ -155,8 +199,8 @@ watch(
|
|||||||
saveTimer = window.setTimeout(() => {
|
saveTimer = window.setTimeout(() => {
|
||||||
invoke("save_filters", {
|
invoke("save_filters", {
|
||||||
repo: repoRoot,
|
repo: repoRoot,
|
||||||
value: { hidden: filters.hidden, rules: filters.rules, collapseAll: collapseAll.value },
|
value: { hidden: filters.hidden, rules: filters.rules, hideTags: filters.hideTags, collapseAll: collapseAll.value },
|
||||||
}).catch((e) => showToast(`could not save filters: ${e}`));
|
}).catch((e) => showToast(`could not save filters: ${e}`, { error: true }));
|
||||||
}, 300);
|
}, 300);
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
@@ -254,19 +298,88 @@ watch([selectedHash, () => settings.previewShown], ([hash, shown]) => {
|
|||||||
}
|
}
|
||||||
// Wait a moment, so holding a key down does not run git for every row passed.
|
// Wait a moment, so holding a key down does not run git for every row passed.
|
||||||
detailsTimer = window.setTimeout(async () => {
|
detailsTimer = window.setTimeout(async () => {
|
||||||
|
// A reply that comes after another repository was opened belongs to the old one.
|
||||||
|
const root = repo.value?.root;
|
||||||
try {
|
try {
|
||||||
const d = await invoke<CommitDetails>("commit_details", { hash });
|
const d = await invoke<CommitDetails>("commit_details", { hash });
|
||||||
|
if (repo.value?.root !== root) return;
|
||||||
detailsCache.set(hash, d);
|
detailsCache.set(hash, d);
|
||||||
if (selectedHash.value === hash) {
|
if (selectedHash.value === hash) {
|
||||||
details.value = d;
|
details.value = d;
|
||||||
detailsError.value = null;
|
detailsError.value = null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (repo.value?.root !== root) return;
|
||||||
if (selectedHash.value === hash) detailsError.value = String(e);
|
if (selectedHash.value === hash) detailsError.value = String(e);
|
||||||
}
|
}
|
||||||
}, 120);
|
}, 120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- diff of one file, shown in place of the commit graph ---------------------
|
||||||
|
|
||||||
|
const diffCache = new Map<string, FileDiff>();
|
||||||
|
const diffFile = shallowRef<ChangedFile | null>(null);
|
||||||
|
const diffData = shallowRef<FileDiff | null>(null);
|
||||||
|
const diffError = ref<string | null>(null);
|
||||||
|
const diffView = ref<InstanceType<typeof DiffView>>();
|
||||||
|
|
||||||
|
async function openDiff(file: ChangedFile, force = false) {
|
||||||
|
const hash = details.value?.hash;
|
||||||
|
if (!hash) return;
|
||||||
|
diffFile.value = file;
|
||||||
|
diffError.value = null;
|
||||||
|
const key = `${hash}\0${file.path}`;
|
||||||
|
const cached = diffCache.get(key);
|
||||||
|
if (cached && !force) {
|
||||||
|
diffData.value = cached;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
diffData.value = null;
|
||||||
|
// A reply that comes after another repository was opened belongs to the old one.
|
||||||
|
const root = repo.value?.root;
|
||||||
|
try {
|
||||||
|
const d = await invoke<FileDiff>("file_diff", { hash, path: file.path, oldPath: file.old_path, force });
|
||||||
|
if (repo.value?.root !== root) return;
|
||||||
|
diffCache.set(key, d);
|
||||||
|
if (diffFile.value === file && details.value?.hash === hash) diffData.value = d;
|
||||||
|
} catch (e) {
|
||||||
|
if (repo.value?.root !== root) return;
|
||||||
|
if (diffFile.value === file) diffError.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDiff() {
|
||||||
|
diffFile.value = null;
|
||||||
|
nextTick(scrollToSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the diff of the next (1) or previous (-1) file of the commit. */
|
||||||
|
function stepFile(dir: 1 | -1) {
|
||||||
|
const files = details.value?.files ?? [];
|
||||||
|
const i = files.findIndex((f) => f.path === diffFile.value?.path);
|
||||||
|
const next = files[i + dir];
|
||||||
|
if (next) openDiff(next);
|
||||||
|
else showToast(dir > 0 ? "this is the last file" : "this is the first file");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the diff of the selected commit's first file. */
|
||||||
|
function openFirstFile() {
|
||||||
|
if (!settings.previewShown) return showToast("show the preview panel first (p)");
|
||||||
|
const d = details.value;
|
||||||
|
if (!d || d.hash !== selectedHash.value) return;
|
||||||
|
if (!d.files.length) return showToast("this commit changed no files");
|
||||||
|
openDiff(d.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleLayout() {
|
||||||
|
settings.diffLayout = LAYOUTS[(LAYOUTS.indexOf(settings.diffLayout) + 1) % LAYOUTS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another commit (from a parent link, or a reload) closes the diff.
|
||||||
|
watch(selectedHash, () => {
|
||||||
|
if (diffFile.value) closeDiff();
|
||||||
|
});
|
||||||
|
|
||||||
const selectedLabels = computed(() => (selectedHash.value ? labels.value.get(selectedHash.value) ?? [] : []));
|
const selectedLabels = computed(() => (selectedHash.value ? labels.value.get(selectedHash.value) ?? [] : []));
|
||||||
const selectedColor = computed(() => colorOf(graph.value.rows[selected.value]?.color ?? 0));
|
const selectedColor = computed(() => colorOf(graph.value.rows[selected.value]?.color ?? 0));
|
||||||
|
|
||||||
@@ -284,10 +397,17 @@ async function copyText(text: string) {
|
|||||||
// ---- actions -------------------------------------------------------------
|
// ---- actions -------------------------------------------------------------
|
||||||
|
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
function showToast(text: string) {
|
/** Shows a short message. An error message stays longer, 8 seconds, so there is time to
|
||||||
|
* read it; a click on any message closes it. */
|
||||||
|
function showToast(text: string, { error = false } = {}) {
|
||||||
toast.value = text;
|
toast.value = text;
|
||||||
clearTimeout(toastTimer);
|
clearTimeout(toastTimer);
|
||||||
toastTimer = window.setTimeout(() => (toast.value = null), 1800);
|
toastTimer = window.setTimeout(() => (toast.value = null), error ? 8000 : 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeToast() {
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toast.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyHash() {
|
async function copyHash() {
|
||||||
@@ -328,7 +448,28 @@ function jumpTo(r: Ref) {
|
|||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (menuOpen.value || e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
|
if (menuOpen.value || e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
const actions: Record<string, () => void> = {
|
const view = diffView.value;
|
||||||
|
const diffActions: Record<string, () => void> = {
|
||||||
|
ArrowDown: () => view?.scrollLines(1),
|
||||||
|
j: () => view?.scrollLines(1),
|
||||||
|
ArrowUp: () => view?.scrollLines(-1),
|
||||||
|
k: () => view?.scrollLines(-1),
|
||||||
|
PageDown: () => view?.scrollPages(1),
|
||||||
|
PageUp: () => view?.scrollPages(-1),
|
||||||
|
Home: () => view?.toTop(),
|
||||||
|
g: () => view?.toTop(),
|
||||||
|
End: () => view?.toBottom(),
|
||||||
|
G: () => view?.toBottom(),
|
||||||
|
n: () => view?.jumpHunk(1),
|
||||||
|
N: () => view?.jumpHunk(-1),
|
||||||
|
"]": () => stepFile(1),
|
||||||
|
"[": () => stepFile(-1),
|
||||||
|
v: cycleLayout,
|
||||||
|
Escape: closeDiff,
|
||||||
|
y: copyHash,
|
||||||
|
p: () => (settings.previewShown = !settings.previewShown),
|
||||||
|
};
|
||||||
|
const actions: Record<string, () => void> = diffFile.value ? diffActions : {
|
||||||
ArrowDown: () => select(selected.value + 1),
|
ArrowDown: () => select(selected.value + 1),
|
||||||
j: () => select(selected.value + 1),
|
j: () => select(selected.value + 1),
|
||||||
ArrowUp: () => select(selected.value - 1),
|
ArrowUp: () => select(selected.value - 1),
|
||||||
@@ -349,6 +490,7 @@ function onKey(e: KeyboardEvent) {
|
|||||||
r: load,
|
r: load,
|
||||||
o: () => (menuOpen.value = true),
|
o: () => (menuOpen.value = true),
|
||||||
p: () => (settings.previewShown = !settings.previewShown),
|
p: () => (settings.previewShown = !settings.previewShown),
|
||||||
|
Enter: openFirstFile,
|
||||||
"/": () => sidebar.value?.focusPattern(),
|
"/": () => sidebar.value?.focusPattern(),
|
||||||
};
|
};
|
||||||
const action = actions[e.key];
|
const action = actions[e.key];
|
||||||
@@ -438,9 +580,28 @@ onUnmounted(() => {
|
|||||||
<button @click="menuOpen = true">Open a repository…</button>
|
<button @click="menuOpen = true">Open a repository…</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Welcome
|
||||||
|
v-else-if="!repo && started"
|
||||||
|
:recents="settings.recents"
|
||||||
|
@pick="openRepo"
|
||||||
|
@browse="browse"
|
||||||
|
@remove="(p) => (settings.recents = settings.recents.filter((x) => x !== p))"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else class="center">
|
<div v-else class="center">
|
||||||
|
|
||||||
<div ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
|
<DiffView
|
||||||
|
v-if="diffFile"
|
||||||
|
ref="diffView"
|
||||||
|
:file="diffFile"
|
||||||
|
:diff="diffData"
|
||||||
|
:error="diffError"
|
||||||
|
:layout="settings.diffLayout"
|
||||||
|
@layout="(l) => (settings.diffLayout = l)"
|
||||||
|
@close="closeDiff"
|
||||||
|
@force="openDiff(diffFile!, true)"
|
||||||
|
/>
|
||||||
|
<div v-show="!diffFile" ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
|
||||||
<div v-if="repo && rows.length === 0" class="empty">No commits to show. Check the filters on the left.</div>
|
<div v-if="repo && rows.length === 0" class="empty">No commits to show. Check the filters on the left.</div>
|
||||||
<div class="spacer-rows" :style="{ height: rows.length * ROW_H + 'px' }">
|
<div class="spacer-rows" :style="{ height: rows.length * ROW_H + 'px' }">
|
||||||
<div
|
<div
|
||||||
@@ -497,17 +658,29 @@ onUnmounted(() => {
|
|||||||
:labels="selectedLabels"
|
:labels="selectedLabels"
|
||||||
:color="selectedColor"
|
:color="selectedColor"
|
||||||
:format-time="formatTime"
|
:format-time="formatTime"
|
||||||
|
:chosen="diffFile?.path ?? null"
|
||||||
@jump="jumpToHash"
|
@jump="jumpToHash"
|
||||||
@copy="copyText"
|
@copy="copyText"
|
||||||
|
@open="openDiff"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="keys">
|
<footer v-if="diffFile" class="keys">
|
||||||
|
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> scroll</span>
|
||||||
|
<span><kbd>PgUp</kbd><kbd>PgDn</kbd> page</span>
|
||||||
|
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
|
||||||
|
<span><kbd>n</kbd><kbd>N</kbd> next / previous change</span>
|
||||||
|
<span><kbd>[</kbd><kbd>]</kbd> previous / next file</span>
|
||||||
|
<span><kbd>v</kbd> switch layout</span>
|
||||||
|
<span><kbd>Esc</kbd> back to graph</span>
|
||||||
|
</footer>
|
||||||
|
<footer v-else class="keys">
|
||||||
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> move</span>
|
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> move</span>
|
||||||
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
|
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
|
||||||
<span><kbd>space</kbd> fold run</span>
|
<span><kbd>space</kbd> fold run</span>
|
||||||
|
<span><kbd>Enter</kbd> diff</span>
|
||||||
<span><kbd>c</kbd> fold all</span>
|
<span><kbd>c</kbd> fold all</span>
|
||||||
<span><kbd>y</kbd> copy hash</span>
|
<span><kbd>y</kbd> copy hash</span>
|
||||||
<span><kbd>h</kbd> hide branch</span>
|
<span><kbd>h</kbd> hide branch</span>
|
||||||
@@ -518,7 +691,7 @@ onUnmounted(() => {
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="toast" class="toast">{{ toast }}</div>
|
<div v-if="toast" class="toast" @click="closeToast">{{ toast }}</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -625,6 +798,11 @@ onUnmounted(() => {
|
|||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 6px 24px #0008;
|
box-shadow: 0 6px 24px #0008;
|
||||||
|
/* Keeps each line of a message on its own line, such as the hint under an ssh error. */
|
||||||
|
white-space: pre-line;
|
||||||
|
max-width: min(640px, calc(100vw - 32px));
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from "vue";
|
||||||
|
import { inlineRows, splitRows, unifiedRows, type FileDiff, type Layout, type Row, type SplitRow } from "../lib/diff";
|
||||||
|
import type { ChangedFile } from "../lib/types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
file: ChangedFile;
|
||||||
|
diff: FileDiff | null;
|
||||||
|
error: string | null;
|
||||||
|
layout: Layout;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ layout: [layout: Layout]; close: []; force: [] }>();
|
||||||
|
|
||||||
|
const LINE_H = 18;
|
||||||
|
/** Rows are drawn in groups; the browser skips drawing groups that are out of view. */
|
||||||
|
const GROUP = 200;
|
||||||
|
const LAYOUTS: { id: Layout; name: string }[] = [
|
||||||
|
{ id: "unified", name: "One column" },
|
||||||
|
{ id: "split", name: "Side by side" },
|
||||||
|
{ id: "inline", name: "Inline" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const groups = computed(() => {
|
||||||
|
const hunks = props.diff?.hunks ?? [];
|
||||||
|
const rows: (Row | SplitRow)[] =
|
||||||
|
props.layout === "split" ? splitRows(hunks) : props.layout === "inline" ? inlineRows(hunks) : unifiedRows(hunks);
|
||||||
|
const out: (Row | SplitRow)[][] = [];
|
||||||
|
for (let i = 0; i < rows.length; i += GROUP) out.push(rows.slice(i, i + GROUP));
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
const PIECE = { "=": "", "+": "pa", "-": "pd" } as const;
|
||||||
|
const asRow = (r: Row | SplitRow) => r as Exclude<Row, { kind: "hunk" }>;
|
||||||
|
const asPair = (r: Row | SplitRow) => r as Extract<SplitRow, { kind: "pair" }>;
|
||||||
|
|
||||||
|
const body = ref<HTMLElement>();
|
||||||
|
watch(
|
||||||
|
() => [props.file, props.diff],
|
||||||
|
() => nextTick(() => body.value?.scrollTo({ top: 0 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
function size(n: number | null) {
|
||||||
|
if (n === null) return "none";
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
scrollLines: (n: number) => body.value?.scrollBy({ top: n * LINE_H }),
|
||||||
|
scrollPages: (n: number) => body.value?.scrollBy({ top: n * (body.value.clientHeight - 2 * LINE_H) }),
|
||||||
|
toTop: () => body.value?.scrollTo({ top: 0 }),
|
||||||
|
toBottom: () => body.value?.scrollTo({ top: body.value.scrollHeight }),
|
||||||
|
/** Scrolls to the next (1) or previous (-1) changed block. */
|
||||||
|
jumpHunk(dir: 1 | -1) {
|
||||||
|
const el = body.value;
|
||||||
|
if (!el) return;
|
||||||
|
const tops = [...el.querySelectorAll<HTMLElement>("[data-hunk]")].map((h) => h.offsetTop);
|
||||||
|
const target = dir > 0 ? tops.find((t) => t > el.scrollTop + 2) : tops.reverse().find((t) => t < el.scrollTop - 2);
|
||||||
|
if (target !== undefined) el.scrollTo({ top: target });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="diff">
|
||||||
|
<header class="head">
|
||||||
|
<span class="st" :class="file.status">{{ file.status }}</span>
|
||||||
|
<span class="path" :title="file.old_path ? `${file.old_path} → ${file.path}` : file.path">
|
||||||
|
<span v-if="file.old_path" class="dim">{{ file.old_path }} → </span>{{ file.path }}
|
||||||
|
</span>
|
||||||
|
<span v-if="file.added !== null" class="n"><span class="a">+{{ file.added }}</span> <span class="d">−{{ file.deleted }}</span></span>
|
||||||
|
<span class="seg" title="switch layout (v)">
|
||||||
|
<button v-for="l in LAYOUTS" :key="l.id" :class="{ on: layout === l.id }" @click="emit('layout', l.id)">{{ l.name }}</button>
|
||||||
|
</span>
|
||||||
|
<button class="close" title="back to the commit graph (Esc)" @click="emit('close')">Esc back to graph</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div ref="body" class="body">
|
||||||
|
<div v-if="error" class="state error">{{ error }}</div>
|
||||||
|
<div v-else-if="!diff" class="state">Loading…</div>
|
||||||
|
<div v-else-if="diff.too_large !== null" class="state">
|
||||||
|
Diff too large: {{ diff.too_large }} changed lines.
|
||||||
|
<button @click="emit('force')">Show it anyway</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="diff.binary" class="state">
|
||||||
|
Binary file, not shown. Size before: {{ size(diff.old_size) }}, after: {{ size(diff.new_size) }}.
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!diff.hunks.length" class="state">
|
||||||
|
{{ file.old_path ? "Renamed, content not changed." : "Content not changed." }}
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="(group, g) in groups" :key="`${layout}-${g}`" class="group" :class="layout">
|
||||||
|
<template v-for="(r, i) in group" :key="i">
|
||||||
|
<div v-if="r.kind === 'hunk'" class="hunk" data-hunk>{{ r.text }}</div>
|
||||||
|
<div v-else-if="r.kind === 'pair'" class="pair">
|
||||||
|
<div v-for="(c, side) in [asPair(r).left, asPair(r).right]" :key="side" class="half" :class="c ? 'k' + (c.kind === ' ' ? 'c' : c.kind === '+' ? 'a' : 'd') : 'gap'">
|
||||||
|
<template v-if="c">
|
||||||
|
<span class="no">{{ c.no }}</span>
|
||||||
|
<span class="mk">{{ c.kind === "+" ? "+" : c.kind === "-" ? "−" : "" }}</span>
|
||||||
|
<span class="tx"><span v-for="(p, k) in c.pieces" :key="k" :class="PIECE[p.op]">{{ p.text }}</span></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="line" :class="'k' + { ' ': 'c', '+': 'a', '-': 'd', '~': 'm' }[asRow(r).kind]">
|
||||||
|
<template v-if="layout === 'inline'">
|
||||||
|
<span class="no">{{ asRow(r).new ?? asRow(r).old }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="no">{{ asRow(r).old ?? "" }}</span>
|
||||||
|
<span class="no">{{ asRow(r).new ?? "" }}</span>
|
||||||
|
</template>
|
||||||
|
<span class="mk">{{ { " ": "", "+": "+", "-": "−", "~": "±" }[asRow(r).kind] }}</span>
|
||||||
|
<span class="tx"><span v-for="(p, k) in asRow(r).pieces" :key="k" :class="PIECE[p.op]">{{ p.text }}</span></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.diff { flex: 1; min-height: 0; display: flex; flex-direction: column; background: var(--bg); }
|
||||||
|
.head {
|
||||||
|
flex: none; display: flex; align-items: center; gap: 10px; padding: 5px 12px;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--bg-side); font-size: 12px;
|
||||||
|
}
|
||||||
|
.st { width: 12px; flex: none; font: 700 11px ui-monospace, monospace; }
|
||||||
|
.st.A { color: #69db7c; } .st.M { color: #ffa94d; } .st.D { color: #ff6b6b; } .st.R, .st.C { color: #4dabf7; } .st.T { color: #b197fc; }
|
||||||
|
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px ui-monospace, monospace; }
|
||||||
|
.dim { color: var(--text-dim); }
|
||||||
|
.n { flex: none; font: 11.5px ui-monospace, monospace; }
|
||||||
|
.a { color: #69db7c; } .d { color: #ff6b6b; }
|
||||||
|
.seg { display: inline-flex; flex: none; }
|
||||||
|
.seg button { border-radius: 0; padding: 1px 9px; font-size: 11.5px; color: var(--text-dim); }
|
||||||
|
.seg button + button { border-left: none; }
|
||||||
|
.seg button:first-child { border-radius: 6px 0 0 6px; }
|
||||||
|
.seg button:last-child { border-radius: 0 6px 6px 0; }
|
||||||
|
.seg button.on { background: var(--bg-select); color: var(--text); }
|
||||||
|
.close { flex: none; font-size: 11.5px; color: var(--text-dim); padding: 1px 8px; }
|
||||||
|
|
||||||
|
.body { flex: 1; min-height: 0; overflow: auto; position: relative; user-select: text; }
|
||||||
|
.state { padding: 16px; color: var(--text-dim); display: flex; gap: 12px; align-items: center; }
|
||||||
|
.state.error { color: #ff8787; }
|
||||||
|
.group { content-visibility: auto; contain-intrinsic-size: auto 3600px; font: 12px/18px ui-monospace, monospace; }
|
||||||
|
|
||||||
|
.hunk { color: var(--accent); background: #1a1830; padding: 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.line, .half { display: grid; min-height: 18px; }
|
||||||
|
.unified .line { grid-template-columns: 48px 48px 18px minmax(0, 1fr); }
|
||||||
|
.inline .line, .half { grid-template-columns: 48px 18px minmax(0, 1fr); }
|
||||||
|
.pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.pair .half:first-child { border-right: 1px solid var(--border); }
|
||||||
|
.no { color: var(--text-dim); opacity: 0.7; text-align: right; padding-right: 8px; user-select: none; }
|
||||||
|
.mk { user-select: none; text-align: center; }
|
||||||
|
.tx { white-space: pre-wrap; overflow-wrap: anywhere; padding-right: 10px; }
|
||||||
|
|
||||||
|
.ka { background: #16301f; } .ka .mk { color: #69db7c; }
|
||||||
|
.kd { background: #3a1a20; } .kd .mk { color: #ff6b6b; }
|
||||||
|
.km { background: #1d2033; } .km .mk { color: #ffa94d; }
|
||||||
|
.gap { background: repeating-linear-gradient(135deg, var(--bg-row-alt) 0 4px, var(--bg) 4px 8px); }
|
||||||
|
.ka .pa { background: #2b6a3b; border-radius: 2px; }
|
||||||
|
.kd .pd { background: #7a2a35; border-radius: 2px; }
|
||||||
|
.km .pd { background: #5c2129; color: #ffb3b3; text-decoration: line-through; text-decoration-color: #ff8787; border-radius: 2px; }
|
||||||
|
.km .pa { background: #24502f; color: #c3f5cc; border-radius: 2px; }
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, nextTick, ref, watch } from "vue";
|
||||||
import type { CommitDetails, ChangedFile } from "../lib/types";
|
import type { CommitDetails, ChangedFile } from "../lib/types";
|
||||||
import type { Label } from "./LabelStack.vue";
|
import type { Label } from "./LabelStack.vue";
|
||||||
import RefIcon from "./RefIcon.vue";
|
import RefIcon from "./RefIcon.vue";
|
||||||
@@ -10,8 +10,17 @@ const props = defineProps<{
|
|||||||
labels: Label[];
|
labels: Label[];
|
||||||
color: string;
|
color: string;
|
||||||
formatTime: (t: number) => string;
|
formatTime: (t: number) => string;
|
||||||
|
/** The path of the file whose diff is open. */
|
||||||
|
chosen: string | null;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ jump: [hash: string]; copy: [text: string] }>();
|
const emit = defineEmits<{ jump: [hash: string]; copy: [text: string]; open: [file: ChangedFile] }>();
|
||||||
|
|
||||||
|
// Keep the file whose diff is open in view, also when [ and ] move to another file.
|
||||||
|
const fileList = ref<HTMLElement>();
|
||||||
|
watch(
|
||||||
|
() => props.chosen,
|
||||||
|
() => nextTick(() => fileList.value?.querySelector(".file.on")?.scrollIntoView({ block: "nearest" })),
|
||||||
|
);
|
||||||
|
|
||||||
const subject = computed(() => props.details?.message.split("\n")[0] ?? "");
|
const subject = computed(() => props.details?.message.split("\n")[0] ?? "");
|
||||||
const body = computed(() => props.details?.message.split("\n").slice(1).join("\n").trim() ?? "");
|
const body = computed(() => props.details?.message.split("\n").slice(1).join("\n").trim() ?? "");
|
||||||
@@ -75,13 +84,20 @@ function split(path: string) {
|
|||||||
</template>
|
</template>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="files">
|
<div ref="fileList" class="files">
|
||||||
<div class="files-head">
|
<div class="files-head">
|
||||||
{{ details.files.length }} changed file{{ details.files.length === 1 ? "" : "s" }}
|
{{ details.files.length }} changed file{{ details.files.length === 1 ? "" : "s" }}
|
||||||
<span class="a">+{{ totals.a }}</span> <span class="d">−{{ totals.d }}</span>
|
<span class="a">+{{ totals.a }}</span> <span class="d">−{{ totals.d }}</span>
|
||||||
<span v-if="details.parents.length > 1" class="dim">compared with the first parent</span>
|
<span v-if="details.parents.length > 1" class="dim">compared with the first parent</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="f in details.files" :key="f.path" class="file" :title="f.old_path ? `${f.old_path} → ${f.path}` : f.path">
|
<div
|
||||||
|
v-for="f in details.files"
|
||||||
|
:key="f.path"
|
||||||
|
class="file"
|
||||||
|
:class="{ on: chosen === f.path }"
|
||||||
|
:title="(f.old_path ? `${f.old_path} → ${f.path}` : f.path) + ' (click to see the diff)'"
|
||||||
|
@click="emit('open', f)"
|
||||||
|
>
|
||||||
<span class="st" :class="f.status">{{ f.status }}</span>
|
<span class="st" :class="f.status">{{ f.status }}</span>
|
||||||
<span class="path">
|
<span class="path">
|
||||||
<template v-if="f.old_path"><span class="dim">{{ f.old_path }} → </span></template>
|
<template v-if="f.old_path"><span class="dim">{{ f.old_path }} → </span></template>
|
||||||
@@ -124,7 +140,9 @@ dd { margin: 0; min-width: 0; overflow-wrap: anywhere; display: flex; flex-wrap:
|
|||||||
.files-head { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
.files-head { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.files-head .dim { text-transform: none; letter-spacing: 0; }
|
.files-head .dim { text-transform: none; letter-spacing: 0; }
|
||||||
.file { display: flex; align-items: center; gap: 8px; padding: 1px 4px; border-radius: 4px; font-size: 12px; }
|
.file { display: flex; align-items: center; gap: 8px; padding: 1px 4px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.file { cursor: pointer; }
|
||||||
.file:hover { background: var(--bg-hover); }
|
.file:hover { background: var(--bg-hover); }
|
||||||
|
.file.on { background: var(--bg-select); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||||
.st { width: 12px; flex: none; font: 700 11px ui-monospace, monospace; }
|
.st { width: 12px; flex: none; font: 700 11px ui-monospace, monospace; }
|
||||||
.st.A { color: #69db7c; } .st.M { color: #ffa94d; } .st.D { color: #ff6b6b; } .st.R, .st.C { color: #4dabf7; } .st.T { color: #b197fc; }
|
.st.A { color: #69db7c; } .st.M { color: #ffa94d; } .st.D { color: #ff6b6b; } .st.R, .st.C { color: #4dabf7; } .st.T { color: #b197fc; }
|
||||||
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11.5px ui-monospace, monospace; }
|
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11.5px ui-monospace, monospace; }
|
||||||
|
|||||||
@@ -46,7 +46,15 @@ function toggleFolder(n: TreeNode) {
|
|||||||
<path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" />
|
<path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span>
|
<span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span>
|
||||||
<EyeButton :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
|
<EyeButton
|
||||||
|
v-if="kind === 'tag' && filters.hideTags"
|
||||||
|
class="locked"
|
||||||
|
:shut="true"
|
||||||
|
disabled
|
||||||
|
title="all tags are hidden: use the eye next to Tags"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<EyeButton v-else :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
|
||||||
</div>
|
</div>
|
||||||
<RefTree
|
<RefTree
|
||||||
v-if="n.children.length && !closed.has(key(n))"
|
v-if="n.children.length && !closed.has(key(n))"
|
||||||
@@ -80,4 +88,5 @@ function toggleFolder(n: TreeNode) {
|
|||||||
.arrow.open { transform: rotate(90deg); }
|
.arrow.open { transform: rotate(90deg); }
|
||||||
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.item:hover :deep(.eye) { opacity: 1; }
|
.item:hover :deep(.eye) { opacity: 1; }
|
||||||
|
.item :deep(.eye.locked) { opacity: 0.35; cursor: default; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,29 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, nextTick, onMounted, ref } from "vue";
|
||||||
|
|
||||||
const props = defineProps<{ recents: string[]; current: string | null }>();
|
const props = defineProps<{ recents: string[]; current: string | null }>();
|
||||||
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
|
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
|
||||||
|
|
||||||
// The current repository is not offered: picking it would do nothing.
|
// The current repository is not offered: picking it would do nothing.
|
||||||
const items = computed(() => props.recents.filter((p) => p !== props.current));
|
const items = computed(() => props.recents.filter((p) => p !== props.current));
|
||||||
/** Index into items; items.length means the "Open folder…" entry. */
|
/** Index into items; items.length is the "Open folder…" entry and items.length + 1 the "Open on another machine…" entry. */
|
||||||
const active = ref(0);
|
const active = ref(0);
|
||||||
const menu = ref<HTMLElement>();
|
const menu = ref<HTMLElement>();
|
||||||
onMounted(() => menu.value?.focus());
|
onMounted(() => menu.value?.focus());
|
||||||
|
|
||||||
|
/** True while the "Open on another machine…" entry is a text field. */
|
||||||
|
const typing = ref(false);
|
||||||
|
const remoteText = ref("");
|
||||||
|
const remoteInput = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
async function startTyping() {
|
||||||
|
typing.value = true;
|
||||||
|
await nextTick();
|
||||||
|
remoteInput.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function choose(i: number) {
|
function choose(i: number) {
|
||||||
if (i >= items.value.length) emit("browse");
|
if (i === items.value.length + 1) void startTyping();
|
||||||
|
else if (i === items.value.length) emit("browse");
|
||||||
else emit("pick", items.value[i]);
|
else emit("pick", items.value[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onRemoteKey(e: KeyboardEvent) {
|
||||||
|
// Typed letters belong to the field, not to the menu's own keys.
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const text = remoteText.value.trim();
|
||||||
|
if (text) emit("pick", text);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
typing.value = false;
|
||||||
|
menu.value?.focus();
|
||||||
|
} else return;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
// Keys stay in the menu while it is open.
|
// Keys stay in the menu while it is open.
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const last = items.value.length;
|
const last = items.value.length + 1;
|
||||||
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
|
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
|
||||||
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
|
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
|
||||||
else if (e.key === "Enter") choose(active.value);
|
else if (e.key === "Enter") choose(active.value);
|
||||||
else if (e.key === "Delete" && active.value < last) emit("remove", items.value[active.value]);
|
else if (e.key === "Delete" && active.value < items.value.length) emit("remove", items.value[active.value]);
|
||||||
else if (e.key === "Escape" || e.key === "o") emit("close");
|
else if (e.key === "Escape" || e.key === "o") emit("close");
|
||||||
else return;
|
else return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -57,6 +82,23 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
|||||||
>
|
>
|
||||||
Open folder…
|
Open folder…
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="item remote"
|
||||||
|
:class="{ active: active === items.length + 1 }"
|
||||||
|
@mouseenter="active = items.length + 1"
|
||||||
|
@click="!typing && choose(items.length + 1)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-if="typing"
|
||||||
|
ref="remoteInput"
|
||||||
|
v-model="remoteText"
|
||||||
|
class="remote-input"
|
||||||
|
placeholder="user@host:/path/to/repo"
|
||||||
|
spellcheck="false"
|
||||||
|
@keydown="onRemoteKey"
|
||||||
|
/>
|
||||||
|
<template v-else>Open on another machine…</template>
|
||||||
|
</div>
|
||||||
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
|
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -87,5 +129,7 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
|||||||
.x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; }
|
.x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; }
|
||||||
.item.active .x { opacity: 1; }
|
.item.active .x { opacity: 1; }
|
||||||
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
|
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
|
||||||
|
.remote { color: var(--accent); }
|
||||||
|
.remote-input { flex: 1; min-width: 0; font: 12px ui-monospace, monospace; }
|
||||||
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
|
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from "vue";
|
|||||||
import { refKind, shortName } from "../lib/filter";
|
import { refKind, shortName } from "../lib/filter";
|
||||||
import { buildTree, type TreeNode } from "../lib/tree";
|
import { buildTree, type TreeNode } from "../lib/tree";
|
||||||
import type { FilterState, Ref, RefKind } from "../lib/types";
|
import type { FilterState, Ref, RefKind } from "../lib/types";
|
||||||
|
import EyeButton from "./EyeButton.vue";
|
||||||
import RefTree from "./RefTree.vue";
|
import RefTree from "./RefTree.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -98,7 +99,15 @@ function onPatternKey(e: KeyboardEvent) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group">
|
<section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group">
|
||||||
<h3>{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span></h3>
|
<h3>
|
||||||
|
{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span>
|
||||||
|
<EyeButton
|
||||||
|
v-if="g.kind === 'tag'"
|
||||||
|
:shut="!!filters.hideTags"
|
||||||
|
:title="filters.hideTags ? 'show all tags' : 'hide all tags, also tags added later'"
|
||||||
|
@click="filters.hideTags = !filters.hideTags"
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
<RefTree
|
<RefTree
|
||||||
:nodes="trees[g.kind]"
|
:nodes="trees[g.kind]"
|
||||||
:kind="g.kind"
|
:kind="g.kind"
|
||||||
@@ -122,6 +131,9 @@ function onPatternKey(e: KeyboardEvent) {
|
|||||||
padding: 10px 0 20px;
|
padding: 10px 0 20px;
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
margin: 12px 14px 6px;
|
margin: 12px 14px 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -129,6 +141,8 @@ h3 {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
.count { font-weight: normal; opacity: 0.7; }
|
.count { font-weight: normal; opacity: 0.7; }
|
||||||
|
h3 :deep(.eye) { margin-left: auto; margin-right: -6px; }
|
||||||
|
h3:hover :deep(.eye) { opacity: 1; }
|
||||||
.rules { padding: 0 14px; }
|
.rules { padding: 0 14px; }
|
||||||
.rules h3 { margin-left: 0; margin-top: 2px; }
|
.rules h3 { margin-left: 0; margin-top: 2px; }
|
||||||
.rules input { width: 100%; }
|
.rules input { width: 100%; }
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref } from "vue";
|
||||||
|
|
||||||
|
defineProps<{ recents: string[] }>();
|
||||||
|
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
|
||||||
|
|
||||||
|
/** True while the "Open on another machine…" button is a text field. */
|
||||||
|
const typing = ref(false);
|
||||||
|
const remoteText = ref("");
|
||||||
|
const remoteInput = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
async function startTyping() {
|
||||||
|
typing.value = true;
|
||||||
|
await nextTick();
|
||||||
|
remoteInput.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemoteKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const text = remoteText.value.trim();
|
||||||
|
if (text) emit("pick", text);
|
||||||
|
} else if (e.key === "Escape") typing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths on Windows use backslashes.
|
||||||
|
const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="welcome">
|
||||||
|
<div class="card">
|
||||||
|
<img src="/logo.svg" class="logo" alt="" />
|
||||||
|
<h2>Open a repository</h2>
|
||||||
|
<template v-if="recents.length">
|
||||||
|
<div class="head">Recent repositories</div>
|
||||||
|
<div class="list">
|
||||||
|
<div v-for="p in recents" :key="p" class="item">
|
||||||
|
<button class="open" :title="`open ${p}`" @click="emit('pick', p)">
|
||||||
|
<span class="name">{{ name(p) }}</span>
|
||||||
|
<span class="path">{{ p }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="x" title="remove from the list" @click="emit('remove', p)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="browse" @click="emit('browse')">Choose a folder…</button>
|
||||||
|
<input
|
||||||
|
v-if="typing"
|
||||||
|
ref="remoteInput"
|
||||||
|
v-model="remoteText"
|
||||||
|
class="remote-input"
|
||||||
|
placeholder="user@host:/path/to/repo"
|
||||||
|
spellcheck="false"
|
||||||
|
@keydown="onRemoteKey"
|
||||||
|
/>
|
||||||
|
<button v-else class="browse" @click="startTyping">Open on another machine…</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
You can also start gites with a path: <code>gites ~/code/my-project</code>,
|
||||||
|
or with a repository on another machine: <code>gites myserver:/srv/app</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.welcome { flex: 1; min-width: 0; display: flex; align-items: flex-start; justify-content: center; overflow: auto; padding: 12vh 24px 24px; }
|
||||||
|
.card { width: min(560px, 100%); display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.logo { width: 112px; height: 112px; align-self: center; }
|
||||||
|
h2 { margin: 0 0 4px; font-size: 20px; }
|
||||||
|
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); }
|
||||||
|
.list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 4px; background: var(--bg-side); }
|
||||||
|
.item { display: flex; align-items: center; border-radius: 6px; }
|
||||||
|
.item:hover, .item:focus-within { background: var(--bg-hover); }
|
||||||
|
.open { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 10px; border: none; padding: 7px 10px; text-align: left; background: none; }
|
||||||
|
.open:hover { background: none; }
|
||||||
|
.name { font-weight: 600; flex: none; }
|
||||||
|
.path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.x { border: none; padding: 2px 10px; color: var(--text-dim); opacity: 0; }
|
||||||
|
.item:hover .x, .item:focus-within .x { opacity: 1; }
|
||||||
|
.actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.remote-input { flex: 1; min-width: 220px; font: 12px ui-monospace, monospace; }
|
||||||
|
.browse { align-self: flex-start; padding: 6px 14px; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
|
||||||
|
.hint { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; }
|
||||||
|
.hint code { font-family: ui-monospace, monospace; color: var(--text); }
|
||||||
|
button:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { inlineRows, splitRows, unifiedRows, wordDiff, type DiffLine, type Hunk, type Row, type SplitRow } from "./diff";
|
||||||
|
|
||||||
|
/** Builds a hunk from lines written as git prints them: " same", "-old", "+new". */
|
||||||
|
function hunk(oldStart: number, newStart: number, ...lines: string[]): Hunk {
|
||||||
|
return {
|
||||||
|
header: `@@ -${oldStart} +${newStart} @@`,
|
||||||
|
old_start: oldStart,
|
||||||
|
new_start: newStart,
|
||||||
|
lines: lines.map((l) => ({ kind: l[0] as DiffLine["kind"], text: l.slice(1) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a row as text: removed parts in [-…-], added parts in {+…+}. */
|
||||||
|
const show = (pieces: { op: string; text: string }[]) =>
|
||||||
|
pieces.map((p) => (p.op === "-" ? `[-${p.text}-]` : p.op === "+" ? `{+${p.text}+}` : p.text)).join("");
|
||||||
|
const rowText = (r: Row) => (r.kind === "hunk" ? r.text : `${r.kind} ${r.old ?? "."} ${r.new ?? "."} ${show(r.pieces)}`);
|
||||||
|
const splitText = (r: SplitRow) =>
|
||||||
|
r.kind === "hunk"
|
||||||
|
? r.text
|
||||||
|
: `${r.left ? `${r.left.kind}${r.left.no} ${show(r.left.pieces)}` : "~"} | ${r.right ? `${r.right.kind}${r.right.no} ${show(r.right.pieces)}` : "~"}`;
|
||||||
|
|
||||||
|
describe("wordDiff", () => {
|
||||||
|
it("marks the words that changed", () => {
|
||||||
|
expect(show(wordDiff("Pick a commit to see its message and the files", "Pick a commit to see its message, the files")!)).toBe(
|
||||||
|
"Pick a commit to see its message[- and-]{+,+} the files",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps added text at the end of a line", () => {
|
||||||
|
expect(show(wordDiff('<section class="preview">', '<section class="preview" @keydown="onKey">')!)).toBe(
|
||||||
|
'<section class="preview"{+ @keydown="onKey"+}>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up on lines with little in common", () => {
|
||||||
|
expect(wordDiff("}", "function choose(f: ChangedFile) {")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins changed words separated only by spaces into one change", () => {
|
||||||
|
expect(show(wordDiff("// settings: the sidebar width", "// settings, saved in settings.json")!)).toBe(
|
||||||
|
"// settings[-: the sidebar width-]{+, saved in settings.json+}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats two equal lines as unchanged", () => {
|
||||||
|
expect(wordDiff("same", "same")).toEqual([{ op: "=", text: "same" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const change = hunk(40, 40, " keep", "-const open = ref(false);", "+const chosen = ref(null);", "+extra line", " end");
|
||||||
|
|
||||||
|
describe("unifiedRows", () => {
|
||||||
|
it("numbers lines on both sides and marks changed words", () => {
|
||||||
|
expect(unifiedRows([change]).map(rowText)).toEqual([
|
||||||
|
"@@ -40 +40 @@",
|
||||||
|
" 40 40 keep",
|
||||||
|
"- 41 . const [-open-] = ref([-false-]);",
|
||||||
|
"+ . 41 const {+chosen+} = ref({+null+});",
|
||||||
|
"+ . 42 extra line",
|
||||||
|
" 42 43 end",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark words in lines that were replaced by something unrelated", () => {
|
||||||
|
const rows = unifiedRows([hunk(1, 1, "-}", "+function f() {")]);
|
||||||
|
expect(rows.map(rowText).slice(1)).toEqual(["- 1 . }", "+ . 1 function f() {"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pairing removed and added lines", () => {
|
||||||
|
const block = hunk(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
"-import type { FilterState, Ref } from types;",
|
||||||
|
"+import { addRecent } from recents;",
|
||||||
|
"+import type { CommitDetails, FilterState, Ref } from types;",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("pairs a removed line with the added line most like it", () => {
|
||||||
|
expect(unifiedRows([block]).map(rowText).slice(1)).toEqual([
|
||||||
|
"- 1 . import type { FilterState, Ref } from types;",
|
||||||
|
"+ . 1 import { addRecent } from recents;",
|
||||||
|
"+ . 2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the pair on one row side by side", () => {
|
||||||
|
expect(splitRows([block]).map(splitText).slice(1)).toEqual([
|
||||||
|
"~ | +1 import { addRecent } from recents;",
|
||||||
|
"-1 import type { FilterState, Ref } from types; | +2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the unpaired line before the pair inline", () => {
|
||||||
|
expect(inlineRows([block]).map(rowText).slice(1)).toEqual([
|
||||||
|
"+ . 1 import { addRecent } from recents;",
|
||||||
|
"~ 1 2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitRows", () => {
|
||||||
|
it("puts removed and added lines side by side, with a gap where one side has no line", () => {
|
||||||
|
expect(splitRows([change]).map(splitText)).toEqual([
|
||||||
|
"@@ -40 +40 @@",
|
||||||
|
" 40 keep | 40 keep",
|
||||||
|
"-41 const [-open-] = ref([-false-]); | +41 const {+chosen+} = ref({+null+});",
|
||||||
|
"~ | +42 extra line",
|
||||||
|
" 42 end | 43 end",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inlineRows", () => {
|
||||||
|
it("merges a changed line into one row and keeps added lines whole", () => {
|
||||||
|
expect(inlineRows([change]).map(rowText)).toEqual([
|
||||||
|
"@@ -40 +40 @@",
|
||||||
|
" 40 40 keep",
|
||||||
|
"~ 41 41 const [-open-]{+chosen+} = ref([-false-]{+null+});",
|
||||||
|
"+ . 42 extra line",
|
||||||
|
" 42 43 end",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unrelated lines as all removed, then all added", () => {
|
||||||
|
const rows = inlineRows([hunk(1, 1, "-}", "-return x;", "+function f() {", "+ call();")]);
|
||||||
|
expect(rows.map(rowText).slice(1)).toEqual(["- 1 . }", "- 2 . return x;", "+ . 1 function f() {", "+ . 2 call();"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("large blocks", () => {
|
||||||
|
it("pairs lines by position when there are too many to compare every pair", () => {
|
||||||
|
const lines = Array.from({ length: 60 }, (_, i) => `-value ${i} old`).concat(Array.from({ length: 60 }, (_, i) => `+value ${i} new`));
|
||||||
|
const rows = inlineRows([hunk(1, 1, ...lines)]);
|
||||||
|
expect(rows.length).toBe(61);
|
||||||
|
expect(rowText(rows[60])).toBe("~ 60 60 value 59 [-old-]{+new+}");
|
||||||
|
});
|
||||||
|
});
|
||||||
+306
@@ -0,0 +1,306 @@
|
|||||||
|
// Turns the lines of one file's diff into rows for the three diff layouts.
|
||||||
|
|
||||||
|
export interface DiffLine {
|
||||||
|
kind: " " | "+" | "-";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hunk {
|
||||||
|
header: string;
|
||||||
|
old_start: number;
|
||||||
|
new_start: number;
|
||||||
|
lines: DiffLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDiff {
|
||||||
|
binary: boolean;
|
||||||
|
old_size: number | null;
|
||||||
|
new_size: number | null;
|
||||||
|
hunks: Hunk[];
|
||||||
|
/** The number of changed lines, when the diff is too large and was not sent. */
|
||||||
|
too_large: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Layout = "unified" | "split" | "inline";
|
||||||
|
|
||||||
|
/** A part of a line: unchanged, added or removed. */
|
||||||
|
export interface Piece {
|
||||||
|
op: "=" | "+" | "-";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of the one-column and inline layouts. `~` is a changed line in the inline layout. */
|
||||||
|
export type Row =
|
||||||
|
| { kind: "hunk"; text: string }
|
||||||
|
| { kind: " " | "+" | "-" | "~"; old: number | null; new: number | null; pieces: Piece[] };
|
||||||
|
|
||||||
|
export interface Cell {
|
||||||
|
kind: " " | "+" | "-";
|
||||||
|
no: number;
|
||||||
|
pieces: Piece[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of the side-by-side layout; a side is null where the other side has a line with no partner. */
|
||||||
|
export type SplitRow = { kind: "hunk"; text: string } | { kind: "pair"; left: Cell | null; right: Cell | null };
|
||||||
|
|
||||||
|
/** Words, runs of spaces, and single other characters. */
|
||||||
|
const TOKEN = /\w+|\s+|[^\w\s]/gu;
|
||||||
|
|
||||||
|
/** Lines with less in common than this (share of their non-space characters) are not compared word by word. */
|
||||||
|
const MIN_SIMILARITY = 0.3;
|
||||||
|
/** Comparing words costs (words in one line) × (words in the other); longer pairs are not compared. */
|
||||||
|
const MAX_WORK = 250_000;
|
||||||
|
/** Pairing lines costs (removed lines) × (added lines) comparisons; larger blocks pair lines in order. */
|
||||||
|
const MAX_PAIRING = 2_500;
|
||||||
|
|
||||||
|
const visible = (s: string) => s.replace(/\s/g, "").length;
|
||||||
|
|
||||||
|
/** Compares two lines word by word; `similarity` is the share of their non-space characters they have in common. */
|
||||||
|
function compare(a: string, b: string): { pieces: Piece[]; similarity: number } | null {
|
||||||
|
if (a === b) return { pieces: [{ op: "=", text: a }], similarity: 1 };
|
||||||
|
const x = a.match(TOKEN) ?? [];
|
||||||
|
const y = b.match(TOKEN) ?? [];
|
||||||
|
if (x.length * y.length > MAX_WORK) return null;
|
||||||
|
|
||||||
|
// common[i][j]: how many tokens x[i..] and y[j..] have in common, in order.
|
||||||
|
const common = Array.from({ length: x.length + 1 }, () => new Uint32Array(y.length + 1));
|
||||||
|
for (let i = x.length - 1; i >= 0; i--) {
|
||||||
|
for (let j = y.length - 1; j >= 0; j--) {
|
||||||
|
common[i][j] = x[i] === y[j] ? common[i + 1][j + 1] + 1 : Math.max(common[i + 1][j], common[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs of unchanged tokens, each followed by the removed and added text after it.
|
||||||
|
const runs: { same: string; del: string; add: string }[] = [{ same: "", del: "", add: "" }];
|
||||||
|
let i = 0, j = 0, same = 0;
|
||||||
|
while (i < x.length || j < y.length) {
|
||||||
|
const last = runs[runs.length - 1];
|
||||||
|
if (i < x.length && j < y.length && x[i] === y[j] && common[i][j] === common[i + 1][j + 1] + 1) {
|
||||||
|
same += visible(x[i]);
|
||||||
|
if (last.del || last.add) runs.push({ same: x[i], del: "", add: "" });
|
||||||
|
else last.same += x[i];
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (i < x.length && (j === y.length || common[i + 1][j] >= common[i][j + 1])) {
|
||||||
|
// On a tie, removed text goes before added text.
|
||||||
|
last.del += x[i++];
|
||||||
|
} else {
|
||||||
|
last.add += y[j++];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const size = visible(a) + visible(b);
|
||||||
|
if (size > 0 && (2 * same) / size < MIN_SIMILARITY) return null;
|
||||||
|
|
||||||
|
// Spaces alone between two changes join them, so "[-the-] [-sidebar-]" reads as one change.
|
||||||
|
const pieces: Piece[] = [];
|
||||||
|
let del = "", add = "";
|
||||||
|
runs.forEach((r, k) => {
|
||||||
|
const between = k > 0 && !r.same.trim() && (r.del || r.add) && (runs[k - 1].del || runs[k - 1].add);
|
||||||
|
if (between) {
|
||||||
|
del += r.same;
|
||||||
|
add += r.same;
|
||||||
|
} else {
|
||||||
|
if (del) pieces.push({ op: "-", text: del });
|
||||||
|
if (add) pieces.push({ op: "+", text: add });
|
||||||
|
if (r.same) pieces.push({ op: "=", text: r.same });
|
||||||
|
del = add = "";
|
||||||
|
}
|
||||||
|
del += r.del;
|
||||||
|
add += r.add;
|
||||||
|
});
|
||||||
|
if (del) pieces.push({ op: "-", text: del });
|
||||||
|
if (add) pieces.push({ op: "+", text: add });
|
||||||
|
return { pieces, similarity: size ? (2 * same) / size : 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two lines word by word. Returns the parts of both lines in order, each marked as
|
||||||
|
* unchanged, removed or added; or null when the lines have too little in common to pair.
|
||||||
|
*/
|
||||||
|
export function wordDiff(a: string, b: string): Piece[] | null {
|
||||||
|
return compare(a, b)?.pieces ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldSide = (pieces: Piece[]) => pieces.filter((p) => p.op !== "+");
|
||||||
|
const newSide = (pieces: Piece[]) => pieces.filter((p) => p.op !== "-");
|
||||||
|
const plain = (text: string): Piece[] => [{ op: "=", text }];
|
||||||
|
|
||||||
|
type Numbered = { no: number; text: string };
|
||||||
|
/** A removed line paired with an added line, or a line with no partner. */
|
||||||
|
type Step = { del?: Numbered; add?: Numbered; pieces?: Piece[] };
|
||||||
|
/** A hunk split into unchanged lines and blocks of removed lines followed by added lines. */
|
||||||
|
type Part =
|
||||||
|
| { kind: " "; old: number; new: number; text: string }
|
||||||
|
| { kind: "block"; dels: Numbered[]; adds: Numbered[]; steps: Step[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairs removed lines with added lines, keeping both in order and choosing the pairs most
|
||||||
|
* alike. Lines without a partner come before the next pair: removed ones first.
|
||||||
|
*/
|
||||||
|
function pairLines(dels: Numbered[], adds: Numbered[]): Step[] {
|
||||||
|
const D = dels.length, A = adds.length;
|
||||||
|
const steps: Step[] = [];
|
||||||
|
let lone: Step[] = [], loneAdds: Step[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
steps.push(...lone, ...loneAdds);
|
||||||
|
lone = [];
|
||||||
|
loneAdds = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (D * A > MAX_PAIRING) {
|
||||||
|
// Too many lines to try every pair: only lines at the same place in the block can pair.
|
||||||
|
for (let i = 0; i < Math.max(D, A); i++) {
|
||||||
|
const pieces = i < D && i < A ? wordDiff(dels[i].text, adds[i].text) : null;
|
||||||
|
if (pieces) {
|
||||||
|
flush();
|
||||||
|
steps.push({ del: dels[i], add: adds[i], pieces });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (i < D) lone.push({ del: dels[i] });
|
||||||
|
if (i < A) loneAdds.push({ add: adds[i] });
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match: (Piece[] | null)[][] = [];
|
||||||
|
const sims: number[][] = [];
|
||||||
|
for (let i = 0; i < D; i++) {
|
||||||
|
match.push([]);
|
||||||
|
sims.push([]);
|
||||||
|
for (let j = 0; j < A; j++) {
|
||||||
|
const c = compare(dels[i].text, adds[j].text);
|
||||||
|
match[i].push(c?.pieces ?? null);
|
||||||
|
sims[i].push(c?.similarity ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// score[i][j]: the best total likeness of pairs among dels[i..] and adds[j..].
|
||||||
|
const score = Array.from({ length: D + 1 }, () => new Float64Array(A + 1));
|
||||||
|
for (let i = D - 1; i >= 0; i--) {
|
||||||
|
for (let j = A - 1; j >= 0; j--) {
|
||||||
|
const paired = match[i][j] ? score[i + 1][j + 1] + sims[i][j] : 0;
|
||||||
|
score[i][j] = Math.max(paired, score[i + 1][j], score[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = 0, j = 0;
|
||||||
|
while (i < D || j < A) {
|
||||||
|
const pieces = i < D && j < A ? match[i][j] : null;
|
||||||
|
if (pieces && score[i][j] === score[i + 1][j + 1] + sims[i][j]) {
|
||||||
|
flush();
|
||||||
|
steps.push({ del: dels[i++], add: adds[j++], pieces });
|
||||||
|
} else if (i < D && (j === A || score[i + 1][j] >= score[i][j + 1])) {
|
||||||
|
lone.push({ del: dels[i++] });
|
||||||
|
} else {
|
||||||
|
loneAdds.push({ add: adds[j++] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parts(h: Hunk): Part[] {
|
||||||
|
const out: Part[] = [];
|
||||||
|
let o = h.old_start, n = h.new_start;
|
||||||
|
let block: Extract<Part, { kind: "block" }> | null = null;
|
||||||
|
for (const l of h.lines) {
|
||||||
|
if (l.kind === " ") {
|
||||||
|
block = null;
|
||||||
|
out.push({ kind: " ", old: o++, new: n++, text: l.text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A removed line after added ones starts a new block, so pairs keep git's order.
|
||||||
|
if (!block || (l.kind === "-" && block.adds.length)) {
|
||||||
|
block = { kind: "block", dels: [], adds: [], steps: [] };
|
||||||
|
out.push(block);
|
||||||
|
}
|
||||||
|
if (l.kind === "-") block.dels.push({ no: o++, text: l.text });
|
||||||
|
else block.adds.push({ no: n++, text: l.text });
|
||||||
|
}
|
||||||
|
for (const p of out) if (p.kind === "block") p.steps = pairLines(p.dels, p.adds);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unifiedRows(hunks: Hunk[]): Row[] {
|
||||||
|
const rows: Row[] = [];
|
||||||
|
for (const h of hunks) {
|
||||||
|
rows.push({ kind: "hunk", text: h.header });
|
||||||
|
for (const p of parts(h)) {
|
||||||
|
if (p.kind === " ") {
|
||||||
|
rows.push({ kind: " ", old: p.old, new: p.new, pieces: plain(p.text) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// As git prints them: all removed lines, then all added lines.
|
||||||
|
for (const s of p.steps) {
|
||||||
|
if (s.del) rows.push({ kind: "-", old: s.del.no, new: null, pieces: s.pieces ? oldSide(s.pieces) : plain(s.del.text) });
|
||||||
|
}
|
||||||
|
for (const s of p.steps) {
|
||||||
|
if (s.add) rows.push({ kind: "+", old: null, new: s.add.no, pieces: s.pieces ? newSide(s.pieces) : plain(s.add.text) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitRows(hunks: Hunk[]): SplitRow[] {
|
||||||
|
const rows: SplitRow[] = [];
|
||||||
|
for (const h of hunks) {
|
||||||
|
rows.push({ kind: "hunk", text: h.header });
|
||||||
|
for (const p of parts(h)) {
|
||||||
|
if (p.kind === " ") {
|
||||||
|
rows.push({
|
||||||
|
kind: "pair",
|
||||||
|
left: { kind: " ", no: p.old, pieces: plain(p.text) },
|
||||||
|
right: { kind: " ", no: p.new, pieces: plain(p.text) },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Lines without a partner share rows with each other, up to the next pair.
|
||||||
|
let dels: Numbered[] = [], adds: Numbered[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
for (let i = 0; i < Math.max(dels.length, adds.length); i++) {
|
||||||
|
const d = dels[i], a = adds[i];
|
||||||
|
rows.push({
|
||||||
|
kind: "pair",
|
||||||
|
left: d ? { kind: "-", no: d.no, pieces: plain(d.text) } : null,
|
||||||
|
right: a ? { kind: "+", no: a.no, pieces: plain(a.text) } : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dels = [];
|
||||||
|
adds = [];
|
||||||
|
};
|
||||||
|
for (const s of p.steps) {
|
||||||
|
if (s.pieces && s.del && s.add) {
|
||||||
|
flush();
|
||||||
|
rows.push({
|
||||||
|
kind: "pair",
|
||||||
|
left: { kind: "-", no: s.del.no, pieces: oldSide(s.pieces) },
|
||||||
|
right: { kind: "+", no: s.add.no, pieces: newSide(s.pieces) },
|
||||||
|
});
|
||||||
|
} else if (s.del) dels.push(s.del);
|
||||||
|
else if (s.add) adds.push(s.add);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inlineRows(hunks: Hunk[]): Row[] {
|
||||||
|
const rows: Row[] = [];
|
||||||
|
for (const h of hunks) {
|
||||||
|
rows.push({ kind: "hunk", text: h.header });
|
||||||
|
for (const p of parts(h)) {
|
||||||
|
if (p.kind === " ") {
|
||||||
|
rows.push({ kind: " ", old: p.old, new: p.new, pieces: plain(p.text) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const s of p.steps) {
|
||||||
|
if (s.pieces && s.del && s.add) rows.push({ kind: "~", old: s.del.no, new: s.add.no, pieces: s.pieces });
|
||||||
|
else if (s.del) rows.push({ kind: "-", old: s.del.no, new: null, pieces: plain(s.del.text) });
|
||||||
|
else if (s.add) rows.push({ kind: "+", old: null, new: s.add.no, pieces: plain(s.add.text) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
@@ -78,3 +78,18 @@ describe("lineOwners", () => {
|
|||||||
expect(owners.get("f")).toBe("refs/heads/feature");
|
expect(owners.get("f")).toBe("refs/heads/feature");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hiding all tags", () => {
|
||||||
|
const state = { hidden: [], rules: [], hideTags: true };
|
||||||
|
it("hides every tag", () => {
|
||||||
|
expect(isRefVisible("refs/tags/v1", state)).toBe(false);
|
||||||
|
expect(isRefVisible("refs/tags/release/2", state)).toBe(false);
|
||||||
|
});
|
||||||
|
it("leaves branches alone", () => {
|
||||||
|
expect(isRefVisible("refs/heads/main", state)).toBe(true);
|
||||||
|
expect(isRefVisible("refs/remotes/origin/main", state)).toBe(true);
|
||||||
|
});
|
||||||
|
it("shows tags again when turned off", () => {
|
||||||
|
expect(isRefVisible("refs/tags/v1", { ...state, hideTags: false })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function globMatch(pattern: string, text: string): boolean {
|
|||||||
|
|
||||||
export function isRefVisible(name: string, state: FilterState): boolean {
|
export function isRefVisible(name: string, state: FilterState): boolean {
|
||||||
if (state.hidden.includes(name)) return false;
|
if (state.hidden.includes(name)) return false;
|
||||||
|
if (state.hideTags && name.startsWith("refs/tags/")) return false;
|
||||||
const short = shortName(name);
|
const short = shortName(name);
|
||||||
const only = state.rules.filter((r) => r.mode === "only");
|
const only = state.rules.filter((r) => r.mode === "only");
|
||||||
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
|
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repoRequests } from "./latest";
|
||||||
|
|
||||||
|
describe("repoRequests", () => {
|
||||||
|
it("applies a reload with no open around", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const reload = r.startReload();
|
||||||
|
expect(r.isCurrent(reload)).toBe(true);
|
||||||
|
});
|
||||||
|
it("applies a reload that starts after an open has finished", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const open = r.startOpen();
|
||||||
|
r.finish(open);
|
||||||
|
const reload = r.startReload();
|
||||||
|
expect(r.isCurrent(reload)).toBe(true);
|
||||||
|
});
|
||||||
|
it("drops a reload started during a running open, and applies the open", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const open = r.startOpen();
|
||||||
|
const reload = r.startReload();
|
||||||
|
// The reload answers first, while the open is still running.
|
||||||
|
expect(r.isCurrent(reload)).toBe(false);
|
||||||
|
expect(r.isCurrent(open)).toBe(true);
|
||||||
|
r.finish(open);
|
||||||
|
// Its reply may hold the repository from before the open, so it stays dropped.
|
||||||
|
expect(r.isCurrent(reload)).toBe(false);
|
||||||
|
});
|
||||||
|
it("drops a reload when an open starts after it", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const reload = r.startReload();
|
||||||
|
const open = r.startOpen();
|
||||||
|
expect(r.isCurrent(reload)).toBe(false);
|
||||||
|
r.finish(open);
|
||||||
|
expect(r.isCurrent(reload)).toBe(false);
|
||||||
|
expect(r.isCurrent(open)).toBe(true);
|
||||||
|
});
|
||||||
|
it("applies only the newer of two opens", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const older = r.startOpen();
|
||||||
|
const newer = r.startOpen();
|
||||||
|
expect(r.isCurrent(older)).toBe(false);
|
||||||
|
expect(r.isCurrent(newer)).toBe(true);
|
||||||
|
});
|
||||||
|
it("applies an open even when a reload started after it", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const open = r.startOpen();
|
||||||
|
r.startReload();
|
||||||
|
expect(r.isCurrent(open)).toBe(true);
|
||||||
|
});
|
||||||
|
it("keeps separate sets of requests apart", () => {
|
||||||
|
const a = repoRequests();
|
||||||
|
const b = repoRequests();
|
||||||
|
const reload = a.startReload();
|
||||||
|
b.startOpen();
|
||||||
|
expect(a.isCurrent(reload)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Decides which replies may put a repository on screen. Git runs away from the window, so
|
||||||
|
* replies can arrive in another order than the requests went out. Opening a repository wins
|
||||||
|
* over a reload:
|
||||||
|
* - a reload's reply is dropped if an open was running when the reload started, if an open
|
||||||
|
* started after it, or if an open is still running when the reply arrives;
|
||||||
|
* - an open's reply is dropped only if a newer open started after it.
|
||||||
|
*/
|
||||||
|
export type Request = { kind: "open" | "reload"; number: number; duringOpen: boolean };
|
||||||
|
|
||||||
|
export interface RepoRequests {
|
||||||
|
startOpen(): Request;
|
||||||
|
startReload(): Request;
|
||||||
|
/** Call when an open has finished, whether its reply was used or not. */
|
||||||
|
finish(request: Request): void;
|
||||||
|
isCurrent(request: Request): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repoRequests(): RepoRequests {
|
||||||
|
let counter = 0;
|
||||||
|
let newestOpen = 0;
|
||||||
|
const runningOpens = new Set<number>();
|
||||||
|
return {
|
||||||
|
startOpen() {
|
||||||
|
const number = ++counter;
|
||||||
|
newestOpen = number;
|
||||||
|
runningOpens.add(number);
|
||||||
|
return { kind: "open", number, duringOpen: false };
|
||||||
|
},
|
||||||
|
startReload() {
|
||||||
|
return { kind: "reload", number: ++counter, duringOpen: runningOpens.size > 0 };
|
||||||
|
},
|
||||||
|
finish(request) {
|
||||||
|
runningOpens.delete(request.number);
|
||||||
|
},
|
||||||
|
isCurrent(request) {
|
||||||
|
if (request.kind === "open") return request.number === newestOpen;
|
||||||
|
return !request.duringOpen && newestOpen < request.number && runningOpens.size === 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { addRecent, MAX_RECENTS } from "./recents";
|
import { addRecent, MAX_RECENTS, mergeRecents } from "./recents";
|
||||||
|
|
||||||
describe("addRecent", () => {
|
describe("addRecent", () => {
|
||||||
it("puts the path first", () => {
|
it("puts the path first", () => {
|
||||||
@@ -16,3 +16,12 @@ describe("addRecent", () => {
|
|||||||
expect(out).not.toContain(`/r${MAX_RECENTS - 1}`);
|
expect(out).not.toContain(`/r${MAX_RECENTS - 1}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mergeRecents", () => {
|
||||||
|
it("keeps the saved order", () => {
|
||||||
|
expect(mergeRecents([], ["/a", "/b", "/c"])).toEqual(["/a", "/b", "/c"]);
|
||||||
|
});
|
||||||
|
it("puts a path opened before the settings arrived first, once", () => {
|
||||||
|
expect(mergeRecents(["/b"], ["/a", "/b", "/c"])).toEqual(["/b", "/a", "/c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,3 +4,11 @@ export const MAX_RECENTS = 12;
|
|||||||
export function addRecent(list: string[], path: string): string[] {
|
export function addRecent(list: string[], path: string): string[] {
|
||||||
return [path, ...list.filter((p) => p !== path)].slice(0, MAX_RECENTS);
|
return [path, ...list.filter((p) => p !== path)].slice(0, MAX_RECENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the saved list with paths opened before the saved settings arrived: those come
|
||||||
|
* first, then the saved ones in their saved order, each path once.
|
||||||
|
*/
|
||||||
|
export function mergeRecents(openedFirst: string[], saved: string[]): string[] {
|
||||||
|
return [...openedFirst, ...saved.filter((p) => !openedFirst.includes(p))].slice(0, MAX_RECENTS);
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface FilterState {
|
|||||||
/** Full ref names hidden with the eye toggle. */
|
/** Full ref names hidden with the eye toggle. */
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
rules: FilterRule[];
|
rules: FilterRule[];
|
||||||
|
/** Hides every tag, also tags created later. */
|
||||||
|
hideTags?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A commit as shown on screen, with its parent edges after collapsing. */
|
/** A commit as shown on screen, with its parent edges after collapsing. */
|
||||||
|
|||||||
Reference in New Issue
Block a user