Commit preview panel at the bottom; open other repositories from a recent list or a folder picker
This commit is contained in:
@@ -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"><{{ details.author_email }}></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"><{{ details.committer_email }}></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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user