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
publish-arch:
name: Build and publish gitty-desktop to AUR
name: Build and publish AUR packages
needs: publish-ubuntu
runs-on: archlinux
environment: production
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
defaults:
run:
@@ -236,6 +238,43 @@ jobs:
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
env:
AUR_GIT_NAME: ${{ vars.USERNAME_GIT }}
@@ -273,14 +312,19 @@ jobs:
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"
AUR_CHECKOUT_ROOT="$(mktemp -d)"
AUR_CHECKOUT=""
publish_aur_package() {
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
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT"
echo "AUR clone attempt $AUR_ATTEMPT of 5"
clone_candidate="$checkout_root/attempt-$AUR_ATTEMPT"
echo "$package_name clone attempt $AUR_ATTEMPT of 5"
if timeout 3m git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then
AUR_CHECKOUT="$AUR_CLONE_CANDIDATE"
"ssh://aur@aur.archlinux.org/$package_name.git" "$clone_candidate"; then
checkout="$clone_candidate"
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
@@ -288,29 +332,27 @@ jobs:
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ -z "$AUR_CHECKOUT" ]; then
echo "Could not clone the AUR repository after 5 attempts" >&2
exit 1
if [ -z "$checkout" ]; then
echo "Could not clone $package_name after 5 attempts" >&2
return 1
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"
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "AUR metadata already matches release $PACKAGE_VERSION"
exit 0
if git -C "$checkout" diff --cached --quiet; then
echo "$package_name already matches release $PACKAGE_VERSION"
return 0
fi
git commit -m "Update to $PACKAGE_VERSION"
AUR_PUSHED=0
git -C "$checkout" commit -m "Update to $PACKAGE_VERSION"
pushed=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR push attempt $AUR_ATTEMPT of 5"
if timeout 3m git push origin HEAD:master; then
AUR_PUSHED=1
echo "$package_name push attempt $AUR_ATTEMPT of 5"
if timeout 3m git -C "$checkout" push origin HEAD:master; then
pushed=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
@@ -318,10 +360,14 @@ jobs:
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$AUR_PUSHED" -ne 1 ]; then
echo "Could not publish to AUR after 5 attempts" >&2
exit 1
if [ "$pushed" -ne 1 ]; then
echo "Could not publish $package_name after 5 attempts" >&2
return 1
fi
}
publish_aur_package gitty-desktop "$AUR_SOURCE_DIR"
publish_aur_package gitty-desktop-bin "$AUR_BIN_DIR"
publish-ubuntu:
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
```
To install the prebuilt AppImage instead of compiling from source:
```bash
yay -S gitty-desktop-bin
```
Or build the AUR package manually:
```bash
@@ -68,8 +74,9 @@ cd gitty-desktop
makepkg -si
```
The AUR recipe downloads the public Gitea release archive and builds Gitty from
source. The release pipeline updates its version, checksum, and `.SRCINFO`.
The source recipe downloads the public Gitea release archive and builds Gitty.
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 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
View File
@@ -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
View File
@@ -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);
}
+14 -11
View File
@@ -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}
+85 -2
View File
@@ -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
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 });
}
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> {
+2
View File
@@ -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";
}