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 | ||
|
|
f621638eb3 | ||
|
|
56467f481d | ||
|
|
6434a9f10c | ||
|
|
cc0f1c076f | ||
|
|
b6b9964a93 | ||
|
|
f43fe00873 | ||
|
|
ca14fac90c | ||
|
|
608b3131d2 | ||
|
|
a823aabbb9 | ||
|
|
15d1f2bfd6 | ||
|
|
3eb554fee7 | ||
|
|
1fa57eea6f | ||
|
|
444a7acadd | ||
|
|
ac7cacd687 | ||
|
|
80dc66366d |
@@ -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,55 +395,62 @@ jobs:
|
|||||||
install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts"
|
install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts"
|
||||||
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"
|
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"
|
||||||
|
|
||||||
AUR_CHECKOUT_ROOT="$(mktemp -d)"
|
publish_aur_package() {
|
||||||
AUR_CHECKOUT=""
|
local package_name="$1"
|
||||||
for AUR_ATTEMPT in 1 2 3 4 5; do
|
local package_source_dir="$2"
|
||||||
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT"
|
local checkout_root checkout clone_candidate pushed
|
||||||
echo "AUR clone attempt $AUR_ATTEMPT of 5"
|
|
||||||
if timeout 3m git -c init.defaultBranch=master clone \
|
|
||||||
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then
|
|
||||||
AUR_CHECKOUT="$AUR_CLONE_CANDIDATE"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$AUR_ATTEMPT" -lt 5 ]; then
|
|
||||||
echo "AUR is unavailable; retrying after backoff"
|
|
||||||
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ -z "$AUR_CHECKOUT" ]; then
|
|
||||||
echo "Could not clone the AUR repository after 5 attempts" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/"
|
checkout_root="$(mktemp -d)"
|
||||||
|
checkout=""
|
||||||
cd "$AUR_CHECKOUT"
|
for AUR_ATTEMPT in 1 2 3 4 5; do
|
||||||
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
|
clone_candidate="$checkout_root/attempt-$AUR_ATTEMPT"
|
||||||
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
|
echo "$package_name clone attempt $AUR_ATTEMPT of 5"
|
||||||
git add PKGBUILD .SRCINFO
|
if timeout 3m git -c init.defaultBranch=master clone \
|
||||||
|
"ssh://aur@aur.archlinux.org/$package_name.git" "$clone_candidate"; then
|
||||||
if git diff --cached --quiet; then
|
checkout="$clone_candidate"
|
||||||
echo "AUR metadata already matches release $PACKAGE_VERSION"
|
break
|
||||||
exit 0
|
fi
|
||||||
fi
|
if [ "$AUR_ATTEMPT" -lt 5 ]; then
|
||||||
|
echo "AUR is unavailable; retrying after backoff"
|
||||||
git commit -m "Update to $PACKAGE_VERSION"
|
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
|
||||||
AUR_PUSHED=0
|
fi
|
||||||
for AUR_ATTEMPT in 1 2 3 4 5; do
|
done
|
||||||
echo "AUR push attempt $AUR_ATTEMPT of 5"
|
if [ -z "$checkout" ]; then
|
||||||
if timeout 3m git push origin HEAD:master; then
|
echo "Could not clone $package_name after 5 attempts" >&2
|
||||||
AUR_PUSHED=1
|
return 1
|
||||||
break
|
|
||||||
fi
|
fi
|
||||||
if [ "$AUR_ATTEMPT" -lt 5 ]; then
|
|
||||||
echo "AUR push failed; retrying after backoff"
|
cp "$package_source_dir/PKGBUILD" "$package_source_dir/.SRCINFO" "$checkout/"
|
||||||
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
|
git -C "$checkout" config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
|
||||||
|
git -C "$checkout" config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
|
||||||
|
git -C "$checkout" add PKGBUILD .SRCINFO
|
||||||
|
|
||||||
|
if git -C "$checkout" diff --cached --quiet; then
|
||||||
|
echo "$package_name already matches release $PACKAGE_VERSION"
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
if [ "$AUR_PUSHED" -ne 1 ]; then
|
git -C "$checkout" commit -m "Update to $PACKAGE_VERSION"
|
||||||
echo "Could not publish to AUR after 5 attempts" >&2
|
pushed=0
|
||||||
exit 1
|
for AUR_ATTEMPT in 1 2 3 4 5; do
|
||||||
fi
|
echo "$package_name push attempt $AUR_ATTEMPT of 5"
|
||||||
|
if timeout 3m git -C "$checkout" push origin HEAD:master; then
|
||||||
|
pushed=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$AUR_ATTEMPT" -lt 5 ]; then
|
||||||
|
echo "AUR push failed; retrying after backoff"
|
||||||
|
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$pushed" -ne 1 ]; then
|
||||||
|
echo "Could not publish $package_name after 5 attempts" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_aur_package gitty-desktop "$AUR_SOURCE_DIR"
|
||||||
|
publish_aur_package gitty-desktop-bin "$AUR_BIN_DIR"
|
||||||
|
|
||||||
publish-ubuntu:
|
publish-ubuntu:
|
||||||
name: Build and publish Ubuntu AppImage
|
name: Build and publish Ubuntu AppImage
|
||||||
|
|||||||
+99
-3
@@ -2,9 +2,101 @@
|
|||||||
|
|
||||||
All notable user-facing changes to Gitty are documented in this file.
|
All notable user-facing changes to Gitty are documented in this file.
|
||||||
|
|
||||||
The project uses calendar versions. Displayed release names use `YYYY.MM.DD`;
|
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||||
package metadata uses the equivalent numeric form without leading zeroes where
|
|
||||||
required by the package manager.
|
## [2026.8.4] - 2026-08-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Branch folders now have a context-menu action for deleting all contained
|
||||||
|
local or remote branches at once. The top-level remote folder such as
|
||||||
|
`origin` is protected, while its nested folders remain manageable. The
|
||||||
|
currently checked-out branch is kept,
|
||||||
|
and individual failures are reported after the remaining branches have been
|
||||||
|
processed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The Compare selector is fully localized in German and uses the same visual
|
||||||
|
language as the external-tool selectors for its fields, groups, typography,
|
||||||
|
and dialog surfaces.
|
||||||
|
- Repository-tab close buttons are square and have clearer spacing, hover
|
||||||
|
behavior, and keyboard-focus feedback.
|
||||||
|
|
||||||
|
## [2026.8.3] - 2026-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Configurable external tools for editors, diff viewers, merge tools,
|
||||||
|
terminals, and file managers, including automatic cross-platform discovery
|
||||||
|
and presets for VS Code, JetBrains IDEs, Beyond Compare, and other common
|
||||||
|
applications.
|
||||||
|
- Repository and file actions for opening content in the configured external
|
||||||
|
application. Supported tools open in a separate window.
|
||||||
|
- A choice between Gitty's internal diff/merge views and the configured
|
||||||
|
external applications.
|
||||||
|
- Git Notes support for attaching editable notes to commits without rewriting
|
||||||
|
commit history, including fetch and push synchronization.
|
||||||
|
- A command palette for quickly opening repository actions, files, and commits.
|
||||||
|
- Complete branch-to-branch comparisons for local and remote branches. The
|
||||||
|
comparison dialog shows every changed file and its side-by-side diff.
|
||||||
|
- Safe remote branch renaming from the branch context menu.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Redesigned the settings window with tool categories, detected applications,
|
||||||
|
preset dropdowns, and clearer explanations of where each tool is used.
|
||||||
|
- Redesigned the history graph's branch presentation with compact labels,
|
||||||
|
hover details, cleaner flag connectors, and branch visibility controls.
|
||||||
|
- Reduced the minimum width of the commit history panel so the workspace can
|
||||||
|
be resized more freely.
|
||||||
|
- Local-only branches are now identified consistently in the toolbar,
|
||||||
|
repository summary, status bar, and commit graph. Their first push is labeled
|
||||||
|
Publish and configures the remote tracking branch automatically.
|
||||||
|
- Git operations now run asynchronously to keep the application responsive
|
||||||
|
during slower repository commands.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Closing supported external tools no longer reports their documented
|
||||||
|
comparison result codes as application errors.
|
||||||
|
- External tools that otherwise reuse an existing process are explicitly
|
||||||
|
opened in a new window where supported.
|
||||||
|
- Remote branch renaming uses an atomic push with lease checks, preventing an
|
||||||
|
existing destination branch or a newly changed remote branch from being
|
||||||
|
overwritten.
|
||||||
|
|
||||||
|
## [2026.8.2] - 2026-08-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- History graph colors remain stable across parent lanes, making longer and
|
||||||
|
branching histories easier to follow.
|
||||||
|
- Release artifacts are published to the matching Gitea release automatically
|
||||||
|
without creating duplicate assets.
|
||||||
|
- Application shutdown now completes telemetry cleanup more reliably.
|
||||||
|
|
||||||
|
## [2026.8.1] - 2026-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Paginated commit history that loads older commits on demand instead of
|
||||||
|
limiting the visible repository history to the initial page.
|
||||||
|
- A dedicated file-history dialog opened from the explorer context menu.
|
||||||
|
- Windows and Ubuntu release publishing plus improved AUR packaging workflows.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- File history moved out of the permanent workspace panel into a focused,
|
||||||
|
larger dialog.
|
||||||
|
- Dialogs close more consistently with the Escape key.
|
||||||
|
- Arch Linux installation documentation now uses the `gitty-desktop` AUR
|
||||||
|
package.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- AUR SSH setup, package installation timeouts, and clone/push retries are more
|
||||||
|
robust in the release workflow.
|
||||||
|
|
||||||
## [2026.07.22] - 2026-07-22
|
## [2026.07.22] - 2026-07-22
|
||||||
|
|
||||||
@@ -88,3 +180,7 @@ required by the package manager.
|
|||||||
[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.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
|
||||||
|
|||||||
@@ -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.1",
|
"version": "2026.8.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.8.1",
|
"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.1",
|
"version": "2026.8.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-unminimize",
|
"core:window:allow-unminimize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-destroy",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1194
-184
File diff suppressed because it is too large
Load Diff
+28
-11
@@ -1,29 +1,35 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod badge;
|
mod badge;
|
||||||
|
mod external_tools;
|
||||||
mod git;
|
mod git;
|
||||||
mod telemetry;
|
mod telemetry;
|
||||||
|
|
||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
|
use external_tools::{
|
||||||
|
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||||
|
};
|
||||||
use git::{
|
use git::{
|
||||||
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||||
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||||
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||||
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_remote_branch, delete_tag,
|
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
|
||||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
|
||||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
last_commit_message, list_branches, list_commits, list_file_history,
|
||||||
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
|
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||||
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
|
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
||||||
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
|
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||||
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
|
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
|
||||||
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
||||||
|
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
||||||
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||||
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
|
||||||
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
|
||||||
|
update_remote,
|
||||||
};
|
};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||||
@@ -123,6 +129,10 @@ async fn main() {
|
|||||||
clone_repository,
|
clone_repository,
|
||||||
open_repo_in_explorer,
|
open_repo_in_explorer,
|
||||||
open_repository_file,
|
open_repository_file,
|
||||||
|
detect_external_tools,
|
||||||
|
launch_external_tool,
|
||||||
|
launch_external_diff,
|
||||||
|
launch_external_merge,
|
||||||
get_status,
|
get_status,
|
||||||
list_branches,
|
list_branches,
|
||||||
list_remotes,
|
list_remotes,
|
||||||
@@ -131,10 +141,12 @@ 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,
|
||||||
rename_branch,
|
rename_branch,
|
||||||
|
rename_remote_branch,
|
||||||
delete_branch,
|
delete_branch,
|
||||||
list_worktrees,
|
list_worktrees,
|
||||||
add_worktree,
|
add_worktree,
|
||||||
@@ -174,6 +186,11 @@ async fn main() {
|
|||||||
push,
|
push,
|
||||||
fetch,
|
fetch,
|
||||||
list_commits,
|
list_commits,
|
||||||
|
get_commit_note,
|
||||||
|
set_commit_note,
|
||||||
|
delete_commit_note,
|
||||||
|
fetch_commit_notes,
|
||||||
|
push_commit_notes,
|
||||||
restore_to_commit,
|
restore_to_commit,
|
||||||
restore_file_from_commit,
|
restore_file_from_commit,
|
||||||
merge_branch,
|
merge_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.1",
|
"version": "2026.8.4",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+1073
-270
File diff suppressed because it is too large
Load Diff
+918
-151
File diff suppressed because it is too large
Load Diff
@@ -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,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Code2,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
|
CloudOff,
|
||||||
Download,
|
Download,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
@@ -12,6 +14,7 @@
|
|||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
Terminal,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
export let hasRepository: boolean = false;
|
export let hasRepository: boolean = false;
|
||||||
@@ -19,7 +22,11 @@
|
|||||||
export let operation: string = "";
|
export let operation: string = "";
|
||||||
export let ahead: number = 0;
|
export let ahead: number = 0;
|
||||||
export let behind: number = 0;
|
export let behind: number = 0;
|
||||||
|
export let localOnly: boolean = false;
|
||||||
export let language: "en" | "de" = "en";
|
export let language: "en" | "de" = "en";
|
||||||
|
export let editorName: string = "Editor";
|
||||||
|
export let terminalName: string = "Terminal";
|
||||||
|
export let fileManagerName: string = "Explorer";
|
||||||
export let onFetch: () => void = () => {};
|
export let onFetch: () => void = () => {};
|
||||||
export let onPull: () => void = () => {};
|
export let onPull: () => void = () => {};
|
||||||
export let onPush: () => void = () => {};
|
export let onPush: () => void = () => {};
|
||||||
@@ -29,6 +36,8 @@
|
|||||||
export let onInteractiveRebase: () => void = () => {};
|
export let onInteractiveRebase: () => void = () => {};
|
||||||
export let onReflog: () => void = () => {};
|
export let onReflog: () => void = () => {};
|
||||||
export let onOpenInExplorer: () => void = () => {};
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
|
export let onOpenInEditor: () => void = () => {};
|
||||||
|
export let onOpenTerminal: () => void = () => {};
|
||||||
export let onFetchPrune: () => void = () => {};
|
export let onFetchPrune: () => void = () => {};
|
||||||
export let onForcePush: () => void = () => {};
|
export let onForcePush: () => void = () => {};
|
||||||
export let onSyncOptions: () => void = () => {};
|
export let onSyncOptions: () => void = () => {};
|
||||||
@@ -38,6 +47,12 @@
|
|||||||
let toolbarElement: HTMLDivElement;
|
let toolbarElement: HTMLDivElement;
|
||||||
|
|
||||||
$: isGerman = language === "de";
|
$: isGerman = language === "de";
|
||||||
|
$: pushLabel = localOnly ? (isGerman ? "Veröffentlichen" : "Publish") : "Push";
|
||||||
|
$: pushTitle = localOnly
|
||||||
|
? (isGerman
|
||||||
|
? "Dieser Branch existiert nur lokal. Veröffentlichen erstellt den Remote-Branch und richtet das Tracking ein."
|
||||||
|
: "This branch exists only locally. Publish creates the remote branch and configures tracking.")
|
||||||
|
: "Push";
|
||||||
|
|
||||||
function runHistoryAction(action: () => void) {
|
function runHistoryAction(action: () => void) {
|
||||||
historyOpen = false;
|
historyOpen = false;
|
||||||
@@ -96,10 +111,13 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="repo-action sync-primary"
|
class="repo-action sync-primary"
|
||||||
|
class:publish-local={localOnly}
|
||||||
onclick={onPush}
|
onclick={onPush}
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
title="Push"
|
title={pushTitle}
|
||||||
aria-label={ahead > 0
|
aria-label={localOnly
|
||||||
|
? pushTitle
|
||||||
|
: ahead > 0
|
||||||
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
||||||
: "Push"}
|
: "Push"}
|
||||||
>
|
>
|
||||||
@@ -108,8 +126,12 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Upload size={15} aria-hidden="true" />
|
<Upload size={15} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="repo-action-label">Push</span>
|
<span class="repo-action-label">{pushLabel}</span>
|
||||||
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
{#if localOnly}
|
||||||
|
<span class="repo-action-local-marker"><CloudOff size={9} aria-hidden="true" />{isGerman ? "NUR LOKAL" : "LOCAL"}</span>
|
||||||
|
{:else if ahead > 0}
|
||||||
|
<span class="repo-action-count ahead">↑{ahead}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<div class="repo-history-wrap">
|
<div class="repo-history-wrap">
|
||||||
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
|
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
|
||||||
@@ -144,8 +166,8 @@
|
|||||||
class="repo-action"
|
class="repo-action"
|
||||||
onclick={onCompare}
|
onclick={onCompare}
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||||
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
|
aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||||
>
|
>
|
||||||
<GitCompare size={15} aria-hidden="true" />
|
<GitCompare size={15} aria-hidden="true" />
|
||||||
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||||
@@ -192,15 +214,23 @@
|
|||||||
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="repo-action-group repo-utility-actions">
|
<div class="repo-action-group repo-utility-actions">
|
||||||
|
<button class="repo-action" onclick={onOpenInEditor} disabled={!hasRepository || isBusy} title={isGerman ? `Repository in ${editorName} öffnen` : `Open repository in ${editorName}`}>
|
||||||
|
<Code2 size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label utility-label">{editorName}</span>
|
||||||
|
</button>
|
||||||
|
<button class="repo-action" onclick={onOpenTerminal} disabled={!hasRepository || isBusy} title={isGerman ? `${terminalName} im Repository öffnen` : `Open ${terminalName} in repository`}>
|
||||||
|
<Terminal size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label utility-label">{terminalName}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="repo-action"
|
class="repo-action"
|
||||||
onclick={onOpenInExplorer}
|
onclick={onOpenInExplorer}
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
title={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||||
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
aria-label={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||||
>
|
>
|
||||||
<FolderOpen size={15} aria-hidden="true" />
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
<span class="repo-action-label utility-label">Explorer</span>
|
<span class="repo-action-label utility-label">{fileManagerName}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
const key = CRED_KEYS[target];
|
const key = CRED_KEYS[target];
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
await credSave(key, "api-key", trimmed, null);
|
await credSave(key, "api-key", trimmed);
|
||||||
} else {
|
} else {
|
||||||
await credDelete(key);
|
await credDelete(key);
|
||||||
}
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -1,22 +1,84 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
import {
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
CircleDashed,
|
||||||
|
Code2,
|
||||||
|
FolderOpen,
|
||||||
|
GitCompare,
|
||||||
|
GitMerge,
|
||||||
|
Languages,
|
||||||
|
Palette,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCw,
|
||||||
|
Settings2,
|
||||||
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Terminal,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import {
|
||||||
|
applyExternalToolPreset,
|
||||||
|
defaultExternalToolsSettings,
|
||||||
|
externalToolPresets,
|
||||||
|
isExternalToolPresetAvailable,
|
||||||
|
type ExternalToolKind,
|
||||||
|
type ExternalToolPreset,
|
||||||
|
} from "../externalTools";
|
||||||
|
import type {
|
||||||
|
AnalyticsSettings,
|
||||||
|
AppLanguage,
|
||||||
|
AppTheme,
|
||||||
|
DetectedExternalTool,
|
||||||
|
ExternalToolsSettings,
|
||||||
|
ToolOpenMode,
|
||||||
|
} from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
type SettingsPage = "general" | "tools";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
analytics: AnalyticsSettings;
|
analytics: AnalyticsSettings;
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
autoRefresh: boolean;
|
autoRefresh: boolean;
|
||||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
|
externalTools: ExternalToolsSettings;
|
||||||
|
detectedTools: DetectedExternalTool[];
|
||||||
|
detectionPending: boolean;
|
||||||
|
detectionUnavailable: boolean;
|
||||||
|
onRefreshDetectedTools: () => void | Promise<void>;
|
||||||
|
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props();
|
let {
|
||||||
|
analytics,
|
||||||
|
theme = "system",
|
||||||
|
language = "en",
|
||||||
|
autoRefresh = true,
|
||||||
|
externalTools,
|
||||||
|
detectedTools = [],
|
||||||
|
detectionPending = false,
|
||||||
|
detectionUnavailable = false,
|
||||||
|
onRefreshDetectedTools = () => {},
|
||||||
|
onSave = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
|
||||||
|
|
||||||
|
let activePage = $state<SettingsPage>("tools");
|
||||||
|
let activeToolKind = $state<ExternalToolKind>("editor");
|
||||||
|
let advancedOpen = $state(false);
|
||||||
let analyticsEnabled = $state(true);
|
let analyticsEnabled = $state(true);
|
||||||
let selectedTheme = $state<AppTheme>("system");
|
let selectedTheme = $state<AppTheme>("system");
|
||||||
let selectedLanguage = $state<AppLanguage>("en");
|
let selectedLanguage = $state<AppLanguage>("en");
|
||||||
let autoRefreshEnabled = $state(true);
|
let autoRefreshEnabled = $state(true);
|
||||||
|
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
|
||||||
const isGerman = $derived(selectedLanguage === "de");
|
const isGerman = $derived(selectedLanguage === "de");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -24,6 +86,7 @@
|
|||||||
selectedTheme = theme;
|
selectedTheme = theme;
|
||||||
selectedLanguage = language;
|
selectedLanguage = language;
|
||||||
autoRefreshEnabled = autoRefresh;
|
autoRefreshEnabled = autoRefresh;
|
||||||
|
tools = structuredClone(externalTools);
|
||||||
});
|
});
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
@@ -31,112 +94,482 @@
|
|||||||
...analytics,
|
...analytics,
|
||||||
enabled: analyticsEnabled,
|
enabled: analyticsEnabled,
|
||||||
noticeSeen: true,
|
noticeSeen: true,
|
||||||
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
|
}, selectedTheme, selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolLabel(kind: ExternalToolKind): string {
|
||||||
|
const labels = {
|
||||||
|
editor: "Editor",
|
||||||
|
diff: isGerman ? "Diff-Tool" : "Diff tool",
|
||||||
|
merge: isGerman ? "Merge-Tool" : "Merge tool",
|
||||||
|
terminal: "Terminal",
|
||||||
|
fileManager: isGerman ? "Dateimanager" : "File manager",
|
||||||
|
};
|
||||||
|
return labels[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolDescription(kind: ExternalToolKind): string {
|
||||||
|
const descriptions = isGerman
|
||||||
|
? {
|
||||||
|
editor: "Öffnet Repositories und einzelne Dateien zum Bearbeiten.",
|
||||||
|
diff: "Vergleicht eine Arbeitsdatei mit ihrer Version aus HEAD.",
|
||||||
|
merge: "Übergibt Base, Current, Incoming und Ergebnis an einen 3-Wege-Merger.",
|
||||||
|
terminal: "Startet eine Shell direkt im Repository-Verzeichnis.",
|
||||||
|
fileManager: "Öffnet das Repository im bevorzugten Dateimanager.",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
editor: "Opens repositories and individual files for editing.",
|
||||||
|
diff: "Compares a working file with its version from HEAD.",
|
||||||
|
merge: "Passes base, current, incoming, and result to a three-way merger.",
|
||||||
|
terminal: "Starts a shell directly in the repository directory.",
|
||||||
|
fileManager: "Opens the repository in your preferred file manager.",
|
||||||
|
};
|
||||||
|
return descriptions[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolUsage(kind: ExternalToolKind): string {
|
||||||
|
const usage = isGerman
|
||||||
|
? {
|
||||||
|
editor: "Oben in der Repository-Leiste oder über das Code-Symbol im Datei-Explorer.",
|
||||||
|
diff: "Datei im Explorer markieren und das Vergleichs-Symbol anklicken – alternativ Rechtsklick auf die Datei.",
|
||||||
|
merge: "Bei einem Konflikt „Konflikte lösen“ öffnen und anschließend dieses Merge-Tool starten.",
|
||||||
|
terminal: "Oben in der Repository-Leiste über den Terminal-Button.",
|
||||||
|
fileManager: "Oben in der Repository-Leiste über den Ordner-Button.",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
editor: "Use the repository toolbar or the code button in the file explorer.",
|
||||||
|
diff: "Select a file in Explorer and click the compare button, or right-click the file.",
|
||||||
|
merge: "Open Resolve conflicts and start this merge tool from the conflict view.",
|
||||||
|
terminal: "Use the terminal button in the repository toolbar.",
|
||||||
|
fileManager: "Use the folder button in the repository toolbar.",
|
||||||
|
};
|
||||||
|
return usage[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset): boolean {
|
||||||
|
return isExternalToolPresetAvailable(kind, preset, detectedTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
function availablePresets(kind: ExternalToolKind): ExternalToolPreset[] {
|
||||||
|
return externalToolPresets[kind].filter((preset) => presetAvailable(kind, preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
function otherPresets(kind: ExternalToolKind): ExternalToolPreset[] {
|
||||||
|
return externalToolPresets[kind].filter((preset) => !presetAvailable(kind, preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedPreset(kind: ExternalToolKind): ExternalToolPreset | undefined {
|
||||||
|
return externalToolPresets[kind].find((preset) => preset.id === tools[kind].preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedToolName(kind: ExternalToolKind): string {
|
||||||
|
return tools[kind].preset === "custom"
|
||||||
|
? tools[kind].program.split(/[\\/]/).pop() || (isGerman ? "Eigenes Programm" : "Custom application")
|
||||||
|
: selectedPreset(kind)?.label ?? tools[kind].program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMode(kind: "diff" | "merge"): ToolOpenMode {
|
||||||
|
return kind === "diff" ? tools.diffOpenMode : tools.mergeOpenMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpenMode(kind: "diff" | "merge", mode: ToolOpenMode) {
|
||||||
|
if (kind === "diff") tools.diffOpenMode = mode;
|
||||||
|
else tools.mergeOpenMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionAvailable(kind: ExternalToolKind): boolean {
|
||||||
|
if (tools[kind].preset === "custom") return tools[kind].program.trim().length > 0;
|
||||||
|
const preset = selectedPreset(kind);
|
||||||
|
return preset ? presetAvailable(kind, preset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionStatus(kind: ExternalToolKind): string {
|
||||||
|
if (tools[kind].preset === "custom") {
|
||||||
|
return tools[kind].program.trim()
|
||||||
|
? (isGerman ? "Manuell konfiguriert" : "Manually configured")
|
||||||
|
: (isGerman ? "Programmpfad fehlt" : "Application path missing");
|
||||||
|
}
|
||||||
|
return selectionAvailable(kind)
|
||||||
|
? (isGerman ? "Installiert und verfügbar" : "Installed and available")
|
||||||
|
: (isGerman ? "Nicht automatisch erkannt" : "Not automatically detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePreset(kind: ExternalToolKind, id: string) {
|
||||||
|
if (id === "custom") {
|
||||||
|
tools[kind] = { ...tools[kind], preset: "custom" };
|
||||||
|
advancedOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tools[kind] = applyExternalToolPreset(kind, id, detectedTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectToolKind(kind: ExternalToolKind) {
|
||||||
|
activeToolKind = kind;
|
||||||
|
advancedOpen = tools[kind].preset === "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgram(kind: ExternalToolKind, program: string) {
|
||||||
|
tools[kind] = { ...tools[kind], preset: "custom", program };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateArgs(kind: ExternalToolKind, value: string) {
|
||||||
|
tools[kind] = {
|
||||||
|
...tools[kind],
|
||||||
|
preset: "custom",
|
||||||
|
args: value.split("\n").map((arg) => arg.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseProgram(kind: ExternalToolKind) {
|
||||||
|
const selected = await open({
|
||||||
|
title: isGerman ? `${toolLabel(kind)} auswählen` : `Choose ${toolLabel(kind)}`,
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
updateProgram(kind, selected);
|
||||||
|
advancedOpen = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="app-settings-head">
|
||||||
<div>
|
<div class="app-settings-title">
|
||||||
<span class="eyebrow">Gitty</span>
|
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
|
||||||
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
|
<div>
|
||||||
|
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||||
|
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
|
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Einstellungen schließen" : "Close settings"}>
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
<form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||||
<section class="settings-section">
|
<div class="app-settings-body">
|
||||||
<header>
|
<nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
|
||||||
<Settings size={16} aria-hidden="true" />
|
<button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
|
||||||
<div>
|
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||||
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
|
<span>
|
||||||
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
|
<strong>{isGerman ? "Allgemein" : "General"}</strong>
|
||||||
</div>
|
<small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
|
||||||
</header>
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class:active={activePage === "tools"} onclick={() => { activePage = "tools"; }}>
|
||||||
|
<Wrench size={16} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{isGerman ? "Externe Tools" : "External tools"}</strong>
|
||||||
|
<small>{isGerman ? "Editor, Diff & Terminal" : "Editor, diff & terminal"}</small>
|
||||||
|
</span>
|
||||||
|
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
<div class="settings-nav-note">
|
||||||
<label class:active={selectedTheme === "system"}>
|
<ShieldCheck size={15} aria-hidden="true" />
|
||||||
<input type="radio" bind:group={selectedTheme} value="system" />
|
<p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p>
|
||||||
<span>System</span>
|
</div>
|
||||||
</label>
|
</nav>
|
||||||
<label class:active={selectedTheme === "light"}>
|
|
||||||
<input type="radio" bind:group={selectedTheme} value="light" />
|
<div class="settings-content">
|
||||||
<span>{isGerman ? "Hell" : "Light"}</span>
|
{#if activePage === "general"}
|
||||||
</label>
|
<div class="settings-page-head">
|
||||||
<label class:active={selectedTheme === "dark"}>
|
<div>
|
||||||
<input type="radio" bind:group={selectedTheme} value="dark" />
|
<h3>{isGerman ? "Allgemein" : "General"}</h3>
|
||||||
<span>{isGerman ? "Dunkel" : "Dark"}</span>
|
<p>{isGerman ? "Darstellung, Sprache und Hintergrundverhalten." : "Appearance, language, and background behavior."}</p>
|
||||||
</label>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="general-settings-grid">
|
||||||
|
<section class="general-setting-panel">
|
||||||
|
<header><Palette size={16} /><div><h4>{isGerman ? "Farbschema" : "Theme"}</h4><p>{isGerman ? "Passend zu deiner Umgebung." : "Match your environment."}</p></div></header>
|
||||||
|
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||||
|
<label class:active={selectedTheme === "system"}><input type="radio" bind:group={selectedTheme} value="system" /><span>System</span></label>
|
||||||
|
<label class:active={selectedTheme === "light"}><input type="radio" bind:group={selectedTheme} value="light" /><span>{isGerman ? "Hell" : "Light"}</span></label>
|
||||||
|
<label class:active={selectedTheme === "dark"}><input type="radio" bind:group={selectedTheme} value="dark" /><span>{isGerman ? "Dunkel" : "Dark"}</span></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="general-setting-panel">
|
||||||
|
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
|
||||||
|
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||||
|
<label class:active={selectedLanguage === "en"}><input type="radio" bind:group={selectedLanguage} value="en" /><span>EN · English</span></label>
|
||||||
|
<label class:active={selectedLanguage === "de"}><input type="radio" bind:group={selectedLanguage} value="de" /><span>DE · Deutsch</span></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="general-setting-panel general-setting-wide">
|
||||||
|
<header><RefreshCw size={16} /><div><h4>{isGerman ? "Repository-Aktualisierung" : "Repository refresh"}</h4><p>{isGerman ? "Arbeitsbereich und Remotes aktuell halten." : "Keep the working tree and remotes current."}</p></div></header>
|
||||||
|
<label class="settings-switch-row">
|
||||||
|
<span><strong>{isGerman ? "Automatisch aktualisieren" : "Refresh automatically"}</strong><small>{isGerman ? "Branch-Status und Änderungen regelmäßig im Hintergrund prüfen." : "Periodically check branch state and working-tree changes."}</small></span>
|
||||||
|
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="general-setting-panel general-setting-wide">
|
||||||
|
<header><ShieldCheck size={16} /><div><h4>{isGerman ? "Datenschutz" : "Privacy"}</h4><p>{isGerman ? "Anonyme Produkt- und Fehlerdiagnose." : "Anonymous product and error diagnostics."}</p></div></header>
|
||||||
|
<label class="settings-switch-row">
|
||||||
|
<span><strong>{isGerman ? "Anonyme Analytics erlauben" : "Allow anonymous analytics"}</strong><small>{isGerman ? "Keine Pfade, Remotes, Branches, Diffs, Zugangsdaten oder Quelltexte." : "No paths, remotes, branches, diffs, credentials, or source code."}</small></span>
|
||||||
|
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="settings-page-head tools-page-head">
|
||||||
|
<div>
|
||||||
|
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
|
||||||
|
<p>
|
||||||
|
{detectionUnavailable
|
||||||
|
? (isGerman ? "Automatische Erkennung ist in dieser Umgebung nicht verfügbar." : "Automatic detection is unavailable in this environment.")
|
||||||
|
: (isGerman ? `${detectedTools.length} installierte Programme erkannt.` : `${detectedTools.length} installed applications detected.`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="tool-rescan-button" type="button" onclick={onRefreshDetectedTools} disabled={detectionPending}>
|
||||||
|
<RotateCw class={detectionPending ? "spin" : ""} size={14} aria-hidden="true" />
|
||||||
|
{detectionPending ? (isGerman ? "Erkennung läuft…" : "Detecting…") : (isGerman ? "Neu erkennen" : "Detect again")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-kind-tabs" role="tablist" aria-label={isGerman ? "Tool-Kategorie" : "Tool category"}>
|
||||||
|
{#each toolKinds as kind}
|
||||||
|
<button type="button" role="tab" aria-selected={activeToolKind === kind} class:active={activeToolKind === kind} onclick={() => selectToolKind(kind)}>
|
||||||
|
{#if kind === "editor"}<Code2 size={16} />
|
||||||
|
{:else if kind === "diff"}<GitCompare size={16} />
|
||||||
|
{:else if kind === "merge"}<GitMerge size={16} />
|
||||||
|
{:else if kind === "terminal"}<Terminal size={16} />
|
||||||
|
{:else}<FolderOpen size={16} />{/if}
|
||||||
|
<span>{toolLabel(kind)}</span>
|
||||||
|
<small class:available={selectionAvailable(kind)}></small>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="tool-config-panel" aria-label={`${toolLabel(activeToolKind)} ${isGerman ? "konfigurieren" : "configuration"}`}>
|
||||||
|
<div class="tool-config-summary">
|
||||||
|
<span class="tool-config-icon">
|
||||||
|
{#if activeToolKind === "editor"}<Code2 size={22} />
|
||||||
|
{:else if activeToolKind === "diff"}<GitCompare size={22} />
|
||||||
|
{:else if activeToolKind === "merge"}<GitMerge size={22} />
|
||||||
|
{:else if activeToolKind === "terminal"}<Terminal size={22} />
|
||||||
|
{:else}<FolderOpen size={22} />{/if}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4>{toolLabel(activeToolKind)}</h4>
|
||||||
|
<p>{toolDescription(activeToolKind)}</p>
|
||||||
|
</div>
|
||||||
|
<span class="tool-status" class:available={selectionAvailable(activeToolKind)}>
|
||||||
|
{#if selectionAvailable(activeToolKind)}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}
|
||||||
|
{selectionStatus(activeToolKind)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeToolKind === "diff" || activeToolKind === "merge"}
|
||||||
|
{@const openModeKind = activeToolKind as "diff" | "merge"}
|
||||||
|
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardansicht" : "Current default view"}>
|
||||||
|
<span>{isGerman ? "Standard" : "Default"}</span><ChevronRight size={15} aria-hidden="true" />
|
||||||
|
<strong>{openMode(openModeKind) === "gitty" ? (isGerman ? "Gitty · integriert" : "Gitty · built in") : selectedToolName(activeToolKind)}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="tool-open-mode">
|
||||||
|
<legend>{isGerman ? "Beim Öffnen verwenden" : "Use when opening"}</legend>
|
||||||
|
<div>
|
||||||
|
<button type="button" class:active={openMode(openModeKind) === "gitty"} aria-pressed={openMode(openModeKind) === "gitty"} onclick={() => setOpenMode(openModeKind, "gitty")}>
|
||||||
|
<span>Gitty</span><small>{isGerman ? "Integrierte Ansicht" : "Built-in view"}</small>
|
||||||
|
</button>
|
||||||
|
<button type="button" class:active={openMode(openModeKind) === "external"} aria-pressed={openMode(openModeKind) === "external"} onclick={() => setOpenMode(openModeKind, "external")}>
|
||||||
|
<span>{selectedToolName(activeToolKind)}</span><small>{isGerman ? "Externes Programm" : "External application"}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{:else}
|
||||||
|
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardzuordnung" : "Current default mapping"}>
|
||||||
|
<span>Gitty</span><ChevronRight size={15} aria-hidden="true" /><strong>{selectedToolName(activeToolKind)}</strong>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="tool-default-field">
|
||||||
|
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
|
||||||
|
<SelectMenu
|
||||||
|
class="tool-preset-select"
|
||||||
|
value={tools[activeToolKind].preset}
|
||||||
|
options={[
|
||||||
|
...availablePresets(activeToolKind).map((preset) => ({ value: preset.id, label: `Installed - ${preset.label}`, group: isGerman ? "Installiert" : "Installed" })),
|
||||||
|
...otherPresets(activeToolKind).map((preset) => ({ value: preset.id, label: preset.label, group: isGerman ? "Weitere unterstützte Programme" : "Other supported applications" })),
|
||||||
|
{ value: "custom", label: isGerman ? "Eigenes Programm auswählen..." : "Choose a custom application..." },
|
||||||
|
]}
|
||||||
|
ariaLabel={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}
|
||||||
|
onChange={(value) => changePreset(activeToolKind, value)}
|
||||||
|
/>
|
||||||
|
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="tool-usage-callout">
|
||||||
|
<span>{isGerman ? "So öffnest du es" : "How to open it"}</span>
|
||||||
|
<p>{toolUsage(activeToolKind)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="tool-advanced-toggle" type="button" aria-expanded={advancedOpen} onclick={() => { advancedOpen = !advancedOpen; }}>
|
||||||
|
<span>{isGerman ? "Programmpfad und Argumente" : "Application path and arguments"}</span>
|
||||||
|
{#if advancedOpen}<ChevronDown size={15} />{:else}<ChevronRight size={15} />{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if advancedOpen}
|
||||||
|
<div class="tool-advanced-panel">
|
||||||
|
<label>
|
||||||
|
<span>{isGerman ? "Programmpfad" : "Application path"}</span>
|
||||||
|
<div class="tool-program-row">
|
||||||
|
<input value={tools[activeToolKind].program} oninput={(event) => updateProgram(activeToolKind, event.currentTarget.value)} spellcheck="false" />
|
||||||
|
<button type="button" onclick={() => browseProgram(activeToolKind)} title={isGerman ? "Programm auswählen" : "Choose application"} aria-label={isGerman ? "Programm auswählen" : "Choose application"}><FolderOpen size={15} /></button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{isGerman ? "Argumente · eine Zeile pro Argument" : "Arguments · one per line"}</span>
|
||||||
|
<textarea value={tools[activeToolKind].args.join("\n")} oninput={(event) => updateArgs(activeToolKind, event.currentTarget.value)} spellcheck="false"></textarea>
|
||||||
|
</label>
|
||||||
|
<p class="tool-placeholders">
|
||||||
|
<span>{isGerman ? "Verfügbare Platzhalter" : "Available placeholders"}</span>
|
||||||
|
<code>{"{repo}"}</code><code>{"{file}"}</code><code>{"{parent}"}</code><code>{"{left}"}</code><code>{"{right}"}</code><code>{"{base}"}</code><code>{"{ours}"}</code><code>{"{theirs}"}</code><code>{"{result}"}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-section">
|
|
||||||
<header>
|
|
||||||
<RefreshCw size={16} aria-hidden="true" />
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Repository</span>
|
|
||||||
<h3>{isGerman ? "Automatische Aktualisierung" : "Auto refresh"}</h3>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<label class="settings-toggle-row">
|
|
||||||
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
|
||||||
<span>
|
|
||||||
<strong>{isGerman ? "Repositories automatisch aktualisieren" : "Refresh repositories automatically"}</strong>
|
|
||||||
<small>{isGerman ? "Aktualisiert Arbeitsbereich, Branch-Status und Remotes regelmäßig im Hintergrund." : "Periodically refreshes the working tree, branch status, and remotes in the background."}</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-section">
|
|
||||||
<header>
|
|
||||||
<Languages size={16} aria-hidden="true" />
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
|
|
||||||
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
|
||||||
<label class:active={selectedLanguage === "en"}>
|
|
||||||
<input type="radio" bind:group={selectedLanguage} value="en" />
|
|
||||||
<span>EN · English</span>
|
|
||||||
</label>
|
|
||||||
<label class:active={selectedLanguage === "de"}>
|
|
||||||
<input type="radio" bind:group={selectedLanguage} value="de" />
|
|
||||||
<span>DE · Deutsch</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-section">
|
|
||||||
<header>
|
|
||||||
<Settings size={16} aria-hidden="true" />
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Analytics</span>
|
|
||||||
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<label class="settings-toggle-row">
|
|
||||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
|
||||||
<span>
|
|
||||||
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
|
|
||||||
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="new-branch-actions">
|
|
||||||
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
|
||||||
<button class="btn-primary" type="submit">
|
|
||||||
<Check size={16} aria-hidden="true" />
|
|
||||||
{isGerman ? "Speichern" : "Save"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-settings-footer">
|
||||||
|
<span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span>
|
||||||
|
<div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||||
|
<button class="btn-primary" type="submit"><Check size={16} aria-hidden="true" />{isGerman ? "Änderungen speichern" : "Save changes"}</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-settings-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(920px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; }
|
||||||
|
.app-settings-head { display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 14px 18px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
|
.app-settings-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.app-settings-mark { display: grid; place-items: center; 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); }
|
||||||
|
.app-settings-title h2, .settings-page-head h3, .tool-config-summary h4, .general-setting-panel h4 { margin: 0; color: var(--color-ink); }
|
||||||
|
.app-settings-title h2 { font-size: 18px; line-height: 1.2; }
|
||||||
|
.app-settings-title p, .settings-page-head p, .tool-config-summary p, .general-setting-panel p { margin: 0; color: var(--color-ink-dim); }
|
||||||
|
.app-settings-title p { margin-top: 3px; font-size: 11px; }
|
||||||
|
.app-settings-shell { display: grid; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; }
|
||||||
|
.app-settings-body { display: grid; min-height: 0; grid-template-columns: 205px minmax(0, 1fr); }
|
||||||
|
.settings-nav { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 12px; border-right: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 72%, var(--app-dialog-bg)); }
|
||||||
|
.settings-nav > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||||
|
.settings-nav > button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.settings-nav > button.active { border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||||
|
.settings-nav button > :global(svg) { color: var(--color-ink-muted); }
|
||||||
|
.settings-nav button.active > :global(svg) { color: var(--color-accent); }
|
||||||
|
.settings-nav button span { display: grid; min-width: 0; gap: 2px; }
|
||||||
|
.settings-nav button strong { font-size: 12px; }
|
||||||
|
.settings-nav button small { overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.settings-nav button em { display: grid; place-items: center; min-width: 21px; 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; font-style: normal; font-weight: 800; }
|
||||||
|
.settings-nav-note { display: flex; align-items: flex-start; gap: 8px; margin-top: auto; padding: 10px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); }
|
||||||
|
.settings-nav-note :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-success); }
|
||||||
|
.settings-nav-note p { margin: 0; font-size: 9.5px; line-height: 1.45; }
|
||||||
|
.settings-content { min-width: 0; overflow: auto; padding: 18px 20px 22px; }
|
||||||
|
.settings-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||||
|
.settings-page-head h3 { font-size: 18px; }
|
||||||
|
.settings-page-head p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
|
||||||
|
.tool-rescan-button { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 10px; font-weight: 750; }
|
||||||
|
.tool-rescan-button:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||||
|
.tool-kind-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 14px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||||
|
.tool-kind-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 38px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||||
|
.tool-kind-tabs button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.tool-kind-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
||||||
|
.tool-kind-tabs button.active :global(svg) { color: var(--color-accent); }
|
||||||
|
.tool-kind-tabs button small { position: absolute; top: 5px; right: 6px; width: 5px; height: 5px; border-radius: 50%; background: var(--color-ink-faint); }
|
||||||
|
.tool-kind-tabs button small.available { background: var(--color-success); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 15%, transparent); }
|
||||||
|
.tool-config-panel { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
||||||
|
.tool-config-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
|
||||||
|
.tool-config-icon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--color-accent) 25%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, transparent); }
|
||||||
|
.tool-config-summary h4 { font-size: 14px; }
|
||||||
|
.tool-config-summary p { margin-top: 3px; font-size: 10.5px; line-height: 1.4; }
|
||||||
|
.tool-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
|
||||||
|
.tool-status.available { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
|
||||||
|
.tool-route { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 7px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-faint); background: var(--color-surface-raised); font-size: 10.5px; }
|
||||||
|
.tool-route strong { color: var(--color-ink); }
|
||||||
|
.tool-open-mode { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
|
.tool-open-mode legend { margin-bottom: 6px; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||||
|
.tool-open-mode > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||||
|
.tool-open-mode button { display: grid; justify-items: start; gap: 2px; min-width: 0; min-height: 46px; padding: 7px 10px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||||
|
.tool-open-mode button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.tool-open-mode button.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||||
|
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
|
||||||
|
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||||
|
.tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
|
||||||
|
:global(.tool-preset-select .select-menu-trigger) { height: 38px; min-height: 38px; padding: 0 11px; border-color: var(--color-border); font-size: 12px; font-weight: 700; }
|
||||||
|
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
|
||||||
|
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
||||||
|
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.tool-usage-callout p { margin: 0; color: var(--color-ink-muted); font-size: 10.5px; line-height: 1.45; }
|
||||||
|
.tool-advanced-toggle { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 0; border: 0; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||||
|
.tool-advanced-toggle:hover { color: var(--color-ink); }
|
||||||
|
.tool-advanced-panel { display: grid; gap: 11px; padding-top: 2px; }
|
||||||
|
.tool-program-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 6px; }
|
||||||
|
.tool-advanced-panel input { height: 34px; padding: 0 9px; font-family: var(--font-mono); font-size: 10.5px; }
|
||||||
|
.tool-advanced-panel textarea { min-height: 80px; padding: 8px 9px; resize: vertical; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; }
|
||||||
|
.tool-program-row button { display: grid; place-items: center; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
|
||||||
|
.tool-program-row button:hover { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||||
|
.tool-placeholders { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 0; color: var(--color-ink-faint); font-size: 9px; }
|
||||||
|
.tool-placeholders span { margin-right: 3px; }
|
||||||
|
.tool-placeholders code { padding: 2px 4px; border-radius: 4px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 9%, transparent); }
|
||||||
|
.general-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.general-setting-panel { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 14px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||||
|
.general-setting-panel.general-setting-wide { grid-column: 1 / -1; }
|
||||||
|
.general-setting-panel > header { display: flex; align-items: flex-start; gap: 9px; }
|
||||||
|
.general-setting-panel > header > :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
|
||||||
|
.general-setting-panel h4 { font-size: 12.5px; }
|
||||||
|
.general-setting-panel p { margin-top: 3px; font-size: 9.5px; }
|
||||||
|
.settings-segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||||
|
.settings-segmented.settings-language { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
|
||||||
|
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||||
|
.settings-switch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; }
|
||||||
|
.settings-switch-row span { display: grid; gap: 3px; }
|
||||||
|
.settings-switch-row strong { color: var(--color-ink); font-size: 11px; }
|
||||||
|
.settings-switch-row small { color: var(--color-ink-dim); font-size: 9.5px; line-height: 1.4; }
|
||||||
|
.settings-switch-row input { width: 32px; height: 18px; accent-color: var(--color-accent); }
|
||||||
|
.app-settings-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); }
|
||||||
|
.app-settings-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
|
||||||
|
.app-settings-footer > div { display: flex; gap: 8px; }
|
||||||
|
.app-settings-footer button { min-height: 32px; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.app-settings-dialog { height: min(760px, calc(100vh - 20px)); width: min(660px, calc(100vw - 20px)); }
|
||||||
|
.app-settings-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.settings-nav { flex-direction: row; padding: 8px 10px; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
|
.settings-nav > button { width: auto; min-width: 0; flex: 1 1 0; min-height: 42px; }
|
||||||
|
.settings-nav-note { display: none; }
|
||||||
|
.settings-content { padding: 14px; }
|
||||||
|
.tool-kind-tabs { grid-template-columns: repeat(5, minmax(42px, 1fr)); overflow-x: auto; }
|
||||||
|
.tool-kind-tabs button { height: 40px; }
|
||||||
|
.tool-kind-tabs button span { display: none; }
|
||||||
|
.tool-config-summary { grid-template-columns: auto minmax(0, 1fr); }
|
||||||
|
.tool-status { grid-column: 1 / -1; justify-self: start; }
|
||||||
|
.app-settings-footer > span { display: none; }
|
||||||
|
.app-settings-footer { justify-content: flex-end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.app-settings-head { min-height: 58px; padding: 10px 12px; }
|
||||||
|
.app-settings-mark { width: 34px; height: 34px; }
|
||||||
|
.settings-nav button small, .settings-nav button em { display: none; }
|
||||||
|
.settings-nav > button { grid-template-columns: auto minmax(0, 1fr); }
|
||||||
|
.settings-page-head { align-items: stretch; flex-direction: column; }
|
||||||
|
.tool-rescan-button { align-self: flex-start; }
|
||||||
|
.general-settings-grid { grid-template-columns: 1fr; }
|
||||||
|
.general-setting-panel.general-setting-wide { grid-column: auto; }
|
||||||
|
.tool-config-panel { padding: 13px; }
|
||||||
|
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, 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 {
|
||||||
@@ -49,12 +52,14 @@
|
|||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onCheckout: (branch: GitBranchInfo) => void;
|
onCheckout: (branch: GitBranchInfo) => void;
|
||||||
|
onCompareBranch: (branch: GitBranchInfo) => void;
|
||||||
onMerge: (branch: GitBranchInfo) => void;
|
onMerge: (branch: GitBranchInfo) => void;
|
||||||
onRebase: (branch: GitBranchInfo) => void;
|
onRebase: (branch: GitBranchInfo) => void;
|
||||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
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>;
|
||||||
@@ -72,12 +77,14 @@
|
|||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
|
onCompareBranch = () => {},
|
||||||
onMerge = () => {},
|
onMerge = () => {},
|
||||||
onRebase = () => {},
|
onRebase = () => {},
|
||||||
onCreateBranch = () => {},
|
onCreateBranch = () => {},
|
||||||
onRenameBranch = () => {},
|
onRenameBranch = () => {},
|
||||||
onDeleteBranch = () => {},
|
onDeleteBranch = () => {},
|
||||||
onDeleteRemoteBranch = () => {},
|
onDeleteRemoteBranch = () => {},
|
||||||
|
onDeleteBranchFolder = () => {},
|
||||||
onCreateTag = () => {},
|
onCreateTag = () => {},
|
||||||
onDeleteTag = () => {},
|
onDeleteTag = () => {},
|
||||||
onPushTag = () => {},
|
onPushTag = () => {},
|
||||||
@@ -97,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());
|
||||||
@@ -117,6 +126,7 @@
|
|||||||
children: [],
|
children: [],
|
||||||
branchCount: 0,
|
branchCount: 0,
|
||||||
current: false,
|
current: false,
|
||||||
|
branches: [],
|
||||||
folders: new Map(),
|
folders: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -143,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,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)) {
|
||||||
@@ -241,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) - 190);
|
|
||||||
|
|
||||||
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() {
|
||||||
@@ -268,6 +308,13 @@
|
|||||||
await onRenameBranch(branch);
|
await onRenameBranch(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareContextBranch() {
|
||||||
|
const branch = contextBranch;
|
||||||
|
if (!branch || isBusy) return;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
onCompareBranch(branch);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteContextBranch() {
|
async function deleteContextBranch() {
|
||||||
const branch = contextBranch;
|
const branch = contextBranch;
|
||||||
if (!branch || branch.current || isBusy) return;
|
if (!branch || branch.current || isBusy) return;
|
||||||
@@ -275,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();
|
||||||
@@ -326,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() {
|
||||||
@@ -372,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>
|
||||||
@@ -462,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})`}
|
||||||
>
|
>
|
||||||
@@ -529,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})`}
|
||||||
>
|
>
|
||||||
@@ -667,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"
|
||||||
@@ -677,6 +734,10 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Checkout
|
Checkout
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
|
||||||
|
<GitCompare size={14} aria-hidden="true" />
|
||||||
|
Compare with...
|
||||||
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
||||||
<GitMerge size={14} aria-hidden="true" />
|
<GitMerge size={14} aria-hidden="true" />
|
||||||
Merge into current
|
Merge into current
|
||||||
@@ -690,9 +751,9 @@
|
|||||||
Open in new worktree
|
Open in new worktree
|
||||||
</button>
|
</button>
|
||||||
<div class="menu-separator" role="separator"></div>
|
<div class="menu-separator" role="separator"></div>
|
||||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||||
<Pencil size={14} aria-hidden="true" />
|
<Pencil size={14} aria-hidden="true" />
|
||||||
Rename
|
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="danger"
|
class="danger"
|
||||||
@@ -708,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"
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
|
||||||
|
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
|
||||||
|
|
||||||
|
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
|
||||||
|
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
|
||||||
|
interface Props {
|
||||||
|
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
|
||||||
|
branches: GitBranchInfo[]; files: GitRepositoryFile[]; commits: GitCommit[];
|
||||||
|
onClose: () => void;
|
||||||
|
onCheckoutBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
|
onOpenFile: (file: GitRepositoryFile) => void | Promise<void>;
|
||||||
|
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
|
||||||
|
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
|
||||||
|
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
|
||||||
|
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
|
||||||
|
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
|
||||||
|
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
|
||||||
|
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
|
||||||
|
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
|
||||||
|
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
|
||||||
|
onOpenAiSettings = () => {}, onOpenHelp = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let query = $state("");
|
||||||
|
let activeIndex = $state(0);
|
||||||
|
let inputElement = $state<HTMLInputElement | null>(null);
|
||||||
|
let listElement = $state<HTMLElement | null>(null);
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
const repositoryActionDisabled = $derived(!hasRepository || isBusy);
|
||||||
|
|
||||||
|
function action(id: string, kind: ItemKind, title: string, subtitle: string, run: () => void | Promise<void>, requiresRepository = true): Item {
|
||||||
|
return { id, group: isGerman ? "Aktionen" : "Actions", kind, title, subtitle, search: `${title} ${subtitle}`.toLowerCase(), disabled: requiresRepository ? repositoryActionDisabled : false, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionItems = $derived([
|
||||||
|
action("fetch", "fetch", "Fetch", isGerman ? "Remote-Änderungen abrufen" : "Download remote changes", onFetch),
|
||||||
|
action("pull", "pull", "Pull", isGerman ? "Änderungen abrufen und integrieren" : "Download and integrate changes", onPull),
|
||||||
|
action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush),
|
||||||
|
action("refresh", "refresh", isGerman ? "Repository aktualisieren" : "Refresh repository", isGerman ? "Status und Historie neu laden" : "Reload status and history", onRefresh),
|
||||||
|
action("search", "search", isGerman ? "Globale Codesuche" : "Global code search", isGerman ? "Code und Dateihistorie durchsuchen" : "Search code and file history", onOpenSearch),
|
||||||
|
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
|
||||||
|
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
|
||||||
|
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
|
||||||
|
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
|
||||||
|
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
|
||||||
|
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
|
||||||
|
action("ai-settings", "ai-settings", "AI Settings", isGerman ? "Provider und Modell konfigurieren" : "Configure provider and model", onOpenAiSettings, false),
|
||||||
|
action("help", "help", isGerman ? "Hilfe öffnen" : "Open help", isGerman ? "Git-Dokumentation und Tastenkürzel" : "Git documentation and keyboard shortcuts", onOpenHelp, false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dynamicItems = $derived.by(() => {
|
||||||
|
if (!hasRepository) return [];
|
||||||
|
const branchItems: Item[] = branches.map((branch) => ({
|
||||||
|
id: `branch:${branch.remote ? "remote" : "local"}:${branch.name}`, group: "Branches", kind: "branch", title: branch.name,
|
||||||
|
subtitle: branch.current ? (isGerman ? "Aktueller Branch" : "Current branch") : branch.remote ? (isGerman ? "Remote-Branch auschecken" : "Check out remote branch") : (isGerman ? "Branch auschecken" : "Check out branch"),
|
||||||
|
search: `${branch.name} branch ${branch.remote ? "remote" : "local"}`.toLowerCase(), disabled: isBusy || branch.current, run: () => onCheckoutBranch(branch),
|
||||||
|
}));
|
||||||
|
const fileItems: Item[] = files.map((file) => ({
|
||||||
|
id: `file:${file.path}`, group: isGerman ? "Dateien" : "Files", kind: "file", title: file.path.split(/[\\/]/).pop() ?? file.path,
|
||||||
|
subtitle: file.path, search: `${file.path} file datei`.toLowerCase(), disabled: isBusy, run: () => onOpenFile(file),
|
||||||
|
}));
|
||||||
|
const commitItems: Item[] = commits.map((commit) => ({
|
||||||
|
id: `commit:${commit.hash}`, group: isGerman ? "Geladene Commits" : "Loaded commits", kind: "commit", title: commit.summary || (isGerman ? "Ohne Commit-Nachricht" : "No commit message"),
|
||||||
|
subtitle: `${commit.short_hash} · ${commit.author_name}`, search: `${commit.hash} ${commit.short_hash} ${commit.summary} ${commit.author_name} ${commit.author_email} ${commit.refs.join(" ")}`.toLowerCase(), run: () => onSelectCommit(commit),
|
||||||
|
}));
|
||||||
|
return [...branchItems, ...fileItems, ...commitItems];
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleItems = $derived.by(() => {
|
||||||
|
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
const allItems = [...actionItems, ...dynamicItems];
|
||||||
|
if (terms.length === 0) return allItems.slice(0, 35);
|
||||||
|
return allItems.filter((item) => terms.every((term) => item.search.includes(term))).slice(0, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => { query; activeIndex = 0; });
|
||||||
|
$effect(() => { if (activeIndex >= visibleItems.length) activeIndex = Math.max(0, visibleItems.length - 1); });
|
||||||
|
$effect(() => { activeIndex; queueMicrotask(() => listElement?.querySelector<HTMLElement>("[data-active='true']")?.scrollIntoView({ block: "nearest" })); });
|
||||||
|
onMount(() => inputElement?.focus());
|
||||||
|
|
||||||
|
function execute(item: Item | undefined) { if (!item || item.disabled) return; onClose(); queueMicrotask(() => { void item.run(); }); }
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; }
|
||||||
|
if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, visibleItems.length - 1); return; }
|
||||||
|
if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); return; }
|
||||||
|
if (event.key === "Enter") { event.preventDefault(); execute(visibleItems[activeIndex]); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="command-palette-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||||
|
<div class="command-palette" role="dialog" aria-modal="true" aria-label={isGerman ? "Befehlspalette" : "Command palette"}>
|
||||||
|
<div class="command-palette-search">
|
||||||
|
<Search size={19} aria-hidden="true" />
|
||||||
|
<input bind:this={inputElement} bind:value={query} onkeydown={handleKeydown} placeholder={isGerman ? "Aktion, Branch, Datei oder Commit suchen…" : "Search actions, branches, files, or commits…"} aria-label={isGerman ? "Befehl suchen" : "Search commands"} autocomplete="off" spellcheck="false" />
|
||||||
|
<kbd>ESC</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="command-palette-results" bind:this={listElement} role="listbox" aria-label={isGerman ? "Ergebnisse" : "Results"}>
|
||||||
|
{#if visibleItems.length === 0}
|
||||||
|
<div class="command-palette-empty"><Search size={24} aria-hidden="true" /><strong>{isGerman ? "Keine Treffer" : "No results"}</strong><span>{isGerman ? "Versuche einen anderen Suchbegriff." : "Try a different search term."}</span></div>
|
||||||
|
{:else}
|
||||||
|
{#each visibleItems as item, index (item.id)}
|
||||||
|
{#if index === 0 || visibleItems[index - 1].group !== item.group}<div class="command-palette-group">{item.group}</div>{/if}
|
||||||
|
<button class="command-palette-item" class:active={index === activeIndex} type="button" role="option" aria-selected={index === activeIndex} data-active={index === activeIndex} disabled={item.disabled} onmouseenter={() => { activeIndex = index; }} onclick={() => execute(item)}>
|
||||||
|
<span class={`command-palette-icon ${item.kind}`}>
|
||||||
|
{#if item.kind === "fetch" || item.kind === "pull"}<ArrowDownToLine size={16} />
|
||||||
|
{:else if item.kind === "push"}<ArrowUpFromLine size={16} />
|
||||||
|
{:else if item.kind === "refresh"}<RefreshCw size={16} />
|
||||||
|
{:else if item.kind === "search"}<Search size={16} />
|
||||||
|
{:else if item.kind === "compare"}<GitCompare size={16} />
|
||||||
|
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
|
||||||
|
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
|
||||||
|
{:else if item.kind === "worktrees"}<Boxes size={16} />
|
||||||
|
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
|
||||||
|
{:else if item.kind === "settings"}<Settings size={16} />
|
||||||
|
{:else if item.kind === "ai-settings"}<Sparkles size={16} />
|
||||||
|
{:else if item.kind === "help"}<CircleHelp size={16} />
|
||||||
|
{:else}<FileCode size={16} />{/if}
|
||||||
|
</span>
|
||||||
|
<span class="command-palette-copy"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
|
||||||
|
{#if item.kind === "branch" && item.disabled && !isBusy}<span class="command-palette-current">{isGerman ? "AKTUELL" : "CURRENT"}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<footer class="command-palette-footer"><span><kbd>↑</kbd><kbd>↓</kbd>{isGerman ? "Navigieren" : "Navigate"}</span><span><kbd>↵</kbd>{isGerman ? "Öffnen" : "Open"}</span>{#if commits.length > 0}<span class="command-palette-hint">{isGerman ? `${commits.length} geladene Commits` : `${commits.length} loaded commits`}</span>{/if}</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.command-palette-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: start center; padding: min(14vh, 120px) 20px 20px; background: color-mix(in srgb, var(--app-dialog-backdrop) 76%, transparent); backdrop-filter: blur(7px) saturate(.82); }
|
||||||
|
.command-palette { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(720px, 100%); max-height: min(650px, 76vh); overflow: hidden; border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border)); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 90px rgba(0,0,0,.42); }
|
||||||
|
.command-palette-search { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 15px 17px; border-bottom: 1px solid var(--color-border); color: var(--color-primary); }
|
||||||
|
.command-palette-search input { min-width: 0; border: 0; outline: 0; color: var(--color-ink); background: transparent; font: inherit; font-size: 15px; }
|
||||||
|
.command-palette-search input::placeholder { color: var(--color-ink-dim); }
|
||||||
|
kbd { display: inline-grid; place-items: center; min-width: 23px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border); border-radius: 5px; color: var(--color-ink-dim); background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
|
||||||
|
.command-palette-results { min-height: 120px; overflow-y: auto; padding: 7px; }
|
||||||
|
.command-palette-group { padding: 10px 9px 5px; color: var(--color-ink-dim); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||||
|
.command-palette-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; width: 100%; gap: 10px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; color: var(--color-ink); background: transparent; cursor: pointer; }
|
||||||
|
.command-palette-item.active:not(:disabled) { border-color: color-mix(in srgb, var(--color-primary) 24%, transparent); background: color-mix(in srgb, var(--color-primary) 11%, var(--color-surface-raised)); }
|
||||||
|
.command-palette-item:disabled { cursor: default; opacity: .48; }
|
||||||
|
.command-palette-icon { display: grid; place-items: center; width: 31px; height: 31px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
|
||||||
|
.command-palette-icon.branch { color: #65c98b; } .command-palette-icon.file { color: #69a7ff; } .command-palette-icon.commit { color: #ba82ff; }
|
||||||
|
.command-palette-copy { display: grid; min-width: 0; gap: 2px; } .command-palette-copy strong, .command-palette-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.command-palette-copy strong { font-size: 12.5px; font-weight: 700; } .command-palette-copy small { color: var(--color-ink-dim); font-size: 10.5px; }
|
||||||
|
.command-palette-current { color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
|
||||||
|
.command-palette-empty { display: grid; place-items: center; gap: 5px; padding: 54px 20px; color: var(--color-ink-dim); } .command-palette-empty strong { margin-top: 5px; color: var(--color-ink); font-size: 13px; } .command-palette-empty span { font-size: 11px; }
|
||||||
|
.command-palette-footer { display: flex; align-items: center; gap: 16px; min-height: 38px; padding: 7px 12px; border-top: 1px solid var(--color-border); color: var(--color-ink-dim); font-size: 10px; }
|
||||||
|
.command-palette-footer span { display: flex; align-items: center; gap: 5px; } .command-palette-footer span kbd + kbd { margin-left: -3px; } .command-palette-hint { margin-left: auto; }
|
||||||
|
@media (max-width: 640px) { .command-palette-backdrop { padding: 60px 10px 10px; } .command-palette { max-height: calc(100vh - 80px); } .command-palette-hint { display: none !important; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
Info,
|
||||||
|
LoaderCircle,
|
||||||
|
Save,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import type { AppLanguage, GitCommit, GitRemote } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
commit: GitCommit;
|
||||||
|
note: string;
|
||||||
|
remotes: GitRemote[];
|
||||||
|
preferredRemote: string;
|
||||||
|
language: AppLanguage;
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
error: string;
|
||||||
|
status: string;
|
||||||
|
onSave: (note: string) => void | Promise<void>;
|
||||||
|
onDelete: () => void | Promise<void>;
|
||||||
|
onFetch: (remote: string) => void | Promise<void>;
|
||||||
|
onPush: (remote: string) => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
commit,
|
||||||
|
note = "",
|
||||||
|
remotes = [],
|
||||||
|
preferredRemote = "",
|
||||||
|
language = "en",
|
||||||
|
isLoading = false,
|
||||||
|
isBusy = false,
|
||||||
|
error = "",
|
||||||
|
status = "",
|
||||||
|
onSave = () => {},
|
||||||
|
onDelete = () => {},
|
||||||
|
onFetch = () => {},
|
||||||
|
onPush = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let draft = $state("");
|
||||||
|
let selectedRemote = $state("");
|
||||||
|
let deleteConfirmOpen = $state(false);
|
||||||
|
let lastLoadedKey = $state("");
|
||||||
|
let hasChanges = $derived(draft !== note);
|
||||||
|
let canSave = $derived(!isLoading && !isBusy && draft.trim().length > 0 && hasChanges);
|
||||||
|
|
||||||
|
const text = $derived(language === "de" ? {
|
||||||
|
eyebrow: "Interne Git-Notiz",
|
||||||
|
title: "Commit-Notiz",
|
||||||
|
commit: "Commit",
|
||||||
|
explanation: "Die Notiz wird separat unter refs/notes/commits gespeichert. Hash und Commit-Historie bleiben unverändert.",
|
||||||
|
label: "Notiz",
|
||||||
|
placeholder: "Zum Beispiel Review-Hinweise, Ticket-Kontext, Build-ID oder Freigabestatus …",
|
||||||
|
loading: "Notiz wird geladen …",
|
||||||
|
syncTitle: "Mit Remote synchronisieren",
|
||||||
|
syncHelp: "Git Notes reisen nicht automatisch mit Branches. Lade sie gezielt vom Remote oder sende deine lokalen Notizen dorthin.",
|
||||||
|
noRemotes: "Für dieses Repository ist kein Remote eingerichtet.",
|
||||||
|
remote: "Remote",
|
||||||
|
fetch: "Vom Remote laden",
|
||||||
|
push: "Zum Remote senden",
|
||||||
|
delete: "Notiz löschen",
|
||||||
|
deleteQuestion: "Diese Notiz wirklich löschen?",
|
||||||
|
deleteConfirm: "Ja, löschen",
|
||||||
|
cancel: "Abbrechen",
|
||||||
|
close: "Schließen",
|
||||||
|
save: "Notiz speichern",
|
||||||
|
characters: "Zeichen",
|
||||||
|
} : {
|
||||||
|
eyebrow: "Internal Git note",
|
||||||
|
title: "Commit note",
|
||||||
|
commit: "Commit",
|
||||||
|
explanation: "The note is stored separately under refs/notes/commits. The commit hash and history stay unchanged.",
|
||||||
|
label: "Note",
|
||||||
|
placeholder: "For example review findings, ticket context, a build ID, or approval status…",
|
||||||
|
loading: "Loading note…",
|
||||||
|
syncTitle: "Sync with a remote",
|
||||||
|
syncHelp: "Git Notes do not travel with branches automatically. Fetch them explicitly from a remote or push your local notes there.",
|
||||||
|
noRemotes: "No remote is configured for this repository.",
|
||||||
|
remote: "Remote",
|
||||||
|
fetch: "Fetch from remote",
|
||||||
|
push: "Push to remote",
|
||||||
|
delete: "Delete note",
|
||||||
|
deleteQuestion: "Delete this note?",
|
||||||
|
deleteConfirm: "Yes, delete",
|
||||||
|
cancel: "Cancel",
|
||||||
|
close: "Close",
|
||||||
|
save: "Save note",
|
||||||
|
characters: "characters",
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const loadedKey = `${commit.hash}:${note}`;
|
||||||
|
if (loadedKey === lastLoadedKey) return;
|
||||||
|
draft = note;
|
||||||
|
lastLoadedKey = loadedKey;
|
||||||
|
deleteConfirmOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedRemote && remotes.some((remote) => remote.name === selectedRemote)) return;
|
||||||
|
selectedRemote = remotes.some((remote) => remote.name === preferredRemote)
|
||||||
|
? preferredRemote
|
||||||
|
: (remotes[0]?.name ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSave) return;
|
||||||
|
void onSave(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && !isBusy) onClose();
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (canSave) void onSave(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop commit-note-backdrop" role="presentation">
|
||||||
|
<div class="commit-note-dialog" role="dialog" aria-modal="true" aria-labelledby="commit-note-title" tabindex="-1">
|
||||||
|
<header class="commit-note-head">
|
||||||
|
<div class="commit-note-heading">
|
||||||
|
<span class="commit-note-icon"><StickyNote size={20} aria-hidden="true" /></span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">{text.eyebrow}</span>
|
||||||
|
<h2 id="commit-note-title">{text.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={text.close} aria-label={text.close}>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="commit-note-body">
|
||||||
|
<div class="commit-note-target">
|
||||||
|
<span class="commit-target-icon"><GitCommitHorizontal size={18} aria-hidden="true" /></span>
|
||||||
|
<div>
|
||||||
|
<span>{text.commit} <code>{commit.short_hash}</code></span>
|
||||||
|
<strong title={commit.summary}>{commit.summary}</strong>
|
||||||
|
<small>{commit.author_name} · {new Date(commit.date).toLocaleString()}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="commit-note-info">
|
||||||
|
<Info size={16} aria-hidden="true" />
|
||||||
|
<span>{text.explanation}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="commit-note-message error" role="alert">{error}</div>
|
||||||
|
{:else if status}
|
||||||
|
<div class="commit-note-message success" role="status">{status}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="commit-note-form" onsubmit={submit}>
|
||||||
|
<label for="commit-note-editor">
|
||||||
|
<span>{text.label}</span>
|
||||||
|
<small>{draft.length.toLocaleString()} {text.characters}</small>
|
||||||
|
</label>
|
||||||
|
<div class="commit-note-editor-wrap">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="commit-note-loading"><LoaderCircle class="spin" size={18} aria-hidden="true" />{text.loading}</div>
|
||||||
|
{/if}
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<textarea
|
||||||
|
id="commit-note-editor"
|
||||||
|
bind:value={draft}
|
||||||
|
placeholder={text.placeholder}
|
||||||
|
disabled={isLoading || isBusy}
|
||||||
|
maxlength={262144}
|
||||||
|
spellcheck="true"
|
||||||
|
autofocus
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="commit-note-sync" aria-labelledby="commit-note-sync-title">
|
||||||
|
<div class="commit-note-sync-copy">
|
||||||
|
<strong id="commit-note-sync-title">{text.syncTitle}</strong>
|
||||||
|
<span>{text.syncHelp}</span>
|
||||||
|
</div>
|
||||||
|
{#if remotes.length > 0}
|
||||||
|
<div class="commit-note-sync-controls">
|
||||||
|
<label>
|
||||||
|
<span>{text.remote}</span>
|
||||||
|
<SelectMenu value={selectedRemote} options={remotes.map((remote) => ({ value: remote.name, label: remote.name }))} disabled={isBusy} onChange={(value) => { selectedRemote = value; }} />
|
||||||
|
</label>
|
||||||
|
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
|
||||||
|
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
|
||||||
|
{text.fetch}
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={() => onPush(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
|
||||||
|
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Upload size={15} aria-hidden="true" />{/if}
|
||||||
|
{text.push}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="commit-note-no-remotes">{text.noRemotes}</span>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="commit-note-footer">
|
||||||
|
<div class="commit-note-delete">
|
||||||
|
{#if deleteConfirmOpen}
|
||||||
|
<span>{text.deleteQuestion}</span>
|
||||||
|
<button class="btn-danger" type="button" onclick={() => onDelete()} disabled={isBusy}>{text.deleteConfirm}</button>
|
||||||
|
<button type="button" onclick={() => { deleteConfirmOpen = false; }} disabled={isBusy}>{text.cancel}</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="commit-note-delete-trigger" onclick={() => { deleteConfirmOpen = true; }} disabled={isBusy || isLoading || !note}>
|
||||||
|
<Trash2 size={15} aria-hidden="true" />{text.delete}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="commit-note-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{text.close}</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={() => onSave(draft)} disabled={!canSave}>
|
||||||
|
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Save size={16} aria-hidden="true" />{/if}
|
||||||
|
{text.save}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.commit-note-backdrop { z-index: 72; }
|
||||||
|
.commit-note-dialog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
max-height: min(780px, calc(100vh - 74px));
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--app-dialog-bg);
|
||||||
|
box-shadow: var(--app-dialog-shadow);
|
||||||
|
}
|
||||||
|
.commit-note-head,
|
||||||
|
.commit-note-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--app-dialog-chrome); }
|
||||||
|
.commit-note-head { padding: 16px 18px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
|
.commit-note-heading { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.commit-note-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 18px; line-height: 1.2; }
|
||||||
|
.commit-note-icon,
|
||||||
|
.commit-target-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||||
|
.commit-note-icon { width: 40px; height: 40px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 11px; }
|
||||||
|
.commit-note-body { display: grid; align-content: start; gap: 13px; min-height: 0; padding: 16px 18px; overflow: auto; }
|
||||||
|
.commit-note-target { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
|
||||||
|
.commit-target-icon { width: 34px; height: 34px; border-radius: 8px; }
|
||||||
|
.commit-note-target > div { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.commit-note-target span,
|
||||||
|
.commit-note-target small { color: var(--color-ink-faint); font-size: 10.5px; }
|
||||||
|
.commit-note-target code { margin-left: 4px; color: var(--color-accent); font: 700 10.5px var(--font-mono); }
|
||||||
|
.commit-note-target strong { overflow: hidden; color: var(--color-ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.commit-note-info { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 8px; padding: 10px 11px; border: 1px solid color-mix(in srgb, var(--color-accent) 22%, var(--color-border-subtle)); border-radius: 9px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--color-accent) 6%, transparent); font-size: 11px; line-height: 1.45; }
|
||||||
|
.commit-note-info :global(svg) { margin-top: 1px; color: var(--color-accent); }
|
||||||
|
.commit-note-message { padding: 9px 11px; border: 1px solid; border-radius: 8px; font-size: 11px; font-weight: 700; }
|
||||||
|
.commit-note-message.error { border-color: rgba(232, 96, 96, .32); color: #ef8888; background: rgba(232, 96, 96, .08); }
|
||||||
|
.commit-note-message.success { border-color: color-mix(in srgb, #5bd18a 34%, var(--color-border)); color: #70dc99; background: color-mix(in srgb, #5bd18a 8%, transparent); }
|
||||||
|
.commit-note-form { display: grid; gap: 6px; }
|
||||||
|
.commit-note-form > label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--color-ink-muted); font-size: 11px; font-weight: 800; }
|
||||||
|
.commit-note-form > label small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 600; }
|
||||||
|
.commit-note-editor-wrap { position: relative; }
|
||||||
|
.commit-note-editor-wrap textarea { min-height: 164px; max-height: 320px; resize: vertical; font-size: 12.5px; line-height: 1.55; }
|
||||||
|
.commit-note-loading { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--app-input-bg) 92%, transparent); font-size: 11px; font-weight: 700; }
|
||||||
|
.commit-note-sync { display: grid; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
|
||||||
|
.commit-note-sync-copy { display: grid; gap: 4px; }
|
||||||
|
.commit-note-sync-copy strong { color: var(--color-ink); font-size: 12px; }
|
||||||
|
.commit-note-sync-copy span,
|
||||||
|
.commit-note-no-remotes { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.45; }
|
||||||
|
.commit-note-sync-controls { display: grid; grid-template-columns: minmax(130px, 1fr) auto auto; align-items: end; gap: 8px; }
|
||||||
|
.commit-note-sync-controls label { display: grid; gap: 5px; color: var(--color-ink-muted); font-size: 10px; font-weight: 800; }
|
||||||
|
.commit-note-sync-controls button { min-height: 34px; font-size: 10.5px; font-weight: 750; }
|
||||||
|
.commit-note-footer { min-height: 66px; padding: 12px 18px; border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
.commit-note-delete,
|
||||||
|
.commit-note-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.commit-note-delete > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 700; }
|
||||||
|
.commit-note-delete-trigger { border-color: transparent; color: #e87a7a; background: transparent; }
|
||||||
|
.commit-note-delete-trigger:hover:not(:disabled) { border-color: rgba(232, 96, 96, .24); color: #ff9a9a; background: rgba(232, 96, 96, .08); }
|
||||||
|
@media (max-width: 660px) {
|
||||||
|
.commit-note-sync-controls { grid-template-columns: 1fr; }
|
||||||
|
.commit-note-footer { align-items: stretch; flex-direction: column; }
|
||||||
|
.commit-note-delete,
|
||||||
|
.commit-note-actions { justify-content: flex-end; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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 =
|
||||||
@@ -20,9 +20,12 @@
|
|||||||
comparison: GitCommitComparison;
|
comparison: GitCommitComparison;
|
||||||
selectedDiffPath: string;
|
selectedDiffPath: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
fromLabel?: string;
|
||||||
|
toLabel?: string;
|
||||||
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;
|
||||||
@@ -32,13 +35,18 @@
|
|||||||
comparison,
|
comparison,
|
||||||
selectedDiffPath = "",
|
selectedDiffPath = "",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
fromLabel = "",
|
||||||
|
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
|
||||||
@@ -219,16 +227,19 @@
|
|||||||
class="dialog-backdrop compare-dialog-backdrop"
|
class="dialog-backdrop compare-dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="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>
|
<div class="compare-dialog-title">
|
||||||
<span class="eyebrow">Compare</span>
|
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
|
||||||
<h2 class="dialog-range">
|
<div>
|
||||||
<span class="hash">{comparison.from_short}</span>
|
<h2>{isGerman ? "Änderungen vergleichen" : "Compare changes"}</h2>
|
||||||
<ArrowRight size={14} aria-hidden="true" />
|
<p class="dialog-range">
|
||||||
<span class="hash">{comparison.to_short}</span>
|
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||||
</h2>
|
<ArrowRight size={14} aria-hidden="true" />
|
||||||
|
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-header-actions">
|
<div class="dialog-header-actions">
|
||||||
{#if restoreLabel && onRestore}
|
{#if restoreLabel && onRestore}
|
||||||
@@ -237,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"
|
||||||
@@ -271,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">
|
||||||
@@ -288,12 +303,12 @@
|
|||||||
<!-- 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">{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">{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,14 +1,17 @@
|
|||||||
<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 { GitCommit } from "../types";
|
import type { GitBranch, GitCommit } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
|
branches: GitBranch[];
|
||||||
compareFrom: string;
|
compareFrom: string;
|
||||||
compareTo: string;
|
compareTo: string;
|
||||||
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;
|
||||||
@@ -17,21 +20,50 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
commits = [],
|
commits = [],
|
||||||
|
branches = [],
|
||||||
compareFrom = "",
|
compareFrom = "",
|
||||||
compareTo = "",
|
compareTo = "",
|
||||||
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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function branchValue(branch: GitBranch): string {
|
||||||
|
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let localBranches = $derived(branches.filter((branch) => !branch.remote));
|
||||||
|
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
|
||||||
|
let targetCount = $derived(branches.length + commits.length);
|
||||||
|
let compareOptions = $derived([
|
||||||
|
...localBranches.map((branch) => ({
|
||||||
|
value: branchValue(branch),
|
||||||
|
label: `${branch.name}${branch.current ? (isGerman ? " (aktuell)" : " (current)") : ""}`,
|
||||||
|
group: isGerman ? "Lokale Branches" : "Local branches",
|
||||||
|
})),
|
||||||
|
...remoteBranches.map((branch) => ({
|
||||||
|
value: branchValue(branch),
|
||||||
|
label: branch.name,
|
||||||
|
group: isGerman ? "Remote-Branches" : "Remote branches",
|
||||||
|
})),
|
||||||
|
...commits.map((item) => ({
|
||||||
|
value: item.hash,
|
||||||
|
label: commitOptionLabel(item),
|
||||||
|
group: "Commits",
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
function handleSubmit(event: SubmitEvent) {
|
function handleSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCompare();
|
onCompare();
|
||||||
@@ -42,66 +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 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>
|
<div class="compare-dialog-title">
|
||||||
<span class="eyebrow">Compare</span>
|
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
<div>
|
||||||
|
<h2>{isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}</h2>
|
||||||
|
<p>{isGerman ? "Zwei Repository-Stände direkt gegenüberstellen" : "Review two repository states side by side"}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if commits.length < 2}
|
<form class="compare-select-shell" onsubmit={handleSubmit}>
|
||||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
<div class="compare-select-body">
|
||||||
{:else}
|
{#if targetCount < 2}
|
||||||
<form class="compare-form" onsubmit={handleSubmit}>
|
<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>
|
||||||
<label class="compare-field">
|
{:else}
|
||||||
<span>From (older)</span>
|
<section class="compare-target-panel">
|
||||||
<select
|
<div class="compare-target-heading">
|
||||||
value={compareFrom}
|
<h3>{isGerman ? "Vergleichsbereich" : "Comparison range"}</h3>
|
||||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
<p>{isGerman ? "Wähle Ausgangspunkt und Ziel des Vergleichs." : "Choose the starting point and target for the comparison."}</p>
|
||||||
disabled={isBusy}
|
</div>
|
||||||
>
|
<div class="compare-form">
|
||||||
<option value="" disabled>Select a commit</option>
|
<label class="compare-field">
|
||||||
{#each commits as item (item.hash)}
|
<span>{isGerman ? "Ausgangsbasis" : "Base"}</span>
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
<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} />
|
||||||
{/each}
|
</label>
|
||||||
</select>
|
|
||||||
</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>To (newer)</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}
|
</label>
|
||||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
</div>
|
||||||
disabled={isBusy}
|
</section>
|
||||||
>
|
|
||||||
<option value="" disabled>Select a commit</option>
|
|
||||||
{#each commits as item (item.hash)}
|
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||||
{#if operation === "Comparing commits"}
|
<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>
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
||||||
{:else}
|
{:else}
|
||||||
<GitCompare size={16} aria-hidden="true" />
|
<div class="compare-target-help"><Info size={15} aria-hidden="true" /><span>{isGerman ? "Verglichen werden die vollständigen Repository-Stände. Nicht committete Änderungen im Arbeitsverzeichnis sind nicht enthalten." : "The complete repository states are compared. Uncommitted working-tree changes are not included."}</span></div>
|
||||||
{/if}
|
{/if}
|
||||||
Compare
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
<footer class="compare-select-footer">
|
||||||
|
<span>{isGerman ? `${targetCount} Ziele verfügbar` : `${targetCount} targets available`}</span>
|
||||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
<div>
|
||||||
<div class="blank-state">Select two different commits to compare.</div>
|
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||||
{:else}
|
<button class="btn-primary" type="submit" disabled={!canCompare || targetCount < 2}>
|
||||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
{#if operation === "Comparing revisions"}
|
||||||
{/if}
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{/if}
|
{:else}
|
||||||
|
<GitCompare size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
{isGerman ? "Vergleich öffnen" : "Open comparison"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</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, expiresAt: string | null) => void;
|
initialUsername?: string;
|
||||||
|
initialMode?: Mode;
|
||||||
|
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,35 +28,40 @@
|
|||||||
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);
|
||||||
let expiresAt = $state("");
|
|
||||||
|
|
||||||
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 === "fetch"
|
: action === "rename"
|
||||||
? "Authenticate fetch"
|
? "Authenticate remote rename"
|
||||||
: action === "clone"
|
: action === "delete"
|
||||||
? "Authenticate clone"
|
? "Authenticate remote deletion"
|
||||||
: "Authenticate pull",
|
: action === "fetch"
|
||||||
|
? "Authenticate fetch"
|
||||||
|
: action === "clone"
|
||||||
|
? "Authenticate clone"
|
||||||
|
: "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."
|
||||||
@@ -62,12 +70,7 @@
|
|||||||
function handleSubmit(e: SubmitEvent) {
|
function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
onSubmit(
|
onSubmit(username.trim(), password, saveSession, mode);
|
||||||
mode === "token" ? "oauth2" : username,
|
|
||||||
password,
|
|
||||||
saveSession,
|
|
||||||
saveSession && expiresAt ? expiresAt : null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,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" />
|
||||||
@@ -98,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>
|
||||||
|
|
||||||
@@ -122,27 +125,25 @@
|
|||||||
aria-pressed={mode === "token"}
|
aria-pressed={mode === "token"}
|
||||||
>
|
>
|
||||||
<Key size={13} aria-hidden="true" />
|
<Key size={13} aria-hidden="true" />
|
||||||
Token
|
Access token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cred-fields">
|
<div class="cred-fields">
|
||||||
{#if mode === "credentials"}
|
<div class="cred-field">
|
||||||
<div class="cred-field">
|
<label class="cred-field-label" for="cred-username">Username</label>
|
||||||
<label class="cred-field-label" for="cred-username">Username</label>
|
<div class="cred-input">
|
||||||
<div class="cred-input">
|
<User size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
<User size={15} class="cred-field-icon" aria-hidden="true" />
|
<input
|
||||||
<input
|
id="cred-username"
|
||||||
id="cred-username"
|
type="text"
|
||||||
type="text"
|
bind:value={username}
|
||||||
bind:value={username}
|
placeholder="Your account username"
|
||||||
placeholder="e.g. my-github-username"
|
autocomplete="username"
|
||||||
autocomplete="username"
|
disabled={isBusy}
|
||||||
disabled={isBusy}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
<div class="cred-field">
|
<div class="cred-field">
|
||||||
<label class="cred-field-label" for="cred-password">
|
<label class="cred-field-label" for="cred-password">
|
||||||
@@ -180,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}
|
||||||
|
|
||||||
@@ -191,19 +192,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if saveSession}
|
|
||||||
<div class="cred-expiry">
|
|
||||||
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
|
|
||||||
<input
|
|
||||||
id="cred-expiry"
|
|
||||||
type="date"
|
|
||||||
bind:value={expiresAt}
|
|
||||||
disabled={isBusy}
|
|
||||||
/>
|
|
||||||
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="cred-footer">
|
<div class="cred-footer">
|
||||||
<label class="cred-save">
|
<label class="cred-save">
|
||||||
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
FileImage,
|
FileImage,
|
||||||
FileJson,
|
FileJson,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
|
GitCompare,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FileText,
|
FileText,
|
||||||
FileType,
|
FileType,
|
||||||
@@ -33,11 +34,16 @@
|
|||||||
selectedExplorerKind: ExplorerNodeKind;
|
selectedExplorerKind: ExplorerNodeKind;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
language?: "en" | "de";
|
||||||
|
editorName?: string;
|
||||||
|
diffName?: string;
|
||||||
onToggleFolder: (node: ExplorerNode) => void;
|
onToggleFolder: (node: ExplorerNode) => void;
|
||||||
onExpandAllFolders: () => void;
|
onExpandAllFolders: () => void;
|
||||||
onCollapseAllFolders: () => void;
|
onCollapseAllFolders: () => void;
|
||||||
onSelectNode: (node: ExplorerNode) => void;
|
onSelectNode: (node: ExplorerNode) => void;
|
||||||
onOpenFile: (node: ExplorerNode) => void;
|
onOpenFile: (node: ExplorerNode) => void;
|
||||||
|
onOpenInEditor: (node: ExplorerNode) => void;
|
||||||
|
onExternalDiff: (node: ExplorerNode) => void;
|
||||||
onFileHistory: (node: ExplorerNode) => void;
|
onFileHistory: (node: ExplorerNode) => void;
|
||||||
onBlame: (node: ExplorerNode) => void;
|
onBlame: (node: ExplorerNode) => void;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
@@ -51,11 +57,16 @@
|
|||||||
selectedExplorerKind = "file",
|
selectedExplorerKind = "file",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
language = "en",
|
||||||
|
editorName = "Editor",
|
||||||
|
diffName = "diff tool",
|
||||||
onToggleFolder = () => {},
|
onToggleFolder = () => {},
|
||||||
onExpandAllFolders = () => {},
|
onExpandAllFolders = () => {},
|
||||||
onCollapseAllFolders = () => {},
|
onCollapseAllFolders = () => {},
|
||||||
onSelectNode = () => {},
|
onSelectNode = () => {},
|
||||||
onOpenFile = () => {},
|
onOpenFile = () => {},
|
||||||
|
onOpenInEditor = () => {},
|
||||||
|
onExternalDiff = () => {},
|
||||||
onFileHistory = () => {},
|
onFileHistory = () => {},
|
||||||
onBlame = () => {},
|
onBlame = () => {},
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
@@ -65,6 +76,7 @@
|
|||||||
let contextNode = $state<ExplorerNode | null>(null);
|
let contextNode = $state<ExplorerNode | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||||
if (!next) return current;
|
if (!next) return current;
|
||||||
@@ -178,7 +190,7 @@
|
|||||||
|
|
||||||
contextNode = node;
|
contextNode = node;
|
||||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
||||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 132));
|
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220));
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeFileContextMenu() {
|
function closeFileContextMenu() {
|
||||||
@@ -192,6 +204,20 @@
|
|||||||
onOpenFile(node);
|
onOpenFile(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openContextFileInEditor() {
|
||||||
|
const node = contextNode;
|
||||||
|
if (!node || node.kind !== "file") return;
|
||||||
|
closeFileContextMenu();
|
||||||
|
onOpenInEditor(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContextExternalDiff() {
|
||||||
|
const node = contextNode;
|
||||||
|
if (!node || node.kind !== "file") return;
|
||||||
|
closeFileContextMenu();
|
||||||
|
onExternalDiff(node);
|
||||||
|
}
|
||||||
|
|
||||||
function openContextBlame() {
|
function openContextBlame() {
|
||||||
const node = contextNode;
|
const node = contextNode;
|
||||||
if (!node || node.kind !== "file") return;
|
if (!node || node.kind !== "file") return;
|
||||||
@@ -213,6 +239,11 @@
|
|||||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||||
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
||||||
|
let selectedFileNode = $derived(
|
||||||
|
selectedExplorerKind === "file"
|
||||||
|
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
|
||||||
|
: null,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
||||||
@@ -224,6 +255,35 @@
|
|||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="explorer-head-actions">
|
<div class="explorer-head-actions">
|
||||||
|
<button
|
||||||
|
class="explorer-bulk-button explorer-tool-action"
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectedFileNode && onOpenInEditor(selectedFileNode)}
|
||||||
|
disabled={isBusy || !selectedFileNode || selectedFileNode.status === "deleted"}
|
||||||
|
title={isGerman
|
||||||
|
? `Ausgewählte Datei in ${editorName} öffnen`
|
||||||
|
: `Open selected file in ${editorName}`}
|
||||||
|
aria-label={isGerman
|
||||||
|
? `Ausgewählte Datei in ${editorName} öffnen`
|
||||||
|
: `Open selected file in ${editorName}`}
|
||||||
|
>
|
||||||
|
<FileCode size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="explorer-bulk-button explorer-tool-action"
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectedFileNode && onExternalDiff(selectedFileNode)}
|
||||||
|
disabled={isBusy || !selectedFileNode || !selectedFileNode.tracked || selectedFileNode.status === "deleted"}
|
||||||
|
title={isGerman
|
||||||
|
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
|
||||||
|
: `Compare selected file with HEAD in ${diffName}`}
|
||||||
|
aria-label={isGerman
|
||||||
|
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
|
||||||
|
: `Compare selected file with HEAD in ${diffName}`}
|
||||||
|
>
|
||||||
|
<GitCompare size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span class="explorer-action-divider" aria-hidden="true"></span>
|
||||||
<button
|
<button
|
||||||
class="explorer-bulk-button"
|
class="explorer-bulk-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -366,6 +426,14 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label={`Actions for ${contextNode.path}`}
|
aria-label={`Actions for ${contextNode.path}`}
|
||||||
>
|
>
|
||||||
|
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
|
||||||
|
<FileCode size={14} aria-hidden="true" />
|
||||||
|
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
|
||||||
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
|
||||||
|
<GitCompare size={14} aria-hidden="true" />
|
||||||
|
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -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,56 @@
|
|||||||
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",
|
||||||
|
title: "Version 2026.8.3",
|
||||||
|
summary: "Dieses Release verbindet Gitty enger mit deinen Entwicklungswerkzeugen und macht Branches, Historie und Vergleiche deutlich leistungsfähiger.",
|
||||||
|
steps: [
|
||||||
|
"Externe Tools: Editor, Diff-Tool, Merge-Tool, Terminal und Dateimanager lassen sich in den neu gestalteten Einstellungen erkennen, auswählen und individuell konfigurieren.",
|
||||||
|
"VS Code, JetBrains-IDEs, Beyond Compare und weitere unterstützte Programme werden in einem eigenen Fenster geöffnet; dokumentierte Ergebnis-Codes werden beim Schließen korrekt behandelt.",
|
||||||
|
"Git Notes: Commits erhalten lokale Notizen, ohne ihre Historie umzuschreiben. Notizen lassen sich bearbeiten, löschen sowie gezielt vom Remote abrufen oder dorthin übertragen.",
|
||||||
|
"Vollständiger Branch-Vergleich: Lokale Branches, Remote-Branches und Commits können direkt ausgewählt und dateiweise im Side-by-Side-Diff verglichen werden.",
|
||||||
|
"Remote-Branches lassen sich im Kontextmenü sicher umbenennen. Gitty schützt dabei vorhandene Ziel-Branches und zwischenzeitlich geänderte Remote-Stände.",
|
||||||
|
"Der überarbeitete Commit-Graph zeigt Branches kompakter, reduziert überladene Commit-Zeilen und blendet zusätzliche Flag-Details beim Darüberfahren ein.",
|
||||||
|
"Noch nicht veröffentlichte Branches sind als „Nur lokal“ erkennbar – in der Werkzeugleiste, Repository-Übersicht, Statuszeile und direkt an der Branch-Flag. Die erste Push-Aktion heißt passend „Veröffentlichen“.",
|
||||||
|
"Branch-Sichtbarkeit, feinere Graph-Verbindungen und ein kleineres Mindestmaß des Verlaufsbereichs verbessern die Übersicht bei großen Repositories.",
|
||||||
|
"Die neue Befehlspalette öffnet Aktionen, Dateien und Commits schneller; asynchrone Git-Befehle halten Gitty auch bei langsameren Operationen reaktionsfähig.",
|
||||||
|
],
|
||||||
|
note: "Der Branch-Vergleich zeigt die vollständig festgeschriebenen Zustände der beiden Branch-Spitzen. Nicht commitete Änderungen im Arbeitsverzeichnis sind nicht enthalten.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-2",
|
||||||
|
title: "Version 2026.8.2",
|
||||||
|
summary: "Dieses Release stabilisiert die Darstellung komplexer Verläufe und verbessert die Veröffentlichung neuer Gitty-Versionen.",
|
||||||
|
steps: [
|
||||||
|
"Branch-Farben bleiben über Eltern-Lanes hinweg stabil, sodass sich Linien in längeren und verzweigten Historien leichter verfolgen lassen.",
|
||||||
|
"Release-Artefakte werden automatisch und ohne doppelte Dateien an das passende Gitea-Release angehängt.",
|
||||||
|
"Beim Beenden der Anwendung wird die Telemetrie zuverlässiger abgeschlossen.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-1",
|
||||||
|
title: "Version 2026.8.1",
|
||||||
|
summary: "Dieses Release macht große Commit-Verläufe und die Historie einzelner Dateien leichter zugänglich und verbessert die Paketverteilung.",
|
||||||
|
steps: [
|
||||||
|
"Die Commit-Historie lädt ältere Einträge seitenweise nach und ist nicht mehr auf die erste Ergebnismenge begrenzt.",
|
||||||
|
"Die Dateihistorie öffnet sich aus dem Explorer-Kontextmenü in einem eigenen, größeren Dialog statt in einem dauerhaft belegten Seitenbereich.",
|
||||||
|
"Dialoge reagieren konsistenter auf die Escape-Taste.",
|
||||||
|
"Windows- und Ubuntu-Releases sowie der AUR-Paketablauf wurden erweitert und robuster gemacht.",
|
||||||
|
"Die Arch-Linux-Anleitung verwendet jetzt das AUR-Paket gitty-desktop; SSH-Einrichtung, Zeitlimits und Wiederholungsversuche wurden verbessert.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "changelog-2026-07-22",
|
id: "changelog-2026-07-22",
|
||||||
title: "Version 2026.07.22",
|
title: "Version 2026.07.22",
|
||||||
@@ -1411,6 +1499,56 @@
|
|||||||
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",
|
||||||
|
title: "Version 2026.8.3",
|
||||||
|
summary: "This release connects Gitty more closely with your development tools and makes branches, history, and comparisons substantially more capable.",
|
||||||
|
steps: [
|
||||||
|
"External tools: editors, diff tools, merge tools, terminals, and file managers can be detected, selected, and customized in the redesigned settings.",
|
||||||
|
"VS Code, JetBrains IDEs, Beyond Compare, and other supported applications open in a separate window; documented result codes are handled correctly when they close.",
|
||||||
|
"Git Notes: attach local notes to commits without rewriting history. Notes can be edited, deleted, fetched from a remote, or pushed explicitly.",
|
||||||
|
"Complete branch comparison: choose local branches, remote branches, or commits and inspect every changed file in a side-by-side diff.",
|
||||||
|
"Remote branches can be renamed safely from the context menu. Gitty protects existing destination branches and remote branches that changed after the last fetch.",
|
||||||
|
"The redesigned commit graph presents branches more compactly, reduces crowded commit rows, and reveals additional flag details on hover.",
|
||||||
|
"Unpublished branches are clearly marked as Local only in the toolbar, repository summary, status bar, and on the graph flag. Their first push is labeled Publish.",
|
||||||
|
"Branch visibility controls, refined graph connectors, and a smaller minimum history width improve navigation in large repositories.",
|
||||||
|
"The new command palette opens actions, files, and commits faster; asynchronous Git commands keep Gitty responsive during slower operations.",
|
||||||
|
],
|
||||||
|
note: "Branch comparison uses the fully committed state at each branch tip. Uncommitted working-tree changes are not included.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-2",
|
||||||
|
title: "Version 2026.8.2",
|
||||||
|
summary: "This release stabilizes complex history rendering and improves publication of new Gitty versions.",
|
||||||
|
steps: [
|
||||||
|
"Branch colors remain stable across parent lanes, making longer and branching histories easier to follow.",
|
||||||
|
"Release artifacts are attached to the matching Gitea release automatically without uploading duplicates.",
|
||||||
|
"Telemetry cleanup completes more reliably while the application is shutting down.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-1",
|
||||||
|
title: "Version 2026.8.1",
|
||||||
|
summary: "This release makes large commit histories and individual file histories easier to access and improves package distribution.",
|
||||||
|
steps: [
|
||||||
|
"Commit history loads older entries page by page instead of stopping after the initial result set.",
|
||||||
|
"File history opens from the explorer context menu in a dedicated larger dialog instead of occupying a permanent workspace panel.",
|
||||||
|
"Dialogs respond more consistently to the Escape key.",
|
||||||
|
"Windows and Ubuntu publishing plus the AUR package workflow were expanded and made more robust.",
|
||||||
|
"The Arch Linux guide now uses the gitty-desktop AUR package; SSH setup, timeouts, and retry handling were improved.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "changelog-2026-07-22",
|
id: "changelog-2026-07-22",
|
||||||
title: "Version 2026.07.22",
|
title: "Version 2026.07.22",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
|
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
|
||||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -22,11 +22,34 @@
|
|||||||
graphCommit: GitCommit;
|
graphCommit: GitCommit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
|
||||||
|
type CommitRefKind = "local" | "remote" | "head";
|
||||||
|
|
||||||
|
interface CommitBranchDecoration {
|
||||||
|
label: string;
|
||||||
|
kind: CommitRefKind;
|
||||||
|
current: boolean;
|
||||||
|
trackedRemote: string;
|
||||||
|
localOnly: boolean;
|
||||||
|
representedBranches: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommitRefSummary {
|
||||||
|
branches: CommitBranchDecoration[];
|
||||||
|
tags: string[];
|
||||||
|
other: string[];
|
||||||
|
primaryBranch: CommitBranchDecoration | null;
|
||||||
|
primaryTag: string;
|
||||||
|
overflowCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
const GRAPH_COLORS = [
|
const GRAPH_COLORS = [
|
||||||
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
||||||
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
||||||
];
|
];
|
||||||
const GRAPH_LANE = 18;
|
const GRAPH_LANE = 18;
|
||||||
|
const GRAPH_REF_ARM = 16;
|
||||||
|
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
|
||||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
@@ -35,8 +58,12 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
localBranchNames: string[];
|
localBranchNames: string[];
|
||||||
|
localBranchUpstreams: Record<string, string>;
|
||||||
|
remoteBranchNames: string[];
|
||||||
activeBranch: string;
|
activeBranch: string;
|
||||||
activeUpstream: string;
|
activeUpstream: string;
|
||||||
|
activeAhead: number;
|
||||||
|
activeBehind: number;
|
||||||
repositoryKey: string;
|
repositoryKey: string;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
@@ -44,6 +71,7 @@
|
|||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
loadMoreError: string;
|
loadMoreError: string;
|
||||||
expandedCommitHashes: Set<string>;
|
expandedCommitHashes: Set<string>;
|
||||||
|
selectedCommitHash: string;
|
||||||
onLoadMore: () => void | Promise<void>;
|
onLoadMore: () => void | Promise<void>;
|
||||||
onRestoreCommit: (commit: GitCommit) => void;
|
onRestoreCommit: (commit: GitCommit) => void;
|
||||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||||
@@ -51,13 +79,20 @@
|
|||||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||||
onCherryPickCommit: (commit: GitCommit) => void;
|
onCherryPickCommit: (commit: GitCommit) => void;
|
||||||
onRevertCommit: (commit: GitCommit) => void;
|
onRevertCommit: (commit: GitCommit) => void;
|
||||||
|
onOpenCommitNote: (commit: GitCommit) => void;
|
||||||
|
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
|
||||||
|
onSelectCommit: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
commits = [],
|
commits = [],
|
||||||
localBranchNames = [],
|
localBranchNames = [],
|
||||||
|
localBranchUpstreams = {},
|
||||||
|
remoteBranchNames = [],
|
||||||
activeBranch = "",
|
activeBranch = "",
|
||||||
activeUpstream = "",
|
activeUpstream = "",
|
||||||
|
activeAhead = 0,
|
||||||
|
activeBehind = 0,
|
||||||
repositoryKey = "",
|
repositoryKey = "",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
@@ -65,6 +100,7 @@
|
|||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
loadMoreError = "",
|
loadMoreError = "",
|
||||||
expandedCommitHashes = new Set(),
|
expandedCommitHashes = new Set(),
|
||||||
|
selectedCommitHash = "",
|
||||||
onLoadMore = () => {},
|
onLoadMore = () => {},
|
||||||
onRestoreCommit = () => {},
|
onRestoreCommit = () => {},
|
||||||
onPreviewCommitFile = () => {},
|
onPreviewCommitFile = () => {},
|
||||||
@@ -72,16 +108,30 @@
|
|||||||
onCreateBranchFromCommit = () => {},
|
onCreateBranchFromCommit = () => {},
|
||||||
onCherryPickCommit = () => {},
|
onCherryPickCommit = () => {},
|
||||||
onRevertCommit = () => {},
|
onRevertCommit = () => {},
|
||||||
|
onOpenCommitNote = () => {},
|
||||||
|
onLoadCommitNote = async () => null,
|
||||||
|
onSelectCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
|
||||||
|
let customVisibleBranches = $state<Set<string>>(new Set());
|
||||||
|
let loadedVisibilityRepository = $state("");
|
||||||
let branchDialogOpen = $state(false);
|
let branchDialogOpen = $state(false);
|
||||||
let userAdjustedBranchFilter = $state(false);
|
let expandedRefsCommitHash = $state("");
|
||||||
let lastDefaultFilterKey = $state("");
|
|
||||||
let panelElement = $state<HTMLElement | null>(null);
|
let panelElement = $state<HTMLElement | null>(null);
|
||||||
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");
|
||||||
@@ -226,39 +276,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let localBranchNameSet = $derived(new Set(localBranchNames));
|
let localBranchNameSet = $derived(new Set(localBranchNames));
|
||||||
|
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
|
||||||
|
|
||||||
function uniqueStrings(values: string[]): string[] {
|
function uniqueStrings(values: string[]): string[] {
|
||||||
return [...new Set(values.filter(Boolean))];
|
return [...new Set(values.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
|
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
|
||||||
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
||||||
|
|
||||||
|
function focusBranchNames(): string[] {
|
||||||
|
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
|
||||||
|
if (focused.length > 0) return focused;
|
||||||
|
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchNamesForMode(): string[] {
|
||||||
|
if (branchVisibilityMode === "focus") return focusBranchNames();
|
||||||
|
if (branchVisibilityMode === "local") return localBranchNames;
|
||||||
|
if (branchVisibilityMode === "all") return graphBranchNames;
|
||||||
|
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibleGraphBranchNames = $derived(branchNamesForMode());
|
||||||
|
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
|
||||||
|
|
||||||
function branchIsVisible(branch: string): boolean {
|
function branchIsVisible(branch: string): boolean {
|
||||||
if (branch === activeUpstream && activeUpstream) {
|
return visibleGraphBranchNameSet.has(branch);
|
||||||
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
|
||||||
}
|
|
||||||
return !hiddenGraphBranches.has(branch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleBranchLabels(labels: string[]): string[] {
|
|
||||||
return labels.filter(branchIsVisible);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
|
||||||
const directBranches = graphBranchRefs(commit);
|
|
||||||
if (directBranches.length > 0) return directBranches;
|
|
||||||
|
|
||||||
const containingBranches = row?.branchLabels ?? [];
|
|
||||||
if (containingBranches.length <= 3) return containingBranches;
|
|
||||||
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||||
const directBranches = graphBranchRefs(commit);
|
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
||||||
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||||
|
|
||||||
const containingBranches = row?.branchLabels ?? [];
|
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
||||||
if (containingBranches.length === 0) return commit.short_hash;
|
if (containingBranches.length === 0) return commit.short_hash;
|
||||||
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||||
}
|
}
|
||||||
@@ -278,7 +329,7 @@
|
|||||||
|
|
||||||
function branchesAreVisible(branches: string[]): boolean {
|
function branchesAreVisible(branches: string[]): boolean {
|
||||||
if (graphBranchNames.length === 0) return true;
|
if (graphBranchNames.length === 0) return true;
|
||||||
return branches.some(branchIsVisible);
|
return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
||||||
@@ -355,20 +406,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleGraphBranch(branch: string) {
|
function toggleGraphBranch(branch: string) {
|
||||||
const next = new Set(hiddenGraphBranches);
|
const next = branchVisibilityMode === "custom"
|
||||||
|
? new Set(customVisibleBranches)
|
||||||
|
: new Set(visibleGraphBranchNames);
|
||||||
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
||||||
hiddenGraphBranches = next;
|
customVisibleBranches = next;
|
||||||
userAdjustedBranchFilter = true;
|
branchVisibilityMode = "custom";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAllGraphBranches() {
|
function showAllGraphBranches() {
|
||||||
hiddenGraphBranches = new Set();
|
branchVisibilityMode = "all";
|
||||||
userAdjustedBranchFilter = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAllGraphBranches() {
|
function hideAllGraphBranches() {
|
||||||
hiddenGraphBranches = new Set(localBranchNames);
|
customVisibleBranches = new Set();
|
||||||
userAdjustedBranchFilter = true;
|
branchVisibilityMode = "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFocusGraphBranches() {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBranchDialog() {
|
function openBranchDialog() {
|
||||||
@@ -385,6 +441,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCommitRefs(commit: GitCommit) {
|
||||||
|
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
|
||||||
|
}
|
||||||
|
|
||||||
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -441,9 +501,52 @@
|
|||||||
await onRevertCommit(commit);
|
await onRevertCommit(commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openContextCommitNote() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
onSelectCommit(commit);
|
||||||
|
await onOpenCommitNote(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCommitNote(commit: GitCommit) {
|
||||||
|
if (isBusy) return;
|
||||||
|
onSelectCommit(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();
|
||||||
|
expandedRefsCommitHash = "";
|
||||||
handleBranchDialogKeydown(event);
|
handleBranchDialogKeydown(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,22 +574,140 @@
|
|||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleRefs(commit: GitCommit): string[] {
|
|
||||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function refClass(ref: string): string {
|
|
||||||
if (ref.startsWith("HEAD")) return "head";
|
|
||||||
if (ref.startsWith("tag:")) return "tag";
|
|
||||||
if (localBranchNameSet.has(refLabel(ref))) return "branch";
|
|
||||||
if (ref.includes("/")) return "remote";
|
|
||||||
return "branch";
|
|
||||||
}
|
|
||||||
|
|
||||||
function refLabel(ref: string): string {
|
function refLabel(ref: string): string {
|
||||||
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSymbolicRemoteHead(ref: string): boolean {
|
||||||
|
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
|
||||||
|
const label = refLabel(ref);
|
||||||
|
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredUpstreamForBranch(local: string): string {
|
||||||
|
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
|
||||||
|
const configuredUpstream = configuredUpstreamForBranch(local);
|
||||||
|
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
|
||||||
|
const priority = (item: CommitBranchDecoration) => {
|
||||||
|
if (item.label === activeBranch) return 0;
|
||||||
|
if (item.kind === "head") return 1;
|
||||||
|
if (item.kind === "local") return 2;
|
||||||
|
if (item.label === activeUpstream) return 3;
|
||||||
|
return 4;
|
||||||
|
};
|
||||||
|
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRefSummary(commit: GitCommit): CommitRefSummary {
|
||||||
|
const local = new Set<string>();
|
||||||
|
const remote = new Set<string>();
|
||||||
|
const tags = new Set<string>();
|
||||||
|
const other = new Set<string>();
|
||||||
|
let detachedHead = false;
|
||||||
|
|
||||||
|
for (const ref of commit.refs) {
|
||||||
|
if (isSymbolicRemoteHead(ref)) continue;
|
||||||
|
const label = refLabel(ref);
|
||||||
|
if (!label) continue;
|
||||||
|
if (ref.startsWith("tag:")) {
|
||||||
|
tags.add(label);
|
||||||
|
} else if (localBranchNameSet.has(label)) {
|
||||||
|
local.add(label);
|
||||||
|
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
|
||||||
|
remote.add(label);
|
||||||
|
} else if (label === "HEAD") {
|
||||||
|
detachedHead = true;
|
||||||
|
} else {
|
||||||
|
other.add(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingRemote = [...remote];
|
||||||
|
const branches: CommitBranchDecoration[] = [...local].map((label) => {
|
||||||
|
const configuredUpstream = configuredUpstreamForBranch(label);
|
||||||
|
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
|
||||||
|
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
kind: "local",
|
||||||
|
current: label === activeBranch,
|
||||||
|
trackedRemote,
|
||||||
|
localOnly: !configuredUpstream,
|
||||||
|
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detachedHead) {
|
||||||
|
branches.push({
|
||||||
|
label: "HEAD",
|
||||||
|
kind: "head",
|
||||||
|
current: true,
|
||||||
|
trackedRemote: "",
|
||||||
|
localOnly: false,
|
||||||
|
representedBranches: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
branches.push(...remainingRemote.map((label) => ({
|
||||||
|
label,
|
||||||
|
kind: "remote" as const,
|
||||||
|
current: false,
|
||||||
|
trackedRemote: "",
|
||||||
|
localOnly: false,
|
||||||
|
representedBranches: [label],
|
||||||
|
})));
|
||||||
|
|
||||||
|
const sortedBranches = branches.sort(compareBranchDecorations);
|
||||||
|
const primaryBranch = sortedBranches.find(
|
||||||
|
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
|
||||||
|
) ?? null;
|
||||||
|
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
||||||
|
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
|
||||||
|
const primaryTag = sortedTags[0] ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
branches: sortedBranches,
|
||||||
|
tags: sortedTags,
|
||||||
|
other: sortedOther,
|
||||||
|
primaryBranch,
|
||||||
|
primaryTag,
|
||||||
|
overflowCount:
|
||||||
|
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
|
||||||
|
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
|
||||||
|
+ sortedOther.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchStatusLabel(branch: CommitBranchDecoration): string {
|
||||||
|
if (branch.label === activeBranch) {
|
||||||
|
const parts = [];
|
||||||
|
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
|
||||||
|
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
||||||
|
if (branch.localOnly) {
|
||||||
|
const status = branchStatusLabel(branch);
|
||||||
|
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
|
||||||
|
}
|
||||||
|
const status = branchStatusLabel(branch);
|
||||||
|
if (branch.trackedRemote) {
|
||||||
|
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
|
||||||
|
}
|
||||||
|
if (status) return `${branch.label} · ${status}`;
|
||||||
|
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCommitDate(value: string): string {
|
function formatCommitDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
@@ -494,43 +715,76 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const available = new Set(localBranchNames);
|
const currentRepository = repositoryKey;
|
||||||
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
|
if (loadedVisibilityRepository === currentRepository) return;
|
||||||
if (nextHidden.size !== hiddenGraphBranches.size) {
|
loadedVisibilityRepository = currentRepository;
|
||||||
hiddenGraphBranches = nextHidden;
|
expandedRefsCommitHash = "";
|
||||||
|
|
||||||
|
if (!currentRepository) {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
|
customVisibleBranches = new Set();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
|
||||||
|
if (!stored) {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
|
customVisibleBranches = new Set();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
|
||||||
|
const mode = parsed.mode;
|
||||||
|
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
|
||||||
|
? mode
|
||||||
|
: "focus";
|
||||||
|
customVisibleBranches = new Set(
|
||||||
|
Array.isArray(parsed.branches)
|
||||||
|
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
|
customVisibleBranches = new Set();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
const currentRepository = repositoryKey;
|
||||||
? activeBranch
|
const mode = branchVisibilityMode;
|
||||||
: (graphBranchNames[0] ?? "");
|
const branches = [...customVisibleBranches];
|
||||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
|
||||||
|
try {
|
||||||
if (!defaultBranch) {
|
localStorage.setItem(
|
||||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
|
||||||
hiddenGraphBranches = new Set();
|
JSON.stringify({ mode, branches }),
|
||||||
userAdjustedBranchFilter = false;
|
);
|
||||||
lastDefaultFilterKey = defaultFilterKey;
|
} catch {
|
||||||
}
|
// The graph still works if storage is unavailable.
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
$effect(() => {
|
||||||
userAdjustedBranchFilter = false;
|
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
|
||||||
lastDefaultFilterKey = defaultFilterKey;
|
const available = new Set(graphBranchNames);
|
||||||
}
|
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
|
||||||
|
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
|
||||||
|
});
|
||||||
|
|
||||||
if (!userAdjustedBranchFilter) {
|
$effect(() => {
|
||||||
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
|
if (!selectedCommitHash) return;
|
||||||
}
|
queueMicrotask(() => {
|
||||||
|
panelElement
|
||||||
|
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
|
||||||
|
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let branchMembership = $derived(branchMembershipByHash(commits));
|
let branchMembership = $derived(branchMembershipByHash(commits));
|
||||||
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
||||||
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
||||||
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
||||||
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
|
let visibleBranchCount = $derived(visibleGraphBranchNames.length);
|
||||||
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
||||||
let graphRows = $derived(graph.rows);
|
let graphRows = $derived(graph.rows);
|
||||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||||
@@ -544,17 +798,18 @@
|
|||||||
<span class="eyebrow">History</span>
|
<span class="eyebrow">History</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if localBranchNames.length > 0}
|
{#if graphBranchNames.length > 0}
|
||||||
<div class="section-head-actions">
|
<div class="section-head-actions">
|
||||||
<button
|
<button
|
||||||
class="graph-branch-dialog-button"
|
class="graph-branch-dialog-button"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={openBranchDialog}
|
onclick={openBranchDialog}
|
||||||
title="Select branches shown in the graph"
|
title="Customize visible branches"
|
||||||
|
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
||||||
>
|
>
|
||||||
<GitBranch size={13} aria-hidden="true" />
|
<GitBranch size={13} aria-hidden="true" />
|
||||||
Branches
|
Branches
|
||||||
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
<span>{visibleBranchCount}/{graphBranchNames.length}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -572,11 +827,13 @@
|
|||||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||||
{@const item = entry.commit}
|
{@const item = entry.commit}
|
||||||
{@const row = graphRows[rowIndex]}
|
{@const row = graphRows[rowIndex]}
|
||||||
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
{@const refSummary = commitRefSummary(item)}
|
||||||
{@const otherRefs = visibleRefs(item)}
|
|
||||||
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||||
<article
|
<article
|
||||||
class="commit-row graph-row"
|
class="commit-row graph-row"
|
||||||
|
class:selected={selectedCommitHash === item.hash}
|
||||||
|
class:has-note={item.has_note}
|
||||||
|
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"}
|
||||||
class:merge-row={item.parents.length > 1}
|
class:merge-row={item.parents.length > 1}
|
||||||
@@ -608,6 +865,15 @@
|
|||||||
vector-effect="non-scaling-stroke"
|
vector-effect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if refSummary.primaryBranch}
|
||||||
|
<path
|
||||||
|
class="graph-ref-connector"
|
||||||
|
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
|
||||||
|
stroke={row.dotColor}
|
||||||
|
stroke-width="1.5"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="graph-dot"
|
class="graph-dot"
|
||||||
@@ -619,25 +885,114 @@
|
|||||||
title={commitHoverTitle(item, row)}
|
title={commitHoverTitle(item, row)}
|
||||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||||
></span>
|
></span>
|
||||||
{#if hoverBranchRefs.length > 0}
|
|
||||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
|
||||||
{#each hoverBranchRefs as branch}
|
|
||||||
<span
|
|
||||||
class:remote={branch === activeUpstream}
|
|
||||||
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
|
|
||||||
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
|
|
||||||
title={branch}
|
|
||||||
>
|
|
||||||
<GitBranch size={10} aria-hidden="true" />
|
|
||||||
{branch}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="commit-body">
|
<div
|
||||||
|
class="commit-body"
|
||||||
|
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
||||||
|
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}
|
||||||
|
<div class="commit-ref-area">
|
||||||
|
<div class="commit-ref-strip" aria-label="Commit references">
|
||||||
|
{#if refSummary.primaryBranch}
|
||||||
|
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
||||||
|
<span
|
||||||
|
class="compact-ref-chip branch"
|
||||||
|
class:current={refSummary.primaryBranch.current}
|
||||||
|
class:remote={refSummary.primaryBranch.kind === "remote"}
|
||||||
|
title={branchDecorationTitle(refSummary.primaryBranch)}
|
||||||
|
>
|
||||||
|
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
|
||||||
|
<span>{refSummary.primaryBranch.label}</span>
|
||||||
|
{#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
|
||||||
|
<small>{branchStatusLabel(refSummary.primaryBranch)}</small>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if refSummary.primaryBranch.localOnly}
|
||||||
|
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
||||||
|
LOCAL
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.primaryTag}
|
||||||
|
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
|
||||||
|
<Tag size={10} aria-hidden="true" />
|
||||||
|
<span>{refSummary.primaryTag}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.overflowCount > 0}
|
||||||
|
<button
|
||||||
|
class="compact-ref-overflow"
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleCommitRefs(item)}
|
||||||
|
aria-expanded={expandedRefsCommitHash === item.hash}
|
||||||
|
aria-controls={`commit-refs-${item.hash}`}
|
||||||
|
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
|
||||||
|
>
|
||||||
|
+{refSummary.overflowCount}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
||||||
|
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
|
||||||
|
<strong>References on this commit</strong>
|
||||||
|
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
||||||
|
<section>
|
||||||
|
<span>Local</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
||||||
|
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
||||||
|
<i aria-hidden="true"></i>{branch.label}
|
||||||
|
{#if branch.current}<small>Current</small>{/if}
|
||||||
|
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
|
||||||
|
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
||||||
|
<section>
|
||||||
|
<span>Remote</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
||||||
|
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.tags.length > 0}
|
||||||
|
<section>
|
||||||
|
<span>Tags</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.tags as tag}
|
||||||
|
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.other.length > 0}
|
||||||
|
<section>
|
||||||
|
<span>Other</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.other as ref}
|
||||||
|
<span class="commit-ref-detail-item">{ref}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="commit-card-head">
|
<div class="commit-card-head">
|
||||||
<span class="commit-avatar">
|
<span class="commit-avatar">
|
||||||
{authorInitials(item.author_name)}
|
{authorInitials(item.author_name)}
|
||||||
@@ -655,18 +1010,47 @@
|
|||||||
<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>
|
||||||
|
|
||||||
{#if otherRefs.length > 0}
|
|
||||||
<div class="ref-list" aria-label="Commit refs">
|
|
||||||
{#each otherRefs as ref}
|
|
||||||
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if item.files.length > 0}
|
{#if item.files.length > 0}
|
||||||
<div class="commit-files">
|
<div class="commit-files">
|
||||||
<button
|
<button
|
||||||
@@ -705,6 +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
|
||||||
|
class="commit-menu-button commit-note-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => openCommitNote(item)}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={`Add a Git note to ${item.short_hash}`}
|
||||||
|
aria-label={`Add a Git note to ${item.short_hash}`}
|
||||||
|
>
|
||||||
|
<StickyNote size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="commit-menu-button"
|
class="commit-menu-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -752,6 +1148,10 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Branch
|
Branch
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
|
||||||
|
<StickyNote size={14} aria-hidden="true" />
|
||||||
|
Note
|
||||||
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Restore
|
Restore
|
||||||
@@ -792,25 +1192,43 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="branch-filter-summary">
|
<div class="branch-filter-summary">
|
||||||
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
|
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
|
||||||
<div class="branch-filter-actions">
|
<div class="branch-filter-actions">
|
||||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
|
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
||||||
|
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
||||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="branch-filter-dialog-list">
|
<div class="branch-filter-dialog-list">
|
||||||
{#each localBranchNames as branch}
|
{#if localBranchNames.length > 0}
|
||||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
<span class="branch-filter-group-label">Local</span>
|
||||||
<input
|
{#each localBranchNames as branch}
|
||||||
type="checkbox"
|
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||||
checked={branchIsVisible(branch)}
|
<input
|
||||||
onchange={() => toggleGraphBranch(branch)}
|
type="checkbox"
|
||||||
/>
|
checked={branchIsVisible(branch)}
|
||||||
<GitBranch size={14} aria-hidden="true" />
|
onchange={() => toggleGraphBranch(branch)}
|
||||||
<span>{branch}</span>
|
/>
|
||||||
</label>
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
{/each}
|
<span>{branch}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#if remoteBranchNames.length > 0}
|
||||||
|
<span class="branch-filter-group-label">Remote</span>
|
||||||
|
{#each remoteBranchNames as branch}
|
||||||
|
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={branchIsVisible(branch)}
|
||||||
|
onchange={() => toggleGraphBranch(branch)}
|
||||||
|
/>
|
||||||
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
|
<span>{branch}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
||||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||||
|
|
||||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||||
@@ -34,9 +34,12 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
language?: "en" | "de";
|
||||||
|
diffName?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void | Promise<void>;
|
onRefresh: () => void | Promise<void>;
|
||||||
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||||
|
onExternalDiff: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -46,11 +49,16 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "",
|
error = "",
|
||||||
|
language = "en",
|
||||||
|
diffName = "diff tool",
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onRefresh = () => {},
|
onRefresh = () => {},
|
||||||
onApply = () => {},
|
onApply = () => {},
|
||||||
|
onExternalDiff = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||||
let selectedLineIds = $state<Set<string>>(new Set());
|
let selectedLineIds = $state<Set<string>>(new Set());
|
||||||
@@ -270,6 +278,15 @@
|
|||||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-header-actions">
|
<div class="dialog-header-actions">
|
||||||
|
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
|
||||||
|
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
|
||||||
|
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
|
||||||
|
<FileDiff size={13} aria-hidden="true" />Gitty
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
|
||||||
|
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
|
|||||||
@@ -18,14 +18,29 @@
|
|||||||
|
|
||||||
let name = $state("");
|
let name = $state("");
|
||||||
|
|
||||||
|
function remoteName(branch: GitBranchInfo): string {
|
||||||
|
if (!branch.remote) return "";
|
||||||
|
const slash = branch.name.indexOf("/");
|
||||||
|
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableName(branch: GitBranchInfo): string {
|
||||||
|
if (!branch.remote) return branch.name;
|
||||||
|
const slash = branch.name.indexOf("/");
|
||||||
|
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
let originalName = $derived(editableName(branch));
|
||||||
|
let remote = $derived(remoteName(branch));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
name = branch.name;
|
name = originalName;
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function submit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const value = name.trim();
|
const value = name.trim();
|
||||||
if (!value || value === branch.name) return;
|
if (!value || value === originalName) return;
|
||||||
onRename(value);
|
onRename(value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -34,10 +49,10 @@
|
|||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Rename branch</span>
|
<span class="eyebrow">{branch.remote ? "Rename remote branch" : "Rename branch"}</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||||
@@ -48,27 +63,37 @@
|
|||||||
<form class="rename-branch-form" onsubmit={submit}>
|
<form class="rename-branch-form" onsubmit={submit}>
|
||||||
<label class="new-branch-field">
|
<label class="new-branch-field">
|
||||||
<span>Branch name</span>
|
<span>Branch name</span>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<div class:remote-branch-name-field={branch.remote}>
|
||||||
<input
|
{#if branch.remote}<strong>{remote}/</strong>{/if}
|
||||||
bind:value={name}
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
autocomplete="off"
|
<input
|
||||||
spellcheck="false"
|
bind:value={name}
|
||||||
disabled={isBusy}
|
autocomplete="off"
|
||||||
autofocus
|
spellcheck="false"
|
||||||
/>
|
disabled={isBusy}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{#if branch.remote}
|
||||||
|
<p class="rename-remote-note">
|
||||||
|
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
|
||||||
|
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="new-branch-actions">
|
<div class="new-branch-actions">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
|
||||||
{#if isBusy}
|
{#if isBusy}
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
Rename
|
{branch.remote ? "Rename on remote" : "Rename"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { AlertCircle, Check, LoaderCircle, X } from "@lucide/svelte";
|
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||||
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
||||||
|
|
||||||
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
|
onExternalMerge: (path: string) => void | Promise<void>;
|
||||||
|
language?: "en" | "de";
|
||||||
|
mergeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -37,7 +40,11 @@
|
|||||||
onSelectFile = () => {},
|
onSelectFile = () => {},
|
||||||
onMarkResolved = () => {},
|
onMarkResolved = () => {},
|
||||||
onApply = () => {},
|
onApply = () => {},
|
||||||
|
onExternalMerge = () => {},
|
||||||
|
language = "en",
|
||||||
|
mergeName = "merge tool",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
let conflictParts = $derived<ConflictPart[]>(
|
let conflictParts = $derived<ConflictPart[]>(
|
||||||
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
||||||
@@ -268,9 +275,22 @@
|
|||||||
<span class="eyebrow">Resolve</span>
|
<span class="eyebrow">Resolve</span>
|
||||||
<h2 class="dialog-title">Conflicts</h2>
|
<h2 class="dialog-title">Conflicts</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<div class="dialog-header-actions">
|
||||||
<X size={18} aria-hidden="true" />
|
{#if conflictTarget}
|
||||||
</button>
|
<div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
|
||||||
|
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
|
||||||
|
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
|
||||||
|
<GitMerge size={13} aria-hidden="true" />Gitty
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
|
||||||
|
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if conflictedFiles.length === 0}
|
{#if conflictedFiles.length === 0}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { StoredCredential } from "./types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a credential key from a remote URL, scoped to host + organisation —
|
* Derives a credential key from a remote URL, scoped to host + organisation —
|
||||||
* the same granularity Azure DevOps / GitHub use. Examples:
|
* the same granularity Azure DevOps / GitHub use. Examples:
|
||||||
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
|
|||||||
return org ? `${host}/${org}` : host;
|
return org ? `${host}/${org}` : host;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A stored credential is expired only if it carries a past expiry date. */
|
|
||||||
export function isCredentialExpired(cred: StoredCredential): boolean {
|
|
||||||
if (!cred.expiresAt) return false;
|
|
||||||
const time = new Date(cred.expiresAt).getTime();
|
|
||||||
return !Number.isNaN(time) && time < Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const AUTH_PREFIX = "AUTH_FAILED:";
|
const AUTH_PREFIX = "AUTH_FAILED:";
|
||||||
|
|
||||||
export function isAuthError(message: string): boolean {
|
export function isAuthError(message: string): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import type {
|
||||||
|
DetectedExternalTool,
|
||||||
|
ExternalToolKind,
|
||||||
|
ExternalToolSetting,
|
||||||
|
ExternalToolsSettings,
|
||||||
|
ToolOpenMode,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export type { ExternalToolKind } from "./types";
|
||||||
|
|
||||||
|
export interface ExternalToolPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
program: string;
|
||||||
|
args: string[];
|
||||||
|
builtIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolIdentity = Pick<ExternalToolPreset, "id" | "label" | "program">;
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
export const externalToolsPlatform = userAgent.includes("Windows")
|
||||||
|
? "windows"
|
||||||
|
: userAgent.includes("Mac")
|
||||||
|
? "macos"
|
||||||
|
: "linux";
|
||||||
|
|
||||||
|
const codeFamily: ToolIdentity[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "vscode", label: "Visual Studio Code", program: "Code.exe" },
|
||||||
|
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "Code - Insiders.exe" },
|
||||||
|
{ id: "cursor", label: "Cursor", program: "Cursor.exe" },
|
||||||
|
{ id: "windsurf", label: "Windsurf", program: "Windsurf.exe" },
|
||||||
|
{ id: "vscodium", label: "VSCodium", program: "VSCodium.exe" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "vscode", label: "Visual Studio Code", program: "code" },
|
||||||
|
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "code-insiders" },
|
||||||
|
{ id: "cursor", label: "Cursor", program: "cursor" },
|
||||||
|
{ id: "windsurf", label: "Windsurf", program: "windsurf" },
|
||||||
|
{ id: "vscodium", label: "VSCodium", program: "codium" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const jetBrains: ToolIdentity[] = [
|
||||||
|
{ id: "intellij-idea", label: "IntelliJ IDEA", program: externalToolsPlatform === "windows" ? "idea64.exe" : "idea" },
|
||||||
|
{ id: "webstorm", label: "WebStorm", program: externalToolsPlatform === "windows" ? "webstorm64.exe" : "webstorm" },
|
||||||
|
{ id: "pycharm", label: "PyCharm", program: externalToolsPlatform === "windows" ? "pycharm64.exe" : "pycharm" },
|
||||||
|
{ id: "phpstorm", label: "PhpStorm", program: externalToolsPlatform === "windows" ? "phpstorm64.exe" : "phpstorm" },
|
||||||
|
{ id: "rider", label: "JetBrains Rider", program: externalToolsPlatform === "windows" ? "rider64.exe" : "rider" },
|
||||||
|
{ id: "clion", label: "CLion", program: externalToolsPlatform === "windows" ? "clion64.exe" : "clion" },
|
||||||
|
{ id: "rustrover", label: "RustRover", program: externalToolsPlatform === "windows" ? "rustrover64.exe" : "rustrover" },
|
||||||
|
{ id: "goland", label: "GoLand", program: externalToolsPlatform === "windows" ? "goland64.exe" : "goland" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const editor = (tool: ToolIdentity, args = ["{file}"]): ExternalToolPreset => ({ ...tool, args });
|
||||||
|
const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--diff", "{left}", "{right}"] });
|
||||||
|
const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||||
|
const jetBrainsDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["diff", "{left}", "{right}"] });
|
||||||
|
const jetBrainsMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||||
|
|
||||||
|
const commonEditors: ExternalToolPreset[] = [
|
||||||
|
...codeFamily.map((item) => editor(item, ["--new-window", "{file}"])),
|
||||||
|
editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }, ["--new", "{file}"]),
|
||||||
|
editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }, ["--new-window", "{file}"]),
|
||||||
|
...jetBrains.map((item) => editor(item)),
|
||||||
|
editor({ id: "neovim", label: "Neovim", program: externalToolsPlatform === "windows" ? "nvim.exe" : "nvim" }),
|
||||||
|
editor({ id: "vim", label: "Vim", program: externalToolsPlatform === "windows" ? "gvim.exe" : "gvim" }),
|
||||||
|
editor({ id: "emacs", label: "Emacs", program: externalToolsPlatform === "windows" ? "runemacs.exe" : "emacs" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }, ["-multiInst", "{file}"]),
|
||||||
|
{ ...editor({ id: "notepad", label: "Windows Notepad", program: "notepad.exe" }), builtIn: true },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
editor({ id: "nova", label: "Nova", program: "nova" }),
|
||||||
|
editor({ id: "textmate", label: "TextMate", program: "mate" }),
|
||||||
|
editor({ id: "bbedit", label: "BBEdit", program: "bbedit" }),
|
||||||
|
editor({ id: "xcode", label: "Xcode", program: "xed" }),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
editor({ id: "lapce", label: "Lapce", program: "lapce" }),
|
||||||
|
editor({ id: "kate", label: "Kate", program: "kate" }, ["--new", "{file}"]),
|
||||||
|
editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }),
|
||||||
|
editor({ id: "geany", label: "Geany", program: "geany" }, ["--new-instance", "{file}"]),
|
||||||
|
editor({ id: "helix", label: "Helix", program: "hx" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "/readonly", "{left}", "{right}"] },
|
||||||
|
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "/wl", "/wr", "{left}", "{right}"] },
|
||||||
|
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "{left}", "{right}"] },
|
||||||
|
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{left}", "{right}"] },
|
||||||
|
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{left}", "/mine:{right}"] },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
|
||||||
|
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--wait", "{left}", "{right}"] },
|
||||||
|
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{left}", "{right}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{left}", "{right}"] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
|
||||||
|
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "{left}", "{right}"] },
|
||||||
|
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "kompare", label: "Kompare", program: "kompare", args: ["{left}", "{right}"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||||
|
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] },
|
||||||
|
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||||
|
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||||
|
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||||
|
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{base}", "/theirs:{theirs}", "/mine:{ours}", "/merged:{result}"] },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||||
|
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--merge", "--output", "{result}", "{base}", "{ours}", "{theirs}"] },
|
||||||
|
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||||
|
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{ours}", "{theirs}", "-ancestor", "{base}", "-merge", "{result}"] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||||
|
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||||
|
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-w", "new", "-d", "{repo}"] },
|
||||||
|
{ id: "powershell", label: "PowerShell 7", program: "pwsh.exe", args: ["-NoExit", "-WorkingDirectory", "{repo}"] },
|
||||||
|
{ id: "windows-powershell", label: "Windows PowerShell", program: "powershell.exe", args: ["-NoExit"], builtIn: true },
|
||||||
|
{ id: "git-bash", label: "Git Bash", program: "git-bash.exe", args: ["--login", "-i"] },
|
||||||
|
{ id: "cmd", label: "Command Prompt", program: "cmd.exe", args: ["/K"], builtIn: true },
|
||||||
|
{ id: "wezterm", label: "WezTerm", program: "wezterm-gui.exe", args: ["start", "--cwd", "{repo}"] },
|
||||||
|
{ id: "alacritty", label: "Alacritty", program: "alacritty.exe", args: ["--working-directory", "{repo}"] },
|
||||||
|
{ id: "kitty-terminal", label: "kitty", program: "kitty.exe", args: ["--directory", "{repo}"] },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
{ id: "terminal", label: "Terminal", program: "open", args: ["-n", "-a", "Terminal", "{repo}"], builtIn: true },
|
||||||
|
{ id: "iterm2", label: "iTerm2", program: "open", args: ["-n", "-a", "iTerm", "{repo}"] },
|
||||||
|
{ id: "warp", label: "Warp", program: "open", args: ["-n", "-a", "Warp", "{repo}"] },
|
||||||
|
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||||
|
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "x-terminal", label: "System terminal", program: "x-terminal-emulator", args: ["--working-directory={repo}"] },
|
||||||
|
{ id: "gnome-terminal", label: "GNOME Terminal", program: "gnome-terminal", args: ["--working-directory={repo}"] },
|
||||||
|
{ id: "konsole", label: "Konsole", program: "konsole", args: ["--workdir", "{repo}"] },
|
||||||
|
{ id: "kitty-terminal", label: "kitty", program: "kitty", args: ["--directory", "{repo}"] },
|
||||||
|
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||||
|
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||||
|
{ id: "xfce-terminal", label: "Xfce Terminal", program: "xfce4-terminal", args: ["--working-directory={repo}"] },
|
||||||
|
{ id: "tilix", label: "Tilix", program: "tilix", args: ["--working-directory={repo}"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["/n,", "{repo}"], builtIn: true },
|
||||||
|
{ id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/N", "/T", "{repo}"] },
|
||||||
|
{ id: "directory-opus", label: "Directory Opus", program: "dopus.exe", args: ["{repo}"] },
|
||||||
|
{ id: "double-commander", label: "Double Commander", program: "doublecmd.exe", args: ["{repo}"] },
|
||||||
|
{ id: "freecommander", label: "FreeCommander XE", program: "FreeCommander.exe", args: ["/L={repo}"] },
|
||||||
|
{ id: "xyplorer", label: "XYplorer", program: "XYplorer.exe", args: ["{repo}"] },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
{ id: "finder", label: "Finder", program: "open", args: ["-n", "{repo}"], builtIn: true },
|
||||||
|
{ id: "forklift", label: "ForkLift", program: "forklift", args: ["{repo}"] },
|
||||||
|
{ id: "path-finder", label: "Path Finder", program: "Path Finder", args: ["{repo}"] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "system-file-manager", label: "System file manager", program: "xdg-open", args: ["{repo}"], builtIn: true },
|
||||||
|
{ id: "nautilus", label: "GNOME Files", program: "nautilus", args: ["{repo}"] },
|
||||||
|
{ id: "dolphin", label: "Dolphin", program: "dolphin", args: ["{repo}"] },
|
||||||
|
{ id: "thunar", label: "Thunar", program: "thunar", args: ["{repo}"] },
|
||||||
|
{ id: "nemo", label: "Nemo", program: "nemo", args: ["{repo}"] },
|
||||||
|
{ id: "pcmanfm", label: "PCManFM", program: "pcmanfm", args: ["{repo}"] },
|
||||||
|
{ id: "double-commander", label: "Double Commander", program: "doublecmd", args: ["{repo}"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const externalToolPresets: Record<ExternalToolKind, ExternalToolPreset[]> = {
|
||||||
|
editor: [...commonEditors, ...platformEditors],
|
||||||
|
diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--new", "--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)],
|
||||||
|
merge: [...dedicatedMerge, ...codeFamily.map(codeMerge), ...jetBrains.map(jetBrainsMerge)],
|
||||||
|
terminal: terminalPresets,
|
||||||
|
fileManager: fileManagerPresets,
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectedPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[]): DetectedExternalTool | undefined {
|
||||||
|
return detectedTools.find((tool) => tool.id === id && tool.kinds.includes(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExternalToolPresetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset, detectedTools: DetectedExternalTool[]): boolean {
|
||||||
|
return preset.builtIn === true || detectedPreset(kind, preset.id, detectedTools) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyExternalToolPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[] = []): ExternalToolSetting {
|
||||||
|
const value = externalToolPresets[kind].find((item) => item.id === id) ?? externalToolPresets[kind][0];
|
||||||
|
const detected = detectedPreset(kind, value.id, detectedTools);
|
||||||
|
return { preset: value.id, program: detected?.program ?? value.program, args: [...value.args] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferredPreset(kind: ExternalToolKind, detectedTools: DetectedExternalTool[]): ExternalToolPreset {
|
||||||
|
return externalToolPresets[kind].find((item) => isExternalToolPresetAvailable(kind, item, detectedTools))
|
||||||
|
?? externalToolPresets[kind][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultExternalToolsSettings(detectedTools: DetectedExternalTool[] = []): ExternalToolsSettings {
|
||||||
|
return {
|
||||||
|
editor: applyExternalToolPreset("editor", preferredPreset("editor", detectedTools).id, detectedTools),
|
||||||
|
diff: applyExternalToolPreset("diff", preferredPreset("diff", detectedTools).id, detectedTools),
|
||||||
|
merge: applyExternalToolPreset("merge", preferredPreset("merge", detectedTools).id, detectedTools),
|
||||||
|
terminal: applyExternalToolPreset("terminal", preferredPreset("terminal", detectedTools).id, detectedTools),
|
||||||
|
fileManager: applyExternalToolPreset("fileManager", preferredPreset("fileManager", detectedTools).id, detectedTools),
|
||||||
|
diffOpenMode: "gitty",
|
||||||
|
mergeOpenMode: "gitty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseSetting(kind: ExternalToolKind, value: unknown, fallback: ExternalToolSetting): ExternalToolSetting {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||||
|
const candidate = value as Partial<ExternalToolSetting>;
|
||||||
|
const program = typeof candidate.program === "string" && candidate.program.trim() ? candidate.program : fallback.program;
|
||||||
|
const storedArgs = Array.isArray(candidate.args)
|
||||||
|
? candidate.args.filter((item): item is string => typeof item === "string").slice(0, 64)
|
||||||
|
: fallback.args;
|
||||||
|
const preset = typeof candidate.preset === "string" && candidate.preset ? candidate.preset : fallback.preset;
|
||||||
|
// Preset arguments are application-owned and can be upgraded safely. Editing either
|
||||||
|
// the executable or arguments in Settings changes the preset to "custom", which keeps
|
||||||
|
// genuine user commands untouched.
|
||||||
|
const currentPreset = preset === "custom"
|
||||||
|
? undefined
|
||||||
|
: externalToolPresets[kind].find((item) => item.id === preset);
|
||||||
|
const args = currentPreset ? [...currentPreset.args] : storedArgs;
|
||||||
|
return { preset, program, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normaliseExternalToolsSettings(value: unknown): ExternalToolsSettings {
|
||||||
|
const defaults = defaultExternalToolsSettings();
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return defaults;
|
||||||
|
const candidate = value as Partial<ExternalToolsSettings>;
|
||||||
|
return {
|
||||||
|
editor: normaliseSetting("editor", candidate.editor, defaults.editor),
|
||||||
|
diff: normaliseSetting("diff", candidate.diff, defaults.diff),
|
||||||
|
merge: normaliseSetting("merge", candidate.merge, defaults.merge),
|
||||||
|
terminal: normaliseSetting("terminal", candidate.terminal, defaults.terminal),
|
||||||
|
fileManager: normaliseSetting("fileManager", candidate.fileManager, defaults.fileManager),
|
||||||
|
diffOpenMode: normaliseOpenMode(candidate.diffOpenMode, defaults.diffOpenMode),
|
||||||
|
mergeOpenMode: normaliseOpenMode(candidate.mergeOpenMode, defaults.mergeOpenMode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseOpenMode(value: unknown, fallback: ToolOpenMode): ToolOpenMode {
|
||||||
|
return value === "gitty" || value === "external" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDetectedExternalToolPrograms(settings: ExternalToolsSettings, detectedTools: DetectedExternalTool[]): ExternalToolsSettings {
|
||||||
|
const resolved = structuredClone(settings);
|
||||||
|
for (const kind of ["editor", "diff", "merge", "terminal", "fileManager"] as ExternalToolKind[]) {
|
||||||
|
if (resolved[kind].preset === "custom") continue;
|
||||||
|
const detected = detectedPreset(kind, resolved[kind].preset, detectedTools);
|
||||||
|
if (detected) resolved[kind].program = detected.program;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function externalToolDisplayName(kind: ExternalToolKind, setting: ExternalToolSetting, detectedTools: DetectedExternalTool[] = []): string {
|
||||||
|
if (setting.preset === "custom") return setting.program.split(/[\\/]/).pop() || setting.program;
|
||||||
|
return detectedPreset(kind, setting.preset, detectedTools)?.label
|
||||||
|
?? externalToolPresets[kind].find((item) => item.id === setting.preset)?.label
|
||||||
|
?? setting.program;
|
||||||
|
}
|
||||||
+77
-10
@@ -7,6 +7,9 @@ import type {
|
|||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
CommitAiStatus,
|
CommitAiStatus,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
|
DetectedExternalTool,
|
||||||
|
ExternalToolCommand,
|
||||||
|
ExternalDiffScope,
|
||||||
GitBlameResult,
|
GitBlameResult,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
@@ -45,6 +48,22 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
|
|||||||
return invoke<void>("open_repository_file", { path, file });
|
return invoke<void>("open_repository_file", { path, file });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectExternalTools(): Promise<DetectedExternalTool[]> {
|
||||||
|
return invoke<DetectedExternalTool[]>("detect_external_tools");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
|
||||||
|
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchExternalDiff(path: string, file: string, command: ExternalToolCommand, scope: ExternalDiffScope = "head"): Promise<void> {
|
||||||
|
return invoke<void>("launch_external_diff", { path, file, command, scope });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchExternalMerge(path: string, file: string, command: ExternalToolCommand): Promise<void> {
|
||||||
|
return invoke<void>("launch_external_merge", { path, file, command });
|
||||||
|
}
|
||||||
|
|
||||||
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||||
}
|
}
|
||||||
@@ -88,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 });
|
||||||
@@ -114,6 +138,24 @@ export function renameBranch(
|
|||||||
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameRemoteBranch(
|
||||||
|
path: string,
|
||||||
|
remote: string,
|
||||||
|
oldBranch: string,
|
||||||
|
newBranch: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
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> {
|
||||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||||
}
|
}
|
||||||
@@ -345,21 +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(
|
export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
|
||||||
key: string,
|
return invoke<void>("cred_save", { key, username, password, mode });
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
expiresAt: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
return invoke<void>("cred_save", { key, username, password, expiresAt });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credDelete(key: string): Promise<void> {
|
export function credDelete(key: string): Promise<void> {
|
||||||
@@ -370,6 +407,36 @@ export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCom
|
|||||||
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
|
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCommitNote(path: string, commit: string): Promise<string | null> {
|
||||||
|
return invoke<string | null>("get_commit_note", { path, commit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCommitNote(path: string, commit: string, note: string): Promise<void> {
|
||||||
|
return invoke<void>("set_commit_note", { path, commit, note });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCommitNote(path: string, commit: string): Promise<void> {
|
||||||
|
return invoke<void>("delete_commit_note", { path, commit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
|
||||||
|
return invoke<void>("fetch_commit_notes", {
|
||||||
|
path,
|
||||||
|
remote,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
|
||||||
|
return invoke<void>("push_commit_notes", {
|
||||||
|
path,
|
||||||
|
remote,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("restore_to_commit", { path, commit });
|
return invoke<GitStatus>("restore_to_commit", { path, commit });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ type TelemetryLevel = "info" | "warn" | "error";
|
|||||||
const MAX_MESSAGE_LENGTH = 2_048;
|
const MAX_MESSAGE_LENGTH = 2_048;
|
||||||
let telemetryEnabled = false;
|
let telemetryEnabled = false;
|
||||||
let configuration: Promise<unknown> = Promise.resolve();
|
let configuration: Promise<unknown> = Promise.resolve();
|
||||||
|
let frontendShuttingDown = false;
|
||||||
|
|
||||||
|
export function beginFrontendShutdown() {
|
||||||
|
frontendShuttingDown = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Telemetry must never contain repository contents or identifying local data.
|
// Telemetry must never contain repository contents or identifying local data.
|
||||||
function sanitize(message: string): string {
|
function sanitize(message: string): string {
|
||||||
@@ -42,6 +47,14 @@ function randomHex(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
if (frontendShuttingDown) {
|
||||||
|
// The window is on its way out and nothing calls preventDefault() on the
|
||||||
|
// close event, so it will finish closing regardless. Never resolving
|
||||||
|
// avoids surfacing a spurious "shutting down" error in whatever UI catch
|
||||||
|
// block happens to be awaiting an in-flight operation (e.g. a pull/push
|
||||||
|
// that makes several sequential calls) when the close request lands.
|
||||||
|
return new Promise<T>(() => {});
|
||||||
|
}
|
||||||
if (!telemetryEnabled) return invoke<T>(command, args);
|
if (!telemetryEnabled) return invoke<T>(command, args);
|
||||||
const startedAtMs = Date.now();
|
const startedAtMs = Date.now();
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
|
|||||||
+33
-1
@@ -69,6 +69,36 @@ export interface AnalyticsSettings {
|
|||||||
noticeSeen: boolean;
|
noticeSeen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalToolCommand {
|
||||||
|
program: string;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalToolSetting extends ExternalToolCommand {
|
||||||
|
preset: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExternalToolKind = "editor" | "diff" | "merge" | "terminal" | "fileManager";
|
||||||
|
export type ToolOpenMode = "gitty" | "external";
|
||||||
|
export type ExternalDiffScope = "head" | "staged" | "unstaged";
|
||||||
|
|
||||||
|
export interface ExternalToolsSettings {
|
||||||
|
editor: ExternalToolSetting;
|
||||||
|
diff: ExternalToolSetting;
|
||||||
|
merge: ExternalToolSetting;
|
||||||
|
terminal: ExternalToolSetting;
|
||||||
|
fileManager: ExternalToolSetting;
|
||||||
|
diffOpenMode: ToolOpenMode;
|
||||||
|
mergeOpenMode: ToolOpenMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectedExternalTool {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
program: string;
|
||||||
|
kinds: ExternalToolKind[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitStatus {
|
export interface GitStatus {
|
||||||
repo_path: string;
|
repo_path: string;
|
||||||
current_branch: string | null;
|
current_branch: string | null;
|
||||||
@@ -99,6 +129,7 @@ export interface GitBranch {
|
|||||||
name: string;
|
name: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
remote: boolean;
|
remote: boolean;
|
||||||
|
upstream: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitWorktree {
|
export interface GitWorktree {
|
||||||
@@ -147,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 {
|
||||||
@@ -279,5 +311,5 @@ export interface ReflogEntry {
|
|||||||
export interface StoredCredential {
|
export interface StoredCredential {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
expiresAt?: string | null;
|
mode?: "credentials" | "token";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user