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

Tests cover commit details through ssh, the message for a failed
connection, and a ~/ path end to end. The stand-in ssh is now set per
test, so a test can give it its own home folder.
2026-10-15 15:26:16 +02:00
szaman 5ef5c1ba94 README: repositories on another machine 2026-10-15 15:16:46 +02:00
szaman ccf945a067 Open on another machine from the window; keep the graph when a reload fails 2026-10-15 15:15:41 +02:00
szaman 16b0363d07 Run the stand-in ssh for tests without writing a script file 2026-10-15 15:13:02 +02:00
szaman 48e5e8c8ce Keep command-line paths that are not valid UTF-8 intact 2026-10-15 15:11:26 +02:00
szaman 7fe5a70c69 Run git through ssh for repositories on another machine 2026-10-15 15:09:17 +02:00
szaman 121fe1bc2f Send remote commands in a form every login shell reads the same way 2026-10-15 15:07:31 +02:00
szaman 157a4063c7 Read host:path locations and quote words for a server's shell 2026-10-15 15:05:23 +02:00
szaman ddb05980e0 Plan: open repositories on another machine over ssh 2026-10-15 14:44:17 +02:00
szaman 761e528e15 Design: open repositories on another machine over ssh 2026-10-15 14:35:27 +02:00
szaman e4e597dc5c Eye on the Tags header hides all tags, also tags added later
ci / check (push) Successful in 3m3s
release / release (push) Successful in 11m34s
2026-10-15 13:55:08 +02:00
szaman 302fe4a4ae Keep the saved order of recent repositories; it was reversed on every start 2026-10-15 13:55:08 +02:00
szaman 2b433fb650 Welcome screen with recent repositories and a folder picker when gites starts outside a repository 2026-10-15 13:55:08 +02:00
szaman 394d92e756 Start git without a console window on Windows, and run one reload at a time
ci / check (push) Successful in 2m27s
release / release (push) Successful in 11m52s
2026-10-15 12:14:01 +02:00
szaman e6c70554f3 README with screenshots, install steps, keys and how to build
ci / check (push) Successful in 3m7s
2026-10-15 11:23:12 +02:00
szaman 432fe58563 Share one tool cache between CI and release builds, fall back to the last tools after a mise.toml change, and cache cargo-xwin for the Windows build
ci / check (push) Successful in 2m56s
2026-10-15 06:42:16 +02:00
25 changed files with 1518 additions and 81 deletions
+6 -1
View File
@@ -14,6 +14,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The same tool cache as in release.yaml: the same key and the same folders, or
# neither job can use what the other saved.
- name: Restore tool cache - name: Restore tool cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -24,7 +26,10 @@ jobs:
~/.rustup ~/.rustup
~/.local/bin/mise ~/.local/bin/mise
~/.local/share/mise ~/.local/share/mise
key: tools-check-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }} key: tools-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
# After a change to mise.toml, start from the last tools; mise installs only what changed.
restore-keys: |
tools-
- name: Restore build cache - name: Restore build cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
+17 -2
View File
@@ -24,10 +24,11 @@ jobs:
CARGO_BUILD_JOBS: "2" CARGO_BUILD_JOBS: "2"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The same tool cache as in ci.yaml: the same key and the same folders, or
# neither job can use what the other saved.
- name: Restore tool cache - name: Restore tool cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
# ~/.cache/cargo-xwin holds the Windows SDK files cargo-xwin downloads.
path: | path: |
~/.cargo/bin ~/.cargo/bin
~/.cargo/registry ~/.cargo/registry
@@ -35,8 +36,22 @@ jobs:
~/.rustup ~/.rustup
~/.local/bin/mise ~/.local/bin/mise
~/.local/share/mise ~/.local/share/mise
key: tools-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
# After a change to mise.toml, start from the last tools; mise installs only what changed.
restore-keys: |
tools-
# cargo-xwin, which takes minutes to compile, and the Windows SDK files it downloads.
# The Windows Rust target is left out: rustup lists its installed parts in a file in
# the tool cache, and refuses to install the target over files that list does not name.
- name: Restore cargo-xwin cache
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/cargo-xwin
~/.cache/cargo-xwin ~/.cache/cargo-xwin
key: tools-release-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }} key: cargo-xwin-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
restore-keys: |
cargo-xwin-
- name: Restore build cache - name: Restore build cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
+136
View File
@@ -0,0 +1,136 @@
<p align="center">
<img src="logo.svg" width="96" alt="">
</p>
<h1 align="center">gites</h1>
<p align="center">
A colorful viewer for the history of a git repository.<br>
Linux and Windows desktop app, driven from the keyboard.
</p>
<p align="center">
<a href="https://git.wrobel.it/szaman/gites/releases">Download</a> ·
<a href="#keys">Keys</a> ·
<a href="#build-from-source">Build from source</a>
</p>
![The commit graph of a small repository, with branches, merges, tags and the preview panel for the selected commit](docs/screenshots/graph.png)
## What it does
- **Draws the commit graph.** Every branch gets its own color. Labels on each commit show
local branches, remote branches and tags, and the checked-out commit is ringed.
- **Hides what you don't need.** Hide one branch with its eye button in the sidebar, or
with <kbd>h</kbd> on a commit. Type a pattern such as `prototypes/*` and choose
**hide** or **show only**. The eye next to **Tags** hides all tags, also tags added
later. gites remembers the filters of each repository.
- **Folds long straight stretches.** Commits in a row that have no branch, tag, fork or
merge fold into one dotted line. <kbd>c</kbd> folds all of them, <kbd>space</kbd>
opens or closes the one at the selected commit.
- **Shows the selected commit** in a panel at the bottom: the full message, the author
and committer, the hash (click to copy), the parents (click to go there), and the
files it changed with the number of added and removed lines.
- **Shows what changed in a file.** Click a file in the panel, or press <kbd>Enter</kbd>
for the first one. Choose one of three layouts with <kbd>v</kbd>:
- **One column**, as `git diff` prints it.
- **Side by side**, the old file on the left and the new one on the right.
- **Inline**, one line per changed line, with removed words struck out and added words
marked. Best for prose, such as Markdown.
In every layout, the words that changed inside a line are marked.
- **Opens other repositories** from a list of recent ones, or from a folder picker
(<kbd>o</kbd>).
- **Opens repositories on another machine.** Write the place as `[user@]host:path`, for
example `gites myserver:/srv/app`, or choose **Open on another machine…** in the
repository menu (<kbd>o</kbd>) or on the welcome screen. `host` can be any host name
from your `~/.ssh/config`; that file is also where a port other than 22 goes. gites
runs git on that machine through ssh, so git must be installed there. It logs in with
your ssh keys or the ssh agent and never asks for a password, so a login that needs a
password does not work. You see the history of the repository on that machine: its own
branches and its checked-out branch, as for a repository on this computer.
- **Stays current.** gites reloads the repository when its window gets focus again.
![The diff of one file, with old and new side by side and the changed words marked](docs/screenshots/diff.png)
## Install
Download a package from the [releases page](https://git.wrobel.it/szaman/gites/releases):
| System | File |
|---|---|
| Debian, Ubuntu | `gites_<version>_amd64.deb` |
| Fedora, openSUSE | `gites-<version>-1.x86_64.rpm` |
| Any Linux | `gites_<version>_amd64.AppImage` |
| Windows, installer | `gites_<version>_x64-setup.exe` |
| Windows, no install | `gites_<version>_x64_portable.exe` |
gites runs the `git` command, so git must be installed and on your `PATH`.
## Use
```sh
gites # the repository in the current folder
gites ~/code/my-project # another repository
gites myserver:/srv/app # a repository on another machine, over ssh
```
## Keys
**In the commit graph**
| Key | Action |
|---|---|
| <kbd>↑</kbd> <kbd>↓</kbd> or <kbd>k</kbd> <kbd>j</kbd> | Select the previous or next commit |
| <kbd>PgUp</kbd> <kbd>PgDn</kbd> | Move one screen up or down |
| <kbd>g</kbd> <kbd>G</kbd> or <kbd>Home</kbd> <kbd>End</kbd> | Go to the first or last commit |
| <kbd>Enter</kbd> | Show what changed in the first file of the commit |
| <kbd>space</kbd> | Fold or unfold the straight stretch at the selected commit |
| <kbd>c</kbd> | Fold or unfold all straight stretches |
| <kbd>h</kbd> | Hide the branch of the selected commit |
| <kbd>y</kbd> | Copy the short hash |
| <kbd>/</kbd> | Type a filter pattern |
| <kbd>p</kbd> | Show or hide the preview panel |
| <kbd>o</kbd> | Open another repository |
| <kbd>r</kbd> | Reload |
**In a file's diff**
| Key | Action |
|---|---|
| <kbd>↑</kbd> <kbd>↓</kbd> or <kbd>k</kbd> <kbd>j</kbd> | Scroll by one line |
| <kbd>PgUp</kbd> <kbd>PgDn</kbd> | Scroll by one screen |
| <kbd>g</kbd> <kbd>G</kbd> or <kbd>Home</kbd> <kbd>End</kbd> | Go to the top or bottom |
| <kbd>n</kbd> <kbd>N</kbd> | Go to the next or previous change |
| <kbd>]</kbd> <kbd>[</kbd> | Show the next or previous file of the commit |
| <kbd>v</kbd> | Switch the layout: one column, side by side, inline |
| <kbd>Esc</kbd> | Go back to the commit graph |
## Where gites keeps its settings
The window layout, the diff layout and the recent repositories are kept in
`settings.json`, and the filters of each repository in `filters.json`. Both files are in
the `gites` folder of your data folder: `~/.local/share/gites` on Linux,
`%APPDATA%\gites` on Windows.
## Build from source
gites is a [Tauri](https://tauri.app) app: the window is Vue and TypeScript, and the part
that runs git is Rust. [mise](https://mise.jdx.dev) installs the right versions of Node,
Bun and Rust.
On Linux, Tauri also needs the WebKitGTK development packages. `scripts/ci-setup.sh`
lists them for Debian and Ubuntu.
```sh
mise install # Node, Bun and Rust
bun install # packages for the window code
mise run dev # run gites with live reload; add a path to open a repository
mise run test # unit tests for the window code and the Rust code
mise run check # type checks for both
mise run install # build a release binary and install it as ~/.local/bin/gites
```
Pushing a tag such as `v1.2.3` builds the Linux and Windows packages and publishes them
on the releases page.
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

@@ -0,0 +1,142 @@
# Repositories on another machine, over ssh: Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Open a git repository on another machine with `gites host:/path` or from the window, and show its history exactly as for a local repository, by running every git command through ssh.
**Architecture:** A new `Location` value (local folder, or ssh target plus path) replaces the folder that `git()`, `git::load`, `details::load` and `diff::load` take today. `git()` runs git directly for a local location, and `ssh … "git -C '<path>' '<arg>' …"` for a remote one, with a shared ssh connection. Nothing above `git()` changes except how the location is chosen at start-up and when opening a repository; the window gets a text field to type `host:/path`.
**Tech Stack:** Rust (Tauri 2 commands), Vue 3 + TypeScript, OpenSSH client, git.
**Spec:** `docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md`
## Global Constraints
- Remote text form: `[user@]host:path`; `host` has no `/` or `\`, is longer than one character, and no local folder with exactly the whole text as its name exists (relative to the current folder). Everything else is local.
- A remote path starting with `~/` keeps the `~` outside the quotes.
- ssh options, in this order: `-o BatchMode=yes -o ConnectTimeout=10 -o ControlMaster=auto -o ControlPath=<socket> -o ControlPersist=10m`, then the target, then one command string. On Windows the three `Control…` options are left out.
- Socket: `<runtime folder>/gites-%C`, where the runtime folder is `dirs::runtime_dir()`, or `/tmp` when that is `None`.
- Quoting: each word in single quotes; a `'` inside becomes `'\''`.
- Remote repository name: `<target>:<output of git rev-parse --show-toplevel on the server>`.
- ssh exit code 255 means a connection error. Messages, exactly:
- `could not connect to <target>: <ssh stderr, trimmed>`
- if stderr contains `Host key verification failed`, add on a new line: ``Run `ssh <target>` once in a terminal to accept the server's key.``
- if stderr contains `Permission denied`, add on a new line: ``gites logs in with keys or your ssh agent only; check that `ssh <target>` works without a password.``
- The ssh program is `ssh`, unless the environment variable `GITES_SSH` names another program (used by tests).
- Window text: the menu and welcome entry reads `Open on another machine…`; the text field's placeholder is `user@host:/path/to/repo`.
- Commit messages describe the change only: no session links, no co-author lines.
## Review Focus
- A remote path with spaces, such as `myserver:/srv/my app`, must open: covered in Task 2's end-to-end test.
- `ssh` missing on this machine: the error must say `could not run ssh: …`, not crash or hang; test in Task 2.
- A file name with `'`, `$`, `;` and a space must give the same diff remotely as locally: Task 2's end-to-end test.
- A `host:path` typed with surrounding spaces in the window's text field must open: the field trims it; test in Task 4 by hand (the window code has no unit-test surface for this component).
- A server whose login shell prints text for non-interactive sessions would corrupt git's output. This cannot be fixed in gites; it is listed so a reviewer checks that nothing in the code makes it worse (for example, no `-t` option is passed to ssh).
---
### Task 1: Location: reading `host:path`, quoting, the remote command
**Files:**
- Create: `src-tauri/src/location.rs`
- Modify: `src-tauri/src/lib.rs` (add `mod location;`)
**Interfaces:**
- Produces:
- `#[derive(Clone, Debug, PartialEq)] pub enum Location { Local(PathBuf), Ssh { target: String, path: String } }`
- `pub fn parse(text: &str, cwd: &Path) -> Location`
- `impl Location { pub fn name(&self) -> String }`: local → the path as text (`to_string_lossy`); remote → `"{target}:{path}"`.
- `impl Location { pub fn with_path(&self, path: &str) -> Location }`: same kind and target, another path (`Local(PathBuf::from(path))` for local).
- `pub fn quote(word: &str) -> String`
- `pub fn remote_command(path: &str, args: &[&str]) -> String`: `git -C <path> <args…>`, every word quoted with `quote`, except that a path starting with `~/` is written `~/` followed by the quoted rest.
- [ ] **Step 1: Write the failing tests** in `location.rs` under `#[cfg(test)] mod tests`:
- `reads_remote_locations`: `parse("myserver:/srv/app", cwd)` is `Ssh { target: "myserver", path: "/srv/app" }`; `parse("ada@myserver:~/x", cwd)` is `Ssh { target: "ada@myserver", path: "~/x" }`.
- `keeps_local_paths_local`: `C:\code\x`, `C:/code/x`, `./a:b`, `relative/dir` and `/abs/dir` all give `Local(PathBuf::from(text))`.
- `keeps_an_existing_local_folder_with_a_colon_local`: create `<temp>/notes:2024`; `parse("notes:2024", &temp)` is `Local(PathBuf::from("notes:2024"))`; with that folder removed it is `Ssh { target: "notes", path: "2024" }`.
- `names_locations`: `Ssh{…}.name()` is `"myserver:/srv/app"`.
- `quoted_words_survive_a_shell`: for each of `plain`, `with space`, `it's`, `"double"`, `$HOME`, `a;b`, `*`, `line\nbreak`: `sh -c "printf %s <quote(word)>"` prints exactly `word`.
- `builds_the_remote_command`: `remote_command("/srv/my app", &["log", "--format=%H"])` is `git -C '/srv/my app' 'log' '--format=%H'`; `remote_command("~/x y", &["status"])` is `git -C ~/'x y' 'status'`.
- [ ] **Step 2: Add stubs** for every function in Interfaces (returning `Location::Local(PathBuf::new())` / `String::new()`) so the tests compile, then run `cargo test --manifest-path src-tauri/Cargo.toml location::` — Expected: the tests FAIL on their assertions.
- [ ] **Step 3: Implement** the functions in `location.rs` following Global Constraints.
- [ ] **Step 4: Run** `cargo test --manifest-path src-tauri/Cargo.toml location::` — Expected: PASS.
- [ ] **Step 5: Commit** `src-tauri/src/location.rs src-tauri/src/lib.rs` with message `Read host:path locations and quote words for a server's shell`.
### Task 2: Run git through ssh, and pass locations everywhere
**Files:**
- Modify: `src-tauri/src/git.rs` (`git`, `load`, `start_dir` and tests)
- Modify: `src-tauri/src/details.rs` (`load`)
- Modify: `src-tauri/src/diff.rs` (`load` and tests)
- Modify: `src-tauri/src/lib.rs` (`RepoDir`, every command that reads it, `open_repo`, `run`)
- Create: `src-tauri/src/test_ssh.rs` (`#[cfg(test)]` only; registered in `lib.rs` with `#[cfg(test)] mod test_ssh;`)
**Interfaces:**
- Consumes: Task 1's `Location`, `parse`, `name`, `with_path`, `remote_command`.
- Produces:
- `pub fn git(loc: &Location, args: &[&str]) -> Result<String, String>`: calls `git_via` with the program from `GITES_SSH`, or `ssh`.
- `pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result<String, String>`: does the work; tests call it with another program without changing `GITES_SSH`, which other tests running at the same time rely on.
- `pub fn ssh_error(target: &str, stderr: &str) -> String` (in `git.rs`): the connection message from Global Constraints.
- `pub fn load(loc: &Location) -> Result<RepoData, String>`: `RepoData.root` is `loc.with_path(<toplevel>).name()`; later git calls in `load` use `loc.with_path(<toplevel>)`.
- `pub fn start_dir(arg: Option<String>, cwd: PathBuf) -> Option<Location>`: an argument goes through `location::parse(&arg, &cwd)` and is used as is; no argument keeps today's rule (current folder if inside a repository, as `Location::Local`).
- `details::load(loc: &Location, hash: &str)`, `diff::load(loc: &Location, hash, path, old_path, force)`: same behaviour, location instead of folder.
- `struct RepoDir(Mutex<Option<Location>>)`; `open_repo(path: String)` parses `path` with `location::parse` against the current folder.
- `test_ssh::fake_ssh() -> PathBuf`: writes once (guarded by `std::sync::Once`) an executable `sh` script to the temp folder that skips each `-o <value>` pair and the target, then runs `exec sh -c "$1"`; sets `GITES_SSH` to it; returns its path.
- [ ] **Step 1: Write the failing tests**
- In `git.rs`: `explains_connection_errors`: `ssh_error("myserver", "Host key verification failed.\n")` contains `could not connect to myserver: Host key verification failed.` and ``Run `ssh myserver` once in a terminal``; with `"ada@myserver: Permission denied (publickey).\n"` it contains `gites logs in with keys or your ssh agent only`; with `"ssh: connect to host myserver port 22: Connection timed out"` it contains only the first line.
- In `git.rs`: `loads_a_repository_through_ssh`: make a repository at `<temp>/remote repo` with one commit, call `fake_ssh()`, then `load(&Location::Ssh { target: "myserver".into(), path: "<temp>/remote repo".into() })` returns one commit and `root == "myserver:<temp>/remote repo"`.
- In `git.rs`: `reports_a_missing_ssh_program`: `git_via(OsStr::new("/no/such/ssh"), &Location::Ssh{…}, &["status"])` is an `Err` starting with `could not run ssh:`.
- In `diff.rs`, module `with_a_repository`: `shows_the_same_diff_through_ssh`: a repository with a file named `it's $a; b.txt` changed in one commit; `load` through `Location::Local` and through `Location::Ssh` (with `fake_ssh()`) give equal `FileDiff`s with at least one hunk.
- Update the existing tests to pass `&Location::Local(…)` instead of a folder.
- [ ] **Step 2: Run** `cargo test --manifest-path src-tauri/Cargo.toml` — Expected: the new tests FAIL (compile errors are not acceptable: first add stubs for `ssh_error` and the new `git`/`load` signatures).
- [ ] **Step 3: Implement**
- `git()`: local as today (keep the Windows no-console flag on both paths). Remote: program from `GITES_SSH` or `ssh`; arguments per Global Constraints; command from `remote_command`. A failure to start gives `could not run ssh: <error>`. Exit code 255 gives `ssh_error(target, stderr)`; other failures give the trimmed stderr, as today.
- Socket path: `dirs::runtime_dir().unwrap_or_else(|| PathBuf::from("/tmp")).join("gites-%C")`.
- Thread the `Location` through `load`, `details::load`, `diff::load`, `start_dir` and `lib.rs`.
- `run()`: pass the first argument as a `String` (`to_string_lossy`) to `start_dir`.
- [ ] **Step 4: Run** `cargo test --manifest-path src-tauri/Cargo.toml` and `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets` — Expected: all tests PASS; clippy shows no new warnings.
- [ ] **Step 5: Commit** the files above with message `Run git through ssh for repositories on another machine`.
### Task 3: Open a remote repository from the window
**Files:**
- Modify: `src/components/RepoMenu.vue`
- Modify: `src/components/Welcome.vue`
- Modify: `src/App.vue` (`load`)
**Interfaces:**
- Consumes: the `open_repo` command from Task 2, which now accepts `host:path` text; App's existing `openRepo(path: string)`.
- Produces: both components emit the existing `pick` event with the trimmed text typed in the field.
- [ ] **Step 1: Add the entry.** In `RepoMenu.vue`, below "Open folder…", an item `Open on another machine…` that, when chosen (click or <kbd>Enter</kbd> on it), turns into a text input with placeholder `user@host:/path/to/repo`. <kbd>Enter</kbd> in the input emits `pick` with the trimmed text when it is not empty; <kbd>Esc</kbd> turns it back into the item. Keys typed in the input must not reach the menu's own key handler (no `j`/`k`/`o` actions while typing). In `Welcome.vue`, the same input next to "Choose a folder…".
- [ ] **Step 2: Keep the graph when a reload fails.** In `App.vue`'s `load()`: when a repository is already on screen (`repo.value` is set), show the error with `showToast` and keep the graph; only when nothing is open yet, set `error.value` as today. The spec requires this for a connection that drops while a repository is open.
- [ ] **Step 3: Run** `bun run vue-tsc --noEmit` and `bun run vitest run` — Expected: no type errors, all tests PASS.
- [ ] **Step 4: Check in the browser** with the stand-in backend used for the earlier features (Vite dev server plus a mocked `__TAURI_INTERNALS__.invoke` whose `open_repo` records its argument): type ` myserver:/srv/app ` in each of the two places, press <kbd>Enter</kbd>, and confirm `open_repo` received `myserver:/srv/app`. Confirm <kbd>Esc</kbd> in the menu's field closes only the field. Then make the mocked `load_repo` throw and trigger a reload (focus the window): the graph stays and a message appears at the bottom.
- [ ] **Step 5: Commit** with message `Open on another machine from the window; keep the graph when a reload fails`.
### Task 4: README and a check against a real server
**Files:**
- Modify: `README.md`
- [ ] **Step 1: README.** In "What it does", a bullet: repositories on another machine open with `gites myserver:/srv/app`, or from the repository menu; git runs there through ssh, which must log in without a password. In "Use", the line `gites myserver:/srv/app # a repository on another machine, over ssh`.
- [ ] **Step 2: Real server, by hand.** Build with `mise run dev <host>:<path>` against a server the user names. Check: the graph loads; a commit's details; a file's diff; reload with <kbd>r</kbd>; a wrong path shows git's "not a git repository" message; an unknown host shows the connection message. Report what was checked and what was not.
- [ ] **Step 3: Run** `mise run test` and `mise run check` — Expected: PASS.
- [ ] **Step 4: Commit** `README.md` with message `README: repositories on another machine`.
@@ -0,0 +1,147 @@
# Repositories on another machine, over ssh
## Goal
Open a git repository that lives on another machine and see its history exactly as
gites shows a local one: branches, tags, remote branches, commits, the details panel
and diffs. gites shows the state of that folder on that machine, including its local
branches and which branch is checked out there, not the state of its origin.
Only history is shown, as for local repositories today. Uncommitted changes are not
part of this work.
Linux and macOS are the platforms that matter. Windows must work, but may be slow.
## How the user opens one
- Command line: `gites myserver:/srv/app` or `gites user@myserver:/srv/app`.
`myserver` can be any host name from `~/.ssh/config`, so ports, user names and keys
set there apply.
- In the window: the repository menu (`o`) and the welcome screen get an entry
"Open on another machine…" with one text field for `[user@]host:path`.
- Remote repositories go into the recent list like local ones, written as
`myserver:/srv/app`.
## Where a repository is
The Rust side stores the open repository as one of two kinds:
- a local folder, as today;
- an ssh target (`myserver` or `user@myserver`) and a path on that machine.
One function turns text into a location. The text means a remote repository when it
has the form `[user@]host:path`, where `host` contains no `/` or `\` and is longer than
one character, and no local folder with exactly that name exists. Every other text is a
local folder. So `C:\code\x` and `C:/code/x` stay local, `./a:b` stays local, and a
folder in the current folder named `notes:2024` stays local when it exists.
A path on the server that is `~` or starts with `~/` is handed to the server's shell with the
`~` outside the quotes, so the shell expands it to the home folder there.
## Running git
`git()` in `src-tauri/src/git.rs` takes the repository's location instead of a folder.
- Local: `git -C <folder> <args>`, as today, with the Windows flag that keeps console
windows from opening.
- Remote: the program `ssh`, with these options, then the target, then one command
string for the server's shell:
```
ssh -o BatchMode=yes -o ConnectTimeout=10
-o ServerAliveInterval=5 -o ServerAliveCountMax=2
-o ControlMaster=auto -o ControlPath=<socket> -o ControlPersist=10m
user@myserver "git -C '/srv/app' 'log' '--format=…' …"
```
- `BatchMode=yes`: ssh never asks for a password or a yes/no answer, because gites
has no terminal to ask in. Keys and the ssh agent work.
- `ConnectTimeout=10`: a server that does not answer gives an error after 10 seconds
instead of a window that waits for ever.
- `ServerAliveInterval=5`, `ServerAliveCountMax=2`: when a server stops answering
on a connection that is already open (also a shared one), ssh notices it in about
10 seconds and gives an error instead of waiting for ever.
- `ControlMaster`, `ControlPath`, `ControlPersist`: the first command opens one
connection and leaves it open; later commands go through it and cost about one
network round trip each instead of a new login. The connection closes 10 minutes
after the last command. The socket is `gites-%C` in the user's runtime folder
(`/run/user/<id>` on Linux). Where there is none (always on macOS), it is in a
`gites` folder in the user's cache folder, made so that only the user can open it;
never in `/tmp`. If neither folder can be found or made, these three options are
left out and every command opens a new connection. `%C` is a hash ssh makes from
the host, port and user, 40 characters long. macOS accepts a socket path of at most
103 characters (Linux 107), and while ssh sets up a shared connection it adds a
temporary end of up to 18 characters to the file name. So gites counts the folder's
length + 1 + 6 (`gites-`) + 40 + 18; if that is more than 103 (one limit on every
system), these three options are left out too. The folder is found, made and checked
once when gites first runs ssh, not for every command.
- On Windows, ssh cannot share connections, so these three options are left out and
every command opens a new connection.
- Quoting: every word of the git command is put in single quotes, and a `'` inside a
word is written as `'\''`. This works in sh, bash, zsh and fish, so a file name with
spaces, quotes, `$`, `;` or a line break reaches git unchanged and cannot run
anything else on the server.
Nothing above `git()` changes: loading the graph, the details panel, diffs and the
reload when the window gets focus all run through it.
## Names and saved data
A remote repository's name is `<target>:<top folder>`, where the top folder is what
`git rev-parse --show-toplevel` prints on the server. That name is used:
- in the window title and the header;
- in the recent list in `settings.json`;
- as the key of the repository's filters in `filters.json`, so filters are remembered
for remote repositories just like for local ones.
## Start-up
A remote location given on the command line becomes the open repository at once. gites
does not test the connection before the window appears, so a slow server never delays
the window. If the server cannot be reached, the first load fails and the window shows
the error screen, as it does today for a local path that is not a repository.
## Errors
ssh exits with code 255 when the connection fails, and with git's own exit code
otherwise. That tells a connection problem from a git error:
| What happened | What gites shows |
|---|---|
| Connection failed (exit code 255) | "could not connect to myserver:" and ssh's message |
| …and ssh says "Host key verification failed" | also: "Run `ssh myserver` once in a terminal to accept the server's key." |
| …and ssh says "Permission denied" | also: "gites logs in with keys or your ssh agent only; check that `ssh myserver` works without a password." |
| The server does not answer within 10 seconds | the connection error above, with ssh's timeout message |
| git fails on the server, for example "not a git repository" | git's message, as today |
If the connection drops while a repository is open, the next command shows its error
as a message at the bottom of the window and the graph that is already on screen
stays. The next reload connects again by itself.
A message that reports an error stays for 8 seconds, or until it is clicked, and each
hint line above stays on its own line. Other messages go after 1.8 seconds, as before.
The commands that run git run away from the thread that draws the window, so the
window does not freeze while it waits for a slow server.
## Tests
- Reading locations: `host:/path`, `user@host:/path`, `host:~/x`, `C:\x`, `C:/x`, a
plain relative path, and an existing local folder with a `:` in its name.
- Quoting: words with spaces, `'`, `"`, `$`, `;`, `*` and a line break are quoted, run
through a real `sh -c`, and come out unchanged.
- End to end without a server: for tests, the ssh program can be replaced by a small
script that drops the ssh options and runs the command string with `sh -c` on this
machine, the way a server's shell would. With it, the existing tests for loading a
repository, commit details and diffs also run against a "remote" location, including
files with awkward names. CI runs these.
- By hand, before release: open a repository on a real server, check the graph, a
commit's details and a diff, then wait for the shared connection to close and reload.
## Not part of this work
- Uncommitted changes on the remote machine.
- Passwords and other questions ssh might ask.
- `ssh://` addresses; ports and users go in `~/.ssh/config` or `user@host`.
- Closing the shared connection when gites quits; it closes itself after 10 minutes.
+38 -5
View File
@@ -2,7 +2,7 @@
use crate::git::git; use crate::git::git;
use serde::Serialize; use serde::Serialize;
use std::path::Path; use crate::location::Location;
#[derive(Serialize, Debug, PartialEq)] #[derive(Serialize, Debug, PartialEq)]
pub struct ChangedFile { pub struct ChangedFile {
@@ -16,7 +16,7 @@ pub struct ChangedFile {
pub deleted: Option<u32>, pub deleted: Option<u32>,
} }
#[derive(Serialize, Debug)] #[derive(Serialize, Debug, PartialEq)]
pub struct CommitDetails { pub struct CommitDetails {
hash: String, hash: String,
author: String, author: String,
@@ -63,13 +63,13 @@ pub fn parse_files(name_status: &str, numstat: &str) -> Vec<ChangedFile> {
files files
} }
pub fn load(dir: &Path, hash: &str) -> Result<CommitDetails, String> { pub fn load(loc: &Location, hash: &str) -> Result<CommitDetails, String> {
// The hash comes from the window; refuse anything git could read as an option. // The hash comes from the window; refuse anything git could read as an option.
if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("not a commit hash: {hash}")); return Err(format!("not a commit hash: {hash}"));
} }
let info = git( let info = git(
dir, loc,
&["show", "-s", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B", hash, "--"], &["show", "-s", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B", hash, "--"],
)?; )?;
let f: Vec<&str> = info.splitn(9, '\x1f').collect(); let f: Vec<&str> = info.splitn(9, '\x1f').collect();
@@ -90,7 +90,7 @@ pub fn load(dir: &Path, hash: &str) -> Result<CommitDetails, String> {
let with = |flag: &'static str| { let with = |flag: &'static str| {
let mut a = args.clone(); let mut a = args.clone();
a.insert(1, flag); a.insert(1, flag);
git(dir, &a) git(loc, &a)
}; };
let files = parse_files(&with("--name-status")?, &with("--numstat")?); let files = parse_files(&with("--name-status")?, &with("--numstat")?);
@@ -153,4 +153,37 @@ mod tests {
fn parses_an_empty_diff() { fn parses_an_empty_diff() {
assert_eq!(parse_files("", ""), vec![]); assert_eq!(parse_files("", ""), vec![]);
} }
#[test]
fn shows_the_same_details_through_ssh() {
use std::process::Command;
let dir = std::env::temp_dir().join(format!("gites-details-ssh-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |args: &[&str]| {
let out = Command::new("git")
.arg("-C")
.arg(&dir)
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
run(&["init", "-q", "-b", "main"]);
std::fs::write(dir.join("it's $a; b.txt"), "one\ntwo\n").unwrap();
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "first"]);
std::fs::write(dir.join("it's $a; b.txt"), "one\nthree\n").unwrap();
run(&["commit", "-q", "-am", "second\n\nwith a body"]);
let hash = run(&["rev-parse", "HEAD"]);
crate::test_ssh::fake_ssh();
let remote = Location::Ssh { target: "myserver".into(), path: dir.to_string_lossy().into_owned() };
let local = load(&Location::Local(dir.clone()), &hash).unwrap();
let through_ssh = load(&remote, &hash).unwrap();
assert_eq!(local.files, vec![file("M", "it's $a; b.txt", None, Some(1), Some(1))]);
assert_eq!(local, through_ssh);
let _ = std::fs::remove_dir_all(&dir);
}
} }
+34 -15
View File
@@ -2,7 +2,7 @@
use crate::git::git; use crate::git::git;
use serde::Serialize; use serde::Serialize;
use std::path::Path; use crate::location::Location;
/// Above this many changed lines, or this many bytes of patch, the diff is only sent /// Above this many changed lines, or this many bytes of patch, the diff is only sent
/// when the user asks for it again. /// when the user asks for it again.
@@ -71,12 +71,12 @@ pub fn parse_patch(patch: &str) -> FileDiff {
diff diff
} }
pub fn load(dir: &Path, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result<FileDiff, String> { pub fn load(loc: &Location, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result<FileDiff, String> {
// The hash comes from the window; refuse anything git could read as an option. // The hash comes from the window; refuse anything git could read as an option.
if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("not a commit hash: {hash}")); return Err(format!("not a commit hash: {hash}"));
} }
let parents = git(dir, &["rev-list", "--parents", "-n", "1", hash, "--"])?; let parents = git(loc, &["rev-list", "--parents", "-n", "1", hash, "--"])?;
// A merge is compared with its first parent, as in the preview panel's file list. // 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 parent = parents.split_whitespace().nth(1).map(String::from);
@@ -89,11 +89,11 @@ pub fn load(dir: &Path, hash: &str, path: &str, old_path: Option<&str>, force: b
if let Some(old) = old_path { if let Some(old) = old_path {
args.push(old); args.push(old);
} }
let patch = git(dir, &args)?; let patch = git(loc, &args)?;
let mut diff = parse_patch(&patch); let mut diff = parse_patch(&patch);
if diff.binary { if diff.binary {
let size = |spec: String| git(dir, &["cat-file", "-s", &spec]).ok().and_then(|s| s.trim().parse().ok()); let size = |spec: String| git(loc, &["cat-file", "-s", &spec]).ok().and_then(|s| s.trim().parse().ok());
diff.old_size = parent.as_ref().and_then(|p| size(format!("{p}:{}", old_path.unwrap_or(path)))); diff.old_size = parent.as_ref().and_then(|p| size(format!("{p}:{}", old_path.unwrap_or(path))));
diff.new_size = size(format!("{hash}:{path}")); diff.new_size = size(format!("{hash}:{path}"));
} }
@@ -192,7 +192,7 @@ Binary files a/logo.png and b/logo.png differ
#[test] #[test]
fn refuses_a_hash_that_git_could_read_as_an_option() { fn refuses_a_hash_that_git_could_read_as_an_option() {
let err = load(Path::new("."), "--output=x", "a", None, false).unwrap_err(); let err = load(&Location::Local(".".into()), "--output=x", "a", None, false).unwrap_err();
assert!(err.contains("not a commit hash"), "{err}"); assert!(err.contains("not a commit hash"), "{err}");
} }
@@ -230,6 +230,9 @@ Binary files a/logo.png and b/logo.png differ
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string() String::from_utf8_lossy(&out.stdout).trim().to_string()
} }
fn loc(&self) -> Location {
Location::Local(self.0.clone())
}
fn write(&self, path: &str, content: &[u8]) { fn write(&self, path: &str, content: &[u8]) {
std::fs::write(self.0.join(path), content).unwrap(); std::fs::write(self.0.join(path), content).unwrap();
} }
@@ -249,7 +252,7 @@ Binary files a/logo.png and b/logo.png differ
let r = Repo::new("first"); let r = Repo::new("first");
r.write("a.txt", b"one\ntwo\n"); r.write("a.txt", b"one\ntwo\n");
let first = r.commit("first"); let first = r.commit("first");
let d = load(&r.0, &first, "a.txt", None, false).unwrap(); let d = load(&r.loc(), &first, "a.txt", None, false).unwrap();
assert_eq!(texts(&d), vec!["+one", "+two"]); assert_eq!(texts(&d), vec!["+one", "+two"]);
} }
@@ -262,14 +265,14 @@ Binary files a/logo.png and b/logo.png differ
r.write("a.txt", body.replace("line 5\n", "line five\n").as_bytes()); r.write("a.txt", body.replace("line 5\n", "line five\n").as_bytes());
let second = r.commit("second"); let second = r.commit("second");
assert_eq!( assert_eq!(
texts(&load(&r.0, &second, "a.txt", None, false).unwrap()), texts(&load(&r.loc(), &second, "a.txt", None, false).unwrap()),
vec![" line 2", " line 3", " line 4", "-line 5", "+line five", " line 6", " line 7", " line 8"] 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.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()); 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 third = r.commit("third");
let d = load(&r.0, &third, "b.txt", Some("a.txt"), false).unwrap(); let d = load(&r.loc(), &third, "b.txt", Some("a.txt"), false).unwrap();
assert_eq!(&texts(&d)[3..5], ["-line 20", "+line twenty"]); assert_eq!(&texts(&d)[3..5], ["-line 20", "+line twenty"]);
} }
@@ -280,7 +283,7 @@ Binary files a/logo.png and b/logo.png differ
r.commit("first"); r.commit("first");
r.run(&["mv", "a.txt", "b.txt"]); r.run(&["mv", "a.txt", "b.txt"]);
let second = r.commit("second"); let second = r.commit("second");
assert_eq!(load(&r.0, &second, "b.txt", Some("a.txt"), false).unwrap(), FileDiff::default()); assert_eq!(load(&r.loc(), &second, "b.txt", Some("a.txt"), false).unwrap(), FileDiff::default());
} }
#[test] #[test]
@@ -290,13 +293,13 @@ Binary files a/logo.png and b/logo.png differ
r.commit("first"); r.commit("first");
r.write("x.bin", b"\0\x01\x02\x03\x04"); r.write("x.bin", b"\0\x01\x02\x03\x04");
let second = r.commit("second"); let second = r.commit("second");
let d = load(&r.0, &second, "x.bin", None, false).unwrap(); let d = load(&r.loc(), &second, "x.bin", None, false).unwrap();
assert!(d.binary); assert!(d.binary);
assert_eq!((d.old_size, d.new_size), (Some(3), Some(5))); assert_eq!((d.old_size, d.new_size), (Some(3), Some(5)));
std::fs::remove_file(r.0.join("x.bin")).unwrap(); std::fs::remove_file(r.0.join("x.bin")).unwrap();
let third = r.commit("third"); let third = r.commit("third");
let d = load(&r.0, &third, "x.bin", None, false).unwrap(); let d = load(&r.loc(), &third, "x.bin", None, false).unwrap();
assert_eq!((d.old_size, d.new_size), (Some(5), None)); assert_eq!((d.old_size, d.new_size), (Some(5), None));
} }
@@ -313,7 +316,7 @@ Binary files a/logo.png and b/logo.png differ
r.commit("main"); r.commit("main");
r.run(&["merge", "-q", "--no-edit", "side"]); r.run(&["merge", "-q", "--no-edit", "side"]);
let merge = r.run(&["rev-parse", "HEAD"]); let merge = r.run(&["rev-parse", "HEAD"]);
let d = load(&r.0, &merge, "side.txt", None, false).unwrap(); let d = load(&r.loc(), &merge, "side.txt", None, false).unwrap();
assert_eq!(texts(&d), vec!["+from side"]); assert_eq!(texts(&d), vec!["+from side"]);
} }
@@ -323,12 +326,28 @@ Binary files a/logo.png and b/logo.png differ
let body: String = (0..MAX_CHANGED_LINES + 1).map(|i| format!("{i}\n")).collect(); let body: String = (0..MAX_CHANGED_LINES + 1).map(|i| format!("{i}\n")).collect();
r.write("big.txt", body.as_bytes()); r.write("big.txt", body.as_bytes());
let first = r.commit("first"); let first = r.commit("first");
let d = load(&r.0, &first, "big.txt", None, false).unwrap(); let d = load(&r.loc(), &first, "big.txt", None, false).unwrap();
assert_eq!(d.too_large, Some(MAX_CHANGED_LINES + 1)); assert_eq!(d.too_large, Some(MAX_CHANGED_LINES + 1));
assert!(d.hunks.is_empty()); assert!(d.hunks.is_empty());
let d = load(&r.0, &first, "big.txt", None, true).unwrap(); let d = load(&r.loc(), &first, "big.txt", None, true).unwrap();
assert_eq!(d.too_large, None); assert_eq!(d.too_large, None);
assert_eq!(texts(&d).len(), (MAX_CHANGED_LINES + 1) as usize); assert_eq!(texts(&d).len(), (MAX_CHANGED_LINES + 1) as usize);
} }
#[test]
fn shows_the_same_diff_through_ssh() {
let r = Repo::new("ssh");
r.write("it's $a; b.txt", b"one\ntwo\n");
r.commit("first");
r.write("it's $a; b.txt", b"one\nthree\n");
let second = r.commit("second");
crate::test_ssh::fake_ssh();
let remote = Location::Ssh { target: "myserver".into(), path: r.0.to_string_lossy().into_owned() };
let name = "it's $a; b.txt";
let local = load(&r.loc(), &second, name, None, false).unwrap();
let through_ssh = load(&remote, &second, name, None, false).unwrap();
assert!(!local.hunks.is_empty());
assert_eq!(local, through_ssh);
}
} }
} }
+356 -12
View File
@@ -1,5 +1,7 @@
use serde::Serialize; use serde::Serialize;
use std::path::Path; use crate::location::{Location, remote_command};
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
#[derive(Serialize)] #[derive(Serialize)]
@@ -31,22 +33,148 @@ pub struct RepoData {
head: Option<String>, head: Option<String>,
} }
pub fn git(dir: &Path, args: &[&str]) -> Result<String, String> { /// The text to show when ssh could not reach the server.
let out = Command::new("git") pub fn ssh_error(target: &str, stderr: &str) -> String {
.arg("-C") let stderr = stderr.trim();
.arg(dir) let mut message = format!("could not connect to {target}: {stderr}");
.args(args) if stderr.contains("Host key verification failed") {
.output() message.push_str(&format!("\nRun `ssh {target}` once in a terminal to accept the server's key."));
.map_err(|e| format!("could not run git: {e}"))?; }
if stderr.contains("Permission denied") {
message.push_str(&format!(
"\ngites logs in with keys or your ssh agent only; check that `ssh {target}` works without a password."
));
}
message
}
/// The folder for the sockets of shared connections: `runtime`, the user's runtime folder,
/// or else a `gites` folder inside `cache`, the user's cache folder, made if missing so that
/// only the user can open it. `None` when neither gives a folder; then every command opens
/// its own connection.
#[cfg(not(windows))]
fn socket_dir(runtime: Option<PathBuf>, cache: Option<PathBuf>) -> Option<PathBuf> {
if runtime.is_some() {
return runtime;
}
let dir = cache?.join("gites");
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
match builder.create(&dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => {}
Err(_) => return None,
}
// A folder left from before may let others in; only the user may reach the sockets.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).ok()?;
}
Some(dir)
}
/// The start of every socket's file name; ssh puts `%C`, a hash of the connection, after it.
#[cfg(not(windows))]
const SOCKET_PREFIX: &str = "gites-";
/// The longest socket path macOS accepts (Linux accepts 107). One limit for every system
/// keeps one rule.
#[cfg(not(windows))]
const MAX_SOCKET_PATH: usize = 103;
/// True when sockets in `dir` stay short enough for the system. ssh puts in place of `%C` a
/// hash of 40 characters, and while it sets up a shared connection it adds a temporary end of
/// up to 18 characters to the file name.
#[cfg(not(windows))]
fn socket_path_fits(dir: &std::path::Path) -> bool {
const HASH: usize = 40;
const TEMPORARY_END: usize = 18;
// The 1 is the `/` between the folder and the file name.
dir.as_os_str().len() + 1 + SOCKET_PREFIX.len() + HASH + TEMPORARY_END <= MAX_SOCKET_PATH
}
/// The path ssh is given for the socket of shared connections, or `None` to share none: when
/// there is no folder for sockets, or the path would be too long.
#[cfg(not(windows))]
fn control_path(dir: Option<PathBuf>) -> Option<PathBuf> {
dir.filter(|d| socket_path_fits(d)).map(|d| d.join(format!("{SOCKET_PREFIX}%C")))
}
/// Runs git in the folder `loc` names: here, or on another machine through ssh.
pub fn git(loc: &Location, args: &[&str]) -> Result<String, String> {
// Tests swap in a script that acts like ssh.
#[cfg(test)]
if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.with_borrow(|fake| fake.clone()) {
return git_via(&fake, loc, args);
}
git_via(&[OsString::from("ssh")], loc, args)
}
/// Does the work of `git`. For a remote location it starts `ssh`: the first word of `ssh` is
/// the program and the other words come before the ssh options (tests use them).
pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result<String, String> {
let mut cmd = match loc {
Location::Local(dir) => {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
cmd
}
Location::Ssh { target, path } => {
let mut cmd = Command::new(&ssh[0]);
cmd.args(&ssh[1..]);
// Never ask for a password: gites has no terminal to ask on.
cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]);
// If the server stops answering, ssh gives up after about 10 seconds instead of
// waiting for ever, also on a connection that is shared.
cmd.args(["-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2"]);
// Later commands to the same server reuse one connection for a while. Windows ssh
// does not support this.
// The folder is found, made and checked once per run, not for every command.
#[cfg(not(windows))]
{
static CONTROL_PATH: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
let path = CONTROL_PATH
.get_or_init(|| control_path(socket_dir(dirs::runtime_dir(), dirs::cache_dir())));
if let Some(path) = path {
cmd.args(["-o", "ControlMaster=auto", "-o"]);
cmd.arg(format!("ControlPath={}", path.display()));
cmd.args(["-o", "ControlPersist=10m"]);
}
}
// `--` keeps a target from being read as an option.
cmd.arg("--").arg(target).arg(remote_command(path, args));
cmd
}
};
// gites has no console, so Windows would open a console window for every program it
// starts. Each one flashes on screen and takes the focus, and gites reloads on focus.
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
let program = if matches!(loc, Location::Local(_)) { "git" } else { "ssh" };
let out = cmd.output().map_err(|e| format!("could not run {program}: {e}"))?;
if !out.status.success() { if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); let stderr = String::from_utf8_lossy(&out.stderr);
if let Location::Ssh { target, .. } = loc
&& out.status.code() == Some(255)
{
return Err(ssh_error(target, &stderr));
}
return Err(stderr.trim().to_string());
} }
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} }
pub fn load(dir: &Path) -> Result<RepoData, String> { pub fn load(loc: &Location) -> Result<RepoData, String> {
let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string(); let top = git(loc, &["rev-parse", "--show-toplevel"])?.trim().to_string();
let root_path = Path::new(&root); let root_loc = loc.with_path(&top);
let root_path = &root_loc;
let root = root_loc.name();
let head = git(root_path, &["rev-parse", "--verify", "-q", "HEAD"]) let head = git(root_path, &["rev-parse", "--verify", "-q", "HEAD"])
.ok() .ok()
@@ -125,3 +253,219 @@ pub fn load(dir: &Path) -> Result<RepoData, String> {
Ok(RepoData { root, commits, refs, head_ref, head }) Ok(RepoData { root, commits, refs, head_ref, head })
} }
/// The repository to open at start: the path given on the command line, or else the
/// current folder if it is inside a repository. `None` means gites starts with its
/// welcome screen. A path given on the command line is used even if it is not a
/// repository, so the window can say what is wrong with it.
pub fn start_dir(arg: Option<std::ffi::OsString>, cwd: PathBuf) -> Option<Location> {
if let Some(arg) = arg {
// Text that is not valid UTF-8 cannot be a `host:path`; keep it exactly as given.
return Some(match arg.to_str() {
Some(text) => crate::location::parse(text, &cwd),
None => Location::Local(PathBuf::from(arg)),
});
}
let here = Location::Local(cwd);
git(&here, &["rev-parse", "--git-dir"]).ok().map(|_| here)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("gites-start-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn opens_the_path_given_on_the_command_line_even_if_it_is_not_a_repository() {
let given = PathBuf::from("/no/such/folder");
assert_eq!(start_dir(Some("/no/such/folder".into()), temp_dir("arg")), Some(Location::Local(given)));
}
#[cfg(unix)]
#[test]
fn keeps_a_command_line_path_that_is_not_valid_utf8_intact() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let bytes = b"/tmp/caf\xe9".to_vec();
let got = start_dir(Some(std::ffi::OsString::from_vec(bytes.clone())), temp_dir("bytes"));
match got {
Some(Location::Local(p)) => assert_eq!(p.as_os_str().as_bytes(), &bytes[..]),
other => panic!("{other:?}"),
}
}
#[test]
fn opens_the_current_folder_when_it_is_inside_a_repository() {
let dir = temp_dir("repo");
git(&Location::Local(dir.clone()), &["init", "-q"]).unwrap();
std::fs::create_dir_all(dir.join("sub")).unwrap();
assert_eq!(start_dir(None, dir.join("sub")), Some(Location::Local(dir.join("sub"))));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn opens_nothing_when_the_current_folder_is_not_in_a_repository() {
let dir = temp_dir("plain");
assert_eq!(start_dir(None, dir.clone()), None);
let _ = std::fs::remove_dir_all(&dir);
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
#[test]
fn explains_connection_errors() {
let e = ssh_error("myserver", "Host key verification failed.\n");
assert!(e.contains("could not connect to myserver: Host key verification failed."), "{e}");
assert!(e.contains("Run `ssh myserver` once in a terminal"), "{e}");
let e = ssh_error("myserver", "ada@myserver: Permission denied (publickey).\n");
assert!(e.contains("gites logs in with keys or your ssh agent only"), "{e}");
assert!(e.contains("check that `ssh myserver` works without a password."), "{e}");
let e = ssh_error("myserver", "ssh: connect to host myserver port 22: Connection timed out");
assert_eq!(e, "could not connect to myserver: ssh: connect to host myserver port 22: Connection timed out");
}
#[test]
fn loads_a_repository_through_ssh() {
let base = temp_dir("remote");
let dir = base.join("remote repo");
std::fs::create_dir_all(&dir).unwrap();
run_git(&dir, &["init", "-q", "-b", "main"]);
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
run_git(&dir, &["add", "-A"]);
run_git(&dir, &["commit", "-q", "-m", "first"]);
crate::test_ssh::fake_ssh();
let path = dir.to_string_lossy().into_owned();
let data = load(&Location::Ssh { target: "myserver".into(), path: path.clone() }).unwrap();
assert_eq!(data.commits.len(), 1);
assert_eq!(data.root, format!("myserver:{path}"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn explains_a_failed_connection_when_ssh_exits_with_255() {
let fake = ["sh", "-c", "echo 'Host key verification failed.' >&2; exit 255", "fake-ssh"].map(OsString::from);
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
let err = git_via(&fake, &loc, &["status"]).unwrap_err();
assert_eq!(
err,
"could not connect to myserver: Host key verification failed.\n\
Run `ssh myserver` once in a terminal to accept the server's key."
);
}
#[test]
fn asks_ssh_to_notice_a_dead_connection() {
// This stand-in prints the words it was given, one per line, and fails.
let fake = ["sh", "-c", "printf '%s\\n' \"$@\" >&2; exit 1", "fake-ssh"].map(OsString::from);
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
let err = git_via(&fake, &loc, &["status"]).unwrap_err();
assert!(
err.contains("-o\nConnectTimeout=10\n-o\nServerAliveInterval=5\n-o\nServerAliveCountMax=2\n"),
"{err}"
);
}
#[test]
fn opens_a_path_in_the_home_folder_through_ssh() {
let home = temp_dir("home");
let dir = home.join("repo");
std::fs::create_dir_all(&dir).unwrap();
run_git(&dir, &["init", "-q", "-b", "main"]);
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
run_git(&dir, &["add", "-A"]);
run_git(&dir, &["commit", "-q", "-m", "first"]);
crate::test_ssh::fake_ssh_with_home(&home);
let data = load(&Location::Ssh { target: "myserver".into(), path: "~/repo".into() }).unwrap();
assert_eq!(data.commits.len(), 1);
assert_eq!(data.root, format!("myserver:{}", dir.display()));
let _ = std::fs::remove_dir_all(&home);
}
#[cfg(not(windows))]
#[test]
fn keeps_sockets_in_the_runtime_folder() {
let runtime = PathBuf::from("/run/user/1000");
assert_eq!(socket_dir(Some(runtime.clone()), Some(PathBuf::from("/c"))), Some(runtime));
}
#[cfg(not(windows))]
#[test]
fn keeps_sockets_in_a_private_cache_folder_when_there_is_no_runtime_folder() {
let cache = temp_dir("cache");
let got = socket_dir(None, Some(cache.clone()));
assert_eq!(got, Some(cache.join("gites")));
assert!(cache.join("gites").is_dir());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(cache.join("gites")).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
// A second call finds the folder already there.
assert_eq!(socket_dir(None, Some(cache.clone())), Some(cache.join("gites")));
let _ = std::fs::remove_dir_all(&cache);
}
#[cfg(not(windows))]
#[test]
fn shares_no_connection_when_there_is_no_folder_for_sockets() {
assert_eq!(socket_dir(None, None), None);
// The cache folder is a file here, so no folder can be made inside it.
let base = temp_dir("cache-file");
let file = base.join("file");
std::fs::write(&file, "").unwrap();
assert_eq!(socket_dir(None, Some(file)), None);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(not(windows))]
#[test]
fn shares_connections_through_a_socket_in_a_short_folder() {
assert!(socket_path_fits(&PathBuf::from("/run/user/1000")));
assert_eq!(
control_path(Some(PathBuf::from("/run/user/1000"))),
Some(PathBuf::from("/run/user/1000/gites-%C"))
);
}
#[cfg(not(windows))]
#[test]
fn shares_no_connection_when_the_socket_path_would_be_too_long() {
// The longest folder that fits: the rest of the path takes 1 + 6 + 40 + 18 characters.
let longest = 103 - 1 - SOCKET_PREFIX.len() - 40 - 18;
let too_long = PathBuf::from(format!("/{}", "a".repeat(longest)));
assert!(!socket_path_fits(&too_long));
assert_eq!(control_path(Some(too_long)), None);
assert_eq!(control_path(None), None);
}
#[cfg(not(windows))]
#[test]
fn shares_connections_when_the_socket_path_is_exactly_at_the_limit() {
let longest = 103 - 1 - SOCKET_PREFIX.len() - 40 - 18;
let at_limit = PathBuf::from(format!("/{}", "a".repeat(longest - 1)));
assert_eq!(at_limit.as_os_str().len(), longest);
assert!(socket_path_fits(&at_limit));
}
#[test]
fn reports_a_missing_ssh_program() {
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
let err = git_via(&[OsString::from("/no/such/ssh")], &loc, &["status"]).unwrap_err();
assert!(err.starts_with("could not run ssh:"), "{err}");
}
}
+34 -22
View File
@@ -2,48 +2,61 @@ mod details;
mod diff; mod diff;
mod filters; mod filters;
mod git; mod git;
mod location;
mod settings; mod settings;
#[cfg(test)]
mod test_ssh;
use location::Location;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
/// The open repository folder. It starts as the first command line argument, or the /// The open repository (a folder here or on another machine), from `git::start_dir`; it changes when the user opens
/// current folder, and changes when the user opens another one. /// another one. `None` until a repository is open: the window shows its welcome screen.
struct RepoDir(Mutex<PathBuf>); struct RepoDir(Mutex<Option<Location>>);
impl RepoDir { impl RepoDir {
fn get(&self) -> PathBuf { fn get(&self) -> Result<Location, String> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone() self.0.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or_else(|| "no repository is open".into())
} }
} }
#[tauri::command] // The commands that run git are marked `async` so Tauri runs them away from the thread that
fn load_repo(dir: tauri::State<RepoDir>) -> Result<git::RepoData, String> { // draws the window: a command that waits for a slow server must not freeze the window.
git::load(&dir.get())
/// The open repository, or `None` when there is none yet.
#[tauri::command(async)]
fn load_repo(dir: tauri::State<'_, RepoDir>) -> Result<Option<git::RepoData>, String> {
match dir.get() {
Ok(d) => git::load(&d).map(Some),
Err(_) => Ok(None),
}
} }
/// Switches to another repository. The current one stays open if `path` is not a repository. /// Switches to another repository. The current one stays open if `path` is not a repository.
#[tauri::command] #[tauri::command(async)]
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> { fn open_repo(dir: tauri::State<'_, RepoDir>, path: String) -> Result<git::RepoData, String> {
let data = git::load(&PathBuf::from(&path))?; let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = PathBuf::from(&path); let loc = location::parse(&path, &cwd);
let data = git::load(&loc)?;
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(loc);
Ok(data) Ok(data)
} }
#[tauri::command] #[tauri::command(async)]
fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::CommitDetails, String> { fn commit_details(dir: tauri::State<'_, RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
details::load(&dir.get(), &hash) details::load(&dir.get()?, &hash)
} }
#[tauri::command] #[tauri::command(async)]
fn file_diff( fn file_diff(
dir: tauri::State<RepoDir>, dir: tauri::State<'_, RepoDir>,
hash: String, hash: String,
path: String, path: String,
old_path: Option<String>, old_path: Option<String>,
force: bool, force: bool,
) -> Result<diff::FileDiff, String> { ) -> Result<diff::FileDiff, String> {
diff::load(&dir.get(), &hash, &path, old_path.as_deref(), force) diff::load(&dir.get()?, &hash, &path, old_path.as_deref(), force)
} }
#[tauri::command] #[tauri::command]
@@ -68,10 +81,9 @@ fn save_settings(value: serde_json::Value) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let path = std::env::args_os() let arg = std::env::args_os().nth(1);
.nth(1) let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
.map(PathBuf::from) let path = git::start_dir(arg, cwd);
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
+190
View File
@@ -0,0 +1,190 @@
//! Where a repository lives: a folder on this machine, or a folder on another machine
//! reached over ssh.
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq)]
pub enum Location {
Local(PathBuf),
/// `target` is what is given to ssh, like `host` or `user@host`.
Ssh { target: String, path: String },
}
/// Reads `[user@]host:path` as a remote location. Everything else is local: text where the
/// part before the first `:` is a single character (a Windows drive like `C:`) or contains
/// `/` or `\`, text starting with `-` (ssh would read it as an option), and text that is the whole name of a folder that exists inside `cwd`.
pub fn parse(text: &str, cwd: &Path) -> Location {
let local = || Location::Local(PathBuf::from(text));
let Some((target, path)) = text.split_once(':') else { return local() };
if target.chars().count() <= 1 || target.starts_with('-') || target.contains('/') || target.contains('\\') || cwd.join(text).exists() {
return local();
}
Location::Ssh { target: target.to_string(), path: path.to_string() }
}
impl Location {
/// The text the user would type to open this location.
pub fn name(&self) -> String {
match self {
Location::Local(path) => path.to_string_lossy().into_owned(),
Location::Ssh { target, path } => format!("{target}:{path}"),
}
}
/// The same machine with another folder.
pub fn with_path(&self, path: &str) -> Location {
match self {
Location::Local(_) => Location::Local(PathBuf::from(path)),
Location::Ssh { target, .. } => Location::Ssh { target: target.clone(), path: path.to_string() },
}
}
}
/// Wraps one word in single quotes so a shell reads it back unchanged.
pub fn quote(word: &str) -> String {
format!("'{}'", word.replace('\'', r"'\''"))
}
/// The command for a shell that reads quotes the standard way: `git -C <path>` followed by
/// `args`. A path that is `~` or starts with `~/` keeps the `~` outside the quotes so the
/// shell still replaces it with the home folder.
pub fn sh_command(path: &str, args: &[&str]) -> String {
let path = match path.strip_prefix("~/") {
Some(rest) => format!("~/{}", quote(rest)),
None if path == "~" => "~".to_string(),
None => quote(path),
};
let mut command = format!("git -C {path}");
for arg in args {
command.push(' ');
command.push_str(&quote(arg));
}
command
}
/// Wraps a command so every login shell on the server (sh, bash, zsh, fish) runs it the same
/// way. Fish reads quotes differently from the others, so the command is not sent as written:
/// every byte becomes a backslash and three octal digits, which no shell changes inside single
/// quotes, and `printf` turns them back into the command for `sh` to run.
pub fn shell_neutral(sh_command: &str) -> String {
let mut wrapped = String::from("printf '");
for byte in sh_command.bytes() {
wrapped.push_str(&format!("\\{byte:03o}"));
}
wrapped.push_str("' | sh");
wrapped
}
/// The command line to give to ssh to run `git -C <path> <args>` on the remote machine.
pub fn remote_command(path: &str, args: &[&str]) -> String {
shell_neutral(&sh_command(path, args))
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn cwd() -> PathBuf {
std::env::temp_dir()
}
#[test]
fn reads_remote_locations() {
assert_eq!(
parse("myserver:/srv/app", &cwd()),
Location::Ssh { target: "myserver".into(), path: "/srv/app".into() }
);
assert_eq!(
parse("ada@myserver:~/x", &cwd()),
Location::Ssh { target: "ada@myserver".into(), path: "~/x".into() }
);
}
#[test]
fn keeps_local_paths_local() {
for text in [r"C:\code\x", "C:/code/x", "./a:b", "relative/dir", "/abs/dir"] {
assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)), "{text}");
}
}
#[test]
fn keeps_a_target_that_ssh_would_read_as_an_option_local() {
let text = "-oProxyCommand=x:y";
assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)));
}
#[test]
fn keeps_an_existing_local_folder_with_a_colon_local() {
let temp = std::env::temp_dir().join(format!("gites-location-{}", std::process::id()));
let folder = temp.join("notes:2024");
std::fs::create_dir_all(&folder).unwrap();
assert_eq!(parse("notes:2024", &temp), Location::Local(PathBuf::from("notes:2024")));
std::fs::remove_dir_all(&folder).unwrap();
assert_eq!(parse("notes:2024", &temp), Location::Ssh { target: "notes".into(), path: "2024".into() });
std::fs::remove_dir_all(&temp).unwrap();
}
#[test]
fn names_locations() {
let remote = Location::Ssh { target: "myserver".into(), path: "/srv/app".into() };
assert_eq!(remote.name(), "myserver:/srv/app");
assert_eq!(Location::Local(PathBuf::from("/abs/dir")).name(), "/abs/dir");
}
#[test]
fn changes_only_the_path() {
let remote = Location::Ssh { target: "ada@myserver".into(), path: "/srv/app".into() };
assert_eq!(remote.with_path("/srv/other"), Location::Ssh { target: "ada@myserver".into(), path: "/srv/other".into() });
assert_eq!(Location::Local(PathBuf::from("/a")).with_path("/b"), Location::Local(PathBuf::from("/b")));
}
#[test]
fn quoted_words_survive_a_shell() {
for word in ["plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "line\nbreak"] {
let out = Command::new("sh").arg("-c").arg(format!("printf %s {}", quote(word))).output().unwrap();
assert_eq!(String::from_utf8(out.stdout).unwrap(), word);
}
}
#[test]
fn wrapped_words_survive_every_login_shell() {
let words = [
"plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "%s", "line\nbreak", "a\\b", "\\",
"a\\'b", "x\\",
];
let mut shells_run = Vec::new();
for shell in ["sh", "bash", "zsh", "fish"] {
if Command::new(shell).arg("-c").arg("true").output().is_err() {
assert_ne!(shell, "sh", "sh must be installed");
continue;
}
shells_run.push(shell);
for word in words {
let wrapper = shell_neutral(&format!("printf %s {}", quote(word)));
let out = Command::new(shell).arg("-c").arg(&wrapper).output().unwrap();
assert_eq!(String::from_utf8(out.stdout).unwrap(), word, "{shell} with {word:?}");
}
}
assert!(shells_run.contains(&"sh"));
}
#[test]
fn the_remote_command_uses_only_plain_characters() {
let command = remote_command("~/x y", &["status", "it's"]);
assert!(command.starts_with("printf '") && command.ends_with("' | sh"));
assert!(command.chars().all(|c| "printf'\\0123456789 |sh".contains(c)), "{command}");
}
#[test]
fn builds_the_remote_command() {
assert_eq!(
sh_command("/srv/my app", &["log", "--format=%H"]),
"git -C '/srv/my app' 'log' '--format=%H'"
);
assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'");
assert_eq!(sh_command("~", &["status"]), "git -C ~ 'status'");
// Only the home folder of the user logged in is left for the shell; `~other` is not.
assert_eq!(sh_command("~other", &["status"]), "git -C '~other' 'status'");
}
}
+42
View File
@@ -0,0 +1,42 @@
//! A stand-in for ssh, so tests can run the remote code path without a server.
use std::cell::RefCell;
use std::ffi::OsString;
use std::path::Path;
thread_local! {
/// The words `git::git` starts instead of `ssh` in the test running on this thread: a
/// program, then its arguments. Each test runs on its own thread, so one test's stand-in
/// never reaches another test.
pub static SSH_OVERRIDE: RefCell<Option<Vec<OsString>>> = const { RefCell::new(None) };
}
/// The shell script that acts like ssh. It checks the arguments the way a real ssh would
/// need them (so a wrong argument list fails a test), skips the options and the target,
/// then runs the command on this machine.
const SCRIPT: &str = r#"
ok=
while [ "$1" = "-o" ]; do
[ "$2" = "BatchMode=yes" ] && ok=1
shift 2
done
[ -n "$ok" ] || { echo "fake ssh: BatchMode=yes is missing" >&2; exit 98; }
[ "$1" = "--" ] || { echo "fake ssh: expected -- before the target" >&2; exit 99; }
shift 2
exec sh -c "$1"
"#;
/// Makes `git::git` use the stand-in for the rest of the calling test. No file is written:
/// the script is given to `sh` on its command line.
pub fn fake_ssh() {
SSH_OVERRIDE.set(Some(["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec()));
}
/// Like `fake_ssh`, but the command runs with `home` as its home folder, the way a real
/// server would run it in the user's home. Only the stand-in's own shell sees the new home.
pub fn fake_ssh_with_home(home: &Path) {
let script = format!("HOME=\"$1\"; export HOME; shift\n{SCRIPT}");
let mut words: Vec<OsString> = ["sh", "-c", &script, "fake-ssh"].map(OsString::from).to_vec();
words.push(home.as_os_str().to_owned());
SSH_OVERRIDE.set(Some(words));
}
+80 -16
View File
@@ -10,7 +10,8 @@ import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/f
import { layout } from "./lib/layout"; import { layout } from "./lib/layout";
import { textWidth } from "./lib/measure"; import { textWidth } from "./lib/measure";
import { colorOf } from "./lib/palette"; import { colorOf } from "./lib/palette";
import { addRecent } from "./lib/recents"; import { addRecent, mergeRecents } from "./lib/recents";
import { repoRequests, type Request } from "./lib/latest";
import type { FileDiff, Layout } from "./lib/diff"; import type { FileDiff, Layout } from "./lib/diff";
import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types"; import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
import DiffView from "./components/DiffView.vue"; import DiffView from "./components/DiffView.vue";
@@ -19,6 +20,7 @@ import LabelStack from "./components/LabelStack.vue";
import PreviewPanel from "./components/PreviewPanel.vue"; import PreviewPanel from "./components/PreviewPanel.vue";
import RepoMenu from "./components/RepoMenu.vue"; import RepoMenu from "./components/RepoMenu.vue";
import Sidebar from "./components/Sidebar.vue"; import Sidebar from "./components/Sidebar.vue";
import Welcome from "./components/Welcome.vue";
const ROW_H = 28; const ROW_H = 28;
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif'; const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
@@ -58,10 +60,7 @@ async function loadSettings() {
if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight); if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight);
if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown; if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown;
if (saved?.diffLayout && LAYOUTS.includes(saved.diffLayout)) settings.diffLayout = saved.diffLayout; if (saved?.diffLayout && LAYOUTS.includes(saved.diffLayout)) settings.diffLayout = saved.diffLayout;
if (Array.isArray(saved?.recents)) { if (Array.isArray(saved?.recents)) settings.recents = mergeRecents(settings.recents, saved.recents);
// A repository opened before the settings arrived is already at the front.
settings.recents = saved.recents.reduceRight((list, p) => (list.includes(p) ? list : [...list, p]), settings.recents);
}
settingsLoaded = true; settingsLoaded = true;
} }
@@ -72,7 +71,7 @@ watch(
if (!settingsLoaded) return; if (!settingsLoaded) return;
clearTimeout(settingsTimer); clearTimeout(settingsTimer);
settingsTimer = window.setTimeout(() => { settingsTimer = window.setTimeout(() => {
invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`)); invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`, { error: true }));
}, 300); }, 300);
}, },
{ deep: true }, { deep: true },
@@ -105,21 +104,49 @@ function startDrag(e: PointerEvent, axis: "x" | "y") {
let filtersLoadedFor: string | null = null; let filtersLoadedFor: string | null = null;
let loading = false;
/**
* Git runs away from the window, so replies can arrive in another order than the requests
* went out. Every reload and every open is noted here, and its reply is used only if
* `requests.isCurrent` allows it: opening a repository wins over a reload.
*/
const requests = repoRequests();
/** False until the first load answers, so the welcome screen does not flash up at start. */
const started = ref(false);
async function load() { async function load() {
// The window gets focus often; one reload at a time is enough.
if (loading) return;
loading = true;
const request = requests.startReload();
try { try {
await applyRepo(await invoke<RepoData>("load_repo")); // null: gites was started outside a repository and none is open yet.
const data = await invoke<RepoData | null>("load_repo");
if (data) await applyRepo(data, request);
} catch (e) { } catch (e) {
error.value = String(e); // An open replaced this reload; its error no longer matters.
if (!requests.isCurrent(request)) return;
// A repository already on screen stays, for example when a connection drops.
if (repo.value) showToast(`could not reload: ${e}`, { error: true });
else error.value = String(e);
} finally {
loading = false;
started.value = true;
} }
} }
async function openRepo(path: string) { async function openRepo(path: string) {
menuOpen.value = false; menuOpen.value = false;
const request = requests.startOpen();
try { try {
await applyRepo(await invoke<RepoData>("open_repo", { path })); await applyRepo(await invoke<RepoData>("open_repo", { path }), request);
} catch (e) { } catch (e) {
// A newer open replaced this one; its error no longer matters.
if (!requests.isCurrent(request)) return;
// The repository that was open stays open. // The repository that was open stays open.
showToast(`could not open ${path}: ${e}`); showToast(`could not open ${path}: ${e}`, { error: true });
} finally {
requests.finish(request);
} }
} }
@@ -132,14 +159,19 @@ async function browse() {
const menuOpen = ref(false); const menuOpen = ref(false);
async function applyRepo(data: RepoData) { /** Puts `data` on screen, unless `request` was replaced (see `requests`). */
async function applyRepo(data: RepoData, request: Request) {
if (!requests.isCurrent(request)) return;
error.value = null; error.value = null;
if (filtersLoadedFor !== data.root) { if (filtersLoadedFor !== data.root) {
const saved = await invoke<Partial<FilterState & { collapseAll: boolean }> | null>("load_filters", { const saved = await invoke<Partial<FilterState & { collapseAll: boolean }> | null>("load_filters", {
repo: data.root, repo: data.root,
}); });
// Another repository may have been asked for while the filters were read.
if (!requests.isCurrent(request)) return;
filters.hidden = saved?.hidden ?? []; filters.hidden = saved?.hidden ?? [];
filters.rules = saved?.rules ?? []; filters.rules = saved?.rules ?? [];
filters.hideTags = saved?.hideTags ?? false;
collapseAll.value = saved?.collapseAll ?? false; collapseAll.value = saved?.collapseAll ?? false;
filtersLoadedFor = data.root; filtersLoadedFor = data.root;
toggled.value = new Set(); toggled.value = new Set();
@@ -148,7 +180,11 @@ async function applyRepo(data: RepoData) {
diffCache.clear(); diffCache.clear();
if (scroller.value) scroller.value.scrollTop = 0; if (scroller.value) scroller.value.scrollTop = 0;
settings.recents = addRecent(settings.recents, data.root); settings.recents = addRecent(settings.recents, data.root);
// The caches were emptied just above; the new root goes on screen in the same step, so a
// late reply for the old repository sees that the root changed and is not kept.
repo.value = data;
await getCurrentWindow().setTitle(`gites — ${data.root}`); await getCurrentWindow().setTitle(`gites — ${data.root}`);
return;
} }
repo.value = data; repo.value = data;
} }
@@ -163,8 +199,8 @@ watch(
saveTimer = window.setTimeout(() => { saveTimer = window.setTimeout(() => {
invoke("save_filters", { invoke("save_filters", {
repo: repoRoot, repo: repoRoot,
value: { hidden: filters.hidden, rules: filters.rules, collapseAll: collapseAll.value }, value: { hidden: filters.hidden, rules: filters.rules, hideTags: filters.hideTags, collapseAll: collapseAll.value },
}).catch((e) => showToast(`could not save filters: ${e}`)); }).catch((e) => showToast(`could not save filters: ${e}`, { error: true }));
}, 300); }, 300);
}, },
{ deep: true }, { deep: true },
@@ -262,14 +298,18 @@ watch([selectedHash, () => settings.previewShown], ([hash, shown]) => {
} }
// Wait a moment, so holding a key down does not run git for every row passed. // Wait a moment, so holding a key down does not run git for every row passed.
detailsTimer = window.setTimeout(async () => { detailsTimer = window.setTimeout(async () => {
// A reply that comes after another repository was opened belongs to the old one.
const root = repo.value?.root;
try { try {
const d = await invoke<CommitDetails>("commit_details", { hash }); const d = await invoke<CommitDetails>("commit_details", { hash });
if (repo.value?.root !== root) return;
detailsCache.set(hash, d); detailsCache.set(hash, d);
if (selectedHash.value === hash) { if (selectedHash.value === hash) {
details.value = d; details.value = d;
detailsError.value = null; detailsError.value = null;
} }
} catch (e) { } catch (e) {
if (repo.value?.root !== root) return;
if (selectedHash.value === hash) detailsError.value = String(e); if (selectedHash.value === hash) detailsError.value = String(e);
} }
}, 120); }, 120);
@@ -295,11 +335,15 @@ async function openDiff(file: ChangedFile, force = false) {
return; return;
} }
diffData.value = null; diffData.value = null;
// A reply that comes after another repository was opened belongs to the old one.
const root = repo.value?.root;
try { try {
const d = await invoke<FileDiff>("file_diff", { hash, path: file.path, oldPath: file.old_path, force }); const d = await invoke<FileDiff>("file_diff", { hash, path: file.path, oldPath: file.old_path, force });
if (repo.value?.root !== root) return;
diffCache.set(key, d); diffCache.set(key, d);
if (diffFile.value === file && details.value?.hash === hash) diffData.value = d; if (diffFile.value === file && details.value?.hash === hash) diffData.value = d;
} catch (e) { } catch (e) {
if (repo.value?.root !== root) return;
if (diffFile.value === file) diffError.value = String(e); if (diffFile.value === file) diffError.value = String(e);
} }
} }
@@ -353,10 +397,17 @@ async function copyText(text: string) {
// ---- actions ------------------------------------------------------------- // ---- actions -------------------------------------------------------------
let toastTimer: number | undefined; let toastTimer: number | undefined;
function showToast(text: string) { /** Shows a short message. An error message stays longer, 8 seconds, so there is time to
* read it; a click on any message closes it. */
function showToast(text: string, { error = false } = {}) {
toast.value = text; toast.value = text;
clearTimeout(toastTimer); clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => (toast.value = null), 1800); toastTimer = window.setTimeout(() => (toast.value = null), error ? 8000 : 1800);
}
function closeToast() {
clearTimeout(toastTimer);
toast.value = null;
} }
async function copyHash() { async function copyHash() {
@@ -529,6 +580,14 @@ onUnmounted(() => {
<button @click="menuOpen = true">Open a repository…</button> <button @click="menuOpen = true">Open a repository…</button>
</div> </div>
<Welcome
v-else-if="!repo && started"
:recents="settings.recents"
@pick="openRepo"
@browse="browse"
@remove="(p) => (settings.recents = settings.recents.filter((x) => x !== p))"
/>
<div v-else class="center"> <div v-else class="center">
<DiffView <DiffView
@@ -632,7 +691,7 @@ onUnmounted(() => {
</footer> </footer>
<transition name="fade"> <transition name="fade">
<div v-if="toast" class="toast">{{ toast }}</div> <div v-if="toast" class="toast" @click="closeToast">{{ toast }}</div>
</transition> </transition>
</div> </div>
</template> </template>
@@ -739,6 +798,11 @@ onUnmounted(() => {
padding: 6px 14px; padding: 6px 14px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 6px 24px #0008; box-shadow: 0 6px 24px #0008;
/* Keeps each line of a message on its own line, such as the hint under an ssh error. */
white-space: pre-line;
max-width: min(640px, calc(100vw - 32px));
overflow-wrap: anywhere;
cursor: pointer;
} }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; } .fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; } .fade-enter-from, .fade-leave-to { opacity: 0; }
+10 -1
View File
@@ -46,7 +46,15 @@ function toggleFolder(n: TreeNode) {
<path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" /> <path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" />
</svg> </svg>
<span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span> <span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span>
<EyeButton :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" /> <EyeButton
v-if="kind === 'tag' && filters.hideTags"
class="locked"
:shut="true"
disabled
title="all tags are hidden: use the eye next to Tags"
@click.stop
/>
<EyeButton v-else :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
</div> </div>
<RefTree <RefTree
v-if="n.children.length && !closed.has(key(n))" v-if="n.children.length && !closed.has(key(n))"
@@ -80,4 +88,5 @@ function toggleFolder(n: TreeNode) {
.arrow.open { transform: rotate(90deg); } .arrow.open { transform: rotate(90deg); }
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item:hover :deep(.eye) { opacity: 1; } .item:hover :deep(.eye) { opacity: 1; }
.item :deep(.eye.locked) { opacity: 0.35; cursor: default; }
</style> </style>
+49 -5
View File
@@ -1,29 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, nextTick, onMounted, ref } from "vue";
const props = defineProps<{ recents: string[]; current: string | null }>(); const props = defineProps<{ recents: string[]; current: string | null }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>(); const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
// The current repository is not offered: picking it would do nothing. // The current repository is not offered: picking it would do nothing.
const items = computed(() => props.recents.filter((p) => p !== props.current)); const items = computed(() => props.recents.filter((p) => p !== props.current));
/** Index into items; items.length means the "Open folder…" entry. */ /** Index into items; items.length is the "Open folder…" entry and items.length + 1 the "Open on another machine…" entry. */
const active = ref(0); const active = ref(0);
const menu = ref<HTMLElement>(); const menu = ref<HTMLElement>();
onMounted(() => menu.value?.focus()); onMounted(() => menu.value?.focus());
/** True while the "Open on another machine…" entry is a text field. */
const typing = ref(false);
const remoteText = ref("");
const remoteInput = ref<HTMLInputElement>();
async function startTyping() {
typing.value = true;
await nextTick();
remoteInput.value?.focus();
}
function choose(i: number) { function choose(i: number) {
if (i >= items.value.length) emit("browse"); if (i === items.value.length + 1) void startTyping();
else if (i === items.value.length) emit("browse");
else emit("pick", items.value[i]); else emit("pick", items.value[i]);
} }
function onRemoteKey(e: KeyboardEvent) {
// Typed letters belong to the field, not to the menu's own keys.
e.stopPropagation();
if (e.key === "Enter") {
const text = remoteText.value.trim();
if (text) emit("pick", text);
} else if (e.key === "Escape") {
typing.value = false;
menu.value?.focus();
} else return;
e.preventDefault();
}
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
// Keys stay in the menu while it is open. // Keys stay in the menu while it is open.
e.stopPropagation(); e.stopPropagation();
const last = items.value.length; const last = items.value.length + 1;
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1); if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1); else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
else if (e.key === "Enter") choose(active.value); else if (e.key === "Enter") choose(active.value);
else if (e.key === "Delete" && active.value < last) emit("remove", items.value[active.value]); else if (e.key === "Delete" && active.value < items.value.length) emit("remove", items.value[active.value]);
else if (e.key === "Escape" || e.key === "o") emit("close"); else if (e.key === "Escape" || e.key === "o") emit("close");
else return; else return;
e.preventDefault(); e.preventDefault();
@@ -57,6 +82,23 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
> >
Open folder… Open folder…
</div> </div>
<div
class="item remote"
:class="{ active: active === items.length + 1 }"
@mouseenter="active = items.length + 1"
@click="!typing && choose(items.length + 1)"
>
<input
v-if="typing"
ref="remoteInput"
v-model="remoteText"
class="remote-input"
placeholder="user@host:/path/to/repo"
spellcheck="false"
@keydown="onRemoteKey"
/>
<template v-else>Open on another machine…</template>
</div>
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div> <div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
</div> </div>
</template> </template>
@@ -87,5 +129,7 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
.x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; } .x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; }
.item.active .x { opacity: 1; } .item.active .x { opacity: 1; }
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; } .browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
.remote { color: var(--accent); }
.remote-input { flex: 1; min-width: 0; font: 12px ui-monospace, monospace; }
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); } .foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
</style> </style>
+15 -1
View File
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from "vue";
import { refKind, shortName } from "../lib/filter"; import { refKind, shortName } from "../lib/filter";
import { buildTree, type TreeNode } from "../lib/tree"; import { buildTree, type TreeNode } from "../lib/tree";
import type { FilterState, Ref, RefKind } from "../lib/types"; import type { FilterState, Ref, RefKind } from "../lib/types";
import EyeButton from "./EyeButton.vue";
import RefTree from "./RefTree.vue"; import RefTree from "./RefTree.vue";
const props = defineProps<{ const props = defineProps<{
@@ -98,7 +99,15 @@ function onPatternKey(e: KeyboardEvent) {
</section> </section>
<section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group"> <section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group">
<h3>{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span></h3> <h3>
{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span>
<EyeButton
v-if="g.kind === 'tag'"
:shut="!!filters.hideTags"
:title="filters.hideTags ? 'show all tags' : 'hide all tags, also tags added later'"
@click="filters.hideTags = !filters.hideTags"
/>
</h3>
<RefTree <RefTree
:nodes="trees[g.kind]" :nodes="trees[g.kind]"
:kind="g.kind" :kind="g.kind"
@@ -122,6 +131,9 @@ function onPatternKey(e: KeyboardEvent) {
padding: 10px 0 20px; padding: 10px 0 20px;
} }
h3 { h3 {
display: flex;
align-items: center;
gap: 4px;
margin: 12px 14px 6px; margin: 12px 14px 6px;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
@@ -129,6 +141,8 @@ h3 {
color: var(--text-dim); color: var(--text-dim);
} }
.count { font-weight: normal; opacity: 0.7; } .count { font-weight: normal; opacity: 0.7; }
h3 :deep(.eye) { margin-left: auto; margin-right: -6px; }
h3:hover :deep(.eye) { opacity: 1; }
.rules { padding: 0 14px; } .rules { padding: 0 14px; }
.rules h3 { margin-left: 0; margin-top: 2px; } .rules h3 { margin-left: 0; margin-top: 2px; }
.rules input { width: 100%; } .rules input { width: 100%; }
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
defineProps<{ recents: string[] }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
/** True while the "Open on another machine…" button is a text field. */
const typing = ref(false);
const remoteText = ref("");
const remoteInput = ref<HTMLInputElement>();
async function startTyping() {
typing.value = true;
await nextTick();
remoteInput.value?.focus();
}
function onRemoteKey(e: KeyboardEvent) {
if (e.key === "Enter") {
const text = remoteText.value.trim();
if (text) emit("pick", text);
} else if (e.key === "Escape") typing.value = false;
}
// Paths on Windows use backslashes.
const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
</script>
<template>
<div class="welcome">
<div class="card">
<img src="/logo.svg" class="logo" alt="" />
<h2>Open a repository</h2>
<template v-if="recents.length">
<div class="head">Recent repositories</div>
<div class="list">
<div v-for="p in recents" :key="p" class="item">
<button class="open" :title="`open ${p}`" @click="emit('pick', p)">
<span class="name">{{ name(p) }}</span>
<span class="path">{{ p }}</span>
</button>
<button class="x" title="remove from the list" @click="emit('remove', p)">×</button>
</div>
</div>
</template>
<div class="actions">
<button class="browse" @click="emit('browse')">Choose a folder…</button>
<input
v-if="typing"
ref="remoteInput"
v-model="remoteText"
class="remote-input"
placeholder="user@host:/path/to/repo"
spellcheck="false"
@keydown="onRemoteKey"
/>
<button v-else class="browse" @click="startTyping">Open on another machine…</button>
</div>
<p class="hint">
You can also start gites with a path: <code>gites ~/code/my-project</code>,
or with a repository on another machine: <code>gites myserver:/srv/app</code>
</p>
</div>
</div>
</template>
<style scoped>
.welcome { flex: 1; min-width: 0; display: flex; align-items: flex-start; justify-content: center; overflow: auto; padding: 12vh 24px 24px; }
.card { width: min(560px, 100%); display: flex; flex-direction: column; gap: 12px; }
.logo { width: 112px; height: 112px; align-self: center; }
h2 { margin: 0 0 4px; font-size: 20px; }
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); }
.list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 4px; background: var(--bg-side); }
.item { display: flex; align-items: center; border-radius: 6px; }
.item:hover, .item:focus-within { background: var(--bg-hover); }
.open { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 10px; border: none; padding: 7px 10px; text-align: left; background: none; }
.open:hover { background: none; }
.name { font-weight: 600; flex: none; }
.path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.x { border: none; padding: 2px 10px; color: var(--text-dim); opacity: 0; }
.item:hover .x, .item:focus-within .x { opacity: 1; }
.actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.remote-input { flex: 1; min-width: 220px; font: 12px ui-monospace, monospace; }
.browse { align-self: flex-start; padding: 6px 14px; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
.hint { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; }
.hint code { font-family: ui-monospace, monospace; color: var(--text); }
button:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
</style>
+15
View File
@@ -78,3 +78,18 @@ describe("lineOwners", () => {
expect(owners.get("f")).toBe("refs/heads/feature"); expect(owners.get("f")).toBe("refs/heads/feature");
}); });
}); });
describe("hiding all tags", () => {
const state = { hidden: [], rules: [], hideTags: true };
it("hides every tag", () => {
expect(isRefVisible("refs/tags/v1", state)).toBe(false);
expect(isRefVisible("refs/tags/release/2", state)).toBe(false);
});
it("leaves branches alone", () => {
expect(isRefVisible("refs/heads/main", state)).toBe(true);
expect(isRefVisible("refs/remotes/origin/main", state)).toBe(true);
});
it("shows tags again when turned off", () => {
expect(isRefVisible("refs/tags/v1", { ...state, hideTags: false })).toBe(true);
});
});
+1
View File
@@ -21,6 +21,7 @@ export function globMatch(pattern: string, text: string): boolean {
export function isRefVisible(name: string, state: FilterState): boolean { export function isRefVisible(name: string, state: FilterState): boolean {
if (state.hidden.includes(name)) return false; if (state.hidden.includes(name)) return false;
if (state.hideTags && name.startsWith("refs/tags/")) return false;
const short = shortName(name); const short = shortName(name);
const only = state.rules.filter((r) => r.mode === "only"); const only = state.rules.filter((r) => r.mode === "only");
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false; if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { repoRequests } from "./latest";
describe("repoRequests", () => {
it("applies a reload with no open around", () => {
const r = repoRequests();
const reload = r.startReload();
expect(r.isCurrent(reload)).toBe(true);
});
it("applies a reload that starts after an open has finished", () => {
const r = repoRequests();
const open = r.startOpen();
r.finish(open);
const reload = r.startReload();
expect(r.isCurrent(reload)).toBe(true);
});
it("drops a reload started during a running open, and applies the open", () => {
const r = repoRequests();
const open = r.startOpen();
const reload = r.startReload();
// The reload answers first, while the open is still running.
expect(r.isCurrent(reload)).toBe(false);
expect(r.isCurrent(open)).toBe(true);
r.finish(open);
// Its reply may hold the repository from before the open, so it stays dropped.
expect(r.isCurrent(reload)).toBe(false);
});
it("drops a reload when an open starts after it", () => {
const r = repoRequests();
const reload = r.startReload();
const open = r.startOpen();
expect(r.isCurrent(reload)).toBe(false);
r.finish(open);
expect(r.isCurrent(reload)).toBe(false);
expect(r.isCurrent(open)).toBe(true);
});
it("applies only the newer of two opens", () => {
const r = repoRequests();
const older = r.startOpen();
const newer = r.startOpen();
expect(r.isCurrent(older)).toBe(false);
expect(r.isCurrent(newer)).toBe(true);
});
it("applies an open even when a reload started after it", () => {
const r = repoRequests();
const open = r.startOpen();
r.startReload();
expect(r.isCurrent(open)).toBe(true);
});
it("keeps separate sets of requests apart", () => {
const a = repoRequests();
const b = repoRequests();
const reload = a.startReload();
b.startOpen();
expect(a.isCurrent(reload)).toBe(true);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Decides which replies may put a repository on screen. Git runs away from the window, so
* replies can arrive in another order than the requests went out. Opening a repository wins
* over a reload:
* - a reload's reply is dropped if an open was running when the reload started, if an open
* started after it, or if an open is still running when the reply arrives;
* - an open's reply is dropped only if a newer open started after it.
*/
export type Request = { kind: "open" | "reload"; number: number; duringOpen: boolean };
export interface RepoRequests {
startOpen(): Request;
startReload(): Request;
/** Call when an open has finished, whether its reply was used or not. */
finish(request: Request): void;
isCurrent(request: Request): boolean;
}
export function repoRequests(): RepoRequests {
let counter = 0;
let newestOpen = 0;
const runningOpens = new Set<number>();
return {
startOpen() {
const number = ++counter;
newestOpen = number;
runningOpens.add(number);
return { kind: "open", number, duringOpen: false };
},
startReload() {
return { kind: "reload", number: ++counter, duringOpen: runningOpens.size > 0 };
},
finish(request) {
runningOpens.delete(request.number);
},
isCurrent(request) {
if (request.kind === "open") return request.number === newestOpen;
return !request.duringOpen && newestOpen < request.number && runningOpens.size === 0;
},
};
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { addRecent, MAX_RECENTS } from "./recents"; import { addRecent, MAX_RECENTS, mergeRecents } from "./recents";
describe("addRecent", () => { describe("addRecent", () => {
it("puts the path first", () => { it("puts the path first", () => {
@@ -16,3 +16,12 @@ describe("addRecent", () => {
expect(out).not.toContain(`/r${MAX_RECENTS - 1}`); expect(out).not.toContain(`/r${MAX_RECENTS - 1}`);
}); });
}); });
describe("mergeRecents", () => {
it("keeps the saved order", () => {
expect(mergeRecents([], ["/a", "/b", "/c"])).toEqual(["/a", "/b", "/c"]);
});
it("puts a path opened before the settings arrived first, once", () => {
expect(mergeRecents(["/b"], ["/a", "/b", "/c"])).toEqual(["/b", "/a", "/c"]);
});
});
+8
View File
@@ -4,3 +4,11 @@ export const MAX_RECENTS = 12;
export function addRecent(list: string[], path: string): string[] { export function addRecent(list: string[], path: string): string[] {
return [path, ...list.filter((p) => p !== path)].slice(0, MAX_RECENTS); return [path, ...list.filter((p) => p !== path)].slice(0, MAX_RECENTS);
} }
/**
* Joins the saved list with paths opened before the saved settings arrived: those come
* first, then the saved ones in their saved order, each path once.
*/
export function mergeRecents(openedFirst: string[], saved: string[]): string[] {
return [...openedFirst, ...saved.filter((p) => !openedFirst.includes(p))].slice(0, MAX_RECENTS);
}
+2
View File
@@ -33,6 +33,8 @@ export interface FilterState {
/** Full ref names hidden with the eye toggle. */ /** Full ref names hidden with the eye toggle. */
hidden: string[]; hidden: string[];
rules: FilterRule[]; rules: FilterRule[];
/** Hides every tag, also tags created later. */
hideTags?: boolean;
} }
/** A commit as shown on screen, with its parent edges after collapsing. */ /** A commit as shown on screen, with its parent edges after collapsing. */