Resizable sidebar remembered in settings.json; nest tags by / like branches
This commit is contained in:
+18
-1
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user