Commit preview panel at the bottom; open other repositories from a recent list or a folder picker
ci / check (push) Successful in 4m13s
release / release (push) Successful in 9m54s

This commit is contained in:
2026-10-15 00:00:12 +02:00
parent 0c5340aca4
commit 58268c4015
14 changed files with 725 additions and 45 deletions
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed } from "vue";
import type { CommitDetails, ChangedFile } from "../lib/types";
import type { Label } from "./LabelStack.vue";
import RefIcon from "./RefIcon.vue";
const props = defineProps<{
details: CommitDetails | null;
error: string | null;
labels: Label[];
color: string;
formatTime: (t: number) => string;
}>();
const emit = defineEmits<{ jump: [hash: string]; copy: [text: string] }>();
const subject = computed(() => props.details?.message.split("\n")[0] ?? "");
const body = computed(() => props.details?.message.split("\n").slice(1).join("\n").trim() ?? "");
const sameCommitter = computed(
() =>
props.details &&
props.details.committer === props.details.author &&
props.details.committer_email === props.details.author_email,
);
const totals = computed(() =>
(props.details?.files ?? []).reduce((t, f) => ({ a: t.a + (f.added ?? 0), d: t.d + (f.deleted ?? 0) }), { a: 0, d: 0 }),
);
/** Five small blocks showing the share of added (green) and deleted (red) lines. */
function bars(f: ChangedFile) {
const a = f.added ?? 0, d = f.deleted ?? 0, total = a + d;
if (total === 0) return [];
const n = Math.min(5, Math.max(1, Math.round(Math.log2(total + 1))));
const green = Math.round((a / total) * n);
return Array.from({ length: 5 }, (_, i) => (i < green ? "a" : i < n ? "d" : ""));
}
function split(path: string) {
const i = path.lastIndexOf("/");
return i < 0 ? { dir: "", name: path } : { dir: path.slice(0, i + 1), name: path.slice(i + 1) };
}
</script>
<template>
<section class="preview">
<div v-if="error" class="state error">{{ error }}</div>
<div v-else-if="!details" class="state">Loading…</div>
<template v-else>
<div class="info">
<h3>{{ subject }}</h3>
<p v-if="body" class="body">{{ body }}</p>
<dl>
<dt>Author</dt>
<dd>{{ details.author }} <span class="dim">&lt;{{ details.author_email }}&gt;</span> · {{ formatTime(details.author_time) }}</dd>
<template v-if="!sameCommitter || details.commit_time !== details.author_time">
<dt>Committer</dt>
<dd>
<template v-if="!sameCommitter">{{ details.committer }} <span class="dim">&lt;{{ details.committer_email }}&gt;</span> · </template>
{{ formatTime(details.commit_time) }}
</dd>
</template>
<dt>Hash</dt>
<dd><button class="link mono" title="copy" @click="emit('copy', details.hash)">{{ details.hash }}</button></dd>
<dt>{{ details.parents.length > 1 ? "Parents" : "Parent" }}</dt>
<dd>
<span v-if="!details.parents.length" class="dim">none, this is the first commit</span>
<button v-for="p in details.parents" :key="p" class="link mono" title="go to this commit" @click="emit('jump', p)">
{{ p.slice(0, 10) }}
</button>
</dd>
<template v-if="labels.length">
<dt>Labels</dt>
<dd class="labels">
<span v-for="l in labels" :key="l.name" class="label" :style="{ '--c': color }"><RefIcon :kind="l.kind" />{{ l.short }}</span>
</dd>
</template>
</dl>
</div>
<div class="files">
<div class="files-head">
{{ details.files.length }} changed file{{ details.files.length === 1 ? "" : "s" }}
<span class="a">+{{ totals.a }}</span> <span class="d">−{{ totals.d }}</span>
<span v-if="details.parents.length > 1" class="dim">compared with the first parent</span>
</div>
<div v-for="f in details.files" :key="f.path" class="file" :title="f.old_path ? `${f.old_path} → ${f.path}` : f.path">
<span class="st" :class="f.status">{{ f.status }}</span>
<span class="path">
<template v-if="f.old_path"><span class="dim">{{ f.old_path }} → </span></template>
<span class="dim">{{ split(f.path).dir }}</span>{{ split(f.path).name }}
</span>
<span v-if="f.added === null" class="n dim">binary</span>
<span v-else class="n"><span class="a">+{{ f.added }}</span> <span class="d">−{{ f.deleted }}</span></span>
<span class="bars"><i v-for="(b, i) in bars(f)" :key="i" :class="b" /></span>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.preview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
background: var(--bg-side);
user-select: text;
}
.state { grid-column: 1 / -1; padding: 16px; color: var(--text-dim); }
.state.error { color: #ff8787; }
.info, .files { overflow: auto; padding: 12px 16px; min-width: 0; }
.files { border-left: 1px solid var(--border); }
h3 { margin: 0 0 6px; font-size: 14px; line-height: 1.35; }
.body { margin: 0 0 10px; white-space: pre-wrap; font-size: 12.5px; color: var(--text); }
dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 3px 12px; margin: 0; font-size: 12px; }
dt { color: var(--text-dim); }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
.dim { color: var(--text-dim); }
.mono { font-family: ui-monospace, monospace; font-size: 11.5px; }
.link { border: none; padding: 0; color: #9aa3c7; background: none; cursor: pointer; }
.link:hover { color: var(--accent); background: none; text-decoration: underline; }
.label {
display: inline-flex; align-items: center; gap: 4px; padding: 0 7px; border-radius: 999px; font-size: 11.5px;
color: var(--c); border: 1px solid color-mix(in srgb, var(--c) 55%, var(--bg)); background: color-mix(in srgb, var(--c) 16%, var(--bg));
}
.files-head { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
.files-head .dim { text-transform: none; letter-spacing: 0; }
.file { display: flex; align-items: center; gap: 8px; padding: 1px 4px; border-radius: 4px; font-size: 12px; }
.file:hover { background: var(--bg-hover); }
.st { width: 12px; flex: none; font: 700 11px ui-monospace, monospace; }
.st.A { color: #69db7c; } .st.M { color: #ffa94d; } .st.D { color: #ff6b6b; } .st.R, .st.C { color: #4dabf7; } .st.T { color: #b197fc; }
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11.5px ui-monospace, monospace; }
.n { flex: none; font: 11px ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.a { color: #69db7c; } .d { color: #ff6b6b; }
.bars { display: inline-flex; gap: 1px; flex: none; width: 29px; }
.bars i { width: 5px; height: 8px; background: var(--border); }
.bars i.a { background: #69db7c; } .bars i.d { background: #ff6b6b; }
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
const props = defineProps<{ recents: string[]; current: string | null }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
// The current repository is not offered: picking it would do nothing.
const items = computed(() => props.recents.filter((p) => p !== props.current));
/** Index into items; items.length means the "Open folder…" entry. */
const active = ref(0);
const menu = ref<HTMLElement>();
onMounted(() => menu.value?.focus());
function choose(i: number) {
if (i >= items.value.length) emit("browse");
else emit("pick", items.value[i]);
}
function onKey(e: KeyboardEvent) {
// Keys stay in the menu while it is open.
e.stopPropagation();
const last = items.value.length;
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
else if (e.key === "Enter") choose(active.value);
else if (e.key === "Delete" && active.value < last) emit("remove", items.value[active.value]);
else if (e.key === "Escape" || e.key === "o") emit("close");
else return;
e.preventDefault();
}
const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
</script>
<template>
<div class="backdrop" @click="emit('close')" />
<div ref="menu" class="menu" tabindex="-1" @keydown="onKey">
<div class="head">Recent repositories</div>
<div v-if="!items.length" class="none">No other repositories yet.</div>
<div
v-for="(p, i) in items"
:key="p"
class="item"
:class="{ active: i === active }"
@mouseenter="active = i"
@click="choose(i)"
>
<span class="name">{{ name(p) }}</span>
<span class="path">{{ p }}</span>
<button class="x" title="remove from the list (Delete)" @click.stop="emit('remove', p)">×</button>
</div>
<div
class="item browse"
:class="{ active: active === items.length }"
@mouseenter="active = items.length"
@click="choose(items.length)"
>
Open folder…
</div>
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
</div>
</template>
<style scoped>
.backdrop { position: fixed; inset: 0; z-index: 90; }
.menu {
position: absolute;
top: 38px;
left: 70px;
z-index: 100;
width: min(560px, calc(100vw - 90px));
max-height: 70vh;
overflow-y: auto;
background: #181b2c;
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 40px #000a;
padding: 6px;
outline: none;
}
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); padding: 4px 10px 6px; }
.none { padding: 6px 10px; color: var(--text-dim); }
.item { display: flex; align-items: baseline; gap: 10px; padding: 6px 10px; border-radius: 6px; cursor: pointer; }
.item.active { background: var(--bg-select); }
.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: 0 6px; color: var(--text-dim); opacity: 0; }
.item.active .x { opacity: 1; }
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
</style>