feat(blame): add file blame dialog with git porcelain parsing
This change introduces a new backend command to fetch file blame using git's line-porcelain output and returns structured per-line metadata. The UI adds a BlameDialog that groups lines by commit, highlights uncommitted changes, and styles the dialog to match the updated theme. - Add get_file_blame command and parsing with uncommitted detection - Wire BlameDialog into the explorer file node actions - Add blame UI component and supporting types and styles
This commit is contained in:
@@ -85,7 +85,14 @@
|
||||
"Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)",
|
||||
"Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")",
|
||||
"Bash(grep -v \"^--$\")",
|
||||
"Bash(grep \"^error$\" -A2)"
|
||||
"Bash(grep \"^error$\" -A2)",
|
||||
"Bash(cargo run *)",
|
||||
"Bash(node_modules/.bin/svelte-check --version)",
|
||||
"Bash(rustfmt --check --edition 2021 src/git.rs src/main.rs)",
|
||||
"Bash(rustfmt --check --edition 2021 src/git.rs)",
|
||||
"Bash(awk *)",
|
||||
"Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)",
|
||||
"Read(//tmp/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,25 @@ pub struct GitCommitComparison {
|
||||
pub patch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitBlameLine {
|
||||
pub line_number: u32,
|
||||
pub content: String,
|
||||
pub commit_hash: String,
|
||||
pub short_hash: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub author_time: i64,
|
||||
pub summary: String,
|
||||
pub is_uncommitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitBlameResult {
|
||||
pub path: String,
|
||||
pub lines: Vec<GitBlameLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ConflictFile {
|
||||
pub path: String,
|
||||
@@ -1607,6 +1626,107 @@ pub fn cancel_file_history(
|
||||
state.cancel(request_id)
|
||||
}
|
||||
|
||||
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
return Err("Repository has no commits yet.".to_string());
|
||||
}
|
||||
|
||||
let args = vec![
|
||||
OsString::from("blame"),
|
||||
OsString::from("--line-porcelain"),
|
||||
OsString::from("--"),
|
||||
OsString::from(file.clone()),
|
||||
];
|
||||
let output =
|
||||
run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?;
|
||||
|
||||
Ok(GitBlameResult {
|
||||
path: file,
|
||||
lines: parse_blame_porcelain(&output),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_blame_porcelain(output: &[u8]) -> Vec<GitBlameLine> {
|
||||
#[derive(Default, Clone)]
|
||||
struct BlameMeta {
|
||||
author_name: String,
|
||||
author_email: String,
|
||||
author_time: i64,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(output);
|
||||
let mut lines_out: Vec<GitBlameLine> = Vec::new();
|
||||
let mut commit_meta: BTreeMap<String, BlameMeta> = BTreeMap::new();
|
||||
let mut current_hash = String::new();
|
||||
let mut current_final_line: u32 = 0;
|
||||
|
||||
for line in text.split('\n') {
|
||||
if let Some(content) = line.strip_prefix('\t') {
|
||||
let meta = commit_meta.get(¤t_hash).cloned().unwrap_or_default();
|
||||
lines_out.push(GitBlameLine {
|
||||
line_number: current_final_line,
|
||||
content: content.to_string(),
|
||||
commit_hash: current_hash.clone(),
|
||||
short_hash: short_hash(¤t_hash),
|
||||
author_name: meta.author_name,
|
||||
author_email: meta.author_email,
|
||||
author_time: meta.author_time,
|
||||
summary: meta.summary,
|
||||
is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut parts = line.splitn(2, ' ');
|
||||
let head = parts.next().unwrap_or("");
|
||||
let tail = parts.next().unwrap_or("");
|
||||
|
||||
if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
if let Some(final_line) = tail.split_whitespace().nth(1) {
|
||||
current_final_line = final_line.parse().unwrap_or(current_final_line);
|
||||
}
|
||||
current_hash = head.to_string();
|
||||
commit_meta.entry(current_hash.clone()).or_default();
|
||||
continue;
|
||||
}
|
||||
|
||||
match head {
|
||||
"author" => {
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_name = tail.to_string()
|
||||
}
|
||||
"author-mail" => {
|
||||
let email = tail.trim_matches(|c| c == '<' || c == '>').to_string();
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_email = email;
|
||||
}
|
||||
"author-time" => {
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_time = tail.parse().unwrap_or(0);
|
||||
}
|
||||
"summary" => {
|
||||
commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
lines_out
|
||||
}
|
||||
|
||||
fn list_file_history_core(
|
||||
repo: &Path,
|
||||
file: String,
|
||||
@@ -5419,4 +5539,44 @@ mod tests {
|
||||
|
||||
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
|
||||
let repo = init_temp_repo("file_blame");
|
||||
commit_initial_file(&repo.path);
|
||||
fs::write(repo.path.join("old.txt"), "original\ntwo\n").expect("tracked file should change");
|
||||
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
|
||||
|
||||
let result = get_file_blame(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.path, "old.txt");
|
||||
assert_eq!(result.lines.len(), 2);
|
||||
assert_eq!(result.lines[0].content, "original");
|
||||
assert_eq!(result.lines[0].summary, "init");
|
||||
assert_eq!(result.lines[1].content, "two");
|
||||
assert_eq!(result.lines[1].summary, "add second line");
|
||||
assert!(!result.lines[1].author_name.is_empty());
|
||||
assert!(!result.lines[1].is_uncommitted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_blame_marks_uncommitted_working_tree_changes() {
|
||||
let repo = init_temp_repo("file_blame_uncommitted");
|
||||
commit_initial_file(&repo.path);
|
||||
fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change");
|
||||
|
||||
let result = get_file_blame(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.lines.len(), 1);
|
||||
assert!(result.lines[0].is_uncommitted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use git::{
|
||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_patch,
|
||||
get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
||||
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
||||
@@ -76,6 +76,7 @@ fn main() {
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
cancel_file_history,
|
||||
get_file_blame,
|
||||
compare_commits,
|
||||
compare_file_to_head,
|
||||
compare_file_to_parent,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
||||
import BlameDialog from "./lib/components/BlameDialog.svelte";
|
||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||
@@ -50,6 +51,7 @@
|
||||
diffFileAgainstWorkingTree,
|
||||
compareFileToParent,
|
||||
fetchRemote,
|
||||
getFileBlame,
|
||||
getStatus,
|
||||
lastCommitMessage,
|
||||
listBranches,
|
||||
@@ -97,6 +99,7 @@
|
||||
ConflictFile,
|
||||
ExplorerNode,
|
||||
ExplorerNodeKind,
|
||||
GitBlameLine,
|
||||
GitBranch as GitBranchInfo,
|
||||
GitCommit,
|
||||
GitCommitFile,
|
||||
@@ -218,6 +221,11 @@
|
||||
let linePatchText = "";
|
||||
let linePatchLoading = false;
|
||||
let linePatchError = "";
|
||||
let blameOpen = false;
|
||||
let blameFilePath = "";
|
||||
let blameLines: GitBlameLine[] = [];
|
||||
let blameLoading = false;
|
||||
let blameError = "";
|
||||
let pendingDiscard: PendingDiscard | null = null;
|
||||
let globalSearchOpen = false;
|
||||
let lastSearchQuery = "";
|
||||
@@ -1921,6 +1929,33 @@
|
||||
linePatchError = "";
|
||||
}
|
||||
|
||||
async function openBlame(node: ExplorerNode) {
|
||||
if (!activeRepoPath || node.kind !== "file") return;
|
||||
blameOpen = true;
|
||||
blameFilePath = node.path;
|
||||
blameLines = [];
|
||||
blameError = "";
|
||||
blameLoading = true;
|
||||
|
||||
try {
|
||||
const result = await getFileBlame(activeRepoPath, node.path);
|
||||
blameLines = result.lines;
|
||||
} catch (error) {
|
||||
blameError = errorToMessage(error);
|
||||
errorMessage = blameError;
|
||||
} finally {
|
||||
blameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeBlame() {
|
||||
if (isBusy) return;
|
||||
blameOpen = false;
|
||||
blameFilePath = "";
|
||||
blameLines = [];
|
||||
blameError = "";
|
||||
}
|
||||
|
||||
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string {
|
||||
switch (action) {
|
||||
case "stage":
|
||||
@@ -2726,6 +2761,7 @@
|
||||
onCollapseAllFolders={collapseAllExplorerFolders}
|
||||
onSelectNode={selectExplorerNode}
|
||||
onOpenFile={openFileFromExplorer}
|
||||
onBlame={openBlame}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -2886,6 +2922,17 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if blameOpen}
|
||||
<BlameDialog
|
||||
filePath={blameFilePath}
|
||||
lines={blameLines}
|
||||
{isBusy}
|
||||
isLoading={blameLoading}
|
||||
error={blameError}
|
||||
onClose={closeBlame}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pendingDiscard}
|
||||
<DiscardConfirmDialog
|
||||
file={pendingDiscard.file}
|
||||
|
||||
+152
-20
@@ -234,8 +234,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(4,8,18,0.58);
|
||||
backdrop-filter: blur(8px);
|
||||
background: #050712;
|
||||
}
|
||||
|
||||
.branch-filter-dialog {
|
||||
@@ -247,8 +246,8 @@
|
||||
border: 1px solid rgba(94,110,156,0.24);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 70%),
|
||||
var(--color-surface);
|
||||
linear-gradient(180deg, #20253f 0%, #151827 72%),
|
||||
#151827;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
@@ -2416,8 +2415,8 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
background-color: #050712;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@@ -2427,7 +2426,8 @@
|
||||
height: min(760px, 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
background-color: #111321;
|
||||
background-image: none;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.6), 0 2px 12px rgba(0,0,0,0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2444,6 +2444,11 @@
|
||||
width: min(1320px, calc(100vw - 32px));
|
||||
height: min(860px, calc(100vh - 32px));
|
||||
}
|
||||
.blame-dialog {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(1320px, calc(100vw - 32px));
|
||||
height: min(860px, calc(100vh - 32px));
|
||||
}
|
||||
.compare-select-dialog {
|
||||
display: block;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
@@ -2643,7 +2648,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
||||
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||
.dialog-header > div:first-child { min-width: 0; }
|
||||
.dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; }
|
||||
.compare-restore { max-width: 170px; min-width: 0; }
|
||||
@@ -2657,7 +2662,7 @@
|
||||
.dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; }
|
||||
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
|
||||
|
||||
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
||||
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||
|
||||
.dialog-file-row {
|
||||
display: grid;
|
||||
@@ -2687,7 +2692,7 @@
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: var(--color-surface-dim);
|
||||
background: #171a2b;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-muted);
|
||||
@@ -2709,7 +2714,7 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
background: var(--color-surface-raised);
|
||||
background: #111321;
|
||||
}
|
||||
|
||||
.split-pane {
|
||||
@@ -2733,7 +2738,7 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.split-span.split-meta { color: var(--color-ink-faint); background: rgba(0,0,0,0.18); font-size: 11px; }
|
||||
.split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; }
|
||||
.split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; }
|
||||
|
||||
.split-num {
|
||||
@@ -2743,11 +2748,11 @@
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background: rgba(0,0,0,0.14);
|
||||
background: #0d101a;
|
||||
}
|
||||
.split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); }
|
||||
.split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); }
|
||||
.split-num.empty { background: rgba(0,0,0,0.08); }
|
||||
.split-num.empty { background: #10131e; }
|
||||
|
||||
.split-cell {
|
||||
padding: 0 8px;
|
||||
@@ -2758,7 +2763,7 @@
|
||||
}
|
||||
.split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; }
|
||||
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
|
||||
.split-cell.empty { background: rgba(0,0,0,0.06); }
|
||||
.split-cell.empty { background: #10131e; }
|
||||
|
||||
/* Search-hit highlight: amber, distinct from add (green) / del (red).
|
||||
Higher specificity so it overrides the add/del backgrounds on a matched line. */
|
||||
@@ -2789,7 +2794,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-ink-faint);
|
||||
background: rgba(0,0,0,0.1);
|
||||
background: #0d101a;
|
||||
}
|
||||
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
|
||||
.split-col-hash {
|
||||
@@ -2805,7 +2810,7 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
||||
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||
.dialog-footer-info { color: var(--color-ink-dim); font-size: 13px; font-weight: 700; }
|
||||
|
||||
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
||||
@@ -2980,6 +2985,134 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.blame-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #111321;
|
||||
}
|
||||
|
||||
.blame-code-header strong {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.blame-column-headers {
|
||||
grid-template-columns: minmax(300px, 340px) minmax(0, 1fr);
|
||||
}
|
||||
.blame-commit-col-label {
|
||||
padding-left: 12px;
|
||||
}
|
||||
.blame-code-col-label {
|
||||
padding-left: calc(3.2rem + 10px);
|
||||
}
|
||||
.blame-commit-col-label + .blame-code-col-label {
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.blame-diff {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.blame-scroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.blame-code-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.blame-group {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 340px) minmax(max-content, 1fr);
|
||||
align-items: stretch;
|
||||
min-width: max-content;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.blame-group.uncommitted {
|
||||
background: #171725;
|
||||
}
|
||||
|
||||
.blame-meta {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background: #0d101a;
|
||||
box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.blame-hash {
|
||||
align-self: flex-start;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid rgba(90,140,248,0.2);
|
||||
border-radius: 5px;
|
||||
background: #141b2d;
|
||||
color: var(--color-accent);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.blame-author {
|
||||
overflow: hidden;
|
||||
color: var(--color-ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.blame-summary {
|
||||
overflow: hidden;
|
||||
color: #aeb6d8;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.blame-date {
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.blame-group:hover .blame-meta {
|
||||
background: #111728;
|
||||
}
|
||||
.blame-group.uncommitted .blame-hash,
|
||||
.blame-group.uncommitted .blame-author {
|
||||
color: #e8b45a;
|
||||
}
|
||||
.blame-group.uncommitted .blame-hash {
|
||||
border-color: rgba(232, 180, 90, 0.26);
|
||||
background: #271f14;
|
||||
}
|
||||
|
||||
.blame-lines {
|
||||
min-width: max-content;
|
||||
grid-template-columns: 4rem minmax(max-content, 1fr);
|
||||
}
|
||||
.blame-line-number {
|
||||
min-height: 20px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.blame-line-code {
|
||||
min-height: 20px;
|
||||
}
|
||||
.blame-group:hover .blame-line-number {
|
||||
background: #111728;
|
||||
}
|
||||
.blame-group:hover .blame-line-code {
|
||||
background: #151a2b;
|
||||
}
|
||||
|
||||
.global-search-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
@@ -3370,11 +3503,10 @@
|
||||
border: 1px solid rgba(100, 108, 255, 0.36);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.06), transparent 42%),
|
||||
rgba(15, 16, 28, 0.96);
|
||||
linear-gradient(180deg, #222743 0%, #111321 42%),
|
||||
#111321;
|
||||
box-shadow: 0 32px 90px rgba(0, 0, 0, 0.68), 0 0 0 1px rgba(255,255,255,0.04) inset;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.cred-hero {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { FileCode, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitBlameLine } from "../types";
|
||||
|
||||
interface BlameGroup {
|
||||
id: string;
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
summary: string;
|
||||
authorTime: number;
|
||||
isUncommitted: boolean;
|
||||
lines: GitBlameLine[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filePath: string;
|
||||
lines: GitBlameLine[];
|
||||
isBusy: boolean;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filePath = "",
|
||||
lines = [],
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
error = "",
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
|
||||
const groups: BlameGroup[] = [];
|
||||
for (const line of source) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.hash === line.commit_hash) {
|
||||
last.lines.push(line);
|
||||
continue;
|
||||
}
|
||||
groups.push({
|
||||
id: `${line.commit_hash}-${line.line_number}`,
|
||||
hash: line.commit_hash,
|
||||
shortHash: line.short_hash,
|
||||
authorName: line.author_name,
|
||||
authorEmail: line.author_email,
|
||||
summary: line.summary,
|
||||
authorTime: line.author_time,
|
||||
isUncommitted: line.is_uncommitted,
|
||||
lines: [line],
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function formatBlameDate(seconds: number): string {
|
||||
if (!seconds) return "";
|
||||
const date = new Date(seconds * 1000);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
function groupTooltip(group: BlameGroup): string {
|
||||
if (group.isUncommitted) return "Not committed yet";
|
||||
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
|
||||
}
|
||||
|
||||
let groups = $derived(groupBlameLines(lines));
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Blame</span>
|
||||
<p class="dialog-title" title={filePath}>{filePath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<span class="pill pill-count">{lines.length}</span>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dialog-diff blame-body">
|
||||
{#if isLoading}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
Loading blame...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state">{error}</div>
|
||||
{:else if groups.length === 0}
|
||||
<div class="blank-state">No blame information available for this file.</div>
|
||||
{:else}
|
||||
<div class="diff-header blame-code-header">
|
||||
<FileCode size={13} aria-hidden="true" />
|
||||
<span title={filePath}>{filePath}</span>
|
||||
<strong>{lines.length} lines</strong>
|
||||
</div>
|
||||
<div class="split-col-headers blame-column-headers">
|
||||
<div class="split-col-label blame-commit-col-label">Commit</div>
|
||||
<div class="split-col-label blame-code-col-label">Code</div>
|
||||
</div>
|
||||
<div class="split-diff blame-diff" role="table" aria-label="File blame">
|
||||
<div class="split-pane blame-scroll">
|
||||
<div class="blame-code-table">
|
||||
{#each groups as group (group.id)}
|
||||
<div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}>
|
||||
<div class="blame-meta">
|
||||
<span class="blame-hash">{group.isUncommitted ? "Uncommitted" : group.shortHash}</span>
|
||||
<span class="blame-author">{group.isUncommitted ? "Not committed yet" : group.authorName}</span>
|
||||
<span class="blame-summary">{group.summary || "No commit message"}</span>
|
||||
{#if !group.isUncommitted}
|
||||
<span class="blame-date">{formatBlameDate(group.authorTime)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="split-pane-grid blame-lines">
|
||||
{#each group.lines as line (line.line_number)}
|
||||
<span class="split-num blame-line-number">{line.line_number}</span>
|
||||
<code class="split-cell blame-line-code">{line.content}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,6 +18,7 @@
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ExternalLink,
|
||||
History,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
import { languageIconForPath } from "../languageIcons";
|
||||
@@ -36,6 +37,7 @@
|
||||
onCollapseAllFolders: () => void;
|
||||
onSelectNode: (node: ExplorerNode) => void;
|
||||
onOpenFile: (node: ExplorerNode) => void;
|
||||
onBlame: (node: ExplorerNode) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -50,6 +52,7 @@
|
||||
onCollapseAllFolders = () => {},
|
||||
onSelectNode = () => {},
|
||||
onOpenFile = () => {},
|
||||
onBlame = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let contextNode = $state<ExplorerNode | null>(null);
|
||||
@@ -182,6 +185,13 @@
|
||||
onOpenFile(node);
|
||||
}
|
||||
|
||||
function openContextBlame() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onBlame(node);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeFileContextMenu();
|
||||
}
|
||||
@@ -335,5 +345,15 @@
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
Open in Explorer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextBlame}
|
||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -47,8 +47,7 @@
|
||||
z-index: 400;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(6, 6, 14, 0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
background: #050712;
|
||||
animation: overlay-in 180ms ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CommitAiProvider,
|
||||
CommitAiStatus,
|
||||
ConflictFile,
|
||||
GitBlameResult,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
@@ -322,6 +323,10 @@ export function cancelFileHistory(requestId: string): Promise<void> {
|
||||
return invoke<void>("cancel_file_history", { requestId });
|
||||
}
|
||||
|
||||
export function getFileBlame(path: string, file: string): Promise<GitBlameResult> {
|
||||
return invoke<GitBlameResult>("get_file_blame", { path, file });
|
||||
}
|
||||
|
||||
export function compareCommits(
|
||||
path: string,
|
||||
from: string,
|
||||
|
||||
@@ -175,6 +175,23 @@ export interface ConflictFile {
|
||||
theirs_size: number | null;
|
||||
}
|
||||
|
||||
export interface GitBlameLine {
|
||||
line_number: number;
|
||||
content: string;
|
||||
commit_hash: string;
|
||||
short_hash: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_time: number;
|
||||
summary: string;
|
||||
is_uncommitted: boolean;
|
||||
}
|
||||
|
||||
export interface GitBlameResult {
|
||||
path: string;
|
||||
lines: GitBlameLine[];
|
||||
}
|
||||
|
||||
export interface StoredCredential {
|
||||
username: string;
|
||||
password: string;
|
||||
|
||||
Reference in New Issue
Block a user