gites: colorful git tree viewer
Tauri 2 + Vue app that draws the commit graph of the repository given on the command line. Branches can be hidden one by one or by patterns, runs of plain commits fold into a dotted line, and everything is reachable from the keyboard.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ shut: boolean }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="eye" :class="{ shut }" :title="shut ? 'show' : 'hide'">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" />
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
<path v-if="shut" d="M2 14L14 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.eye { border: none; padding: 2px 4px; opacity: 0; display: flex; flex: none; }
|
||||
.eye.shut { opacity: 1; color: #ff8787; }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { colorOf } from "../lib/palette";
|
||||
import type { LaidOutRow } from "../lib/types";
|
||||
|
||||
const props = defineProps<{ row: LaidOutRow; width: number; height: number; isHead: boolean }>();
|
||||
|
||||
const LANE = 16;
|
||||
const PAD = 10;
|
||||
const x = (col: number) => PAD + col * LANE;
|
||||
|
||||
function path(s: LaidOutRow["segments"][number]) {
|
||||
const h = props.height;
|
||||
const x1 = x(s.x1), x2 = x(s.x2), y1 = s.y1 * h, y2 = s.y2 * h;
|
||||
if (x1 === x2) return `M${x1} ${y1}V${y2}`;
|
||||
const m = (y1 + y2) / 2;
|
||||
return `M${x1} ${y1}C${x1} ${m} ${x2} ${m} ${x2} ${y2}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="graph" :width="PAD * 2 + (width - 1) * LANE" :height="height">
|
||||
<path
|
||||
v-for="(s, i) in row.segments"
|
||||
:key="i"
|
||||
:d="path(s)"
|
||||
:stroke="colorOf(s.color)"
|
||||
:stroke-dasharray="s.dotted ? '0.1 5' : undefined"
|
||||
:stroke-width="s.dotted ? 2.6 : 2"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<g v-if="isHead">
|
||||
<circle :cx="x(row.col)" :cy="height / 2" r="7" fill="var(--bg)" :stroke="colorOf(row.color)" stroke-width="2" />
|
||||
<circle :cx="x(row.col)" :cy="height / 2" r="3.5" :fill="colorOf(row.color)" />
|
||||
</g>
|
||||
<circle
|
||||
v-else
|
||||
:cx="x(row.col)"
|
||||
:cy="height / 2"
|
||||
r="4.5"
|
||||
:fill="colorOf(row.color)"
|
||||
stroke="var(--bg)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph { flex: none; display: block; overflow: visible; }
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { RefKind } from "../lib/types";
|
||||
defineProps<{ kind: RefKind }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="ref-icon" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<template v-if="kind === 'tag'">
|
||||
<path d="M2 2h6l6 6-6 6-6-6z" stroke-linejoin="round" />
|
||||
<circle cx="5.5" cy="5.5" r="1.2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="kind === 'remote'">
|
||||
<path d="M4.5 12.5h7.5a3 3 0 0 0 .3-6 4.2 4.2 0 0 0-8.1 1A2.5 2.5 0 0 0 4.5 12.5z" stroke-linejoin="round" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<circle cx="4" cy="3.5" r="1.6" /><circle cx="4" cy="12.5" r="1.6" /><circle cx="12" cy="5" r="1.6" />
|
||||
<path d="M4 5.1v5.8M12 6.6c0 3-8 2-8 4.3" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ref-icon { flex: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { isRefVisible } from "../lib/filter";
|
||||
import { refsUnder, type TreeNode } from "../lib/tree";
|
||||
import type { FilterState, Ref, RefKind } from "../lib/types";
|
||||
import EyeButton from "./EyeButton.vue";
|
||||
import RefIcon from "./RefIcon.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: TreeNode[];
|
||||
kind: RefKind;
|
||||
depth: number;
|
||||
filters: FilterState;
|
||||
headRef: string | null;
|
||||
/** Paths of folders the user closed. Shared by the whole tree. */
|
||||
closed: Set<string>;
|
||||
}>();
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="n in nodes" :key="n.path">
|
||||
<div
|
||||
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)"
|
||||
>
|
||||
<span
|
||||
v-if="n.children.length"
|
||||
class="arrow"
|
||||
:class="{ open: !closed.has(n.path) }"
|
||||
@click.stop="toggleFolder(n.path)"
|
||||
>▸</span>
|
||||
<span v-else class="arrow" />
|
||||
<RefIcon v-if="n.ref" :kind="kind" />
|
||||
<svg v-else class="folder-icon" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<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))" />
|
||||
</div>
|
||||
<RefTree
|
||||
v-if="n.children.length && !closed.has(n.path)"
|
||||
:nodes="n.children"
|
||||
:kind="kind"
|
||||
:depth="depth + 1"
|
||||
:filters="filters"
|
||||
:head-ref="headRef"
|
||||
:closed="closed"
|
||||
@jump="emit('jump', $event)"
|
||||
@toggle="emit('toggle', $event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px 2px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.item:hover { background: var(--bg-hover); }
|
||||
.item.off { color: var(--text-dim); opacity: 0.55; }
|
||||
.item.head .name { font-weight: 600; color: #fff; }
|
||||
.item.folder { color: #c5cae0; }
|
||||
.folder-icon { flex: none; color: #ffd43b; opacity: 0.8; }
|
||||
.arrow { width: 10px; flex: none; color: var(--text-dim); font-size: 10px; transition: transform 0.12s; }
|
||||
.arrow.open { transform: rotate(90deg); }
|
||||
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item:hover :deep(.eye) { opacity: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
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 RefTree from "./RefTree.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
refs: Ref[];
|
||||
filters: FilterState;
|
||||
headRef: string | null;
|
||||
/** Commit time for each hash, used to put the newest tags first. */
|
||||
timeOf: (hash: string) => number;
|
||||
}>();
|
||||
const emit = defineEmits<{ jump: [ref: Ref] }>();
|
||||
|
||||
const pattern = ref("");
|
||||
const patternInput = ref<HTMLInputElement>();
|
||||
defineExpose({ focusPattern: () => patternInput.value?.focus() });
|
||||
|
||||
const groups: { kind: RefKind; title: string }[] = [
|
||||
{ kind: "local", title: "Local" },
|
||||
{ kind: "remote", title: "Remote" },
|
||||
{ kind: "tag", title: "Tags" },
|
||||
];
|
||||
|
||||
const closed = reactive(new Set<string>());
|
||||
|
||||
const byKind = computed(() => {
|
||||
const out: Record<RefKind, Ref[]> = { local: [], remote: [], tag: [] };
|
||||
for (const r of props.refs) out[refKind(r.name)].push(r);
|
||||
return out;
|
||||
});
|
||||
|
||||
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.
|
||||
const tags = [...byKind.value.tag].sort(
|
||||
(a, b) =>
|
||||
props.timeOf(b.target) - props.timeOf(a.target) ||
|
||||
b.name.localeCompare(a.name, undefined, { numeric: true }),
|
||||
);
|
||||
return {
|
||||
local: tree(byKind.value.local),
|
||||
remote: tree(byKind.value.remote),
|
||||
tag: tags.map((ref) => ({ label: shortName(ref.name), path: ref.name, children: [], ref })),
|
||||
};
|
||||
});
|
||||
|
||||
/** Hides all of `names`, or shows them all again if they are all hidden already. */
|
||||
function toggle(names: string[]) {
|
||||
const h = props.filters.hidden;
|
||||
if (names.every((n) => h.includes(n))) {
|
||||
props.filters.hidden = h.filter((n) => !names.includes(n));
|
||||
} else {
|
||||
for (const n of names) if (!h.includes(n)) h.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
function addRule(mode: "hide" | "only") {
|
||||
const p = pattern.value.trim();
|
||||
if (!p) return;
|
||||
props.filters.rules.push({ pattern: p, mode });
|
||||
pattern.value = "";
|
||||
}
|
||||
|
||||
function onPatternKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") addRule(e.shiftKey ? "only" : "hide");
|
||||
if (e.key === "Escape") (e.target as HTMLInputElement).blur();
|
||||
e.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="side">
|
||||
<section class="rules">
|
||||
<h3>Filters</h3>
|
||||
<input
|
||||
ref="patternInput"
|
||||
v-model="pattern"
|
||||
placeholder="pattern, e.g. prototypes/*"
|
||||
spellcheck="false"
|
||||
@keydown="onPatternKey"
|
||||
/>
|
||||
<div class="rule-buttons">
|
||||
<button title="Enter" @click="addRule('hide')">hide</button>
|
||||
<button title="Shift+Enter" @click="addRule('only')">show only</button>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span v-for="(r, i) in filters.rules" :key="i" class="chip" :class="r.mode">
|
||||
<button class="mode" title="switch hide / show only" @click="r.mode = r.mode === 'hide' ? 'only' : 'hide'">
|
||||
{{ r.mode === "hide" ? "hide" : "only" }}
|
||||
</button>
|
||||
<code>{{ r.pattern }}</code>
|
||||
<button class="x" title="remove" @click="filters.rules.splice(i, 1)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
<RefTree
|
||||
:nodes="trees[g.kind]"
|
||||
:kind="g.kind"
|
||||
:depth="0"
|
||||
:filters="filters"
|
||||
:head-ref="headRef"
|
||||
:closed="closed"
|
||||
@jump="emit('jump', $event)"
|
||||
@toggle="toggle"
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side {
|
||||
width: 270px;
|
||||
flex: none;
|
||||
background: var(--bg-side);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 10px 0 20px;
|
||||
}
|
||||
h3 {
|
||||
margin: 12px 14px 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.count { font-weight: normal; opacity: 0.7; }
|
||||
.rules { padding: 0 14px; }
|
||||
.rules h3 { margin-left: 0; margin-top: 2px; }
|
||||
.rules input { width: 100%; }
|
||||
.rule-buttons { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.rule-buttons button { flex: 1; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
padding-left: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.chip.hide { border-color: #ff6b6b88; background: #ff6b6b18; }
|
||||
.chip.only { border-color: #69db7c88; background: #69db7c18; }
|
||||
.chip button { border: none; padding: 1px 6px; border-radius: 999px; }
|
||||
.chip .mode { font-size: 10px; text-transform: uppercase; color: var(--text-dim); }
|
||||
.chip code { font-family: ui-monospace, monospace; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user