Compare commits

...
11 Commits
Author SHA1 Message Date
Christoph 25d7f27d7a feat(release): attach native Arch package and repackage for AUR
publish / Build and publish AUR packages (release) Successful in 46m31s
publish / Build and publish Ubuntu AppImage (release) Successful in 20m25s
publish / Build and publish Windows installer (release) Successful in 22m15s
Update release workflow to detect and attach the native Arch package.
Switch the binary AUR recipe to repackage the .pkg.tar.zst. Adjust
checksums and packaging steps so the output carries only the native
payload without bundling upstream package metadata.

- Attach the native .pkg.tar.zst to Gitea releases.
- Rework binary AUR recipe to extract usr from the native package.
- Update README to document using the native Arch package.
2026-08-16 10:48:28 +02:00
Christoph c5f058beb1 ci(app_builder): add fuse2 to Arch package list
publish / Build and publish Ubuntu AppImage (release) Successful in 20m12s
publish / Build and publish Windows installer (release) Successful in 22m8s
publish / Build and publish AUR packages (release) Successful in 49m53s
The app builder workflow now includes fuse2 among Arch packages.
This provides FUSE support in the build environment for tools and
mounts used during builds and tests. It prevents failures caused by
a missing FUSE runtime.

- Add fuse2 to ARCH_PACKAGES to satisfy FUSE-dependent tooling
2026-08-15 22:51:04 +02:00
Christoph 4181925047 ci(app-builder): fetch AppImage from Gitea release using token
publish / Build and publish Ubuntu AppImage (release) Successful in 20m20s
publish / Build and publish Windows installer (release) Successful in 20m56s
publish / Build and publish AUR packages (release) Failing after 45m53s
Replace unauthenticated artifact lookup with an authenticated Gitea
release API call that fetches release JSON and asset metadata.
Require a Gitea token (with optional fallback), use it to locate the
AppImage asset in the release JSON, extract its name and download URL,
and download the file with an Authorization header.
Fail early when no token is present and verify the downloaded asset
checksum to ensure integrity; this enables access to protected releases
and improves asset selection robustness.

- Enforce token presence and use Authorization header for API calls.
- Parse release JSON to select .appimage asset and determine filename.
- Remove dependency on unauthenticated artifact base URL lookup.
2026-08-15 21:15:09 +02:00
Christoph 927e0c3e92 feat(workflows): attach Arch package to Gitea release
publish / Build and publish Windows installer (release) Failing after 14m48s
publish / Build and publish Ubuntu AppImage (release) Successful in 19m53s
publish / Build and publish AUR packages (release) Failing after 46m10s
Add a CI step that finds the built Arch binary and attaches it
to the matching Gitea release using the repository API. The step
validates that the package was created, requires a Gitea token,
and skips upload if the asset already exists to avoid duplicates.

- Export artifact path to the environment and validate its presence
- Query the release to obtain its ID and upload the asset if missing
2026-08-15 19:48:17 +02:00
Christoph 0dac3e9f09 feat(remote): support authenticated deletion of remote branches
Add support for authenticated deletion of remote branches and folders.
The backend adds a batch delete command that queries the remote,
performs an atomic push --delete when possible with a fallback, and
prunes stale tracking refs; it accepts optional credentials. The
frontend and JS API integrate credential handling and prompt users when
authentication is required.

- New batch deletion command with optional username/password for auth.
- Uses ls-remote to scope deletions, tries --atomic then falls back.
- Frontend updates: credential dialog/action, pendingRemoteDelete state.
2026-08-15 19:38:39 +02:00
Christoph 4f1131e855 feat(branches): add bulk delete for branch folders
Add a context-menu action to delete all branches in a folder at once.
Top-level remote folders are protected and the currently checked-out branch
is preserved. The BranchPanel API now includes folder depth to prevent
accidental bulk-deletes and summarizes individual failures after processing.
UI polish and localization were applied, and the package/version metadata,
changelog, and help overlay were updated for the 2026.8.4 release.

- Bulk delete with top-level remote protection and failure summaries.
- BranchPanel now tracks folder depth and disables unsafe context menus.
- Version bump, changelog/help overlay updates, and UI/localization tweaks.
2026-08-15 19:20:06 +02:00
Christoph f24c39c573 feat(compare): localize compare dialog and refine styles
Add German labels to compare select options and refine styles.
Group names and the current marker are localized for German.
Style tweaks adjust spacing, weights, and muted colors for better
readability and visual consistency.

- Localize option groups and current marker to German.
- Refine dialog typography, spacing, and muted color tokens.
2026-08-15 19:12:40 +02:00
Christoph f487090b4b feat(branches): add folder delete action and context menu
Add ability to delete all branches in a folder from the UI. Add a
handler to batch-delete local or remote branches and refresh views.

- Batch deletion handles remote and local branches and reports failures.
- Folder context menu receives branches and disables delete if needed.
- Track folder deletion attempts and failures via telemetry.
2026-08-15 19:04:16 +02:00
Christoph c8247f2b2a style(repo-tabs): refine close button layout and focus state
Improve sizing and interaction of the repository tab close button. The
control now has a consistent 24px circular hit area. Hover and
focus-visible states use a subtle accent color for clarity. Icon size
is reduced to better center the glyph inside the control.

- Make the close button a 24px circular, grid-centered element.
- Add focus-visible and hover color with a subtle accent background.
- Reduce the close icon to 11px to improve visual balance.
2026-08-15 18:51:01 +02:00
Christoph a7558e457d feat(compare): add German localization and revamp compare UI
Pass the application language into compare dialogs and add German
translations so titles, buttons and helper text render appropriately.
Refactor dialog markup to introduce a compact header with a GitCompare
icon, show changed-file counts, and surface contextual help and warnings.
Adjust and extend CSS for spacing, layout and responsive behavior.

- Pass app language into compare components and derive isGerman flag
- Replace icons and restructure headers; add counts, help and warnings
- Update styles for sizing, spacing, responsive rules and new classes
2026-08-15 18:39:10 +02:00
Christoph 412e8d19b5 feat(ui): add SelectMenu component and replace native selects
Introduce a reusable SelectMenu component and replace native select
controls across the UI. This centralizes select behavior and styling,
enabling grouped options, placeholders, and a consistent popup
interaction. Add comprehensive CSS and light-theme tweaks, and update
rebase action styling to integrate the new control.

