Author SHA1 Message Date
szaman 898135121c Let opening a repository win over a reload
ci / check (push) Successful in 2m58s
A reload's reply or error is dropped when an open was running as it
started, an open started after it, or an open is still running when it
answers. An open's reply or error is dropped only when a newer open
started after it.
2026-10-15 15:32:44 +02:00
szaman bd7c129116 Share no ssh connection when the socket path would be too long
macOS accepts socket paths of at most 103 characters. Count the folder,
the file name, the 40-character hash and the temporary end ssh adds; when
the total is over 103, leave the connection-sharing options out. Find,
make and check the socket folder once per run instead of for every
command.
2026-10-15 15:31:08 +02:00
szaman 25308f5f0f Show only the newest repository when replies arrive out of order
Every reload and every open takes a number before asking git; a reply is
used only if no newer request was sent after it, and an out-of-date error
is not shown. Commit details and diffs that come back after another
repository was opened are neither kept nor shown.
2026-10-15 15:31:08 +02:00
szaman 7b902616fe Let error messages stay until read; show the remote form on the welcome screen
Error messages now stay for 8 seconds or until clicked, and keep each
hint on its own line. The welcome screen shows how to start gites with
a repository on another machine. The spec follows these changes and the
new ssh options and socket folder.
2026-10-15 15:26:16 +02:00
szaman bf6ae3e2bb Keep the window responsive and ssh connections safe
Commands that run git now run away from the thread that draws the
window, so a slow server no longer freezes it. ssh now notices a server
that stopped answering in about 10 seconds. The sockets for shared
connections go in the runtime folder, or else in a private gites folder
in the cache folder, never in /tmp; without either, connections are not
shared. A path that is exactly ~ now means the home folder on the
server.

