Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25d7f27d7a | ||
|
|
c5f058beb1 | ||
|
|
4181925047 | ||
|
|
927e0c3e92 | ||
|
|
0dac3e9f09 | ||
|
|
4f1131e855 | ||
|
|
f24c39c573 | ||
|
|
f487090b4b | ||
|
|
c8247f2b2a | ||
|
|
a7558e457d | ||
|
|
412e8d19b5 | ||
|
|
32832f5db7 | ||
|
|
37d2152fcd | ||
|
|
2bc74dc4a7 | ||
|
|
4db488415f | ||
|
|
be695d78a9 | ||
|
|
40311275b0 | ||
|
|
fe577d78a8 | ||
|
|
a27a8666ee | ||
|
|
c442b3735f |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
</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>
|
</div>
|
||||||
{/if}
|
</footer>
|
||||||
{/if}
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user