Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e597dc5c | ||
|
|
302fe4a4ae | ||
|
|
2b433fb650 | ||
|
|
394d92e756 | ||
|
|
e6c70554f3 | ||
|
|
432fe58563 | ||
|
|
52d6522075 | ||
|
|
d75afe0249 | ||
|
|
13f0fbcb39 | ||
|
|
983ac2cfa9 | ||
|
|
93e9cf94a5 | ||
|
|
788a373e17 | ||
|
|
80906a9bda | ||
|
|
58268c4015 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "gites",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.4",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.3",
|
||||
"vue": "^3.5.13",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.4",
|
||||
"vue-tsc": "^3.3.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="],
|
||||
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.12.4", "", {}, "sha512-pVmfis+4zT8H0Qhp3xPZx9Ju7kpzvA6rr/PSJPyuwfvucTQ8sVt61NYU9KMkK703UFlYadFmjKA4HhybOtN0gg=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.12.3", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.3", "@tauri-apps/cli-darwin-x64": "2.12.3", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.3", "@tauri-apps/cli-linux-arm64-gnu": "2.12.3", "@tauri-apps/cli-linux-arm64-musl": "2.12.3", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.3", "@tauri-apps/cli-linux-x64-gnu": "2.12.3", "@tauri-apps/cli-linux-x64-musl": "2.12.3", "@tauri-apps/cli-win32-arm64-msvc": "2.12.3", "@tauri-apps/cli-win32-ia32-msvc": "2.12.3", "@tauri-apps/cli-win32-x64-msvc": "2.12.3" }, "bin": { "tauri": "tauri.js" } }, "sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.3", "", { "os": "linux", "cpu": "arm" }, "sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.3", "", { "os": "linux", "cpu": "none" }, "sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.3", "", { "os": "win32", "cpu": "x64" }, "sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ=="],
|
||||
|
||||
"@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "sha512-dibUy1nKURYDXRMvjNk4deAbTZ5jPQ8wCE4nolt5xniryWgXQ4sK2GkFXoXlHbRdPzfZptZj95BRLfalA7m/2Q=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.8.3", "", { "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "sha512-Hd/yBfk2R5v2MFrz/0VArSXeh8xKLLXGunqSiTtcHKn1zNZwaKujuA0VSwUvNfwC4hDoF1PveS+gBDNe3nHspw=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@5.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@5.0.4", "", {}, "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
|
||||
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
||||
|
||||
"@vue/language-core": ["@vue/language-core@3.3.13", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.44", "", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.44", "", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="],
|
||||
|
||||
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.30", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.14", "", { "dependencies": { "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.14", "@rolldown/binding-android-arm64": "1.2.14", "@rolldown/binding-darwin-arm64": "1.2.14", "@rolldown/binding-darwin-x64": "1.2.14", "@rolldown/binding-freebsd-x64": "1.2.14", "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", "@rolldown/binding-linux-arm64-gnu": "1.2.14", "@rolldown/binding-linux-arm64-musl": "1.2.14", "@rolldown/binding-linux-ppc64-gnu": "1.2.14", "@rolldown/binding-linux-s390x-gnu": "1.2.14", "@rolldown/binding-linux-x64-gnu": "1.2.14", "@rolldown/binding-linux-x64-musl": "1.2.14", "@rolldown/binding-openharmony-arm64": "1.2.14", "@rolldown/binding-win32-arm64-msvc": "1.2.14", "@rolldown/binding-win32-x64-msvc": "1.2.14" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="],
|
||||
|
||||
"tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
|
||||
|
||||
"vitest": ["vitest@5.0.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.4", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.4", "@vitest/browser-preview": "5.0.4", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.4", "@vitest/coverage-v8": "5.0.4", "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||
|
||||
"vue": ["vue@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.3.13", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
@@ -0,0 +1,106 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
|
||||
|
||||
lockfile_version = 1
|
||||
|
||||
[[tools.bun]]
|
||||
version = "1.4.3"
|
||||
backend = "core:bun"
|
||||
specifiers = ["1.4"]
|
||||
|
||||
[tools.bun."platforms.linux-arm64"]
|
||||
checksum = "sha256:efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-aarch64.zip"
|
||||
|
||||
[tools.bun."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:8de8a0003cc2707bb555887e3d450e24e1edf3f605f491393e89ed61a8853420"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-aarch64-musl.zip"
|
||||
|
||||
[tools.bun."platforms.linux-x64"]
|
||||
checksum = "sha256:3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64.zip"
|
||||
|
||||
[tools.bun."platforms.linux-x64-baseline"]
|
||||
checksum = "sha256:1fc2edac843102909e3a1be1d8d9802cc6071cf074e67e88231f4ffe0f8b397b"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64-baseline.zip"
|
||||
|
||||
[tools.bun."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:6e9ea98554ea0d05efda5840ae292618aaa3adefd66b0853520c5c86a37eac9c"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64-musl.zip"
|
||||
|
||||
[tools.bun."platforms.linux-x64-musl-baseline"]
|
||||
checksum = "sha256:4b5b0b5503f6ebf15161e8c56eb5a263134c05cd98a86dc11ee43ac5bda9862e"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64-musl-baseline.zip"
|
||||
|
||||
[tools.bun."platforms.macos-arm64"]
|
||||
checksum = "sha256:80afd4c069b46a6fa8f3ec520bebc750a7843e4a8d5b9583d29ad436c00a3403"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-darwin-aarch64.zip"
|
||||
|
||||
[tools.bun."platforms.macos-x64"]
|
||||
checksum = "sha256:62660e6438eb872278877acca77e1152d5d031324df368d4a81b428211489d2d"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-darwin-x64.zip"
|
||||
|
||||
[tools.bun."platforms.macos-x64-baseline"]
|
||||
checksum = "sha256:0fde04e8b092156b97fe6432e899baf16d9ed6ce52cbb4218d2d9c07a2f827ef"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-darwin-x64-baseline.zip"
|
||||
|
||||
[tools.bun."platforms.windows-x64"]
|
||||
checksum = "sha256:0bf84e808552beea67bf1202a287a35c6c825d837b287b3f8e9dd513dba35d3d"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-windows-x64.zip"
|
||||
|
||||
[tools.bun."platforms.windows-x64-baseline"]
|
||||
checksum = "sha256:d7a7d3eb216614544aab0cde2d8d9f2e4e87839bf5a30a1cd11d077f02d84e0c"
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-windows-x64-baseline.zip"
|
||||
|
||||
[[tools.node]]
|
||||
version = "24.21.0"
|
||||
backend = "core:node"
|
||||
specifiers = ["24"]
|
||||
|
||||
[tools.node."platforms.linux-arm64"]
|
||||
checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4"
|
||||
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-x64"]
|
||||
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-x64-baseline"]
|
||||
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
|
||||
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-x64-musl-baseline"]
|
||||
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
|
||||
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
|
||||
|
||||
[tools.node."platforms.macos-arm64"]
|
||||
checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz"
|
||||
|
||||
[tools.node."platforms.macos-x64"]
|
||||
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.macos-x64-baseline"]
|
||||
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.windows-x64"]
|
||||
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip"
|
||||
|
||||
[tools.node."platforms.windows-x64-baseline"]
|
||||
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip"
|
||||
|
||||
[[tools.rust]]
|
||||
version = "1.99.0"
|
||||
backend = "core:rust"
|
||||
specifiers = ["1.99"]
|
||||
@@ -1,22 +1,23 @@
|
||||
[tools]
|
||||
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
@@ -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",
|
||||
|
||||
Generated
-1027
File diff suppressed because it is too large
Load Diff
+11
-17
@@ -16,24 +16,18 @@ fi
|
||||
$SUDO apt-get update -q
|
||||
$SUDO apt-get 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
|
||||
|
||||
Generated
+70
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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![]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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"><{{ details.author_email }}></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"><{{ details.committer_email }}></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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inlineRows, splitRows, unifiedRows, wordDiff, type DiffLine, type Hunk, type Row, type SplitRow } from "./diff";
|
||||
|
||||
/** Builds a hunk from lines written as git prints them: " same", "-old", "+new". */
|
||||
function hunk(oldStart: number, newStart: number, ...lines: string[]): Hunk {
|
||||
return {
|
||||
header: `@@ -${oldStart} +${newStart} @@`,
|
||||
old_start: oldStart,
|
||||
new_start: newStart,
|
||||
lines: lines.map((l) => ({ kind: l[0] as DiffLine["kind"], text: l.slice(1) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes a row as text: removed parts in [-…-], added parts in {+…+}. */
|
||||
const show = (pieces: { op: string; text: string }[]) =>
|
||||
pieces.map((p) => (p.op === "-" ? `[-${p.text}-]` : p.op === "+" ? `{+${p.text}+}` : p.text)).join("");
|
||||
const rowText = (r: Row) => (r.kind === "hunk" ? r.text : `${r.kind} ${r.old ?? "."} ${r.new ?? "."} ${show(r.pieces)}`);
|
||||
const splitText = (r: SplitRow) =>
|
||||
r.kind === "hunk"
|
||||
? r.text
|
||||
: `${r.left ? `${r.left.kind}${r.left.no} ${show(r.left.pieces)}` : "~"} | ${r.right ? `${r.right.kind}${r.right.no} ${show(r.right.pieces)}` : "~"}`;
|
||||
|
||||
describe("wordDiff", () => {
|
||||
it("marks the words that changed", () => {
|
||||
expect(show(wordDiff("Pick a commit to see its message and the files", "Pick a commit to see its message, the files")!)).toBe(
|
||||
"Pick a commit to see its message[- and-]{+,+} the files",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps added text at the end of a line", () => {
|
||||
expect(show(wordDiff('<section class="preview">', '<section class="preview" @keydown="onKey">')!)).toBe(
|
||||
'<section class="preview"{+ @keydown="onKey"+}>',
|
||||
);
|
||||
});
|
||||
|
||||
it("gives up on lines with little in common", () => {
|
||||
expect(wordDiff("}", "function choose(f: ChangedFile) {")).toBeNull();
|
||||
});
|
||||
|
||||
it("joins changed words separated only by spaces into one change", () => {
|
||||
expect(show(wordDiff("// settings: the sidebar width", "// settings, saved in settings.json")!)).toBe(
|
||||
"// settings[-: the sidebar width-]{+, saved in settings.json+}",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats two equal lines as unchanged", () => {
|
||||
expect(wordDiff("same", "same")).toEqual([{ op: "=", text: "same" }]);
|
||||
});
|
||||
});
|
||||
|
||||
const change = hunk(40, 40, " keep", "-const open = ref(false);", "+const chosen = ref(null);", "+extra line", " end");
|
||||
|
||||
describe("unifiedRows", () => {
|
||||
it("numbers lines on both sides and marks changed words", () => {
|
||||
expect(unifiedRows([change]).map(rowText)).toEqual([
|
||||
"@@ -40 +40 @@",
|
||||
" 40 40 keep",
|
||||
"- 41 . const [-open-] = ref([-false-]);",
|
||||
"+ . 41 const {+chosen+} = ref({+null+});",
|
||||
"+ . 42 extra line",
|
||||
" 42 43 end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mark words in lines that were replaced by something unrelated", () => {
|
||||
const rows = unifiedRows([hunk(1, 1, "-}", "+function f() {")]);
|
||||
expect(rows.map(rowText).slice(1)).toEqual(["- 1 . }", "+ . 1 function f() {"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairing removed and added lines", () => {
|
||||
const block = hunk(
|
||||
1,
|
||||
1,
|
||||
"-import type { FilterState, Ref } from types;",
|
||||
"+import { addRecent } from recents;",
|
||||
"+import type { CommitDetails, FilterState, Ref } from types;",
|
||||
);
|
||||
|
||||
it("pairs a removed line with the added line most like it", () => {
|
||||
expect(unifiedRows([block]).map(rowText).slice(1)).toEqual([
|
||||
"- 1 . import type { FilterState, Ref } from types;",
|
||||
"+ . 1 import { addRecent } from recents;",
|
||||
"+ . 2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the pair on one row side by side", () => {
|
||||
expect(splitRows([block]).map(splitText).slice(1)).toEqual([
|
||||
"~ | +1 import { addRecent } from recents;",
|
||||
"-1 import type { FilterState, Ref } from types; | +2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the unpaired line before the pair inline", () => {
|
||||
expect(inlineRows([block]).map(rowText).slice(1)).toEqual([
|
||||
"+ . 1 import { addRecent } from recents;",
|
||||
"~ 1 2 import type { {+CommitDetails, +}FilterState, Ref } from types;",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRows", () => {
|
||||
it("puts removed and added lines side by side, with a gap where one side has no line", () => {
|
||||
expect(splitRows([change]).map(splitText)).toEqual([
|
||||
"@@ -40 +40 @@",
|
||||
" 40 keep | 40 keep",
|
||||
"-41 const [-open-] = ref([-false-]); | +41 const {+chosen+} = ref({+null+});",
|
||||
"~ | +42 extra line",
|
||||
" 42 end | 43 end",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineRows", () => {
|
||||
it("merges a changed line into one row and keeps added lines whole", () => {
|
||||
expect(inlineRows([change]).map(rowText)).toEqual([
|
||||
"@@ -40 +40 @@",
|
||||
" 40 40 keep",
|
||||
"~ 41 41 const [-open-]{+chosen+} = ref([-false-]{+null+});",
|
||||
"+ . 42 extra line",
|
||||
" 42 43 end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows unrelated lines as all removed, then all added", () => {
|
||||
const rows = inlineRows([hunk(1, 1, "-}", "-return x;", "+function f() {", "+ call();")]);
|
||||
expect(rows.map(rowText).slice(1)).toEqual(["- 1 . }", "- 2 . return x;", "+ . 1 function f() {", "+ . 2 call();"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("large blocks", () => {
|
||||
it("pairs lines by position when there are too many to compare every pair", () => {
|
||||
const lines = Array.from({ length: 60 }, (_, i) => `-value ${i} old`).concat(Array.from({ length: 60 }, (_, i) => `+value ${i} new`));
|
||||
const rows = inlineRows([hunk(1, 1, ...lines)]);
|
||||
expect(rows.length).toBe(61);
|
||||
expect(rowText(rows[60])).toBe("~ 60 60 value 59 [-old-]{+new+}");
|
||||
});
|
||||
});
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
// Turns the lines of one file's diff into rows for the three diff layouts.
|
||||
|
||||
export interface DiffLine {
|
||||
kind: " " | "+" | "-";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Hunk {
|
||||
header: string;
|
||||
old_start: number;
|
||||
new_start: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
binary: boolean;
|
||||
old_size: number | null;
|
||||
new_size: number | null;
|
||||
hunks: Hunk[];
|
||||
/** The number of changed lines, when the diff is too large and was not sent. */
|
||||
too_large: number | null;
|
||||
}
|
||||
|
||||
export type Layout = "unified" | "split" | "inline";
|
||||
|
||||
/** A part of a line: unchanged, added or removed. */
|
||||
export interface Piece {
|
||||
op: "=" | "+" | "-";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A row of the one-column and inline layouts. `~` is a changed line in the inline layout. */
|
||||
export type Row =
|
||||
| { kind: "hunk"; text: string }
|
||||
| { kind: " " | "+" | "-" | "~"; old: number | null; new: number | null; pieces: Piece[] };
|
||||
|
||||
export interface Cell {
|
||||
kind: " " | "+" | "-";
|
||||
no: number;
|
||||
pieces: Piece[];
|
||||
}
|
||||
|
||||
/** A row of the side-by-side layout; a side is null where the other side has a line with no partner. */
|
||||
export type SplitRow = { kind: "hunk"; text: string } | { kind: "pair"; left: Cell | null; right: Cell | null };
|
||||
|
||||
/** Words, runs of spaces, and single other characters. */
|
||||
const TOKEN = /\w+|\s+|[^\w\s]/gu;
|
||||
|
||||
/** Lines with less in common than this (share of their non-space characters) are not compared word by word. */
|
||||
const MIN_SIMILARITY = 0.3;
|
||||
/** Comparing words costs (words in one line) × (words in the other); longer pairs are not compared. */
|
||||
const MAX_WORK = 250_000;
|
||||
/** Pairing lines costs (removed lines) × (added lines) comparisons; larger blocks pair lines in order. */
|
||||
const MAX_PAIRING = 2_500;
|
||||
|
||||
const visible = (s: string) => s.replace(/\s/g, "").length;
|
||||
|
||||
/** Compares two lines word by word; `similarity` is the share of their non-space characters they have in common. */
|
||||
function compare(a: string, b: string): { pieces: Piece[]; similarity: number } | null {
|
||||
if (a === b) return { pieces: [{ op: "=", text: a }], similarity: 1 };
|
||||
const x = a.match(TOKEN) ?? [];
|
||||
const y = b.match(TOKEN) ?? [];
|
||||
if (x.length * y.length > MAX_WORK) return null;
|
||||
|
||||
// common[i][j]: how many tokens x[i..] and y[j..] have in common, in order.
|
||||
const common = Array.from({ length: x.length + 1 }, () => new Uint32Array(y.length + 1));
|
||||
for (let i = x.length - 1; i >= 0; i--) {
|
||||
for (let j = y.length - 1; j >= 0; j--) {
|
||||
common[i][j] = x[i] === y[j] ? common[i + 1][j + 1] + 1 : Math.max(common[i + 1][j], common[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Runs of unchanged tokens, each followed by the removed and added text after it.
|
||||
const runs: { same: string; del: string; add: string }[] = [{ same: "", del: "", add: "" }];
|
||||
let i = 0, j = 0, same = 0;
|
||||
while (i < x.length || j < y.length) {
|
||||
const last = runs[runs.length - 1];
|
||||
if (i < x.length && j < y.length && x[i] === y[j] && common[i][j] === common[i + 1][j + 1] + 1) {
|
||||
same += visible(x[i]);
|
||||
if (last.del || last.add) runs.push({ same: x[i], del: "", add: "" });
|
||||
else last.same += x[i];
|
||||
i++;
|
||||
j++;
|
||||
} else if (i < x.length && (j === y.length || common[i + 1][j] >= common[i][j + 1])) {
|
||||
// On a tie, removed text goes before added text.
|
||||
last.del += x[i++];
|
||||
} else {
|
||||
last.add += y[j++];
|
||||
}
|
||||
}
|
||||
const size = visible(a) + visible(b);
|
||||
if (size > 0 && (2 * same) / size < MIN_SIMILARITY) return null;
|
||||
|
||||
// Spaces alone between two changes join them, so "[-the-] [-sidebar-]" reads as one change.
|
||||
const pieces: Piece[] = [];
|
||||
let del = "", add = "";
|
||||
runs.forEach((r, k) => {
|
||||
const between = k > 0 && !r.same.trim() && (r.del || r.add) && (runs[k - 1].del || runs[k - 1].add);
|
||||
if (between) {
|
||||
del += r.same;
|
||||
add += r.same;
|
||||
} else {
|
||||
if (del) pieces.push({ op: "-", text: del });
|
||||
if (add) pieces.push({ op: "+", text: add });
|
||||
if (r.same) pieces.push({ op: "=", text: r.same });
|
||||
del = add = "";
|
||||
}
|
||||
del += r.del;
|
||||
add += r.add;
|
||||
});
|
||||
if (del) pieces.push({ op: "-", text: del });
|
||||
if (add) pieces.push({ op: "+", text: add });
|
||||
return { pieces, similarity: size ? (2 * same) / size : 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two lines word by word. Returns the parts of both lines in order, each marked as
|
||||
* unchanged, removed or added; or null when the lines have too little in common to pair.
|
||||
*/
|
||||
export function wordDiff(a: string, b: string): Piece[] | null {
|
||||
return compare(a, b)?.pieces ?? null;
|
||||
}
|
||||
|
||||
const oldSide = (pieces: Piece[]) => pieces.filter((p) => p.op !== "+");
|
||||
const newSide = (pieces: Piece[]) => pieces.filter((p) => p.op !== "-");
|
||||
const plain = (text: string): Piece[] => [{ op: "=", text }];
|
||||
|
||||
type Numbered = { no: number; text: string };
|
||||
/** A removed line paired with an added line, or a line with no partner. */
|
||||
type Step = { del?: Numbered; add?: Numbered; pieces?: Piece[] };
|
||||
/** A hunk split into unchanged lines and blocks of removed lines followed by added lines. */
|
||||
type Part =
|
||||
| { kind: " "; old: number; new: number; text: string }
|
||||
| { kind: "block"; dels: Numbered[]; adds: Numbered[]; steps: Step[] };
|
||||
|
||||
/**
|
||||
* Pairs removed lines with added lines, keeping both in order and choosing the pairs most
|
||||
* alike. Lines without a partner come before the next pair: removed ones first.
|
||||
*/
|
||||
function pairLines(dels: Numbered[], adds: Numbered[]): Step[] {
|
||||
const D = dels.length, A = adds.length;
|
||||
const steps: Step[] = [];
|
||||
let lone: Step[] = [], loneAdds: Step[] = [];
|
||||
const flush = () => {
|
||||
steps.push(...lone, ...loneAdds);
|
||||
lone = [];
|
||||
loneAdds = [];
|
||||
};
|
||||
|
||||
if (D * A > MAX_PAIRING) {
|
||||
// Too many lines to try every pair: only lines at the same place in the block can pair.
|
||||
for (let i = 0; i < Math.max(D, A); i++) {
|
||||
const pieces = i < D && i < A ? wordDiff(dels[i].text, adds[i].text) : null;
|
||||
if (pieces) {
|
||||
flush();
|
||||
steps.push({ del: dels[i], add: adds[i], pieces });
|
||||
continue;
|
||||
}
|
||||
if (i < D) lone.push({ del: dels[i] });
|
||||
if (i < A) loneAdds.push({ add: adds[i] });
|
||||
}
|
||||
flush();
|
||||
return steps;
|
||||
}
|
||||
|
||||
const match: (Piece[] | null)[][] = [];
|
||||
const sims: number[][] = [];
|
||||
for (let i = 0; i < D; i++) {
|
||||
match.push([]);
|
||||
sims.push([]);
|
||||
for (let j = 0; j < A; j++) {
|
||||
const c = compare(dels[i].text, adds[j].text);
|
||||
match[i].push(c?.pieces ?? null);
|
||||
sims[i].push(c?.similarity ?? 0);
|
||||
}
|
||||
}
|
||||
// score[i][j]: the best total likeness of pairs among dels[i..] and adds[j..].
|
||||
const score = Array.from({ length: D + 1 }, () => new Float64Array(A + 1));
|
||||
for (let i = D - 1; i >= 0; i--) {
|
||||
for (let j = A - 1; j >= 0; j--) {
|
||||
const paired = match[i][j] ? score[i + 1][j + 1] + sims[i][j] : 0;
|
||||
score[i][j] = Math.max(paired, score[i + 1][j], score[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0, j = 0;
|
||||
while (i < D || j < A) {
|
||||
const pieces = i < D && j < A ? match[i][j] : null;
|
||||
if (pieces && score[i][j] === score[i + 1][j + 1] + sims[i][j]) {
|
||||
flush();
|
||||
steps.push({ del: dels[i++], add: adds[j++], pieces });
|
||||
} else if (i < D && (j === A || score[i + 1][j] >= score[i][j + 1])) {
|
||||
lone.push({ del: dels[i++] });
|
||||
} else {
|
||||
loneAdds.push({ add: adds[j++] });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return steps;
|
||||
}
|
||||
|
||||
function parts(h: Hunk): Part[] {
|
||||
const out: Part[] = [];
|
||||
let o = h.old_start, n = h.new_start;
|
||||
let block: Extract<Part, { kind: "block" }> | null = null;
|
||||
for (const l of h.lines) {
|
||||
if (l.kind === " ") {
|
||||
block = null;
|
||||
out.push({ kind: " ", old: o++, new: n++, text: l.text });
|
||||
continue;
|
||||
}
|
||||
// A removed line after added ones starts a new block, so pairs keep git's order.
|
||||
if (!block || (l.kind === "-" && block.adds.length)) {
|
||||
block = { kind: "block", dels: [], adds: [], steps: [] };
|
||||
out.push(block);
|
||||
}
|
||||
if (l.kind === "-") block.dels.push({ no: o++, text: l.text });
|
||||
else block.adds.push({ no: n++, text: l.text });
|
||||
}
|
||||
for (const p of out) if (p.kind === "block") p.steps = pairLines(p.dels, p.adds);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unifiedRows(hunks: Hunk[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
for (const h of hunks) {
|
||||
rows.push({ kind: "hunk", text: h.header });
|
||||
for (const p of parts(h)) {
|
||||
if (p.kind === " ") {
|
||||
rows.push({ kind: " ", old: p.old, new: p.new, pieces: plain(p.text) });
|
||||
continue;
|
||||
}
|
||||
// As git prints them: all removed lines, then all added lines.
|
||||
for (const s of p.steps) {
|
||||
if (s.del) rows.push({ kind: "-", old: s.del.no, new: null, pieces: s.pieces ? oldSide(s.pieces) : plain(s.del.text) });
|
||||
}
|
||||
for (const s of p.steps) {
|
||||
if (s.add) rows.push({ kind: "+", old: null, new: s.add.no, pieces: s.pieces ? newSide(s.pieces) : plain(s.add.text) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function splitRows(hunks: Hunk[]): SplitRow[] {
|
||||
const rows: SplitRow[] = [];
|
||||
for (const h of hunks) {
|
||||
rows.push({ kind: "hunk", text: h.header });
|
||||
for (const p of parts(h)) {
|
||||
if (p.kind === " ") {
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: { kind: " ", no: p.old, pieces: plain(p.text) },
|
||||
right: { kind: " ", no: p.new, pieces: plain(p.text) },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Lines without a partner share rows with each other, up to the next pair.
|
||||
let dels: Numbered[] = [], adds: Numbered[] = [];
|
||||
const flush = () => {
|
||||
for (let i = 0; i < Math.max(dels.length, adds.length); i++) {
|
||||
const d = dels[i], a = adds[i];
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: d ? { kind: "-", no: d.no, pieces: plain(d.text) } : null,
|
||||
right: a ? { kind: "+", no: a.no, pieces: plain(a.text) } : null,
|
||||
});
|
||||
}
|
||||
dels = [];
|
||||
adds = [];
|
||||
};
|
||||
for (const s of p.steps) {
|
||||
if (s.pieces && s.del && s.add) {
|
||||
flush();
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: { kind: "-", no: s.del.no, pieces: oldSide(s.pieces) },
|
||||
right: { kind: "+", no: s.add.no, pieces: newSide(s.pieces) },
|
||||
});
|
||||
} else if (s.del) dels.push(s.del);
|
||||
else if (s.add) adds.push(s.add);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function inlineRows(hunks: Hunk[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
for (const h of hunks) {
|
||||
rows.push({ kind: "hunk", text: h.header });
|
||||
for (const p of parts(h)) {
|
||||
if (p.kind === " ") {
|
||||
rows.push({ kind: " ", old: p.old, new: p.new, pieces: plain(p.text) });
|
||||
continue;
|
||||
}
|
||||
for (const s of p.steps) {
|
||||
if (s.pieces && s.del && s.add) rows.push({ kind: "~", old: s.del.no, new: s.add.no, pieces: s.pieces });
|
||||
else if (s.del) rows.push({ kind: "-", old: s.del.no, new: null, pieces: plain(s.del.text) });
|
||||
else if (s.add) rows.push({ kind: "+", old: null, new: s.add.no, pieces: plain(s.add.text) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -78,3 +78,18 @@ describe("lineOwners", () => {
|
||||
expect(owners.get("f")).toBe("refs/heads/feature");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hiding all tags", () => {
|
||||
const state = { hidden: [], rules: [], hideTags: true };
|
||||
it("hides every tag", () => {
|
||||
expect(isRefVisible("refs/tags/v1", state)).toBe(false);
|
||||
expect(isRefVisible("refs/tags/release/2", state)).toBe(false);
|
||||
});
|
||||
it("leaves branches alone", () => {
|
||||
expect(isRefVisible("refs/heads/main", state)).toBe(true);
|
||||
expect(isRefVisible("refs/remotes/origin/main", state)).toBe(true);
|
||||
});
|
||||
it("shows tags again when turned off", () => {
|
||||
expect(isRefVisible("refs/tags/v1", { ...state, hideTags: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ export function globMatch(pattern: string, text: string): boolean {
|
||||
|
||||
export function isRefVisible(name: string, state: FilterState): boolean {
|
||||
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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user