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

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

+54 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -46,7 +46,15 @@ function toggleFolder(n: TreeNode) {
<path d="M1.5 4a1 1 0 0 1 1-1h3.5l1.5 1.5h6a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" />
</svg>
<span class="name" :title="n.ref?.name ?? n.path">{{ n.label }}</span>
<EyeButton :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
<EyeButton
v-if="kind === 'tag' && filters.hideTags"
class="locked"
:shut="true"
disabled
title="all tags are hidden: use the eye next to Tags"
@click.stop
/>
<EyeButton v-else :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
</div>
<RefTree
v-if="n.children.length && !closed.has(key(n))"
@@ -80,4 +88,5 @@ function toggleFolder(n: TreeNode) {
.arrow.open { transform: rotate(90deg); }
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item:hover :deep(.eye) { opacity: 1; }
.item :deep(.eye.locked) { opacity: 0.35; cursor: default; }
</style>
+15 -1
View File
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from "vue";
import { refKind, shortName } from "../lib/filter";
import { buildTree, type TreeNode } from "../lib/tree";
import type { FilterState, Ref, RefKind } from "../lib/types";
import EyeButton from "./EyeButton.vue";
import RefTree from "./RefTree.vue";
const props = defineProps<{
@@ -98,7 +99,15 @@ function onPatternKey(e: KeyboardEvent) {
</section>
<section v-for="g in groups" v-show="byKind[g.kind].length" :key="g.kind" class="group">
<h3>{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span></h3>
<h3>
{{ g.title }} <span class="count">{{ byKind[g.kind].length }}</span>
<EyeButton
v-if="g.kind === 'tag'"
:shut="!!filters.hideTags"
:title="filters.hideTags ? 'show all tags' : 'hide all tags, also tags added later'"
@click="filters.hideTags = !filters.hideTags"
/>
</h3>
<RefTree
:nodes="trees[g.kind]"
:kind="g.kind"
@@ -122,6 +131,9 @@ function onPatternKey(e: KeyboardEvent) {
padding: 10px 0 20px;
}
h3 {
display: flex;
align-items: center;
gap: 4px;
margin: 12px 14px 6px;
font-size: 11px;
text-transform: uppercase;
@@ -129,6 +141,8 @@ h3 {
color: var(--text-dim);
}
.count { font-weight: normal; opacity: 0.7; }
h3 :deep(.eye) { margin-left: auto; margin-right: -6px; }
h3:hover :deep(.eye) { opacity: 1; }
.rules { padding: 0 14px; }
.rules h3 { margin-left: 0; margin-top: 2px; }
.rules input { width: 100%; }
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineProps<{ recents: string[] }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
// Paths on Windows use backslashes.
const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
</script>
<template>
<div class="welcome">
<div class="card">
<img src="/logo.svg" class="logo" alt="" />
<h2>Open a repository</h2>
<template v-if="recents.length">
<div class="head">Recent repositories</div>
<div class="list">
<div v-for="p in recents" :key="p" class="item">
<button class="open" :title="`open ${p}`" @click="emit('pick', p)">
<span class="name">{{ name(p) }}</span>
<span class="path">{{ p }}</span>
</button>
<button class="x" title="remove from the list" @click="emit('remove', p)">×</button>
</div>
</div>
</template>
<button class="browse" @click="emit('browse')">Choose a folder…</button>
<p class="hint">You can also start gites with a path: <code>gites ~/code/my-project</code></p>
</div>
</div>
</template>
<style scoped>
.welcome { flex: 1; min-width: 0; display: flex; align-items: flex-start; justify-content: center; overflow: auto; padding: 12vh 24px 24px; }
.card { width: min(560px, 100%); display: flex; flex-direction: column; gap: 12px; }
.logo { width: 112px; height: 112px; align-self: center; }
h2 { margin: 0 0 4px; font-size: 20px; }
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); }
.list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 4px; background: var(--bg-side); }
.item { display: flex; align-items: center; border-radius: 6px; }
.item:hover, .item:focus-within { background: var(--bg-hover); }
.open { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 10px; border: none; padding: 7px 10px; text-align: left; background: none; }
.open:hover { background: none; }
.name { font-weight: 600; flex: none; }
.path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.x { border: none; padding: 2px 10px; color: var(--text-dim); opacity: 0; }
.item:hover .x, .item:focus-within .x { opacity: 1; }
.browse { align-self: flex-start; padding: 6px 14px; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
.hint { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; }
.hint code { font-family: ui-monospace, monospace; color: var(--text); }
button:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
</style>
+15
View File
@@ -78,3 +78,18 @@ describe("lineOwners", () => {
expect(owners.get("f")).toBe("refs/heads/feature");
});
});
describe("hiding all tags", () => {
const state = { hidden: [], rules: [], hideTags: true };
it("hides every tag", () => {
expect(isRefVisible("refs/tags/v1", state)).toBe(false);
expect(isRefVisible("refs/tags/release/2", state)).toBe(false);
});
it("leaves branches alone", () => {
expect(isRefVisible("refs/heads/main", state)).toBe(true);
expect(isRefVisible("refs/remotes/origin/main", state)).toBe(true);
});
it("shows tags again when turned off", () => {
expect(isRefVisible("refs/tags/v1", { ...state, hideTags: false })).toBe(true);
});
});
+1
View File
@@ -21,6 +21,7 @@ export function globMatch(pattern: string, text: string): boolean {
export function isRefVisible(name: string, state: FilterState): boolean {
if (state.hidden.includes(name)) return false;
if (state.hideTags && name.startsWith("refs/tags/")) return false;
const short = shortName(name);
const only = state.rules.filter((r) => r.mode === "only");
if (only.length > 0 && !only.some((r) => globMatch(r.pattern, short))) return false;
+10 -1
View File
@@ -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"]);
});
});
+8
View File
@@ -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);
}
+2
View File
@@ -33,6 +33,8 @@ export interface FilterState {
/** Full ref names hidden with the eye toggle. */
hidden: string[];
rules: FilterRule[];
/** Hides every tag, also tags created later. */
hideTags?: boolean;
}
/** A commit as shown on screen, with its parent edges after collapsing. */