Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25d7f27d7a | ||
|
|
c5f058beb1 | ||
|
|
4181925047 | ||
|
|
927e0c3e92 | ||
|
|
0dac3e9f09 | ||
|
|
4f1131e855 | ||
|
|
f24c39c573 | ||
|
|
f487090b4b | ||
|
|
c8247f2b2a |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.3",
|
||||
"version": "2026.8.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+119
-13
@@ -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]
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
+111
-17
@@ -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}
|
||||
@@ -5564,7 +5657,7 @@
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Credential dialog for push/pull -->
|
||||
<!-- Credential dialog for authenticated remote actions -->
|
||||
{#if credDialogOpen && credDialogAction}
|
||||
<CredentialDialog
|
||||
action={credDialogAction}
|
||||
@@ -5576,6 +5669,7 @@
|
||||
onCancel={() => {
|
||||
credDialogOpen = false;
|
||||
credDialogAction = null;
|
||||
pendingRemoteDelete = null;
|
||||
credDialogError = "";
|
||||
credDialogKey = null;
|
||||
credDialogUsername = "";
|
||||
|
||||
+27
-8
@@ -1112,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);
|
||||
@@ -3334,8 +3353,8 @@
|
||||
align-items: end;
|
||||
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-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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -3509,8 +3528,8 @@
|
||||
.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-accent); font-size: 18px; font-weight: 700; line-height: 1.2; }
|
||||
.compare-dialog-title p { margin: 3px 0 0; color: var(--color-ink); font-size: 11px; font-weight: 400; line-height: 1.35; }
|
||||
.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));
|
||||
@@ -3535,8 +3554,8 @@
|
||||
.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-accent); font-size: 18px; font-weight: 700; line-height: 1.2; }
|
||||
.compare-target-heading p { margin: 4px 0 0; color: var(--color-ink); font-size: 11px; font-weight: 400; line-height: 1.45; }
|
||||
.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; }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -47,9 +47,21 @@
|
||||
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 ? " (current)" : ""}`, group: "Branches - Local" })),
|
||||
...remoteBranches.map((branch) => ({ value: branchValue(branch), label: branch.name, group: "Branches - Remote" })),
|
||||
...commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })),
|
||||
...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) {
|
||||
|
||||
@@ -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 === "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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
+6
-1
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user