14 Commits
Author SHA1 Message Date
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
33 changed files with 2518 additions and 1123 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
+127
View File
@@ -0,0 +1,127 @@
<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>).
- **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
```
## 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

+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"]
}
+156
View File
@@ -0,0 +1,156 @@
//! Everything the preview panel shows about one commit.
use crate::git::git;
use serde::Serialize;
use std::path::Path;
#[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)]
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(dir: &Path, 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(
dir,
&["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(dir, &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![]);
}
}
+334
View File
@@ -0,0 +1,334 @@
//! The changes to one file in one commit, for the diff view.
use crate::git::git;
use serde::Serialize;
use std::path::Path;
/// 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(dir: &Path, 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(dir, &["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(dir, &args)?;
let mut diff = parse_patch(&patch);
if diff.binary {
let size = |spec: String| git(dir, &["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(Path::new("."), "--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 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.0, &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.0, &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.0, &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.0, &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.0, &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.0, &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.0, &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.0, &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.0, &first, "big.txt", None, true).unwrap();
assert_eq!(d.too_large, None);
assert_eq!(texts(&d).len(), (MAX_CHANGED_LINES + 1) as usize);
}
}
}
+55 -8
View File
@@ -1,5 +1,5 @@
use serde::Serialize;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Serialize)]
@@ -31,13 +31,18 @@ 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}"))?;
pub fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
// gites has no console, so Windows would open a console window for every git 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 out = cmd.output().map_err(|e| format!("could not run git: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
@@ -125,3 +130,45 @@ 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<PathBuf>, cwd: PathBuf) -> Option<PathBuf> {
arg.or_else(|| git(&cwd, &["rev-parse", "--git-dir"]).ok().map(|_| cwd))
}
#[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(given.clone()), temp_dir("arg")), Some(given));
}
#[test]
fn opens_the_current_folder_when_it_is_inside_a_repository() {
let dir = temp_dir("repo");
git(&dir, &["init", "-q"]).unwrap();
std::fs::create_dir_all(dir.join("sub")).unwrap();
assert_eq!(start_dir(None, dir.join("sub")), Some(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);
}
}
+50 -9
View File
@@ -1,15 +1,53 @@
mod details;
mod diff;
mod filters;
mod git;
mod settings;
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 folder, 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<PathBuf>>);
impl RepoDir {
fn get(&self) -> Result<PathBuf, String> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or_else(|| "no repository is open".into())
}
}
/// The open repository, or `None` when there is none yet.
#[tauri::command]
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]
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
let data = git::load(&PathBuf::from(&path))?;
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(PathBuf::from(&path));
Ok(data)
}
#[tauri::command]
fn load_repo(arg: tauri::State<RepoArg>) -> Result<git::RepoData, String> {
git::load(&arg.0)
fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
details::load(&dir.get()?, &hash)
}
#[tauri::command]
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 +72,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).map(PathBuf::from);
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,
+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": {
+318 -41
View File
@@ -1,17 +1,25 @@
<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 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 +33,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}`));
}, 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 +103,67 @@ function startResize(e: PointerEvent) {
let filtersLoadedFor: string | null = null;
let loading = false;
/** 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;
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);
} catch (e) {
error.value = String(e);
} finally {
loading = false;
started.value = true;
}
}
async function openRepo(path: string) {
menuOpen.value = false;
try {
await applyRepo(await invoke<RepoData>("open_repo", { path }));
} catch (e) {
// The repository that was open stays open.
showToast(`could not open ${path}: ${e}`);
}
}
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);
async function applyRepo(data: RepoData) {
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 ?? [];
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);
await getCurrentWindow().setTitle(`gites — ${data.root}`);
}
repo.value = data;
}
let saveTimer: number | undefined;
watch(
[filters, collapseAll],
@@ -101,7 +174,7 @@ watch(
saveTimer = window.setTimeout(() => {
invoke("save_filters", {
repo: repoRoot,
value: { hidden: filters.hidden, rules: filters.rules, collapseAll: collapseAll.value },
value: { hidden: filters.hidden, rules: filters.rules, hideTags: filters.hideTags, collapseAll: collapseAll.value },
}).catch((e) => showToast(`could not save filters: ${e}`));
}, 300);
},
@@ -181,6 +254,113 @@ 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 () => {
try {
const d = await invoke<CommitDetails>("commit_details", { hash });
detailsCache.set(hash, d);
if (selectedHash.value === hash) {
details.value = d;
detailsError.value = null;
}
} catch (e) {
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;
try {
const d = await invoke<FileDiff>("file_diff", { hash, path: file.path, oldPath: file.old_path, force });
diffCache.set(key, d);
if (diffFile.value === file && details.value?.hash === hash) diffData.value = d;
} catch (e) {
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;
@@ -227,8 +407,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 +448,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 +489,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 +528,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,16 +609,44 @@ 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>
@@ -410,7 +675,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 +701,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;
+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>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, 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 means the "Open folder…" entry. */
const active = ref(0);
const menu = ref<HTMLElement>();
onMounted(() => menu.value?.focus());
function choose(i: number) {
if (i >= items.value.length) emit("browse");
else emit("pick", items.value[i]);
}
function onKey(e: KeyboardEvent) {
// Keys stay in the menu while it is open.
e.stopPropagation();
const last = items.value.length;
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 < last) 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="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; }
.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%; }
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineProps<{ recents: string[] }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
// 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>
<button class="browse" @click="emit('browse')">Choose a folder…</button>
<p class="hint">You can also start gites with a path: <code>gites ~/code/my-project</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; }
.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;
+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[];
}