Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898135121c | ||
|
|
bd7c129116 | ||
|
|
25308f5f0f | ||
|
|
7b902616fe | ||
|
|
bf6ae3e2bb | ||
|
|
5ef5c1ba94 | ||
|
|
ccf945a067 | ||
|
|
16b0363d07 | ||
|
|
48e5e8c8ce | ||
|
|
7fe5a70c69 | ||
|
|
121fe1bc2f | ||
|
|
157a4063c7 | ||
|
|
ddb05980e0 | ||
|
|
761e528e15 | ||
|
|
e4e597dc5c | ||
|
|
302fe4a4ae | ||
|
|
2b433fb650 |
@@ -23,7 +23,8 @@
|
|||||||
local branches, remote branches and tags, and the checked-out commit is ringed.
|
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
|
- **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
|
with <kbd>h</kbd> on a commit. Type a pattern such as `prototypes/*` and choose
|
||||||
**hide** or **show only**. gites remembers the filters of each repository.
|
**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
|
- **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>
|
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.
|
opens or closes the one at the selected commit.
|
||||||
@@ -40,6 +41,14 @@
|
|||||||
In every layout, the words that changed inside a line are marked.
|
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
|
- **Opens other repositories** from a list of recent ones, or from a folder picker
|
||||||
(<kbd>o</kbd>).
|
(<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.
|
- **Stays current.** gites reloads the repository when its window gets focus again.
|
||||||
|
|
||||||

|

|
||||||
@@ -63,6 +72,7 @@ gites runs the `git` command, so git must be installed and on your `PATH`.
|
|||||||
```sh
|
```sh
|
||||||
gites # the repository in the current folder
|
gites # the repository in the current folder
|
||||||
gites ~/code/my-project # another repository
|
gites ~/code/my-project # another repository
|
||||||
|
gites myserver:/srv/app # a repository on another machine, over ssh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Keys
|
## Keys
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+347
-8
@@ -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,10 +33,122 @@ 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.
|
||||||
|
pub fn ssh_error(target: &str, stderr: &str) -> String {
|
||||||
|
let stderr = stderr.trim();
|
||||||
|
let mut message = format!("could not connect to {target}: {stderr}");
|
||||||
|
if stderr.contains("Host key verification failed") {
|
||||||
|
message.push_str(&format!("\nRun `ssh {target}` once in a terminal to accept the server's key."));
|
||||||
|
}
|
||||||
|
if stderr.contains("Permission denied") {
|
||||||
|
message.push_str(&format!(
|
||||||
|
"\ngites logs in with keys or your ssh agent only; check that `ssh {target}` works without a password."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder for the sockets of shared connections: `runtime`, the user's runtime folder,
|
||||||
|
/// or else a `gites` folder inside `cache`, the user's cache folder, made if missing so that
|
||||||
|
/// only the user can open it. `None` when neither gives a folder; then every command opens
|
||||||
|
/// its own connection.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn socket_dir(runtime: Option<PathBuf>, cache: Option<PathBuf>) -> Option<PathBuf> {
|
||||||
|
if runtime.is_some() {
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
let dir = cache?.join("gites");
|
||||||
|
let mut builder = std::fs::DirBuilder::new();
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||||
|
match builder.create(&dir) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => {}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
// A folder left from before may let others in; only the user may reach the sockets.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).ok()?;
|
||||||
|
}
|
||||||
|
Some(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The start of every socket's file name; ssh puts `%C`, a hash of the connection, after it.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
const SOCKET_PREFIX: &str = "gites-";
|
||||||
|
|
||||||
|
/// The longest socket path macOS accepts (Linux accepts 107). One limit for every system
|
||||||
|
/// keeps one rule.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
const MAX_SOCKET_PATH: usize = 103;
|
||||||
|
|
||||||
|
/// True when sockets in `dir` stay short enough for the system. ssh puts in place of `%C` a
|
||||||
|
/// hash of 40 characters, and while it sets up a shared connection it adds a temporary end of
|
||||||
|
/// up to 18 characters to the file name.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn socket_path_fits(dir: &std::path::Path) -> bool {
|
||||||
|
const HASH: usize = 40;
|
||||||
|
const TEMPORARY_END: usize = 18;
|
||||||
|
// The 1 is the `/` between the folder and the file name.
|
||||||
|
dir.as_os_str().len() + 1 + SOCKET_PREFIX.len() + HASH + TEMPORARY_END <= MAX_SOCKET_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path ssh is given for the socket of shared connections, or `None` to share none: when
|
||||||
|
/// there is no folder for sockets, or the path would be too long.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn control_path(dir: Option<PathBuf>) -> Option<PathBuf> {
|
||||||
|
dir.filter(|d| socket_path_fits(d)).map(|d| d.join(format!("{SOCKET_PREFIX}%C")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs git in the folder `loc` names: here, or on another machine through ssh.
|
||||||
|
pub fn git(loc: &Location, args: &[&str]) -> Result<String, String> {
|
||||||
|
// Tests swap in a script that acts like ssh.
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.with_borrow(|fake| fake.clone()) {
|
||||||
|
return git_via(&fake, loc, args);
|
||||||
|
}
|
||||||
|
git_via(&[OsString::from("ssh")], loc, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the work of `git`. For a remote location it starts `ssh`: the first word of `ssh` is
|
||||||
|
/// the program and the other words come before the ssh options (tests use them).
|
||||||
|
pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result<String, String> {
|
||||||
|
let mut cmd = match loc {
|
||||||
|
Location::Local(dir) => {
|
||||||
let mut cmd = Command::new("git");
|
let mut cmd = Command::new("git");
|
||||||
cmd.arg("-C").arg(dir).args(args);
|
cmd.arg("-C").arg(dir).args(args);
|
||||||
// gites has no console, so Windows would open a console window for every git it
|
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.
|
// starts. Each one flashes on screen and takes the focus, and gites reloads on focus.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -42,16 +156,25 @@ pub fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
|||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let out = cmd.output().map_err(|e| format!("could not run git: {e}"))?;
|
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()
|
||||||
@@ -130,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
@@ -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())
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Where a repository lives: a folder on this machine, or a folder on another machine
|
||||||
|
//! reached over ssh.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Location {
|
||||||
|
Local(PathBuf),
|
||||||
|
/// `target` is what is given to ssh, like `host` or `user@host`.
|
||||||
|
Ssh { target: String, path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `[user@]host:path` as a remote location. Everything else is local: text where the
|
||||||
|
/// part before the first `:` is a single character (a Windows drive like `C:`) or contains
|
||||||
|
/// `/` or `\`, text starting with `-` (ssh would read it as an option), and text that is the whole name of a folder that exists inside `cwd`.
|
||||||
|
pub fn parse(text: &str, cwd: &Path) -> Location {
|
||||||
|
let local = || Location::Local(PathBuf::from(text));
|
||||||
|
let Some((target, path)) = text.split_once(':') else { return local() };
|
||||||
|
if target.chars().count() <= 1 || target.starts_with('-') || target.contains('/') || target.contains('\\') || cwd.join(text).exists() {
|
||||||
|
return local();
|
||||||
|
}
|
||||||
|
Location::Ssh { target: target.to_string(), path: path.to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Location {
|
||||||
|
/// The text the user would type to open this location.
|
||||||
|
pub fn name(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Location::Local(path) => path.to_string_lossy().into_owned(),
|
||||||
|
Location::Ssh { target, path } => format!("{target}:{path}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same machine with another folder.
|
||||||
|
pub fn with_path(&self, path: &str) -> Location {
|
||||||
|
match self {
|
||||||
|
Location::Local(_) => Location::Local(PathBuf::from(path)),
|
||||||
|
Location::Ssh { target, .. } => Location::Ssh { target: target.clone(), path: path.to_string() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps one word in single quotes so a shell reads it back unchanged.
|
||||||
|
pub fn quote(word: &str) -> String {
|
||||||
|
format!("'{}'", word.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The command for a shell that reads quotes the standard way: `git -C <path>` followed by
|
||||||
|
/// `args`. A path that is `~` or starts with `~/` keeps the `~` outside the quotes so the
|
||||||
|
/// shell still replaces it with the home folder.
|
||||||
|
pub fn sh_command(path: &str, args: &[&str]) -> String {
|
||||||
|
let path = match path.strip_prefix("~/") {
|
||||||
|
Some(rest) => format!("~/{}", quote(rest)),
|
||||||
|
None if path == "~" => "~".to_string(),
|
||||||
|
None => quote(path),
|
||||||
|
};
|
||||||
|
let mut command = format!("git -C {path}");
|
||||||
|
for arg in args {
|
||||||
|
command.push(' ');
|
||||||
|
command.push_str("e(arg));
|
||||||
|
}
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a command so every login shell on the server (sh, bash, zsh, fish) runs it the same
|
||||||
|
/// way. Fish reads quotes differently from the others, so the command is not sent as written:
|
||||||
|
/// every byte becomes a backslash and three octal digits, which no shell changes inside single
|
||||||
|
/// quotes, and `printf` turns them back into the command for `sh` to run.
|
||||||
|
pub fn shell_neutral(sh_command: &str) -> String {
|
||||||
|
let mut wrapped = String::from("printf '");
|
||||||
|
for byte in sh_command.bytes() {
|
||||||
|
wrapped.push_str(&format!("\\{byte:03o}"));
|
||||||
|
}
|
||||||
|
wrapped.push_str("' | sh");
|
||||||
|
wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The command line to give to ssh to run `git -C <path> <args>` on the remote machine.
|
||||||
|
pub fn remote_command(path: &str, args: &[&str]) -> String {
|
||||||
|
shell_neutral(&sh_command(path, args))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn cwd() -> PathBuf {
|
||||||
|
std::env::temp_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_remote_locations() {
|
||||||
|
assert_eq!(
|
||||||
|
parse("myserver:/srv/app", &cwd()),
|
||||||
|
Location::Ssh { target: "myserver".into(), path: "/srv/app".into() }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse("ada@myserver:~/x", &cwd()),
|
||||||
|
Location::Ssh { target: "ada@myserver".into(), path: "~/x".into() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_local_paths_local() {
|
||||||
|
for text in [r"C:\code\x", "C:/code/x", "./a:b", "relative/dir", "/abs/dir"] {
|
||||||
|
assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_a_target_that_ssh_would_read_as_an_option_local() {
|
||||||
|
let text = "-oProxyCommand=x:y";
|
||||||
|
assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_an_existing_local_folder_with_a_colon_local() {
|
||||||
|
let temp = std::env::temp_dir().join(format!("gites-location-{}", std::process::id()));
|
||||||
|
let folder = temp.join("notes:2024");
|
||||||
|
std::fs::create_dir_all(&folder).unwrap();
|
||||||
|
assert_eq!(parse("notes:2024", &temp), Location::Local(PathBuf::from("notes:2024")));
|
||||||
|
std::fs::remove_dir_all(&folder).unwrap();
|
||||||
|
assert_eq!(parse("notes:2024", &temp), Location::Ssh { target: "notes".into(), path: "2024".into() });
|
||||||
|
std::fs::remove_dir_all(&temp).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_locations() {
|
||||||
|
let remote = Location::Ssh { target: "myserver".into(), path: "/srv/app".into() };
|
||||||
|
assert_eq!(remote.name(), "myserver:/srv/app");
|
||||||
|
assert_eq!(Location::Local(PathBuf::from("/abs/dir")).name(), "/abs/dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changes_only_the_path() {
|
||||||
|
let remote = Location::Ssh { target: "ada@myserver".into(), path: "/srv/app".into() };
|
||||||
|
assert_eq!(remote.with_path("/srv/other"), Location::Ssh { target: "ada@myserver".into(), path: "/srv/other".into() });
|
||||||
|
assert_eq!(Location::Local(PathBuf::from("/a")).with_path("/b"), Location::Local(PathBuf::from("/b")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quoted_words_survive_a_shell() {
|
||||||
|
for word in ["plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "line\nbreak"] {
|
||||||
|
let out = Command::new("sh").arg("-c").arg(format!("printf %s {}", quote(word))).output().unwrap();
|
||||||
|
assert_eq!(String::from_utf8(out.stdout).unwrap(), word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrapped_words_survive_every_login_shell() {
|
||||||
|
let words = [
|
||||||
|
"plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "%s", "line\nbreak", "a\\b", "\\",
|
||||||
|
"a\\'b", "x\\",
|
||||||
|
];
|
||||||
|
let mut shells_run = Vec::new();
|
||||||
|
for shell in ["sh", "bash", "zsh", "fish"] {
|
||||||
|
if Command::new(shell).arg("-c").arg("true").output().is_err() {
|
||||||
|
assert_ne!(shell, "sh", "sh must be installed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shells_run.push(shell);
|
||||||
|
for word in words {
|
||||||
|
let wrapper = shell_neutral(&format!("printf %s {}", quote(word)));
|
||||||
|
let out = Command::new(shell).arg("-c").arg(&wrapper).output().unwrap();
|
||||||
|
assert_eq!(String::from_utf8(out.stdout).unwrap(), word, "{shell} with {word:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(shells_run.contains(&"sh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_remote_command_uses_only_plain_characters() {
|
||||||
|
let command = remote_command("~/x y", &["status", "it's"]);
|
||||||
|
assert!(command.starts_with("printf '") && command.ends_with("' | sh"));
|
||||||
|
assert!(command.chars().all(|c| "printf'\\0123456789 |sh".contains(c)), "{command}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_the_remote_command() {
|
||||||
|
assert_eq!(
|
||||||
|
sh_command("/srv/my app", &["log", "--format=%H"]),
|
||||||
|
"git -C '/srv/my app' 'log' '--format=%H'"
|
||||||
|
);
|
||||||
|
assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'");
|
||||||
|
assert_eq!(sh_command("~", &["status"]), "git -C ~ 'status'");
|
||||||
|
// Only the home folder of the user logged in is left for the shell; `~other` is not.
|
||||||
|
assert_eq!(sh_command("~other", &["status"]), "git -C '~other' 'status'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! A stand-in for ssh, so tests can run the remote code path without a server.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// The words `git::git` starts instead of `ssh` in the test running on this thread: a
|
||||||
|
/// program, then its arguments. Each test runs on its own thread, so one test's stand-in
|
||||||
|
/// never reaches another test.
|
||||||
|
pub static SSH_OVERRIDE: RefCell<Option<Vec<OsString>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shell script that acts like ssh. It checks the arguments the way a real ssh would
|
||||||
|
/// need them (so a wrong argument list fails a test), skips the options and the target,
|
||||||
|
/// then runs the command on this machine.
|
||||||
|
const SCRIPT: &str = r#"
|
||||||
|
ok=
|
||||||
|
while [ "$1" = "-o" ]; do
|
||||||
|
[ "$2" = "BatchMode=yes" ] && ok=1
|
||||||
|
shift 2
|
||||||
|
done
|
||||||
|
[ -n "$ok" ] || { echo "fake ssh: BatchMode=yes is missing" >&2; exit 98; }
|
||||||
|
[ "$1" = "--" ] || { echo "fake ssh: expected -- before the target" >&2; exit 99; }
|
||||||
|
shift 2
|
||||||
|
exec sh -c "$1"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Makes `git::git` use the stand-in for the rest of the calling test. No file is written:
|
||||||
|
/// the script is given to `sh` on its command line.
|
||||||
|
pub fn fake_ssh() {
|
||||||
|
SSH_OVERRIDE.set(Some(["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `fake_ssh`, but the command runs with `home` as its home folder, the way a real
|
||||||
|
/// server would run it in the user's home. Only the stand-in's own shell sees the new home.
|
||||||
|
pub fn fake_ssh_with_home(home: &Path) {
|
||||||
|
let script = format!("HOME=\"$1\"; export HOME; shift\n{SCRIPT}");
|
||||||
|
let mut words: Vec<OsString> = ["sh", "-c", &script, "fake-ssh"].map(OsString::from).to_vec();
|
||||||
|
words.push(home.as_os_str().to_owned());
|
||||||
|
SSH_OVERRIDE.set(Some(words));
|
||||||
|
}
|
||||||
+73
-16
@@ -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 },
|
||||||
@@ -106,27 +105,48 @@ function startDrag(e: PointerEvent, axis: "x" | "y") {
|
|||||||
let filtersLoadedFor: string | null = null;
|
let filtersLoadedFor: string | null = null;
|
||||||
|
|
||||||
let loading = false;
|
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.
|
// The window gets focus often; one reload at a time is enough.
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
loading = true;
|
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 {
|
} finally {
|
||||||
loading = false;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,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();
|
||||||
@@ -155,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;
|
||||||
}
|
}
|
||||||
@@ -170,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 },
|
||||||
@@ -269,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);
|
||||||
@@ -302,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,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() {
|
||||||
@@ -536,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
|
||||||
@@ -639,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>
|
||||||
@@ -746,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; }
|
||||||
|
|||||||
@@ -46,7 +46,15 @@ function toggleFolder(n: TreeNode) {
|
|||||||
<path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" />
|
<path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span>
|
<span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span>
|
||||||
<EyeButton :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
|
<EyeButton
|
||||||
|
v-if="kind === 'tag' && filters.hideTags"
|
||||||
|
class="locked"
|
||||||
|
:shut="true"
|
||||||
|
disabled
|
||||||
|
title="all tags are hidden: use the eye next to Tags"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<EyeButton v-else :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
|
||||||
</div>
|
</div>
|
||||||
<RefTree
|
<RefTree
|
||||||
v-if="n.children.length && !closed.has(key(n))"
|
v-if="n.children.length && !closed.has(key(n))"
|
||||||
@@ -80,4 +88,5 @@ function toggleFolder(n: TreeNode) {
|
|||||||
.arrow.open { transform: rotate(90deg); }
|
.arrow.open { transform: rotate(90deg); }
|
||||||
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.item:hover :deep(.eye) { opacity: 1; }
|
.item:hover :deep(.eye) { opacity: 1; }
|
||||||
|
.item :deep(.eye.locked) { opacity: 0.35; cursor: default; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,29 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, nextTick, onMounted, ref } from "vue";
|
||||||
|
|
||||||
const props = defineProps<{ recents: string[]; current: string | null }>();
|
const props = defineProps<{ recents: string[]; current: string | null }>();
|
||||||
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
|
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
|
||||||
|
|
||||||
// The current repository is not offered: picking it would do nothing.
|
// The current repository is not offered: picking it would do nothing.
|
||||||
const items = computed(() => props.recents.filter((p) => p !== props.current));
|
const items = computed(() => props.recents.filter((p) => p !== props.current));
|
||||||
/** Index into items; items.length means the "Open folder…" entry. */
|
/** Index into items; items.length is the "Open folder…" entry and items.length + 1 the "Open on another machine…" entry. */
|
||||||
const active = ref(0);
|
const active = ref(0);
|
||||||
const menu = ref<HTMLElement>();
|
const menu = ref<HTMLElement>();
|
||||||
onMounted(() => menu.value?.focus());
|
onMounted(() => menu.value?.focus());
|
||||||
|
|
||||||
|
/** True while the "Open on another machine…" entry is a text field. */
|
||||||
|
const typing = ref(false);
|
||||||
|
const remoteText = ref("");
|
||||||
|
const remoteInput = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
async function startTyping() {
|
||||||
|
typing.value = true;
|
||||||
|
await nextTick();
|
||||||
|
remoteInput.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function choose(i: number) {
|
function choose(i: number) {
|
||||||
if (i >= items.value.length) emit("browse");
|
if (i === items.value.length + 1) void startTyping();
|
||||||
|
else if (i === items.value.length) emit("browse");
|
||||||
else emit("pick", items.value[i]);
|
else emit("pick", items.value[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onRemoteKey(e: KeyboardEvent) {
|
||||||
|
// Typed letters belong to the field, not to the menu's own keys.
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const text = remoteText.value.trim();
|
||||||
|
if (text) emit("pick", text);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
typing.value = false;
|
||||||
|
menu.value?.focus();
|
||||||
|
} else return;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
// Keys stay in the menu while it is open.
|
// Keys stay in the menu while it is open.
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const last = items.value.length;
|
const last = items.value.length + 1;
|
||||||
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
|
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
|
||||||
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
|
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
|
||||||
else if (e.key === "Enter") choose(active.value);
|
else if (e.key === "Enter") choose(active.value);
|
||||||
else if (e.key === "Delete" && active.value < last) emit("remove", items.value[active.value]);
|
else if (e.key === "Delete" && active.value < items.value.length) emit("remove", items.value[active.value]);
|
||||||
else if (e.key === "Escape" || e.key === "o") emit("close");
|
else if (e.key === "Escape" || e.key === "o") emit("close");
|
||||||
else return;
|
else return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -57,6 +82,23 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
|||||||
>
|
>
|
||||||
Open folder…
|
Open folder…
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="item remote"
|
||||||
|
:class="{ active: active === items.length + 1 }"
|
||||||
|
@mouseenter="active = items.length + 1"
|
||||||
|
@click="!typing && choose(items.length + 1)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-if="typing"
|
||||||
|
ref="remoteInput"
|
||||||
|
v-model="remoteText"
|
||||||
|
class="remote-input"
|
||||||
|
placeholder="user@host:/path/to/repo"
|
||||||
|
spellcheck="false"
|
||||||
|
@keydown="onRemoteKey"
|
||||||
|
/>
|
||||||
|
<template v-else>Open on another machine…</template>
|
||||||
|
</div>
|
||||||
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
|
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -87,5 +129,7 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
|||||||
.x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; }
|
.x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; }
|
||||||
.item.active .x { opacity: 1; }
|
.item.active .x { opacity: 1; }
|
||||||
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
|
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
|
||||||
|
.remote { color: var(--accent); }
|
||||||
|
.remote-input { flex: 1; min-width: 0; font: 12px ui-monospace, monospace; }
|
||||||
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
|
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from "vue";
|
|||||||
import { refKind, shortName } from "../lib/filter";
|
import { refKind, shortName } from "../lib/filter";
|
||||||
import { buildTree, type TreeNode } from "../lib/tree";
|
import { buildTree, type TreeNode } from "../lib/tree";
|
||||||
import type { FilterState, Ref, RefKind } from "../lib/types";
|
import type { FilterState, Ref, RefKind } from "../lib/types";
|
||||||
|
import EyeButton from "./EyeButton.vue";
|
||||||
import RefTree from "./RefTree.vue";
|
import RefTree from "./RefTree.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -98,7 +99,15 @@ function onPatternKey(e: KeyboardEvent) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group">
|
<section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group">
|
||||||
<h3>{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span></h3>
|
<h3>
|
||||||
|
{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span>
|
||||||
|
<EyeButton
|
||||||
|
v-if="g.kind === 'tag'"
|
||||||
|
:shut="!!filters.hideTags"
|
||||||
|
:title="filters.hideTags ? 'show all tags' : 'hide all tags, also tags added later'"
|
||||||
|
@click="filters.hideTags = !filters.hideTags"
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
<RefTree
|
<RefTree
|
||||||
:nodes="trees[g.kind]"
|
:nodes="trees[g.kind]"
|
||||||
:kind="g.kind"
|
:kind="g.kind"
|
||||||
@@ -122,6 +131,9 @@ function onPatternKey(e: KeyboardEvent) {
|
|||||||
padding: 10px 0 20px;
|
padding: 10px 0 20px;
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
margin: 12px 14px 6px;
|
margin: 12px 14px 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -129,6 +141,8 @@ h3 {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
.count { font-weight: normal; opacity: 0.7; }
|
.count { font-weight: normal; opacity: 0.7; }
|
||||||
|
h3 :deep(.eye) { margin-left: auto; margin-right: -6px; }
|
||||||
|
h3:hover :deep(.eye) { opacity: 1; }
|
||||||
.rules { padding: 0 14px; }
|
.rules { padding: 0 14px; }
|
||||||
.rules h3 { margin-left: 0; margin-top: 2px; }
|
.rules h3 { margin-left: 0; margin-top: 2px; }
|
||||||
.rules input { width: 100%; }
|
.rules input { width: 100%; }
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref } from "vue";
|
||||||
|
|
||||||
|
defineProps<{ recents: string[] }>();
|
||||||
|
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
|
||||||
|
|
||||||
|
/** True while the "Open on another machine…" button is a text field. */
|
||||||
|
const typing = ref(false);
|
||||||
|
const remoteText = ref("");
|
||||||
|
const remoteInput = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
async function startTyping() {
|
||||||
|
typing.value = true;
|
||||||
|
await nextTick();
|
||||||
|
remoteInput.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemoteKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const text = remoteText.value.trim();
|
||||||
|
if (text) emit("pick", text);
|
||||||
|
} else if (e.key === "Escape") typing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths on Windows use backslashes.
|
||||||
|
const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="welcome">
|
||||||
|
<div class="card">
|
||||||
|
<img src="/logo.svg" class="logo" alt="" />
|
||||||
|
<h2>Open a repository</h2>
|
||||||
|
<template v-if="recents.length">
|
||||||
|
<div class="head">Recent repositories</div>
|
||||||
|
<div class="list">
|
||||||
|
<div v-for="p in recents" :key="p" class="item">
|
||||||
|
<button class="open" :title="`open ${p}`" @click="emit('pick', p)">
|
||||||
|
<span class="name">{{ name(p) }}</span>
|
||||||
|
<span class="path">{{ p }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="x" title="remove from the list" @click="emit('remove', p)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="browse" @click="emit('browse')">Choose a folder…</button>
|
||||||
|
<input
|
||||||
|
v-if="typing"
|
||||||
|
ref="remoteInput"
|
||||||
|
v-model="remoteText"
|
||||||
|
class="remote-input"
|
||||||
|
placeholder="user@host:/path/to/repo"
|
||||||
|
spellcheck="false"
|
||||||
|
@keydown="onRemoteKey"
|
||||||
|
/>
|
||||||
|
<button v-else class="browse" @click="startTyping">Open on another machine…</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
You can also start gites with a path: <code>gites ~/code/my-project</code>,
|
||||||
|
or with a repository on another machine: <code>gites myserver:/srv/app</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.welcome { flex: 1; min-width: 0; display: flex; align-items: flex-start; justify-content: center; overflow: auto; padding: 12vh 24px 24px; }
|
||||||
|
.card { width: min(560px, 100%); display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.logo { width: 112px; height: 112px; align-self: center; }
|
||||||
|
h2 { margin: 0 0 4px; font-size: 20px; }
|
||||||
|
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); }
|
||||||
|
.list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 4px; background: var(--bg-side); }
|
||||||
|
.item { display: flex; align-items: center; border-radius: 6px; }
|
||||||
|
.item:hover, .item:focus-within { background: var(--bg-hover); }
|
||||||
|
.open { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 10px; border: none; padding: 7px 10px; text-align: left; background: none; }
|
||||||
|
.open:hover { background: none; }
|
||||||
|
.name { font-weight: 600; flex: none; }
|
||||||
|
.path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.x { border: none; padding: 2px 10px; color: var(--text-dim); opacity: 0; }
|
||||||
|
.item:hover .x, .item:focus-within .x { opacity: 1; }
|
||||||
|
.actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.remote-input { flex: 1; min-width: 220px; font: 12px ui-monospace, monospace; }
|
||||||
|
.browse { align-self: flex-start; padding: 6px 14px; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
|
||||||
|
.hint { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; }
|
||||||
|
.hint code { font-family: ui-monospace, monospace; color: var(--text); }
|
||||||
|
button:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
||||||
|
</style>
|
||||||
@@ -78,3 +78,18 @@ describe("lineOwners", () => {
|
|||||||
expect(owners.get("f")).toBe("refs/heads/feature");
|
expect(owners.get("f")).toBe("refs/heads/feature");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hiding all tags", () => {
|
||||||
|
const state = { hidden: [], rules: [], hideTags: true };
|
||||||
|
it("hides every tag", () => {
|
||||||
|
expect(isRefVisible("refs/tags/v1", state)).toBe(false);
|
||||||
|
expect(isRefVisible("refs/tags/release/2", state)).toBe(false);
|
||||||
|
});
|
||||||
|
it("leaves branches alone", () => {
|
||||||
|
expect(isRefVisible("refs/heads/main", state)).toBe(true);
|
||||||
|
expect(isRefVisible("refs/remotes/origin/main", state)).toBe(true);
|
||||||
|
});
|
||||||
|
it("shows tags again when turned off", () => {
|
||||||
|
expect(isRefVisible("refs/tags/v1", { ...state, hideTags: false })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function globMatch(pattern: string, text: string): boolean {
|
|||||||
|
|
||||||
export function isRefVisible(name: string, state: FilterState): boolean {
|
export function isRefVisible(name: string, state: FilterState): boolean {
|
||||||
if (state.hidden.includes(name)) return false;
|
if (state.hidden.includes(name)) return false;
|
||||||
|
if (state.hideTags && name.startsWith("refs/tags/")) return false;
|
||||||
const short = shortName(name);
|
const short = shortName(name);
|
||||||
const only = state.rules.filter((r) => r.mode === "only");
|
const only = state.rules.filter((r) => r.mode === "only");
|
||||||
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
|
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repoRequests } from "./latest";
|
||||||
|
|
||||||
|
describe("repoRequests", () => {
|
||||||
|
it("applies a reload with no open around", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const reload = r.startReload();
|
||||||
|
expect(r.isCurrent(reload)).toBe(true);
|
||||||
|
});
|
||||||
|
it("applies a reload that starts after an open has finished", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const open = r.startOpen();
|
||||||
|
r.finish(open);
|
||||||
|
const reload = r.startReload();
|
||||||
|
expect(r.isCurrent(reload)).toBe(true);
|
||||||
|
});
|
||||||
|
it("drops a reload started during a running open, and applies the open", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const open = r.startOpen();
|
||||||
|
const reload = r.startReload();
|
||||||
|
// The reload answers first, while the open is still running.
|
||||||
|
expect(r.isCurrent(reload)).toBe(false);
|
||||||
|
expect(r.isCurrent(open)).toBe(true);
|
||||||
|
r.finish(open);
|
||||||
|
// Its reply may hold the repository from before the open, so it stays dropped.
|
||||||
|
expect(r.isCurrent(reload)).toBe(false);
|
||||||
|
});
|
||||||
|
it("drops a reload when an open starts after it", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const reload = r.startReload();
|
||||||
|
const open = r.startOpen();
|
||||||
|
expect(r.isCurrent(reload)).toBe(false);
|
||||||
|
r.finish(open);
|
||||||
|
expect(r.isCurrent(reload)).toBe(false);
|
||||||
|
expect(r.isCurrent(open)).toBe(true);
|
||||||
|
});
|
||||||
|
it("applies only the newer of two opens", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const older = r.startOpen();
|
||||||
|
const newer = r.startOpen();
|
||||||
|
expect(r.isCurrent(older)).toBe(false);
|
||||||
|
expect(r.isCurrent(newer)).toBe(true);
|
||||||
|
});
|
||||||
|
it("applies an open even when a reload started after it", () => {
|
||||||
|
const r = repoRequests();
|
||||||
|
const open = r.startOpen();
|
||||||
|
r.startReload();
|
||||||
|
expect(r.isCurrent(open)).toBe(true);
|
||||||
|
});
|
||||||
|
it("keeps separate sets of requests apart", () => {
|
||||||
|
const a = repoRequests();
|
||||||
|
const b = repoRequests();
|
||||||
|
const reload = a.startReload();
|
||||||
|
b.startOpen();
|
||||||
|
expect(a.isCurrent(reload)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Decides which replies may put a repository on screen. Git runs away from the window, so
|
||||||
|
* replies can arrive in another order than the requests went out. Opening a repository wins
|
||||||
|
* over a reload:
|
||||||
|
* - a reload's reply is dropped if an open was running when the reload started, if an open
|
||||||
|
* started after it, or if an open is still running when the reply arrives;
|
||||||
|
* - an open's reply is dropped only if a newer open started after it.
|
||||||
|
*/
|
||||||
|
export type Request = { kind: "open" | "reload"; number: number; duringOpen: boolean };
|
||||||
|
|
||||||
|
export interface RepoRequests {
|
||||||
|
startOpen(): Request;
|
||||||
|
startReload(): Request;
|
||||||
|
/** Call when an open has finished, whether its reply was used or not. */
|
||||||
|
finish(request: Request): void;
|
||||||
|
isCurrent(request: Request): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repoRequests(): RepoRequests {
|
||||||
|
let counter = 0;
|
||||||
|
let newestOpen = 0;
|
||||||
|
const runningOpens = new Set<number>();
|
||||||
|
return {
|
||||||
|
startOpen() {
|
||||||
|
const number = ++counter;
|
||||||
|
newestOpen = number;
|
||||||
|
runningOpens.add(number);
|
||||||
|
return { kind: "open", number, duringOpen: false };
|
||||||
|
},
|
||||||
|
startReload() {
|
||||||
|
return { kind: "reload", number: ++counter, duringOpen: runningOpens.size > 0 };
|
||||||
|
},
|
||||||
|
finish(request) {
|
||||||
|
runningOpens.delete(request.number);
|
||||||
|
},
|
||||||
|
isCurrent(request) {
|
||||||
|
if (request.kind === "open") return request.number === newestOpen;
|
||||||
|
return !request.duringOpen && newestOpen < request.number && runningOpens.size === 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { addRecent, MAX_RECENTS } from "./recents";
|
import { addRecent, MAX_RECENTS, mergeRecents } from "./recents";
|
||||||
|
|
||||||
describe("addRecent", () => {
|
describe("addRecent", () => {
|
||||||
it("puts the path first", () => {
|
it("puts the path first", () => {
|
||||||
@@ -16,3 +16,12 @@ describe("addRecent", () => {
|
|||||||
expect(out).not.toContain(`/r${MAX_RECENTS - 1}`);
|
expect(out).not.toContain(`/r${MAX_RECENTS - 1}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mergeRecents", () => {
|
||||||
|
it("keeps the saved order", () => {
|
||||||
|
expect(mergeRecents([], ["/a", "/b", "/c"])).toEqual(["/a", "/b", "/c"]);
|
||||||
|
});
|
||||||
|
it("puts a path opened before the settings arrived first, once", () => {
|
||||||
|
expect(mergeRecents(["/b"], ["/a", "/b", "/c"])).toEqual(["/b", "/a", "/c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,3 +4,11 @@ export const MAX_RECENTS = 12;
|
|||||||
export function addRecent(list: string[], path: string): string[] {
|
export function addRecent(list: string[], path: string): string[] {
|
||||||
return [path, ...list.filter((p) => p !== path)].slice(0, MAX_RECENTS);
|
return [path, ...list.filter((p) => p !== path)].slice(0, MAX_RECENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the saved list with paths opened before the saved settings arrived: those come
|
||||||
|
* first, then the saved ones in their saved order, each path once.
|
||||||
|
*/
|
||||||
|
export function mergeRecents(openedFirst: string[], saved: string[]): string[] {
|
||||||
|
return [...openedFirst, ...saved.filter((p) => !openedFirst.includes(p))].slice(0, MAX_RECENTS);
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface FilterState {
|
|||||||
/** Full ref names hidden with the eye toggle. */
|
/** Full ref names hidden with the eye toggle. */
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
rules: FilterRule[];
|
rules: FilterRule[];
|
||||||
|
/** Hides every tag, also tags created later. */
|
||||||
|
hideTags?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A commit as shown on screen, with its parent edges after collapsing. */
|
/** A commit as shown on screen, with its parent edges after collapsing. */
|
||||||
|
|||||||
Reference in New Issue
Block a user