Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e597dc5c | ||
|
|
302fe4a4ae | ||
|
|
2b433fb650 | ||
|
|
394d92e756 | ||
|
|
e6c70554f3 | ||
|
|
432fe58563 |
@@ -14,6 +14,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The same tool cache as in release.yaml: the same key and the same folders, or
|
||||
# neither job can use what the other saved.
|
||||
- name: Restore tool cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -24,7 +26,10 @@ jobs:
|
||||
~/.rustup
|
||||
~/.local/bin/mise
|
||||
~/.local/share/mise
|
||||
key: tools-check-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
||||
key: tools-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
||||
# After a change to mise.toml, start from the last tools; mise installs only what changed.
|
||||
restore-keys: |
|
||||
tools-
|
||||
- name: Restore build cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
@@ -24,10 +24,11 @@ jobs:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The same tool cache as in ci.yaml: the same key and the same folders, or
|
||||
# neither job can use what the other saved.
|
||||
- name: Restore tool cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
# ~/.cache/cargo-xwin holds the Windows SDK files cargo-xwin downloads.
|
||||
path: |
|
||||
~/.cargo/bin
|
||||
~/.cargo/registry
|
||||
@@ -35,8 +36,22 @@ jobs:
|
||||
~/.rustup
|
||||
~/.local/bin/mise
|
||||
~/.local/share/mise
|
||||
key: tools-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
||||
# After a change to mise.toml, start from the last tools; mise installs only what changed.
|
||||
restore-keys: |
|
||||
tools-
|
||||
# cargo-xwin, which takes minutes to compile, and the Windows SDK files it downloads.
|
||||
# The Windows Rust target is left out: rustup lists its installed parts in a file in
|
||||
# the tool cache, and refuses to install the target over files that list does not name.
|
||||
- name: Restore cargo-xwin cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/cargo-xwin
|
||||
~/.cache/cargo-xwin
|
||||
key: tools-release-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
||||
key: cargo-xwin-${{ hashFiles('scripts/ci-setup.sh', 'mise.toml') }}
|
||||
restore-keys: |
|
||||
cargo-xwin-
|
||||
- name: Restore build cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<p align="center">
|
||||
<img src="logo.svg" width="96" alt="">
|
||||
</p>
|
||||
|
||||
<h1 align="center">gites</h1>
|
||||
|
||||
<p align="center">
|
||||
A colorful viewer for the history of a git repository.<br>
|
||||
Linux and Windows desktop app, driven from the keyboard.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://git.wrobel.it/szaman/gites/releases">Download</a> ·
|
||||
<a href="#keys">Keys</a> ·
|
||||
<a href="#build-from-source">Build from source</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
- **Draws the commit graph.** Every branch gets its own color. Labels on each commit show
|
||||
local branches, remote branches and tags, and the checked-out commit is ringed.
|
||||
- **Hides what you don't need.** Hide one branch with its eye button in the sidebar, or
|
||||
with <kbd>h</kbd> on a commit. Type a pattern such as `prototypes/*` and choose
|
||||
**hide** or **show only**. The eye next to **Tags** hides all tags, also tags added
|
||||
later. gites remembers the filters of each repository.
|
||||
- **Folds long straight stretches.** Commits in a row that have no branch, tag, fork or
|
||||
merge fold into one dotted line. <kbd>c</kbd> folds all of them, <kbd>space</kbd>
|
||||
opens or closes the one at the selected commit.
|
||||
- **Shows the selected commit** in a panel at the bottom: the full message, the author
|
||||
and committer, the hash (click to copy), the parents (click to go there), and the
|
||||
files it changed with the number of added and removed lines.
|
||||
- **Shows what changed in a file.** Click a file in the panel, or press <kbd>Enter</kbd>
|
||||
for the first one. Choose one of three layouts with <kbd>v</kbd>:
|
||||
- **One column**, as `git diff` prints it.
|
||||
- **Side by side**, the old file on the left and the new one on the right.
|
||||
- **Inline**, one line per changed line, with removed words struck out and added words
|
||||
marked. Best for prose, such as Markdown.
|
||||
|
||||
In every layout, the words that changed inside a line are marked.
|
||||
- **Opens other repositories** from a list of recent ones, or from a folder picker
|
||||
(<kbd>o</kbd>).
|
||||
- **Stays current.** gites reloads the repository when its window gets focus again.
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
Download a package from the [releases page](https://git.wrobel.it/szaman/gites/releases):
|
||||
|
||||
| System | File |
|
||||
|---|---|
|
||||
| Debian, Ubuntu | `gites_<version>_amd64.deb` |
|
||||
| Fedora, openSUSE | `gites-<version>-1.x86_64.rpm` |
|
||||
| Any Linux | `gites_<version>_amd64.AppImage` |
|
||||
| Windows, installer | `gites_<version>_x64-setup.exe` |
|
||||
| Windows, no install | `gites_<version>_x64_portable.exe` |
|
||||
|
||||
gites runs the `git` command, so git must be installed and on your `PATH`.
|
||||
|
||||
## Use
|
||||
|
||||
```sh
|
||||
gites # the repository in the current folder
|
||||
gites ~/code/my-project # another repository
|
||||
```
|
||||
|
||||
## Keys
|
||||
|
||||
**In the commit graph**
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| <kbd>↑</kbd> <kbd>↓</kbd> or <kbd>k</kbd> <kbd>j</kbd> | Select the previous or next commit |
|
||||
| <kbd>PgUp</kbd> <kbd>PgDn</kbd> | Move one screen up or down |
|
||||
| <kbd>g</kbd> <kbd>G</kbd> or <kbd>Home</kbd> <kbd>End</kbd> | Go to the first or last commit |
|
||||
| <kbd>Enter</kbd> | Show what changed in the first file of the commit |
|
||||
| <kbd>space</kbd> | Fold or unfold the straight stretch at the selected commit |
|
||||
| <kbd>c</kbd> | Fold or unfold all straight stretches |
|
||||
| <kbd>h</kbd> | Hide the branch of the selected commit |
|
||||
| <kbd>y</kbd> | Copy the short hash |
|
||||
| <kbd>/</kbd> | Type a filter pattern |
|
||||
| <kbd>p</kbd> | Show or hide the preview panel |
|
||||
| <kbd>o</kbd> | Open another repository |
|
||||
| <kbd>r</kbd> | Reload |
|
||||
|
||||
**In a file's diff**
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| <kbd>↑</kbd> <kbd>↓</kbd> or <kbd>k</kbd> <kbd>j</kbd> | Scroll by one line |
|
||||
| <kbd>PgUp</kbd> <kbd>PgDn</kbd> | Scroll by one screen |
|
||||
| <kbd>g</kbd> <kbd>G</kbd> or <kbd>Home</kbd> <kbd>End</kbd> | Go to the top or bottom |
|
||||
| <kbd>n</kbd> <kbd>N</kbd> | Go to the next or previous change |
|
||||
| <kbd>]</kbd> <kbd>[</kbd> | Show the next or previous file of the commit |
|
||||
| <kbd>v</kbd> | Switch the layout: one column, side by side, inline |
|
||||
| <kbd>Esc</kbd> | Go back to the commit graph |
|
||||
|
||||
## Where gites keeps its settings
|
||||
|
||||
The window layout, the diff layout and the recent repositories are kept in
|
||||
`settings.json`, and the filters of each repository in `filters.json`. Both files are in
|
||||
the `gites` folder of your data folder: `~/.local/share/gites` on Linux,
|
||||
`%APPDATA%\gites` on Windows.
|
||||
|
||||
## Build from source
|
||||
|
||||
gites is a [Tauri](https://tauri.app) app: the window is Vue and TypeScript, and the part
|
||||
that runs git is Rust. [mise](https://mise.jdx.dev) installs the right versions of Node,
|
||||
Bun and Rust.
|
||||
|
||||
On Linux, Tauri also needs the WebKitGTK development packages. `scripts/ci-setup.sh`
|
||||
lists them for Debian and Ubuntu.
|
||||
|
||||
```sh
|
||||
mise install # Node, Bun and Rust
|
||||
bun install # packages for the window code
|
||||
|
||||
mise run dev # run gites with live reload; add a path to open a repository
|
||||
mise run test # unit tests for the window code and the Rust code
|
||||
mise run check # type checks for both
|
||||
mise run install # build a release binary and install it as ~/.local/bin/gites
|
||||
```
|
||||
|
||||
Pushing a tag such as `v1.2.3` builds the Linux and Windows packages and publishes them
|
||||
on the releases page.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
+54
-7
@@ -1,5 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -32,12 +32,17 @@ pub struct RepoData {
|
||||
}
|
||||
|
||||
pub fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("could not run git: {e}"))?;
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
// gites has no console, so Windows would open a console window for every git it
|
||||
// starts. Each one flashes on screen and takes the focus, and gites reloads on focus.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let out = cmd.output().map_err(|e| format!("could not run git: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||
}
|
||||
@@ -125,3 +130,45 @@ pub fn load(dir: &Path) -> Result<RepoData, String> {
|
||||
|
||||
Ok(RepoData { root, commits, refs, head_ref, head })
|
||||
}
|
||||
|
||||
/// The repository to open at start: the path given on the command line, or else the
|
||||
/// current folder if it is inside a repository. `None` means gites starts with its
|
||||
/// welcome screen. A path given on the command line is used even if it is not a
|
||||
/// repository, so the window can say what is wrong with it.
|
||||
pub fn start_dir(arg: Option<PathBuf>, cwd: PathBuf) -> Option<PathBuf> {
|
||||
arg.or_else(|| git(&cwd, &["rev-parse", "--git-dir"]).ok().map(|_| cwd))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("gites-start-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_the_path_given_on_the_command_line_even_if_it_is_not_a_repository() {
|
||||
let given = PathBuf::from("/no/such/folder");
|
||||
assert_eq!(start_dir(Some(given.clone()), temp_dir("arg")), Some(given));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_the_current_folder_when_it_is_inside_a_repository() {
|
||||
let dir = temp_dir("repo");
|
||||
git(&dir, &["init", "-q"]).unwrap();
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
assert_eq!(start_dir(None, dir.join("sub")), Some(dir.join("sub")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_nothing_when_the_current_folder_is_not_in_a_repository() {
|
||||
let dir = temp_dir("plain");
|
||||
assert_eq!(start_dir(None, dir.clone()), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-14
@@ -7,32 +7,36 @@ mod settings;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// The open repository folder. It starts as the first command line argument, or the
|
||||
/// current folder, and changes when the user opens another one.
|
||||
struct RepoDir(Mutex<PathBuf>);
|
||||
/// The open repository folder, from `git::start_dir`; it changes when the user opens
|
||||
/// another one. `None` until a repository is open: the window shows its welcome screen.
|
||||
struct RepoDir(Mutex<Option<PathBuf>>);
|
||||
|
||||
impl RepoDir {
|
||||
fn get(&self) -> PathBuf {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
fn get(&self) -> Result<PathBuf, String> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or_else(|| "no repository is open".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The open repository, or `None` when there is none yet.
|
||||
#[tauri::command]
|
||||
fn load_repo(dir: tauri::State<RepoDir>) -> Result<git::RepoData, String> {
|
||||
git::load(&dir.get())
|
||||
fn load_repo(dir: tauri::State<RepoDir>) -> Result<Option<git::RepoData>, String> {
|
||||
match dir.get() {
|
||||
Ok(d) => git::load(&d).map(Some),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches to another repository. The current one stays open if `path` is not a repository.
|
||||
#[tauri::command]
|
||||
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
|
||||
let data = git::load(&PathBuf::from(&path))?;
|
||||
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = PathBuf::from(&path);
|
||||
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(PathBuf::from(&path));
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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]
|
||||
@@ -43,7 +47,7 @@ fn file_diff(
|
||||
old_path: Option<String>,
|
||||
force: bool,
|
||||
) -> 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]
|
||||
@@ -68,10 +72,9 @@ fn save_settings(value: serde_json::Value) -> Result<(), String> {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let path = std::env::args_os()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
let arg = std::env::args_os().nth(1).map(PathBuf::from);
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let path = git::start_dir(arg, cwd);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
|
||||
+26
-7
@@ -10,7 +10,7 @@ import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/f
|
||||
import { layout } from "./lib/layout";
|
||||
import { textWidth } from "./lib/measure";
|
||||
import { colorOf } from "./lib/palette";
|
||||
import { addRecent } from "./lib/recents";
|
||||
import { addRecent, mergeRecents } from "./lib/recents";
|
||||
import type { FileDiff, Layout } from "./lib/diff";
|
||||
import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
|
||||
import DiffView from "./components/DiffView.vue";
|
||||
@@ -19,6 +19,7 @@ import LabelStack from "./components/LabelStack.vue";
|
||||
import PreviewPanel from "./components/PreviewPanel.vue";
|
||||
import RepoMenu from "./components/RepoMenu.vue";
|
||||
import Sidebar from "./components/Sidebar.vue";
|
||||
import Welcome from "./components/Welcome.vue";
|
||||
|
||||
const ROW_H = 28;
|
||||
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
|
||||
@@ -58,10 +59,7 @@ async function loadSettings() {
|
||||
if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight);
|
||||
if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown;
|
||||
if (saved?.diffLayout && LAYOUTS.includes(saved.diffLayout)) settings.diffLayout = saved.diffLayout;
|
||||
if (Array.isArray(saved?.recents)) {
|
||||
// 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);
|
||||
}
|
||||
if (Array.isArray(saved?.recents)) settings.recents = mergeRecents(settings.recents, saved.recents);
|
||||
settingsLoaded = true;
|
||||
}
|
||||
|
||||
@@ -105,11 +103,23 @@ function startDrag(e: PointerEvent, axis: "x" | "y") {
|
||||
|
||||
let filtersLoadedFor: string | null = null;
|
||||
|
||||
let loading = false;
|
||||
/** False until the first load answers, so the welcome screen does not flash up at start. */
|
||||
const started = ref(false);
|
||||
|
||||
async function load() {
|
||||
// The window gets focus often; one reload at a time is enough.
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
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);
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
started.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +150,7 @@ async function applyRepo(data: RepoData) {
|
||||
});
|
||||
filters.hidden = saved?.hidden ?? [];
|
||||
filters.rules = saved?.rules ?? [];
|
||||
filters.hideTags = saved?.hideTags ?? false;
|
||||
collapseAll.value = saved?.collapseAll ?? false;
|
||||
filtersLoadedFor = data.root;
|
||||
toggled.value = new Set();
|
||||
@@ -163,7 +174,7 @@ watch(
|
||||
saveTimer = window.setTimeout(() => {
|
||||
invoke("save_filters", {
|
||||
repo: repoRoot,
|
||||
value: { hidden: filters.hidden, rules: filters.rules, collapseAll: collapseAll.value },
|
||||
value: { hidden: filters.hidden, rules: filters.rules, hideTags: filters.hideTags, collapseAll: collapseAll.value },
|
||||
}).catch((e) => showToast(`could not save filters: ${e}`));
|
||||
}, 300);
|
||||
},
|
||||
@@ -529,6 +540,14 @@ onUnmounted(() => {
|
||||
<button @click="menuOpen = true">Open a repository…</button>
|
||||
</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">
|
||||
|
||||
<DiffView
|
||||
|
||||
@@ -46,7 +46,15 @@ function toggleFolder(n: TreeNode) {
|
||||
<path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" />
|
||||
</svg>
|
||||
<span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span>
|
||||
<EyeButton :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
|
||||
<EyeButton
|
||||
v-if="kind === 'tag' && filters.hideTags"
|
||||
class="locked"
|
||||
:shut="true"
|
||||
disabled
|
||||
title="all tags are hidden: use the eye next to Tags"
|
||||
@click.stop
|
||||
/>
|
||||
<EyeButton v-else :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
|
||||
</div>
|
||||
<RefTree
|
||||
v-if="n.children.length && !closed.has(key(n))"
|
||||
@@ -80,4 +88,5 @@ function toggleFolder(n: TreeNode) {
|
||||
.arrow.open { transform: rotate(90deg); }
|
||||
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item:hover :deep(.eye) { opacity: 1; }
|
||||
.item :deep(.eye.locked) { opacity: 0.35; cursor: default; }
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from "vue";
|
||||
import { refKind, shortName } from "../lib/filter";
|
||||
import { buildTree, type TreeNode } from "../lib/tree";
|
||||
import type { FilterState, Ref, RefKind } from "../lib/types";
|
||||
import EyeButton from "./EyeButton.vue";
|
||||
import RefTree from "./RefTree.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -98,7 +99,15 @@ function onPatternKey(e: KeyboardEvent) {
|
||||
</section>
|
||||
|
||||
<section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group">
|
||||
<h3>{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span></h3>
|
||||
<h3>
|
||||
{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span>
|
||||
<EyeButton
|
||||
v-if="g.kind === 'tag'"
|
||||
:shut="!!filters.hideTags"
|
||||
:title="filters.hideTags ? 'show all tags' : 'hide all tags, also tags added later'"
|
||||
@click="filters.hideTags = !filters.hideTags"
|
||||
/>
|
||||
</h3>
|
||||
<RefTree
|
||||
:nodes="trees[g.kind]"
|
||||
:kind="g.kind"
|
||||
@@ -122,6 +131,9 @@ function onPatternKey(e: KeyboardEvent) {
|
||||
padding: 10px 0 20px;
|
||||
}
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 12px 14px 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
@@ -129,6 +141,8 @@ h3 {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.count { font-weight: normal; opacity: 0.7; }
|
||||
h3 :deep(.eye) { margin-left: auto; margin-right: -6px; }
|
||||
h3:hover :deep(.eye) { opacity: 1; }
|
||||
.rules { padding: 0 14px; }
|
||||
.rules h3 { margin-left: 0; margin-top: 2px; }
|
||||
.rules input { width: 100%; }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ recents: string[] }>();
|
||||
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
|
||||
|
||||
// Paths on Windows use backslashes.
|
||||
const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<div class="card">
|
||||
<img src="/logo.svg" class="logo" alt="" />
|
||||
<h2>Open a repository</h2>
|
||||
<template v-if="recents.length">
|
||||
<div class="head">Recent repositories</div>
|
||||
<div class="list">
|
||||
<div v-for="p in recents" :key="p" class="item">
|
||||
<button class="open" :title="`open ${p}`" @click="emit('pick', p)">
|
||||
<span class="name">{{ name(p) }}</span>
|
||||
<span class="path">{{ p }}</span>
|
||||
</button>
|
||||
<button class="x" title="remove from the list" @click="emit('remove', p)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button class="browse" @click="emit('browse')">Choose a folder…</button>
|
||||
<p class="hint">You can also start gites with a path: <code>gites ~/code/my-project</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.welcome { flex: 1; min-width: 0; display: flex; align-items: flex-start; justify-content: center; overflow: auto; padding: 12vh 24px 24px; }
|
||||
.card { width: min(560px, 100%); display: flex; flex-direction: column; gap: 12px; }
|
||||
.logo { width: 112px; height: 112px; align-self: center; }
|
||||
h2 { margin: 0 0 4px; font-size: 20px; }
|
||||
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); }
|
||||
.list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 4px; background: var(--bg-side); }
|
||||
.item { display: flex; align-items: center; border-radius: 6px; }
|
||||
.item:hover, .item:focus-within { background: var(--bg-hover); }
|
||||
.open { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 10px; border: none; padding: 7px 10px; text-align: left; background: none; }
|
||||
.open:hover { background: none; }
|
||||
.name { font-weight: 600; flex: none; }
|
||||
.path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.x { border: none; padding: 2px 10px; color: var(--text-dim); opacity: 0; }
|
||||
.item:hover .x, .item:focus-within .x { opacity: 1; }
|
||||
.browse { align-self: flex-start; padding: 6px 14px; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
|
||||
.hint { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; }
|
||||
.hint code { font-family: ui-monospace, monospace; color: var(--text); }
|
||||
button:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
||||
</style>
|
||||
@@ -78,3 +78,18 @@ describe("lineOwners", () => {
|
||||
expect(owners.get("f")).toBe("refs/heads/feature");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hiding all tags", () => {
|
||||
const state = { hidden: [], rules: [], hideTags: true };
|
||||
it("hides every tag", () => {
|
||||
expect(isRefVisible("refs/tags/v1", state)).toBe(false);
|
||||
expect(isRefVisible("refs/tags/release/2", state)).toBe(false);
|
||||
});
|
||||
it("leaves branches alone", () => {
|
||||
expect(isRefVisible("refs/heads/main", state)).toBe(true);
|
||||
expect(isRefVisible("refs/remotes/origin/main", state)).toBe(true);
|
||||
});
|
||||
it("shows tags again when turned off", () => {
|
||||
expect(isRefVisible("refs/tags/v1", { ...state, hideTags: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ export function globMatch(pattern: string, text: string): boolean {
|
||||
|
||||
export function isRefVisible(name: string, state: FilterState): boolean {
|
||||
if (state.hidden.includes(name)) return false;
|
||||
if (state.hideTags && name.startsWith("refs/tags/")) return false;
|
||||
const short = shortName(name);
|
||||
const only = state.rules.filter((r) => r.mode === "only");
|
||||
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
|
||||
|
||||
+10
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addRecent, MAX_RECENTS } from "./recents";
|
||||
import { addRecent, MAX_RECENTS, mergeRecents } from "./recents";
|
||||
|
||||
describe("addRecent", () => {
|
||||
it("puts the path first", () => {
|
||||
@@ -16,3 +16,12 @@ describe("addRecent", () => {
|
||||
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[] {
|
||||
return [path, ...list.filter((p) => p !== path)].slice(0, MAX_RECENTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the saved list with paths opened before the saved settings arrived: those come
|
||||
* first, then the saved ones in their saved order, each path once.
|
||||
*/
|
||||
export function mergeRecents(openedFirst: string[], saved: string[]): string[] {
|
||||
return [...openedFirst, ...saved.filter((p) => !openedFirst.includes(p))].slice(0, MAX_RECENTS);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface FilterState {
|
||||
/** Full ref names hidden with the eye toggle. */
|
||||
hidden: string[];
|
||||
rules: FilterRule[];
|
||||
/** Hides every tag, also tags created later. */
|
||||
hideTags?: boolean;
|
||||
}
|
||||
|
||||
/** A commit as shown on screen, with its parent edges after collapsing. */
|
||||
|
||||
Reference in New Issue
Block a user