Resizable sidebar remembered in settings.json; nest tags by / like branches

This commit is contained in:
2026-10-14 22:21:09 +02:00
parent bf9b3baa1f
commit f027ebe2aa
7 changed files with 127 additions and 13 deletions
+18 -1
View File
@@ -1,5 +1,6 @@
mod filters;
mod git;
mod settings;
use std::path::PathBuf;
@@ -21,6 +22,16 @@ fn save_filters(repo: String, value: serde_json::Value) -> Result<(), String> {
filters::save(&repo, value)
}
#[tauri::command]
fn load_settings() -> Result<serde_json::Value, String> {
settings::load()
}
#[tauri::command]
fn save_settings(value: serde_json::Value) -> Result<(), String> {
settings::save(value)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let path = std::env::args_os()
@@ -31,7 +42,13 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
.manage(RepoArg(path))
.invoke_handler(tauri::generate_handler![load_repo, load_filters, save_filters])
.invoke_handler(tauri::generate_handler![
load_repo,
load_filters,
save_filters,
load_settings,
save_settings
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+26
View File
@@ -0,0 +1,26 @@
//! App-wide settings, such as the sidebar width, in `~/.local/share/gites/settings.json`.
use serde_json::Value;
use std::path::PathBuf;
fn file() -> Result<PathBuf, String> {
Ok(dirs::data_dir().ok_or("no data folder found")?.join("gites").join("settings.json"))
}
pub fn load() -> Result<Value, String> {
let path = file()?;
match std::fs::read_to_string(&path) {
Ok(text) => Ok(serde_json::from_str(&text).unwrap_or(Value::Null)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Value::Null),
Err(e) => Err(format!("could not read {}: {e}", path.display())),
}
}
pub fn save(value: Value) -> Result<(), String> {
let path = file()?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let text = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| format!("could not write {}: {e}", path.display()))
}
+55
View File
@@ -25,6 +25,48 @@ const selected = ref(0);
const toast = ref<string | null>(null);
const sidebar = ref<InstanceType<typeof Sidebar>>();
// ---- settings: the sidebar width -------------------------------------------
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 700;
const sidebarWidth = ref(270);
let settingsLoaded = false;
async function loadSettings() {
const saved = await invoke<{ sidebarWidth?: number } | null>("load_settings").catch(() => null);
if (typeof saved?.sidebarWidth === "number") {
sidebarWidth.value = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, saved.sidebarWidth));
}
settingsLoaded = true;
}
let settingsTimer: number | undefined;
watch(sidebarWidth, () => {
if (!settingsLoaded) return;
clearTimeout(settingsTimer);
settingsTimer = window.setTimeout(() => {
invoke("save_settings", { value: { sidebarWidth: sidebarWidth.value } }).catch((e) =>
showToast(`could not save settings: ${e}`),
);
}, 300);
});
function startResize(e: PointerEvent) {
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startWidth = sidebarWidth.value;
const move = (ev: PointerEvent) => {
sidebarWidth.value = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + ev.clientX - startX));
};
const stop = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", stop);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", stop);
}
// ---- loading and saving -------------------------------------------------
let filtersLoadedFor: string | null = null;
@@ -228,6 +270,7 @@ onMounted(() => {
viewWidth.value = scroller.value?.clientWidth ?? 1000;
});
if (scroller.value) resizeObserver.observe(scroller.value);
loadSettings();
load();
});
onUnmounted(() => {
@@ -270,8 +313,10 @@ onUnmounted(() => {
:filters="filters"
:head-ref="repo.head_ref"
:time-of="timeOf"
:style="{ width: sidebarWidth + 'px' }"
@jump="jumpTo"
/>
<div v-if="repo" class="splitter" title="drag to resize" @pointerdown.prevent="startResize" />
<div v-if="error" class="error">
<h2>Could not open the repository</h2>
@@ -372,6 +417,16 @@ onUnmounted(() => {
.collapse:disabled { opacity: 0.4; cursor: default; background: none; }
.collapse.on { border-color: var(--accent); color: var(--accent); background: #b197fc1a; }
.main { flex: 1; display: flex; min-height: 0; }
.splitter {
flex: none;
width: 5px;
margin-left: -3px;
margin-right: -2px;
cursor: col-resize;
position: relative;
z-index: 5;
}
.splitter:hover, .splitter:active { background: var(--accent); opacity: 0.6; }
.list { flex: 1; overflow: auto; position: relative; }
.spacer-rows { position: relative; min-width: 700px; }
.row {
+8 -6
View File
@@ -19,8 +19,10 @@ const emit = defineEmits<{ jump: [ref: Ref]; toggle: [names: string[]] }>();
const allHidden = (n: TreeNode) => refsUnder(n).every((r) => props.filters.hidden.includes(r.name));
const anyVisible = (n: TreeNode) => refsUnder(n).some((r) => isRefVisible(r.name, props.filters));
function toggleFolder(path: string) {
if (!props.closed.delete(path)) props.closed.add(path);
// Keyed by kind too, so a `backup` tag folder and a `backup` branch folder are separate.
const key = (n: TreeNode) => `${props.kind}:${n.path}`;
function toggleFolder(n: TreeNode) {
if (!props.closed.delete(key(n))) props.closed.add(key(n));
}
</script>
@@ -30,13 +32,13 @@ function toggleFolder(path: string) {
class="item"
:class="{ off: !anyVisible(n), head: n.ref?.name === headRef, folder: n.children.length }"
:style="{ paddingLeft: 10 + depth * 14 + 'px' }"
@click="n.ref ? emit('jump', n.ref) : toggleFolder(n.path)"
@click="n.ref ? emit('jump', n.ref) : toggleFolder(n)"
>
<span
v-if="n.children.length"
class="arrow"
:class="{ open: !closed.has(n.path) }"
@click.stop="toggleFolder(n.path)"
:class="{ open: !closed.has(key(n)) }"
@click.stop="toggleFolder(n)"
>▸</span>
<span v-else class="arrow" />
<RefIcon v-if="n.ref" :kind="kind" />
@@ -47,7 +49,7 @@ function toggleFolder(path: string) {
<EyeButton :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
</div>
<RefTree
v-if="n.children.length && !closed.has(n.path)"
v-if="n.children.length && !closed.has(key(n))"
:nodes="n.children"
:kind="kind"
:depth="depth + 1"
+2 -3
View File
@@ -34,7 +34,7 @@ const byKind = computed(() => {
const trees = computed((): Record<RefKind, TreeNode[]> => {
const tree = (refs: Ref[]) => buildTree(refs.map((ref) => ({ short: shortName(ref.name), ref })));
// Tags stay a flat list, newest first; equal times fall back to the higher version.
// Tags are newest first; equal times fall back to the higher version.
const tags = [...byKind.value.tag].sort(
(a, b) =>
props.timeOf(b.target) - props.timeOf(a.target) ||
@@ -43,7 +43,7 @@ const trees = computed((): Record<RefKind, TreeNode[]> => {
return {
local: tree(byKind.value.local),
remote: tree(byKind.value.remote),
tag: tags.map((ref) => ({ label: shortName(ref.name), path: ref.name, children: [], ref })),
tag: buildTree(tags.map((ref) => ({ short: shortName(ref.name), ref })), true),
};
});
@@ -115,7 +115,6 @@ function onPatternKey(e: KeyboardEvent) {
<style scoped>
.side {
width: 270px;
flex: none;
background: var(--bg-side);
border-right: 1px solid var(--border);
+7
View File
@@ -22,6 +22,13 @@ describe("buildTree", () => {
});
});
describe("buildTree with keepOrder", () => {
it("keeps the given order, folders first, each folder placed by its first item", () => {
const t = buildTree(["v3", "backup/new", "v2", "backup/old", "rc/x"].map(item), true);
expect(shape(t)).toEqual([{ backup: ["new", "old"] }, { rc: ["x"] }, "v3", "v2"]);
});
});
describe("refsUnder", () => {
it("collects every ref in a subtree", () => {
const t = buildTree(["feat/a", "feat/b/c", "main"].map(item));
+11 -3
View File
@@ -10,8 +10,11 @@ export interface TreeNode {
ref?: Ref;
}
/** Splits short names by `/` into a tree. Folders come before refs, each sorted by name. */
export function buildTree(items: { short: string; ref: Ref }[]): TreeNode[] {
/**
* Splits short names by `/` into a tree. Folders come before refs, each sorted by name,
* or, with `keepOrder`, in the order of `items` (a folder takes the place of its first item).
*/
export function buildTree(items: { short: string; ref: Ref }[], keepOrder = false): TreeNode[] {
const root: TreeNode = { label: "", path: "", children: [] };
for (const { short, ref } of items) {
let node = root;
@@ -32,7 +35,12 @@ export function buildTree(items: { short: string; ref: Ref }[]): TreeNode[] {
);
nodes.forEach((n) => sort(n.children));
};
sort(root.children);
const foldersFirst = (nodes: TreeNode[]) => {
// Array.prototype.sort is stable, so items keep their order within each group.
nodes.sort((a, b) => Number(b.children.length > 0) - Number(a.children.length > 0));
nodes.forEach((n) => foldersFirst(n.children));
};
(keepOrder ? foldersFirst : sort)(root.children);
return root.children;
}