Compare commits

...
20 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
Christoph 32832f5db7 feat(git): support credentials for remote branch rename
Allow remote branch renames to be performed with optional credentials so
operations against protected remotes succeed when authentication is needed.
The backend accepts username/password and uses an authenticated push path
when provided, while the frontend prompts for and reuses stored credentials.

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

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

- Added German and English documentation for Git Notes
- Included commands for viewing and managing commit notes
- Explained how to fetch and push Git Notes to remote repositories
2026-08-15 17:08:53 +02:00
Christoph 4db488415f Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-08-15 17:00:44 +02:00
Christoph be695d78a9 feat(publishing): add support for prebuilt AppImage package
This update enhances the publishing workflow by introducing a new
prebuilt AppImage package for Gitty, allowing users to install
the application without needing to compile from source. The
workflow now includes steps to generate and publish the binary
AUR package alongside the standard source package.

- Introduced PKGBUILD-bin for the prebuilt AppImage
- Updated workflow to handle both source and binary package publishing
- Enhanced README to guide users on installing the new package
2026-08-15 17:00:42 +02:00
Christoph Brandau 40311275b0 style(app): remove border radius on commit-body
Removes the rounded corners on the commit-body by setting border-radius to 0.
This makes the component use a flat edge and aligns with the design system.

- Remove dependency on ui radius variable for commit-body
2026-08-13 23:07:19 +02:00
Christoph Brandau fe577d78a8 feat(app): add background commit notes fetch and refresh logic
This change adds a background path for fetching commit notes per repo
and a shared cache to avoid duplicate work. When notes are fetched
and the active repo is visible, history is refreshed to reflect
notes without blocking user actions.

- Adds per-path backgroundCommitNotesFetches cache to debounce fetches
- Integrates note fetch into the background tick and refresh flow
- Handles credential errors and shutdown gracefully during fetches
2026-08-13 22:56:53 +02:00
Christoph Brandau a27a8666ee feat(git): add commit-note support and previews in history
This change adds Git notes support to the history UI.
Commits now carry a has_note flag which triggers a note indicator.
Notes can be previewed on hover and loaded on demand,
then the history can be refreshed after edits.

- Adds has_note support on commits and parses from logs.
- Renders a note indicator in history rows with a hover preview.
- Triggers history refresh after note-related actions.
2026-08-13 22:51:24 +02:00
Christoph Brandau c442b3735f feat(auth): add credential mode (credentials/token) support
Introduces a credential mode for stored credentials and wire it to
per-remote URL resolution and operation flows. The UI, storage, and
remote interactions now track and persist the mode, enabling token
based auth alongside username/password credentials.

- Remote URL resolution now considers direction (pull/push) and mode
- Credential dialog, saving, and keychain handling updated to pass and
  respect the mode
