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:
Notes:
Christoph Brandau
2026-08-13 22:56:29 +02:00
one more test
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -178,6 +178,7 @@ export interface GitCommit {
|
||||
refs: string[];
|
||||
parents: string[];
|
||||
files: GitCommitFile[];
|
||||
has_note: boolean;
|
||||
}
|
||||
|
||||
export interface GitCommitFile {
|
||||
|
||||
Reference in New Issue
Block a user