Compare commits

..
9 Commits
Author SHA1 Message Date
Christoph 32832f5db7 feat(git): support credentials for remote branch rename
Allow remote branch renames to be performed with optional credentials so
operations against protected remotes succeed when authentication is needed.
The backend accepts username/password and uses an authenticated push path
when provided, while the frontend prompts for and reuses stored credentials.

- Add optional username/password to rename RPC and use authenticated push
- Wire UI to queue rename, open credential dialog, and execute rename
- Extend credential dialog and handling to include the rename action
2026-08-15 17:23:40 +02:00
Christoph 37d2152fcd feat(branch-panel): improve context menu positioning and styling
This update enhances the context menu for branches and tags by
ensuring it fits within the viewport, preventing overflow and
improving usability. Additionally, the styling of the branch
context menu has been adjusted for better visibility and
interaction.

- Added dynamic positioning for context menus to avoid overflow
- Updated CSS for branch context menu to improve layout and usability
- Simplified context menu opening logic for better performance
2026-08-15 17:15:13 +02:00
Christoph 2bc74dc4a7 feat(HelpOverlay): add Git Notes feature documentation
This update introduces documentation for the Git Notes feature, which
allows users to attach additional context to commits without altering
the commit history. The new section includes detailed steps, commands,
and notes on how to effectively use Git Notes within the application.

