Compare commits

..
6 Commits
Author SHA1 Message Date
Christoph 4db488415f Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-08-15 17:00:44 +02:00
Christoph be695d78a9 feat(publishing): add support for prebuilt AppImage package
This update enhances the publishing workflow by introducing a new
prebuilt AppImage package for Gitty, allowing users to install
the application without needing to compile from source. The
workflow now includes steps to generate and publish the binary
AUR package alongside the standard source package.

- Introduced PKGBUILD-bin for the prebuilt AppImage
- Updated workflow to handle both source and binary package publishing
- Enhanced README to guide users on installing the new package
2026-08-15 17:00:42 +02:00
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
10 changed files with 839 additions and 150 deletions
+73 -27
View File
@@ -121,11 +121,13 @@ jobs:
uv run main.py uv run main.py
publish-arch: publish-arch:
name: Build and publish gitty-desktop to AUR name: Build and publish AUR packages
needs: publish-ubuntu
runs-on: archlinux runs-on: archlinux
environment: production environment: production
env: env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
defaults: defaults:
run: run:
@@ -236,6 +238,43 @@ jobs:
echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV" echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
- name: Generate and build binary AUR package
run: |
set -euo pipefail
LATEST_JSON="$(curl --fail --location --silent --show-error \
"$ARTIFACT_BASE_URL/gitty/latest.json")"
export LATEST_JSON
APPIMAGE_URL="$(node -e "const p=JSON.parse(process.env.LATEST_JSON); const a=p.platforms?.['linux-x86_64']?.url; if (!a) process.exit(1); process.stdout.write(a)")"
APPIMAGE_NAME="${APPIMAGE_URL##*/}"
AUR_BIN_DIR="$(mktemp -d)"
cp PKGBUILD-bin "$AUR_BIN_DIR/PKGBUILD"
APPIMAGE_PATH="$AUR_BIN_DIR/$APPIMAGE_NAME"
curl --fail --location --silent --show-error \
--output "$APPIMAGE_PATH" "$APPIMAGE_URL"
APPIMAGE_CHECKSUM="$(sha256sum "$APPIMAGE_PATH" | cut -d ' ' -f 1)"
ICON_PATH="$AUR_BIN_DIR/gitty-desktop.png"
curl --fail --location --silent --show-error \
--output "$ICON_PATH" \
"https://git.cbsk-tech.de/Christoph/GitLite/raw/tag/$RELEASE_TAG/src-tauri/icons/icon.png"
ICON_CHECKSUM="$(sha256sum "$ICON_PATH" | cut -d ' ' -f 1)"
sed -i \
-e "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/" \
-e "s/^pkgrel=.*/pkgrel=1/" \
-e "s|^_appimage=.*|_appimage=\"$APPIMAGE_NAME\"|" \
-e "s|^_artifact_url=.*|_artifact_url=\"$APPIMAGE_URL\"|" \
-e "s/^_tag=.*/_tag=$RELEASE_TAG/" \
-e "/^sha256sums=/,/^[[:space:]]*'SKIP')$/c\\sha256sums=('$APPIMAGE_CHECKSUM'\\n '$ICON_CHECKSUM')" \
"$AUR_BIN_DIR/PKGBUILD"
chown -R builder:builder "$AUR_BIN_DIR"
runuser -u builder -- \
bash -lc "cd '$AUR_BIN_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
echo "AUR_BIN_DIR=$AUR_BIN_DIR" >> "$GITHUB_ENV"
- name: Publish PKGBUILD to AUR - name: Publish PKGBUILD to AUR
env: env:
AUR_GIT_NAME: ${{ vars.USERNAME_GIT }} AUR_GIT_NAME: ${{ vars.USERNAME_GIT }}
@@ -273,14 +312,19 @@ jobs:
install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts" install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2" export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"
AUR_CHECKOUT_ROOT="$(mktemp -d)" publish_aur_package() {
AUR_CHECKOUT="" local package_name="$1"
local package_source_dir="$2"
local checkout_root checkout clone_candidate pushed
checkout_root="$(mktemp -d)"
checkout=""
for AUR_ATTEMPT in 1 2 3 4 5; do for AUR_ATTEMPT in 1 2 3 4 5; do
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT" clone_candidate="$checkout_root/attempt-$AUR_ATTEMPT"
echo "AUR clone attempt $AUR_ATTEMPT of 5" echo "$package_name clone attempt $AUR_ATTEMPT of 5"
if timeout 3m git -c init.defaultBranch=master clone \ if timeout 3m git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then "ssh://aur@aur.archlinux.org/$package_name.git" "$clone_candidate"; then
AUR_CHECKOUT="$AUR_CLONE_CANDIDATE" checkout="$clone_candidate"
break break
fi fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then if [ "$AUR_ATTEMPT" -lt 5 ]; then
@@ -288,29 +332,27 @@ jobs:
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}" sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi fi
done done
if [ -z "$AUR_CHECKOUT" ]; then if [ -z "$checkout" ]; then
echo "Could not clone the AUR repository after 5 attempts" >&2 echo "Could not clone $package_name after 5 attempts" >&2
exit 1 return 1
fi fi
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/" cp "$package_source_dir/PKGBUILD" "$package_source_dir/.SRCINFO" "$checkout/"
git -C "$checkout" config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
git -C "$checkout" config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
git -C "$checkout" add PKGBUILD .SRCINFO
cd "$AUR_CHECKOUT" if git -C "$checkout" diff --cached --quiet; then
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}" echo "$package_name already matches release $PACKAGE_VERSION"
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}" return 0
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "AUR metadata already matches release $PACKAGE_VERSION"
exit 0
fi fi
git commit -m "Update to $PACKAGE_VERSION" git -C "$checkout" commit -m "Update to $PACKAGE_VERSION"
AUR_PUSHED=0 pushed=0
for AUR_ATTEMPT in 1 2 3 4 5; do for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR push attempt $AUR_ATTEMPT of 5" echo "$package_name push attempt $AUR_ATTEMPT of 5"
if timeout 3m git push origin HEAD:master; then if timeout 3m git -C "$checkout" push origin HEAD:master; then
AUR_PUSHED=1 pushed=1
break break
fi fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then if [ "$AUR_ATTEMPT" -lt 5 ]; then
@@ -318,10 +360,14 @@ jobs:
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}" sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi fi
done done
if [ "$AUR_PUSHED" -ne 1 ]; then if [ "$pushed" -ne 1 ]; then
echo "Could not publish to AUR after 5 attempts" >&2 echo "Could not publish $package_name after 5 attempts" >&2
exit 1 return 1
fi fi
}
publish_aur_package gitty-desktop "$AUR_SOURCE_DIR"
publish_aur_package gitty-desktop-bin "$AUR_BIN_DIR"
publish-ubuntu: publish-ubuntu:
name: Build and publish Ubuntu AppImage name: Build and publish Ubuntu AppImage
+45
View File
@@ -0,0 +1,45 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
pkgname=gitty-desktop-bin
pkgver=2026.8.3
pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt AppImage)"
arch=('x86_64')
url="https://git.cbsk-tech.de/Christoph/GitLite"
license=('MIT')
depends=('fuse2' 'git' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
provides=('gitty-desktop')
conflicts=('gitty-desktop')
options=('!strip')
_appimage="Gitty_${pkgver}_amd64.AppImage"
_artifact_url="https://cdn.cbsk-tech.de/gitty/${pkgver}/${_appimage}"
_tag=2026.8.3
source=("${_appimage}::${_artifact_url}"
"gitty-desktop.png::${url}/raw/tag/${_tag}/src-tauri/icons/icon.png")
sha256sums=('SKIP'
'SKIP')
package() {
install -Dm755 "$srcdir/$_appimage" \
"$pkgdir/opt/$pkgname/gitty-desktop.AppImage"
install -d "$pkgdir/usr/bin"
ln -s "/opt/$pkgname/gitty-desktop.AppImage" \
"$pkgdir/usr/bin/gitty-desktop"
install -Dm644 "$srcdir/gitty-desktop.png" \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty-desktop.png"
install -d "$pkgdir/usr/share/applications"
cat > "$pkgdir/usr/share/applications/gitty-desktop.desktop" <<-EOF
[Desktop Entry]
Type=Application
Name=Gitty
Comment=$pkgdesc
Exec=gitty-desktop
Icon=gitty-desktop
Terminal=false
Categories=Development;RevisionControl;
StartupWMClass=gitty
EOF
}
+9 -2
View File
@@ -60,6 +60,12 @@ Install Gitty from the AUR with an AUR helper:
yay -S gitty-desktop yay -S gitty-desktop
``` ```
To install the prebuilt AppImage instead of compiling from source:
```bash
yay -S gitty-desktop-bin
```
Or build the AUR package manually: Or build the AUR package manually:
```bash ```bash
@@ -68,8 +74,9 @@ cd gitty-desktop
makepkg -si makepkg -si
``` ```
The AUR recipe downloads the public Gitea release archive and builds Gitty from The source recipe downloads the public Gitea release archive and builds Gitty.
source. The release pipeline updates its version, checksum, and `.SRCINFO`. The `-bin` recipe installs the prebuilt AppImage. The release pipeline updates
both packages' versions, checksums, and `.SRCINFO` files.
--- ---
+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,
+215 -45
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 { } 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 { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -738,15 +824,27 @@
if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return; if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return;
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try {
let refsFetched = false;
try { try {
await fetchRemote(path); 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); const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) { if (sameRepoPath(path, activeRepoPath)) {
applyStatus(nextStatus); applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path); await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus); } 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);
} }
+14 -11
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,12 +121,11 @@
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">
@@ -130,13 +134,12 @@
id="cred-username" id="cred-username"
type="text" type="text"
bind:value={username} bind:value={username}
placeholder="e.g. my-github-username" placeholder="Your account username"
autocomplete="username" autocomplete="username"
disabled={isBusy} disabled={isBusy}
/> />
</div> </div>
</div> </div>
{/if}
<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}
+85 -2
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">
{#if !item.has_note}
<button <button
class="commit-menu-button commit-note-button" class="commit-menu-button commit-note-button"
type="button" type="button"
onclick={() => openCommitNote(item)} onclick={() => openCommitNote(item)}
disabled={isBusy} disabled={isBusy}
title={`Open internal note for ${item.short_hash}`} title={`Add a Git note to ${item.short_hash}`}
aria-label={`Open internal note for ${item.short_hash}`} aria-label={`Add a Git note to ${item.short_hash}`}
> >
<StickyNote size={14} aria-hidden="true" /> <StickyNote size={14} aria-hidden="true" />
</button> </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";
} }