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 filters;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -21,6 +22,16 @@ fn save_filters(repo: String, value: serde_json::Value) -> Result<(), String> {
|
|||||||
filters::save(&repo, value)
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let path = std::env::args_os()
|
let path = std::env::args_os()
|
||||||
@@ -31,7 +42,13 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.manage(RepoArg(path))
|
.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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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 toast = ref<string | null>(null);
|
||||||
const sidebar = ref<InstanceType<typeof Sidebar>>();
|
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 -------------------------------------------------
|
// ---- loading and saving -------------------------------------------------
|
||||||
|
|
||||||
let filtersLoadedFor: string | null = null;
|
let filtersLoadedFor: string | null = null;
|
||||||
@@ -228,6 +270,7 @@ onMounted(() => {
|
|||||||
viewWidth.value = scroller.value?.clientWidth ?? 1000;
|
viewWidth.value = scroller.value?.clientWidth ?? 1000;
|
||||||
});
|
});
|
||||||
if (scroller.value) resizeObserver.observe(scroller.value);
|
if (scroller.value) resizeObserver.observe(scroller.value);
|
||||||
|
loadSettings();
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -270,8 +313,10 @@ onUnmounted(() => {
|
|||||||
:filters="filters"
|
:filters="filters"
|
||||||
:head-ref="repo.head_ref"
|
:head-ref="repo.head_ref"
|
||||||
:time-of="timeOf"
|
:time-of="timeOf"
|
||||||
|
:style="{ width: sidebarWidth + 'px' }"
|
||||||
@jump="jumpTo"
|
@jump="jumpTo"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="repo" class="splitter" title="drag to resize" @pointerdown.prevent="startResize" />
|
||||||
|
|
||||||
<div v-if="error" class="error">
|
<div v-if="error" class="error">
|
||||||
<h2>Could not open the repository</h2>
|
<h2>Could not open the repository</h2>
|
||||||
@@ -372,6 +417,16 @@ onUnmounted(() => {
|
|||||||
.collapse:disabled { opacity: 0.4; cursor: default; background: none; }
|
.collapse:disabled { opacity: 0.4; cursor: default; background: none; }
|
||||||
.collapse.on { border-color: var(--accent); color: var(--accent); background: #b197fc1a; }
|
.collapse.on { border-color: var(--accent); color: var(--accent); background: #b197fc1a; }
|
||||||
.main { flex: 1; display: flex; min-height: 0; }
|
.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; }
|
.list { flex: 1; overflow: auto; position: relative; }
|
||||||
.spacer-rows { position: relative; min-width: 700px; }
|
.spacer-rows { position: relative; min-width: 700px; }
|
||||||
.row {
|
.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 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));
|
const anyVisible = (n: TreeNode) => refsUnder(n).some((r) => isRefVisible(r.name, props.filters));
|
||||||
|
|
||||||
function toggleFolder(path: string) {
|
// Keyed by kind too, so a `backup` tag folder and a `backup` branch folder are separate.
|
||||||
if (!props.closed.delete(path)) props.closed.add(path);
|
const key = (n: TreeNode) => `${props.kind}:${n.path}`;
|
||||||
|
function toggleFolder(n: TreeNode) {
|
||||||
|
if (!props.closed.delete(key(n))) props.closed.add(key(n));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -30,13 +32,13 @@ function toggleFolder(path: string) {
|
|||||||
class="item"
|
class="item"
|
||||||
:class="{ off: !anyVisible(n), head: n.ref?.name === headRef, folder: n.children.length }"
|
:class="{ off: !anyVisible(n), head: n.ref?.name === headRef, folder: n.children.length }"
|
||||||
:style="{ paddingLeft: 10 + depth * 14 + 'px' }"
|
: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
|
<span
|
||||||
v-if="n.children.length"
|
v-if="n.children.length"
|
||||||
class="arrow"
|
class="arrow"
|
||||||
:class="{ open: !closed.has(n.path) }"
|
:class="{ open: !closed.has(key(n)) }"
|
||||||
@click.stop="toggleFolder(n.path)"
|
@click.stop="toggleFolder(n)"
|
||||||
>▸</span>
|
>▸</span>
|
||||||
<span v-else class="arrow" />
|
<span v-else class="arrow" />
|
||||||
<RefIcon v-if="n.ref" :kind="kind" />
|
<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))" />
|
<EyeButton :shut="allHidden(n)" @click.stop="emit('toggle', refsUnder(n).map((r) => r.name))" />
|
||||||
</div>
|
</div>
|
||||||
<RefTree
|
<RefTree
|
||||||
v-if="n.children.length && !closed.has(n.path)"
|
v-if="n.children.length && !closed.has(key(n))"
|
||||||
:nodes="n.children"
|
:nodes="n.children"
|
||||||
:kind="kind"
|
:kind="kind"
|
||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const byKind = computed(() => {
|
|||||||
|
|
||||||
const trees = computed((): Record<RefKind, TreeNode[]> => {
|
const trees = computed((): Record<RefKind, TreeNode[]> => {
|
||||||
const tree = (refs: Ref[]) => buildTree(refs.map((ref) => ({ short: shortName(ref.name), ref })));
|
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(
|
const tags = [...byKind.value.tag].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
props.timeOf(b.target) - props.timeOf(a.target) ||
|
props.timeOf(b.target) - props.timeOf(a.target) ||
|
||||||
@@ -43,7 +43,7 @@ const trees = computed((): Record<RefKind, TreeNode[]> => {
|
|||||||
return {
|
return {
|
||||||
local: tree(byKind.value.local),
|
local: tree(byKind.value.local),
|
||||||
remote: tree(byKind.value.remote),
|
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>
|
<style scoped>
|
||||||
.side {
|
.side {
|
||||||
width: 270px;
|
|
||||||
flex: none;
|
flex: none;
|
||||||
background: var(--bg-side);
|
background: var(--bg-side);
|
||||||
border-right: 1px solid var(--border);
|
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", () => {
|
describe("refsUnder", () => {
|
||||||
it("collects every ref in a subtree", () => {
|
it("collects every ref in a subtree", () => {
|
||||||
const t = buildTree(["feat/a", "feat/b/c", "main"].map(item));
|
const t = buildTree(["feat/a", "feat/b/c", "main"].map(item));
|
||||||
|
|||||||
+11
-3
@@ -10,8 +10,11 @@ export interface TreeNode {
|
|||||||
ref?: Ref;
|
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: [] };
|
const root: TreeNode = { label: "", path: "", children: [] };
|
||||||
for (const { short, ref } of items) {
|
for (const { short, ref } of items) {
|
||||||
let node = root;
|
let node = root;
|
||||||
@@ -32,7 +35,12 @@ export function buildTree(items: { short: string; ref: Ref }[]): TreeNode[] {
|
|||||||
);
|
);
|
||||||
nodes.forEach((n) => sort(n.children));
|
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;
|
return root.children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user