- Unique askpass scripts generated per invocation to avoid clashes
2026-08-13 22:27:00 +02:00
30 changed files with 1938 additions and 428 deletions
+156 -27
View File
@@ -121,11 +121,19 @@ jobs:
uv run main.py uv run main.py
publish-arch: publish-arch:
name: Build and publish gitty-desktop to AUR name: Build and publish AUR packages
needs: publish-ubuntu
permissions:
contents: write
runs-on: archlinux runs-on: archlinux
environment: production environment: production
env: env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
GITEA_API: https://git.cbsk-tech.de/api/v1
OWNER: Christoph
REPO: GitLite
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
GITEA_FALLBACK_TOKEN: ${{ github.token }}
defaults: defaults:
run: run:
@@ -234,7 +242,121 @@ jobs:
runuser -u builder -- \ runuser -u builder -- \
bash -lc "cd '$AUR_SOURCE_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO" 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 "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
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"
PACKAGE_PATH="$AUR_BIN_DIR/$PACKAGE_NAME"
curl --fail --location --silent --show-error \
--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|^_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 - name: Publish PKGBUILD to AUR
env: env:
@@ -273,14 +395,19 @@ jobs:
install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts" install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2" export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"
AUR_CHECKOUT_ROOT="$(mktemp -d)" publish_aur_package() {
AUR_CHECKOUT="" local package_name="$1"
local package_source_dir="$2"
local checkout_root checkout clone_candidate pushed
checkout_root="$(mktemp -d)"
checkout=""
for AUR_ATTEMPT in 1 2 3 4 5; do for AUR_ATTEMPT in 1 2 3 4 5; do
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT" clone_candidate="$checkout_root/attempt-$AUR_ATTEMPT"
echo "AUR clone attempt $AUR_ATTEMPT of 5" echo "$package_name clone attempt $AUR_ATTEMPT of 5"
if timeout 3m git -c init.defaultBranch=master clone \ if timeout 3m git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then "ssh://aur@aur.archlinux.org/$package_name.git" "$clone_candidate"; then
AUR_CHECKOUT="$AUR_CLONE_CANDIDATE" checkout="$clone_candidate"
break break
fi fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then if [ "$AUR_ATTEMPT" -lt 5 ]; then
@@ -288,29 +415,27 @@ jobs:
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}" sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi fi
done done
if [ -z "$AUR_CHECKOUT" ]; then if [ -z "$checkout" ]; then
echo "Could not clone the AUR repository after 5 attempts" >&2 echo "Could not clone $package_name after 5 attempts" >&2
exit 1 return 1
fi fi
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/" cp "$package_source_dir/PKGBUILD" "$package_source_dir/.SRCINFO" "$checkout/"
git -C "$checkout" config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
git -C "$checkout" config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
git -C "$checkout" add PKGBUILD .SRCINFO
cd "$AUR_CHECKOUT" if git -C "$checkout" diff --cached --quiet; then
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}" echo "$package_name already matches release $PACKAGE_VERSION"
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}" return 0
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "AUR metadata already matches release $PACKAGE_VERSION"
exit 0
fi fi
git commit -m "Update to $PACKAGE_VERSION" git -C "$checkout" commit -m "Update to $PACKAGE_VERSION"
AUR_PUSHED=0 pushed=0
for AUR_ATTEMPT in 1 2 3 4 5; do for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR push attempt $AUR_ATTEMPT of 5" echo "$package_name push attempt $AUR_ATTEMPT of 5"
if timeout 3m git push origin HEAD:master; then if timeout 3m git -C "$checkout" push origin HEAD:master; then
AUR_PUSHED=1 pushed=1
break break
fi fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then if [ "$AUR_ATTEMPT" -lt 5 ]; then
@@ -318,10 +443,14 @@ jobs:
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}" sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi fi
done done
if [ "$AUR_PUSHED" -ne 1 ]; then if [ "$pushed" -ne 1 ]; then
echo "Could not publish to AUR after 5 attempts" >&2 echo "Could not publish $package_name after 5 attempts" >&2
exit 1 return 1
fi fi
}
publish_aur_package gitty-desktop "$AUR_SOURCE_DIR"
publish_aur_package gitty-desktop-bin "$AUR_BIN_DIR"
publish-ubuntu: publish-ubuntu:
name: Build and publish Ubuntu AppImage name: Build and publish Ubuntu AppImage
+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`. 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 ## [2026.8.3] - 2026-08-13
### Added ### 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.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.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.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.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.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 [2026.8.1]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.1
+25
View File
@@ -0,0 +1,25 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
pkgname=gitty-desktop-bin
pkgver=2026.8.4
pkgrel=1
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=('git' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
provides=('gitty-desktop')
conflicts=('gitty-desktop')
options=('!strip')
_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() {
# Extract only the native package payload, without carrying its package
# metadata (.PKGINFO, .BUILDINFO and .MTREE) across.
bsdtar -xf "$srcdir/$_package" -C "$pkgdir" usr
}
+10 -2
View File
@@ -60,6 +60,12 @@ Install Gitty from the AUR with an AUR helper:
yay -S gitty-desktop yay -S gitty-desktop
``` ```
To install the prebuilt native Arch package instead of compiling from source:
```bash
yay -S gitty-desktop-bin
```
Or build the AUR package manually: Or build the AUR package manually:
```bash ```bash
@@ -68,8 +74,10 @@ cd gitty-desktop
makepkg -si makepkg -si
``` ```
The AUR recipe downloads the public Gitea release archive and builds Gitty from The source recipe downloads the public Gitea release archive and builds Gitty.
source. The release pipeline updates its version, checksum, and `.SRCINFO`. The `-bin` recipe 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", "name": "gitty",
"version": "2026.8.3", "version": "2026.8.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.8.3", "version": "2026.8.4",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.8.3", "version": "2026.8.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+329 -31
View File
@@ -119,6 +119,7 @@ pub struct GitCommit {
pub refs: Vec<String>, pub refs: Vec<String>,
pub parents: Vec<String>, pub parents: Vec<String>,
pub files: Vec<GitCommitFile>, pub files: Vec<GitCommitFile>,
pub has_note: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -650,30 +651,136 @@ pub fn set_branch_upstream(
status_for_repo(&repo) status_for_repo(&repo)
} }
#[tauri::command(async)] #[tauri::command]
pub fn delete_remote_branch( pub async fn delete_remote_branch(
path: String, path: String,
remote: String, remote: String,
branch: String, branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}"); run_git_task("Could not delete remote branch", move || {
let result = (|| {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?; let remote = validate_remote_name(&repo, &remote, true)?;
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(); let branch = branch.trim();
if branch.is_empty() || branch.starts_with('-') { if branch.is_empty() || branch.starts_with('-') {
return Err("Invalid remote branch name.".to_string()); return Err("Invalid remote branch name.".to_string());
} }
run_git(&repo, ["check-ref-format", "--branch", branch])?; run_git(repo, ["check-ref-format", "--branch", branch])?;
log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}"); if !branch_names.iter().any(|existing| existing == branch) {
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?; branch_names.push(branch.to_string());
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}");
} }
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] #[tauri::command]
@@ -682,9 +789,17 @@ pub async fn rename_remote_branch(
remote: String, remote: String,
old_branch: String, old_branch: String,
new_branch: String, new_branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || { run_git_task("Could not rename remote branch", move || {
rename_remote_branch_core(path, remote, old_branch, new_branch) rename_remote_branch_core(
path,
remote,
old_branch,
new_branch,
username.as_deref().zip(password.as_deref()),
)
}) })
.await .await
} }
@@ -694,6 +809,7 @@ fn rename_remote_branch_core(
remote: String, remote: String,
old_branch: String, old_branch: String,
new_branch: String, new_branch: String,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?; let remote = validate_remote_name(&repo, &remote, true)?;
@@ -729,9 +845,7 @@ fn rename_remote_branch_core(
// Git has no standalone remote-rename command. Create the new ref and delete // Git has no standalone remote-rename command. Create the new ref and delete
// the old one in a single atomic push so a rejected update leaves both untouched. // the old one in a single atomic push so a rejected update leaves both untouched.
run_git( let push_args = [
&repo,
[
"push", "push",
"--atomic", "--atomic",
source_lease.as_str(), source_lease.as_str(),
@@ -739,8 +853,15 @@ fn rename_remote_branch_core(
remote.as_str(), remote.as_str(),
create_refspec.as_str(), create_refspec.as_str(),
delete_refspec.as_str(), delete_refspec.as_str(),
], ];
)?; match credentials {
Some((username, password)) if !username.is_empty() || !password.is_empty() => {
run_git_authenticated(&repo, push_args, username, password)?;
}
_ => {
run_git(&repo, push_args)?;
}
}
// Git normally updates remote-tracking refs after a successful push. Keep the // Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups. // local view consistent as a fallback for unusual remote/refspec setups.
@@ -2432,6 +2553,8 @@ const CRED_SERVICE: &str = "tauri_git_lite";
pub struct StoredCredential { pub struct StoredCredential {
pub username: String, pub username: String,
pub password: String, pub password: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
} }
fn cred_entry(key: &str) -> Result<keyring::Entry, String> { fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
@@ -2445,17 +2568,27 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
/// Returns the remote URL used for auth key derivation (upstream remote of the /// Returns the remote URL used for auth key derivation (upstream remote of the
/// current branch, falling back to `origin`, then the first configured remote). /// current branch, falling back to `origin`, then the first configured remote).
#[tauri::command(async)] #[tauri::command(async)]
pub fn get_remote_url(path: String) -> Result<Option<String>, String> { pub fn get_remote_url(
path: String,
remote: Option<String>,
push: Option<bool>,
) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()); let remote = match remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
Some(remote) => validate_remote_name(&repo, &remote, true)?,
None => upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()),
};
if let Some(url) = remote_url_for(&repo, &remote) { if let Some(url) = remote_url_for_auth(&repo, &remote, push.unwrap_or(false)) {
return Ok(Some(url)); return Ok(Some(url));
} }
// origin missing → try the first configured remote // origin missing → try the first configured remote
if let Some(first) = first_remote_name(&repo) { if let Some(first) = first_remote_name(&repo) {
if first != remote { if first != remote {
if let Some(url) = remote_url_for(&repo, &first) { if let Some(url) = remote_url_for_auth(&repo, &first, push.unwrap_or(false)) {
return Ok(Some(url)); return Ok(Some(url));
} }
} }
@@ -2463,6 +2596,20 @@ pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
Ok(None) Ok(None)
} }
fn remote_url_for_auth(repo: &Path, remote: &str, push: bool) -> Option<String> {
let mut command = git_command();
command.arg("-C").arg(repo).args(["remote", "get-url"]);
if push {
command.arg("--push");
}
let out = command.arg(remote).output().ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> { fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
let out = git_command() let out = git_command()
.arg("-C") .arg("-C")
@@ -2614,9 +2761,22 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
} }
#[tauri::command(async)] #[tauri::command(async)]
pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> { pub fn cred_save(
key: String,
username: String,
password: String,
mode: Option<String>,
) -> Result<(), String> {
let entry = cred_entry(&key)?; let entry = cred_entry(&key)?;
let cred = StoredCredential { username, password }; let mode = match mode.as_deref() {
Some("token") => Some("token".to_string()),
_ => Some("credentials".to_string()),
};
let cred = StoredCredential {
username,
password,
mode,
};
let json = serde_json::to_string(&cred) let json = serde_json::to_string(&cred)
.map_err(|err| format!("Could not serialize credentials: {err}"))?; .map_err(|err| format!("Could not serialize credentials: {err}"))?;
entry entry
@@ -3464,7 +3624,10 @@ fn commit_page_for_repo(
repo, repo,
[ [
"log", "log",
"--all", "--branches",
"--remotes",
"--tags",
"HEAD",
"--topo-order", "--topo-order",
"--decorate=short", "--decorate=short",
"--name-status", "--name-status",
@@ -3479,7 +3642,28 @@ fn commit_page_for_repo(
], ],
)?; )?;
parse_commit_log_inline(&output) let mut commits = parse_commit_log_inline(&output)?;
mark_commits_with_notes(repo, &mut commits)?;
Ok(commits)
}
fn mark_commits_with_notes(repo: &Path, commits: &mut [GitCommit]) -> Result<(), String> {
if commits.is_empty() || !ref_exists(repo, COMMIT_NOTES_REF)? {
return Ok(());
}
let output = run_git(repo, ["notes", "--ref", COMMIT_NOTES_REF, "list"])?;
let noted_commits: BTreeSet<String> = String::from_utf8_lossy(&output)
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.map(ToString::to_string)
.collect();
for commit in commits {
commit.has_note = noted_commits.contains(&commit.hash);
}
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -3665,7 +3849,9 @@ fn list_file_history_core(
let output = run_git_cancellable(repo, args, cancellation, "Git file history failed")?; let output = run_git_cancellable(repo, args, cancellation, "Git file history failed")?;
check_search_cancelled(cancellation)?; check_search_cancelled(cancellation)?;
parse_commit_log(repo, &output) let mut commits = parse_commit_log(repo, &output)?;
mark_commits_with_notes(repo, &mut commits)?;
Ok(commits)
} }
#[tauri::command] #[tauri::command]
@@ -4688,6 +4874,13 @@ fn run_git_clone(
password: Option<&str>, password: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut command = git_command(); let mut command = git_command();
let has_explicit_credentials = matches!(
(username, password),
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty()
);
if has_explicit_credentials {
command.arg("-c").arg("credential.helper=");
}
command command
.arg("clone") .arg("clone")
.arg("--") .arg("--")
@@ -5093,6 +5286,7 @@ fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
parents, parents,
summary: String::from_utf8_lossy(parts[7]).to_string(), summary: String::from_utf8_lossy(parts[7]).to_string(),
files: parse_commit_files(files_bytes)?, files: parse_commit_files(files_bytes)?,
has_note: false,
}); });
} }
@@ -5141,6 +5335,7 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String
parents, parents,
summary: fields[7].to_string(), summary: fields[7].to_string(),
files, files,
has_note: false,
}); });
} }
@@ -5187,6 +5382,7 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
parents, parents,
summary: fields[7].to_string(), summary: fields[7].to_string(),
files: Vec::new(), files: Vec::new(),
has_note: false,
}); });
} }
@@ -5649,10 +5845,20 @@ fn run_apply_patch_command(
run_git(repo, args).map(|_| ()) run_git(repo, args).map(|_| ())
} }
static ASKPASS_COUNTER: AtomicU64 = AtomicU64::new(1);
fn next_askpass_path(extension: &str) -> std::path::PathBuf {
let sequence = ASKPASS_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"gitty-askpass-{}-{sequence}.{extension}",
std::process::id()
))
}
#[cfg(unix)] #[cfg(unix)]
fn write_askpass_script() -> Result<std::path::PathBuf, String> { fn write_askpass_script() -> Result<std::path::PathBuf, String> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join("gitlite_askpass.sh"); let path = next_askpass_path("sh");
let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n"; let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n";
std::fs::write(&path, script) std::fs::write(&path, script)
.map_err(|e| format!("Could not write authentication script: {e}"))?; .map_err(|e| format!("Could not write authentication script: {e}"))?;
@@ -5663,8 +5869,17 @@ fn write_askpass_script() -> Result<std::path::PathBuf, String> {
#[cfg(not(unix))] #[cfg(not(unix))]
fn write_askpass_script() -> Result<std::path::PathBuf, String> { fn write_askpass_script() -> Result<std::path::PathBuf, String> {
let path = std::env::temp_dir().join("gitlite_askpass.bat"); let path = next_askpass_path("bat");
let script = "@echo off\necho %1 | findstr /I \"sername\" >nul 2>&1\nif %errorlevel% == 0 (echo %GIT_CRED_USER%) else (echo %GIT_CRED_PASS%)\n"; // Reading the value from PowerShell avoids cmd.exe interpreting special
// characters such as &, |, ^ or % from passwords and access tokens.
let script = r#"@echo off
echo %1 | findstr /I "sername" >nul 2>&1
if %errorlevel% == 0 (
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_USER)"
) else (
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_PASS)"
)
"#;
std::fs::write(&path, script) std::fs::write(&path, script)
.map_err(|e| format!("Could not write authentication script: {e}"))?; .map_err(|e| format!("Could not write authentication script: {e}"))?;
Ok(path) Ok(path)
@@ -5706,6 +5921,8 @@ where
let askpass = write_askpass_script()?; let askpass = write_askpass_script()?;
let result = git_command() let result = git_command()
.arg("-c")
.arg("credential.helper=")
.arg("-C") .arg("-C")
.arg(repo) .arg(repo)
.args(args) .args(args)
@@ -6302,6 +6519,19 @@ mod tests {
"Review: sieht gut aus\nBuild: 42", "Review: sieht gut aus\nBuild: 42",
) )
.expect("note should be created"); .expect("note should be created");
let commits = commits_for_repo(&repo.path, Some(20)).expect("history should load");
assert!(
commits
.iter()
.find(|commit| commit.hash == commit_before)
.expect("annotated commit should be in history")
.has_note
);
assert!(
commits
.iter()
.all(|commit| commit.summary != "Notes added by 'git notes add'")
);
assert_eq!( assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("note should load"), commit_note_for_repo(&repo.path, &commit_before).expect("note should load"),
Some("Review: sieht gut aus\nBuild: 42".to_string()) Some("Review: sieht gut aus\nBuild: 42".to_string())
@@ -6315,6 +6545,14 @@ mod tests {
); );
delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted"); delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted");
let commits = commits_for_repo(&repo.path, Some(20)).expect("history should reload");
assert!(
!commits
.iter()
.find(|commit| commit.hash == commit_before)
.expect("commit should remain in history")
.has_note
);
assert_eq!( assert_eq!(
commit_note_for_repo(&repo.path, &commit_before) commit_note_for_repo(&repo.path, &commit_before)
.expect("deleted note lookup should work"), .expect("deleted note lookup should work"),
@@ -6715,6 +6953,7 @@ mod tests {
], ],
summary: "Add history panel".to_string(), summary: "Add history panel".to_string(),
files: Vec::new(), files: Vec::new(),
has_note: false,
}] }]
); );
} }
@@ -7146,6 +7385,64 @@ mod tests {
); );
} }
#[test]
fn credential_payload_remains_backward_compatible() {
let legacy: StoredCredential =
serde_json::from_str(r#"{"username":"alice","password":"secret"}"#)
.expect("legacy credential should deserialize");
assert_eq!(legacy.username, "alice");
assert_eq!(legacy.password, "secret");
assert_eq!(legacy.mode, None);
let token = StoredCredential {
username: "alice".to_string(),
password: "token".to_string(),
mode: Some("token".to_string()),
};
let encoded = serde_json::to_string(&token).expect("credential should serialize");
assert!(encoded.contains(r#""mode":"token""#));
}
#[test]
fn auth_remote_url_uses_the_requested_direction() {
let repo = init_temp_repo("auth_remote_url_direction");
run_git_test(
&repo.path,
[
"remote",
"add",
"origin",
"https://gitea.example/fetch/repo.git",
],
);
run_git_test(
&repo.path,
[
"remote",
"set-url",
"--push",
"origin",
"https://gitea.example/push/repo.git",
],
);
assert_eq!(
remote_url_for_auth(&repo.path, "origin", false).as_deref(),
Some("https://gitea.example/fetch/repo.git")
);
assert_eq!(
remote_url_for_auth(&repo.path, "origin", true).as_deref(),
Some("https://gitea.example/push/repo.git")
);
}
#[test]
fn askpass_scripts_use_unique_paths() {
let first = next_askpass_path("test");
let second = next_askpass_path("test");
assert_ne!(first, second);
}
#[tokio::test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
@@ -7485,6 +7782,7 @@ mod tests {
"origin".to_string(), "origin".to_string(),
"feature/old-name".to_string(), "feature/old-name".to_string(),
"feature/new-name".to_string(), "feature/new-name".to_string(),
None,
) )
.unwrap(); .unwrap();
+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, 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, 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, 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, delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
get_file_blame, get_file_patch, get_remote_url, get_status, init_repository, 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, last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files, list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
@@ -141,6 +141,7 @@ async fn main() {
remove_remote, remove_remote,
set_branch_upstream, set_branch_upstream,
delete_remote_branch, delete_remote_branch,
delete_remote_branches,
list_stashes, list_stashes,
checkout_branch, checkout_branch,
create_branch, create_branch,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "2026.8.3", "version": "2026.8.4",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+363 -65
View File
@@ -60,6 +60,7 @@
deleteCommitNote, deleteCommitNote,
deleteTag, deleteTag,
deleteRemoteBranch, deleteRemoteBranch,
deleteRemoteBranches,
initRepository, initRepository,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
@@ -111,7 +112,6 @@
launchExternalTool, launchExternalTool,
credLoad, credLoad,
credSave, credSave,
credDelete,
getFilePatch, getFilePatch,
readConflict, readConflict,
resolveConflict, resolveConflict,
@@ -189,7 +189,8 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error"; type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository"; type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone"; type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
type CredentialMode = "credentials" | "token";
type PendingDiscard = type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean } | { kind: "file"; files: GitFileStatus[]; staged: boolean }
| { kind: "all-changes"; files: GitFileStatus[] } | { kind: "all-changes"; files: GitFileStatus[] }
@@ -410,8 +411,13 @@
let autoRefreshInFlight = false; let autoRefreshInFlight = false;
let credDialogOpen = false; let credDialogOpen = false;
let credDialogAction: CredentialAction | null = null; let credDialogAction: CredentialAction | null = null;
let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null;
let pendingRemoteDelete: { remote: string; branches: string[]; label: string; folder: boolean } | null = null;
let credDialogError = ""; let credDialogError = "";
let credDialogKey: string | null = null; let credDialogKey: string | null = null;
let credDialogUsername = "";
let credDialogMode: CredentialMode = "credentials";
const rejectedCredentialKeys = new Set<string>();
let lastStatusFingerprint = ""; let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000; const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined; let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -424,6 +430,7 @@
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined; let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined; let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false; let backgroundFetchInFlight = false;
const backgroundCommitNotesFetches = new Map<string, Promise<boolean>>();
let backgroundRepoStatusInFlight = false; let backgroundRepoStatusInFlight = false;
let backgroundRepoStatusIndex = 0; let backgroundRepoStatusIndex = 0;
let appShuttingDown = false; let appShuttingDown = false;
@@ -669,6 +676,15 @@
// Keep the cached tab data if this repo is unavailable at startup. // Keep the cached tab data if this repo is unavailable at startup.
} }
} }
const notesFetched = await backgroundFetchCommitNotes(path);
if (notesFetched && sameRepoPath(path, activeRepoPath)) {
try {
await refreshCommitHistory(path);
} catch {
// The active repository may still be opening; its own background pass retries.
}
}
} }
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
@@ -714,18 +730,91 @@
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually. // ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/ // Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup. // Push buttons instead, not as a background popup.
function preferredNotesRemote(remotes: GitRemote[]): GitRemote | undefined {
return remotes.find((remote) => remote.name === selectedRemote)
?? remotes.find((remote) => remote.name === "origin")
?? remotes[0];
}
async function backgroundFetchCommitNotesCore(path: string): Promise<boolean> {
if (appShuttingDown || !autoRefreshEnabled || !path) return false;
if (commitNoteTarget && sameRepoPath(path, commitNoteRepoPath)) return false;
let credentialKey: string | null = null;
try {
const remote = preferredNotesRemote(await listRemotes(path));
if (!remote) return false;
credentialKey = orgKeyFromUrl(remote.fetch_url);
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) return false;
const credential = await loadStoredCredential(credentialKey);
if (/^https?:\/\//i.test(remote.fetch_url) && !credential) return false;
await fetchCommitNotes(path, remote.name, credential?.username, credential?.password);
if (credentialKey) rejectedCredentialKeys.delete(credentialKey);
trackEvent("commit_notes_background_fetched");
return true;
} catch (error) {
if (credentialKey && isAuthError(errorToMessage(error))) {
rejectedCredentialKeys.add(credentialKey);
}
if (import.meta.env.DEV) console.info("[Gitty notes] Background fetch skipped", errorToMessage(error));
return false;
}
}
function backgroundFetchCommitNotes(path: string): Promise<boolean> {
const key = repoKey(path);
const current = backgroundCommitNotesFetches.get(key);
if (current) return current;
const request = backgroundFetchCommitNotesCore(path).finally(() => {
backgroundCommitNotesFetches.delete(key);
});
backgroundCommitNotesFetches.set(key, request);
return request;
}
async function waitForBackgroundCommitNotes(path: string) {
await backgroundCommitNotesFetches.get(repoKey(path));
}
async function backgroundFetchCommitNotesAndRefresh(path: string) {
if (!await backgroundFetchCommitNotes(path)) return;
if (!sameRepoPath(path, activeRepoPath)) return;
try {
await refreshCommitHistory(path);
} catch {
// Repository changes can invalidate this best-effort background refresh.
}
}
async function backgroundFetchTick() { async function backgroundFetchTick() {
if (appShuttingDown || !autoRefreshEnabled) return; if (appShuttingDown || !autoRefreshEnabled) return;
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) { && !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
const path = activeRepoPath;
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(activeRepoPath); let refsFetched = false;
applyStatus(await getStatus(activeRepoPath)); try {
await refreshRefsAndCommitGraph(activeRepoPath); await fetchRemote(path);
refsFetched = true;
} catch { } catch {
// ignore — see comment above // Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (sameRepoPath(path, activeRepoPath)) {
if (refsFetched) {
applyStatus(await getStatus(path));
await refreshRefsAndCommitGraph(path);
} else if (notesFetched) {
await refreshCommitHistory(path);
}
}
} catch {
// ignore transient refresh failures
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -738,15 +827,27 @@
if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return; if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return;
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try {
let refsFetched = false;
try { try {
await fetchRemote(path); await fetchRemote(path);
refsFetched = true;
} catch {
// Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (refsFetched) {
const nextStatus = await getStatus(path); const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) { if (sameRepoPath(path, activeRepoPath)) {
applyStatus(nextStatus); applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path); await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus); } else updateRepoManagementStatus(path, nextStatus);
} else if (notesFetched && sameRepoPath(path, activeRepoPath)) {
await refreshCommitHistory(path);
}
} catch { } catch {
// ignore; manual Fetch/Pull surfaces auth or network problems // ignore transient refresh failures
} finally { } finally {
backgroundFetchInFlight = false; backgroundFetchInFlight = false;
} }
@@ -1875,6 +1976,7 @@
globalSearchResults = []; globalSearchResults = [];
deleteBranchTarget = null; deleteBranchTarget = null;
deleteBranchForce = false; deleteBranchForce = false;
pendingRemoteDelete = null;
worktreeDialogOpen = false; worktreeDialogOpen = false;
worktreeInitialBranch = ""; worktreeInitialBranch = "";
worktrees = []; worktrees = [];
@@ -2177,7 +2279,8 @@
changed_files: bundle.status.files.length, changed_files: bundle.status.files.length,
has_upstream: bundle.status.upstream ? 1 : 0, has_upstream: bundle.status.upstream ? 1 : 0,
}); });
void backgroundFetchRepo(activeRepoPath); if (backgroundFetchInFlight) void backgroundFetchCommitNotesAndRefresh(activeRepoPath);
else void backgroundFetchRepo(activeRepoPath);
}); });
} }
@@ -2207,6 +2310,7 @@
password?: string, password?: string,
key?: string | null, key?: string | null,
fromStore = false, fromStore = false,
credentialMode: CredentialMode = "credentials",
) { ) {
if (isBusy) return; if (isBusy) return;
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; } if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
@@ -2219,7 +2323,16 @@
if (!username && !password) { if (!username && !password) {
const stored = await loadStoredCredential(credentialKey); const stored = await loadStoredCredential(credentialKey);
if (stored) { if (stored) {
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true); const storedMode = credentialModeFor(stored);
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) {
credDialogUsername = stored.username === "oauth2" ? "" : stored.username;
credDialogMode = storedMode;
credDialogAction = "clone";
credDialogKey = credentialKey;
credDialogOpen = true;
return;
}
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode);
return; return;
} }
} }
@@ -2260,7 +2373,9 @@
errorMessage = ""; errorMessage = "";
setCloneDialogError(""); setCloneDialogError("");
if (fromStore) { if (fromStore) {
if (credentialKey) void credDelete(credentialKey).catch(() => {}); if (credentialKey) rejectedCredentialKeys.add(credentialKey);
credDialogUsername = username === "oauth2" ? "" : (username ?? "");
credDialogMode = credentialMode;
const detail = summarizeGitError(message); const detail = summarizeGitError(message);
credDialogError = detail credDialogError = detail
? `${detail} — please sign in again.` ? `${detail} — please sign in again.`
@@ -2511,12 +2626,14 @@
const oldRemoteBranch = branch.name.slice(slash + 1); const oldRemoteBranch = branch.name.slice(slash + 1);
if (name === oldRemoteBranch) return; if (name === oldRemoteBranch) return;
await runOperation(`Renaming ${branch.name} on remote`, async () => { pendingRemoteRename = { remote, oldBranch: oldRemoteBranch, newBranch: name };
applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name)); const key = await currentCredKey("rename");
renameBranchTarget = null; const stored = await loadStoredCredential(key);
await refreshRefsAndCommitGraph(activeRepoPath); if (stored && (!key || !rejectedCredentialKeys.has(key))) {
trackEvent("branch_renamed", { remote: 1 }); await doActualRemoteRename(stored.username, stored.password, key, true, credentialModeFor(stored));
}); } else {
await openCredentialDialog("rename", key, stored);
}
return; return;
} }
@@ -2542,6 +2659,72 @@
trackEvent("branch_delete_dialog_opened"); 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() { async function confirmDeleteBranch() {
const branch = deleteBranchTarget; const branch = deleteBranchTarget;
if (!activeRepoPath || !branch || branch.current || isBusy) return; if (!activeRepoPath || !branch || branch.current || isBusy) return;
@@ -2551,17 +2734,13 @@
if (slash < 1) { errorMessage = "Could not determine remote name."; return; } if (slash < 1) { errorMessage = "Could not determine remote name."; return; }
const remote = branch.name.slice(0, slash); const remote = branch.name.slice(0, slash);
const remoteBranch = branch.name.slice(slash + 1); const remoteBranch = branch.name.slice(slash + 1);
operation = `Deleting ${branch.name} from remote`; pendingRemoteDelete = { remote, branches: [remoteBranch], label: branch.name, folder: false };
errorMessage = ""; const key = await currentCredKey("delete");
try { const stored = await loadStoredCredential(key);
applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch)); if (stored && (!key || !rejectedCredentialKeys.has(key))) {
deleteBranchTarget = null; await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored));
await refreshRefsAndCommitGraph(activeRepoPath); } else {
trackEvent("remote_branch_deleted"); await openCredentialDialog("delete", key, stored);
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
operation = "";
} }
return; return;
} }
@@ -2917,7 +3096,7 @@
async function pushLocalTag(tag: GitTag) { async function pushLocalTag(tag: GitTag) {
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy) return;
const key = await currentCredKey(); const key = await currentCredKey("push");
const stored = await loadStoredCredential(key); const stored = await loadStoredCredential(key);
const credential = stored ?? null; const credential = stored ?? null;
@@ -2983,6 +3162,11 @@
commitNoteLoading = false; commitNoteLoading = false;
} }
async function loadCommitNotePreview(commit: GitCommit): Promise<string | null> {
if (!activeRepoPath) return null;
return getCommitNote(activeRepoPath, commit.hash);
}
async function saveActiveCommitNote(note: string) { async function saveActiveCommitNote(note: string) {
const commit = commitNoteTarget; const commit = commitNoteTarget;
const repo = commitNoteRepoPath; const repo = commitNoteRepoPath;
@@ -2991,8 +3175,10 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
await setCommitNote(repo, commit.hash, note); await setCommitNote(repo, commit.hash, note);
commitNoteText = note; commitNoteText = note;
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: true } : item);
commitNoteStatus = appLanguage === "de" commitNoteStatus = appLanguage === "de"
? "Notiz gespeichert. Der Commit-Hash ist unverändert." ? "Notiz gespeichert. Der Commit-Hash ist unverändert."
: "Note saved. The commit hash is unchanged."; : "Note saved. The commit hash is unchanged.";
@@ -3012,8 +3198,10 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
await deleteCommitNote(repo, commit.hash); await deleteCommitNote(repo, commit.hash);
commitNoteText = ""; commitNoteText = "";
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: false } : item);
commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted."; commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted.";
trackEvent("commit_note_deleted"); trackEvent("commit_note_deleted");
} catch (error) { } catch (error) {
@@ -3046,9 +3234,11 @@
commitNoteError = ""; commitNoteError = "";
commitNoteStatus = ""; commitNoteStatus = "";
try { try {
await waitForBackgroundCommitNotes(repo);
const credential = await storedCredentialForNoteRemote(remote, direction); const credential = await storedCredentialForNoteRemote(remote, direction);
if (direction === "fetch") { if (direction === "fetch") {
await fetchCommitNotes(repo, remote, credential?.username, credential?.password); await fetchCommitNotes(repo, remote, credential?.username, credential?.password);
await refreshCommitHistory(repo);
commitNoteText = (await getCommitNote(repo, commit.hash)) ?? ""; commitNoteText = (await getCommitNote(repo, commit.hash)) ?? "";
commitNoteStatus = appLanguage === "de" commitNoteStatus = appLanguage === "de"
? `Notizen von ${remote} geladen und zusammengeführt.` ? `Notizen von ${remote} geladen und zusammengeführt.`
@@ -3102,11 +3292,18 @@
}); });
} }
// Resolve the keychain key (host/org) for the active repo's remote. function credentialModeFor(credential: StoredCredential): CredentialMode {
async function currentCredKey(): Promise<string | null> { if (credential.mode === "token" || credential.username === "oauth2") return "token";
return "credentials";
}
// Resolve the keychain key (host/org) from the exact remote URL used by the
// operation. Push URLs may intentionally differ from fetch URLs.
async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" | "delete" = "fetch"): Promise<string | null> {
if (!activeRepoPath) return null; if (!activeRepoPath) return null;
try { try {
const url = await getRemoteUrl(activeRepoPath); const remote = action === "rename" ? pendingRemoteRename?.remote : action === "delete" ? pendingRemoteDelete?.remote : selectedRemote;
const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename" || action === "delete");
return url ? orgKeyFromUrl(url) : null; return url ? orgKeyFromUrl(url) : null;
} catch { } catch {
return null; return null;
@@ -3122,21 +3319,37 @@
} }
} }
async function openCredentialDialog(action: CredentialAction, key?: string | null) { async function openCredentialDialog(
action: CredentialAction,
key?: string | null,
credential?: StoredCredential | null,
) {
if (!activeRepoPath && action !== "clone") return; if (!activeRepoPath && action !== "clone") return;
credDialogError = ""; credDialogError = "";
credDialogAction = action; credDialogAction = action;
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null); credDialogKey = key === undefined && action !== "clone"
? await currentCredKey(action)
: (key ?? null);
credDialogUsername = credential?.username === "oauth2" ? "" : (credential?.username ?? "");
credDialogMode = credential ? credentialModeFor(credential) : "credentials";
credDialogOpen = true; credDialogOpen = true;
trackEvent("credential_dialog_opened", { trackEvent("credential_dialog_opened", {
action, action,
}); });
} }
// Post-process a pull/push result: surface errors, and on rejected/expired // Post-process a pull/push result. Rejected credentials stay in the keychain
// credentials drop the stored entry and re-open the login dialog. // so a temporary 401/403 cannot erase a valid token; the key is only skipped
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) { // for the rest of this session until the user replaces it successfully.
function handleRemoteResult(
action: "push" | "pull" | "fetch" | "rename" | "delete",
key: string | null,
fromStore: boolean,
username: string,
mode: CredentialMode,
) {
if (!errorMessage) { if (!errorMessage) {
if (key) rejectedCredentialKeys.delete(key);
credDialogOpen = false; credDialogOpen = false;
credDialogAction = null; credDialogAction = null;
return; return;
@@ -3147,7 +3360,9 @@
if (fromStore) { if (fromStore) {
if (auth) { if (auth) {
if (key) void credDelete(key).catch(() => {}); if (key) rejectedCredentialKeys.add(key);
credDialogUsername = username === "oauth2" ? "" : username;
credDialogMode = mode;
const detail = summarizeGitError(message); const detail = summarizeGitError(message);
credDialogError = detail credDialogError = detail
? `${detail} — please sign in again.` ? `${detail} — please sign in again.`
@@ -3160,6 +3375,7 @@
errorMessage = message; errorMessage = message;
} }
} else { } else {
if (auth && key) rejectedCredentialKeys.add(key);
credDialogError = message || "Sign-in failed."; credDialogError = message || "Sign-in failed.";
} }
} }
@@ -3169,6 +3385,7 @@
password: string, password: string,
key: string | null, key: string | null,
fromStore: boolean, fromStore: boolean,
mode: CredentialMode,
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Pulling", async () => { await runOperation("Pulling", async () => {
@@ -3179,7 +3396,7 @@
changed_files: status?.files.length ?? 0, changed_files: status?.files.length ?? 0,
}); });
}); });
handleRemoteResult("pull", key, fromStore); handleRemoteResult("pull", key, fromStore, username, mode);
} }
async function doActualFetch( async function doActualFetch(
@@ -3187,6 +3404,7 @@
password: string, password: string,
key: string | null, key: string | null,
fromStore: boolean, fromStore: boolean,
mode: CredentialMode,
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Fetching", async () => { await runOperation("Fetching", async () => {
@@ -3199,7 +3417,7 @@
behind: status?.behind ?? 0, behind: status?.behind ?? 0,
}); });
}); });
handleRemoteResult("fetch", key, fromStore); handleRemoteResult("fetch", key, fromStore, username, mode);
} }
async function doActualPush( async function doActualPush(
@@ -3207,6 +3425,7 @@
password: string, password: string,
key: string | null, key: string | null,
fromStore: boolean, fromStore: boolean,
mode: CredentialMode,
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Pushing", async () => { await runOperation("Pushing", async () => {
@@ -3235,12 +3454,12 @@
if (!fromStore) credDialogError = ""; if (!fromStore) credDialogError = "";
await runOperation("Pulling before push", async () => { await runOperation("Pulling before push", async () => {
applyStatus(await pull(activeRepoPath, username, password)); applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath); await refreshRepositoryViews(activeRepoPath);
}); });
if (errorMessage) { if (errorMessage) {
handleRemoteResult("pull", key, fromStore); handleRemoteResult("pull", key, fromStore, username, mode);
return; return;
} }
@@ -3252,7 +3471,7 @@
} }
await runOperation("Pushing after pull", async () => { await runOperation("Pushing after pull", async () => {
applyStatus(await push(activeRepoPath, username, password)); applyStatus(await push(activeRepoPath, username, password, false, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath, { files: false }); await refreshRepositoryViews(activeRepoPath, { files: false });
trackEvent("repository_pushed_after_pull", { trackEvent("repository_pushed_after_pull", {
from_stored_credential: fromStore ? 1 : 0, from_stored_credential: fromStore ? 1 : 0,
@@ -3261,14 +3480,86 @@
}); });
} }
handleRemoteResult("push", key, fromStore); handleRemoteResult("push", key, fromStore, username, mode);
} }
async function handleCredentialSubmit(username: string, password: string, save: boolean) { async function doActualRemoteRename(
username: string,
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
const rename = pendingRemoteRename;
if (!activeRepoPath || !rename) return;
errorMessage = "";
await runOperation(`Renaming ${rename.remote}/${rename.oldBranch} on remote`, async () => {
applyStatus(await renameRemoteBranch(
activeRepoPath,
rename.remote,
rename.oldBranch,
rename.newBranch,
username,
password,
));
renameBranchTarget = null;
pendingRemoteRename = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("branch_renamed", { remote: 1 });
});
handleRemoteResult("rename", key, fromStore, username, mode);
}
async function 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,
save: boolean,
mode: CredentialMode,
) {
const key = credDialogKey; const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false); // Honour "Save in keychain" immediately. A successful authentication
else if (credDialogAction === "push") await doActualPush(username, password, key, false); // followed by an unrelated refresh/non-fast-forward error must not lose the
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false); // token and force the user to type it again on the next operation.
if (save && key) {
try {
await credSave(key, username, password, mode);
} catch (error) {
credDialogError = errorToMessage(error);
return;
}
}
if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode);
else if (credDialogAction === "push") await doActualPush(username, password, key, false, mode);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false, mode);
else if (credDialogAction === "rename") await doActualRemoteRename(username, password, key, false, mode);
else if (credDialogAction === "delete") await doActualRemoteDelete(username, password, key, false, mode);
else if (credDialogAction === "clone" && pendingClone) { else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo( await cloneRepo(
pendingClone.remoteUrl, pendingClone.remoteUrl,
@@ -3278,17 +3569,9 @@
password, password,
key, key,
false, false,
mode,
); );
} }
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
try {
await credSave(key, username, password);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
} }
async function startRemoteAction(action: "push" | "pull" | "fetch") { async function startRemoteAction(action: "push" | "pull" | "fetch") {
@@ -3296,17 +3579,18 @@
trackEvent("remote_action_started", { trackEvent("remote_action_started", {
action, action,
}); });
const key = await currentCredKey(); const key = await currentCredKey(action);
const stored = await loadStoredCredential(key); const stored = await loadStoredCredential(key);
if (stored) { if (stored && (!key || !rejectedCredentialKeys.has(key))) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true); const mode = credentialModeFor(stored);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true); if (action === "pull") await doActualPull(stored.username, stored.password, key, true, mode);
else await doActualPush(stored.username, stored.password, key, true); else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true, mode);
else await doActualPush(stored.username, stored.password, key, true, mode);
return; return;
} }
await openCredentialDialog(action, key); await openCredentialDialog(action, key, stored);
} }
async function fetchRepo() { async function fetchRepo() {
@@ -4770,6 +5054,7 @@
onRenameBranch={openRenameBranchDialog} onRenameBranch={openRenameBranchDialog}
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch} onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onDeleteBranchFolder={deleteBranchFolder}
onCreateTag={createNewTag} onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag} onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag} onPushTag={pushLocalTag}
@@ -5015,6 +5300,7 @@
onCherryPickCommit={cherryPickFromCommit} onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit} onRevertCommit={revertHistoryCommit}
onOpenCommitNote={openCommitNoteDialog} onOpenCommitNote={openCommitNoteDialog}
onLoadCommitNote={loadCommitNotePreview}
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }} onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
onToggleCommitFiles={(hash) => { onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes); const next = new Set(expandedCommitHashes);
@@ -5344,6 +5630,7 @@
{canCompare} {canCompare}
{isBusy} {isBusy}
{operation} {operation}
language={appLanguage}
onCompareFromChange={(val) => { compareFrom = val; }} onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }} onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedTargets} onCompare={compareSelectedTargets}
@@ -5358,10 +5645,11 @@
{comparison} {comparison}
{selectedDiffPath} {selectedDiffPath}
{isBusy} {isBusy}
language={appLanguage}
fromLabel={comparisonFromLabel} fromLabel={comparisonFromLabel}
toLabel={comparisonToLabel} toLabel={comparisonToLabel}
highlightQuery={diffHighlightQuery} highlightQuery={diffHighlightQuery}
restoreLabel={pendingRestoreFile ? "Restore file" : ""} restoreLabel={pendingRestoreFile ? (appLanguage === "de" ? "Datei wiederherstellen" : "Restore file") : ""}
onClose={closeCompareDialog} onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile} onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile} onSelectFile={selectDiffFile}
@@ -5369,14 +5657,24 @@
{/await} {/await}
{/if} {/if}
<!-- Credential dialog for push/pull --> <!-- Credential dialog for authenticated remote actions -->
{#if credDialogOpen && credDialogAction} {#if credDialogOpen && credDialogAction}
<CredentialDialog <CredentialDialog
action={credDialogAction} action={credDialogAction}
error={credDialogError} error={credDialogError}
{isBusy} {isBusy}
initialUsername={credDialogUsername}
initialMode={credDialogMode}
onSubmit={handleCredentialSubmit} onSubmit={handleCredentialSubmit}
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }} onCancel={() => {
credDialogOpen = false;
credDialogAction = null;
pendingRemoteDelete = null;
credDialogError = "";
credDialogKey = null;
credDialogUsername = "";
credDialogMode = "credentials";
}}
/> />
{/if} {/if}
+364 -20
View File
@@ -225,6 +225,114 @@
.btn-sm { min-height: 26px; padding: 0 8px; font-size: 12px; } .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 { .btn-primary {
border-color: rgba(111, 140, 255, 0.72); border-color: rgba(111, 140, 255, 0.72);
color: #ffffff; color: #ffffff;
@@ -1004,10 +1112,29 @@
background: transparent; background: transparent;
color: var(--color-ink-faint); 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:hover .repo-tab-close,
.repo-tab-wrap.active .repo-tab-close { opacity: 1; } .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 { .repo-tab-add {
min-width: 44px; min-width: 44px;
border-left: 1px solid var(--color-border-subtle); border-left: 1px solid var(--color-border-subtle);
@@ -2212,7 +2339,11 @@
box-shadow: 0 18px 50px rgba(0,0,0,0.5); box-shadow: 0 18px 50px rgba(0,0,0,0.5);
} }
.branch-context-menu, .branch-context-menu {
position: fixed;
max-height: calc(100vh - 16px);
overflow-y: auto;
}
.history-context-menu { position: absolute; } .history-context-menu { position: absolute; }
.explorer-context-menu, .explorer-context-menu,
.repo-tab-context-menu { position: fixed; } .repo-tab-context-menu { position: fixed; }
@@ -2542,6 +2673,107 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.commit-note-indicator {
position: relative;
display: inline-flex;
flex: 0 0 auto;
}
.commit-note-presence {
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 18px;
padding: 1px 5px 1px 4px;
border: 1px solid transparent;
border-radius: 5px;
color: #dcb96c;
background: linear-gradient(90deg, rgba(216, 167, 74, 0.11), rgba(216, 167, 74, 0.045));
box-shadow: inset 0 -1px 0 rgba(216, 167, 74, 0.22);
font-size: 9px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.015em;
}
.commit-note-presence:hover:not(:disabled) {
border-color: rgba(216, 167, 74, 0.24);
color: #efd08a;
background: linear-gradient(90deg, rgba(216, 167, 74, 0.17), rgba(216, 167, 74, 0.075));
}
.commit-note-presence:focus-visible {
outline: 2px solid rgba(216, 167, 74, 0.32);
outline-offset: 2px;
}
.commit-note-tooltip {
position: absolute;
z-index: 40;
top: calc(100% + 7px);
right: 0;
display: grid;
width: min(270px, calc(100vw - 36px));
padding: 9px 10px 10px;
border: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
border-radius: 7px;
color: var(--color-ink-muted);
background: color-mix(in srgb, #d8a74a 4%, var(--color-surface-solid));
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.34), inset 2px 0 0 rgba(216, 167, 74, 0.5);
opacity: 0;
pointer-events: none;
transform: translateY(-3px);
visibility: hidden;
transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
}
.commit-note-tooltip::before {
content: "";
position: absolute;
top: -4px;
right: 12px;
width: 7px;
height: 7px;
border-top: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
border-left: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
background: color-mix(in srgb, #d8a74a 4%, var(--color-surface-solid));
transform: rotate(45deg);
}
.commit-note-indicator:hover,
.commit-note-indicator:focus-within { z-index: 40; }
.commit-note-indicator:hover .commit-note-tooltip,
.commit-note-indicator:focus-within .commit-note-tooltip {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
.commit-note-tooltip-head {
display: flex;
align-items: center;
gap: 5px;
padding-bottom: 6px;
border-bottom: 1px solid var(--color-border-subtle);
color: #dcb96c;
font-size: 9px;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.commit-note-tooltip-head small {
margin-left: auto;
color: var(--color-ink-faint);
font-size: 8px;
font-weight: 650;
letter-spacing: 0;
text-transform: none;
}
.commit-note-tooltip-body {
display: -webkit-box;
overflow: hidden;
padding-top: 7px;
color: var(--color-ink-muted);
font-size: 10.5px;
line-height: 1.45;
overflow-wrap: anywhere;
white-space: pre-wrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 7;
}
.commit-ref-area { .commit-ref-area {
position: relative; position: relative;
@@ -3117,17 +3349,16 @@
.compare-form { .compare-form {
display: grid; 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; align-items: end;
gap: 8px; gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border-subtle);
} }
.compare-field { display: grid; gap: 4px; min-width: 0; } .compare-field { display: grid; gap: 6px; 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 > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); }
.compare-target-help { .compare-target-help {
margin: 12px; display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle)); border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
border-radius: 8px; border-radius: 8px;
@@ -3136,6 +3367,9 @@
font-size: 11.5px; font-size: 11.5px;
line-height: 1.5; 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-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); } .compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); }
@@ -3281,6 +3515,21 @@
width: min(1560px, calc(100vw - 32px)); width: min(1560px, calc(100vw - 32px));
height: min(940px, calc(100vh - 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 { .global-search-dialog {
width: min(1180px, calc(100vw - 32px)); width: min(1180px, calc(100vw - 32px));
height: min(840px, calc(100vh - 32px)); height: min(840px, calc(100vh - 32px));
@@ -3296,12 +3545,22 @@
height: min(860px, calc(100vh - 32px)); height: min(860px, calc(100vh - 32px));
} }
.compare-select-dialog { .compare-select-dialog {
display: block; grid-template-rows: auto minmax(0, 1fr);
width: min(720px, calc(100vw - 32px)); width: min(720px, calc(100vw - 32px));
height: auto; height: auto;
max-height: calc(100vh - 32px); 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, .interactive-rebase-dialog,
.reflog-dialog { .reflog-dialog {
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
@@ -3358,11 +3617,12 @@
border-radius: 5px; border-radius: 5px;
background: var(--code-surface-subtle); background: var(--code-surface-subtle);
} }
.rebase-action { height: 30px; font-family: var(--font-mono); font-weight: 800; } .rebase-action { font-family: var(--font-mono); font-weight: 800; }
.rebase-action.pick { color: var(--code-add-strong); } .rebase-action .select-menu-trigger { height: 30px; min-height: 30px; font-family: inherit; font-weight: inherit; }
.rebase-action.reword { color: var(--code-hunk-text); } .rebase-action.pick .select-menu-trigger { color: var(--code-add-strong); }
.rebase-action.squash, .rebase-action.fixup { color: #96620f; } .rebase-action.reword .select-menu-trigger { color: var(--code-hunk-text); }
.rebase-action.drop { color: var(--code-delete-strong); } .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-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 { 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; } .rebase-commit-copy strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
@@ -3859,6 +4119,8 @@
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); } .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); } .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 { .dialog-file-row {
display: grid; display: grid;
@@ -5979,12 +6241,35 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.graph-list { background: var(--color-surface-solid); } .graph-list { background: var(--color-surface-solid); }
.graph-gutter { background: var(--color-surface-dim); } .graph-gutter { background: var(--color-surface-dim); }
.commit-body { .commit-body {
border-radius: var(--ui-radius-sm); border-radius: 0;
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid)); background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid));
box-shadow: none; box-shadow: none;
} }
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); } .graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); } .graph-row:hover .commit-body { background: var(--color-surface-hover); }
.commit-note-rail {
position: absolute;
z-index: 2;
top: 10px;
bottom: 10px;
left: 0;
width: 2px;
border-radius: 0 2px 2px 0;
background: #d8a74a;
box-shadow: 0 0 8px rgba(216, 167, 74, 0.16);
opacity: 0.68;
pointer-events: none;
}
.graph-row.has-note .commit-body {
background:
linear-gradient(90deg, rgba(216, 167, 74, 0.075), rgba(216, 167, 74, 0.025) 36%, transparent 68%),
color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid));
}
.graph-row.has-note:hover .commit-body {
background:
linear-gradient(90deg, rgba(216, 167, 74, 0.105), rgba(216, 167, 74, 0.035) 36%, transparent 68%),
var(--color-surface-hover);
}
.graph-row { .graph-row {
content-visibility: auto; content-visibility: auto;
contain-intrinsic-block-size: 108px; contain-intrinsic-block-size: 108px;
@@ -6219,6 +6504,38 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
background: rgba(235,241,250,0.9); background: rgba(235,241,250,0.9);
} }
:root[data-theme="light"] .graph-row.has-note .commit-body {
background:
linear-gradient(90deg, rgba(194, 132, 35, 0.09), rgba(194, 132, 35, 0.025) 38%, transparent 68%),
rgba(255,255,255,0.76);
}
:root[data-theme="light"] .graph-row.has-note:hover .commit-body {
background:
linear-gradient(90deg, rgba(194, 132, 35, 0.12), rgba(194, 132, 35, 0.035) 38%, transparent 68%),
rgba(235,241,250,0.92);
}
:root[data-theme="light"] .commit-note-presence {
border-color: transparent;
color: #986313;
background: linear-gradient(90deg, rgba(194, 132, 35, 0.12), rgba(194, 132, 35, 0.05));
box-shadow: inset 0 -1px 0 rgba(168, 105, 16, 0.2);
}
:root[data-theme="light"] .commit-note-tooltip,
:root[data-theme="light"] .commit-note-tooltip::before {
background: color-mix(in srgb, #c28423 4%, #ffffff);
}
:root[data-theme="light"] .commit-note-tooltip {
box-shadow: 0 12px 30px rgba(35, 45, 68, 0.16), inset 2px 0 0 rgba(194, 132, 35, 0.48);
}
:root[data-theme="light"] .commit-note-tooltip-head {
color: #986313;
}
:root[data-theme="light"] .graph-row.merge-row .commit-body { :root[data-theme="light"] .graph-row.merge-row .commit-body {
background: rgba(248,244,252,0.82); background: rgba(248,244,252,0.82);
} }
@@ -6352,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); 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"] .dialog-file-row:hover,
:root[data-theme="light"] .branch-row:hover, :root[data-theme="light"] .branch-row:hover,
:root[data-theme="light"] .explorer-row:hover, :root[data-theme="light"] .explorer-row:hover,
@@ -6646,7 +6982,15 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.repo-summary { height: 40px; } .repo-summary { height: 40px; }
.change-lanes { grid-template-columns: 1fr; } .change-lanes { grid-template-columns: 1fr; }
.compare-form { 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); } .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); } .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; } .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}`} aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"} title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
> >
<X size={13} aria-hidden="true" /> <X size={11} aria-hidden="true" />
</button> </button>
</div> </div>
{/each} {/each}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte"; import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
import type { AiCommitPlan } from "../types"; import type { AiCommitPlan } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
plan: AiCommitPlan; plan: AiCommitPlan;
@@ -62,9 +63,7 @@
<div class="split-files"> <div class="split-files">
{#each group.files as file} {#each group.files as file}
<div><code>{file}</code> <div><code>{file}</code>
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}> <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))} />
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
</select>
</div> </div>
{/each} {/each}
</div> </div>
@@ -98,6 +97,7 @@
.split-files{display:grid;gap:5px} .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)} .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} 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} .dialog-footer p.invalid{color:#e0a040}
</style> </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 { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git"; import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types"; import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
settings: AiSettings; settings: AiSettings;
@@ -200,11 +201,7 @@
</div> </div>
<label class="cred-field"> <label class="cred-field">
<span class="cred-field-label">Model</span> <span class="cred-field-label">Model</span>
<select bind:value={localModelId}> <SelectMenu value={localModelId} options={localModels.map((option) => ({ value: option.id, label: `${option.label} - ${formatSize(option.approx_size_mb)}` }))} onChange={(value) => { localModelId = value; }} />
{#each localModels as option (option.id)}
<option value={option.id}>{option.label} {formatSize(option.approx_size_mb)}</option>
{/each}
</select>
</label> </label>
<div class="cred-token-hint"> <div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" /> <AlertCircle size={13} aria-hidden="true" />
+14 -13
View File
@@ -37,6 +37,7 @@
ExternalToolsSettings, ExternalToolsSettings,
ToolOpenMode, ToolOpenMode,
} from "../types"; } from "../types";
import SelectMenu from "./SelectMenu.svelte";
type SettingsPage = "general" | "tools"; type SettingsPage = "general" | "tools";
@@ -390,17 +391,17 @@
<label class="tool-default-field"> <label class="tool-default-field">
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span> <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)}`}> <SelectMenu
{#if availablePresets(activeToolKind).length > 0} class="tool-preset-select"
<optgroup label={isGerman ? "Installiert" : "Installed"}> value={tools[activeToolKind].preset}
{#each availablePresets(activeToolKind) as preset}<option value={preset.id}> {preset.label}</option>{/each} options={[
</optgroup> ...availablePresets(activeToolKind).map((preset) => ({ value: preset.id, label: `Installed - ${preset.label}`, group: isGerman ? "Installiert" : "Installed" })),
{/if} ...otherPresets(activeToolKind).map((preset) => ({ value: preset.id, label: preset.label, group: isGerman ? "Weitere unterstützte Programme" : "Other supported applications" })),
<optgroup label={isGerman ? "Weitere unterstützte Programme" : "Other supported applications"}> { value: "custom", label: isGerman ? "Eigenes Programm auswählen..." : "Choose a custom application..." },
{#each otherPresets(activeToolKind) as preset}<option value={preset.id}>{preset.label}</option>{/each} ]}
</optgroup> ariaLabel={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}
<option value="custom">{isGerman ? "Eigenes Programm auswählen…" : "Choose a custom application…"}</option> onChange={(value) => changePreset(activeToolKind, value)}
</select> />
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small> <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> </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 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-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, .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-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; } :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-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 { 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; } .tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
+92 -20
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte"; import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { tick } from "svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types"; import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode; type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -11,6 +12,7 @@
children: BranchTreeNode[]; children: BranchTreeNode[];
branchCount: number; branchCount: number;
current: boolean; current: boolean;
branches: GitBranchInfo[];
folders: Map<string, BranchFolderNode>; folders: Map<string, BranchFolderNode>;
} }
@@ -30,6 +32,7 @@
depth: number; depth: number;
branchCount: number; branchCount: number;
current: boolean; current: boolean;
branches: GitBranchInfo[];
} }
interface BranchLeafRow { interface BranchLeafRow {
@@ -56,6 +59,7 @@
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>; onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>; onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (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>; onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>; onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>; onPushTag: (tag: GitTag) => void | Promise<void>;
@@ -80,6 +84,7 @@
onRenameBranch = () => {}, onRenameBranch = () => {},
onDeleteBranch = () => {}, onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {}, onDeleteRemoteBranch = () => {},
onDeleteBranchFolder = () => {},
onCreateTag = () => {}, onCreateTag = () => {},
onDeleteTag = () => {}, onDeleteTag = () => {},
onPushTag = () => {}, onPushTag = () => {},
@@ -99,11 +104,13 @@
let newTagName = $state(""); let newTagName = $state("");
let newTagMessage = $state(""); let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null); let tagCreateInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null); let contextBranch = $state<GitBranchInfo | null>(null);
let contextFolder = $state<BranchFolderRow | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null); let contextTag = $state<GitTag | null>(null);
let tagContextMenuElement = $state<HTMLElement | null>(null);
let tagContextMenuX = $state(0); let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0); let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set()); let collapsedBranchFolders = $state<Set<string>>(new Set());
@@ -119,6 +126,7 @@
children: [], children: [],
branchCount: 0, branchCount: 0,
current: false, current: false,
branches: [],
folders: new Map(), folders: new Map(),
}; };
} }
@@ -145,6 +153,7 @@
folder.branchCount += 1; folder.branchCount += 1;
folder.current ||= branch.current; folder.current ||= branch.current;
folder.branches.push(branch);
parent = folder; parent = folder;
} }
@@ -186,6 +195,7 @@
depth, depth,
branchCount: node.branchCount, branchCount: node.branchCount,
current: node.current, current: node.current,
branches: node.branches,
}); });
if (isBranchFolderOpen(node.id)) { if (isBranchFolderOpen(node.id)) {
@@ -243,24 +253,52 @@
onCheckout(branch); onCheckout(branch);
} }
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) { function fitContextMenuToViewport(element: HTMLElement | null, x: number, y: number) {
const rect = element?.getBoundingClientRect();
const width = rect?.width ?? 184;
const height = rect?.height ?? 0;
return {
x: Math.max(8, Math.min(x + 2, window.innerWidth - width - 8)),
y: Math.max(8, Math.min(y + 2, window.innerHeight - height - 8)),
};
}
async function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isBusy) return; if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
contextBranch = branch; contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX)); contextMenuX = event.clientX + 2;
contextMenuY = Math.max(8, Math.min(rawY, maxY)); contextMenuY = event.clientY + 2;
await tick();
if (contextBranch !== branch) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
contextMenuX = position.x;
contextMenuY = position.y;
}
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() { function closeBranchContextMenu() {
contextBranch = null; contextBranch = null;
contextFolder = null;
} }
async function renameContextBranch() { async function renameContextBranch() {
@@ -284,6 +322,13 @@
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch); 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() { async function createContextWorktree() {
const branch = contextBranch; const branch = contextBranch;
closeBranchContextMenu(); closeBranchContextMenu();
@@ -335,20 +380,20 @@
tagsOpen = true; tagsOpen = true;
} }
function openTagContextMenu(event: MouseEvent, tag: GitTag) { async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isBusy) return; if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
contextTag = tag; contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX)); tagContextMenuX = event.clientX + 2;
tagContextMenuY = Math.max(8, Math.min(rawY, maxY)); tagContextMenuY = event.clientY + 2;
await tick();
if (contextTag !== tag) return;
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
tagContextMenuX = position.x;
tagContextMenuY = position.y;
} }
function closeTagContextMenu() { function closeTagContextMenu() {
@@ -381,7 +426,7 @@
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} /> <svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches"> <section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Branches</span> <span class="eyebrow">Branches</span>
@@ -471,6 +516,7 @@
style={`--branch-indent: ${row.depth * 16}px;`} style={`--branch-indent: ${row.depth * 16}px;`}
type="button" type="button"
onclick={() => toggleBranchFolder(row.id)} onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={isBranchFolderOpen(row.id)} aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`} title={`${row.name} (${row.branchCount})`}
> >
@@ -538,6 +584,7 @@
style={`--branch-indent: ${row.depth * 16}px;`} style={`--branch-indent: ${row.depth * 16}px;`}
type="button" type="button"
onclick={() => toggleBranchFolder(row.id)} onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={isBranchFolderOpen(row.id)} aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`} title={`${row.name} (${row.branchCount})`}
> >
@@ -676,6 +723,7 @@
{#if contextBranch} {#if contextBranch}
<div <div
bind:this={branchContextMenuElement}
class="branch-context-menu" class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`} style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu" role="menu"
@@ -721,8 +769,32 @@
</div> </div>
{/if} {/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} {#if contextTag}
<div <div
bind:this={tagContextMenuElement}
class="branch-context-menu" class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`} style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu" role="menu"
+2 -5
View File
@@ -11,6 +11,7 @@
X, X,
} from "@lucide/svelte"; } from "@lucide/svelte";
import type { AppLanguage, GitCommit, GitRemote } from "../types"; import type { AppLanguage, GitCommit, GitRemote } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
commit: GitCommit; commit: GitCommit;
@@ -196,11 +197,7 @@
<div class="commit-note-sync-controls"> <div class="commit-note-sync-controls">
<label> <label>
<span>{text.remote}</span> <span>{text.remote}</span>
<select bind:value={selectedRemote} disabled={isBusy}> <SelectMenu value={selectedRemote} options={remotes.map((remote) => ({ value: remote.name, label: remote.name }))} disabled={isBusy} onChange={(value) => { selectedRemote = value; }} />
{#each remotes as remote (remote.name)}
<option value={remote.name}>{remote.name}</option>
{/each}
</select>
</label> </label>
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}> <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} {#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
+23 -12
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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"; import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
type SplitRow = type SplitRow =
@@ -25,6 +25,7 @@
restoreLabel?: string; restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */ /** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string; highlightQuery?: string;
language?: "en" | "de";
onClose: () => void; onClose: () => void;
onRestore?: () => void; onRestore?: () => void;
onSelectFile: (file: GitDiffFile) => void; onSelectFile: (file: GitDiffFile) => void;
@@ -38,11 +39,14 @@
toLabel = "", toLabel = "",
restoreLabel = "", restoreLabel = "",
highlightQuery = "", highlightQuery = "",
language = "en",
onClose = () => {}, onClose = () => {},
onRestore = undefined, onRestore = undefined,
onSelectFile = () => {}, onSelectFile = () => {},
}: Props = $props(); }: Props = $props();
let isGerman = $derived(language === "de");
// Needle = first non-empty line of the search query, lowercased for matching. // Needle = first non-empty line of the search query, lowercased for matching.
let highlightNeedle = $derived( let highlightNeedle = $derived(
highlightQuery highlightQuery
@@ -225,14 +229,17 @@
> >
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1"> <div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
<header class="dialog-header"> <header class="compare-dialog-head">
<div class="compare-dialog-title">
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
<div> <div>
<span class="eyebrow">Compare</span> <h2>{isGerman ? "Änderungen vergleichen" : "Compare changes"}</h2>
<h2 class="dialog-range"> <p class="dialog-range">
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span> <span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
<ArrowRight size={14} aria-hidden="true" /> <ArrowRight size={14} aria-hidden="true" />
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span> <span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</h2> </p>
</div>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions">
{#if restoreLabel && onRestore} {#if restoreLabel && onRestore}
@@ -241,19 +248,23 @@
<span>{restoreLabel}</span> <span>{restoreLabel}</span>
</button> </button>
{/if} {/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" /> <X size={18} aria-hidden="true" />
</button> </button>
</div> </div>
</header> </header>
{#if comparison.files.length === 0} {#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} {:else}
<div class="dialog-body"> <div class="dialog-body">
<!-- File list --> <!-- 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}`)} {#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
<button <button
class="dialog-file-row" class="dialog-file-row"
@@ -275,9 +286,9 @@
<!-- Diff pane --> <!-- Diff pane -->
<div class="dialog-diff"> <div class="dialog-diff">
{#if !selectedFile} {#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} {: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} {:else}
<!-- Path bar --> <!-- Path bar -->
<div class="diff-header"> <div class="diff-header">
@@ -292,11 +303,11 @@
<!-- Column headers --> <!-- Column headers -->
<div class="split-col-headers"> <div class="split-col-headers">
<div class="split-col-label"> <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> <span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
</div> </div>
<div class="split-col-label"> <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> <span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</div> </div>
</div> </div>
+5 -20
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte"; import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
import type { GitCommit, GitCommitComparison } from "../types"; import type { GitCommit, GitCommitComparison } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
@@ -40,6 +41,8 @@
event.preventDefault(); event.preventDefault();
onCompare(); onCompare();
} }
let commitOptions = $derived(commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })));
</script> </script>
<section class="compare-panel panel" aria-label="Compare commits"> <section class="compare-panel panel" aria-label="Compare commits">
@@ -61,32 +64,14 @@
<form class="compare-form" onsubmit={handleSubmit}> <form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field"> <label class="compare-field">
<span>From (older)</span> <span>From (older)</span>
<select <SelectMenu value={compareFrom} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
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>
</label> </label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" /> <ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field"> <label class="compare-field">
<span>To (newer)</span> <span>To (newer)</span>
<select <SelectMenu value={compareTo} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
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>
</label> </label>
<button class="btn-primary" type="submit" disabled={!canCompare}> <button class="btn-primary" type="submit" disabled={!canCompare}>
+62 -78
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <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 type { GitBranch, GitCommit } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
@@ -10,6 +11,7 @@
canCompare: boolean; canCompare: boolean;
isBusy: boolean; isBusy: boolean;
operation: string; operation: string;
language?: "en" | "de";
onCompareFromChange: (val: string) => void; onCompareFromChange: (val: string) => void;
onCompareToChange: (val: string) => void; onCompareToChange: (val: string) => void;
onCompare: () => void; onCompare: () => void;
@@ -24,12 +26,15 @@
canCompare = false, canCompare = false,
isBusy = false, isBusy = false,
operation = "", operation = "",
language = "en",
onCompareFromChange = () => {}, onCompareFromChange = () => {},
onCompareToChange = () => {}, onCompareToChange = () => {},
onCompare = () => {}, onCompare = () => {},
onClose = () => {}, onClose = () => {},
}: Props = $props(); }: Props = $props();
let isGerman = $derived(language === "de");
function commitOptionLabel(item: GitCommit): string { function commitOptionLabel(item: GitCommit): string {
return `${item.short_hash} - ${item.summary}`; return `${item.short_hash} - ${item.summary}`;
} }
@@ -41,6 +46,23 @@
let localBranches = $derived(branches.filter((branch) => !branch.remote)); let localBranches = $derived(branches.filter((branch) => !branch.remote));
let remoteBranches = $derived(branches.filter((branch) => branch.remote)); let remoteBranches = $derived(branches.filter((branch) => branch.remote));
let targetCount = $derived(branches.length + commits.length); 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) { function handleSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
@@ -52,104 +74,66 @@
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
> >
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1"> <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="dialog-header"> <header class="compare-dialog-head">
<div class="compare-dialog-title">
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
<div> <div>
<span class="eyebrow">Compare</span> <h2>{isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}</h2>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">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"> </div>
<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" /> <X size={18} aria-hidden="true" />
</button> </button>
</header> </header>
<form class="compare-select-shell" onsubmit={handleSubmit}>
<div class="compare-select-body">
{#if targetCount < 2} {#if targetCount < 2}
<div class="blank-state">At least two branches or commits are needed to compare.</div> <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} {:else}
<form class="compare-form" onsubmit={handleSubmit}> <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"> <label class="compare-field">
<span>Base</span> <span>{isGerman ? "Ausgangsbasis" : "Base"}</span>
<select <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} />
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> </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"> <label class="compare-field">
<span>Compare with</span> <span>{isGerman ? "Vergleichen mit" : "Compare with"}</span>
<select <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} />
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>
</div>
</section>
<button class="btn-primary" type="submit" disabled={!canCompare}> {#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}
<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}
{/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"} {#if operation === "Comparing revisions"}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else} {:else}
<GitCompare size={16} aria-hidden="true" /> <GitCompare size={16} aria-hidden="true" />
{/if} {/if}
Compare {isGerman ? "Vergleich öffnen" : "Open comparison"}
</button> </button>
</div>
</footer>
</form> </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.
</div>
{/if}
{/if}
</div> </div>
</div> </div>
+22 -15
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { import {
AlertCircle, AlertCircle,
Download, Download,
@@ -14,10 +15,12 @@
} from "@lucide/svelte"; } from "@lucide/svelte";
interface Props { interface Props {
action: "push" | "pull" | "fetch" | "clone"; action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
error: string; error: string;
isBusy: boolean; isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean) => void; initialUsername?: string;
initialMode?: Mode;
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -25,14 +28,16 @@
action, action,
error = "", error = "",
isBusy = false, isBusy = false,
initialUsername = "",
initialMode = "credentials",
onSubmit, onSubmit,
onCancel, onCancel,
}: Props = $props(); }: Props = $props();
type Mode = "credentials" | "token"; type Mode = "credentials" | "token";
let mode = $state<Mode>("credentials"); let mode = $state<Mode>(untrack(() => initialMode));
let username = $state(""); let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
let password = $state(""); let password = $state("");
let showPassword = $state(false); let showPassword = $state(false);
let saveSession = $state(true); let saveSession = $state(true);
@@ -40,19 +45,23 @@
let canSubmit = $derived( let canSubmit = $derived(
!isBusy && !isBusy &&
password.trim().length > 0 && password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0), username.trim().length > 0,
); );
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull"); let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull");
let actionTitle = $derived( let actionTitle = $derived(
action === "push" action === "push"
? "Authenticate push" ? "Authenticate push"
: action === "rename"
? "Authenticate remote rename"
: action === "delete"
? "Authenticate remote deletion"
: action === "fetch" : action === "fetch"
? "Authenticate fetch" ? "Authenticate fetch"
: action === "clone" : action === "clone"
? "Authenticate clone" ? "Authenticate clone"
: "Authenticate pull", : "Authenticate pull",
); );
let actionHint = $derived(action === "push" let actionHint = $derived(action === "push" || action === "rename" || action === "delete"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions." ? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone" : action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token." ? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
@@ -61,7 +70,7 @@
function handleSubmit(e: SubmitEvent) { function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!canSubmit) return; if (!canSubmit) return;
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession); onSubmit(username.trim(), password, saveSession, mode);
} }
</script> </script>
@@ -73,7 +82,7 @@
<div class="cred-hero"> <div class="cred-hero">
<div class="cred-hero-top"> <div class="cred-hero-top">
<div class="cred-hero-icon"> <div class="cred-hero-icon">
{#if action === "push"} {#if action === "push" || action === "rename" || action === "delete"}
<Upload size={27} aria-hidden="true" /> <Upload size={27} aria-hidden="true" />
{:else} {:else}
<Download size={27} aria-hidden="true" /> <Download size={27} aria-hidden="true" />
@@ -92,7 +101,7 @@
<div class="cred-security-note"> <div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" /> <ShieldCheck size={14} aria-hidden="true" />
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span> <span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
</div> </div>
</div> </div>
@@ -116,12 +125,11 @@
aria-pressed={mode === "token"} aria-pressed={mode === "token"}
> >
<Key size={13} aria-hidden="true" /> <Key size={13} aria-hidden="true" />
Token Access token
</button> </button>
</div> </div>
<div class="cred-fields"> <div class="cred-fields">
{#if mode === "credentials"}
<div class="cred-field"> <div class="cred-field">
<label class="cred-field-label" for="cred-username">Username</label> <label class="cred-field-label" for="cred-username">Username</label>
<div class="cred-input"> <div class="cred-input">
@@ -130,13 +138,12 @@
id="cred-username" id="cred-username"
type="text" type="text"
bind:value={username} bind:value={username}
placeholder="e.g. my-github-username" placeholder="Your account username"
autocomplete="username" autocomplete="username"
disabled={isBusy} disabled={isBusy}
/> />
</div> </div>
</div> </div>
{/if}
<div class="cred-field"> <div class="cred-field">
<label class="cred-field-label" for="cred-password"> <label class="cred-field-label" for="cred-password">
@@ -174,7 +181,7 @@
{#if mode === "token"} {#if mode === "token"}
<div class="cred-token-hint"> <div class="cred-token-hint">
<Key size={13} aria-hidden="true" /> <Key size={13} aria-hidden="true" />
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span> <span>Use your normal account username. The access token is sent as the password, as required by Gitea and most Git providers.</span>
</div> </div>
{/if} {/if}
+2 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import { import {
CalendarDays, CalendarDays,
FileCode, FileCode,
@@ -193,12 +194,7 @@
<label class="search-limit"> <label class="search-limit">
<span>Results</span> <span>Results</span>
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}> <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); }} />
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label> </label>
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}> <button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
+60
View File
@@ -547,6 +547,25 @@
"Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.", "Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.",
], ],
}, },
{
id: "app-git-notes",
title: "Commits mit Git Notes ergänzen",
summary: "Git Notes speichern zusätzliche Informationen zu einem Commit, ohne dessen Hash oder die Historie zu verändern. Sie eignen sich etwa für Review-Hinweise, Ticket-Kontext, Build-IDs oder Freigabestatus.",
steps: [
"Öffne im Commit-Verlauf über das Notiz-Symbol oder das Kontextmenü die Commit-Notiz.",
"Schreibe oder bearbeite die Notiz und speichere sie. Commits mit einer Notiz sind im Verlauf markiert; beim Überfahren der Markierung erscheint eine Vorschau.",
"Löschen entfernt nur die Notiz. Der zugehörige Commit und seine Dateien bleiben unverändert.",
"Gitty lädt Git Notes im Hintergrund vom bevorzugten Remote. Nutze im Dialog Vom Remote laden, um sie bei Bedarf gezielt zu aktualisieren.",
"Nutze Zum Remote senden, um lokale Notizen zu veröffentlichen. Ein normaler Branch-Push überträgt Git Notes nicht automatisch.",
],
commands: [
{ command: "git notes show <commit>", description: "Notiz eines Commits in der Kommandozeile anzeigen" },
{ command: "git notes add <commit>", description: "Notiz zu einem Commit hinzufügen oder im Editor verfassen" },
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Commit-Notizen gezielt vom Remote laden" },
{ command: "git push <remote> refs/notes/commits", description: "Lokale Commit-Notizen zum Remote senden" },
],
note: "Git Notes liegen standardmäßig unter refs/notes/commits und werden getrennt von Branches synchronisiert. Prüfe vor einem Push, ob der Ziel-Remote diese Referenz akzeptiert.",
},
{ {
id: "app-search", id: "app-search",
title: "Code-Ursprung mit Global Search finden", title: "Code-Ursprung mit Global Search finden",
@@ -990,6 +1009,25 @@
"Restore from commit writes an older file version into the working tree. Review and commit the result normally.", "Restore from commit writes an older file version into the working tree. Review and commit the result normally.",
], ],
}, },
{
id: "app-git-notes",
title: "Add context to commits with Git Notes",
summary: "Git Notes attach additional information to a commit without changing its hash or rewriting history. They are useful for review findings, ticket context, build IDs, or approval status.",
steps: [
"Open the commit note from the note icon or the commit context menu in History.",
"Write or edit the note and save it. Commits with a note are marked in History, and hovering over the marker shows a preview.",
"Deleting removes only the note. The associated commit and its files remain unchanged.",
"Gitty fetches Git Notes from the preferred remote in the background. Use Fetch from remote in the dialog to refresh them explicitly when needed.",
"Use Push to remote to publish local notes. A regular branch push does not transfer Git Notes automatically.",
],
commands: [
{ command: "git notes show <commit>", description: "Show a commit's note on the command line" },
{ command: "git notes add <commit>", description: "Add a note to a commit or compose it in an editor" },
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Fetch commit notes explicitly from a remote" },
{ command: "git push <remote> refs/notes/commits", description: "Push local commit notes to a remote" },
],
note: "Git Notes are stored under refs/notes/commits by default and synchronize separately from branches. Before pushing, make sure the destination remote accepts this reference.",
},
{ {
id: "app-search", id: "app-search",
title: "Find code origins with Global Search", title: "Find code origins with Global Search",
@@ -1361,6 +1399,17 @@
label: "Neu in Gitty", label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.", description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [ 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", id: "changelog-2026-8-3",
title: "Version 2026.8.3", title: "Version 2026.8.3",
@@ -1450,6 +1499,17 @@
label: "What's new", label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.", description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [ 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", id: "changelog-2026-8-3",
title: "Version 2026.8.3", title: "Version 2026.8.3",
+85 -2
View File
@@ -80,6 +80,7 @@
onCherryPickCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void; onRevertCommit: (commit: GitCommit) => void;
onOpenCommitNote: (commit: GitCommit) => void; onOpenCommitNote: (commit: GitCommit) => void;
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
onSelectCommit: (commit: GitCommit) => void; onSelectCommit: (commit: GitCommit) => void;
} }
@@ -108,6 +109,7 @@
onCherryPickCommit = () => {}, onCherryPickCommit = () => {},
onRevertCommit = () => {}, onRevertCommit = () => {},
onOpenCommitNote = () => {}, onOpenCommitNote = () => {},
onLoadCommitNote = async () => null,
onSelectCommit = () => {}, onSelectCommit = () => {},
}: Props = $props(); }: Props = $props();
@@ -120,6 +122,16 @@
let contextCommit = $state<GitCommit | null>(null); let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
let notePreviews = $state<Record<string, string>>({});
let notePreviewLoading = $state<Set<string>>(new Set());
let notePreviewErrors = $state<Set<string>>(new Set());
$effect(() => {
repositoryKey;
notePreviews = {};
notePreviewLoading = new Set();
notePreviewErrors = new Set();
});
function observeHistoryEnd(node: HTMLElement) { function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list"); const root = node.closest<HTMLElement>(".history-list");
@@ -503,6 +515,34 @@
await onOpenCommitNote(commit); await onOpenCommitNote(commit);
} }
async function loadCommitNotePreview(commit: GitCommit) {
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
const loading = new Set(notePreviewLoading);
loading.add(commit.hash);
notePreviewLoading = loading;
const errors = new Set(notePreviewErrors);
errors.delete(commit.hash);
notePreviewErrors = errors;
try {
const note = await onLoadCommitNote(commit);
notePreviews = {
...notePreviews,
[commit.hash]: note?.trim() || "This Git note is empty.",
};
} catch {
const nextErrors = new Set(notePreviewErrors);
nextErrors.add(commit.hash);
notePreviewErrors = nextErrors;
} finally {
const nextLoading = new Set(notePreviewLoading);
nextLoading.delete(commit.hash);
notePreviewLoading = nextLoading;
}
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeCommitContextMenu(); closeCommitContextMenu();
@@ -792,6 +832,7 @@
<article <article
class="commit-row graph-row" class="commit-row graph-row"
class:selected={selectedCommitHash === item.hash} class:selected={selectedCommitHash === item.hash}
class:has-note={item.has_note}
data-commit-hash={item.hash} data-commit-hash={item.hash}
class:graph-ahead-row={rowSyncClass === "ahead"} class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"} class:graph-behind-row={rowSyncClass === "behind"}
@@ -852,6 +893,9 @@
class:has-branch-ref={Boolean(refSummary.primaryBranch)} class:has-branch-ref={Boolean(refSummary.primaryBranch)}
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`} style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
> >
{#if item.has_note}
<span class="commit-note-rail" aria-hidden="true"></span>
{/if}
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0} {#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area"> <div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references"> <div class="commit-ref-strip" aria-label="Commit references">
@@ -966,6 +1010,43 @@
<div class="commit-meta-line"> <div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span> <span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</span> <span class="commit-author" title={item.author_email}>{item.author_name}</span>
{#if item.has_note}
<span class="commit-note-indicator">
<button
class="commit-note-presence"
type="button"
onpointerenter={() => void loadCommitNotePreview(item)}
onfocus={() => void loadCommitNotePreview(item)}
onclick={() => openCommitNote(item)}
disabled={isBusy}
aria-label={`Open Git note for ${item.short_hash}`}
aria-describedby={`commit-note-preview-${item.hash}`}
>
<StickyNote size={11} aria-hidden="true" />
<span>Note</span>
</button>
<span
class="commit-note-tooltip"
id={`commit-note-preview-${item.hash}`}
role="tooltip"
>
<span class="commit-note-tooltip-head">
<StickyNote size={12} aria-hidden="true" />
Git Note
<small>Click to open</small>
</span>
<span class="commit-note-tooltip-body">
{#if notePreviewLoading.has(item.hash)}
Loading note…
{:else if notePreviewErrors.has(item.hash)}
Note could not be loaded.
{:else}
{notePreviews[item.hash] ?? "Hover to load the note."}
{/if}
</span>
</span>
</span>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -1008,16 +1089,18 @@
<div class="commit-actions"> <div class="commit-actions">
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time> <time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons"> <div class="commit-action-buttons">
{#if !item.has_note}
<button <button
class="commit-menu-button commit-note-button" class="commit-menu-button commit-note-button"
type="button" type="button"
onclick={() => openCommitNote(item)} onclick={() => openCommitNote(item)}
disabled={isBusy} disabled={isBusy}
title={`Open internal note for ${item.short_hash}`} title={`Add a Git note to ${item.short_hash}`}
aria-label={`Open internal note for ${item.short_hash}`} aria-label={`Add a Git note to ${item.short_hash}`}
> >
<StickyNote size={14} aria-hidden="true" /> <StickyNote size={14} aria-hidden="true" />
</button> </button>
{/if}
<button <button
class="commit-menu-button" class="commit-menu-button"
type="button" type="button"
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte"; import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types"; import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface PlanRow extends RebaseCommit { interface PlanRow extends RebaseCommit {
action: RebaseAction; action: RebaseAction;
@@ -41,6 +42,7 @@
)); ));
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim())); 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); 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) { function updateAction(index: number, action: RebaseAction) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row); rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
@@ -82,12 +84,7 @@
<section class="rebase-base-bar"> <section class="rebase-base-bar">
<label> <label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span> <span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}> <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} />
<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>
</label> </label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p> <p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
</section> </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 === 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> <button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
</div> </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}`}> <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)} />
<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>
<code>{row.short_hash}</code> <code>{row.short_hash}</code>
<div class="rebase-commit-copy"> <div class="rebase-commit-copy">
{#if row.action === "reword"} {#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"> <script lang="ts">
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte"; import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
import type { GitRemote, PullStrategy } from "../types"; import type { GitRemote, PullStrategy } from "../types";
import SelectMenu from "./SelectMenu.svelte";
export let remotes: GitRemote[] = []; export let remotes: GitRemote[] = [];
export let remoteBranches: string[] = []; export let remoteBranches: string[] = [];
@@ -76,8 +77,8 @@
</div> </div>
<div class="sync-fields"> <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 ? "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><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 ? `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> </div>
</section> </section>
+12 -6
View File
@@ -21,6 +21,7 @@
X, X,
} from "@lucide/svelte"; } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types"; import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
import SelectMenu from "./SelectMenu.svelte";
type CreateMode = "existing" | "new" | "detached"; type CreateMode = "existing" | "new" | "detached";
@@ -260,12 +261,17 @@
{#if createMode === "existing"} {#if createMode === "existing"}
<label> <label>
<span>Branch</span> <span>Branch</span>
<select bind:value={selectedBranch} disabled={isBusy}> <SelectMenu
<option value="" disabled>Select a local branch</option> value={selectedBranch}
{#each localBranches as branch (branch.name)} placeholder="Select a local branch"
<option value={branch.name} disabled={!branchAvailable(branch.name)}>{branch.name}{!branchAvailable(branch.name) ? " (already checked out)" : ""}</option> options={localBranches.map((branch) => ({
{/each} value: branch.name,
</select> label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
disabled: !branchAvailable(branch.name),
}))}
disabled={isBusy}
onChange={(value) => { selectedBranch = value; }}
/>
</label> </label>
{:else if createMode === "new"} {:else if createMode === "new"}
<label> <label>
+20 -6
View File
@@ -107,7 +107,12 @@ export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
console.log("remove_remote") console.log("remove_remote")
return invoke("remove_remote", { path, name }); } 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 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[]> { export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path }); return invoke<GitStash[]>("list_stashes", { path });
@@ -138,8 +143,17 @@ export function renameRemoteBranch(
remote: string, remote: string,
oldBranch: string, oldBranch: string,
newBranch: string, newBranch: string,
username?: string,
password?: string,
): Promise<GitStatus> { ): Promise<GitStatus> {
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch }); return invoke<GitStatus>("rename_remote_branch", {
path,
remote,
oldBranch,
newBranch,
username: username ?? null,
password: password ?? null,
});
} }
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> { export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
@@ -373,16 +387,16 @@ export function push(path: string, username?: string, password?: string, forceWi
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null }); return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
} }
export function getRemoteUrl(path: string): Promise<string | null> { export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
return invoke<string | null>("get_remote_url", { path }); return invoke<string | null>("get_remote_url", { path, remote: remote || null, push });
} }
export function credLoad(key: string): Promise<StoredCredential | null> { export function credLoad(key: string): Promise<StoredCredential | null> {
return invoke<StoredCredential | null>("cred_load", { key }); return invoke<StoredCredential | null>("cred_load", { key });
} }
export function credSave(key: string, username: string, password: string): Promise<void> { export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
return invoke<void>("cred_save", { key, username, password }); return invoke<void>("cred_save", { key, username, password, mode });
} }
export function credDelete(key: string): Promise<void> { export function credDelete(key: string): Promise<void> {
+2
View File
@@ -178,6 +178,7 @@ export interface GitCommit {
refs: string[]; refs: string[];
parents: string[]; parents: string[];
files: GitCommitFile[]; files: GitCommitFile[];
has_note: boolean;
} }
export interface GitCommitFile { export interface GitCommitFile {
@@ -310,4 +311,5 @@ export interface ReflogEntry {
export interface StoredCredential { export interface StoredCredential {
username: string; username: string;
password: string; password: string;
mode?: "credentials" | "token";
} }