- Added German and English documentation for Git Notes
- Included commands for viewing and managing commit notes
- Explained how to fetch and push Git Notes to remote repositories
2026-08-15 17:08:53 +02:00
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
12 changed files with 1001 additions and 197 deletions
+93 -47
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,55 +312,62 @@ 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"
for AUR_ATTEMPT in 1 2 3 4 5; do local package_source_dir="$2"
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT" local checkout_root checkout clone_candidate pushed
echo "AUR 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"
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR is unavailable; retrying after backoff"
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
fi
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/" checkout_root="$(mktemp -d)"
checkout=""
cd "$AUR_CHECKOUT" for AUR_ATTEMPT in 1 2 3 4 5; do
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}" clone_candidate="$checkout_root/attempt-$AUR_ATTEMPT"
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}" echo "$package_name clone attempt $AUR_ATTEMPT of 5"
git add PKGBUILD .SRCINFO if timeout 3m git -c init.defaultBranch=master clone \
"ssh://aur@aur.archlinux.org/$package_name.git" "$clone_candidate"; then
if git diff --cached --quiet; then checkout="$clone_candidate"
echo "AUR metadata already matches release $PACKAGE_VERSION" break
exit 0 fi
fi if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR is unavailable; retrying after backoff"
git commit -m "Update to $PACKAGE_VERSION" sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
AUR_PUSHED=0 fi
for AUR_ATTEMPT in 1 2 3 4 5; do done
echo "AUR push attempt $AUR_ATTEMPT of 5" if [ -z "$checkout" ]; then
if timeout 3m git push origin HEAD:master; then echo "Could not clone $package_name after 5 attempts" >&2
AUR_PUSHED=1 return 1
break
fi fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR push failed; retrying after backoff" cp "$package_source_dir/PKGBUILD" "$package_source_dir/.SRCINFO" "$checkout/"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}" 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
if git -C "$checkout" diff --cached --quiet; then
echo "$package_name already matches release $PACKAGE_VERSION"
return 0
fi fi
done
if [ "$AUR_PUSHED" -ne 1 ]; then git -C "$checkout" commit -m "Update to $PACKAGE_VERSION"
echo "Could not publish to AUR after 5 attempts" >&2 pushed=0
exit 1 for AUR_ATTEMPT in 1 2 3 4 5; do
fi 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
echo "AUR push failed; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
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: 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.
--- ---
+217 -25
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)]
@@ -682,9 +683,17 @@ pub async fn rename_remote_branch(
remote: String, remote: String,
old_branch: String, old_branch: String,
new_branch: String, new_branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || { run_git_task("Could not rename remote branch", move || {
rename_remote_branch_core(path, remote, old_branch, new_branch) rename_remote_branch_core(
path,
remote,
old_branch,
new_branch,
username.as_deref().zip(password.as_deref()),
)
}) })
.await .await
} }
@@ -694,6 +703,7 @@ fn rename_remote_branch_core(
remote: String, remote: String,
old_branch: String, old_branch: String,
new_branch: String, new_branch: String,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?; let remote = validate_remote_name(&repo, &remote, true)?;
@@ -729,18 +739,23 @@ fn rename_remote_branch_core(
// Git has no standalone remote-rename command. Create the new ref and delete // Git has no standalone remote-rename command. Create the new ref and delete
// the old one in a single atomic push so a rejected update leaves both untouched. // the old one in a single atomic push so a rejected update leaves both untouched.
run_git( let push_args = [
&repo, "push",
[ "--atomic",
"push", source_lease.as_str(),
"--atomic", destination_lease.as_str(),
source_lease.as_str(), remote.as_str(),
destination_lease.as_str(), create_refspec.as_str(),
remote.as_str(), delete_refspec.as_str(),
create_refspec.as_str(), ];
delete_refspec.as_str(), match credentials {
], Some((username, password)) if !username.is_empty() || !password.is_empty() => {
)?; run_git_authenticated(&repo, push_args, username, password)?;
}
_ => {
run_git(&repo, push_args)?;
}
}
// Git normally updates remote-tracking refs after a successful push. Keep the // Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups. // local view consistent as a fallback for unusual remote/refspec setups.
@@ -2432,6 +2447,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 +2462,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 +2490,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 +2655,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 +3518,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 +3536,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 +3743,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 +4768,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 +5180,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 +5229,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 +5276,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 +5739,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 +5763,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 +5815,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 +6413,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 +6439,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 +6847,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 +7279,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,
@@ -7485,6 +7676,7 @@ mod tests {
"origin".to_string(), "origin".to_string(),
"feature/old-name".to_string(), "feature/old-name".to_string(),
"feature/new-name".to_string(), "feature/new-name".to_string(),
None,
) )
.unwrap(); .unwrap();
+260 -58
View File
@@ -111,7 +111,6 @@
launchExternalTool, launchExternalTool,
credLoad, credLoad,
credSave, credSave,
credDelete,
getFilePatch, getFilePatch,
readConflict, readConflict,
resolveConflict, resolveConflict,
@@ -189,7 +188,8 @@
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" | "rename";
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[] }
@@ -410,8 +410,12 @@
let autoRefreshInFlight = false; let autoRefreshInFlight = false;
let credDialogOpen = false; let credDialogOpen = false;
let credDialogAction: CredentialAction | null = null; let credDialogAction: CredentialAction | null = null;
let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | 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 +428,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 +674,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 +728,91 @@
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually. // ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/ // Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup. // Push buttons instead, not as a background popup.
function preferredNotesRemote(remotes: GitRemote[]): GitRemote | undefined {
return remotes.find((remote) => remote.name === selectedRemote)
?? remotes.find((remote) => remote.name === "origin")
?? remotes[0];
}
async function backgroundFetchCommitNotesCore(path: string): Promise<boolean> {
if (appShuttingDown || !autoRefreshEnabled || !path) return false;
if (commitNoteTarget && sameRepoPath(path, commitNoteRepoPath)) return false;
let credentialKey: string | null = null;
try {
const remote = preferredNotesRemote(await listRemotes(path));
if (!remote) return false;
credentialKey = orgKeyFromUrl(remote.fetch_url);
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) return false;
const credential = await loadStoredCredential(credentialKey);
if (/^https?:\/\//i.test(remote.fetch_url) && !credential) return false;
await fetchCommitNotes(path, remote.name, credential?.username, credential?.password);
if (credentialKey) rejectedCredentialKeys.delete(credentialKey);
trackEvent("commit_notes_background_fetched");
return true;
} catch (error) {
if (credentialKey && isAuthError(errorToMessage(error))) {
rejectedCredentialKeys.add(credentialKey);
}
if (import.meta.env.DEV) console.info("[Gitty notes] Background fetch skipped", errorToMessage(error));
return false;
}
}
function backgroundFetchCommitNotes(path: string): Promise<boolean> {
const key = repoKey(path);
const current = backgroundCommitNotesFetches.get(key);
if (current) return current;
const request = backgroundFetchCommitNotesCore(path).finally(() => {
backgroundCommitNotesFetches.delete(key);
});
backgroundCommitNotesFetches.set(key, request);
return request;
}
async function waitForBackgroundCommitNotes(path: string) {
await backgroundCommitNotesFetches.get(repoKey(path));
}
async function backgroundFetchCommitNotesAndRefresh(path: string) {
if (!await backgroundFetchCommitNotes(path)) return;
if (!sameRepoPath(path, activeRepoPath)) return;
try {
await refreshCommitHistory(path);
} catch {
// Repository changes can invalidate this best-effort background refresh.
}
}
async function backgroundFetchTick() { async function backgroundFetchTick() {
if (appShuttingDown || !autoRefreshEnabled) return; if (appShuttingDown || !autoRefreshEnabled) return;
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) { && !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
const path = activeRepoPath;
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(activeRepoPath); let refsFetched = false;
applyStatus(await getStatus(activeRepoPath)); try {
await refreshRefsAndCommitGraph(activeRepoPath); await fetchRemote(path);
refsFetched = true;
} catch {
// Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (sameRepoPath(path, activeRepoPath)) {
if (refsFetched) {
applyStatus(await getStatus(path));
await refreshRefsAndCommitGraph(path);
} else if (notesFetched) {
await refreshCommitHistory(path);
}
}
} catch { } catch {
// ignore — see comment above // ignore transient refresh failures
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -739,14 +826,26 @@
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(path); let refsFetched = false;
const nextStatus = await getStatus(path); try {
if (sameRepoPath(path, activeRepoPath)) { await fetchRemote(path);
applyStatus(nextStatus); refsFetched = true;
await refreshRefsAndCommitGraph(path); } catch {
} else updateRepoManagementStatus(path, nextStatus); // Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (refsFetched) {
const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) {
applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus);
} else if (notesFetched && sameRepoPath(path, activeRepoPath)) {
await refreshCommitHistory(path);
}
} catch { } catch {
// ignore; manual Fetch/Pull surfaces auth or network problems // ignore transient refresh failures
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -2177,7 +2276,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 +2307,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 +2320,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 +2370,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.`
@@ -2511,12 +2623,14 @@
const oldRemoteBranch = branch.name.slice(slash + 1); const oldRemoteBranch = branch.name.slice(slash + 1);
if (name === oldRemoteBranch) return; if (name === oldRemoteBranch) return;
await runOperation(`Renaming ${branch.name} on remote`, async () => { pendingRemoteRename = { remote, oldBranch: oldRemoteBranch, newBranch: name };
applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name)); const key = await currentCredKey("rename");
renameBranchTarget = null; const stored = await loadStoredCredential(key);
await refreshRefsAndCommitGraph(activeRepoPath); if (stored && (!key || !rejectedCredentialKeys.has(key))) {
trackEvent("branch_renamed", { remote: 1 }); await doActualRemoteRename(stored.username, stored.password, key, true, credentialModeFor(stored));
}); } else {
await openCredentialDialog("rename", key, stored);
}
return; return;
} }
@@ -2917,7 +3031,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 +3097,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 +3110,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 +3133,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 +3169,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 +3227,18 @@
}); });
} }
// 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" | "rename" = "fetch"): Promise<string | null> {
if (!activeRepoPath) return null; if (!activeRepoPath) return null;
try { try {
const url = await getRemoteUrl(activeRepoPath); const remote = action === "rename" ? pendingRemoteRename?.remote : selectedRemote;
const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename");
return url ? orgKeyFromUrl(url) : null; return url ? orgKeyFromUrl(url) : null;
} catch { } catch {
return null; return null;
@@ -3122,21 +3254,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" | "rename",
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 +3295,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 +3310,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 +3320,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 +3331,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 +3339,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 +3352,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 +3360,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 +3389,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 +3406,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 +3415,59 @@
}); });
} }
handleRemoteResult("push", key, fromStore); handleRemoteResult("push", key, fromStore, username, mode);
} }
async function handleCredentialSubmit(username: string, password: string, save: boolean) { async function doActualRemoteRename(
username: string,
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
const rename = pendingRemoteRename;
if (!activeRepoPath || !rename) return;
errorMessage = "";
await runOperation(`Renaming ${rename.remote}/${rename.oldBranch} on remote`, async () => {
applyStatus(await renameRemoteBranch(
activeRepoPath,
rename.remote,
rename.oldBranch,
rename.newBranch,
username,
password,
));
renameBranchTarget = null;
pendingRemoteRename = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("branch_renamed", { remote: 1 });
});
handleRemoteResult("rename", key, fromStore, username, mode);
}
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 === "rename") await doActualRemoteRename(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 +3477,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 +3487,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 +5207,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 +5568,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}
+162 -2
View File
@@ -2212,7 +2212,11 @@
box-shadow: 0 18px 50px rgba(0,0,0,0.5); box-shadow: 0 18px 50px rgba(0,0,0,0.5);
} }
.branch-context-menu, .branch-context-menu {
position: fixed;
max-height: calc(100vh - 16px);
overflow-y: auto;
}
.history-context-menu { position: absolute; } .history-context-menu { position: absolute; }
.explorer-context-menu, .explorer-context-menu,
.repo-tab-context-menu { position: fixed; } .repo-tab-context-menu { position: fixed; }
@@ -2542,6 +2546,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 +6084,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 +6347,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);
} }
+35 -20
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte"; import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { tick } from "svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types"; import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode; type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -99,11 +100,12 @@
let newTagName = $state(""); let newTagName = $state("");
let newTagMessage = $state(""); let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null); let tagCreateInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null); let contextBranch = $state<GitBranchInfo | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null); let contextTag = $state<GitTag | null>(null);
let tagContextMenuElement = $state<HTMLElement | null>(null);
let tagContextMenuX = $state(0); let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0); let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set()); let collapsedBranchFolders = $state<Set<string>>(new Set());
@@ -243,20 +245,31 @@
onCheckout(branch); onCheckout(branch);
} }
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) { function fitContextMenuToViewport(element: HTMLElement | null, x: number, y: number) {
const rect = element?.getBoundingClientRect();
const width = rect?.width ?? 184;
const height = rect?.height ?? 0;
return {
x: Math.max(8, Math.min(x + 2, window.innerWidth - width - 8)),
y: Math.max(8, Math.min(y + 2, window.innerHeight - height - 8)),
};
}
async function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isBusy) return; if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
contextBranch = branch; contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX)); contextMenuX = event.clientX + 2;
contextMenuY = Math.max(8, Math.min(rawY, maxY)); contextMenuY = event.clientY + 2;
await tick();
if (contextBranch !== branch) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
contextMenuX = position.x;
contextMenuY = position.y;
} }
function closeBranchContextMenu() { function closeBranchContextMenu() {
@@ -335,20 +348,20 @@
tagsOpen = true; tagsOpen = true;
} }
function openTagContextMenu(event: MouseEvent, tag: GitTag) { async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isBusy) return; if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
contextTag = tag; contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX)); tagContextMenuX = event.clientX + 2;
tagContextMenuY = Math.max(8, Math.min(rawY, maxY)); tagContextMenuY = event.clientY + 2;
await tick();
if (contextTag !== tag) return;
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
tagContextMenuX = position.x;
tagContextMenuY = position.y;
} }
function closeTagContextMenu() { function closeTagContextMenu() {
@@ -381,7 +394,7 @@
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} /> <svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches"> <section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Branches</span> <span class="eyebrow">Branches</span>
@@ -676,6 +689,7 @@
{#if contextBranch} {#if contextBranch}
<div <div
bind:this={branchContextMenuElement}
class="branch-context-menu" class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`} style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu" role="menu"
@@ -723,6 +737,7 @@
{#if contextTag} {#if contextTag}
<div <div
bind:this={tagContextMenuElement}
class="branch-context-menu" class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`} style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu" role="menu"
+33 -28
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { import {
AlertCircle, AlertCircle,
Download, Download,
@@ -14,10 +15,12 @@
} from "@lucide/svelte"; } from "@lucide/svelte";
interface Props { interface Props {
action: "push" | "pull" | "fetch" | "clone"; action: "push" | "pull" | "fetch" | "clone" | "rename";
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,19 +45,21 @@
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" : action === "rename" ? "Rename" : "Pull");
let actionTitle = $derived( let actionTitle = $derived(
action === "push" action === "push"
? "Authenticate push" ? "Authenticate push"
: action === "fetch" : action === "rename"
? "Authenticate remote rename"
: action === "fetch"
? "Authenticate fetch" ? "Authenticate fetch"
: action === "clone" : action === "clone"
? "Authenticate clone" ? "Authenticate clone"
: "Authenticate pull", : "Authenticate pull",
); );
let actionHint = $derived(action === "push" let actionHint = $derived(action === "push" || action === "rename"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions." ? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone" : action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token." ? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
@@ -61,7 +68,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>
@@ -73,7 +80,7 @@
<div class="cred-hero"> <div class="cred-hero">
<div class="cred-hero-top"> <div class="cred-hero-top">
<div class="cred-hero-icon"> <div class="cred-hero-icon">
{#if action === "push"} {#if action === "push" || action === "rename"}
<Upload size={27} aria-hidden="true" /> <Upload size={27} aria-hidden="true" />
{:else} {:else}
<Download size={27} aria-hidden="true" /> <Download size={27} aria-hidden="true" />
@@ -92,7 +99,7 @@
<div class="cred-security-note"> <div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" /> <ShieldCheck size={14} aria-hidden="true" />
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span> <span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
</div> </div>
</div> </div>
@@ -116,27 +123,25 @@
aria-pressed={mode === "token"} aria-pressed={mode === "token"}
> >
<Key size={13} aria-hidden="true" /> <Key size={13} aria-hidden="true" />
Token Access token
</button> </button>
</div> </div>
<div class="cred-fields"> <div class="cred-fields">
{#if mode === "credentials"} <div class="cred-field">
<div class="cred-field"> <label class="cred-field-label" for="cred-username">Username</label>
<label class="cred-field-label" for="cred-username">Username</label> <div class="cred-input">
<div class="cred-input"> <User size={15} class="cred-field-icon" aria-hidden="true" />
<User size={15} class="cred-field-icon" aria-hidden="true" /> <input
<input id="cred-username"
id="cred-username" type="text"
type="text" bind:value={username}
bind:value={username} placeholder="Your account username"
placeholder="e.g. my-github-username" autocomplete="username"
autocomplete="username" disabled={isBusy}
disabled={isBusy} />
/>
</div>
</div> </div>
{/if} </div>
<div class="cred-field"> <div class="cred-field">
<label class="cred-field-label" for="cred-password"> <label class="cred-field-label" for="cred-password">
@@ -174,7 +179,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}
+38
View File
@@ -547,6 +547,25 @@
"Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.", "Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.",
], ],
}, },
{
id: "app-git-notes",
title: "Commits mit Git Notes ergänzen",
summary: "Git Notes speichern zusätzliche Informationen zu einem Commit, ohne dessen Hash oder die Historie zu verändern. Sie eignen sich etwa für Review-Hinweise, Ticket-Kontext, Build-IDs oder Freigabestatus.",
steps: [
"Öffne im Commit-Verlauf über das Notiz-Symbol oder das Kontextmenü die Commit-Notiz.",
"Schreibe oder bearbeite die Notiz und speichere sie. Commits mit einer Notiz sind im Verlauf markiert; beim Überfahren der Markierung erscheint eine Vorschau.",
"Löschen entfernt nur die Notiz. Der zugehörige Commit und seine Dateien bleiben unverändert.",
"Gitty lädt Git Notes im Hintergrund vom bevorzugten Remote. Nutze im Dialog Vom Remote laden, um sie bei Bedarf gezielt zu aktualisieren.",
"Nutze Zum Remote senden, um lokale Notizen zu veröffentlichen. Ein normaler Branch-Push überträgt Git Notes nicht automatisch.",
],
commands: [
{ command: "git notes show <commit>", description: "Notiz eines Commits in der Kommandozeile anzeigen" },
{ command: "git notes add <commit>", description: "Notiz zu einem Commit hinzufügen oder im Editor verfassen" },
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Commit-Notizen gezielt vom Remote laden" },
{ command: "git push <remote> refs/notes/commits", description: "Lokale Commit-Notizen zum Remote senden" },
],
note: "Git Notes liegen standardmäßig unter refs/notes/commits und werden getrennt von Branches synchronisiert. Prüfe vor einem Push, ob der Ziel-Remote diese Referenz akzeptiert.",
},
{ {
id: "app-search", id: "app-search",
title: "Code-Ursprung mit Global Search finden", title: "Code-Ursprung mit Global Search finden",
@@ -990,6 +1009,25 @@
"Restore from commit writes an older file version into the working tree. Review and commit the result normally.", "Restore from commit writes an older file version into the working tree. Review and commit the result normally.",
], ],
}, },
{
id: "app-git-notes",
title: "Add context to commits with Git Notes",
summary: "Git Notes attach additional information to a commit without changing its hash or rewriting history. They are useful for review findings, ticket context, build IDs, or approval status.",
steps: [
"Open the commit note from the note icon or the commit context menu in History.",
"Write or edit the note and save it. Commits with a note are marked in History, and hovering over the marker shows a preview.",
"Deleting removes only the note. The associated commit and its files remain unchanged.",
"Gitty fetches Git Notes from the preferred remote in the background. Use Fetch from remote in the dialog to refresh them explicitly when needed.",
"Use Push to remote to publish local notes. A regular branch push does not transfer Git Notes automatically.",
],
commands: [
{ command: "git notes show <commit>", description: "Show a commit's note on the command line" },
{ command: "git notes add <commit>", description: "Add a note to a commit or compose it in an editor" },
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Fetch commit notes explicitly from a remote" },
{ command: "git push <remote> refs/notes/commits", description: "Push local commit notes to a remote" },
],
note: "Git Notes are stored under refs/notes/commits by default and synchronize separately from branches. Before pushing, make sure the destination remote accepts this reference.",
},
{ {
id: "app-search", id: "app-search",
title: "Find code origins with Global Search", title: "Find code origins with Global Search",
+93 -10
View File
@@ -80,6 +80,7 @@
onCherryPickCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void; onRevertCommit: (commit: GitCommit) => void;
onOpenCommitNote: (commit: GitCommit) => void; onOpenCommitNote: (commit: GitCommit) => void;
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
onSelectCommit: (commit: GitCommit) => void; onSelectCommit: (commit: GitCommit) => void;
} }
@@ -108,6 +109,7 @@
onCherryPickCommit = () => {}, onCherryPickCommit = () => {},
onRevertCommit = () => {}, onRevertCommit = () => {},
onOpenCommitNote = () => {}, onOpenCommitNote = () => {},
onLoadCommitNote = async () => null,
onSelectCommit = () => {}, onSelectCommit = () => {},
}: Props = $props(); }: Props = $props();
@@ -120,6 +122,16 @@
let contextCommit = $state<GitCommit | null>(null); let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
let notePreviews = $state<Record<string, string>>({});
let notePreviewLoading = $state<Set<string>>(new Set());
let notePreviewErrors = $state<Set<string>>(new Set());
$effect(() => {
repositoryKey;
notePreviews = {};
notePreviewLoading = new Set();
notePreviewErrors = new Set();
});
function observeHistoryEnd(node: HTMLElement) { function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list"); const root = node.closest<HTMLElement>(".history-list");
@@ -503,6 +515,34 @@
await onOpenCommitNote(commit); await onOpenCommitNote(commit);
} }
async function loadCommitNotePreview(commit: GitCommit) {
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
const loading = new Set(notePreviewLoading);
loading.add(commit.hash);
notePreviewLoading = loading;
const errors = new Set(notePreviewErrors);
errors.delete(commit.hash);
notePreviewErrors = errors;
try {
const note = await onLoadCommitNote(commit);
notePreviews = {
...notePreviews,
[commit.hash]: note?.trim() || "This Git note is empty.",
};
} catch {
const nextErrors = new Set(notePreviewErrors);
nextErrors.add(commit.hash);
notePreviewErrors = nextErrors;
} finally {
const nextLoading = new Set(notePreviewLoading);
nextLoading.delete(commit.hash);
notePreviewLoading = nextLoading;
}
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeCommitContextMenu(); closeCommitContextMenu();
@@ -792,6 +832,7 @@
<article <article
class="commit-row graph-row" class="commit-row graph-row"
class:selected={selectedCommitHash === item.hash} class:selected={selectedCommitHash === item.hash}
class:has-note={item.has_note}
data-commit-hash={item.hash} data-commit-hash={item.hash}
class:graph-ahead-row={rowSyncClass === "ahead"} class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"} class:graph-behind-row={rowSyncClass === "behind"}
@@ -852,6 +893,9 @@
class:has-branch-ref={Boolean(refSummary.primaryBranch)} class:has-branch-ref={Boolean(refSummary.primaryBranch)}
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`} style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
> >
{#if item.has_note}
<span class="commit-note-rail" aria-hidden="true"></span>
{/if}
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0} {#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area"> <div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references"> <div class="commit-ref-strip" aria-label="Commit references">
@@ -966,6 +1010,43 @@
<div class="commit-meta-line"> <div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span> <span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</span> <span class="commit-author" title={item.author_email}>{item.author_name}</span>
{#if item.has_note}
<span class="commit-note-indicator">
<button
class="commit-note-presence"
type="button"
onpointerenter={() => void loadCommitNotePreview(item)}
onfocus={() => void loadCommitNotePreview(item)}
onclick={() => openCommitNote(item)}
disabled={isBusy}
aria-label={`Open Git note for ${item.short_hash}`}
aria-describedby={`commit-note-preview-${item.hash}`}
>
<StickyNote size={11} aria-hidden="true" />
<span>Note</span>
</button>
<span
class="commit-note-tooltip"
id={`commit-note-preview-${item.hash}`}
role="tooltip"
>
<span class="commit-note-tooltip-head">
<StickyNote size={12} aria-hidden="true" />
Git Note
<small>Click to open</small>
</span>
<span class="commit-note-tooltip-body">
{#if notePreviewLoading.has(item.hash)}
Loading note…
{:else if notePreviewErrors.has(item.hash)}
Note could not be loaded.
{:else}
{notePreviews[item.hash] ?? "Hover to load the note."}
{/if}
</span>
</span>
</span>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -1008,16 +1089,18 @@
<div class="commit-actions"> <div class="commit-actions">
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time> <time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons"> <div class="commit-action-buttons">
<button {#if !item.has_note}
class="commit-menu-button commit-note-button" <button
type="button" class="commit-menu-button commit-note-button"
onclick={() => openCommitNote(item)} type="button"
disabled={isBusy} onclick={() => openCommitNote(item)}
title={`Open internal note for ${item.short_hash}`} disabled={isBusy}
aria-label={`Open internal note for ${item.short_hash}`} title={`Add a Git note to ${item.short_hash}`}
> aria-label={`Add a Git note to ${item.short_hash}`}
<StickyNote size={14} aria-hidden="true" /> >
</button> <StickyNote size={14} aria-hidden="true" />
</button>
{/if}
<button <button
class="commit-menu-button" class="commit-menu-button"
type="button" type="button"
+14 -5
View File
@@ -138,8 +138,17 @@ export function renameRemoteBranch(
remote: string, remote: string,
oldBranch: string, oldBranch: string,
newBranch: string, newBranch: string,
username?: string,
password?: string,
): Promise<GitStatus> { ): Promise<GitStatus> {
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch }); return invoke<GitStatus>("rename_remote_branch", {
path,
remote,
oldBranch,
newBranch,
username: username ?? null,
password: password ?? null,
});
} }
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> { export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
@@ -373,16 +382,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";
} }