Compare commits

...
4 Commits
Author SHA1 Message Date
Christoph Brandau 40311275b0 style(app): remove border radius on commit-body
Removes the rounded corners on the commit-body by setting border-radius to 0.
This makes the component use a flat edge and aligns with the design system.

- Remove dependency on ui radius variable for commit-body
2026-08-13 23:07:19 +02:00
Christoph Brandau fe577d78a8 feat(app): add background commit notes fetch and refresh logic
This change adds a background path for fetching commit notes per repo
and a shared cache to avoid duplicate work. When notes are fetched
and the active repo is visible, history is refreshed to reflect
notes without blocking user actions.

- Adds per-path backgroundCommitNotesFetches cache to debounce fetches
- Integrates note fetch into the background tick and refresh flow
- Handles credential errors and shutdown gracefully during fetches
2026-08-13 22:56:53 +02:00
Christoph Brandau a27a8666ee 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.
2026-08-13 22:51:24 +02:00
Christoph Brandau c442b3735f feat(auth): add credential mode (credentials/token) support
Introduces a credential mode for stored credentials and wire it to
per-remote URL resolution and operation flows. The UI, storage, and
remote interactions now track and persist the mode, enabling token
based auth alongside username/password credentials.

- Remote URL resolution now considers direction (pull/push) and mode
- Credential dialog, saving, and keychain handling updated to pass and
  respect the mode
- Unique askpass scripts generated per invocation to avoid clashes
2026-08-13 22:27:00 +02:00
7 changed files with 692 additions and 101 deletions
+189 -12
View File
@@ -119,6 +119,7 @@ pub struct GitCommit {
pub refs: Vec<String>, pub refs: Vec<String>,
pub parents: Vec<String>, pub parents: Vec<String>,
pub files: Vec<GitCommitFile>, pub files: Vec<GitCommitFile>,
pub has_note: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -2432,6 +2433,8 @@ const CRED_SERVICE: &str = "tauri_git_lite";
pub struct StoredCredential { pub struct StoredCredential {
pub username: String, pub username: String,
pub password: String, pub password: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
} }
fn cred_entry(key: &str) -> Result<keyring::Entry, String> { fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
@@ -2445,17 +2448,27 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
/// Returns the remote URL used for auth key derivation (upstream remote of the /// Returns the remote URL used for auth key derivation (upstream remote of the
/// current branch, falling back to `origin`, then the first configured remote). /// current branch, falling back to `origin`, then the first configured remote).
#[tauri::command(async)] #[tauri::command(async)]
pub fn get_remote_url(path: String) -> Result<Option<String>, String> { pub fn get_remote_url(
path: String,
remote: Option<String>,
push: Option<bool>,
) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()); let remote = match remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
Some(remote) => validate_remote_name(&repo, &remote, true)?,
None => upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()),
};
if let Some(url) = remote_url_for(&repo, &remote) { if let Some(url) = remote_url_for_auth(&repo, &remote, push.unwrap_or(false)) {
return Ok(Some(url)); return Ok(Some(url));
} }
// origin missing → try the first configured remote // origin missing → try the first configured remote
if let Some(first) = first_remote_name(&repo) { if let Some(first) = first_remote_name(&repo) {
if first != remote { if first != remote {
if let Some(url) = remote_url_for(&repo, &first) { if let Some(url) = remote_url_for_auth(&repo, &first, push.unwrap_or(false)) {
return Ok(Some(url)); return Ok(Some(url));
} }
} }
@@ -2463,6 +2476,20 @@ pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
Ok(None) Ok(None)
} }
fn remote_url_for_auth(repo: &Path, remote: &str, push: bool) -> Option<String> {
let mut command = git_command();
command.arg("-C").arg(repo).args(["remote", "get-url"]);
if push {
command.arg("--push");
}
let out = command.arg(remote).output().ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> { fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
let out = git_command() let out = git_command()
.arg("-C") .arg("-C")
@@ -2614,9 +2641,22 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
} }
#[tauri::command(async)] #[tauri::command(async)]
pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> { pub fn cred_save(
key: String,
username: String,
password: String,
mode: Option<String>,
) -> Result<(), String> {
let entry = cred_entry(&key)?; let entry = cred_entry(&key)?;
let cred = StoredCredential { username, password }; let mode = match mode.as_deref() {
Some("token") => Some("token".to_string()),
_ => Some("credentials".to_string()),
};
let cred = StoredCredential {
username,
password,
mode,
};
let json = serde_json::to_string(&cred) let json = serde_json::to_string(&cred)
.map_err(|err| format!("Could not serialize credentials: {err}"))?; .map_err(|err| format!("Could not serialize credentials: {err}"))?;
entry entry
@@ -3464,7 +3504,10 @@ fn commit_page_for_repo(
repo, repo,
[ [
"log", "log",
"--all", "--branches",
"--remotes",
"--tags",
"HEAD",
"--topo-order", "--topo-order",
"--decorate=short", "--decorate=short",
"--name-status", "--name-status",
@@ -3479,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> = 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] #[tauri::command]
@@ -3665,7 +3729,9 @@ fn list_file_history_core(
let output = run_git_cancellable(repo, args, cancellation, "Git file history failed")?; let output = run_git_cancellable(repo, args, cancellation, "Git file history failed")?;
check_search_cancelled(cancellation)?; 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] #[tauri::command]
@@ -4688,6 +4754,13 @@ fn run_git_clone(
password: Option<&str>, password: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut command = git_command(); let mut command = git_command();
let has_explicit_credentials = matches!(
(username, password),
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty()
);
if has_explicit_credentials {
command.arg("-c").arg("credential.helper=");
}
command command
.arg("clone") .arg("clone")
.arg("--") .arg("--")
@@ -5093,6 +5166,7 @@ fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
parents, parents,
summary: String::from_utf8_lossy(parts[7]).to_string(), summary: String::from_utf8_lossy(parts[7]).to_string(),
files: parse_commit_files(files_bytes)?, files: parse_commit_files(files_bytes)?,
has_note: false,
}); });
} }
@@ -5141,6 +5215,7 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String
parents, parents,
summary: fields[7].to_string(), summary: fields[7].to_string(),
files, files,
has_note: false,
}); });
} }
@@ -5187,6 +5262,7 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
parents, parents,
summary: fields[7].to_string(), summary: fields[7].to_string(),
files: Vec::new(), files: Vec::new(),
has_note: false,
}); });
} }
@@ -5649,10 +5725,20 @@ fn run_apply_patch_command(
run_git(repo, args).map(|_| ()) run_git(repo, args).map(|_| ())
} }
static ASKPASS_COUNTER: AtomicU64 = AtomicU64::new(1);
fn next_askpass_path(extension: &str) -> std::path::PathBuf {
let sequence = ASKPASS_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"gitty-askpass-{}-{sequence}.{extension}",
std::process::id()
))
}
#[cfg(unix)] #[cfg(unix)]
fn write_askpass_script() -> Result<std::path::PathBuf, String> { fn write_askpass_script() -> Result<std::path::PathBuf, String> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join("gitlite_askpass.sh"); let path = next_askpass_path("sh");
let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n"; let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n";
std::fs::write(&path, script) std::fs::write(&path, script)
.map_err(|e| format!("Could not write authentication script: {e}"))?; .map_err(|e| format!("Could not write authentication script: {e}"))?;
@@ -5663,8 +5749,17 @@ fn write_askpass_script() -> Result<std::path::PathBuf, String> {
#[cfg(not(unix))] #[cfg(not(unix))]
fn write_askpass_script() -> Result<std::path::PathBuf, String> { fn write_askpass_script() -> Result<std::path::PathBuf, String> {
let path = std::env::temp_dir().join("gitlite_askpass.bat"); let path = next_askpass_path("bat");
let script = "@echo off\necho %1 | findstr /I \"sername\" >nul 2>&1\nif %errorlevel% == 0 (echo %GIT_CRED_USER%) else (echo %GIT_CRED_PASS%)\n"; // Reading the value from PowerShell avoids cmd.exe interpreting special
// characters such as &, |, ^ or % from passwords and access tokens.
let script = r#"@echo off
echo %1 | findstr /I "sername" >nul 2>&1
if %errorlevel% == 0 (
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_USER)"
) else (
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_PASS)"
)
"#;
std::fs::write(&path, script) std::fs::write(&path, script)
.map_err(|e| format!("Could not write authentication script: {e}"))?; .map_err(|e| format!("Could not write authentication script: {e}"))?;
Ok(path) Ok(path)
@@ -5706,6 +5801,8 @@ where
let askpass = write_askpass_script()?; let askpass = write_askpass_script()?;
let result = git_command() let result = git_command()
.arg("-c")
.arg("credential.helper=")
.arg("-C") .arg("-C")
.arg(repo) .arg(repo)
.args(args) .args(args)
@@ -6302,6 +6399,19 @@ mod tests {
"Review: sieht gut aus\nBuild: 42", "Review: sieht gut aus\nBuild: 42",
) )
.expect("note should be created"); .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!( assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("note should load"), commit_note_for_repo(&repo.path, &commit_before).expect("note should load"),
Some("Review: sieht gut aus\nBuild: 42".to_string()) Some("Review: sieht gut aus\nBuild: 42".to_string())
@@ -6315,6 +6425,14 @@ mod tests {
); );
delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted"); 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!( assert_eq!(
commit_note_for_repo(&repo.path, &commit_before) commit_note_for_repo(&repo.path, &commit_before)
.expect("deleted note lookup should work"), .expect("deleted note lookup should work"),
@@ -6715,6 +6833,7 @@ mod tests {
], ],
summary: "Add history panel".to_string(), summary: "Add history panel".to_string(),
files: Vec::new(), files: Vec::new(),
has_note: false,
}] }]
); );
} }
@@ -7146,6 +7265,64 @@ mod tests {
); );
} }
#[test]
fn credential_payload_remains_backward_compatible() {
let legacy: StoredCredential =
serde_json::from_str(r#"{"username":"alice","password":"secret"}"#)
.expect("legacy credential should deserialize");
assert_eq!(legacy.username, "alice");
assert_eq!(legacy.password, "secret");
assert_eq!(legacy.mode, None);
let token = StoredCredential {
username: "alice".to_string(),
password: "token".to_string(),
mode: Some("token".to_string()),
};
let encoded = serde_json::to_string(&token).expect("credential should serialize");
assert!(encoded.contains(r#""mode":"token""#));
}
#[test]
fn auth_remote_url_uses_the_requested_direction() {
let repo = init_temp_repo("auth_remote_url_direction");
run_git_test(
&repo.path,
[
"remote",
"add",
"origin",
"https://gitea.example/fetch/repo.git",
],
);
run_git_test(
&repo.path,
[
"remote",
"set-url",
"--push",
"origin",
"https://gitea.example/push/repo.git",
],
);
assert_eq!(
remote_url_for_auth(&repo.path, "origin", false).as_deref(),
Some("https://gitea.example/fetch/repo.git")
);
assert_eq!(
remote_url_for_auth(&repo.path, "origin", true).as_deref(),
Some("https://gitea.example/push/repo.git")
);
}
#[test]
fn askpass_scripts_use_unique_paths() {
let first = next_askpass_path("test");
let second = next_askpass_path("test");
assert_ne!(first, second);
}
#[tokio::test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
+221 -51
View File
@@ -111,7 +111,6 @@
launchExternalTool, launchExternalTool,
credLoad, credLoad,
credSave, credSave,
credDelete,
getFilePatch, getFilePatch,
readConflict, readConflict,
resolveConflict, resolveConflict,
@@ -190,6 +189,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error"; type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository"; type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone"; type CredentialAction = "push" | "pull" | "fetch" | "clone";
type CredentialMode = "credentials" | "token";
type PendingDiscard = type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean } | { kind: "file"; files: GitFileStatus[]; staged: boolean }
| { kind: "all-changes"; files: GitFileStatus[] } | { kind: "all-changes"; files: GitFileStatus[] }
@@ -412,6 +412,9 @@
let credDialogAction: CredentialAction | null = null; let credDialogAction: CredentialAction | null = null;
let credDialogError = ""; let credDialogError = "";
let credDialogKey: string | null = null; let credDialogKey: string | null = null;
let credDialogUsername = "";
let credDialogMode: CredentialMode = "credentials";
const rejectedCredentialKeys = new Set<string>();
let lastStatusFingerprint = ""; let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000; const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined; let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -424,6 +427,7 @@
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined; let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined; let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false; let backgroundFetchInFlight = false;
const backgroundCommitNotesFetches = new Map<string, Promise<boolean>>();
let backgroundRepoStatusInFlight = false; let backgroundRepoStatusInFlight = false;
let backgroundRepoStatusIndex = 0; let backgroundRepoStatusIndex = 0;
let appShuttingDown = false; let appShuttingDown = false;
@@ -669,6 +673,15 @@
// Keep the cached tab data if this repo is unavailable at startup. // Keep the cached tab data if this repo is unavailable at startup.
} }
} }
const notesFetched = await backgroundFetchCommitNotes(path);
if (notesFetched && sameRepoPath(path, activeRepoPath)) {
try {
await refreshCommitHistory(path);
} catch {
// The active repository may still be opening; its own background pass retries.
}
}
} }
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
@@ -714,18 +727,91 @@
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually. // ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/ // Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup. // Push buttons instead, not as a background popup.
function preferredNotesRemote(remotes: GitRemote[]): GitRemote | undefined {
return remotes.find((remote) => remote.name === selectedRemote)
?? remotes.find((remote) => remote.name === "origin")
?? remotes[0];
}
async function backgroundFetchCommitNotesCore(path: string): Promise<boolean> {
if (appShuttingDown || !autoRefreshEnabled || !path) return false;
if (commitNoteTarget && sameRepoPath(path, commitNoteRepoPath)) return false;
let credentialKey: string | null = null;
try {
const remote = preferredNotesRemote(await listRemotes(path));
if (!remote) return false;
credentialKey = orgKeyFromUrl(remote.fetch_url);
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) return false;
const credential = await loadStoredCredential(credentialKey);
if (/^https?:\/\//i.test(remote.fetch_url) && !credential) return false;
await fetchCommitNotes(path, remote.name, credential?.username, credential?.password);
if (credentialKey) rejectedCredentialKeys.delete(credentialKey);
trackEvent("commit_notes_background_fetched");
return true;
} catch (error) {
if (credentialKey && isAuthError(errorToMessage(error))) {
rejectedCredentialKeys.add(credentialKey);
}
if (import.meta.env.DEV) console.info("[Gitty notes] Background fetch skipped", errorToMessage(error));
return false;
}
}
function backgroundFetchCommitNotes(path: string): Promise<boolean> {
const key = repoKey(path);
const current = backgroundCommitNotesFetches.get(key);
if (current) return current;
const request = backgroundFetchCommitNotesCore(path).finally(() => {
backgroundCommitNotesFetches.delete(key);
});
backgroundCommitNotesFetches.set(key, request);
return request;
}
async function waitForBackgroundCommitNotes(path: string) {
await backgroundCommitNotesFetches.get(repoKey(path));
}
async function backgroundFetchCommitNotesAndRefresh(path: string) {
if (!await backgroundFetchCommitNotes(path)) return;
if (!sameRepoPath(path, activeRepoPath)) return;
try {
await refreshCommitHistory(path);
} catch {
// Repository changes can invalidate this best-effort background refresh.
}
}
async function backgroundFetchTick() { async function backgroundFetchTick() {
if (appShuttingDown || !autoRefreshEnabled) return; if (appShuttingDown || !autoRefreshEnabled) return;
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) { && !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
const path = activeRepoPath;
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(activeRepoPath); let refsFetched = false;
applyStatus(await getStatus(activeRepoPath)); try {
await refreshRefsAndCommitGraph(activeRepoPath); await fetchRemote(path);
refsFetched = true;
} catch {
// Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (sameRepoPath(path, activeRepoPath)) {
if (refsFetched) {
applyStatus(await getStatus(path));
await refreshRefsAndCommitGraph(path);
} else if (notesFetched) {
await refreshCommitHistory(path);
}
}
} catch { } catch {
// ignore — see comment above // ignore transient refresh failures
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -739,14 +825,26 @@
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(path); let refsFetched = false;
const nextStatus = await getStatus(path); try {
if (sameRepoPath(path, activeRepoPath)) { await fetchRemote(path);
applyStatus(nextStatus); refsFetched = true;
await refreshRefsAndCommitGraph(path); } catch {
} else updateRepoManagementStatus(path, nextStatus); // Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (refsFetched) {
const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) {
applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus);
} else if (notesFetched && sameRepoPath(path, activeRepoPath)) {
await refreshCommitHistory(path);
}
} catch { } catch {
// ignore; manual Fetch/Pull surfaces auth or network problems // ignore transient refresh failures
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -2177,7 +2275,8 @@
changed_files: bundle.status.files.length, changed_files: bundle.status.files.length,
has_upstream: bundle.status.upstream ? 1 : 0, has_upstream: bundle.status.upstream ? 1 : 0,
}); });
void backgroundFetchRepo(activeRepoPath); if (backgroundFetchInFlight) void backgroundFetchCommitNotesAndRefresh(activeRepoPath);
else void backgroundFetchRepo(activeRepoPath);
}); });
} }
@@ -2207,6 +2306,7 @@
password?: string, password?: string,
key?: string | null, key?: string | null,
fromStore = false, fromStore = false,
credentialMode: CredentialMode = "credentials",
) { ) {
if (isBusy) return; if (isBusy) return;
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; } if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
@@ -2219,7 +2319,16 @@
if (!username && !password) { if (!username && !password) {
const stored = await loadStoredCredential(credentialKey); const stored = await loadStoredCredential(credentialKey);
if (stored) { if (stored) {
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true); const storedMode = credentialModeFor(stored);
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) {
credDialogUsername = stored.username === "oauth2" ? "" : stored.username;
credDialogMode = storedMode;
credDialogAction = "clone";
credDialogKey = credentialKey;
credDialogOpen = true;
return;
}
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode);
return; return;
} }
} }
@@ -2260,7 +2369,9 @@
errorMessage = ""; errorMessage = "";
setCloneDialogError(""); setCloneDialogError("");
if (fromStore) { if (fromStore) {
if (credentialKey) void credDelete(credentialKey).catch(() => {}); if (credentialKey) rejectedCredentialKeys.add(credentialKey);
credDialogUsername = username === "oauth2" ? "" : (username ?? "");
credDialogMode = credentialMode;
const detail = summarizeGitError(message); const detail = summarizeGitError(message);
credDialogError = detail credDialogError = detail
? `${detail} — please sign in again.` ? `${detail} — please sign in again.`
@@ -2917,7 +3028,7 @@
async function pushLocalTag(tag: GitTag) { async function pushLocalTag(tag: GitTag) {
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy) return;
const key = await currentCredKey(); const key = await currentCredKey("push");
const stored = await loadStoredCredential(key); const stored = await loadStoredCredential(key);
const credential = stored ?? null; const credential = stored ?? null;
@@ -2983,6 +3094,11 @@
commitNoteLoading = false; commitNoteLoading = false;
} }
async function loadCommitNotePreview(commit: GitCommit): Promise<string | null> {
if (!activeRepoPath) return null;
return getCommitNote(activeRepoPath, commit.hash);
}
async function saveActiveCommitNote(note: string) { async function saveActiveCommitNote(note: string) {
const commit = commitNoteTarget; const commit = commitNoteTarget;
const repo = commitNoteRepoPath; const repo = commitNoteRepoPath;
@@ -2991,8 +3107,10 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
await setCommitNote(repo, commit.hash, note); await setCommitNote(repo, commit.hash, note);
commitNoteText = note; commitNoteText = note;
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: true } : item);
commitNoteStatus = appLanguage === "de" commitNoteStatus = appLanguage === "de"
? "Notiz gespeichert. Der Commit-Hash ist unverändert." ? "Notiz gespeichert. Der Commit-Hash ist unverändert."
: "Note saved. The commit hash is unchanged."; : "Note saved. The commit hash is unchanged.";
@@ -3012,8 +3130,10 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
await deleteCommitNote(repo, commit.hash); await deleteCommitNote(repo, commit.hash);
commitNoteText = ""; commitNoteText = "";
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: false } : item);
commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted."; commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted.";
trackEvent("commit_note_deleted"); trackEvent("commit_note_deleted");
} catch (error) { } catch (error) {
@@ -3046,9 +3166,11 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
const credential = await storedCredentialForNoteRemote(remote, direction); const credential = await storedCredentialForNoteRemote(remote, direction);
if (direction === "fetch") { if (direction === "fetch") {
await fetchCommitNotes(repo, remote, credential?.username, credential?.password); await fetchCommitNotes(repo, remote, credential?.username, credential?.password);
await refreshCommitHistory(repo);
commitNoteText = (await getCommitNote(repo, commit.hash)) ?? ""; commitNoteText = (await getCommitNote(repo, commit.hash)) ?? "";
commitNoteStatus = appLanguage === "de" commitNoteStatus = appLanguage === "de"
? `Notizen von ${remote} geladen und zusammengeführt.` ? `Notizen von ${remote} geladen und zusammengeführt.`
@@ -3102,11 +3224,17 @@
}); });
} }
// Resolve the keychain key (host/org) for the active repo's remote. function credentialModeFor(credential: StoredCredential): CredentialMode {
async function currentCredKey(): Promise<string | null> { if (credential.mode === "token" || credential.username === "oauth2") return "token";
return "credentials";
}
// Resolve the keychain key (host/org) from the exact remote URL used by the
// operation. Push URLs may intentionally differ from fetch URLs.
async function currentCredKey(action: "push" | "pull" | "fetch" = "fetch"): Promise<string | null> {
if (!activeRepoPath) return null; if (!activeRepoPath) return null;
try { try {
const url = await getRemoteUrl(activeRepoPath); const url = await getRemoteUrl(activeRepoPath, selectedRemote || undefined, action === "push");
return url ? orgKeyFromUrl(url) : null; return url ? orgKeyFromUrl(url) : null;
} catch { } catch {
return null; return null;
@@ -3122,21 +3250,37 @@
} }
} }
async function openCredentialDialog(action: CredentialAction, key?: string | null) { async function openCredentialDialog(
action: CredentialAction,
key?: string | null,
credential?: StoredCredential | null,
) {
if (!activeRepoPath && action !== "clone") return; if (!activeRepoPath && action !== "clone") return;
credDialogError = ""; credDialogError = "";
credDialogAction = action; credDialogAction = action;
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null); credDialogKey = key === undefined && action !== "clone"
? await currentCredKey(action)
: (key ?? null);
credDialogUsername = credential?.username === "oauth2" ? "" : (credential?.username ?? "");
credDialogMode = credential ? credentialModeFor(credential) : "credentials";
credDialogOpen = true; credDialogOpen = true;
trackEvent("credential_dialog_opened", { trackEvent("credential_dialog_opened", {
action, action,
}); });
} }
// Post-process a pull/push result: surface errors, and on rejected/expired // Post-process a pull/push result. Rejected credentials stay in the keychain
// credentials drop the stored entry and re-open the login dialog. // so a temporary 401/403 cannot erase a valid token; the key is only skipped
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) { // for the rest of this session until the user replaces it successfully.
function handleRemoteResult(
action: "push" | "pull" | "fetch",
key: string | null,
fromStore: boolean,
username: string,
mode: CredentialMode,
) {
if (!errorMessage) { if (!errorMessage) {
if (key) rejectedCredentialKeys.delete(key);
credDialogOpen = false; credDialogOpen = false;
credDialogAction = null; credDialogAction = null;
return; return;
@@ -3147,7 +3291,9 @@
if (fromStore) { if (fromStore) {
if (auth) { if (auth) {
if (key) void credDelete(key).catch(() => {}); if (key) rejectedCredentialKeys.add(key);
credDialogUsername = username === "oauth2" ? "" : username;
credDialogMode = mode;
const detail = summarizeGitError(message); const detail = summarizeGitError(message);
credDialogError = detail credDialogError = detail
? `${detail} — please sign in again.` ? `${detail} — please sign in again.`
@@ -3160,6 +3306,7 @@
errorMessage = message; errorMessage = message;
} }
} else { } else {
if (auth && key) rejectedCredentialKeys.add(key);
credDialogError = message || "Sign-in failed."; credDialogError = message || "Sign-in failed.";
} }
} }
@@ -3169,6 +3316,7 @@
password: string, password: string,
key: string | null, key: string | null,
fromStore: boolean, fromStore: boolean,
mode: CredentialMode,
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Pulling", async () => { await runOperation("Pulling", async () => {
@@ -3179,7 +3327,7 @@
changed_files: status?.files.length ?? 0, changed_files: status?.files.length ?? 0,
}); });
}); });
handleRemoteResult("pull", key, fromStore); handleRemoteResult("pull", key, fromStore, username, mode);
} }
async function doActualFetch( async function doActualFetch(
@@ -3187,6 +3335,7 @@
password: string, password: string,
key: string | null, key: string | null,
fromStore: boolean, fromStore: boolean,
mode: CredentialMode,
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Fetching", async () => { await runOperation("Fetching", async () => {
@@ -3199,7 +3348,7 @@
behind: status?.behind ?? 0, behind: status?.behind ?? 0,
}); });
}); });
handleRemoteResult("fetch", key, fromStore); handleRemoteResult("fetch", key, fromStore, username, mode);
} }
async function doActualPush( async function doActualPush(
@@ -3207,6 +3356,7 @@
password: string, password: string,
key: string | null, key: string | null,
fromStore: boolean, fromStore: boolean,
mode: CredentialMode,
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Pushing", async () => { await runOperation("Pushing", async () => {
@@ -3235,12 +3385,12 @@
if (!fromStore) credDialogError = ""; if (!fromStore) credDialogError = "";
await runOperation("Pulling before push", async () => { await runOperation("Pulling before push", async () => {
applyStatus(await pull(activeRepoPath, username, password)); applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath); await refreshRepositoryViews(activeRepoPath);
}); });
if (errorMessage) { if (errorMessage) {
handleRemoteResult("pull", key, fromStore); handleRemoteResult("pull", key, fromStore, username, mode);
return; return;
} }
@@ -3252,7 +3402,7 @@
} }
await runOperation("Pushing after pull", async () => { await runOperation("Pushing after pull", async () => {
applyStatus(await push(activeRepoPath, username, password)); applyStatus(await push(activeRepoPath, username, password, false, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath, { files: false }); await refreshRepositoryViews(activeRepoPath, { files: false });
trackEvent("repository_pushed_after_pull", { trackEvent("repository_pushed_after_pull", {
from_stored_credential: fromStore ? 1 : 0, from_stored_credential: fromStore ? 1 : 0,
@@ -3261,14 +3411,31 @@
}); });
} }
handleRemoteResult("push", key, fromStore); handleRemoteResult("push", key, fromStore, username, mode);
} }
async function handleCredentialSubmit(username: string, password: string, save: boolean) { async function handleCredentialSubmit(
username: string,
password: string,
save: boolean,
mode: CredentialMode,
) {
const key = credDialogKey; const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false); // Honour "Save in keychain" immediately. A successful authentication
else if (credDialogAction === "push") await doActualPush(username, password, key, false); // followed by an unrelated refresh/non-fast-forward error must not lose the
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false); // token and force the user to type it again on the next operation.
if (save && key) {
try {
await credSave(key, username, password, mode);
} catch (error) {
credDialogError = errorToMessage(error);
return;
}
}
if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode);
else if (credDialogAction === "push") await doActualPush(username, password, key, false, mode);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false, mode);
else if (credDialogAction === "clone" && pendingClone) { else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo( await cloneRepo(
pendingClone.remoteUrl, pendingClone.remoteUrl,
@@ -3278,17 +3445,9 @@
password, password,
key, key,
false, false,
mode,
); );
} }
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
try {
await credSave(key, username, password);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
} }
async function startRemoteAction(action: "push" | "pull" | "fetch") { async function startRemoteAction(action: "push" | "pull" | "fetch") {
@@ -3296,17 +3455,18 @@
trackEvent("remote_action_started", { trackEvent("remote_action_started", {
action, action,
}); });
const key = await currentCredKey(); const key = await currentCredKey(action);
const stored = await loadStoredCredential(key); const stored = await loadStoredCredential(key);
if (stored) { if (stored && (!key || !rejectedCredentialKeys.has(key))) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true); const mode = credentialModeFor(stored);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true); if (action === "pull") await doActualPull(stored.username, stored.password, key, true, mode);
else await doActualPush(stored.username, stored.password, key, true); else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true, mode);
else await doActualPush(stored.username, stored.password, key, true, mode);
return; return;
} }
await openCredentialDialog(action, key); await openCredentialDialog(action, key, stored);
} }
async function fetchRepo() { async function fetchRepo() {
@@ -5015,6 +5175,7 @@
onCherryPickCommit={cherryPickFromCommit} onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit} onRevertCommit={revertHistoryCommit}
onOpenCommitNote={openCommitNoteDialog} onOpenCommitNote={openCommitNoteDialog}
onLoadCommitNote={loadCommitNotePreview}
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }} onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
onToggleCommitFiles={(hash) => { onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes); const next = new Set(expandedCommitHashes);
@@ -5375,8 +5536,17 @@
action={credDialogAction} action={credDialogAction}
error={credDialogError} error={credDialogError}
{isBusy} {isBusy}
initialUsername={credDialogUsername}
initialMode={credDialogMode}
onSubmit={handleCredentialSubmit} onSubmit={handleCredentialSubmit}
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }} onCancel={() => {
credDialogOpen = false;
credDialogAction = null;
credDialogError = "";
credDialogKey = null;
credDialogUsername = "";
credDialogMode = "credentials";
}}
/> />
{/if} {/if}
+157 -1
View File
@@ -2542,6 +2542,107 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .commit-ref-area {
position: relative; position: relative;
@@ -5979,12 +6080,35 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.graph-list { background: var(--color-surface-solid); } .graph-list { background: var(--color-surface-solid); }
.graph-gutter { background: var(--color-surface-dim); } .graph-gutter { background: var(--color-surface-dim); }
.commit-body { .commit-body {
border-radius: var(--ui-radius-sm); border-radius: 0;
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid)); background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid));
box-shadow: none; box-shadow: none;
} }
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); } .graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); } .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 { .graph-row {
content-visibility: auto; content-visibility: auto;
contain-intrinsic-block-size: 108px; 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); 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 { :root[data-theme="light"] .graph-row.merge-row .commit-body {
background: rgba(248,244,252,0.82); background: rgba(248,244,252,0.82);
} }
+26 -23
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { import {
AlertCircle, AlertCircle,
Download, Download,
@@ -17,7 +18,9 @@
action: "push" | "pull" | "fetch" | "clone"; action: "push" | "pull" | "fetch" | "clone";
error: string; error: string;
isBusy: boolean; isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean) => void; initialUsername?: string;
initialMode?: Mode;
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -25,14 +28,16 @@
action, action,
error = "", error = "",
isBusy = false, isBusy = false,
initialUsername = "",
initialMode = "credentials",
onSubmit, onSubmit,
onCancel, onCancel,
}: Props = $props(); }: Props = $props();
type Mode = "credentials" | "token"; type Mode = "credentials" | "token";
let mode = $state<Mode>("credentials"); let mode = $state<Mode>(untrack(() => initialMode));
let username = $state(""); let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
let password = $state(""); let password = $state("");
let showPassword = $state(false); let showPassword = $state(false);
let saveSession = $state(true); let saveSession = $state(true);
@@ -40,7 +45,7 @@
let canSubmit = $derived( let canSubmit = $derived(
!isBusy && !isBusy &&
password.trim().length > 0 && password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0), username.trim().length > 0,
); );
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull"); let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionTitle = $derived( let actionTitle = $derived(
@@ -61,7 +66,7 @@
function handleSubmit(e: SubmitEvent) { function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!canSubmit) return; if (!canSubmit) return;
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession); onSubmit(username.trim(), password, saveSession, mode);
} }
</script> </script>
@@ -92,7 +97,7 @@
<div class="cred-security-note"> <div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" /> <ShieldCheck size={14} aria-hidden="true" />
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span> <span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
</div> </div>
</div> </div>
@@ -116,27 +121,25 @@
aria-pressed={mode === "token"} aria-pressed={mode === "token"}
> >
<Key size={13} aria-hidden="true" /> <Key size={13} aria-hidden="true" />
Token Access token
</button> </button>
</div> </div>
<div class="cred-fields"> <div class="cred-fields">
{#if mode === "credentials"} <div class="cred-field">
<div class="cred-field"> <label class="cred-field-label" for="cred-username">Username</label>
<label class="cred-field-label" for="cred-username">Username</label> <div class="cred-input">
<div class="cred-input"> <User size={15} class="cred-field-icon" aria-hidden="true" />
<User size={15} class="cred-field-icon" aria-hidden="true" /> <input
<input id="cred-username"
id="cred-username" type="text"
type="text" bind:value={username}
bind:value={username} placeholder="Your account username"
placeholder="e.g. my-github-username" autocomplete="username"
autocomplete="username" disabled={isBusy}
disabled={isBusy} />
/>
</div>
</div> </div>
{/if} </div>
<div class="cred-field"> <div class="cred-field">
<label class="cred-field-label" for="cred-password"> <label class="cred-field-label" for="cred-password">
@@ -174,7 +177,7 @@
{#if mode === "token"} {#if mode === "token"}
<div class="cred-token-hint"> <div class="cred-token-hint">
<Key size={13} aria-hidden="true" /> <Key size={13} aria-hidden="true" />
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span> <span>Use your normal account username. The access token is sent as the password, as required by Gitea and most Git providers.</span>
</div> </div>
{/if} {/if}
+93 -10
View File
@@ -80,6 +80,7 @@
onCherryPickCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void; onRevertCommit: (commit: GitCommit) => void;
onOpenCommitNote: (commit: GitCommit) => void; onOpenCommitNote: (commit: GitCommit) => void;
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
onSelectCommit: (commit: GitCommit) => void; onSelectCommit: (commit: GitCommit) => void;
} }
@@ -108,6 +109,7 @@
onCherryPickCommit = () => {}, onCherryPickCommit = () => {},
onRevertCommit = () => {}, onRevertCommit = () => {},
onOpenCommitNote = () => {}, onOpenCommitNote = () => {},
onLoadCommitNote = async () => null,
onSelectCommit = () => {}, onSelectCommit = () => {},
}: Props = $props(); }: Props = $props();
@@ -120,6 +122,16 @@
let contextCommit = $state<GitCommit | null>(null); let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $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) { function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list"); const root = node.closest<HTMLElement>(".history-list");
@@ -503,6 +515,34 @@
await onOpenCommitNote(commit); 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) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeCommitContextMenu(); closeCommitContextMenu();
@@ -792,6 +832,7 @@
<article <article
class="commit-row graph-row" class="commit-row graph-row"
class:selected={selectedCommitHash === item.hash} class:selected={selectedCommitHash === item.hash}
class:has-note={item.has_note}
data-commit-hash={item.hash} data-commit-hash={item.hash}
class:graph-ahead-row={rowSyncClass === "ahead"} class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"} class:graph-behind-row={rowSyncClass === "behind"}
@@ -852,6 +893,9 @@
class:has-branch-ref={Boolean(refSummary.primaryBranch)} class:has-branch-ref={Boolean(refSummary.primaryBranch)}
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`} 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} {#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area"> <div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references"> <div class="commit-ref-strip" aria-label="Commit references">
@@ -966,6 +1010,43 @@
<div class="commit-meta-line"> <div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span> <span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</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> </div>
</div> </div>
@@ -1008,16 +1089,18 @@
<div class="commit-actions"> <div class="commit-actions">
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time> <time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons"> <div class="commit-action-buttons">
<button {#if !item.has_note}
class="commit-menu-button commit-note-button" <button
type="button" class="commit-menu-button commit-note-button"
onclick={() => openCommitNote(item)} type="button"
disabled={isBusy} onclick={() => openCommitNote(item)}
title={`Open internal note for ${item.short_hash}`} disabled={isBusy}
aria-label={`Open internal note for ${item.short_hash}`} 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> <StickyNote size={14} aria-hidden="true" />
</button>
{/if}
<button <button
class="commit-menu-button" class="commit-menu-button"
type="button" type="button"
+4 -4
View File
@@ -373,16 +373,16 @@ export function push(path: string, username?: string, password?: string, forceWi
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null }); return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
} }
export function getRemoteUrl(path: string): Promise<string | null> { export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
return invoke<string | null>("get_remote_url", { path }); return invoke<string | null>("get_remote_url", { path, remote: remote || null, push });
} }
export function credLoad(key: string): Promise<StoredCredential | null> { export function credLoad(key: string): Promise<StoredCredential | null> {
return invoke<StoredCredential | null>("cred_load", { key }); return invoke<StoredCredential | null>("cred_load", { key });
} }
export function credSave(key: string, username: string, password: string): Promise<void> { export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
return invoke<void>("cred_save", { key, username, password }); return invoke<void>("cred_save", { key, username, password, mode });
} }
export function credDelete(key: string): Promise<void> { export function credDelete(key: string): Promise<void> {
+2
View File
@@ -178,6 +178,7 @@ export interface GitCommit {
refs: string[]; refs: string[];
parents: string[]; parents: string[];
files: GitCommitFile[]; files: GitCommitFile[];
has_note: boolean;
} }
export interface GitCommitFile { export interface GitCommitFile {
@@ -310,4 +311,5 @@ export interface ReflogEntry {
export interface StoredCredential { export interface StoredCredential {
username: string; username: string;
password: string; password: string;
mode?: "credentials" | "token";
} }