feat(git): add commit-note support and previews in history

This change adds Git notes support to the history UI.
Commits now carry a has_note flag which triggers a note indicator.
Notes can be previewed on hover and loaded on demand,
then the history can be refreshed after edits.

- Adds has_note support on commits and parses from logs.
- Renders a note indicator in history rows with a hover preview.
- Triggers history refresh after note-related actions.
This commit is contained in:
Christoph Brandau
2026-08-13 22:51:24 +02:00
parent c442b3735f
commit a27a8666ee
Notes: Christoph Brandau 2026-08-13 22:56:29 +02:00
one more test
5 changed files with 314 additions and 13 deletions
+9
View File
@@ -2998,6 +2998,11 @@
commitNoteLoading = false;
}
async function loadCommitNotePreview(commit: GitCommit): Promise<string | null> {
if (!activeRepoPath) return null;
return getCommitNote(activeRepoPath, commit.hash);
}
async function saveActiveCommitNote(note: string) {
const commit = commitNoteTarget;
const repo = commitNoteRepoPath;
@@ -3008,6 +3013,7 @@
try {
await setCommitNote(repo, commit.hash, note);
commitNoteText = note;
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: true } : item);
commitNoteStatus = appLanguage === "de"
? "Notiz gespeichert. Der Commit-Hash ist unverändert."
: "Note saved. The commit hash is unchanged.";
@@ -3029,6 +3035,7 @@
try {
await deleteCommitNote(repo, commit.hash);
commitNoteText = "";
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: false } : item);
commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted.";
trackEvent("commit_note_deleted");
} catch (error) {
@@ -3064,6 +3071,7 @@
const credential = await storedCredentialForNoteRemote(remote, direction);
if (direction === "fetch") {
await fetchCommitNotes(repo, remote, credential?.username, credential?.password);
await refreshCommitHistory(repo);
commitNoteText = (await getCommitNote(repo, commit.hash)) ?? "";
commitNoteStatus = appLanguage === "de"
? `Notizen von ${remote} geladen und zusammengeführt.`
@@ -5068,6 +5076,7 @@
onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit}
onOpenCommitNote={openCommitNoteDialog}
onLoadCommitNote={loadCommitNotePreview}
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes);
+156
View File
@@ -2542,6 +2542,107 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-note-indicator {
position: relative;
display: inline-flex;
flex: 0 0 auto;
}
.commit-note-presence {
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 18px;
padding: 1px 5px 1px 4px;
border: 1px solid transparent;
border-radius: 5px;
color: #dcb96c;
background: linear-gradient(90deg, rgba(216, 167, 74, 0.11), rgba(216, 167, 74, 0.045));
box-shadow: inset 0 -1px 0 rgba(216, 167, 74, 0.22);
font-size: 9px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.015em;
}
.commit-note-presence:hover:not(:disabled) {
border-color: rgba(216, 167, 74, 0.24);
color: #efd08a;
background: linear-gradient(90deg, rgba(216, 167, 74, 0.17), rgba(216, 167, 74, 0.075));
}
.commit-note-presence:focus-visible {
outline: 2px solid rgba(216, 167, 74, 0.32);
outline-offset: 2px;
}
.commit-note-tooltip {
position: absolute;
z-index: 40;
top: calc(100% + 7px);
right: 0;
display: grid;
width: min(270px, calc(100vw - 36px));
padding: 9px 10px 10px;
border: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
border-radius: 7px;
color: var(--color-ink-muted);
background: color-mix(in srgb, #d8a74a 4%, var(--color-surface-solid));
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.34), inset 2px 0 0 rgba(216, 167, 74, 0.5);
opacity: 0;
pointer-events: none;
transform: translateY(-3px);
visibility: hidden;
transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
}
.commit-note-tooltip::before {
content: "";
position: absolute;
top: -4px;
right: 12px;
width: 7px;
height: 7px;
border-top: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
border-left: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
background: color-mix(in srgb, #d8a74a 4%, var(--color-surface-solid));
transform: rotate(45deg);
}
.commit-note-indicator:hover,
.commit-note-indicator:focus-within { z-index: 40; }
.commit-note-indicator:hover .commit-note-tooltip,
.commit-note-indicator:focus-within .commit-note-tooltip {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
.commit-note-tooltip-head {
display: flex;
align-items: center;
gap: 5px;
padding-bottom: 6px;
border-bottom: 1px solid var(--color-border-subtle);
color: #dcb96c;
font-size: 9px;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.commit-note-tooltip-head small {
margin-left: auto;
color: var(--color-ink-faint);
font-size: 8px;
font-weight: 650;
letter-spacing: 0;
text-transform: none;
}
.commit-note-tooltip-body {
display: -webkit-box;
overflow: hidden;
padding-top: 7px;
color: var(--color-ink-muted);
font-size: 10.5px;
line-height: 1.45;
overflow-wrap: anywhere;
white-space: pre-wrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 7;
}
.commit-ref-area {
position: relative;
@@ -5985,6 +6086,29 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
}
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
.commit-note-rail {
position: absolute;
z-index: 2;
top: 10px;
bottom: 10px;
left: 0;
width: 2px;
border-radius: 0 2px 2px 0;
background: #d8a74a;
box-shadow: 0 0 8px rgba(216, 167, 74, 0.16);
opacity: 0.68;
pointer-events: none;
}
.graph-row.has-note .commit-body {
background:
linear-gradient(90deg, rgba(216, 167, 74, 0.075), rgba(216, 167, 74, 0.025) 36%, transparent 68%),
color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid));
}
.graph-row.has-note:hover .commit-body {
background:
linear-gradient(90deg, rgba(216, 167, 74, 0.105), rgba(216, 167, 74, 0.035) 36%, transparent 68%),
var(--color-surface-hover);
}
.graph-row {
content-visibility: auto;
contain-intrinsic-block-size: 108px;
@@ -6219,6 +6343,38 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
background: rgba(235,241,250,0.9);
}
:root[data-theme="light"] .graph-row.has-note .commit-body {
background:
linear-gradient(90deg, rgba(194, 132, 35, 0.09), rgba(194, 132, 35, 0.025) 38%, transparent 68%),
rgba(255,255,255,0.76);
}
:root[data-theme="light"] .graph-row.has-note:hover .commit-body {
background:
linear-gradient(90deg, rgba(194, 132, 35, 0.12), rgba(194, 132, 35, 0.035) 38%, transparent 68%),
rgba(235,241,250,0.92);
}
:root[data-theme="light"] .commit-note-presence {
border-color: transparent;
color: #986313;
background: linear-gradient(90deg, rgba(194, 132, 35, 0.12), rgba(194, 132, 35, 0.05));
box-shadow: inset 0 -1px 0 rgba(168, 105, 16, 0.2);
}
:root[data-theme="light"] .commit-note-tooltip,
:root[data-theme="light"] .commit-note-tooltip::before {
background: color-mix(in srgb, #c28423 4%, #ffffff);
}
:root[data-theme="light"] .commit-note-tooltip {
box-shadow: 0 12px 30px rgba(35, 45, 68, 0.16), inset 2px 0 0 rgba(194, 132, 35, 0.48);
}
:root[data-theme="light"] .commit-note-tooltip-head {
color: #986313;
}
:root[data-theme="light"] .graph-row.merge-row .commit-body {
background: rgba(248,244,252,0.82);
}
+93 -10
View File
@@ -80,6 +80,7 @@
onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void;
onOpenCommitNote: (commit: GitCommit) => void;
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
onSelectCommit: (commit: GitCommit) => void;
}
@@ -108,6 +109,7 @@
onCherryPickCommit = () => {},
onRevertCommit = () => {},
onOpenCommitNote = () => {},
onLoadCommitNote = async () => null,
onSelectCommit = () => {},
}: Props = $props();
@@ -120,6 +122,16 @@
let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let notePreviews = $state<Record<string, string>>({});
let notePreviewLoading = $state<Set<string>>(new Set());
let notePreviewErrors = $state<Set<string>>(new Set());
$effect(() => {
repositoryKey;
notePreviews = {};
notePreviewLoading = new Set();
notePreviewErrors = new Set();
});
function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list");
@@ -503,6 +515,34 @@
await onOpenCommitNote(commit);
}
async function loadCommitNotePreview(commit: GitCommit) {
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
const loading = new Set(notePreviewLoading);
loading.add(commit.hash);
notePreviewLoading = loading;
const errors = new Set(notePreviewErrors);
errors.delete(commit.hash);
notePreviewErrors = errors;
try {
const note = await onLoadCommitNote(commit);
notePreviews = {
...notePreviews,
[commit.hash]: note?.trim() || "This Git note is empty.",
};
} catch {
const nextErrors = new Set(notePreviewErrors);
nextErrors.add(commit.hash);
notePreviewErrors = nextErrors;
} finally {
const nextLoading = new Set(notePreviewLoading);
nextLoading.delete(commit.hash);
notePreviewLoading = nextLoading;
}
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
closeCommitContextMenu();
@@ -792,6 +832,7 @@
<article
class="commit-row graph-row"
class:selected={selectedCommitHash === item.hash}
class:has-note={item.has_note}
data-commit-hash={item.hash}
class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"}
@@ -852,6 +893,9 @@
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
>
{#if item.has_note}
<span class="commit-note-rail" aria-hidden="true"></span>
{/if}
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references">
@@ -966,6 +1010,43 @@
<div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
{#if item.has_note}
<span class="commit-note-indicator">
<button
class="commit-note-presence"
type="button"
onpointerenter={() => void loadCommitNotePreview(item)}
onfocus={() => void loadCommitNotePreview(item)}
onclick={() => openCommitNote(item)}
disabled={isBusy}
aria-label={`Open Git note for ${item.short_hash}`}
aria-describedby={`commit-note-preview-${item.hash}`}
>
<StickyNote size={11} aria-hidden="true" />
<span>Note</span>
</button>
<span
class="commit-note-tooltip"
id={`commit-note-preview-${item.hash}`}
role="tooltip"
>
<span class="commit-note-tooltip-head">
<StickyNote size={12} aria-hidden="true" />
Git Note
<small>Click to open</small>
</span>
<span class="commit-note-tooltip-body">
{#if notePreviewLoading.has(item.hash)}
Loading note…
{:else if notePreviewErrors.has(item.hash)}
Note could not be loaded.
{:else}
{notePreviews[item.hash] ?? "Hover to load the note."}
{/if}
</span>
</span>
</span>
{/if}
</div>
</div>
</div>
@@ -1008,16 +1089,18 @@
<div class="commit-actions">
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button
class="commit-menu-button commit-note-button"
type="button"
onclick={() => openCommitNote(item)}
disabled={isBusy}
title={`Open internal note for ${item.short_hash}`}
aria-label={`Open internal note for ${item.short_hash}`}
>
<StickyNote size={14} aria-hidden="true" />
</button>
{#if !item.has_note}
<button
class="commit-menu-button commit-note-button"
type="button"
onclick={() => openCommitNote(item)}
disabled={isBusy}
title={`Add a Git note to ${item.short_hash}`}
aria-label={`Add a Git note to ${item.short_hash}`}
>
<StickyNote size={14} aria-hidden="true" />
</button>
{/if}
<button
class="commit-menu-button"
type="button"
+1
View File
@@ -178,6 +178,7 @@ export interface GitCommit {
refs: string[];
parents: string[];
files: GitCommitFile[];
has_note: boolean;
}
export interface GitCommitFile {