- Add a unified SelectMenu component and wire change handlers.
- Implement .select-menu styles, popup behavior, and theme overrides.
- Replace ad-hoc native selects in dialogs and rebase UI for consistency.
2026-08-15 18:26:32 +02:00
28 changed files with 999 additions and 293 deletions
+101 -18
View File
@@ -123,11 +123,17 @@ jobs:
publish-arch:
name: Build and publish AUR packages
needs: publish-ubuntu
permissions:
contents: write
runs-on: archlinux
environment: production
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
GITEA_API: https://git.cbsk-tech.de/api/v1
OWNER: Christoph
REPO: GitLite
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
GITEA_FALLBACK_TOKEN: ${{ github.token }}
defaults:
run:
@@ -236,44 +242,121 @@ jobs:
runuser -u builder -- \
bash -lc "cd '$AUR_SOURCE_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
ARCH_PACKAGE_PATH="$(find "$AUR_SOURCE_DIR" -maxdepth 1 -type f -name 'gitty-desktop-*.pkg.tar.zst' -print -quit)"
if [ -z "$ARCH_PACKAGE_PATH" ]; then
echo "The native Arch package was not created" >&2
exit 1
fi
echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
echo "ARCH_PACKAGE_PATH=$ARCH_PACKAGE_PATH" >> "$GITHUB_ENV"
- name: Attach native Arch package to Gitea release
run: |
set -euo pipefail
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo "A Gitea token is required to upload the native Arch package" >&2
exit 1
fi
RELEASE_JSON="$(curl --fail --location --silent --show-error \
--header "Authorization: token $TOKEN" \
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
export RELEASE_JSON
RELEASE_ID="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); if (!r.id) process.exit(1); process.stdout.write(String(r.id))")"
PACKAGE_NAME="${ARCH_PACKAGE_PATH##*/}"
export PACKAGE_NAME
if node -e "const r=JSON.parse(process.env.RELEASE_JSON); process.exit((r.assets || []).some(a => a.name === process.env.PACKAGE_NAME) ? 0 : 1)"; then
echo "Release asset $PACKAGE_NAME already exists; skipping upload."
else
curl --fail --location --silent --show-error \
--request POST \
--header "Authorization: token $TOKEN" \
--form "attachment=@$ARCH_PACKAGE_PATH;type=application/octet-stream" \
"$GITEA_API/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=$PACKAGE_NAME"
echo
echo "Attached $PACKAGE_NAME to Gitea release $RELEASE_TAG."
fi
- 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##*/}"
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo "A Gitea token is required to locate the native Arch package" >&2
exit 1
fi
RELEASE_JSON="$(curl --fail --location --silent --show-error \
--header "Authorization: token $TOKEN" \
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
export RELEASE_JSON
PACKAGE_NAME="${ARCH_PACKAGE_PATH##*/}"
export PACKAGE_NAME
PACKAGE_URL="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); const a=(r.assets || []).find(a => a.name === process.env.PACKAGE_NAME); if (!a?.browser_download_url) process.exit(1); process.stdout.write(a.browser_download_url)")"
AUR_BIN_DIR="$(mktemp -d)"
cp PKGBUILD-bin "$AUR_BIN_DIR/PKGBUILD"
APPIMAGE_PATH="$AUR_BIN_DIR/$APPIMAGE_NAME"
PACKAGE_PATH="$AUR_BIN_DIR/$PACKAGE_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)"
--header "Authorization: token $TOKEN" \
--output "$PACKAGE_PATH" "$PACKAGE_URL"
PACKAGE_CHECKSUM="$(sha256sum "$PACKAGE_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')" \
-e "s|^_package=.*|_package=\"$PACKAGE_NAME\"|" \
-e "s|^_artifact_url=.*|_artifact_url=\"$PACKAGE_URL\"|" \
-e "s/^sha256sums=.*/sha256sums=('$PACKAGE_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"
ARCH_BIN_PACKAGE_PATH="$(find "$AUR_BIN_DIR" -maxdepth 1 -type f -name 'gitty-desktop-bin-*.pkg.tar.zst' -print -quit)"
if [ -z "$ARCH_BIN_PACKAGE_PATH" ]; then
echo "The binary Arch package was not created" >&2
exit 1
fi
echo "AUR_BIN_DIR=$AUR_BIN_DIR" >> "$GITHUB_ENV"
echo "ARCH_BIN_PACKAGE_PATH=$ARCH_BIN_PACKAGE_PATH" >> "$GITHUB_ENV"
- name: Attach binary Arch package to Gitea release
run: |
set -euo pipefail
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo "A Gitea token is required to upload the Arch package" >&2
exit 1
fi
RELEASE_JSON="$(curl --fail --location --silent --show-error \
--header "Authorization: token $TOKEN" \
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
export RELEASE_JSON
RELEASE_ID="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); if (!r.id) process.exit(1); process.stdout.write(String(r.id))")"
PACKAGE_NAME="${ARCH_BIN_PACKAGE_PATH##*/}"
export PACKAGE_NAME
if node -e "const r=JSON.parse(process.env.RELEASE_JSON); process.exit((r.assets || []).some(a => a.name === process.env.PACKAGE_NAME) ? 0 : 1)"; then
echo "Release asset $PACKAGE_NAME already exists; skipping upload."
else
curl --fail --location --silent --show-error \
--request POST \
--header "Authorization: token $TOKEN" \
--form "attachment=@$ARCH_BIN_PACKAGE_PATH;type=application/octet-stream" \
"$GITEA_API/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=$PACKAGE_NAME"
echo
echo "Attached $PACKAGE_NAME to Gitea release $RELEASE_TAG."
fi
- name: Publish PKGBUILD to AUR
env:
+20
View File
@@ -4,6 +4,25 @@ All notable user-facing changes to Gitty are documented in this file.
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
## [2026.8.4] - 2026-08-15
### Added
- Branch folders now have a context-menu action for deleting all contained
local or remote branches at once. The top-level remote folder such as
`origin` is protected, while its nested folders remain manageable. The
currently checked-out branch is kept,
and individual failures are reported after the remaining branches have been
processed.
### Changed
- The Compare selector is fully localized in German and uses the same visual
language as the external-tool selectors for its fields, groups, typography,
and dialog surfaces.
- Repository-tab close buttons are square and have clearer spacing, hover
behavior, and keyboard-focus feedback.
## [2026.8.3] - 2026-08-13
### Added
@@ -161,6 +180,7 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
[2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
[2026.8.2]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.2
[2026.8.1]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.1
+11 -31
View File
@@ -1,45 +1,25 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
pkgname=gitty-desktop-bin
pkgver=2026.8.3
pkgver=2026.8.4
pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt AppImage)"
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch package)"
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')
depends=('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="gitty-desktop-${pkgver}-1-x86_64.pkg.tar.zst"
_artifact_url="https://git.cbsk-tech.de/Christoph/GitLite/releases/download/${pkgver}/${_package}"
source=("${_package}::${_artifact_url}")
noextract=("${_package}")
sha256sums=('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
# Extract only the native package payload, without carrying its package
# metadata (.PKGINFO, .BUILDINFO and .MTREE) across.
bsdtar -xf "$srcdir/$_package" -C "$pkgdir" usr
}
+4 -3
View File
@@ -60,7 +60,7 @@ Install Gitty from the AUR with an AUR helper:
yay -S gitty-desktop
```
To install the prebuilt AppImage instead of compiling from source:
To install the prebuilt native Arch package instead of compiling from source:
```bash
yay -S gitty-desktop-bin
@@ -75,8 +75,9 @@ makepkg -si
```
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.
The `-bin` recipe repackages the native `.pkg.tar.zst` release artifact. The
release pipeline updates both packages' versions, checksums, and `.SRCINFO`
files.
---
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.8.3",
"version": "2026.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.8.3",
"version": "2026.8.4",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.8.3",
"version": "2026.8.4",
"private": true,
"type": "module",
"scripts": {
+119 -13
View File
@@ -651,30 +651,136 @@ pub fn set_branch_upstream(
status_for_repo(&repo)
}
#[tauri::command(async)]
pub fn delete_remote_branch(
#[tauri::command]
pub async fn delete_remote_branch(
path: String,
remote: String,
branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}");
let result = (|| {
run_git_task("Could not delete remote branch", move || {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
delete_remote_branches_core(
&repo,
&remote,
vec![branch],
username.as_deref().zip(password.as_deref()),
)
})
.await
}
#[tauri::command]
pub async fn delete_remote_branches(
path: String,
remote: String,
branches: Vec<String>,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not delete remote branch folder", move || {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
delete_remote_branches_core(
&repo,
&remote,
branches,
username.as_deref().zip(password.as_deref()),
)
})
.await
}
fn delete_remote_branches_core(
repo: &Path,
remote: &str,
branches: Vec<String>,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> {
let run_remote = |args: Vec<String>| match credentials {
Some((user, pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, args, user, pass)
}
_ => run_git(repo, args),
};
let mut branch_names = Vec::new();
for branch in branches {
let branch = branch.trim();
if branch.is_empty() || branch.starts_with('-') {
return Err("Invalid remote branch name.".to_string());
}
run_git(&repo, ["check-ref-format", "--branch", branch])?;
log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}");
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}");
status_for_repo(&repo)
})();
if let Err(error) = &result {
log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}");
run_git(repo, ["check-ref-format", "--branch", branch])?;
if !branch_names.iter().any(|existing| existing == branch) {
branch_names.push(branch.to_string());
}
}
result
if branch_names.is_empty() {
return Err("No remote branches were selected.".to_string());
}
// Local remote-tracking refs can be stale when a branch was deleted by
// another client. Query the server first so deletion remains idempotent.
let mut query_args = vec![
"ls-remote".to_string(),
"--heads".to_string(),
remote.to_string(),
];
query_args.extend(
branch_names
.iter()
.map(|branch| format!("refs/heads/{branch}")),
);
let remote_refs = String::from_utf8_lossy(&run_remote(query_args)?).to_string();
let existing_refs: BTreeSet<&str> = remote_refs
.lines()
.filter_map(|line| line.split_once('\t').map(|(_, reference)| reference.trim()))
.collect();
branch_names.retain(|branch| existing_refs.contains(format!("refs/heads/{branch}").as_str()));
if !branch_names.is_empty() {
let mut atomic_args = vec![
"push".to_string(),
"--atomic".to_string(),
remote.to_string(),
"--delete".to_string(),
];
atomic_args.extend(branch_names.iter().cloned());
if let Err(error) = run_remote(atomic_args) {
let message = error.to_lowercase();
let atomic_unsupported = message.contains("does not support --atomic")
|| message.contains("atomic push is not supported")
|| message.contains("does not support atomic push");
if !atomic_unsupported {
return Err(error);
}
log::warn!(
target: "gitty::remote",
"remote {remote} does not support atomic pushes; retrying branch-folder deletion as one regular push"
);
let mut fallback_args = vec![
"push".to_string(),
remote.to_string(),
"--delete".to_string(),
];
fallback_args.extend(branch_names);
run_remote(fallback_args)?;
}
}
// Also remove stale tracking refs for branches that were already absent.
if let Err(error) = run_remote(vec![
"fetch".to_string(),
"--prune".to_string(),
remote.to_string(),
]) {
log::warn!(target: "gitty::remote", "remote branches were deleted, but tracking refs could not be pruned: {error}");
}
status_for_repo(repo)
}
#[tauri::command]
+3 -2
View File
@@ -16,8 +16,8 @@ use git::{
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note,
get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
@@ -141,6 +141,7 @@ async fn main() {
remove_remote,
set_branch_upstream,
delete_remote_branch,
delete_remote_branches,
list_stashes,
checkout_branch,
create_branch,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.8.3",
"version": "2026.8.4",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+114 -18
View File
@@ -60,6 +60,7 @@
deleteCommitNote,
deleteTag,
deleteRemoteBranch,
deleteRemoteBranches,
initRepository,
diffFileAgainstWorkingTree,
compareFileToParent,
@@ -188,7 +189,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
@@ -411,6 +412,7 @@
let credDialogOpen = false;
let credDialogAction: CredentialAction | null = null;
let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null;
let pendingRemoteDelete: { remote: string; branches: string[]; label: string; folder: boolean } | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let credDialogUsername = "";
@@ -1974,6 +1976,7 @@
globalSearchResults = [];
deleteBranchTarget = null;
deleteBranchForce = false;
pendingRemoteDelete = null;
worktreeDialogOpen = false;
worktreeInitialBranch = "";
worktrees = [];
@@ -2656,6 +2659,72 @@
trackEvent("branch_delete_dialog_opened");
}
async function deleteBranchFolder(folderName: string, folderBranches: GitBranchInfo[], folderDepth: number) {
if (!activeRepoPath || isBusy) return;
const remoteFolder = folderBranches.every((branch) => branch.remote);
if (remoteFolder && folderDepth === 0) {
errorMessage = "The top-level remote folder cannot be deleted as a group.";
return;
}
const deletableBranches = folderBranches.filter((branch) => !branch.current);
const currentBranchKept = folderBranches.some((branch) => branch.current);
if (deletableBranches.length === 0) {
errorMessage = "The folder only contains the current branch, which cannot be deleted.";
return;
}
const scope = remoteFolder ? "remote" : "local";
const currentNote = currentBranchKept ? "\n\nThe current branch will be kept." : "";
if (!window.confirm(`Delete ${deletableBranches.length} ${scope} branches in “${folderName}”?${currentNote}`)) return;
const repoPath = activeRepoPath;
if (remoteFolder) {
const remoteBranches = deletableBranches.map((branch) => {
const slash = branch.name.indexOf("/");
if (slash < 1) throw new Error(`Could not determine the remote for ${branch.name}.`);
return { remote: branch.name.slice(0, slash), name: branch.name.slice(slash + 1) };
});
const remote = remoteBranches[0]?.remote;
if (!remote || remoteBranches.some((branch) => branch.remote !== remote)) {
errorMessage = "A remote branch folder must belong to exactly one remote.";
return;
}
pendingRemoteDelete = { remote, branches: remoteBranches.map((branch) => branch.name), label: folderName, folder: true };
const key = await currentCredKey("delete");
const stored = await loadStoredCredential(key);
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored));
} else {
await openCredentialDialog("delete", key, stored);
}
return;
}
const failures: string[] = [];
operation = `Deleting branches in ${folderName}`;
errorMessage = "";
try {
for (const branch of deletableBranches) {
try {
applyStatus(await deleteBranch(repoPath, branch.name, false));
} catch (error) {
failures.push(`${branch.name}: ${errorToMessage(error)}`);
}
}
await refreshRepositoryViews(repoPath);
trackEvent("branch_folder_deleted", {
attempted: deletableBranches.length,
failed: failures.length,
remote: 0,
});
if (failures.length > 0) {
errorMessage = `${failures.length} branch${failures.length === 1 ? "" : "es"} could not be deleted:\n${failures.join("\n")}`;
}
} finally {
operation = "";
}
}
async function confirmDeleteBranch() {
const branch = deleteBranchTarget;
if (!activeRepoPath || !branch || branch.current || isBusy) return;
@@ -2665,17 +2734,13 @@
if (slash < 1) { errorMessage = "Could not determine remote name."; return; }
const remote = branch.name.slice(0, slash);
const remoteBranch = branch.name.slice(slash + 1);
operation = `Deleting ${branch.name} from remote`;
errorMessage = "";
try {
applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch));
deleteBranchTarget = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("remote_branch_deleted");
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
operation = "";
pendingRemoteDelete = { remote, branches: [remoteBranch], label: branch.name, folder: false };
const key = await currentCredKey("delete");
const stored = await loadStoredCredential(key);
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored));
} else {
await openCredentialDialog("delete", key, stored);
}
return;
}
@@ -3234,11 +3299,11 @@
// 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> {
async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" | "delete" = "fetch"): Promise<string | null> {
if (!activeRepoPath) return null;
try {
const remote = action === "rename" ? pendingRemoteRename?.remote : selectedRemote;
const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename");
const remote = action === "rename" ? pendingRemoteRename?.remote : action === "delete" ? pendingRemoteDelete?.remote : selectedRemote;
const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename" || action === "delete");
return url ? orgKeyFromUrl(url) : null;
} catch {
return null;
@@ -3277,7 +3342,7 @@
// 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" | "rename",
action: "push" | "pull" | "fetch" | "rename" | "delete",
key: string | null,
fromStore: boolean,
username: string,
@@ -3445,6 +3510,32 @@
handleRemoteResult("rename", key, fromStore, username, mode);
}
async function doActualRemoteDelete(
username: string,
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
const deletion = pendingRemoteDelete;
if (!activeRepoPath || !deletion) return;
errorMessage = "";
await runOperation(`Deleting ${deletion.label} from remote`, async () => {
applyStatus(deletion.folder
? await deleteRemoteBranches(activeRepoPath, deletion.remote, deletion.branches, username, password)
: await deleteRemoteBranch(activeRepoPath, deletion.remote, deletion.branches[0], username, password));
deleteBranchTarget = null;
pendingRemoteDelete = null;
await refreshRefsAndCommitGraph(activeRepoPath);
if (deletion.folder) {
trackEvent("branch_folder_deleted", { attempted: deletion.branches.length, failed: 0, remote: 1 });
} else {
trackEvent("remote_branch_deleted");
}
});
handleRemoteResult("delete", key, fromStore, username, mode);
}
async function handleCredentialSubmit(
username: string,
password: string,
@@ -3468,6 +3559,7 @@
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 === "delete") await doActualRemoteDelete(username, password, key, false, mode);
else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo(
pendingClone.remoteUrl,
@@ -4962,6 +5054,7 @@
onRenameBranch={openRenameBranchDialog}
onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onDeleteBranchFolder={deleteBranchFolder}
onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag}
@@ -5537,6 +5630,7 @@
{canCompare}
{isBusy}
{operation}
language={appLanguage}
onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedTargets}
@@ -5551,10 +5645,11 @@
{comparison}
{selectedDiffPath}
{isBusy}
language={appLanguage}
fromLabel={comparisonFromLabel}
toLabel={comparisonToLabel}
highlightQuery={diffHighlightQuery}
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
restoreLabel={pendingRestoreFile ? (appLanguage === "de" ? "Datei wiederherstellen" : "Restore file") : ""}
onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile}
@@ -5562,7 +5657,7 @@
{/await}
{/if}
<!-- Credential dialog for push/pull -->
<!-- Credential dialog for authenticated remote actions -->
{#if credDialogOpen && credDialogAction}
<CredentialDialog
action={credDialogAction}
@@ -5574,6 +5669,7 @@
onCancel={() => {
credDialogOpen = false;
credDialogAction = null;
pendingRemoteDelete = null;
credDialogError = "";
credDialogKey = null;
credDialogUsername = "";
+202 -18
View File
@@ -225,6 +225,114 @@
.btn-sm { min-height: 26px; padding: 0 8px; font-size: 12px; }
.select-menu { position: relative; width: 100%; min-width: 0; }
.select-menu-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
height: 34px;
min-height: 34px;
padding: 0 9px 0 11px;
border: 1px solid var(--color-border-input);
border-radius: 6px;
color: var(--color-ink);
background: var(--color-surface-raised);
font-size: 13px;
text-align: left;
}
.select-menu-value { display: flex; align-items: center; min-width: 0; gap: 7px; overflow: hidden; }
.select-menu-value > span { overflow: hidden; color: var(--color-ink); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.select-menu-value > small {
flex: 0 0 auto;
padding: 2px 5px;
border: 1px solid color-mix(in srgb, var(--color-accent) 28%, transparent);
border-radius: 4px;
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
font-size: 9px;
font-weight: 850;
letter-spacing: 0.025em;
}
.select-menu-value.placeholder > span { color: var(--color-ink-muted); font-weight: 600; }
.select-menu-trigger svg { color: var(--color-ink-faint); transition: transform 120ms ease; }
.select-menu.open .select-menu-trigger {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(90, 140, 248, 0.18);
}
.select-menu.open .select-menu-trigger svg { transform: rotate(180deg); }
.select-menu-popup {
position: fixed;
z-index: 1000;
display: grid;
gap: 2px;
padding: 5px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-solid);
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
}
.select-menu-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 5px 3px 2px;
padding: 7px 5px 5px;
border-top: 1px solid var(--color-border-subtle);
color: var(--color-accent);
font-size: 10.5px;
font-weight: 900;
letter-spacing: 0.065em;
text-transform: uppercase;
}
.select-menu-group:first-child { margin-top: 0; border-top: 0; }
.select-menu-group small {
min-width: 18px;
padding: 1px 5px;
border-radius: 999px;
color: var(--color-ink);
background: var(--color-surface-hover);
font-size: 9px;
letter-spacing: 0;
text-align: center;
}
.select-menu-option {
display: grid;
grid-template-columns: minmax(0, 1fr) 14px;
align-items: center;
justify-content: initial;
width: 100%;
min-height: 30px;
padding: 6px 8px;
border-color: transparent;
border-radius: 6px;
color: var(--color-ink);
background: transparent;
font-size: 12px;
font-weight: 750;
text-align: left;
}
.select-menu-option > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.select-menu-option svg { color: var(--color-primary); }
.select-menu-option:hover:not(:disabled), .select-menu-option.active:not(:disabled) {
border-color: var(--color-border-subtle);
color: var(--color-ink);
background: rgba(255,255,255,0.06);
}
.select-menu-option.selected { color: var(--color-ink); }
.select-menu-option:disabled { cursor: not-allowed; opacity: 0.42; }
.compare-target-select .select-menu-trigger {
height: 38px;
min-height: 38px;
padding: 0 11px;
border-color: var(--color-border);
font-size: 12px;
font-weight: 700;
}
.btn-primary {
border-color: rgba(111, 140, 255, 0.72);
color: #ffffff;
@@ -1004,10 +1112,29 @@
background: transparent;
color: var(--color-ink-faint);
}
.repo-tab-close { opacity: 0.58; transition: opacity 120ms ease, color 120ms ease, background 120ms ease; }
.repo-tab-close {
display: grid;
place-items: center;
align-self: center;
min-width: 26px;
width: 26px;
max-width: 26px;
min-height: 26px;
height: 26px;
max-height: 26px;
flex: 0 0 26px;
margin-right: 6px;
border-radius: 5px;
opacity: 0.58;
transition: opacity 120ms ease, color 120ms ease, background 120ms ease;
}
.repo-tab-wrap:hover .repo-tab-close,
.repo-tab-wrap.active .repo-tab-close { opacity: 1; }
.repo-tab-close:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); }
.repo-tab-close:hover:not(:disabled),
.repo-tab-close:focus-visible:not(:disabled) {
color: #ff6673;
background: rgba(255, 90, 103, 0.13);
}
.repo-tab-add {
min-width: 44px;
border-left: 1px solid var(--color-border-subtle);
@@ -3222,17 +3349,16 @@
.compare-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) 40px minmax(0, 1fr);
align-items: end;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border-subtle);
gap: 10px;
}
.compare-field { display: grid; gap: 4px; min-width: 0; }
.compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
.compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); }
.compare-field { display: grid; gap: 6px; min-width: 0; }
.compare-field > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.compare-target-help {
margin: 12px;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
border-radius: 8px;
@@ -3241,6 +3367,9 @@
font-size: 11.5px;
line-height: 1.5;
}
.compare-target-help svg { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
.compare-target-warning { border-color: color-mix(in srgb, #e0a040 30%, var(--color-border-subtle)); background: color-mix(in srgb, #e0a040 7%, var(--color-surface-raised)); }
.compare-target-warning svg { color: #e0a040; }
.compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
.compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); }
@@ -3386,6 +3515,21 @@
width: min(1560px, calc(100vw - 32px));
height: min(940px, calc(100vh - 32px));
}
.compare-dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 70px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--app-dialog-chrome);
}
.compare-dialog-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.compare-dialog-title > div { min-width: 0; }
.compare-dialog-mark { display: grid; place-items: center; flex: 0 0 auto; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
.compare-dialog-title h2 { margin: 0; color: var(--color-ink); font-size: 18px; font-weight: 700; line-height: 1.2; }
.compare-dialog-title p { margin: 3px 0 0; color: var(--color-ink-dim); font-size: 11px; font-weight: 400; line-height: 1.35; }
.global-search-dialog {
width: min(1180px, calc(100vw - 32px));
height: min(840px, calc(100vh - 32px));
@@ -3401,12 +3545,22 @@
height: min(860px, calc(100vh - 32px));
}
.compare-select-dialog {
display: block;
grid-template-rows: auto minmax(0, 1fr);
width: min(720px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
overflow: hidden;
}
.compare-select-shell { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; }
.compare-select-body { display: grid; align-content: start; gap: 12px; min-height: 0; padding: 18px 20px 20px; overflow: auto; }
.compare-target-panel { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.compare-target-heading h3 { margin: 0; color: var(--color-ink); font-size: 14px; font-weight: 700; line-height: 1.2; }
.compare-target-heading p { margin: 3px 0 0; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 400; line-height: 1.4; }
.compare-arrow-shell { display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-accent); background: var(--color-surface-raised); }
.compare-select-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 11px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.compare-select-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
.compare-select-footer > div { display: flex; gap: 8px; }
.compare-select-footer button { min-height: 32px; }
.interactive-rebase-dialog,
.reflog-dialog {
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -3463,11 +3617,12 @@
border-radius: 5px;
background: var(--code-surface-subtle);
}
.rebase-action { height: 30px; font-family: var(--font-mono); font-weight: 800; }
.rebase-action.pick { color: var(--code-add-strong); }
.rebase-action.reword { color: var(--code-hunk-text); }
.rebase-action.squash, .rebase-action.fixup { color: #96620f; }
.rebase-action.drop { color: var(--code-delete-strong); }
.rebase-action { font-family: var(--font-mono); font-weight: 800; }
.rebase-action .select-menu-trigger { height: 30px; min-height: 30px; font-family: inherit; font-weight: inherit; }
.rebase-action.pick .select-menu-trigger { color: var(--code-add-strong); }
.rebase-action.reword .select-menu-trigger { color: var(--code-hunk-text); }
.rebase-action.squash .select-menu-trigger, .rebase-action.fixup .select-menu-trigger { color: #96620f; }
.rebase-action.drop .select-menu-trigger { color: var(--code-delete-strong); }
.rebase-plan-row > code { color: var(--color-accent); font-family: var(--font-mono); font-size: 11px; font-weight: 800; }
.rebase-commit-copy { display: grid; gap: 3px; min-width: 0; }
.rebase-commit-copy strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
@@ -3964,6 +4119,8 @@
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.compare-files-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 36px; margin: -8px -8px 4px; padding: 8px 12px; border-bottom: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); font-size: 10px; font-weight: 850; letter-spacing: .05em; text-transform: uppercase; }
.compare-files-head strong { display: grid; place-items: center; min-width: 22px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; }
.dialog-file-row {
display: grid;
@@ -6512,6 +6669,25 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
box-shadow: 0 18px 50px rgba(28,44,74,0.18);
}
:root[data-theme="light"] .select-menu-popup {
box-shadow: 0 18px 50px rgba(28,44,74,0.18);
}
:root[data-theme="light"] .select-menu-value > span,
:root[data-theme="light"] .select-menu-option,
:root[data-theme="light"] .select-menu-option.selected {
color: var(--color-ink);
}
:root[data-theme="light"] .select-menu-value.placeholder > span {
color: var(--color-ink-muted);
}
:root[data-theme="light"] .select-menu-option:hover:not(:disabled),
:root[data-theme="light"] .select-menu-option.active:not(:disabled) {
background: var(--color-surface-hover);
}
:root[data-theme="light"] .dialog-file-row:hover,
:root[data-theme="light"] .branch-row:hover,
:root[data-theme="light"] .explorer-row:hover,
@@ -6806,7 +6982,15 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.repo-summary { height: 40px; }
.change-lanes { grid-template-columns: 1fr; }
.compare-form { grid-template-columns: 1fr; }
.compare-arrow { display: none; }
.compare-arrow-shell { width: 100%; height: 30px; }
.compare-arrow-shell svg { transform: rotate(90deg); }
.compare-dialog-head { min-height: 60px; padding: 10px 12px; }
.compare-dialog-mark { width: 34px; height: 34px; }
.compare-dialog-title h2 { font-size: 15px; }
.compare-select-body { padding: 14px; }
.compare-target-panel { padding: 13px; }
.compare-select-footer > span { display: none; }
.compare-select-footer { justify-content: flex-end; }
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
.compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
.global-search-options { grid-template-columns: 1fr; }
+1 -1
View File
@@ -58,7 +58,7 @@
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
>
<X size={13} aria-hidden="true" />
<X size={11} aria-hidden="true" />
</button>
</div>
{/each}
@@ -1,6 +1,7 @@
<script lang="ts">
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
import type { AiCommitPlan } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props {
plan: AiCommitPlan;
@@ -62,9 +63,7 @@
<div class="split-files">
{#each group.files as file}
<div><code>{file}</code>
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}>
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
</select>
<SelectMenu class="split-file-target" value={String(groupIndex)} options={draft.groups.map((_, target) => ({ value: String(target), label: `Commit ${target + 1}` }))} disabled={isApplying} onChange={(value) => moveFile(file, groupIndex, Number(value))} />
</div>
{/each}
</div>
@@ -98,6 +97,7 @@
.split-files{display:grid;gap:5px}
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
select{height:28px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface-raised);color:var(--color-ink);font-size:11px}
:global(.split-file-target){width:110px;flex:0 0 110px}
:global(.split-file-target .select-menu-trigger){height:28px;min-height:28px;font-size:11px}
.dialog-footer p.invalid{color:#e0a040}
</style>
+2 -5
View File
@@ -3,6 +3,7 @@
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props {
settings: AiSettings;
@@ -200,11 +201,7 @@
</div>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<select bind:value={localModelId}>
{#each localModels as option (option.id)}
<option value={option.id}>{option.label} {formatSize(option.approx_size_mb)}</option>
{/each}
</select>
<SelectMenu value={localModelId} options={localModels.map((option) => ({ value: option.id, label: `${option.label} - ${formatSize(option.approx_size_mb)}` }))} onChange={(value) => { localModelId = value; }} />
</label>
<div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" />
+14 -13
View File
@@ -37,6 +37,7 @@
ExternalToolsSettings,
ToolOpenMode,
} from "../types";
import SelectMenu from "./SelectMenu.svelte";
type SettingsPage = "general" | "tools";
@@ -390,17 +391,17 @@
<label class="tool-default-field">
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
<select value={tools[activeToolKind].preset} onchange={(event) => changePreset(activeToolKind, event.currentTarget.value)} aria-label={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}>
{#if availablePresets(activeToolKind).length > 0}
<optgroup label={isGerman ? "Installiert" : "Installed"}>
{#each availablePresets(activeToolKind) as preset}<option value={preset.id}> {preset.label}</option>{/each}
</optgroup>
{/if}
<optgroup label={isGerman ? "Weitere unterstützte Programme" : "Other supported applications"}>
{#each otherPresets(activeToolKind) as preset}<option value={preset.id}>{preset.label}</option>{/each}
</optgroup>
<option value="custom">{isGerman ? "Eigenes Programm auswählen…" : "Choose a custom application…"}</option>
</select>
<SelectMenu
class="tool-preset-select"
value={tools[activeToolKind].preset}
options={[
...availablePresets(activeToolKind).map((preset) => ({ value: preset.id, label: `Installed - ${preset.label}`, group: isGerman ? "Installiert" : "Installed" })),
...otherPresets(activeToolKind).map((preset) => ({ value: preset.id, label: preset.label, group: isGerman ? "Weitere unterstützte Programme" : "Other supported applications" })),
{ value: "custom", label: isGerman ? "Eigenes Programm auswählen..." : "Choose a custom application..." },
]}
ariaLabel={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}
onChange={(value) => changePreset(activeToolKind, value)}
/>
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small>
</label>
@@ -504,8 +505,8 @@
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.tool-default-field select, .tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
.tool-default-field select { height: 38px; padding: 0 11px; font-size: 12px; font-weight: 700; }
.tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
:global(.tool-preset-select .select-menu-trigger) { height: 38px; min-height: 38px; padding: 0 11px; border-color: var(--color-border); font-size: 12px; font-weight: 700; }
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
+57
View File
@@ -12,6 +12,7 @@
children: BranchTreeNode[];
branchCount: number;
current: boolean;
branches: GitBranchInfo[];
folders: Map<string, BranchFolderNode>;
}
@@ -31,6 +32,7 @@
depth: number;
branchCount: number;
current: boolean;
branches: GitBranchInfo[];
}
interface BranchLeafRow {
@@ -57,6 +59,7 @@
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[], depth: number) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
@@ -81,6 +84,7 @@
onRenameBranch = () => {},
onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onDeleteBranchFolder = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
@@ -101,6 +105,7 @@
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextFolder = $state<BranchFolderRow | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
@@ -121,6 +126,7 @@
children: [],
branchCount: 0,
current: false,
branches: [],
folders: new Map(),
};
}
@@ -147,6 +153,7 @@
folder.branchCount += 1;
folder.current ||= branch.current;
folder.branches.push(branch);
parent = folder;
}
@@ -188,6 +195,7 @@
depth,
branchCount: node.branchCount,
current: node.current,
branches: node.branches,
});
if (isBranchFolderOpen(node.id)) {
@@ -272,8 +280,25 @@
contextMenuY = position.y;
}
async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
event.preventDefault();
event.stopPropagation();
if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
contextFolder = folder;
contextMenuX = event.clientX + 2;
contextMenuY = event.clientY + 2;
await tick();
if (contextFolder !== folder) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
contextMenuX = position.x;
contextMenuY = position.y;
}
function closeBranchContextMenu() {
contextBranch = null;
contextFolder = null;
}
async function renameContextBranch() {
@@ -297,6 +322,13 @@
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
}
async function deleteContextFolder() {
const folder = contextFolder;
if (!folder || isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
closeBranchContextMenu();
await onDeleteBranchFolder(folder.name, folder.branches, folder.depth);
}
async function createContextWorktree() {
const branch = contextBranch;
closeBranchContextMenu();
@@ -484,6 +516,7 @@
style={`--branch-indent: ${row.depth * 16}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`}
>
@@ -551,6 +584,7 @@
style={`--branch-indent: ${row.depth * 16}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`}
>
@@ -735,6 +769,29 @@
</div>
{/if}
{#if contextFolder}
<div
bind:this={branchContextMenuElement}
class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for branch folder ${contextFolder.name}`}
>
<button
class="danger"
type="button"
role="menuitem"
onclick={deleteContextFolder}
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
title={contextFolder.current ? "The current branch will be kept" : "Delete all branches in this folder"}
>
<Trash2 size={14} aria-hidden="true" />
Delete {contextFolder.branches.filter((branch) => !branch.current).length} branches
</button>
</div>
{/if}
{#if contextTag}
<div
bind:this={tagContextMenuElement}
+2 -5
View File
@@ -11,6 +11,7 @@
X,
} from "@lucide/svelte";
import type { AppLanguage, GitCommit, GitRemote } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props {
commit: GitCommit;
@@ -196,11 +197,7 @@
<div class="commit-note-sync-controls">
<label>
<span>{text.remote}</span>
<select bind:value={selectedRemote} disabled={isBusy}>
{#each remotes as remote (remote.name)}
<option value={remote.name}>{remote.name}</option>
{/each}
</select>
<SelectMenu value={selectedRemote} options={remotes.map((remote) => ({ value: remote.name, label: remote.name }))} disabled={isBusy} onChange={(value) => { selectedRemote = value; }} />
</label>
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
+27 -16
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowRight, FileCode, RotateCcw, X } from "@lucide/svelte";
import { ArrowRight, FileCode, GitCompare, RotateCcw, X } from "@lucide/svelte";
import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
type SplitRow =
@@ -25,6 +25,7 @@
restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string;
language?: "en" | "de";
onClose: () => void;
onRestore?: () => void;
onSelectFile: (file: GitDiffFile) => void;
@@ -38,11 +39,14 @@
toLabel = "",
restoreLabel = "",
highlightQuery = "",
language = "en",
onClose = () => {},
onRestore = undefined,
onSelectFile = () => {},
}: Props = $props();
let isGerman = $derived(language === "de");
// Needle = first non-empty line of the search query, lowercased for matching.
let highlightNeedle = $derived(
highlightQuery
@@ -225,14 +229,17 @@
>
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Compare</span>
<h2 class="dialog-range">
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
<ArrowRight size={14} aria-hidden="true" />
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</h2>
<header class="compare-dialog-head">
<div class="compare-dialog-title">
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
<div>
<h2>{isGerman ? "Änderungen vergleichen" : "Compare changes"}</h2>
<p class="dialog-range">
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
<ArrowRight size={14} aria-hidden="true" />
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</p>
</div>
</div>
<div class="dialog-header-actions">
{#if restoreLabel && onRestore}
@@ -241,19 +248,23 @@
<span>{restoreLabel}</span>
</button>
{/if}
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
<X size={18} aria-hidden="true" />
</button>
</div>
</header>
{#if comparison.files.length === 0}
<div class="blank-state">No differences these versions are identical.</div>
<div class="blank-state">{isGerman ? "Keine Unterschiede - diese Versionen sind identisch." : "No differences - these versions are identical."}</div>
{:else}
<div class="dialog-body">
<!-- File list -->
<aside class="dialog-files" aria-label="Changed files">
<aside class="dialog-files" aria-label={isGerman ? "Geänderte Dateien" : "Changed files"}>
<div class="compare-files-head">
<span>{isGerman ? "Geänderte Dateien" : "Changed files"}</span>
<strong>{comparison.files.length}</strong>
</div>
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
<button
class="dialog-file-row"
@@ -275,9 +286,9 @@
<!-- Diff pane -->
<div class="dialog-diff">
{#if !selectedFile}
<div class="blank-state">Select a file to see its changes.</div>
<div class="blank-state">{isGerman ? "Wähle eine Datei aus, um ihre Änderungen zu sehen." : "Select a file to see its changes."}</div>
{:else if splitRows.length === 0}
<div class="blank-state">No textual changes for this file.</div>
<div class="blank-state">{isGerman ? "Keine textuellen Änderungen für diese Datei." : "No textual changes for this file."}</div>
{:else}
<!-- Path bar -->
<div class="diff-header">
@@ -292,11 +303,11 @@
<!-- Column headers -->
<div class="split-col-headers">
<div class="split-col-label">
<span>Before</span>
<span>{isGerman ? "Vorher" : "Before"}</span>
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
</div>
<div class="split-col-label">
<span>After</span>
<span>{isGerman ? "Nachher" : "After"}</span>
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</div>
</div>
+5 -20
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
import type { GitCommit, GitCommitComparison } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props {
commits: GitCommit[];
@@ -40,6 +41,8 @@
event.preventDefault();
onCompare();
}
let commitOptions = $derived(commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })));
</script>
<section class="compare-panel panel" aria-label="Compare commits">
@@ -61,32 +64,14 @@
<form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field">
<span>From (older)</span>
<select
value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</select>
<SelectMenu value={compareFrom} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
</label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field">
<span>To (newer)</span>
<select
value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</select>
<SelectMenu value={compareTo} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
</label>
<button class="btn-primary" type="submit" disabled={!canCompare}>
+73 -89
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
import { ArrowRight, GitCompare, Info, LoaderCircle, X } from "@lucide/svelte";
import type { GitBranch, GitCommit } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props {
commits: GitCommit[];
@@ -10,6 +11,7 @@
canCompare: boolean;
isBusy: boolean;
operation: string;
language?: "en" | "de";
onCompareFromChange: (val: string) => void;
onCompareToChange: (val: string) => void;
onCompare: () => void;
@@ -24,12 +26,15 @@
canCompare = false,
isBusy = false,
operation = "",
language = "en",
onCompareFromChange = () => {},
onCompareToChange = () => {},
onCompare = () => {},
onClose = () => {},
}: Props = $props();
let isGerman = $derived(language === "de");
function commitOptionLabel(item: GitCommit): string {
return `${item.short_hash} - ${item.summary}`;
}
@@ -41,6 +46,23 @@
let localBranches = $derived(branches.filter((branch) => !branch.remote));
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
let targetCount = $derived(branches.length + commits.length);
let compareOptions = $derived([
...localBranches.map((branch) => ({
value: branchValue(branch),
label: `${branch.name}${branch.current ? (isGerman ? " (aktuell)" : " (current)") : ""}`,
group: isGerman ? "Lokale Branches" : "Local branches",
})),
...remoteBranches.map((branch) => ({
value: branchValue(branch),
label: branch.name,
group: isGerman ? "Remote-Branches" : "Remote branches",
})),
...commits.map((item) => ({
value: item.hash,
label: commitOptionLabel(item),
group: "Commits",
})),
]);
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
@@ -52,104 +74,66 @@
class="dialog-backdrop"
role="presentation"
>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Compare</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Compare branches or commits</h2>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"} tabindex="-1">
<header class="compare-dialog-head">
<div class="compare-dialog-title">
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
<div>
<h2>{isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}</h2>
<p>{isGerman ? "Zwei Repository-Stände direkt gegenüberstellen" : "Review two repository states side by side"}</p>
</div>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
<X size={18} aria-hidden="true" />
</button>
</header>
{#if targetCount < 2}
<div class="blank-state">At least two branches or commits are needed to compare.</div>
{:else}
<form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field">
<span>Base</span>
<select
value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a branch or commit</option>
{#if localBranches.length > 0}
<optgroup label="Local branches">
{#each localBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
{/each}
</optgroup>
{/if}
{#if remoteBranches.length > 0}
<optgroup label="Remote branches">
{#each remoteBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}</option>
{/each}
</optgroup>
{/if}
{#if commits.length > 0}
<optgroup label="Recent commits">
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</optgroup>
{/if}
</select>
</label>
<form class="compare-select-shell" onsubmit={handleSubmit}>
<div class="compare-select-body">
{#if targetCount < 2}
<div class="blank-state">{isGerman ? "Für einen Vergleich werden mindestens zwei Branches oder Commits benötigt." : "At least two branches or commits are needed to compare."}</div>
{:else}
<section class="compare-target-panel">
<div class="compare-target-heading">
<h3>{isGerman ? "Vergleichsbereich" : "Comparison range"}</h3>
<p>{isGerman ? "Wähle Ausgangspunkt und Ziel des Vergleichs." : "Choose the starting point and target for the comparison."}</p>
</div>
<div class="compare-form">
<label class="compare-field">
<span>{isGerman ? "Ausgangsbasis" : "Base"}</span>
<SelectMenu class="compare-target-select" value={compareFrom} options={compareOptions} placeholder={isGerman ? "Branch oder Commit wählen" : "Select a branch or commit"} showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
</label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<span class="compare-arrow-shell"><ArrowRight size={18} aria-hidden="true" /></span>
<label class="compare-field">
<span>Compare with</span>
<select
value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a branch or commit</option>
{#if localBranches.length > 0}
<optgroup label="Local branches">
{#each localBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
{/each}
</optgroup>
{/if}
{#if remoteBranches.length > 0}
<optgroup label="Remote branches">
{#each remoteBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}</option>
{/each}
</optgroup>
{/if}
{#if commits.length > 0}
<optgroup label="Recent commits">
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</optgroup>
{/if}
</select>
</label>
<label class="compare-field">
<span>{isGerman ? "Vergleichen mit" : "Compare with"}</span>
<SelectMenu class="compare-target-select" value={compareTo} options={compareOptions} placeholder={isGerman ? "Branch oder Commit wählen" : "Select a branch or commit"} showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
</label>
</div>
</section>
<button class="btn-primary" type="submit" disabled={!canCompare}>
{#if operation === "Comparing revisions"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{#if compareFrom && compareTo && compareFrom === compareTo}
<div class="compare-target-help compare-target-warning"><Info size={15} aria-hidden="true" /><span>{isGerman ? "Wähle zwei unterschiedliche Branches oder Commits." : "Select two different branches or commits to compare."}</span></div>
{:else}
<GitCompare size={16} aria-hidden="true" />
<div class="compare-target-help"><Info size={15} aria-hidden="true" /><span>{isGerman ? "Verglichen werden die vollständigen Repository-Stände. Nicht committete Änderungen im Arbeitsverzeichnis sind nicht enthalten." : "The complete repository states are compared. Uncommitted working-tree changes are not included."}</span></div>
{/if}
Compare
</button>
</form>
{#if compareFrom && compareTo && compareFrom === compareTo}
<div class="blank-state">Select two different branches or commits to compare.</div>
{:else}
<div class="compare-target-help">
The two branch tips are compared across the entire repository. Uncommitted working-tree changes are not included.
{/if}
</div>
<footer class="compare-select-footer">
<span>{isGerman ? `${targetCount} Ziele verfügbar` : `${targetCount} targets available`}</span>
<div>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit" disabled={!canCompare || targetCount < 2}>
{#if operation === "Comparing revisions"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitCompare size={16} aria-hidden="true" />
{/if}
{isGerman ? "Vergleich öffnen" : "Open comparison"}
</button>
</div>
{/if}
{/if}
</footer>
</form>
</div>
</div>
+11 -9
View File
@@ -15,7 +15,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull" | "fetch" | "clone" | "rename";
action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
error: string;
isBusy: boolean;
initialUsername?: string;
@@ -47,19 +47,21 @@
password.trim().length > 0 &&
username.trim().length > 0,
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : "Pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull");
let actionTitle = $derived(
action === "push"
? "Authenticate push"
: action === "rename"
? "Authenticate remote rename"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
: action === "delete"
? "Authenticate remote deletion"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push" || action === "rename"
let actionHint = $derived(action === "push" || action === "rename" || action === "delete"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
@@ -80,7 +82,7 @@
<div class="cred-hero">
<div class="cred-hero-top">
<div class="cred-hero-icon">
{#if action === "push" || action === "rename"}
{#if action === "push" || action === "rename" || action === "delete"}
<Upload size={27} aria-hidden="true" />
{:else}
<Download size={27} aria-hidden="true" />
+2 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import {
CalendarDays,
FileCode,
@@ -193,12 +194,7 @@
<label class="search-limit">
<span>Results</span>
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
<SelectMenu value={String(limit)} options={[100, 250, 500, 1000].map((value) => ({ value: String(value), label: String(value) }))} disabled={!hasRepository || isBusy || isSearching} onChange={(value) => { limit = Number(value); }} />
</label>
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
+22
View File
@@ -1399,6 +1399,17 @@
label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [
{
id: "changelog-2026-8-4",
title: "Version 2026.8.4",
summary: "Dieses Release vereinfacht die Verwaltung zusammengehöriger Branches und sorgt für eine einheitlichere, klarere Oberfläche.",
steps: [
"Lokale und verschachtelte Remote-Branch-Ordner lassen sich über ihr Kontextmenü gesammelt löschen. Der oberste Remote-Ordner wie origin ist geschützt. Der aktuell ausgecheckte Branch bleibt erhalten und einzelne Fehler werden nach Abschluss verständlich aufgeführt.",
"Der Compare-Auswahldialog ist vollständig auf Deutsch verfügbar und orientiert sich bei Feldern, Gruppen, Typografie und Dialogflächen am Styling der externen Tools.",
"Die Schließen-Schaltflächen der Repository-Tabs sind quadratisch und haben ausgewogenere Abstände sowie deutlichere Hover- und Tastaturfokus-Zustände.",
],
note: "Der oberste Remote-Ordner wie origin kann nicht gesammelt gelöscht werden. Seine Unterordner können weiterhin gezielt verwaltet werden.",
},
{
id: "changelog-2026-8-3",
title: "Version 2026.8.3",
@@ -1488,6 +1499,17 @@
label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [
{
id: "changelog-2026-8-4",
title: "Version 2026.8.4",
summary: "This release simplifies managing related branches and makes the interface more consistent and easier to read.",
steps: [
"Local and nested remote branch folders can be deleted in one action from their context menu. The top-level remote folder such as origin is protected. The currently checked-out branch is kept, and individual failures are summarized after processing.",
"The Compare selector is fully localized in German and now follows the external-tool selectors for fields, groups, typography, and dialog surfaces.",
"Repository-tab close buttons are square and have more balanced spacing and clearer hover and keyboard-focus states.",
],
note: "The top-level remote folder such as origin cannot be deleted in bulk. Its nested folders can still be managed selectively.",
},
{
id: "changelog-2026-8-3",
title: "Version 2026.8.3",
@@ -1,6 +1,7 @@
<script lang="ts">
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface PlanRow extends RebaseCommit {
action: RebaseAction;
@@ -41,6 +42,7 @@
));
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
const rebaseActions: RebaseAction[] = ["pick", "reword", "squash", "fixup", "drop"];
function updateAction(index: number, action: RebaseAction) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
@@ -82,12 +84,7 @@
<section class="rebase-base-bar">
<label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}>
<option value="" disabled>Select a base branch</option>
{#each availableBases as branch (branch.name)}
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
{/each}
</select>
<SelectMenu value={base} options={availableBases.map((branch) => ({ value: branch.name, label: `${branch.remote ? "Remote - " : "Local - "}${branch.name}` }))} placeholder="Select a base branch" disabled={isBusy || isLoading} onChange={onBaseChange} />
</label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
</section>
@@ -110,9 +107,7 @@
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
</div>
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}>
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
</select>
<SelectMenu class={`rebase-action ${row.action}`} value={row.action} options={rebaseActions.map((action) => ({ value: action, label: action }))} disabled={isBusy} ariaLabel={`Action for ${row.short_hash}`} onChange={(value) => updateAction(index, value as RebaseAction)} />
<code>{row.short_hash}</code>
<div class="rebase-commit-copy">
{#if row.action === "reword"}
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts">
import { tick } from "svelte";
import { Check, ChevronDown } from "@lucide/svelte";
export interface SelectMenuOption {
value: string;
label: string;
group?: string;
disabled?: boolean;
}
interface Props {
value: string;
options: SelectMenuOption[];
placeholder?: string;
disabled?: boolean;
ariaLabel?: string;
class?: string;
showSelectedGroup?: boolean;
onChange: (value: string) => void;
}
let {
value,
options = [],
placeholder = "Select an option",
disabled = false,
ariaLabel = "",
class: className = "",
showSelectedGroup = false,
onChange,
}: Props = $props();
let root = $state<HTMLDivElement>();
let trigger = $state<HTMLButtonElement>();
let open = $state(false);
let activeIndex = $state(-1);
let menuStyle = $state("");
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
let selectedOption = $derived(options.find((option) => option.value === value));
let enabledIndices = $derived(options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
function groupCount(group: string): number {
return options.filter((option) => option.group === group).length;
}
function positionMenu() {
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportGap = 8;
const menuGap = 5;
const desiredHeight = Math.min(300, options.length * 34 + 24);
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
const spaceAbove = rect.top - viewportGap;
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
const maxHeight = Math.max(96, Math.min(desiredHeight, openAbove ? spaceAbove - menuGap : spaceBelow - menuGap));
const width = Math.max(rect.width, 180);
const left = Math.min(rect.left, window.innerWidth - width - viewportGap);
const top = openAbove ? rect.top - menuGap : rect.bottom + menuGap;
menuStyle = `left:${Math.max(viewportGap, left)}px;${openAbove ? `bottom:${window.innerHeight - top}px;` : `top:${top}px;`}min-width:${width}px;max-width:${Math.max(180, window.innerWidth - viewportGap * 2)}px;max-height:${maxHeight}px;`;
}
async function show() {
if (disabled || enabledIndices.length === 0) return;
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
open = true;
await tick();
positionMenu();
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}
function close() {
open = false;
}
function choose(index: number) {
const option = options[index];
if (!option || option.disabled) return;
onChange(option.value);
close();
trigger?.focus();
}
function moveActive(direction: -1 | 1) {
if (enabledIndices.length === 0) return;
const current = enabledIndices.indexOf(activeIndex);
const next = current < 0
? (direction > 0 ? 0 : enabledIndices.length - 1)
: (current + direction + enabledIndices.length) % enabledIndices.length;
activeIndex = enabledIndices[next];
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}
function handleKeydown(event: KeyboardEvent) {
if (disabled) return;
if (!open && ["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
event.preventDefault();
void show();
return;
}
if (!open) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
activeIndex = event.key === "Home" ? enabledIndices[0] : enabledIndices[enabledIndices.length - 1];
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
choose(activeIndex);
} else if (event.key === "Escape" || event.key === "Tab") {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
}
close();
}
}
function handleWindowPointerDown(event: PointerEvent) {
if (open && root && !root.contains(event.target as Node)) close();
}
</script>
<svelte:window onpointerdown={handleWindowPointerDown} onresize={positionMenu} onscroll={positionMenu} />
<div class={`select-menu ${className}`} class:open bind:this={root}>
<button
bind:this={trigger}
class="select-menu-trigger"
type="button"
{disabled}
aria-label={ariaLabel || undefined}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onclick={() => open ? close() : void show()}
onkeydown={handleKeydown}
>
<span class="select-menu-value" class:placeholder={!selectedOption} title={selectedOption?.label}>
{#if showSelectedGroup && selectedOption?.group}<small>{selectedOption.group}</small>{/if}
<span>{selectedOption?.label ?? placeholder}</span>
</span>
<ChevronDown size={14} aria-hidden="true" />
</button>
{#if open}
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
{#each options as option, index (`${option.value}:${index}`)}
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
<div class="select-menu-group" role="presentation">
<span>{option.group}</span>
<small>{groupCount(option.group)}</small>
</div>
{/if}
<button
id={`${menuId}-option-${index}`}
class="select-menu-option"
class:active={index === activeIndex}
class:selected={option.value === value}
type="button"
role="option"
aria-selected={option.value === value}
disabled={option.disabled}
onmouseenter={() => { if (!option.disabled) activeIndex = index; }}
onclick={() => choose(index)}
>
<span>{option.label}</span>
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
</button>
{/each}
</div>
{/if}
</div>
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
import type { GitRemote, PullStrategy } from "../types";
import SelectMenu from "./SelectMenu.svelte";
export let remotes: GitRemote[] = [];
export let remoteBranches: string[] = [];
@@ -76,8 +77,8 @@
</div>
<div class="sync-fields">
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><select bind:value={draftRemote}><option value="">{de ? "Automatisch wählen" : "Choose automatically"}</option>{#each remotes as remote}<option value={remote.name}>{remote.name}</option>{/each}</select><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><select bind:value={draftUpstream}><option value="">{de ? "Kein Upstream" : "No upstream"}</option>{#each remoteBranches as branch}<option value={branch}>{branch}</option>{/each}</select><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><SelectMenu value={draftRemote} options={[{ value: "", label: de ? "Automatisch wählen" : "Choose automatically" }, ...remotes.map((remote) => ({ value: remote.name, label: remote.name }))]} onChange={(value) => { draftRemote = value; }} /><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><SelectMenu value={draftUpstream} options={[{ value: "", label: de ? "Kein Upstream" : "No upstream" }, ...remoteBranches.map((branch) => ({ value: branch, label: branch }))]} onChange={(value) => { draftUpstream = value; }} /><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
</div>
</section>
+12 -6
View File
@@ -21,6 +21,7 @@
X,
} from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
import SelectMenu from "./SelectMenu.svelte";
type CreateMode = "existing" | "new" | "detached";
@@ -260,12 +261,17 @@
{#if createMode === "existing"}
<label>
<span>Branch</span>
<select bind:value={selectedBranch} disabled={isBusy}>
<option value="" disabled>Select a local branch</option>
{#each localBranches as branch (branch.name)}
<option value={branch.name} disabled={!branchAvailable(branch.name)}>{branch.name}{!branchAvailable(branch.name) ? " (already checked out)" : ""}</option>
{/each}
</select>
<SelectMenu
value={selectedBranch}
placeholder="Select a local branch"
options={localBranches.map((branch) => ({
value: branch.name,
label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
disabled: !branchAvailable(branch.name),
}))}
disabled={isBusy}
onChange={(value) => { selectedBranch = value; }}
/>
</label>
{:else if createMode === "new"}
<label>
+6 -1
View File
@@ -107,7 +107,12 @@ export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
console.log("remove_remote")
return invoke("remove_remote", { path, name }); }
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise<GitStatus> { return invoke("delete_remote_branch", { path, remote, branch }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string, username?: string, password?: string): Promise<GitStatus> {
return invoke("delete_remote_branch", { path, remote, branch, username: username ?? null, password: password ?? null });
}
export function deleteRemoteBranches(path: string, remote: string, branches: string[], username?: string, password?: string): Promise<GitStatus> {
return invoke("delete_remote_branches", { path, remote, branches, username: username ?? null, password: password ?? null });
}
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });