Compare commits
4
Commits
2026.8.3
...
40311275b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40311275b0 | ||
|
|
fe577d78a8 | ||
|
|
a27a8666ee | ||
|
|
c442b3735f |
+189
-12
@@ -119,6 +119,7 @@ pub struct GitCommit {
|
||||
pub refs: Vec<String>,
|
||||
pub parents: Vec<String>,
|
||||
pub files: Vec<GitCommitFile>,
|
||||
pub has_note: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
@@ -2432,6 +2433,8 @@ const CRED_SERVICE: &str = "tauri_git_lite";
|
||||
pub struct StoredCredential {
|
||||
pub username: 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> {
|
||||
@@ -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
|
||||
/// current branch, falling back to `origin`, then the first configured remote).
|
||||
#[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 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));
|
||||
}
|
||||
// origin missing → try the first configured remote
|
||||
if let Some(first) = first_remote_name(&repo) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -2463,6 +2476,20 @@ pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
|
||||
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> {
|
||||
let out = git_command()
|
||||
.arg("-C")
|
||||
@@ -2614,9 +2641,22 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
|
||||
}
|
||||
|
||||
#[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 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)
|
||||
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
|
||||
entry
|
||||
@@ -3464,7 +3504,10 @@ fn commit_page_for_repo(
|
||||
repo,
|
||||
[
|
||||
"log",
|
||||
"--all",
|
||||
"--branches",
|
||||
"--remotes",
|
||||
"--tags",
|
||||
"HEAD",
|
||||
"--topo-order",
|
||||
"--decorate=short",
|
||||
"--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]
|
||||
@@ -3665,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]
|
||||
@@ -4688,6 +4754,13 @@ fn run_git_clone(
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
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
|
||||
.arg("clone")
|
||||
.arg("--")
|
||||
@@ -5093,6 +5166,7 @@ fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
||||
parents,
|
||||
summary: String::from_utf8_lossy(parts[7]).to_string(),
|
||||
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,
|
||||
summary: fields[7].to_string(),
|
||||
files,
|
||||
has_note: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5187,6 +5262,7 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
||||
parents,
|
||||
summary: fields[7].to_string(),
|
||||
files: Vec::new(),
|
||||
has_note: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5649,10 +5725,20 @@ fn run_apply_patch_command(
|
||||
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)]
|
||||
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
||||
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";
|
||||
std::fs::write(&path, script)
|
||||
.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))]
|
||||
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
||||
let path = std::env::temp_dir().join("gitlite_askpass.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";
|
||||
let path = next_askpass_path("bat");
|
||||
// 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)
|
||||
.map_err(|e| format!("Could not write authentication script: {e}"))?;
|
||||
Ok(path)
|
||||
@@ -5706,6 +5801,8 @@ where
|
||||
let askpass = write_askpass_script()?;
|
||||
|
||||
let result = git_command()
|
||||
.arg("-c")
|
||||
.arg("credential.helper=")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
@@ -6302,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())
|
||||
@@ -6315,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"),
|
||||
@@ -6715,6 +6833,7 @@ mod tests {
|
||||
],
|
||||
summary: "Add history panel".to_string(),
|
||||
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]
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
|
||||
+215
-45
@@ -111,7 +111,6 @@
|
||||
launchExternalTool,
|
||||
credLoad,
|
||||
credSave,
|
||||
credDelete,
|
||||
getFilePatch,
|
||||
readConflict,
|
||||
resolveConflict,
|
||||
@@ -190,6 +189,7 @@
|
||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||
type AppView = "management" | "repository";
|
||||
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
||||
type CredentialMode = "credentials" | "token";
|
||||
type PendingDiscard =
|
||||
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
|
||||
| { kind: "all-changes"; files: GitFileStatus[] }
|
||||
@@ -412,6 +412,9 @@
|
||||
let credDialogAction: CredentialAction | null = null;
|
||||
let credDialogError = "";
|
||||
let credDialogKey: string | null = null;
|
||||
let credDialogUsername = "";
|
||||
let credDialogMode: CredentialMode = "credentials";
|
||||
const rejectedCredentialKeys = new Set<string>();
|
||||
let lastStatusFingerprint = "";
|
||||
const AUTO_REFRESH_INTERVAL = 4000;
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
@@ -424,6 +427,7 @@
|
||||
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let backgroundFetchInFlight = false;
|
||||
const backgroundCommitNotesFetches = new Map<string, Promise<boolean>>();
|
||||
let backgroundRepoStatusInFlight = false;
|
||||
let backgroundRepoStatusIndex = 0;
|
||||
let appShuttingDown = false;
|
||||
@@ -669,6 +673,15 @@
|
||||
// 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 {
|
||||
backgroundFetchInFlight = false;
|
||||
@@ -714,18 +727,91 @@
|
||||
// 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/
|
||||
// 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() {
|
||||
if (appShuttingDown || !autoRefreshEnabled) return;
|
||||
|
||||
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
|
||||
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
|
||||
const path = activeRepoPath;
|
||||
backgroundFetchInFlight = true;
|
||||
try {
|
||||
await fetchRemote(activeRepoPath);
|
||||
applyStatus(await getStatus(activeRepoPath));
|
||||
await refreshRefsAndCommitGraph(activeRepoPath);
|
||||
let refsFetched = false;
|
||||
try {
|
||||
await fetchRemote(path);
|
||||
refsFetched = true;
|
||||
} catch {
|
||||
// ignore — see comment above
|
||||
// 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 {
|
||||
// ignore transient refresh failures
|
||||
} finally {
|
||||
backgroundFetchInFlight = false;
|
||||
}
|
||||
@@ -738,15 +824,27 @@
|
||||
if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return;
|
||||
|
||||
backgroundFetchInFlight = true;
|
||||
try {
|
||||
let refsFetched = false;
|
||||
try {
|
||||
await fetchRemote(path);
|
||||
refsFetched = true;
|
||||
} catch {
|
||||
// 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 {
|
||||
// ignore; manual Fetch/Pull surfaces auth or network problems
|
||||
// ignore transient refresh failures
|
||||
} finally {
|
||||
backgroundFetchInFlight = false;
|
||||
}
|
||||
@@ -2177,7 +2275,8 @@
|
||||
changed_files: bundle.status.files.length,
|
||||
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,
|
||||
key?: string | null,
|
||||
fromStore = false,
|
||||
credentialMode: CredentialMode = "credentials",
|
||||
) {
|
||||
if (isBusy) return;
|
||||
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
||||
@@ -2219,7 +2319,16 @@
|
||||
if (!username && !password) {
|
||||
const stored = await loadStoredCredential(credentialKey);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2260,7 +2369,9 @@
|
||||
errorMessage = "";
|
||||
setCloneDialogError("");
|
||||
if (fromStore) {
|
||||
if (credentialKey) void credDelete(credentialKey).catch(() => {});
|
||||
if (credentialKey) rejectedCredentialKeys.add(credentialKey);
|
||||
credDialogUsername = username === "oauth2" ? "" : (username ?? "");
|
||||
credDialogMode = credentialMode;
|
||||
const detail = summarizeGitError(message);
|
||||
credDialogError = detail
|
||||
? `${detail} — please sign in again.`
|
||||
@@ -2917,7 +3028,7 @@
|
||||
|
||||
async function pushLocalTag(tag: GitTag) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
const key = await currentCredKey();
|
||||
const key = await currentCredKey("push");
|
||||
const stored = await loadStoredCredential(key);
|
||||
const credential = stored ?? null;
|
||||
|
||||
@@ -2983,6 +3094,11 @@
|
||||
commitNoteLoading = false;
|
||||
}
|
||||
|
||||
async function loadCommitNotePreview(commit: GitCommit): Promise<string | null> {
|
||||
if (!activeRepoPath) return null;
|
||||
return getCommitNote(activeRepoPath, commit.hash);
|
||||
}
|
||||
|
||||
async function saveActiveCommitNote(note: string) {
|
||||
const commit = commitNoteTarget;
|
||||
const repo = commitNoteRepoPath;
|
||||
@@ -2991,8 +3107,10 @@
|
||||
commitNoteError = "";
|
||||
commitNoteStatus = "";
|
||||
try {
|
||||
await waitForBackgroundCommitNotes(repo);
|
||||
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.";
|
||||
@@ -3012,8 +3130,10 @@
|
||||
commitNoteError = "";
|
||||
commitNoteStatus = "";
|
||||
try {
|
||||
await waitForBackgroundCommitNotes(repo);
|
||||
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) {
|
||||
@@ -3046,9 +3166,11 @@
|
||||
commitNoteError = "";
|
||||
commitNoteStatus = "";
|
||||
try {
|
||||
await waitForBackgroundCommitNotes(repo);
|
||||
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.`
|
||||
@@ -3102,11 +3224,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the keychain key (host/org) for the active repo's remote.
|
||||
async function currentCredKey(): Promise<string | null> {
|
||||
function credentialModeFor(credential: StoredCredential): CredentialMode {
|
||||
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;
|
||||
try {
|
||||
const url = await getRemoteUrl(activeRepoPath);
|
||||
const url = await getRemoteUrl(activeRepoPath, selectedRemote || undefined, action === "push");
|
||||
return url ? orgKeyFromUrl(url) : null;
|
||||
} catch {
|
||||
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;
|
||||
credDialogError = "";
|
||||
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;
|
||||
trackEvent("credential_dialog_opened", {
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
// Post-process a pull/push result: surface errors, and on rejected/expired
|
||||
// credentials drop the stored entry and re-open the login dialog.
|
||||
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) {
|
||||
// Post-process a pull/push result. Rejected credentials stay in the keychain
|
||||
// so a temporary 401/403 cannot erase a valid token; the key is only skipped
|
||||
// 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 (key) rejectedCredentialKeys.delete(key);
|
||||
credDialogOpen = false;
|
||||
credDialogAction = null;
|
||||
return;
|
||||
@@ -3147,7 +3291,9 @@
|
||||
|
||||
if (fromStore) {
|
||||
if (auth) {
|
||||
if (key) void credDelete(key).catch(() => {});
|
||||
if (key) rejectedCredentialKeys.add(key);
|
||||
credDialogUsername = username === "oauth2" ? "" : username;
|
||||
credDialogMode = mode;
|
||||
const detail = summarizeGitError(message);
|
||||
credDialogError = detail
|
||||
? `${detail} — please sign in again.`
|
||||
@@ -3160,6 +3306,7 @@
|
||||
errorMessage = message;
|
||||
}
|
||||
} else {
|
||||
if (auth && key) rejectedCredentialKeys.add(key);
|
||||
credDialogError = message || "Sign-in failed.";
|
||||
}
|
||||
}
|
||||
@@ -3169,6 +3316,7 @@
|
||||
password: string,
|
||||
key: string | null,
|
||||
fromStore: boolean,
|
||||
mode: CredentialMode,
|
||||
) {
|
||||
errorMessage = "";
|
||||
await runOperation("Pulling", async () => {
|
||||
@@ -3179,7 +3327,7 @@
|
||||
changed_files: status?.files.length ?? 0,
|
||||
});
|
||||
});
|
||||
handleRemoteResult("pull", key, fromStore);
|
||||
handleRemoteResult("pull", key, fromStore, username, mode);
|
||||
}
|
||||
|
||||
async function doActualFetch(
|
||||
@@ -3187,6 +3335,7 @@
|
||||
password: string,
|
||||
key: string | null,
|
||||
fromStore: boolean,
|
||||
mode: CredentialMode,
|
||||
) {
|
||||
errorMessage = "";
|
||||
await runOperation("Fetching", async () => {
|
||||
@@ -3199,7 +3348,7 @@
|
||||
behind: status?.behind ?? 0,
|
||||
});
|
||||
});
|
||||
handleRemoteResult("fetch", key, fromStore);
|
||||
handleRemoteResult("fetch", key, fromStore, username, mode);
|
||||
}
|
||||
|
||||
async function doActualPush(
|
||||
@@ -3207,6 +3356,7 @@
|
||||
password: string,
|
||||
key: string | null,
|
||||
fromStore: boolean,
|
||||
mode: CredentialMode,
|
||||
) {
|
||||
errorMessage = "";
|
||||
await runOperation("Pushing", async () => {
|
||||
@@ -3235,12 +3385,12 @@
|
||||
if (!fromStore) credDialogError = "";
|
||||
|
||||
await runOperation("Pulling before push", async () => {
|
||||
applyStatus(await pull(activeRepoPath, username, password));
|
||||
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
});
|
||||
|
||||
if (errorMessage) {
|
||||
handleRemoteResult("pull", key, fromStore);
|
||||
handleRemoteResult("pull", key, fromStore, username, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3252,7 +3402,7 @@
|
||||
}
|
||||
|
||||
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 });
|
||||
trackEvent("repository_pushed_after_pull", {
|
||||
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;
|
||||
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
|
||||
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
|
||||
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
|
||||
// Honour "Save in keychain" immediately. A successful authentication
|
||||
// followed by an unrelated refresh/non-fast-forward error must not lose the
|
||||
// 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) {
|
||||
await cloneRepo(
|
||||
pendingClone.remoteUrl,
|
||||
@@ -3278,17 +3445,9 @@
|
||||
password,
|
||||
key,
|
||||
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") {
|
||||
@@ -3296,17 +3455,18 @@
|
||||
trackEvent("remote_action_started", {
|
||||
action,
|
||||
});
|
||||
const key = await currentCredKey();
|
||||
const key = await currentCredKey(action);
|
||||
const stored = await loadStoredCredential(key);
|
||||
|
||||
if (stored) {
|
||||
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
|
||||
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
|
||||
else await doActualPush(stored.username, stored.password, key, true);
|
||||
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
|
||||
const mode = credentialModeFor(stored);
|
||||
if (action === "pull") await doActualPull(stored.username, stored.password, key, true, mode);
|
||||
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true, mode);
|
||||
else await doActualPush(stored.username, stored.password, key, true, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
await openCredentialDialog(action, key);
|
||||
await openCredentialDialog(action, key, stored);
|
||||
}
|
||||
|
||||
async function fetchRepo() {
|
||||
@@ -5015,6 +5175,7 @@
|
||||
onCherryPickCommit={cherryPickFromCommit}
|
||||
onRevertCommit={revertHistoryCommit}
|
||||
onOpenCommitNote={openCommitNoteDialog}
|
||||
onLoadCommitNote={loadCommitNotePreview}
|
||||
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
|
||||
onToggleCommitFiles={(hash) => {
|
||||
const next = new Set(expandedCommitHashes);
|
||||
@@ -5375,8 +5536,17 @@
|
||||
action={credDialogAction}
|
||||
error={credDialogError}
|
||||
{isBusy}
|
||||
initialUsername={credDialogUsername}
|
||||
initialMode={credDialogMode}
|
||||
onSubmit={handleCredentialSubmit}
|
||||
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
|
||||
onCancel={() => {
|
||||
credDialogOpen = false;
|
||||
credDialogAction = null;
|
||||
credDialogError = "";
|
||||
credDialogKey = null;
|
||||
credDialogUsername = "";
|
||||
credDialogMode = "credentials";
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
+157
-1
@@ -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;
|
||||
@@ -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-gutter { background: var(--color-surface-dim); }
|
||||
.commit-body {
|
||||
border-radius: var(--ui-radius-sm);
|
||||
border-radius: 0;
|
||||
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid));
|
||||
box-shadow: none;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
AlertCircle,
|
||||
Download,
|
||||
@@ -17,7 +18,9 @@
|
||||
action: "push" | "pull" | "fetch" | "clone";
|
||||
error: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -25,14 +28,16 @@
|
||||
action,
|
||||
error = "",
|
||||
isBusy = false,
|
||||
initialUsername = "",
|
||||
initialMode = "credentials",
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props = $props();
|
||||
|
||||
type Mode = "credentials" | "token";
|
||||
|
||||
let mode = $state<Mode>("credentials");
|
||||
let username = $state("");
|
||||
let mode = $state<Mode>(untrack(() => initialMode));
|
||||
let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
|
||||
let password = $state("");
|
||||
let showPassword = $state(false);
|
||||
let saveSession = $state(true);
|
||||
@@ -40,7 +45,7 @@
|
||||
let canSubmit = $derived(
|
||||
!isBusy &&
|
||||
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 actionTitle = $derived(
|
||||
@@ -61,7 +66,7 @@
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession);
|
||||
onSubmit(username.trim(), password, saveSession, mode);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -92,7 +97,7 @@
|
||||
|
||||
<div class="cred-security-note">
|
||||
<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>
|
||||
|
||||
@@ -116,12 +121,11 @@
|
||||
aria-pressed={mode === "token"}
|
||||
>
|
||||
<Key size={13} aria-hidden="true" />
|
||||
Token
|
||||
Access token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cred-fields">
|
||||
{#if mode === "credentials"}
|
||||
<div class="cred-field">
|
||||
<label class="cred-field-label" for="cred-username">Username</label>
|
||||
<div class="cred-input">
|
||||
@@ -130,13 +134,12 @@
|
||||
id="cred-username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
placeholder="e.g. my-github-username"
|
||||
placeholder="Your account username"
|
||||
autocomplete="username"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="cred-field">
|
||||
<label class="cred-field-label" for="cred-password">
|
||||
@@ -174,7 +177,7 @@
|
||||
{#if mode === "token"}
|
||||
<div class="cred-token-hint">
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
onCherryPickCommit: (commit: GitCommit) => void;
|
||||
onRevertCommit: (commit: GitCommit) => void;
|
||||
onOpenCommitNote: (commit: GitCommit) => void;
|
||||
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
|
||||
onSelectCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
@@ -108,6 +109,7 @@
|
||||
onCherryPickCommit = () => {},
|
||||
onRevertCommit = () => {},
|
||||
onOpenCommitNote = () => {},
|
||||
onLoadCommitNote = async () => null,
|
||||
onSelectCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
@@ -120,6 +122,16 @@
|
||||
let contextCommit = $state<GitCommit | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
let notePreviews = $state<Record<string, string>>({});
|
||||
let notePreviewLoading = $state<Set<string>>(new Set());
|
||||
let notePreviewErrors = $state<Set<string>>(new Set());
|
||||
|
||||
$effect(() => {
|
||||
repositoryKey;
|
||||
notePreviews = {};
|
||||
notePreviewLoading = new Set();
|
||||
notePreviewErrors = new Set();
|
||||
});
|
||||
|
||||
function observeHistoryEnd(node: HTMLElement) {
|
||||
const root = node.closest<HTMLElement>(".history-list");
|
||||
@@ -503,6 +515,34 @@
|
||||
await onOpenCommitNote(commit);
|
||||
}
|
||||
|
||||
async function loadCommitNotePreview(commit: GitCommit) {
|
||||
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
|
||||
|
||||
const loading = new Set(notePreviewLoading);
|
||||
loading.add(commit.hash);
|
||||
notePreviewLoading = loading;
|
||||
|
||||
const errors = new Set(notePreviewErrors);
|
||||
errors.delete(commit.hash);
|
||||
notePreviewErrors = errors;
|
||||
|
||||
try {
|
||||
const note = await onLoadCommitNote(commit);
|
||||
notePreviews = {
|
||||
...notePreviews,
|
||||
[commit.hash]: note?.trim() || "This Git note is empty.",
|
||||
};
|
||||
} catch {
|
||||
const nextErrors = new Set(notePreviewErrors);
|
||||
nextErrors.add(commit.hash);
|
||||
notePreviewErrors = nextErrors;
|
||||
} finally {
|
||||
const nextLoading = new Set(notePreviewLoading);
|
||||
nextLoading.delete(commit.hash);
|
||||
notePreviewLoading = nextLoading;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
closeCommitContextMenu();
|
||||
@@ -792,6 +832,7 @@
|
||||
<article
|
||||
class="commit-row graph-row"
|
||||
class:selected={selectedCommitHash === item.hash}
|
||||
class:has-note={item.has_note}
|
||||
data-commit-hash={item.hash}
|
||||
class:graph-ahead-row={rowSyncClass === "ahead"}
|
||||
class:graph-behind-row={rowSyncClass === "behind"}
|
||||
@@ -852,6 +893,9 @@
|
||||
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
||||
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
|
||||
>
|
||||
{#if item.has_note}
|
||||
<span class="commit-note-rail" aria-hidden="true"></span>
|
||||
{/if}
|
||||
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
||||
<div class="commit-ref-area">
|
||||
<div class="commit-ref-strip" aria-label="Commit references">
|
||||
@@ -966,6 +1010,43 @@
|
||||
<div class="commit-meta-line">
|
||||
<span class="commit-hash">{item.short_hash}</span>
|
||||
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
|
||||
{#if item.has_note}
|
||||
<span class="commit-note-indicator">
|
||||
<button
|
||||
class="commit-note-presence"
|
||||
type="button"
|
||||
onpointerenter={() => void loadCommitNotePreview(item)}
|
||||
onfocus={() => void loadCommitNotePreview(item)}
|
||||
onclick={() => openCommitNote(item)}
|
||||
disabled={isBusy}
|
||||
aria-label={`Open Git note for ${item.short_hash}`}
|
||||
aria-describedby={`commit-note-preview-${item.hash}`}
|
||||
>
|
||||
<StickyNote size={11} aria-hidden="true" />
|
||||
<span>Note</span>
|
||||
</button>
|
||||
<span
|
||||
class="commit-note-tooltip"
|
||||
id={`commit-note-preview-${item.hash}`}
|
||||
role="tooltip"
|
||||
>
|
||||
<span class="commit-note-tooltip-head">
|
||||
<StickyNote size={12} aria-hidden="true" />
|
||||
Git Note
|
||||
<small>Click to open</small>
|
||||
</span>
|
||||
<span class="commit-note-tooltip-body">
|
||||
{#if notePreviewLoading.has(item.hash)}
|
||||
Loading note…
|
||||
{:else if notePreviewErrors.has(item.hash)}
|
||||
Note could not be loaded.
|
||||
{:else}
|
||||
{notePreviews[item.hash] ?? "Hover to load the note."}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1008,16 +1089,18 @@
|
||||
<div class="commit-actions">
|
||||
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="commit-action-buttons">
|
||||
{#if !item.has_note}
|
||||
<button
|
||||
class="commit-menu-button commit-note-button"
|
||||
type="button"
|
||||
onclick={() => openCommitNote(item)}
|
||||
disabled={isBusy}
|
||||
title={`Open internal note for ${item.short_hash}`}
|
||||
aria-label={`Open internal note for ${item.short_hash}`}
|
||||
title={`Add a Git note to ${item.short_hash}`}
|
||||
aria-label={`Add a Git note to ${item.short_hash}`}
|
||||
>
|
||||
<StickyNote size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="commit-menu-button"
|
||||
type="button"
|
||||
|
||||
+4
-4
@@ -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 });
|
||||
}
|
||||
|
||||
export function getRemoteUrl(path: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_remote_url", { path });
|
||||
export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
|
||||
return invoke<string | null>("get_remote_url", { path, remote: remote || null, push });
|
||||
}
|
||||
|
||||
export function credLoad(key: string): Promise<StoredCredential | null> {
|
||||
return invoke<StoredCredential | null>("cred_load", { key });
|
||||
}
|
||||
|
||||
export function credSave(key: string, username: string, password: string): Promise<void> {
|
||||
return invoke<void>("cred_save", { key, username, password });
|
||||
export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
|
||||
return invoke<void>("cred_save", { key, username, password, mode });
|
||||
}
|
||||
|
||||
export function credDelete(key: string): Promise<void> {
|
||||
|
||||
@@ -178,6 +178,7 @@ export interface GitCommit {
|
||||
refs: string[];
|
||||
parents: string[];
|
||||
files: GitCommitFile[];
|
||||
has_note: boolean;
|
||||
}
|
||||
|
||||
export interface GitCommitFile {
|
||||
@@ -310,4 +311,5 @@ export interface ReflogEntry {
|
||||
export interface StoredCredential {
|
||||
username: string;
|
||||
password: string;
|
||||
mode?: "credentials" | "token";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user