From a27a8666eec64ad98a0ca6b57e9ae9433c4f43f3 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 13 Aug 2026 22:51:24 +0200 Subject: [PATCH] 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. --- src-tauri/src/git.rs | 58 ++++++++- src/App.svelte | 9 ++ src/app.css | 156 +++++++++++++++++++++++++ src/lib/components/HistoryPanel.svelte | 103 ++++++++++++++-- src/lib/types.ts | 1 + 5 files changed, 314 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 1c2d379..dd73120 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -119,6 +119,7 @@ pub struct GitCommit { pub refs: Vec, pub parents: Vec, pub files: Vec, + pub has_note: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -3503,7 +3504,10 @@ fn commit_page_for_repo( repo, [ "log", - "--all", + "--branches", + "--remotes", + "--tags", + "HEAD", "--topo-order", "--decorate=short", "--name-status", @@ -3518,7 +3522,28 @@ fn commit_page_for_repo( ], )?; - parse_commit_log_inline(&output) + let mut commits = parse_commit_log_inline(&output)?; + mark_commits_with_notes(repo, &mut commits)?; + Ok(commits) +} + +fn mark_commits_with_notes(repo: &Path, commits: &mut [GitCommit]) -> Result<(), String> { + if commits.is_empty() || !ref_exists(repo, COMMIT_NOTES_REF)? { + return Ok(()); + } + + let output = run_git(repo, ["notes", "--ref", COMMIT_NOTES_REF, "list"])?; + let noted_commits: BTreeSet = String::from_utf8_lossy(&output) + .lines() + .filter_map(|line| line.split_whitespace().nth(1)) + .map(ToString::to_string) + .collect(); + + for commit in commits { + commit.has_note = noted_commits.contains(&commit.hash); + } + + Ok(()) } #[tauri::command] @@ -3704,7 +3729,9 @@ fn list_file_history_core( let output = run_git_cancellable(repo, args, cancellation, "Git file history failed")?; check_search_cancelled(cancellation)?; - parse_commit_log(repo, &output) + let mut commits = parse_commit_log(repo, &output)?; + mark_commits_with_notes(repo, &mut commits)?; + Ok(commits) } #[tauri::command] @@ -5139,6 +5166,7 @@ fn parse_commit_log_inline(output: &[u8]) -> Result, String> { parents, summary: String::from_utf8_lossy(parts[7]).to_string(), files: parse_commit_files(files_bytes)?, + has_note: false, }); } @@ -5187,6 +5215,7 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String parents, summary: fields[7].to_string(), files, + has_note: false, }); } @@ -5233,6 +5262,7 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result, String> { parents, summary: fields[7].to_string(), files: Vec::new(), + has_note: false, }); } @@ -6369,6 +6399,19 @@ mod tests { "Review: sieht gut aus\nBuild: 42", ) .expect("note should be created"); + let commits = commits_for_repo(&repo.path, Some(20)).expect("history should load"); + assert!( + commits + .iter() + .find(|commit| commit.hash == commit_before) + .expect("annotated commit should be in history") + .has_note + ); + assert!( + commits + .iter() + .all(|commit| commit.summary != "Notes added by 'git notes add'") + ); assert_eq!( commit_note_for_repo(&repo.path, &commit_before).expect("note should load"), Some("Review: sieht gut aus\nBuild: 42".to_string()) @@ -6382,6 +6425,14 @@ mod tests { ); delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted"); + let commits = commits_for_repo(&repo.path, Some(20)).expect("history should reload"); + assert!( + !commits + .iter() + .find(|commit| commit.hash == commit_before) + .expect("commit should remain in history") + .has_note + ); assert_eq!( commit_note_for_repo(&repo.path, &commit_before) .expect("deleted note lookup should work"), @@ -6782,6 +6833,7 @@ mod tests { ], summary: "Add history panel".to_string(), files: Vec::new(), + has_note: false, }] ); } diff --git a/src/App.svelte b/src/App.svelte index 6ab1dd8..4ffd5d0 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2998,6 +2998,11 @@ commitNoteLoading = false; } + async function loadCommitNotePreview(commit: GitCommit): Promise { + 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); diff --git a/src/app.css b/src/app.css index 697edef..29f2d6a 100644 --- a/src/app.css +++ b/src/app.css @@ -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); } diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte index 28e041e..c0fe304 100644 --- a/src/lib/components/HistoryPanel.svelte +++ b/src/lib/components/HistoryPanel.svelte @@ -80,6 +80,7 @@ onCherryPickCommit: (commit: GitCommit) => void; onRevertCommit: (commit: GitCommit) => void; onOpenCommitNote: (commit: GitCommit) => void; + onLoadCommitNote: (commit: GitCommit) => Promise; onSelectCommit: (commit: GitCommit) => void; } @@ -108,6 +109,7 @@ onCherryPickCommit = () => {}, onRevertCommit = () => {}, onOpenCommitNote = () => {}, + onLoadCommitNote = async () => null, onSelectCommit = () => {}, }: Props = $props(); @@ -120,6 +122,16 @@ let contextCommit = $state(null); let contextMenuX = $state(0); let contextMenuY = $state(0); + let notePreviews = $state>({}); + let notePreviewLoading = $state>(new Set()); + let notePreviewErrors = $state>(new Set()); + + $effect(() => { + repositoryKey; + notePreviews = {}; + notePreviewLoading = new Set(); + notePreviewErrors = new Set(); + }); function observeHistoryEnd(node: HTMLElement) { const root = node.closest(".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 @@
+ {#if item.has_note} + + {/if} {#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
@@ -966,6 +1010,43 @@
{item.short_hash} {item.author_name} + {#if item.has_note} + + + + + + + {#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} + + + + {/if}
@@ -1008,16 +1089,18 @@
- + {#if !item.has_note} + + {/if}