Tests cover commit details through ssh, the message for a failed
connection, and a ~/ path end to end. The stand-in ssh is now set per
test, so a test can give it its own home folder.
2026-10-15 15:26:16 +02:00
szaman 5ef5c1ba94 README: repositories on another machine 2026-10-15 15:16:46 +02:00
szaman ccf945a067 Open on another machine from the window; keep the graph when a reload fails 2026-10-15 15:15:41 +02:00
szaman 16b0363d07 Run the stand-in ssh for tests without writing a script file 2026-10-15 15:13:02 +02:00
szaman 48e5e8c8ce Keep command-line paths that are not valid UTF-8 intact 2026-10-15 15:11:26 +02:00
szaman 7fe5a70c69 Run git through ssh for repositories on another machine 2026-10-15 15:09:17 +02:00
szaman 121fe1bc2f Send remote commands in a form every login shell reads the same way 2026-10-15 15:07:31 +02:00
szaman 157a4063c7 Read host:path locations and quote words for a server's shell 2026-10-15 15:05:23 +02:00
szaman ddb05980e0 Plan: open repositories on another machine over ssh 2026-10-15 14:44:17 +02:00
szaman 761e528e15 Design: open repositories on another machine over ssh 2026-10-15 14:35:27 +02:00
szaman e4e597dc5c Eye on the Tags header hides all tags, also tags added later
ci / check (push) Successful in 3m3s
release / release (push) Successful in 11m34s
2026-10-15 13:55:08 +02:00
szaman 302fe4a4ae Keep the saved order of recent repositories; it was reversed on every start 2026-10-15 13:55:08 +02:00
szaman 2b433fb650 Welcome screen with recent repositories and a folder picker when gites starts outside a repository 2026-10-15 13:55:08 +02:00
szaman 394d92e756 Start git without a console window on Windows, and run one reload at a time
ci / check (push) Successful in 2m27s
release / release (push) Successful in 11m52s
2026-10-15 12:14:01 +02:00
szaman e6c70554f3 README with screenshots, install steps, keys and how to build
ci / check (push) Successful in 3m7s
2026-10-15 11:23:12 +02:00
szaman 432fe58563 Share one tool cache between CI and release builds, fall back to the last tools after a mise.toml change, and cache cargo-xwin for the Windows build
ci / check (push) Successful in 2m56s
2026-10-15 06:42:16 +02:00
szaman 52d6522075 Run the Rust tests in the test task, so CI runs them too
ci / check (push) Successful in 6m4s
release / release (push) Successful in 22m23s
2026-10-15 05:13:29 +02:00
szaman d75afe0249 Diff view for a file chosen in the commit preview: one column, side by side or inline, with changed words marked 2026-10-15 05:13:29 +02:00
szaman 13f0fbcb39 Limit the default capabilities to desktop platforms 2026-10-15 05:13:29 +02:00
szaman 983ac2cfa9 Use the current stable Rust line, 1.99
ci / check (push) Successful in 3m16s
2026-10-15 04:11:43 +02:00
szaman 93e9cf94a5 Switch from pnpm to Bun; install Rust through mise; update Tauri packages on both sides
ci / check (push) Successful in 5m9s
2026-10-15 04:03:01 +02:00
szaman 788a373e17 Start the folder picker in the home folder
ci / check (push) Successful in 1m40s
2026-10-15 00:41:26 +02:00
szaman 80906a9bda Clear cached packages before a release build, so old versions are not uploaded
ci / check (push) Successful in 1m46s
2026-10-15 00:15:09 +02:00
szaman 58268c4015 Commit preview panel at the bottom; open other repositories from a recent list or a folder picker
ci / check (push) Successful in 4m13s
release / release (push) Successful in 9m54s
2026-10-15 00:00:12 +02:00
39 changed files with 3640 additions and 1133 deletions
+6 -1
View File
@@ -14,6 +14,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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
uses: actions/cache@v4
with:
@@ -24,7 +26,10 @@ jobs:
~/.rustup
~/.local/bin/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
uses: actions/cache@v4
with:
+21 -4
View File
@@ -24,10 +24,11 @@ jobs:
CARGO_BUILD_JOBS: "2"
steps:
- 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
uses: actions/cache@v4
with:
# ~/.cache/cargo-xwin holds the Windows SDK files cargo-xwin downloads.
path: |
~/.cargo/bin
~/.cargo/registry
@@ -35,8 +36,22 @@ jobs:
~/.rustup
~/.local/bin/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
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
uses: actions/cache@v4
with:
@@ -58,10 +73,12 @@ jobs:
'
- name: Unit tests
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
run: pnpm tauri build --bundles deb,rpm,appimage
run: bun run tauri build --bundles deb,rpm,appimage
- 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
run: |
mkdir -p release
+136
View File
@@ -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>
![The commit graph of a small repository, with branches, merges, tags and the preview panel for the selected commit](docs/screenshots/graph.png)
## 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.
![The diff of one file, with old and new side by side and the changed words marked](docs/screenshots/diff.png)
## 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.
+234
View File
@@ -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.
+106
View File
@@ -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"]
+6 -5
View File
@@ -1,22 +1,23 @@
[tools]
node = "24"
pnpm = "11"
bun = "1.4"
rust = "1.99"
[tasks.dev]
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]
description = "Run unit tests"
run = "pnpm vitest run"
run = ["bun run vitest run", "cargo test --manifest-path src-tauri/Cargo.toml"]
[tasks.check]
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]
description = "Build a release binary and install it as ~/.local/bin/gites"
run = [
"pnpm tauri build --no-bundle",
"bun run tauri build --no-bundle",
"install -Dm755 src-tauri/target/release/gites ~/.local/bin/gites",
]
+4 -3
View File
@@ -10,12 +10,13 @@
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.4.2",
"@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",
"@tauri-apps/cli": "^2.12.3",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.0.16",
-1027
View File
File diff suppressed because it is too large Load Diff
+11 -17
View File
@@ -16,24 +16,18 @@ fi
$SUDO apt-get update -q
$SUDO apt-get install -yq --no-install-recommends "${packages[@]}"
# Rust, cargo-xwin and mise may already be here from the CI cache.
export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH"
# Rust: not pinned in mise.toml, because mise needs rustup for it.
if ! command -v cargo >/dev/null; then
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
fi
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
# mise, and the Rust it installs through rustup, may already be here from the CI cache.
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
for dir in "$HOME/.cargo/bin" "$HOME/.local/bin" "$HOME/.local/share/mise/shims"; do
echo "$dir" >> "$GITHUB_PATH"
done
# Node, Bun and Rust, at the versions in mise.toml.
mise trust && mise install
if [[ "${1:-}" == "--windows" ]]; then
rustup target add x86_64-pc-windows-msvc
command -v cargo-xwin >/dev/null || cargo install --locked cargo-xwin
mise exec -- rustup target add x86_64-pc-windows-msvc
command -v cargo-xwin >/dev/null || mise exec -- cargo install --locked cargo-xwin
fi
command -v mise >/dev/null || curl -fsSL https://mise.run | MISE_VERSION=v2026.9.1 sh
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
bun install --frozen-lockfile
+70 -2
View File
@@ -1167,6 +1167,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
]
[[package]]
@@ -2179,6 +2180,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2699,6 +2701,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3394,9 +3420,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.4.2"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640d0789c9db02265a800fded60520df5a3baa4a1b5f40715b83d58842c24fcb"
checksum = "9be5ed8d94c33afe8377e885119069f6508780659271d0a25cfc54d441f2bc81"
dependencies = [
"arboard",
"log",
@@ -3407,6 +3433,48 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.21",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.21",
"toml 1.1.8+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-runtime"
version = "2.12.2"
+1
View File
@@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-clipboard-manager = "2"
dirs = "6"
tauri-plugin-dialog = "2"
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
+4 -2
View File
@@ -8,6 +8,8 @@
"permissions": [
"core:default",
"core:window:allow-set-title",
"clipboard-manager:allow-write-text"
]
"clipboard-manager:allow-write-text",
"dialog:allow-open"
],
"platforms": ["linux", "windows", "macOS"]
}
+189
View File
@@ -0,0 +1,189 @@
//! Everything the preview panel shows about one commit.
use crate::git::git;
use serde::Serialize;
use crate::location::Location;
#[derive(Serialize, Debug, PartialEq)]
pub struct ChangedFile {
/// One letter: A added, M modified, D deleted, R renamed, C copied, T type changed.
pub status: String,
pub path: String,
/// The path before a rename or copy.
pub old_path: Option<String>,
/// Line counts; `None` for binary files.
pub added: Option<u32>,
pub deleted: Option<u32>,
}
#[derive(Serialize, Debug, PartialEq)]
pub struct CommitDetails {
hash: String,
author: String,
author_email: String,
author_time: i64,
committer: String,
committer_email: String,
commit_time: i64,
parents: Vec<String>,
/// The full message, subject line included.
message: String,
files: Vec<ChangedFile>,
}
/// Parses `git diff-tree -z --name-status` and `-z --numstat` output for the same diff.
pub fn parse_files(name_status: &str, numstat: &str) -> Vec<ChangedFile> {
let mut counts = Vec::new();
let mut num = numstat.split('\0').filter(|s| !s.is_empty());
while let Some(head) = num.next() {
let mut f = head.splitn(3, '\t');
let added = f.next().and_then(|n| n.parse().ok());
let deleted = f.next().and_then(|n| n.parse().ok());
// A rename has an empty path here, followed by the old and new paths.
if f.next().unwrap_or("").is_empty() {
num.next();
num.next();
}
counts.push((added, deleted));
}
let mut files = Vec::new();
let mut ns = name_status.split('\0').filter(|s| !s.is_empty());
while let Some(code) = ns.next() {
let status = code[..1].to_string();
let first = ns.next().unwrap_or_default().to_string();
let (path, old_path) = if status == "R" || status == "C" {
(ns.next().unwrap_or_default().to_string(), Some(first))
} else {
(first, None)
};
let (added, deleted) = counts.get(files.len()).copied().unwrap_or((None, None));
files.push(ChangedFile { status, path, old_path, added, deleted });
}
files
}
pub fn load(loc: &Location, hash: &str) -> Result<CommitDetails, 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 info = git(
loc,
&["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();
if f.len() < 9 {
return Err(format!("unexpected git show output for {hash}"));
}
let parents: Vec<String> = f[7].split_whitespace().map(String::from).collect();
// A merge is compared with its first parent; other commits with their only parent,
// or with nothing when they are the first commit.
let mut args = vec!["diff-tree", "-r", "-M", "-z"];
if let Some(first) = parents.first().filter(|_| parents.len() > 1) {
args.push(first);
} else {
args.extend(["--root", "--no-commit-id"]);
}
args.push(hash);
let with = |flag: &'static str| {
let mut a = args.clone();
a.insert(1, flag);
git(loc, &a)
};
let files = parse_files(&with("--name-status")?, &with("--numstat")?);
Ok(CommitDetails {
hash: f[0].to_string(),
author: f[1].to_string(),
author_email: f[2].to_string(),
author_time: f[3].parse().unwrap_or(0),
committer: f[4].to_string(),
committer_email: f[5].to_string(),
commit_time: f[6].parse().unwrap_or(0),
parents,
message: f[8].trim_end().to_string(),
files,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn file(status: &str, path: &str, old: Option<&str>, a: Option<u32>, d: Option<u32>) -> ChangedFile {
ChangedFile {
status: status.into(),
path: path.into(),
old_path: old.map(String::from),
added: a,
deleted: d,
}
}
#[test]
fn parses_plain_changes() {
let ns = "M\0src/a.ts\0A\0src/b.ts\0D\0old.txt\0";
let num = "3\t1\tsrc/a.ts\09\t0\tsrc/b.ts\00\t40\told.txt\0";
assert_eq!(
parse_files(ns, num),
vec![
file("M", "src/a.ts", None, Some(3), Some(1)),
file("A", "src/b.ts", None, Some(9), Some(0)),
file("D", "old.txt", None, Some(0), Some(40)),
]
);
}
#[test]
fn parses_renames_and_binary_files() {
let ns = "R087\0lib/old.rs\0lib/new.rs\0M\0logo.png\0";
let num = "2\t2\t\0lib/old.rs\0lib/new.rs\0-\t-\tlogo.png\0";
assert_eq!(
parse_files(ns, num),
vec![
file("R", "lib/new.rs", Some("lib/old.rs"), Some(2), Some(2)),
file("M", "logo.png", None, None, None),
]
);
}
#[test]
fn parses_an_empty_diff() {
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);
}
}
+353
View File
@@ -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
View File
@@ -1,5 +1,7 @@
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;
#[derive(Serialize)]
@@ -31,22 +33,148 @@ pub struct RepoData {
head: Option<String>,
}
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map_err(|e| format!("could not run git: {e}"))?;
/// The text to show when ssh could not reach the server.
pub fn ssh_error(target: &str, stderr: &str) -> String {
let stderr = stderr.trim();
let mut message = format!("could not connect to {target}: {stderr}");
if stderr.contains("Host key verification failed") {
message.push_str(&format!("\nRun `ssh {target}` once in a terminal to accept the server's key."));
}
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() {
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())
}
pub fn load(dir: &Path) -> Result<RepoData, String> {
let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string();
let root_path = Path::new(&root);
pub fn load(loc: &Location) -> Result<RepoData, String> {
let top = git(loc, &["rev-parse", "--show-toplevel"])?.trim().to_string();
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"])
.ok()
@@ -125,3 +253,219 @@ pub fn load(dir: &Path) -> Result<RepoData, String> {
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}");
}
}
+60 -10
View File
@@ -1,15 +1,62 @@
mod details;
mod diff;
mod filters;
mod git;
mod location;
mod settings;
#[cfg(test)]
mod test_ssh;
use location::Location;
use std::path::PathBuf;
use std::sync::Mutex;
/// The repository folder: the first command line argument, or the current folder.
struct RepoArg(PathBuf);
/// The open repository (a folder here or on another machine), from `git::start_dir`; it changes when the user opens
/// another one. `None` until a repository is open: the window shows its welcome screen.
struct RepoDir(Mutex<Option<Location>>);
#[tauri::command]
fn load_repo(arg: tauri::State<RepoArg>) -> Result<git::RepoData, String> {
git::load(&arg.0)
impl RepoDir {
fn get(&self) -> Result<Location, String> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or_else(|| "no repository is open".into())
}
}
// The commands that run git are marked `async` so Tauri runs them away from the thread that
// draws the window: a command that waits for a slow server must not freeze the window.
/// 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.
#[tauri::command(async)]
fn open_repo(dir: tauri::State<'_, RepoDir>, path: String) -> Result<git::RepoData, String> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
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)
}
#[tauri::command(async)]
fn commit_details(dir: tauri::State<'_, RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
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]
@@ -34,16 +81,19 @@ fn save_settings(value: serde_json::Value) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let path = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let arg = std::env::args_os().nth(1);
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let path = git::start_dir(arg, cwd);
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
.manage(RepoArg(path))
.plugin(tauri_plugin_dialog::init())
.manage(RepoDir(Mutex::new(path)))
.invoke_handler(tauri::generate_handler![
load_repo,
open_repo,
commit_details,
file_diff,
load_filters,
save_filters,
load_settings,
+190
View File
@@ -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(&quote(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'");
}
}
+42
View File
@@ -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));
}
+2 -2
View File
@@ -4,9 +4,9 @@
"version": "0.1.0",
"identifier": "it.wrobel.gites",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
+368 -46
View File
@@ -1,17 +1,26 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { homeDir } from "@tauri-apps/api/path";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { buildRows } from "./lib/collapse";
import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/filter";
import { layout } from "./lib/layout";
import { textWidth } from "./lib/measure";
import { colorOf } from "./lib/palette";
import type { FilterState, Ref, RefKind, RepoData } from "./lib/types";
import { addRecent, mergeRecents } from "./lib/recents";
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 LabelStack from "./components/LabelStack.vue";
import PreviewPanel from "./components/PreviewPanel.vue";
import RepoMenu from "./components/RepoMenu.vue";
import Sidebar from "./components/Sidebar.vue";
import Welcome from "./components/Welcome.vue";
const ROW_H = 28;
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
@@ -25,39 +34,63 @@ const selected = ref(0);
const toast = ref<string | null>(null);
const sidebar = ref<InstanceType<typeof Sidebar>>();
// ---- settings: the sidebar width -------------------------------------------
// ---- settings, saved in settings.json ---------------------------------------
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 700;
const sidebarWidth = ref(270);
const PREVIEW_MIN = 80;
const LAYOUTS: Layout[] = ["unified", "split", "inline"];
interface Settings {
sidebarWidth: number;
previewHeight: number;
previewShown: boolean;
diffLayout: Layout;
/** Repository roots, most recently opened first. */
recents: string[];
}
const settings = reactive<Settings>({ sidebarWidth: 270, previewHeight: 220, previewShown: true, diffLayout: "unified", recents: [] });
let settingsLoaded = false;
async function loadSettings() {
const saved = await invoke<{ sidebarWidth?: number } | null>("load_settings").catch(() => null);
const saved = await invoke<Partial<Settings> | null>("load_settings").catch(() => null);
if (typeof saved?.sidebarWidth === "number") {
sidebarWidth.value = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, saved.sidebarWidth));
settings.sidebarWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, saved.sidebarWidth));
}
if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight);
if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown;
if (saved?.diffLayout && LAYOUTS.includes(saved.diffLayout)) settings.diffLayout = saved.diffLayout;
if (Array.isArray(saved?.recents)) settings.recents = mergeRecents(settings.recents, saved.recents);
settingsLoaded = true;
}
let settingsTimer: number | undefined;
watch(sidebarWidth, () => {
if (!settingsLoaded) return;
clearTimeout(settingsTimer);
settingsTimer = window.setTimeout(() => {
invoke("save_settings", { value: { sidebarWidth: sidebarWidth.value } }).catch((e) =>
showToast(`could not save settings: ${e}`),
);
}, 300);
});
watch(
settings,
() => {
if (!settingsLoaded) return;
clearTimeout(settingsTimer);
settingsTimer = window.setTimeout(() => {
invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`, { error: true }));
}, 300);
},
{ deep: true },
);
function startResize(e: PointerEvent) {
/** Drag handler for both dividers: `axis` says which way it moves. */
function startDrag(e: PointerEvent, axis: "x" | "y") {
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startWidth = sidebarWidth.value;
const start = axis === "x" ? e.clientX : e.clientY;
const startValue = axis === "x" ? settings.sidebarWidth : settings.previewHeight;
const move = (ev: PointerEvent) => {
sidebarWidth.value = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + ev.clientX - startX));
if (axis === "x") {
settings.sidebarWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startValue + ev.clientX - start));
} else {
// Dragging up makes the panel taller; the list keeps at least a few rows.
const max = window.innerHeight - 220;
settings.previewHeight = Math.min(max, Math.max(PREVIEW_MIN, startValue - (ev.clientY - start)));
}
};
const stop = () => {
handle.removeEventListener("pointermove", move);
@@ -71,26 +104,91 @@ function startResize(e: PointerEvent) {
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() {
// The window gets focus often; one reload at a time is enough.
if (loading) return;
loading = true;
const request = requests.startReload();
try {
const data = await invoke<RepoData>("load_repo");
error.value = null;
if (filtersLoadedFor !== data.root) {
const saved = await invoke<Partial<FilterState & { collapseAll: boolean }> | null>("load_filters", {
repo: data.root,
});
filters.hidden = saved?.hidden ?? [];
filters.rules = saved?.rules ?? [];
collapseAll.value = saved?.collapseAll ?? false;
filtersLoadedFor = data.root;
await getCurrentWindow().setTitle(`gites — ${data.root}`);
}
repo.value = data;
// 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) {
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) {
menuOpen.value = false;
const request = requests.startOpen();
try {
await applyRepo(await invoke<RepoData>("open_repo", { path }), request);
} 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.
showToast(`could not open ${path}: ${e}`, { error: true });
} finally {
requests.finish(request);
}
}
async function browse() {
menuOpen.value = false;
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);
}
const menuOpen = ref(false);
/** 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;
if (filtersLoadedFor !== data.root) {
const saved = await invoke<Partial<FilterState & { collapseAll: boolean }> | null>("load_filters", {
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.rules = saved?.rules ?? [];
filters.hideTags = saved?.hideTags ?? false;
collapseAll.value = saved?.collapseAll ?? false;
filtersLoadedFor = data.root;
toggled.value = new Set();
selected.value = 0;
detailsCache.clear();
diffCache.clear();
if (scroller.value) scroller.value.scrollTop = 0;
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}`);
return;
}
repo.value = data;
}
let saveTimer: number | undefined;
watch(
[filters, collapseAll],
@@ -101,8 +199,8 @@ watch(
saveTimer = window.setTimeout(() => {
invoke("save_filters", {
repo: repoRoot,
value: { hidden: filters.hidden, rules: filters.rules, collapseAll: collapseAll.value },
}).catch((e) => showToast(`could not save filters: ${e}`));
value: { hidden: filters.hidden, rules: filters.rules, hideTags: filters.hideTags, collapseAll: collapseAll.value },
}).catch((e) => showToast(`could not save filters: ${e}`, { error: true }));
}, 300);
},
{ deep: true },
@@ -181,13 +279,135 @@ function select(i: number) {
nextTick(scrollToSelected);
}
// ---- preview of the selected commit ------------------------------------------
const detailsCache = new Map<string, CommitDetails>();
const details = shallowRef<CommitDetails | null>(null);
const detailsError = ref<string | null>(null);
const selectedHash = computed(() => rows.value[selected.value]?.commit.hash ?? null);
let detailsTimer: number | undefined;
watch([selectedHash, () => settings.previewShown], ([hash, shown]) => {
clearTimeout(detailsTimer);
if (!hash || !shown) return;
const cached = detailsCache.get(hash);
if (cached) {
details.value = cached;
detailsError.value = null;
return;
}
// Wait a moment, so holding a key down does not run git for every row passed.
detailsTimer = window.setTimeout(async () => {
// A reply that comes after another repository was opened belongs to the old one.
const root = repo.value?.root;
try {
const d = await invoke<CommitDetails>("commit_details", { hash });
if (repo.value?.root !== root) return;
detailsCache.set(hash, d);
if (selectedHash.value === hash) {
details.value = d;
detailsError.value = null;
}
} catch (e) {
if (repo.value?.root !== root) return;
if (selectedHash.value === hash) detailsError.value = String(e);
}
}, 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 selectedColor = computed(() => colorOf(graph.value.rows[selected.value]?.color ?? 0));
function jumpToHash(hash: string) {
const i = rows.value.findIndex((x) => x.commit.hash === hash);
if (i >= 0) select(i);
else showToast("that commit is hidden by a filter or a fold");
}
async function copyText(text: string) {
await writeText(text);
showToast(`copied ${text}`);
}
// ---- actions -------------------------------------------------------------
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;
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() {
@@ -227,8 +447,29 @@ function jumpTo(r: Ref) {
}
function onKey(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
const actions: Record<string, () => void> = {
if (menuOpen.value || e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
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),
j: () => select(selected.value + 1),
ArrowUp: () => select(selected.value - 1),
@@ -247,6 +488,9 @@ function onKey(e: KeyboardEvent) {
y: copyHash,
h: hideSelectedLine,
r: load,
o: () => (menuOpen.value = true),
p: () => (settings.previewShown = !settings.previewShown),
Enter: openFirstFile,
"/": () => sidebar.value?.focusPattern(),
};
const action = actions[e.key];
@@ -285,7 +529,18 @@ onUnmounted(() => {
<header class="bar">
<img src="/logo.svg" class="logo" alt="" />
<span class="title">gites</span>
<span v-if="repo" class="path">{{ repo.root }}</span>
<button class="path" title="open another repository (o)" @click="menuOpen = !menuOpen">
{{ repo?.root ?? "Open a repository" }} <span class="caret">▾</span>
</button>
<RepoMenu
v-if="menuOpen"
:recents="settings.recents"
:current="repo?.root ?? null"
@pick="openRepo"
@browse="browse"
@remove="(p) => (settings.recents = settings.recents.filter((x) => x !== p))"
@close="menuOpen = false"
/>
<span class="spacer" />
<span v-if="repo" class="stats">{{ visibleCommits.length }} commits · {{ visibleRefs.length }}/{{ repo.refs.length }} refs</span>
<button
@@ -313,18 +568,40 @@ onUnmounted(() => {
:filters="filters"
:head-ref="repo.head_ref"
:time-of="timeOf"
:style="{ width: sidebarWidth + 'px' }"
:style="{ width: settings.sidebarWidth + 'px' }"
@jump="jumpTo"
/>
<div v-if="repo" class="splitter" title="drag to resize" @pointerdown.prevent="startResize" />
<div v-if="repo" class="splitter" title="drag to resize" @pointerdown.prevent="startDrag($event, 'x')" />
<div v-if="error" class="error">
<h2>Could not open the repository</h2>
<pre>{{ error }}</pre>
<p>Start gites inside a git repository, or pass its path: <code>gites /path/to/repo</code></p>
<button @click="menuOpen = true">Open a repository…</button>
</div>
<div v-else ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
<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">
<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 class="spacer-rows" :style="{ height: rows.length * ROW_H + 'px' }">
<div
@@ -372,21 +649,49 @@ onUnmounted(() => {
</div>
</div>
</div>
<template v-if="settings.previewShown && repo && rows.length">
<div class="splitter-y" title="drag to resize" @pointerdown.prevent="startDrag($event, 'y')" />
<PreviewPanel
:style="{ height: settings.previewHeight + 'px' }"
:details="details"
:error="detailsError"
:labels="selectedLabels"
:color="selectedColor"
:format-time="formatTime"
:chosen="diffFile?.path ?? null"
@jump="jumpToHash"
@copy="copyText"
@open="openDiff"
/>
</template>
</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>g</kbd><kbd>G</kbd> top / bottom</span>
<span><kbd>space</kbd> fold run</span>
<span><kbd>Enter</kbd> diff</span>
<span><kbd>c</kbd> fold all</span>
<span><kbd>y</kbd> copy hash</span>
<span><kbd>h</kbd> hide branch</span>
<span><kbd>/</kbd> filter</span>
<span><kbd>p</kbd> preview</span>
<span><kbd>o</kbd> open repo</span>
<span><kbd>r</kbd> reload</span>
</footer>
<transition name="fade">
<div v-if="toast" class="toast">{{ toast }}</div>
<div v-if="toast" class="toast" @click="closeToast">{{ toast }}</div>
</transition>
</div>
</template>
@@ -410,7 +715,16 @@ onUnmounted(() => {
background-clip: text;
color: transparent;
}
.path { color: var(--text-dim); font-family: ui-monospace, monospace; font-size: 12px; }
.path {
color: var(--text-dim);
font-family: ui-monospace, monospace;
font-size: 12px;
border-color: transparent;
padding: 2px 8px;
}
.path:hover { color: var(--text); border-color: var(--border); }
.caret { font-size: 10px; opacity: 0.7; }
.bar { position: relative; }
.spacer { flex: 1; }
.stats { color: var(--text-dim); font-size: 12px; }
.collapse { display: inline-flex; align-items: center; gap: 6px; }
@@ -427,7 +741,10 @@ onUnmounted(() => {
z-index: 5;
}
.splitter:hover, .splitter:active { background: var(--accent); opacity: 0.6; }
.list { flex: 1; overflow: auto; position: relative; }
.center { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.splitter-y { flex: none; height: 5px; margin-top: -2px; margin-bottom: -3px; cursor: row-resize; position: relative; z-index: 5; border-top: 1px solid var(--border); }
.splitter-y:hover, .splitter-y:active { background: var(--accent); opacity: 0.6; }
.list { flex: 1; min-height: 0; overflow: auto; position: relative; }
.spacer-rows { position: relative; min-width: 700px; }
.row {
position: absolute;
@@ -481,6 +798,11 @@ onUnmounted(() => {
padding: 6px 14px;
border-radius: 8px;
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-from, .fade-leave-to { opacity: 0; }
+165
View File
@@ -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>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import type { CommitDetails, ChangedFile } from "../lib/types";
import type { Label } from "./LabelStack.vue";
import RefIcon from "./RefIcon.vue";
const props = defineProps<{
details: CommitDetails | null;
error: string | null;
labels: Label[];
color: 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]; 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 body = computed(() => props.details?.message.split("\n").slice(1).join("\n").trim() ?? "");
const sameCommitter = computed(
() =>
props.details &&
props.details.committer === props.details.author &&
props.details.committer_email === props.details.author_email,
);
const totals = computed(() =>
(props.details?.files ?? []).reduce((t, f) => ({ a: t.a + (f.added ?? 0), d: t.d + (f.deleted ?? 0) }), { a: 0, d: 0 }),
);
/** Five small blocks showing the share of added (green) and deleted (red) lines. */
function bars(f: ChangedFile) {
const a = f.added ?? 0, d = f.deleted ?? 0, total = a + d;
if (total === 0) return [];
const n = Math.min(5, Math.max(1, Math.round(Math.log2(total + 1))));
const green = Math.round((a / total) * n);
return Array.from({ length: 5 }, (_, i) => (i < green ? "a" : i < n ? "d" : ""));
}
function split(path: string) {
const i = path.lastIndexOf("/");
return i < 0 ? { dir: "", name: path } : { dir: path.slice(0, i + 1), name: path.slice(i + 1) };
}
</script>
<template>
<section class="preview">
<div v-if="error" class="state error">{{ error }}</div>
<div v-else-if="!details" class="state">Loading…</div>
<template v-else>
<div class="info">
<h3>{{ subject }}</h3>
<p v-if="body" class="body">{{ body }}</p>
<dl>
<dt>Author</dt>
<dd>{{ details.author }} <span class="dim">&lt;{{ details.author_email }}&gt;</span> · {{ formatTime(details.author_time) }}</dd>
<template v-if="!sameCommitter || details.commit_time !== details.author_time">
<dt>Committer</dt>
<dd>
<template v-if="!sameCommitter">{{ details.committer }} <span class="dim">&lt;{{ details.committer_email }}&gt;</span> · </template>
{{ formatTime(details.commit_time) }}
</dd>
</template>
<dt>Hash</dt>
<dd><button class="link mono" title="copy" @click="emit('copy', details.hash)">{{ details.hash }}</button></dd>
<dt>{{ details.parents.length > 1 ? "Parents" : "Parent" }}</dt>
<dd>
<span v-if="!details.parents.length" class="dim">none, this is the first commit</span>
<button v-for="p in details.parents" :key="p" class="link mono" title="go to this commit" @click="emit('jump', p)">
{{ p.slice(0, 10) }}
</button>
</dd>
<template v-if="labels.length">
<dt>Labels</dt>
<dd class="labels">
<span v-for="l in labels" :key="l.name" class="label" :style="{ '--c': color }"><RefIcon :kind="l.kind" />{{ l.short }}</span>
</dd>
</template>
</dl>
</div>
<div ref="fileList" class="files">
<div class="files-head">
{{ details.files.length }} changed file{{ details.files.length === 1 ? "" : "s" }}
<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>
</div>
<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="path">
<template v-if="f.old_path"><span class="dim">{{ f.old_path }} → </span></template>
<span class="dim">{{ split(f.path).dir }}</span>{{ split(f.path).name }}
</span>
<span v-if="f.added === null" class="n dim">binary</span>
<span v-else class="n"><span class="a">+{{ f.added }}</span> <span class="d">−{{ f.deleted }}</span></span>
<span class="bars"><i v-for="(b, i) in bars(f)" :key="i" :class="b" /></span>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.preview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
background: var(--bg-side);
user-select: text;
}
.state { grid-column: 1 / -1; padding: 16px; color: var(--text-dim); }
.state.error { color: #ff8787; }
.info, .files { overflow: auto; padding: 12px 16px; min-width: 0; }
.files { border-left: 1px solid var(--border); }
h3 { margin: 0 0 6px; font-size: 14px; line-height: 1.35; }
.body { margin: 0 0 10px; white-space: pre-wrap; font-size: 12.5px; color: var(--text); }
dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 3px 12px; margin: 0; font-size: 12px; }
dt { color: var(--text-dim); }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
.dim { color: var(--text-dim); }
.mono { font-family: ui-monospace, monospace; font-size: 11.5px; }
.link { border: none; padding: 0; color: #9aa3c7; background: none; cursor: pointer; }
.link:hover { color: var(--accent); background: none; text-decoration: underline; }
.label {
display: inline-flex; align-items: center; gap: 4px; padding: 0 7px; border-radius: 999px; font-size: 11.5px;
color: var(--c); border: 1px solid color-mix(in srgb, var(--c) 55%, var(--bg)); background: color-mix(in srgb, var(--c) 16%, var(--bg));
}
.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; }
.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.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.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; }
.n { flex: none; font: 11px ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.a { color: #69db7c; } .d { color: #ff6b6b; }
.bars { display: inline-flex; gap: 1px; flex: none; width: 29px; }
.bars i { width: 5px; height: 8px; background: var(--border); }
.bars i.a { background: #69db7c; } .bars i.d { background: #ff6b6b; }
</style>
+10 -1
View File
@@ -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" />
</svg>
<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>
<RefTree
v-if="n.children.length && !closed.has(key(n))"
@@ -80,4 +88,5 @@ function toggleFolder(n: TreeNode) {
.arrow.open { transform: rotate(90deg); }
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item:hover :deep(.eye) { opacity: 1; }
.item :deep(.eye.locked) { opacity: 0.35; cursor: default; }
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue";
const props = defineProps<{ recents: string[]; current: string | null }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
// The current repository is not offered: picking it would do nothing.
const items = computed(() => props.recents.filter((p) => p !== props.current));
/** 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 menu = ref<HTMLElement>();
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) {
if (i === items.value.length + 1) void startTyping();
else if (i === items.value.length) emit("browse");
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) {
// Keys stay in the menu while it is open.
e.stopPropagation();
const last = items.value.length + 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 === "Enter") choose(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 return;
e.preventDefault();
}
const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
</script>
<template>
<div class="backdrop" @click="emit('close')" />
<div ref="menu" class="menu" tabindex="-1" @keydown="onKey">
<div class="head">Recent repositories</div>
<div v-if="!items.length" class="none">No other repositories yet.</div>
<div
v-for="(p, i) in items"
:key="p"
class="item"
:class="{ active: i === active }"
@mouseenter="active = i"
@click="choose(i)"
>
<span class="name">{{ name(p) }}</span>
<span class="path">{{ p }}</span>
<button class="x" title="remove from the list (Delete)" @click.stop="emit('remove', p)">×</button>
</div>
<div
class="item browse"
:class="{ active: active === items.length }"
@mouseenter="active = items.length"
@click="choose(items.length)"
>
Open folder…
</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>
</template>
<style scoped>
.backdrop { position: fixed; inset: 0; z-index: 90; }
.menu {
position: absolute;
top: 38px;
left: 70px;
z-index: 100;
width: min(560px, calc(100vw - 90px));
max-height: 70vh;
overflow-y: auto;
background: #181b2c;
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 40px #000a;
padding: 6px;
outline: none;
}
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); padding: 4px 10px 6px; }
.none { padding: 6px 10px; color: var(--text-dim); }
.item { display: flex; align-items: baseline; gap: 10px; padding: 6px 10px; border-radius: 6px; cursor: pointer; }
.item.active { background: var(--bg-select); }
.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: 0 6px; color: var(--text-dim); opacity: 0; }
.item.active .x { opacity: 1; }
.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); }
</style>
+15 -1
View File
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from "vue";
import { refKind, shortName } from "../lib/filter";
import { buildTree, type TreeNode } from "../lib/tree";
import type { FilterState, Ref, RefKind } from "../lib/types";
import EyeButton from "./EyeButton.vue";
import RefTree from "./RefTree.vue";
const props = defineProps<{
@@ -98,7 +99,15 @@ function onPatternKey(e: KeyboardEvent) {
</section>
<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
:nodes="trees[g.kind]"
:kind="g.kind"
@@ -122,6 +131,9 @@ function onPatternKey(e: KeyboardEvent) {
padding: 10px 0 20px;
}
h3 {
display: flex;
align-items: center;
gap: 4px;
margin: 12px 14px 6px;
font-size: 11px;
text-transform: uppercase;
@@ -129,6 +141,8 @@ h3 {
color: var(--text-dim);
}
.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 h3 { margin-left: 0; margin-top: 2px; }
.rules input { width: 100%; }
+88
View File
@@ -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>
+139
View File
@@ -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
View File
@@ -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;
}
+15
View File
@@ -78,3 +78,18 @@ describe("lineOwners", () => {
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);
});
});
+1
View File
@@ -21,6 +21,7 @@ export function globMatch(pattern: string, text: string): boolean {
export function isRefVisible(name: string, state: FilterState): boolean {
if (state.hidden.includes(name)) return false;
if (state.hideTags && name.startsWith("refs/tags/")) return false;
const short = shortName(name);
const only = state.rules.filter((r) => r.mode === "only");
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
+57
View File
@@ -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);
});
});
+41
View File
@@ -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;
},
};
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { addRecent, MAX_RECENTS, mergeRecents } from "./recents";
describe("addRecent", () => {
it("puts the path first", () => {
expect(addRecent(["/a", "/b"], "/c")).toEqual(["/c", "/a", "/b"]);
});
it("moves a path that is already there to the front instead of repeating it", () => {
expect(addRecent(["/a", "/b", "/c"], "/b")).toEqual(["/b", "/a", "/c"]);
});
it("keeps the list short", () => {
const many = Array.from({ length: MAX_RECENTS }, (_, i) => `/r${i}`);
const out = addRecent(many, "/new");
expect(out).toHaveLength(MAX_RECENTS);
expect(out[0]).toBe("/new");
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"]);
});
});
+14
View File
@@ -0,0 +1,14 @@
export const MAX_RECENTS = 12;
/** Puts `path` first, drops an older copy of it, and keeps at most MAX_RECENTS paths. */
export function addRecent(list: string[], path: string): string[] {
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);
}
+25
View File
@@ -33,6 +33,8 @@ export interface FilterState {
/** Full ref names hidden with the eye toggle. */
hidden: string[];
rules: FilterRule[];
/** Hides every tag, also tags created later. */
hideTags?: boolean;
}
/** A commit as shown on screen, with its parent edges after collapsing. */
@@ -58,3 +60,26 @@ export interface LaidOutRow {
color: number;
segments: Segment[];
}
export interface ChangedFile {
/** One letter: A added, M modified, D deleted, R renamed, C copied, T type changed. */
status: string;
path: string;
old_path: string | null;
/** Line counts; null for binary files. */
added: number | null;
deleted: number | null;
}
export interface CommitDetails {
hash: string;
author: string;
author_email: string;
author_time: number;
committer: string;
committer_email: string;
commit_time: number;
parents: string[];
message: string;
files: ChangedFile[];
}