Compare commits

...
18 Commits
Author SHA1 Message Date
Christoph Brandau a4936ba790 docs(ui): update changelog and help overlay text
publish / Build and publish Windows installer (release) Successful in 26m54s
publish / Build and publish Ubuntu AppImage (release) Successful in 20m10s
publish / Build and publish AUR packages (release) Successful in 45m9s
These changes update the docs to reflect the redesigned context menu.
HelpOverlay copy in EN/DE notes the separation of name, path, and count.
The changelog now records the context menu improvements in both languages.

- Update HelpOverlay translations to describe the redesigned context menu
- Extend changelog to mention the context menu improvements
- Align UI text with the new context menu design
2026-08-17 22:27:20 +02:00
Christoph Brandau b5d15f9190 feat(git): add stash push with optional paths and per-file scope
The stash push feature now supports limiting the stash to
selected files via an optional paths parameter.
The UI passes file paths to stash_push and adds a
per-file context menu for scoped stash operations.

- Extend stash API to accept optional paths for scoped stashes
- Implement per-file stash actions via a status panel context menu
- Update tests and docs to reflect scoped stash behavior
2026-08-17 22:24:22 +02:00
Christoph Brandau e7fbaadf5e build(release): bump to 2026.8.5 and update UI icons
This release bumps version numbers to 2026.8.5 across all
manifests and docs and includes UI refinements. The
status panel now uses lucide folder icons for tree view
and folders, replacing the previous icons and CSS.

- Replace the tree view icon with lucide FolderTree in status panel
- Use Folder and FolderOpen icons for expanded/collapsed folders
- Bump version to 2026.8.5 in all manifests and changelog
2026-08-17 22:02:46 +02:00
Christoph Brandau 8b559fa15c feat(status-panel): add tree view for changes
The body:
Introduces a tree view for the status panel in addition to the
existing list view. A new toggle allows switching between modes.

It builds a hierarchical tree of folders and files from changed
paths and renders both unstaged and staged sections with
collapsible folders and indentation.

Adds responsive styling so the view switch condenses to icons on
narrow screens and includes styles for folders, chevrons, and
folder counts.

- Build and sort a tree structure from file paths.
- Render folders and files with indentation in tree view.
- Add responsive styling and a view switch for status changes.
2026-08-17 21:42:32 +02:00
Christoph Brandau fac22ff173 feat(status-panel): overhaul status lanes and icons
The status panel now uses a grid-based layout with a flow divider
between unstaged and staged sections and clearer headers. Icons
were swapped to arrows for stage/unstage actions, and labels were
updated to show Working tree and Next commit for clarity.

- Replace action icons with arrows to indicate staging
- Redesign lanes and headers for a compact, responsive UI
- Add a divider and contextual labels for unstaged and staged sections
2026-08-17 21:30:53 +02:00
Christoph Brandau cd01df0108 feat(git-lfs): add Git LFS status and sidecar bundling
Adds Git LFS support to the backend API and related UI.
The app now exposes commands to inspect, install, track and pull.
A sidecar git-lfs binary is bundled and a prep script is added.
This prepares the correct binary for each target platform.

- Expose Git LFS status and management commands in API
- Bundle and prepare a sidecar git-lfs binary for targets
- Update packaging, docs, and README with LFS notes
2026-08-17 20:27:54 +02:00
Christoph 6f2d4dd8c9 style(ui): enforce square corners and preserve circular markers
Introduce a square UI language by resetting border-radius on
controls and surfaces to create consistent square corners app-wide.
Add explicit exceptions to preserve circular markers and keep
branch-flag shapes so avatars, dots, and graph connections remain
recognizable. Remove a few unnecessary !important modifiers to
simplify overrides.

- Reset border-radius globally for elements and pseudo-elements.
- Preserve circular avatars, graph dots, and similar markers.
- Keep curved branch flag shapes and targeted corner rules.
2026-08-16 16:42:22 +02:00
Christoph 33e85fe6be style(ui): tweak compact ref and repo-tab-close sizing
Increase the compact reference overflow to 20px and slightly raise its
mono font to improve legibility and vertical alignment. Add explicit
square sizing and an aspect-ratio for the repo tab close control so
its icon and layout remain consistent across breakpoints and overrides.
Use !important on the size rules to ensure they take precedence.

- bump .compact-ref-overflow height and font-size for alignment
- add fixed sizing and aspect-ratio for repo tab close button
2026-08-16 16:12:05 +02:00
Christoph 993179c25e feat(startup): wire startup repo path from CLI to UI
The app now supports opening a repository at startup by
reading a --repo argument and passing the path to the UI.
A new startup repository state is managed in the backend and
exposed via a tauri command and event, enabling the frontend
to auto-open the repository when ready. The UI adds a small
startup flow with retry handling to ensure the path is
consumed once available.
- Reads --repo or --repo=PATH and resolves relative paths
- Signals UI to open startup repo via open-startup-repository
- Frontend retries opening the repo until it succeeds
2026-08-16 15:54:54 +02:00
Christoph 25d7f27d7a feat(release): attach native Arch package and repackage for AUR
publish / Build and publish AUR packages (release) Successful in 46m31s
publish / Build and publish Ubuntu AppImage (release) Successful in 20m25s
publish / Build and publish Windows installer (release) Successful in 22m15s
Update release workflow to detect and attach the native Arch package.
Switch the binary AUR recipe to repackage the .pkg.tar.zst. Adjust
checksums and packaging steps so the output carries only the native
payload without bundling upstream package metadata.

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

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

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

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

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

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

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

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

- Make the close button a 24px circular, grid-centered element.
- Add focus-visible and hover color with a subtle accent background.
- Reduce the close icon to 11px to improve visual balance.
2026-08-15 18:51:01 +02:00
26 changed files with 2927 additions and 189 deletions
+103 -20
View File
@@ -123,11 +123,17 @@ jobs:
publish-arch:
name: Build and publish AUR packages
needs: publish-ubuntu
permissions:
contents: write
runs-on: archlinux
environment: production
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
GITEA_API: https://git.cbsk-tech.de/api/v1
OWNER: Christoph
REPO: GitLite
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
GITEA_FALLBACK_TOKEN: ${{ github.token }}
defaults:
run:
@@ -155,7 +161,7 @@ jobs:
>> /etc/pacman.d/mirrorlist
ARCH_PACKAGES=(
base-devel curl git nodejs npm openssh rust
base-devel curl git git-lfs nodejs npm openssh rust
webkit2gtk-4.1 gtk3 hicolor-icon-theme
libappindicator-gtk3 librsvg xdotool
)
@@ -236,44 +242,121 @@ jobs:
runuser -u builder -- \
bash -lc "cd '$AUR_SOURCE_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
ARCH_PACKAGE_PATH="$(find "$AUR_SOURCE_DIR" -maxdepth 1 -type f -name 'gitty-desktop-*.pkg.tar.zst' -print -quit)"
if [ -z "$ARCH_PACKAGE_PATH" ]; then
echo "The native Arch package was not created" >&2
exit 1
fi
echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
echo "ARCH_PACKAGE_PATH=$ARCH_PACKAGE_PATH" >> "$GITHUB_ENV"
- name: Attach native Arch package to Gitea release
run: |
set -euo pipefail
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo "A Gitea token is required to upload the native Arch package" >&2
exit 1
fi
RELEASE_JSON="$(curl --fail --location --silent --show-error \
--header "Authorization: token $TOKEN" \
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
export RELEASE_JSON
RELEASE_ID="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); if (!r.id) process.exit(1); process.stdout.write(String(r.id))")"
PACKAGE_NAME="${ARCH_PACKAGE_PATH##*/}"
export PACKAGE_NAME
if node -e "const r=JSON.parse(process.env.RELEASE_JSON); process.exit((r.assets || []).some(a => a.name === process.env.PACKAGE_NAME) ? 0 : 1)"; then
echo "Release asset $PACKAGE_NAME already exists; skipping upload."
else
curl --fail --location --silent --show-error \
--request POST \
--header "Authorization: token $TOKEN" \
--form "attachment=@$ARCH_PACKAGE_PATH;type=application/octet-stream" \
"$GITEA_API/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=$PACKAGE_NAME"
echo
echo "Attached $PACKAGE_NAME to Gitea release $RELEASE_TAG."
fi
- name: Generate and build binary AUR package
run: |
set -euo pipefail
LATEST_JSON="$(curl --fail --location --silent --show-error \
"$ARTIFACT_BASE_URL/gitty/latest.json")"
export LATEST_JSON
APPIMAGE_URL="$(node -e "const p=JSON.parse(process.env.LATEST_JSON); const a=p.platforms?.['linux-x86_64']?.url; if (!a) process.exit(1); process.stdout.write(a)")"
APPIMAGE_NAME="${APPIMAGE_URL##*/}"
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo "A Gitea token is required to locate the native Arch package" >&2
exit 1
fi
RELEASE_JSON="$(curl --fail --location --silent --show-error \
--header "Authorization: token $TOKEN" \
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
export RELEASE_JSON
PACKAGE_NAME="${ARCH_PACKAGE_PATH##*/}"
export PACKAGE_NAME
PACKAGE_URL="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); const a=(r.assets || []).find(a => a.name === process.env.PACKAGE_NAME); if (!a?.browser_download_url) process.exit(1); process.stdout.write(a.browser_download_url)")"
AUR_BIN_DIR="$(mktemp -d)"
cp PKGBUILD-bin "$AUR_BIN_DIR/PKGBUILD"
APPIMAGE_PATH="$AUR_BIN_DIR/$APPIMAGE_NAME"
PACKAGE_PATH="$AUR_BIN_DIR/$PACKAGE_NAME"
curl --fail --location --silent --show-error \
--output "$APPIMAGE_PATH" "$APPIMAGE_URL"
APPIMAGE_CHECKSUM="$(sha256sum "$APPIMAGE_PATH" | cut -d ' ' -f 1)"
ICON_PATH="$AUR_BIN_DIR/gitty-desktop.png"
curl --fail --location --silent --show-error \
--output "$ICON_PATH" \
"https://git.cbsk-tech.de/Christoph/GitLite/raw/tag/$RELEASE_TAG/src-tauri/icons/icon.png"
ICON_CHECKSUM="$(sha256sum "$ICON_PATH" | cut -d ' ' -f 1)"
--header "Authorization: token $TOKEN" \
--output "$PACKAGE_PATH" "$PACKAGE_URL"
PACKAGE_CHECKSUM="$(sha256sum "$PACKAGE_PATH" | cut -d ' ' -f 1)"
sed -i \
-e "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/" \
-e "s/^pkgrel=.*/pkgrel=1/" \
-e "s|^_appimage=.*|_appimage=\"$APPIMAGE_NAME\"|" \
-e "s|^_artifact_url=.*|_artifact_url=\"$APPIMAGE_URL\"|" \
-e "s/^_tag=.*/_tag=$RELEASE_TAG/" \
-e "/^sha256sums=/,/^[[:space:]]*'SKIP')$/c\\sha256sums=('$APPIMAGE_CHECKSUM'\\n '$ICON_CHECKSUM')" \
-e "s|^_package=.*|_package=\"$PACKAGE_NAME\"|" \
-e "s|^_artifact_url=.*|_artifact_url=\"$PACKAGE_URL\"|" \
-e "s/^sha256sums=.*/sha256sums=('$PACKAGE_CHECKSUM')/" \
"$AUR_BIN_DIR/PKGBUILD"
chown -R builder:builder "$AUR_BIN_DIR"
runuser -u builder -- \
bash -lc "cd '$AUR_BIN_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
ARCH_BIN_PACKAGE_PATH="$(find "$AUR_BIN_DIR" -maxdepth 1 -type f -name 'gitty-desktop-bin-*.pkg.tar.zst' -print -quit)"
if [ -z "$ARCH_BIN_PACKAGE_PATH" ]; then
echo "The binary Arch package was not created" >&2
exit 1
fi
echo "AUR_BIN_DIR=$AUR_BIN_DIR" >> "$GITHUB_ENV"
echo "ARCH_BIN_PACKAGE_PATH=$ARCH_BIN_PACKAGE_PATH" >> "$GITHUB_ENV"
- name: Attach binary Arch package to Gitea release
run: |
set -euo pipefail
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo "A Gitea token is required to upload the Arch package" >&2
exit 1
fi
RELEASE_JSON="$(curl --fail --location --silent --show-error \
--header "Authorization: token $TOKEN" \
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
export RELEASE_JSON
RELEASE_ID="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); if (!r.id) process.exit(1); process.stdout.write(String(r.id))")"
PACKAGE_NAME="${ARCH_BIN_PACKAGE_PATH##*/}"
export PACKAGE_NAME
if node -e "const r=JSON.parse(process.env.RELEASE_JSON); process.exit((r.assets || []).some(a => a.name === process.env.PACKAGE_NAME) ? 0 : 1)"; then
echo "Release asset $PACKAGE_NAME already exists; skipping upload."
else
curl --fail --location --silent --show-error \
--request POST \
--header "Authorization: token $TOKEN" \
--form "attachment=@$ARCH_BIN_PACKAGE_PATH;type=application/octet-stream" \
"$GITEA_API/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=$PACKAGE_NAME"
echo
echo "Attached $PACKAGE_NAME to Gitea release $RELEASE_TAG."
fi
- name: Publish PKGBUILD to AUR
env:
@@ -403,7 +486,7 @@ jobs:
- name: Install Ubuntu dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
sudo apt-get install -y build-essential curl wget file git-lfs libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: Setup Node
uses: actions/setup-node@v4
+4 -1
View File
@@ -1,6 +1,9 @@
/node_modules
/dist
/src-tauri/target
/src-tauri/binaries/git-lfs
/src-tauri/binaries/git-lfs.exe
/src-tauri/binaries/git-lfs-*-*-*
*.log
.idea
.DS_Store
@@ -9,4 +12,4 @@
target
*.pkg.tar.zst
pkg
pkg
+54
View File
@@ -4,6 +4,58 @@ All notable user-facing changes to Gitty are documented in this file.
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
## [2026.8.5] - 2026-08-17
### Added
- Integrated Git LFS management now detects the available extension and the
repository setup, shows tracked patterns and checkout objects, and provides
actions for local activation, tracking and untracking patterns, downloading
objects, and pruning the local cache. Desktop installers bundle Git LFS,
while Arch packages declare it as a dependency.
- The Changes panel now offers a List/Tree switch. Tree view groups staged and
unstaged files into independently collapsible folders while retaining all
existing file actions.
- Files and folders in the Changes panel now have context-menu actions for
staging or unstaging their scope and for creating a stash containing only
the selected file or folder.
- Gitty can open a repository directly at startup through the `--repo PATH` or
`--repo=PATH` command-line argument.
### Changed
- A successful Git pull now detects repositories that use LFS and downloads
the required LFS objects automatically with the same remote and credentials.
- Unstaged and staged changes use an equal-width side-by-side layout with
independent scrolling, directional stage/unstage actions, and a responsive
vertical fallback. The List/Tree switch is centered above both areas.
- File and folder context menus now separate the selected name from its parent
path, show the affected file count, and present stage, unstage, and stash
actions with clearer icons and descriptions.
- Controls and surfaces use a more consistent square visual language while
circular status markers, avatars, and branch shapes remain recognizable.
- The built-in German and English help now includes Git LFS setup, tracking,
troubleshooting, and everyday workflow guidance.
## [2026.8.4] - 2026-08-15
### Added
- Branch folders now have a context-menu action for deleting all contained
local or remote branches at once. The top-level remote folder such as
`origin` is protected, while its nested folders remain manageable. The
currently checked-out branch is kept,
and individual failures are reported after the remaining branches have been
processed.
### Changed
- The Compare selector is fully localized in German and uses the same visual
language as the external-tool selectors for its fields, groups, typography,
and dialog surfaces.
- Repository-tab close buttons are square and have clearer spacing, hover
behavior, and keyboard-focus feedback.
## [2026.8.3] - 2026-08-13
### Added
@@ -161,6 +213,8 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
[2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5
[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
+1 -1
View File
@@ -7,7 +7,7 @@ pkgdesc="A lightweight, modern Git client built with Tauri"
arch=('x86_64')
url="https://git.cbsk-tech.de/Christoph/GitLite"
license=('MIT')
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'git-lfs' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
makedepends=('rust' 'nodejs' 'npm')
options=('!lto' '!debug')
+11 -31
View File
@@ -1,45 +1,25 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
pkgname=gitty-desktop-bin
pkgver=2026.8.3
pkgver=2026.8.5
pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt AppImage)"
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch package)"
arch=('x86_64')
url="https://git.cbsk-tech.de/Christoph/GitLite"
license=('MIT')
depends=('fuse2' 'git' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
depends=('git' 'git-lfs' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
provides=('gitty-desktop')
conflicts=('gitty-desktop')
options=('!strip')
_appimage="Gitty_${pkgver}_amd64.AppImage"
_artifact_url="https://cdn.cbsk-tech.de/gitty/${pkgver}/${_appimage}"
_tag=2026.8.3
source=("${_appimage}::${_artifact_url}"
"gitty-desktop.png::${url}/raw/tag/${_tag}/src-tauri/icons/icon.png")
sha256sums=('SKIP'
'SKIP')
_package="gitty-desktop-${pkgver}-1-x86_64.pkg.tar.zst"
_artifact_url="https://git.cbsk-tech.de/Christoph/GitLite/releases/download/${pkgver}/${_package}"
source=("${_package}::${_artifact_url}")
noextract=("${_package}")
sha256sums=('SKIP')
package() {
install -Dm755 "$srcdir/$_appimage" \
"$pkgdir/opt/$pkgname/gitty-desktop.AppImage"
install -d "$pkgdir/usr/bin"
ln -s "/opt/$pkgname/gitty-desktop.AppImage" \
"$pkgdir/usr/bin/gitty-desktop"
install -Dm644 "$srcdir/gitty-desktop.png" \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty-desktop.png"
install -d "$pkgdir/usr/share/applications"
cat > "$pkgdir/usr/share/applications/gitty-desktop.desktop" <<-EOF
[Desktop Entry]
Type=Application
Name=Gitty
Comment=$pkgdesc
Exec=gitty-desktop
Icon=gitty-desktop
Terminal=false
Categories=Development;RevisionControl;
StartupWMClass=gitty
EOF
# Extract only the native package payload, without carrying its package
# metadata (.PKGINFO, .BUILDINFO and .MTREE) across.
bsdtar -xf "$srcdir/$_package" -C "$pkgdir" usr
}
+19 -3
View File
@@ -20,6 +20,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
- 🔄 Pull, Push & Fetch
- 🔀 Merge & Rebase
- 📦 Repository management
- 🗄️ Git LFS detection, tracking and object management
- 🎨 Modern and intuitive UI
---
@@ -60,7 +61,7 @@ Install Gitty from the AUR with an AUR helper:
yay -S gitty-desktop
```
To install the prebuilt AppImage instead of compiling from source:
To install the prebuilt native Arch package instead of compiling from source:
```bash
yay -S gitty-desktop-bin
@@ -75,8 +76,23 @@ makepkg -si
```
The source recipe downloads the public Gitea release archive and builds Gitty.
The `-bin` recipe installs the prebuilt AppImage. The release pipeline updates
both packages' versions, checksums, and `.SRCINFO` files.
The `-bin` recipe repackages the native `.pkg.tar.zst` release artifact. The
release pipeline updates both packages' versions, checksums, and `.SRCINFO`
files.
---
## Git LFS
Gitty bundles the `git-lfs` executable in its desktop installers and checks it
at runtime before offering LFS actions. Arch packages also declare `git-lfs` as
a dependency so Git hooks and command-line workflows outside Gitty use the same
extension. The repository toolbar exposes LFS setup, tracked patterns, object
downloads, and safe cache pruning. Normal clone, pull, checkout, and push
operations continue to use Git's standard LFS filters and pre-push hook. After
every successful pull, Gitty detects LFS usage and automatically downloads the
required LFS objects with the same remote and credentials, so no second pull is
needed.
---
+37
View File
@@ -73,6 +73,34 @@ interface GitRepositoryFile {
tracked: boolean;
status: FileStatusKind | null;
}
interface GitLfsStatus {
available: boolean;
bundled: boolean;
version: string | null;
filters_configured: boolean;
hook_installed: boolean;
repository_uses_lfs: boolean;
patterns: GitLfsPattern[];
files: GitLfsFile[];
}
interface GitLfsPattern {
pattern: string;
source: string;
lockable: boolean;
tracked: boolean;
}
interface GitLfsFile {
name: string;
size: number;
checkout: boolean;
downloaded: boolean;
oid_type: string;
oid: string;
version: string;
}
```
## Commands
@@ -83,6 +111,14 @@ The command list below includes the repository-management and synchronization AP
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
- `clone_repository(...): Promise<RepositoryBundle>`
- `get_status(path: string): Promise<GitStatus>`
- `git_lfs_status(path: string): Promise<GitLfsStatus>`
- `git_lfs_install(path: string): Promise<GitLfsStatus>`
- `git_lfs_track(path: string, pattern: string, lockable?: boolean): Promise<GitLfsStatus>`
- `git_lfs_untrack(path: string, pattern: string): Promise<GitLfsStatus>`
- `git_lfs_pull(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus>`
- `git_lfs_prune(path: string): Promise<GitLfsStatus>`
- `pull(...)` automatically runs `git lfs pull` after a successful Git pull when
the repository contains LFS attributes or tracked LFS objects.
- `list_branches(path: string): Promise<GitBranch[]>`
- `list_remotes(path: string): Promise<GitRemote[]>`
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
@@ -102,6 +138,7 @@ The command list below includes the repository-management and synchronization AP
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
- `commit(path: string, message: string): Promise<GitStatus>`
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.8.3",
"version": "2026.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.8.3",
"version": "2026.8.5",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "gitty",
"version": "2026.8.3",
"version": "2026.8.5",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"prepare:lfs": "node scripts/prepare-git-lfs-sidecar.mjs",
"icons": "node scripts/generate-app-icons.mjs",
"preview": "vite preview --host 127.0.0.1",
"tauri": "tauri",
+71
View File
@@ -0,0 +1,71 @@
import { copyFileSync, existsSync, mkdirSync, chmodSync, statSync } from "node:fs";
import { basename, delimiter, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(scriptDirectory, "..");
const binariesDirectory = join(projectRoot, "src-tauri", "binaries");
const extension = process.platform === "win32" ? ".exe" : "";
function defaultTargetTriple() {
const triples = {
"win32:x64": "x86_64-pc-windows-msvc",
"win32:arm64": "aarch64-pc-windows-msvc",
"linux:x64": "x86_64-unknown-linux-gnu",
"linux:arm64": "aarch64-unknown-linux-gnu",
"darwin:x64": "x86_64-apple-darwin",
"darwin:arm64": "aarch64-apple-darwin",
};
return triples[`${process.platform}:${process.arch}`];
}
function executableCandidates() {
const configured = process.env.GIT_LFS_BINARY?.trim();
if (configured) return [configured];
const pathEntries = (process.env.PATH ?? "")
.split(delimiter)
.filter(Boolean);
const candidates = pathEntries.map((entry) => join(entry, `git-lfs${extension}`));
if (process.platform === "win32") {
for (const entry of pathEntries) {
if (basename(entry).toLowerCase() === "cmd") {
candidates.push(resolve(entry, "..", "mingw64", "bin", "git-lfs.exe"));
}
}
}
return candidates;
}
const source = executableCandidates()
.filter((candidate) => existsSync(candidate))
// Git for Windows exposes a small launcher in cmd/ and the standalone
// executable in mingw64/bin/. The standalone file is the portable sidecar.
.sort((left, right) => statSync(right).size - statSync(left).size)[0];
if (!source) {
throw new Error(
"Git LFS was not found. Install git-lfs or set GIT_LFS_BINARY before building Gitty.",
);
}
const targetTriple = (
process.env.GITTY_TARGET_TRIPLE
?? process.env.TAURI_ENV_TARGET_TRIPLE
?? process.env.TARGET
?? defaultTargetTriple()
)?.trim();
if (!targetTriple) throw new Error("Rust did not report a target triple.");
mkdirSync(binariesDirectory, { recursive: true });
const developmentTarget = join(binariesDirectory, `git-lfs${extension}`);
const bundleTarget = join(
binariesDirectory,
`git-lfs-${targetTriple}${extension}`,
);
for (const target of [developmentTarget, bundleTarget]) {
if (resolve(source) !== resolve(target)) copyFileSync(source, target);
if (process.platform !== "win32") chmodSync(target, 0o755);
}
console.log(`Prepared bundled git-lfs from ${source} for ${targetTriple}.`);
+48
View File
@@ -0,0 +1,48 @@
MIT License
Copyright (c) 2014- GitHub, Inc. and Git LFS contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Portions of the subprocess and tools directories are copied from Go and are
under the following license:
Copyright (c) 2009,2010 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of Google Inc. nor the names of its contributors may be
used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+810 -31
View File
@@ -186,6 +186,61 @@ pub struct GitRepositoryFile {
pub status: Option<FileStatusKind>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitLfsPattern {
pub pattern: String,
pub source: String,
#[serde(default)]
pub lockable: bool,
#[serde(default = "default_true")]
pub tracked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitLfsFile {
pub name: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub checkout: bool,
#[serde(default)]
pub downloaded: bool,
#[serde(default)]
pub oid_type: String,
#[serde(default)]
pub oid: String,
#[serde(default)]
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitLfsStatus {
pub available: bool,
pub bundled: bool,
pub version: Option<String>,
pub filters_configured: bool,
pub hook_installed: bool,
pub repository_uses_lfs: bool,
pub patterns: Vec<GitLfsPattern>,
pub files: Vec<GitLfsFile>,
}
#[derive(Debug, Default, Deserialize)]
struct GitLfsPatternsOutput {
#[serde(default)]
patterns: Vec<GitLfsPattern>,
}
#[derive(Debug, Default, Deserialize)]
struct GitLfsFilesOutput {
#[serde(default)]
files: Option<Vec<GitLfsFile>>,
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitSearchHit {
pub commit_hash: String,
@@ -333,11 +388,50 @@ fn git_command() -> Command {
// and other message heuristics keep working (e.g. German git prints
// "Authentifizierung fehlgeschlagen" instead of "Authentication failed").
command.env("LC_ALL", "C");
if let Some(path) = command_path_with_bundled_lfs() {
command.env("PATH", path);
}
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
command
}
fn bundled_git_lfs_path() -> Option<PathBuf> {
#[cfg(windows)]
const EXECUTABLE_NAME: &str = "git-lfs.exe";
#[cfg(not(windows))]
const EXECUTABLE_NAME: &str = "git-lfs";
if let Ok(executable) = env::current_exe()
&& let Some(directory) = executable.parent()
{
let candidate = directory.join(EXECUTABLE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
if cfg!(debug_assertions) {
let candidate = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("binaries")
.join(EXECUTABLE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn command_path_with_bundled_lfs() -> Option<OsString> {
let directory = bundled_git_lfs_path()?.parent()?.to_path_buf();
let mut paths = vec![directory];
if let Some(current) = env::var_os("PATH") {
paths.extend(env::split_paths(&current));
}
env::join_paths(paths).ok()
}
#[derive(Debug, Default, Clone)]
pub struct SearchCancellationState {
cancelled: Arc<Mutex<BTreeSet<String>>>,
@@ -549,6 +643,92 @@ pub async fn get_status(path: String) -> Result<GitStatus, String> {
.await
}
#[tauri::command]
pub async fn git_lfs_status(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not inspect Git LFS", move || {
let repo = resolve_repo(&path)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_install(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not activate Git LFS", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
run_git(&repo, ["lfs", "install", "--local"])?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_track(
path: String,
pattern: String,
lockable: Option<bool>,
) -> Result<GitLfsStatus, String> {
run_git_task("Could not add Git LFS pattern", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
let pattern = validate_lfs_pattern(&pattern)?;
let mut args = vec![OsString::from("lfs"), OsString::from("track")];
if lockable.unwrap_or(false) {
args.push(OsString::from("--lockable"));
}
args.push(pattern.into());
run_git(&repo, args)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_untrack(path: String, pattern: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not remove Git LFS pattern", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
let pattern = validate_lfs_pattern(&pattern)?;
run_git(&repo, ["lfs", "untrack", pattern.as_str()])?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_pull(
path: String,
remote: Option<String>,
username: Option<String>,
password: Option<String>,
) -> Result<GitLfsStatus, String> {
run_git_task("Could not download Git LFS objects", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
pull_git_lfs_objects(
&repo,
remote.as_deref(),
username.as_deref(),
password.as_deref(),
)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_prune(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not prune Git LFS objects", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
run_git(&repo, ["lfs", "prune"])?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
run_git_task("Could not load branches", move || {
@@ -651,30 +831,136 @@ pub fn set_branch_upstream(
status_for_repo(&repo)
}
#[tauri::command(async)]
pub fn delete_remote_branch(
#[tauri::command]
pub async fn delete_remote_branch(
path: String,
remote: String,
branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}");
let result = (|| {
run_git_task("Could not delete remote branch", move || {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
delete_remote_branches_core(
&repo,
&remote,
vec![branch],
username.as_deref().zip(password.as_deref()),
)
})
.await
}
#[tauri::command]
pub async fn delete_remote_branches(
path: String,
remote: String,
branches: Vec<String>,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not delete remote branch folder", move || {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
delete_remote_branches_core(
&repo,
&remote,
branches,
username.as_deref().zip(password.as_deref()),
)
})
.await
}
fn delete_remote_branches_core(
repo: &Path,
remote: &str,
branches: Vec<String>,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> {
let run_remote = |args: Vec<String>| match credentials {
Some((user, pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, args, user, pass)
}
_ => run_git(repo, args),
};
let mut branch_names = Vec::new();
for branch in branches {
let branch = branch.trim();
if branch.is_empty() || branch.starts_with('-') {
return Err("Invalid remote branch name.".to_string());
}
run_git(&repo, ["check-ref-format", "--branch", branch])?;
log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}");
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}");
status_for_repo(&repo)
})();
if let Err(error) = &result {
log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}");
run_git(repo, ["check-ref-format", "--branch", branch])?;
if !branch_names.iter().any(|existing| existing == branch) {
branch_names.push(branch.to_string());
}
}
result
if branch_names.is_empty() {
return Err("No remote branches were selected.".to_string());
}
// Local remote-tracking refs can be stale when a branch was deleted by
// another client. Query the server first so deletion remains idempotent.
let mut query_args = vec![
"ls-remote".to_string(),
"--heads".to_string(),
remote.to_string(),
];
query_args.extend(
branch_names
.iter()
.map(|branch| format!("refs/heads/{branch}")),
);
let remote_refs = String::from_utf8_lossy(&run_remote(query_args)?).to_string();
let existing_refs: BTreeSet<&str> = remote_refs
.lines()
.filter_map(|line| line.split_once('\t').map(|(_, reference)| reference.trim()))
.collect();
branch_names.retain(|branch| existing_refs.contains(format!("refs/heads/{branch}").as_str()));
if !branch_names.is_empty() {
let mut atomic_args = vec![
"push".to_string(),
"--atomic".to_string(),
remote.to_string(),
"--delete".to_string(),
];
atomic_args.extend(branch_names.iter().cloned());
if let Err(error) = run_remote(atomic_args) {
let message = error.to_lowercase();
let atomic_unsupported = message.contains("does not support --atomic")
|| message.contains("atomic push is not supported")
|| message.contains("does not support atomic push");
if !atomic_unsupported {
return Err(error);
}
log::warn!(
target: "gitty::remote",
"remote {remote} does not support atomic pushes; retrying branch-folder deletion as one regular push"
);
let mut fallback_args = vec![
"push".to_string(),
remote.to_string(),
"--delete".to_string(),
];
fallback_args.extend(branch_names);
run_remote(fallback_args)?;
}
}
// Also remove stale tracking refs for branches that were already absent.
if let Err(error) = run_remote(vec![
"fetch".to_string(),
"--prune".to_string(),
remote.to_string(),
]) {
log::warn!(target: "gitty::remote", "remote branches were deleted, but tracking refs could not be pruned: {error}");
}
status_for_repo(repo)
}
#[tauri::command]
@@ -957,25 +1243,45 @@ pub async fn stash_push(
path: String,
message: Option<String>,
include_untracked: bool,
paths: Option<Vec<String>>,
) -> Result<GitStatus, String> {
run_git_task("Could not stash changes", move || {
let repo = resolve_repo(&path)?;
let trimmed_message = message.unwrap_or_default().trim().to_string();
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
if include_untracked {
args.push(OsString::from("--include-untracked"));
}
if !trimmed_message.is_empty() {
args.push(OsString::from("-m"));
args.push(OsString::from(trimmed_message));
}
run_git(&repo, args)?;
status_for_repo(&repo)
stash_push_for_repo(
&repo,
message.as_deref(),
include_untracked,
paths.as_deref().unwrap_or_default(),
)
})
.await
}
fn stash_push_for_repo(
repo: &Path,
message: Option<&str>,
include_untracked: bool,
paths: &[String],
) -> Result<GitStatus, String> {
validate_files(paths)?;
let trimmed_message = message.unwrap_or_default().trim();
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
if include_untracked {
args.push(OsString::from("--include-untracked"));
}
if !trimmed_message.is_empty() {
args.push(OsString::from("-m"));
args.push(OsString::from(trimmed_message));
}
if !paths.is_empty() {
args.push(OsString::from("--"));
args.extend(paths.iter().map(OsString::from));
}
run_git(repo, args)?;
status_for_repo(repo)
}
#[tauri::command]
pub async fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
run_git_task("Could not apply stash", move || {
@@ -2311,6 +2617,9 @@ pub async fn pull(
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let strategy = strategy.as_deref().unwrap_or("merge");
let remote = remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
@@ -2320,12 +2629,9 @@ pub async fn pull(
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote) = remote
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
validate_remote_name(&repo, &remote, true)?;
pull_args.push(remote.into());
if let Some(remote_name) = remote.as_deref() {
validate_remote_name(&repo, remote_name, true)?;
pull_args.push(remote_name.into());
if let Some(branch) = branch
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
@@ -2346,6 +2652,15 @@ pub async fn pull(
};
if output.status.success() {
if repository_uses_lfs(&repo) {
ensure_git_lfs_available()?;
pull_git_lfs_objects(
&repo,
remote.as_deref(),
username.as_deref(),
password.as_deref(),
)?;
}
return status_for_repo(&repo);
}
@@ -4472,6 +4787,299 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
})
}
fn git_lfs_status_for_repo(repo: &Path) -> Result<GitLfsStatus, String> {
let bundled = bundled_git_lfs_path().is_some();
let version = git_lfs_version();
let available = version.is_some();
let patterns = if available {
git_lfs_patterns(repo).unwrap_or_else(|_| lfs_patterns_from_attributes(repo))
} else {
lfs_patterns_from_attributes(repo)
};
let files = if available {
git_lfs_files(repo).unwrap_or_default()
} else {
Vec::new()
};
Ok(GitLfsStatus {
available,
bundled,
version,
filters_configured: lfs_filters_configured(repo),
hook_installed: lfs_hook_installed(repo),
repository_uses_lfs: !patterns.is_empty() || !files.is_empty(),
patterns,
files,
})
}
fn git_lfs_version() -> Option<String> {
let output = git_command().args(["lfs", "version"]).output().ok()?;
if !output.status.success() {
return None;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!version.is_empty()).then_some(version)
}
fn ensure_git_lfs_available() -> Result<String, String> {
git_lfs_version().ok_or_else(|| {
"Git LFS is unavailable. Reinstall Gitty or install git-lfs and restart the app."
.to_string()
})
}
fn repository_uses_lfs(repo: &Path) -> bool {
!lfs_patterns_from_attributes(repo).is_empty()
|| git_lfs_files(repo).is_ok_and(|files| !files.is_empty())
}
fn pull_git_lfs_objects(
repo: &Path,
remote: Option<&str>,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let mut args = vec![OsString::from("lfs"), OsString::from("pull")];
if let Some(remote) = remote.map(str::trim).filter(|remote| !remote.is_empty()) {
args.push(validate_remote_name(repo, remote, true)?.into());
}
let output = match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated_output(repo, args, user, pass)?
}
_ => git_command()
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|err| format!("Could not start Git LFS: {err}"))?,
};
if output.status.success() {
return Ok(());
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git LFS pull failed: {details}"))
}
fn lfs_filters_configured(repo: &Path) -> bool {
[
"filter.lfs.process",
"filter.lfs.clean",
"filter.lfs.smudge",
]
.iter()
.all(|key| git_config_value(repo, key).is_some())
}
fn git_config_value(repo: &Path, key: &str) -> Option<String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["config", "--get", key])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
fn lfs_hook_installed(repo: &Path) -> bool {
let Ok(path) = run_git(repo, ["rev-parse", "--git-path", "hooks/pre-push"]) else {
return false;
};
let path = PathBuf::from(String::from_utf8_lossy(&path).trim().to_string());
let path = if path.is_absolute() {
path
} else {
repo.join(path)
};
let Ok(hook) = fs::read_to_string(path) else {
return false;
};
hook.contains("git lfs pre-push") || hook.contains("git-lfs pre-push")
}
fn git_lfs_patterns(repo: &Path) -> Result<Vec<GitLfsPattern>, String> {
let output = run_git(repo, ["lfs", "track", "--json"])?;
let mut parsed: GitLfsPatternsOutput = serde_json::from_slice(&output)
.map_err(|err| format!("Git LFS returned invalid tracked-pattern data: {err}"))?;
parsed.patterns.retain(|pattern| pattern.tracked);
parsed.patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
Ok(parsed.patterns)
}
fn git_lfs_files(repo: &Path) -> Result<Vec<GitLfsFile>, String> {
let json_output = run_git(repo, ["lfs", "ls-files", "--long", "--size", "--json"]);
if let Ok(output) = json_output {
let parsed: GitLfsFilesOutput = serde_json::from_slice(&output)
.map_err(|err| format!("Git LFS returned invalid file data: {err}"))?;
let mut files = parsed.files.unwrap_or_default();
files.sort_by(|left, right| left.name.cmp(&right.name));
return Ok(files);
}
// Git LFS versions before JSON output was introduced still expose a stable
// line format. Size is left at zero because the human-readable --size
// suffix cannot be converted back to exact bytes reliably.
let output = run_git(repo, ["lfs", "ls-files", "--long"])?;
let mut files = String::from_utf8_lossy(&output)
.lines()
.filter_map(parse_git_lfs_file_line)
.collect::<Vec<_>>();
files.sort_by(|left, right| left.name.cmp(&right.name));
Ok(files)
}
fn parse_git_lfs_file_line(line: &str) -> Option<GitLfsFile> {
let mut fields = line.splitn(3, ' ');
let oid = fields.next()?.trim();
let marker = fields.next()?.trim();
let name = fields.next()?.trim();
if oid.is_empty() || name.is_empty() || !matches!(marker, "*" | "-") {
return None;
}
Some(GitLfsFile {
name: name.to_string(),
size: 0,
checkout: marker == "*",
downloaded: marker == "*",
oid_type: "sha256".to_string(),
oid: oid.to_string(),
version: "https://git-lfs.github.com/spec/v1".to_string(),
})
}
fn lfs_patterns_from_attributes(repo: &Path) -> Vec<GitLfsPattern> {
let Ok(output) = run_git(
repo,
[
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
],
) else {
return Vec::new();
};
let mut patterns = Vec::new();
for path in parse_nul_paths(&output) {
if Path::new(&path).file_name() != Some(OsStr::new(".gitattributes")) {
continue;
}
read_lfs_patterns_from_file(repo, &path, &mut patterns);
}
if let Ok(info_path) = run_git(repo, ["rev-parse", "--git-path", "info/attributes"]) {
let path = PathBuf::from(String::from_utf8_lossy(&info_path).trim().to_string());
let path = if path.is_absolute() {
path
} else {
repo.join(path)
};
if let Ok(contents) = fs::read_to_string(path) {
append_lfs_patterns(&contents, ".git/info/attributes", &mut patterns);
}
}
patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
patterns.dedup();
patterns
}
fn read_lfs_patterns_from_file(
repo: &Path,
relative_path: &str,
patterns: &mut Vec<GitLfsPattern>,
) {
let Ok(contents) = fs::read_to_string(repo.join(relative_path)) else {
return;
};
append_lfs_patterns(&contents, relative_path, patterns);
}
fn append_lfs_patterns(contents: &str, source: &str, patterns: &mut Vec<GitLfsPattern>) {
for line in contents.lines() {
let Some((pattern, attributes)) = split_gitattributes_pattern(line) else {
continue;
};
let attributes = attributes.split_whitespace().collect::<Vec<_>>();
if !attributes.contains(&"filter=lfs") {
continue;
}
patterns.push(GitLfsPattern {
pattern,
source: source.replace('\\', "/"),
lockable: attributes.contains(&"lockable"),
tracked: true,
});
}
}
fn split_gitattributes_pattern(line: &str) -> Option<(String, &str)> {
let line = line.trim_start();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
return None;
}
let mut pattern = String::new();
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
pattern.push(character);
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if character.is_whitespace() {
let attributes = line[index..].trim_start();
return (!pattern.is_empty() && !attributes.is_empty())
.then_some((pattern, attributes));
}
pattern.push(character);
}
None
}
fn validate_lfs_pattern(pattern: &str) -> Result<String, String> {
let pattern = pattern.trim();
if pattern.is_empty() {
return Err("Git LFS pattern must not be empty.".to_string());
}
if pattern.len() > 1_024 {
return Err("Git LFS pattern is too long.".to_string());
}
if pattern.starts_with('-') {
return Err("Git LFS pattern must not start with '-'.".to_string());
}
if pattern.chars().any(char::is_control) {
return Err("Git LFS pattern contains invalid control characters.".to_string());
}
Ok(pattern.to_string())
}
fn rebase_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
}
@@ -6346,6 +6954,177 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]);
}
#[test]
fn parses_git_lfs_json_status_records() {
let patterns: GitLfsPatternsOutput = serde_json::from_str(
r#"{"patterns":[{"pattern":"*.psd","source":".gitattributes","lockable":true,"tracked":true}]}"#,
)
.expect("pattern JSON should parse");
assert_eq!(patterns.patterns.len(), 1);
assert_eq!(patterns.patterns[0].pattern, "*.psd");
assert!(patterns.patterns[0].lockable);
let files: GitLfsFilesOutput = serde_json::from_str(
r#"{"files":[{"name":"Assets/scene.psd","size":2048,"checkout":false,"downloaded":true,"oid_type":"sha256","oid":"abc","version":"https://git-lfs.github.com/spec/v1"}]}"#,
)
.expect("file JSON should parse");
let file = files
.files
.expect("files should be present")
.pop()
.expect("one file should be present");
assert_eq!(file.name, "Assets/scene.psd");
assert_eq!(file.size, 2048);
assert!(file.downloaded);
}
#[test]
fn parses_lfs_attributes_and_plain_file_output() {
assert_eq!(
split_gitattributes_pattern(r"Assets/My\ Files/** filter=lfs diff=lfs -text"),
Some((
"Assets/My Files/**".to_string(),
"filter=lfs diff=lfs -text"
))
);
assert_eq!(split_gitattributes_pattern("# *.zip filter=lfs"), None);
let file = parse_git_lfs_file_line(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - Assets/My File.bin",
)
.expect("plain LFS record should parse");
assert_eq!(file.name, "Assets/My File.bin");
assert!(!file.downloaded);
assert!(!file.checkout);
}
#[test]
fn validates_git_lfs_patterns() {
assert_eq!(validate_lfs_pattern(" *.psd ").unwrap(), "*.psd");
assert!(validate_lfs_pattern("").is_err());
assert!(validate_lfs_pattern("--include=*").is_err());
assert!(validate_lfs_pattern("*.bin\n*.zip").is_err());
}
#[test]
fn detects_git_lfs_repository_from_attributes() {
let repo = init_temp_repo("lfs_repository_detection");
assert!(!repository_uses_lfs(&repo.path));
fs::write(
repo.path.join(".gitattributes"),
"*.bin filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
assert!(repository_uses_lfs(&repo.path));
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail LFS filter tests with a sh signal pipe error"
)]
fn pulls_git_lfs_objects_from_local_remote() {
ensure_git_lfs_available().expect("Git LFS should be available for the test");
let source = init_temp_repo("lfs_pull_source");
let remote = init_bare_temp_repo("lfs_pull_remote");
let checkout = temp_dir("lfs_pull_checkout");
run_git_test(&source.path, ["lfs", "install", "--local"]);
run_git_test(&source.path, ["lfs", "track", "*.bin"]);
fs::write(source.path.join("asset.bin"), b"downloaded LFS payload\n")
.expect("LFS fixture should be written");
run_git_test(&source.path, ["add", ".gitattributes", "asset.bin"]);
run_git_test(&source.path, ["commit", "-q", "-m", "add LFS object"]);
run_git_test(
&source.path,
[
OsString::from("remote"),
OsString::from("add"),
OsString::from("origin"),
remote.path.as_os_str().to_owned(),
],
);
let branch = git_output_test(&source.path, ["branch", "--show-current"]);
run_git_test(&source.path, ["push", "-q", "-u", "origin", &branch]);
let remote_head = format!("refs/heads/{branch}");
run_git_test(&remote.path, ["symbolic-ref", "HEAD", &remote_head]);
let output = git_command()
.arg("clone")
.arg("-q")
.arg(&remote.path)
.arg(&checkout.path)
.env("GIT_LFS_SKIP_SMUDGE", "1")
.output()
.expect("Git clone should start");
assert!(
output.status.success(),
"Git clone failed: {}",
command_output_details(&output)
);
let pointer = fs::read_to_string(checkout.path.join("asset.bin"))
.expect("skipped LFS object should remain a pointer");
assert!(pointer.starts_with("version https://git-lfs.github.com/spec/v1"));
assert!(repository_uses_lfs(&checkout.path));
pull_git_lfs_objects(&checkout.path, Some("origin"), None, None)
.expect("automatic LFS pull should succeed");
assert_eq!(
fs::read(checkout.path.join("asset.bin")).expect("LFS object should be downloaded"),
b"downloaded LFS payload\n"
);
}
#[test]
fn git_lfs_status_finds_the_bundled_extension() {
let repo = init_temp_repo("bundled_lfs_status");
let status = git_lfs_status_for_repo(&repo.path).expect("LFS status should load");
assert!(status.available);
assert!(status.bundled);
assert!(
status
.version
.as_deref()
.is_some_and(|version| version.starts_with("git-lfs/"))
);
assert!(!status.repository_uses_lfs);
assert!(status.patterns.is_empty());
assert!(status.files.is_empty());
}
#[test]
fn stash_push_can_be_limited_to_selected_files() {
let repo = init_temp_repo("scoped_stash");
fs::write(repo.path.join("selected.txt"), "initial selected\n")
.expect("selected file should be written");
fs::write(repo.path.join("remaining.txt"), "initial remaining\n")
.expect("remaining file should be written");
run_git_test(&repo.path, ["add", "selected.txt", "remaining.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "initial files"]);
fs::write(repo.path.join("selected.txt"), "stashed change\n")
.expect("selected change should be written");
fs::write(repo.path.join("remaining.txt"), "remaining change\n")
.expect("remaining change should be written");
let paths = vec!["selected.txt".to_string()];
let status = stash_push_for_repo(&repo.path, Some("selected file"), false, &paths)
.expect("selected file should be stashed");
assert!(status.files.iter().all(|file| file.path != "selected.txt"));
assert!(status.files.iter().any(|file| file.path == "remaining.txt"));
assert_eq!(
git_output_test(
&repo.path,
["stash", "show", "--name-only", "--format=", "stash@{0}"],
),
"selected.txt"
);
}
#[test]
fn branches_report_configured_upstream_and_local_only_state() {
let repo = init_temp_repo("branch_upstream_state");
+96 -5
View File
@@ -16,8 +16,9 @@ use git::{
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note,
get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install,
git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
@@ -31,9 +32,79 @@ use git::{
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote,
};
use tauri::Manager;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::{Emitter, Manager};
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
struct StartupRepository(Mutex<Option<String>>);
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
let mut args = args.into_iter();
let mut repository = None;
while let Some(arg) = args.next() {
if arg == "--repo" {
repository = args.next();
break;
}
if let Some(path) = arg.strip_prefix("--repo=") {
repository = Some(path.to_string());
break;
}
if !arg.starts_with('-') {
repository = Some(arg);
break;
}
}
repository.filter(|path| !path.trim().is_empty()).map(|path| {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
cwd.join(path)
}
.to_string_lossy()
.into_owned()
})
}
#[cfg(test)]
mod startup_repository_tests {
use super::repository_path_from_args;
use std::path::Path;
#[test]
fn accepts_direct_relative_repository_path() {
let path = repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
}
#[test]
fn accepts_repo_option() {
let path = repository_path_from_args(
["--repo".to_string(), "/projects/repo".to_string()],
Path::new("/home/user"),
);
assert_eq!(path.as_deref(), Some("/projects/repo"));
}
#[test]
fn accepts_repo_equals_option() {
let path = repository_path_from_args(
["--repo=projects/repo".to_string()],
Path::new("/home/user"),
);
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
}
}
#[tauri::command]
fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option<String> {
state.0.lock().ok()?.take()
}
struct ConsoleLogger;
impl log::Log for ConsoleLogger {
@@ -96,8 +167,19 @@ async fn main() {
return;
}
let startup_repository = repository_path_from_args(
std::env::args().skip(1),
&std::env::current_dir().unwrap_or_default(),
);
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
if let Some(path) = repository_path_from_args(args.into_iter().skip(1), Path::new(&cwd)) {
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
*pending = Some(path);
}
let _ = app.emit("open-startup-repository", ());
}
#[cfg(desktop)]
let _ = app
.get_webview_window("main")
@@ -112,6 +194,7 @@ async fn main() {
})
.build(),
)
.manage(StartupRepository(Mutex::new(startup_repository)))
.manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init());
@@ -134,6 +217,12 @@ async fn main() {
launch_external_diff,
launch_external_merge,
get_status,
git_lfs_status,
git_lfs_install,
git_lfs_track,
git_lfs_untrack,
git_lfs_pull,
git_lfs_prune,
list_branches,
list_remotes,
add_remote,
@@ -141,6 +230,7 @@ async fn main() {
remove_remote,
set_branch_upstream,
delete_remote_branch,
delete_remote_branches,
list_stashes,
checkout_branch,
create_branch,
@@ -225,7 +315,8 @@ async fn main() {
close_splashscreen,
set_telemetry_enabled,
emit_frontend_log,
emit_frontend_span
emit_frontend_span,
take_startup_repository
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+9 -3
View File
@@ -1,12 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.8.3",
"version": "2026.8.5",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"beforeBuildCommand": "npm run prepare:lfs && npm run build",
"frontendDist": "../dist"
},
"app": {
@@ -41,6 +41,12 @@
},
"bundle": {
"active": true,
"externalBin": [
"binaries/git-lfs"
],
"resources": [
"binaries/git-lfs-LICENSE.txt"
],
"targets": [
"nsis"
],
+307 -22
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onDestroy, onMount, tick } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
@@ -60,6 +61,7 @@
deleteCommitNote,
deleteTag,
deleteRemoteBranch,
deleteRemoteBranches,
initRepository,
diffFileAgainstWorkingTree,
compareFileToParent,
@@ -67,7 +69,9 @@
fetchRemote,
getCommitNote,
getFileBlame,
getGitLfsStatus,
getStatus,
installGitLfs,
lastCommitMessage,
listBranches,
listRemotes,
@@ -88,7 +92,9 @@
lockWorktree,
moveWorktree,
pruneWorktrees,
pruneGitLfsObjects,
pull,
pullGitLfsObjects,
push,
pushCommitNotes,
pushTag,
@@ -123,12 +129,14 @@
startInteractiveRebase,
setSyncBadge,
stageFiles,
trackGitLfsPattern,
stashApply,
stashDrop,
stashPop,
stashPush,
undoLastCommit,
unlockWorktree,
untrackGitLfsPattern,
unstageFiles,
} from "./lib/git";
@@ -153,6 +161,7 @@
GitCommitComparison,
GitDiffFile,
GitFileStatus,
GitLfsStatus,
GitRepositoryFile,
GitRemote,
PullStrategy,
@@ -188,7 +197,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
@@ -274,6 +283,10 @@
// ── State ──────────────────────────────────────────────────────────────────
let repoPath = "";
let startupReady = false;
let pendingStartupRepoPath = "";
let startupRepositoryRetryTimer: ReturnType<typeof setTimeout> | undefined;
let unlistenStartupRepository: (() => void) | undefined;
let activeRepoPath = "";
let activeView: AppView = "management";
let repoTabs: RepoTab[] = [];
@@ -370,6 +383,10 @@
let worktrees: GitWorktree[] = [];
let worktreesLoading = false;
let worktreeError = "";
let gitLfsDialogOpen = false;
let gitLfsStatus: GitLfsStatus | null = null;
let gitLfsLoading = false;
let gitLfsError = "";
let compareSelectOpen = false;
let compareDialogOpen = false;
let interactiveRebaseOpen = false;
@@ -411,6 +428,7 @@
let credDialogOpen = false;
let credDialogAction: CredentialAction | null = null;
let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null;
let pendingRemoteDelete: { remote: string; branches: string[]; label: string; folder: boolean } | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let credDialogUsername = "";
@@ -541,6 +559,14 @@
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
void runStartupSequence();
void listen("open-startup-repository", () => {
void receiveStartupRepository();
}).then((unlisten) => {
if (appShuttingDown) unlisten();
else unlistenStartupRepository = unlisten;
}).catch(() => {
// Browser preview has no Tauri event system.
});
void refreshDetectedExternalTools();
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
window.addEventListener("beforeunload", handleAppShutdown);
@@ -563,7 +589,9 @@
window.removeEventListener("beforeunload", handleAppShutdown);
window.removeEventListener("pagehide", handleAppShutdown);
unlistenCloseRequested?.();
unlistenStartupRepository?.();
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
if (startupRepositoryRetryTimer) clearTimeout(startupRepositoryRetryTimer);
Object.values(errorAutoHideStates).forEach((state) => {
if (state?.timer) clearTimeout(state.timer);
});
@@ -640,6 +668,8 @@
if (remainingSplashTime > 0) await wait(remainingSplashTime);
await closeStartupSplashscreen();
startupReady = true;
await receiveStartupRepository();
startBackgroundTimers();
// Remote access can take seconds (offline networks, SSH negotiation,
// credential helpers). It must never hold the startup screen hostage.
@@ -648,6 +678,29 @@
}
}
async function receiveStartupRepository() {
try {
const path = await tracedInvoke<string | null>("take_startup_repository");
if (path) pendingStartupRepoPath = path;
} catch {
return;
}
if (!startupReady || !pendingStartupRepoPath) return;
if (isBusy) {
if (!startupRepositoryRetryTimer) {
startupRepositoryRetryTimer = setTimeout(() => {
startupRepositoryRetryTimer = undefined;
void receiveStartupRepository();
}, 100);
}
return;
}
const path = pendingStartupRepoPath;
pendingStartupRepoPath = "";
await openRepo(path);
}
async function fetchOpenRepositoriesDuringStartup() {
const paths = uniqueRepoPaths(repoTabs.map((tab) => tab.path));
if (paths.length === 0 || backgroundFetchInFlight) return;
@@ -914,7 +967,7 @@
}
async function autoRefreshTick() {
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
const path = activeRepoPath;
autoRefreshInFlight = true;
try {
@@ -1974,6 +2027,7 @@
globalSearchResults = [];
deleteBranchTarget = null;
deleteBranchForce = false;
pendingRemoteDelete = null;
worktreeDialogOpen = false;
worktreeInitialBranch = "";
worktrees = [];
@@ -2656,6 +2710,72 @@
trackEvent("branch_delete_dialog_opened");
}
async function deleteBranchFolder(folderName: string, folderBranches: GitBranchInfo[], folderDepth: number) {
if (!activeRepoPath || isBusy) return;
const remoteFolder = folderBranches.every((branch) => branch.remote);
if (remoteFolder && folderDepth === 0) {
errorMessage = "The top-level remote folder cannot be deleted as a group.";
return;
}
const deletableBranches = folderBranches.filter((branch) => !branch.current);
const currentBranchKept = folderBranches.some((branch) => branch.current);
if (deletableBranches.length === 0) {
errorMessage = "The folder only contains the current branch, which cannot be deleted.";
return;
}
const scope = remoteFolder ? "remote" : "local";
const currentNote = currentBranchKept ? "\n\nThe current branch will be kept." : "";
if (!window.confirm(`Delete ${deletableBranches.length} ${scope} branches in “${folderName}”?${currentNote}`)) return;
const repoPath = activeRepoPath;
if (remoteFolder) {
const remoteBranches = deletableBranches.map((branch) => {
const slash = branch.name.indexOf("/");
if (slash < 1) throw new Error(`Could not determine the remote for ${branch.name}.`);
return { remote: branch.name.slice(0, slash), name: branch.name.slice(slash + 1) };
});
const remote = remoteBranches[0]?.remote;
if (!remote || remoteBranches.some((branch) => branch.remote !== remote)) {
errorMessage = "A remote branch folder must belong to exactly one remote.";
return;
}
pendingRemoteDelete = { remote, branches: remoteBranches.map((branch) => branch.name), label: folderName, folder: true };
const key = await currentCredKey("delete");
const stored = await loadStoredCredential(key);
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored));
} else {
await openCredentialDialog("delete", key, stored);
}
return;
}
const failures: string[] = [];
operation = `Deleting branches in ${folderName}`;
errorMessage = "";
try {
for (const branch of deletableBranches) {
try {
applyStatus(await deleteBranch(repoPath, branch.name, false));
} catch (error) {
failures.push(`${branch.name}: ${errorToMessage(error)}`);
}
}
await refreshRepositoryViews(repoPath);
trackEvent("branch_folder_deleted", {
attempted: deletableBranches.length,
failed: failures.length,
remote: 0,
});
if (failures.length > 0) {
errorMessage = `${failures.length} branch${failures.length === 1 ? "" : "es"} could not be deleted:\n${failures.join("\n")}`;
}
} finally {
operation = "";
}
}
async function confirmDeleteBranch() {
const branch = deleteBranchTarget;
if (!activeRepoPath || !branch || branch.current || isBusy) return;
@@ -2665,17 +2785,13 @@
if (slash < 1) { errorMessage = "Could not determine remote name."; return; }
const remote = branch.name.slice(0, slash);
const remoteBranch = branch.name.slice(slash + 1);
operation = `Deleting ${branch.name} from remote`;
errorMessage = "";
try {
applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch));
deleteBranchTarget = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("remote_branch_deleted");
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
operation = "";
pendingRemoteDelete = { remote, branches: [remoteBranch], label: branch.name, folder: false };
const key = await currentCredKey("delete");
const stored = await loadStoredCredential(key);
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored));
} else {
await openCredentialDialog("delete", key, stored);
}
return;
}
@@ -2850,6 +2966,114 @@
worktreeError = "";
}
async function openGitLfsDialog() {
if (!activeRepoPath || isBusy) return;
gitLfsDialogOpen = true;
gitLfsStatus = null;
gitLfsError = "";
await refreshGitLfsStatus();
const inspectedStatus = gitLfsStatus as GitLfsStatus | null;
trackEvent("git_lfs_dialog_opened", {
available: inspectedStatus?.available ? 1 : 0,
repository_uses_lfs: inspectedStatus?.repository_uses_lfs ? 1 : 0,
});
}
async function refreshGitLfsStatus() {
if (!activeRepoPath || gitLfsLoading) return;
gitLfsLoading = true;
gitLfsError = "";
try {
gitLfsStatus = await getGitLfsStatus(activeRepoPath);
} catch (error) {
gitLfsError = errorToMessage(error);
} finally {
gitLfsLoading = false;
}
}
async function runGitLfsOperation(
label: string,
task: () => Promise<GitLfsStatus>,
eventName: string,
): Promise<boolean> {
if (!activeRepoPath || isBusy) return false;
const repository = activeRepoPath;
operation = label;
gitLfsError = "";
try {
gitLfsStatus = await task();
applyStatus(await getStatus(repository));
await refreshExplorerFiles(repository);
trackEvent(eventName, {
patterns: gitLfsStatus.patterns.length,
files: gitLfsStatus.files.length,
});
return true;
} catch (error) {
gitLfsError = errorToMessage(error);
return false;
} finally {
operation = "";
}
}
function activateGitLfs() {
return runGitLfsOperation(
"Activating Git LFS",
() => installGitLfs(activeRepoPath),
"git_lfs_activated",
);
}
function addGitLfsPattern(pattern: string, lockable: boolean): Promise<boolean> {
return runGitLfsOperation(
`Tracking ${pattern} with Git LFS`,
() => trackGitLfsPattern(activeRepoPath, pattern, lockable),
"git_lfs_pattern_added",
);
}
async function removeGitLfsPattern(pattern: string) {
await runGitLfsOperation(
`Removing Git LFS pattern ${pattern}`,
() => untrackGitLfsPattern(activeRepoPath, pattern),
"git_lfs_pattern_removed",
);
}
async function pullGitLfsFiles() {
if (!activeRepoPath || isBusy) return;
let credential: StoredCredential | null = null;
const key = await currentCredKey("pull");
if (key) credential = await credLoad(key);
await runGitLfsOperation(
"Downloading Git LFS objects",
() => pullGitLfsObjects(
activeRepoPath,
selectedRemote || undefined,
credential?.username,
credential?.password,
),
"git_lfs_objects_pulled",
);
}
async function pruneGitLfsCache() {
await runGitLfsOperation(
"Pruning Git LFS cache",
() => pruneGitLfsObjects(activeRepoPath),
"git_lfs_cache_pruned",
);
}
function closeGitLfsDialog() {
if (isBusy) return;
gitLfsDialogOpen = false;
gitLfsStatus = null;
gitLfsError = "";
}
function openNewBranchDialog(commit: GitCommit) {
if (!activeRepoPath || isBusy) return;
newBranchCommit = commit;
@@ -3234,11 +3458,11 @@
// Resolve the keychain key (host/org) from the exact remote URL used by the
// operation. Push URLs may intentionally differ from fetch URLs.
async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" = "fetch"): Promise<string | null> {
async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" | "delete" = "fetch"): Promise<string | null> {
if (!activeRepoPath) return null;
try {
const remote = action === "rename" ? pendingRemoteRename?.remote : selectedRemote;
const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename");
const remote = action === "rename" ? pendingRemoteRename?.remote : action === "delete" ? pendingRemoteDelete?.remote : selectedRemote;
const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename" || action === "delete");
return url ? orgKeyFromUrl(url) : null;
} catch {
return null;
@@ -3277,7 +3501,7 @@
// so a temporary 401/403 cannot erase a valid token; the key is only skipped
// for the rest of this session until the user replaces it successfully.
function handleRemoteResult(
action: "push" | "pull" | "fetch" | "rename",
action: "push" | "pull" | "fetch" | "rename" | "delete",
key: string | null,
fromStore: boolean,
username: string,
@@ -3445,6 +3669,32 @@
handleRemoteResult("rename", key, fromStore, username, mode);
}
async function doActualRemoteDelete(
username: string,
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
const deletion = pendingRemoteDelete;
if (!activeRepoPath || !deletion) return;
errorMessage = "";
await runOperation(`Deleting ${deletion.label} from remote`, async () => {
applyStatus(deletion.folder
? await deleteRemoteBranches(activeRepoPath, deletion.remote, deletion.branches, username, password)
: await deleteRemoteBranch(activeRepoPath, deletion.remote, deletion.branches[0], username, password));
deleteBranchTarget = null;
pendingRemoteDelete = null;
await refreshRefsAndCommitGraph(activeRepoPath);
if (deletion.folder) {
trackEvent("branch_folder_deleted", { attempted: deletion.branches.length, failed: 0, remote: 1 });
} else {
trackEvent("remote_branch_deleted");
}
});
handleRemoteResult("delete", key, fromStore, username, mode);
}
async function handleCredentialSubmit(
username: string,
password: string,
@@ -3468,6 +3718,7 @@
else if (credDialogAction === "push") await doActualPush(username, password, key, false, mode);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false, mode);
else if (credDialogAction === "rename") await doActualRemoteRename(username, password, key, false, mode);
else if (credDialogAction === "delete") await doActualRemoteDelete(username, password, key, false, mode);
else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo(
pendingClone.remoteUrl,
@@ -3601,11 +3852,15 @@
}
}
async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return;
const stashedFiles = changedFiles.length;
async function saveStash(message: string, includeUntracked: boolean, files?: GitFileStatus[]) {
const targets = files ?? changedFiles;
if (!activeRepoPath || targets.length === 0) return;
const paths = files
? [...new Set(files.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))]
: undefined;
const stashedFiles = targets.length;
await runOperation("Stashing changes", async () => {
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
applyStatus(await stashPush(activeRepoPath, message, includeUntracked, paths));
await refreshRepositoryViews(activeRepoPath, {
branches: false,
stashes: true,
@@ -3614,10 +3869,16 @@
trackEvent("stash_saved", {
include_untracked: includeUntracked ? 1 : 0,
changed_files: stashedFiles,
scoped: files ? 1 : 0,
});
});
}
async function stashStatusFiles(files: GitFileStatus[], label: string) {
const suffix = files.length === 1 ? files[0].path : `${label} (${files.length} files)`;
await saveStash(`Gitty: ${suffix}`, true, files);
}
async function applyStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Applying ${stash.selector}`, async () => {
@@ -4573,6 +4834,7 @@
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog();
else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog();
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
@@ -4646,6 +4908,7 @@
onFetchPrune={fetchPruneRepo}
onForcePush={forcePushRepo}
onSyncOptions={openSyncOptions}
onOpenLfs={openGitLfsDialog}
/>
{/if}
@@ -4962,6 +5225,7 @@
onRenameBranch={openRenameBranchDialog}
onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onDeleteBranchFolder={deleteBranchFolder}
onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag}
@@ -5113,6 +5377,7 @@
onUnstage={unstageFile}
onDiscard={discardFiles}
onDiscardMany={discardChanges}
onStash={stashStatusFiles}
onPatch={openPreferredFileDiff}
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
@@ -5432,6 +5697,25 @@
{/await}
{/if}
{#if gitLfsDialogOpen}
{#await import("./lib/components/GitLfsDialog.svelte") then module}
<module.default
status={gitLfsStatus}
language={appLanguage}
isLoading={gitLfsLoading}
{isBusy}
error={gitLfsError}
onRefresh={refreshGitLfsStatus}
onInstall={activateGitLfs}
onTrack={addGitLfsPattern}
onUntrack={removeGitLfsPattern}
onPull={pullGitLfsFiles}
onPrune={pruneGitLfsCache}
onClose={closeGitLfsDialog}
/>
{/await}
{/if}
<!-- Create a branch from a specific commit in the history -->
{#if newBranchCommit}
<NewBranchDialog
@@ -5564,7 +5848,7 @@
{/await}
{/if}
<!-- Credential dialog for push/pull -->
<!-- Credential dialog for authenticated remote actions -->
{#if credDialogOpen && credDialogAction}
<CredentialDialog
action={credDialogAction}
@@ -5576,6 +5860,7 @@
onCancel={() => {
credDialogOpen = false;
credDialogAction = null;
pendingRemoteDelete = null;
credDialogError = "";
credDialogKey = null;
credDialogUsername = "";
+426 -18
View File
@@ -1112,10 +1112,29 @@
background: transparent;
color: var(--color-ink-faint);
}
.repo-tab-close { opacity: 0.58; transition: opacity 120ms ease, color 120ms ease, background 120ms ease; }
.repo-tab-close {
display: grid;
place-items: center;
align-self: center;
min-width: 26px;
width: 26px;
max-width: 26px;
min-height: 26px;
height: 26px;
max-height: 26px;
flex: 0 0 26px;
margin-right: 6px;
border-radius: 5px;
opacity: 0.58;
transition: opacity 120ms ease, color 120ms ease, background 120ms ease;
}
.repo-tab-wrap:hover .repo-tab-close,
.repo-tab-wrap.active .repo-tab-close { opacity: 1; }
.repo-tab-close:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); }
.repo-tab-close:hover:not(:disabled),
.repo-tab-close:focus-visible:not(:disabled) {
color: #ff6673;
background: rgba(255, 90, 103, 0.13);
}
.repo-tab-add {
min-width: 44px;
border-left: 1px solid var(--color-border-subtle);
@@ -2308,6 +2327,7 @@
.branch-context-menu,
.history-context-menu,
.explorer-context-menu,
.status-context-menu,
.repo-tab-context-menu {
z-index: 120;
display: grid;
@@ -2327,11 +2347,102 @@
}
.history-context-menu { position: absolute; }
.explorer-context-menu,
.status-context-menu,
.repo-tab-context-menu { position: fixed; }
.status-context-menu {
width: min(280px, calc(100vw - 16px));
padding: 6px;
border-color: color-mix(in srgb, var(--color-border) 78%, #5a8cf8);
background:
linear-gradient(180deg, rgba(90,140,248,0.045), transparent 70px),
var(--color-surface-solid);
}
.status-context-label {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
min-width: 0;
margin: -1px -1px 5px;
padding: 8px 8px 10px;
border-bottom: 1px solid var(--color-border-subtle);
}
.status-context-object-icon,
.status-context-action-icon {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
color: #83adff;
background: rgba(90,140,248,0.12);
border: 1px solid rgba(90,140,248,0.2);
}
.status-context-object-icon {
width: 32px;
height: 32px;
border-radius: 7px;
}
.status-context-object-copy,
.status-context-action-copy {
display: grid;
min-width: 0;
}
.status-context-kind {
margin-bottom: 2px;
color: var(--color-ink-faint);
font-size: 8.5px;
font-weight: 800;
letter-spacing: .1em;
line-height: 1;
text-transform: uppercase;
}
.status-context-object-copy > strong {
overflow: hidden;
color: var(--color-ink);
font-size: 12.5px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-context-path {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-top: 2px;
overflow: hidden;
color: var(--color-ink-faint);
font: 9.5px/1.2 var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-context-path svg { flex: 0 0 auto; }
.status-context-count {
display: inline-grid;
place-items: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border: 1px solid rgba(90,140,248,0.18);
border-radius: 5px;
color: #83adff;
background: rgba(90,140,248,0.1);
font: 700 9.5px/1 var(--font-mono);
}
.branch-context-menu button,
.history-context-menu button,
.explorer-context-menu button,
.status-context-menu button,
.repo-tab-context-menu button {
display: flex;
align-items: center;
@@ -2349,15 +2460,52 @@
text-align: left;
}
.status-context-menu button {
min-height: 42px;
gap: 9px;
padding: 6px 7px;
}
.status-context-action-icon {
width: 26px;
height: 26px;
border-color: transparent;
border-radius: 6px;
background: transparent;
color: var(--color-ink-dim);
}
.status-context-action-copy { gap: 1px; }
.status-context-action-copy strong {
color: var(--color-ink-muted);
font-size: 11.5px;
line-height: 1.25;
}
.status-context-action-copy span {
color: var(--color-ink-faint);
font-size: 9.5px;
font-weight: 500;
line-height: 1.25;
}
.branch-context-menu button:hover:not(:disabled),
.history-context-menu button:hover:not(:disabled),
.explorer-context-menu button:hover:not(:disabled),
.status-context-menu button:hover:not(:disabled),
.repo-tab-context-menu button:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.06);
color: var(--color-ink);
}
.status-context-menu button:hover:not(:disabled) .status-context-action-icon {
color: #9bbcff;
background: rgba(90,140,248,0.1);
}
.status-context-menu button:hover:not(:disabled) .status-context-action-copy strong {
color: var(--color-ink);
}
.branch-context-menu .menu-separator,
.history-context-menu .menu-separator,
.repo-tab-context-menu .menu-separator {
@@ -2379,6 +2527,7 @@
.branch-context-menu button:disabled,
.history-context-menu button:disabled,
.explorer-context-menu button:disabled,
.status-context-menu button:disabled,
.repo-tab-context-menu button:disabled {
cursor: not-allowed;
opacity: 0.48;
@@ -2805,7 +2954,7 @@
padding: 0 5px;
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 34%, transparent);
border-left-width: 2px;
border-radius: 0 5px 5px 0 !important;
border-radius: 0 5px 5px 0;
color: #dce9ff;
background:
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 14%, rgba(18,24,36,.96)), rgba(18,24,36,.82));
@@ -2941,15 +3090,15 @@
.compact-ref-overflow {
flex: 0 0 auto;
min-width: 29px;
min-height: 19px;
height: 19px;
min-height: 20px !important;
height: 20px !important;
padding: 0 5px;
border-color: transparent;
border-radius: 4px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.025);
font-family: var(--font-mono);
font-size: 9px;
font-size: 9.5px;
font-weight: 800;
}
.compact-ref-overflow:hover:not(:disabled),
@@ -3334,8 +3483,8 @@
align-items: end;
gap: 10px;
}
.compare-field { display: grid; gap: 4px; min-width: 0; }
.compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
.compare-field { display: grid; gap: 6px; min-width: 0; }
.compare-field > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.compare-target-help {
display: flex;
align-items: flex-start;
@@ -3509,8 +3658,8 @@
.compare-dialog-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.compare-dialog-title > div { min-width: 0; }
.compare-dialog-mark { display: grid; place-items: center; flex: 0 0 auto; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
.compare-dialog-title h2 { margin: 0; color: var(--color-accent); font-size: 18px; font-weight: 700; line-height: 1.2; }
.compare-dialog-title p { margin: 3px 0 0; color: var(--color-ink); font-size: 11px; font-weight: 400; line-height: 1.35; }
.compare-dialog-title h2 { margin: 0; color: var(--color-ink); font-size: 18px; font-weight: 700; line-height: 1.2; }
.compare-dialog-title p { margin: 3px 0 0; color: var(--color-ink-dim); font-size: 11px; font-weight: 400; line-height: 1.35; }
.global-search-dialog {
width: min(1180px, calc(100vw - 32px));
height: min(840px, calc(100vh - 32px));
@@ -3535,8 +3684,8 @@
.compare-select-shell { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; }
.compare-select-body { display: grid; align-content: start; gap: 12px; min-height: 0; padding: 18px 20px 20px; overflow: auto; }
.compare-target-panel { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.compare-target-heading h3 { margin: 0; color: var(--color-accent); font-size: 18px; font-weight: 700; line-height: 1.2; }
.compare-target-heading p { margin: 4px 0 0; color: var(--color-ink); font-size: 11px; font-weight: 400; line-height: 1.45; }
.compare-target-heading h3 { margin: 0; color: var(--color-ink); font-size: 14px; font-weight: 700; line-height: 1.2; }
.compare-target-heading p { margin: 3px 0 0; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 400; line-height: 1.4; }
.compare-arrow-shell { display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-accent); background: var(--color-surface-raised); }
.compare-select-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 11px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.compare-select-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
@@ -6094,6 +6243,16 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.repo-tab-wrap:first-child { border-left: 1px solid var(--color-border-subtle); }
.repo-tab-wrap.active { background: var(--color-surface-solid); box-shadow: inset 0 -3px 0 var(--color-primary); }
.repo-tab { min-height: 45px; padding-left: 14px; font-size: 12.5px; }
.repo-tab-close {
width: 26px;
min-width: 26px;
max-width: 26px;
height: 26px;
min-height: 26px;
max-height: 26px;
flex-basis: 26px;
aspect-ratio: 1;
}
.repo-tab-add { min-height: 45px; }
.repo-toolbar {
@@ -6162,20 +6321,151 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
background: var(--color-surface-dim);
}
.status-toolbar .danger { margin-left: auto; }
.status-lanes { display: grid; align-content: start; min-height: 0; }
.status-lane + .status-lane { border-top: 1px solid var(--color-border); }
.status-panel > .section-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
}
.status-panel > .section-head > :first-child { justify-self: start; }
.status-panel > .section-head > .status-head-actions {
justify-self: end;
max-width: 100%;
}
.status-view-switch {
display: inline-grid;
grid-template-columns: repeat(2, auto);
align-items: center;
min-height: 26px;
padding: 2px;
border: 1px solid var(--color-border);
border-radius: 5px;
background: var(--color-surface-dim);
}
.status-view-switch button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 0;
min-height: 20px;
padding: 0 6px;
border: 0;
border-radius: 3px;
color: var(--color-ink-faint);
background: transparent;
font-size: 9.5px;
font-weight: 700;
}
.status-view-switch button:hover:not(:disabled) {
color: var(--color-ink);
background: var(--color-surface-hover);
}
.status-view-switch button.active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 13%, var(--color-surface-solid));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.status-view-icon {
position: relative;
display: inline-block;
flex: 0 0 auto;
width: 12px;
height: 10px;
}
.status-view-icon.list-icon {
background: repeating-linear-gradient(to bottom, currentColor 0 1px, transparent 1px 4px);
}
.status-panel { container-type: inline-size; }
.status-lanes {
display: grid;
grid-template-columns: minmax(0, 1fr) 25px minmax(280px, 1fr);
align-items: stretch;
min-height: 0;
overflow: hidden;
background: var(--color-border-subtle);
}
.status-lane {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--color-surface-solid);
}
.status-lane.staged-lane {
background:
linear-gradient(180deg, color-mix(in srgb, #2da44e 2.5%, transparent), transparent 120px),
var(--color-surface-solid);
}
.status-flow-divider {
position: relative;
display: grid;
place-items: center;
min-width: 0;
color: var(--color-primary);
background: var(--color-surface-dim);
}
.status-flow-divider::before {
content: "";
position: absolute;
inset-block: 0;
left: 50%;
width: 1px;
background: var(--color-border);
}
.status-flow-divider span {
position: relative;
z-index: 1;
display: grid;
place-items: center;
width: 21px;
height: 21px;
border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
border-radius: 50%;
background: var(--color-surface-solid);
box-shadow: 0 0 0 4px var(--color-surface-dim);
}
.status-lane-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 42px;
padding: 5px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.status-lane-head > div { display: flex; align-items: center; gap: 7px; }
.status-lane-head strong { color: var(--color-ink); font-size: 12px; }
.status-lane-head span { display: grid; place-items: center; min-width: 20px; height: 20px; border-radius: 5px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); font-size: 10.5px; font-weight: 750; }
.status-file-list { padding: 5px 0; }
.status-lane-copy { display: grid; gap: 1px; }
.status-lane-copy small {
color: var(--color-ink-faint);
font-size: 8px;
font-weight: 700;
letter-spacing: .08em;
line-height: 1;
text-transform: uppercase;
}
.status-lane-count {
display: grid;
place-items: center;
min-width: 20px;
height: 20px;
border-radius: 5px;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
font-size: 10.5px;
font-weight: 750;
}
.staged-lane .status-lane-count {
color: #55bd72;
background: color-mix(in srgb, #2da44e 12%, transparent);
}
.status-file-list {
min-height: 0;
padding: 5px 0;
overflow: auto;
scrollbar-gutter: stable;
}
.status-file-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -6189,6 +6479,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.status-file-row:hover { background: var(--color-surface-hover); }
.status-file-row.selected { background: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface-solid)); }
.status-file-row.active { border-left-color: var(--color-primary); }
.status-file-row.status-tree-file { padding-left: calc(12px + var(--tree-indent, 0px)); }
.status-file-main { display: grid; justify-content: start; gap: 1px; min-width: 0; padding: 3px 8px; border: 0; background: transparent; text-align: left; }
.status-file-main:hover:not(:disabled) { background: transparent; }
.status-file-main strong, .status-file-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -6198,7 +6489,64 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.status-file-row:hover .status-file-actions, .status-file-row:focus-within .status-file-actions { opacity: 1; }
.status-file-actions button { width: 28px; min-width: 28px; min-height: 28px; padding: 0; border-color: transparent; background: transparent; }
.status-file-actions .danger:hover:not(:disabled) { color: #e86060; background: rgba(232,96,96,.1); }
.status-lane-empty { margin: 0; padding: 14px 16px; color: var(--color-ink-faint); font-size: 11.5px; }
.status-folder-row {
display: grid;
grid-template-columns: 14px 15px minmax(0, 1fr) auto;
align-items: center;
gap: 5px;
width: 100%;
min-height: 32px;
padding: 2px 12px 2px calc(12px + var(--tree-indent, 0px));
border: 0;
border-bottom: 1px solid var(--color-border-subtle);
border-radius: 0;
color: var(--color-ink-muted);
background: color-mix(in srgb, var(--color-surface-dim) 64%, transparent);
text-align: left;
}
.status-folder-row:hover:not(:disabled) {
color: var(--color-ink);
background: var(--color-surface-hover);
}
.status-tree-chevron {
width: 6px;
height: 6px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(-45deg);
transition: transform 120ms ease;
}
.status-tree-chevron.expanded { transform: rotate(45deg) translate(-1px, -1px); }
.status-folder-row .status-folder-lucide { color: var(--color-accent); }
.status-folder-row strong {
overflow: hidden;
color: inherit;
font: 650 11px/1.2 var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-folder-count {
display: grid;
place-items: center;
min-width: 18px;
height: 18px;
padding-inline: 4px;
border-radius: 4px;
color: var(--color-ink-faint);
background: color-mix(in srgb, var(--color-ink-faint) 9%, transparent);
font-size: 9px;
font-weight: 750;
}
.status-lane-empty {
display: grid;
place-items: center;
min-height: 86px;
margin: 0;
padding: 14px 16px;
color: var(--color-ink-faint);
font-size: 11.5px;
text-align: center;
}
.status-badge { min-width: 58px; padding-inline: 5px; border: 0; border-radius: 4px; font-size: 9px; letter-spacing: .04em; }
.commit-panel { border-top: 1px solid var(--color-border); }
@@ -6256,7 +6604,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
contain-intrinsic-block-size: 108px;
}
.commit-avatar { border-radius: 50%; }
.commit-kind, .compact-ref-chip { border-radius: 4px !important; }
.commit-kind, .compact-ref-chip { border-radius: 4px; }
.workspace-statusbar {
display: flex;
align-items: center;
@@ -7337,6 +7685,45 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
background: var(--color-border);
}
.ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); }
/* Square UI language: controls and surfaces have no rounded corners. */
*,
*::before,
*::after {
border-radius: 0 !important;
}
/* Preserve shapes that communicate a circular marker rather than a rounded surface. */
.commit-avatar,
.repo-switch > span,
.commit-ref-detail-item > i,
.graph-dot,
.worktree-rail span,
.workspace-health > span,
.workspace-auto i {
border-radius: 50% !important;
}
/* Branch flags keep their flag shape and curved graph connection. */
.compact-ref-chip,
.compact-ref-overflow {
border-radius: 5px !important;
}
.compact-ref-chip.branch {
border-radius: 0 5px 5px 0 !important;
}
.branch-ref-cluster.local-only .compact-ref-chip.branch {
border-radius: 0 !important;
}
.compact-ref-local-marker {
border-radius: 0 5px 5px 0 !important;
}
.commit-body.has-branch-ref::before {
border-top-left-radius: 14px !important;
}
.commit-body.has-branch-ref::after {
border-bottom-right-radius: 14px !important;
}
.ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; }
.ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; }
.ai-review-clean-state { display: grid; place-items: center; align-content: center; min-height: 280px; gap: 8px; color: #2da44e; text-align: center; }
@@ -7389,6 +7776,27 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
font-size: 9.5px;
}
@container (max-width: 680px) {
.status-lanes {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) 25px minmax(0, 1fr);
}
.status-flow-divider::before {
inset-block: auto;
inset-inline: 0;
top: 50%;
width: auto;
height: 1px;
}
.status-flow-divider span { transform: rotate(90deg); }
}
@container (max-width: 520px) {
.status-view-switch button > span { display: none; }
.status-view-switch button { width: 24px; padding-inline: 0; }
}
@media (max-width: 760px) {
.status-lane-head { align-items: flex-start; flex-direction: column; }
.status-lane-actions { width: 100%; justify-content: flex-start; }
+1 -1
View File
@@ -58,7 +58,7 @@
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
>
<X size={13} aria-hidden="true" />
<X size={11} aria-hidden="true" />
</button>
</div>
{/each}
+3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {
Box,
ChevronDown,
Code2,
CloudDownload,
@@ -41,6 +42,7 @@
export let onFetchPrune: () => void = () => {};
export let onForcePush: () => void = () => {};
export let onSyncOptions: () => void = () => {};
export let onOpenLfs: () => void = () => {};
let historyOpen = false;
let syncOpen = false;
@@ -142,6 +144,7 @@
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onOpenLfs(); }}><Box size={15} /><span><strong>Git LFS</strong><small>{isGerman ? "Große Dateien und LFS-Installation verwalten" : "Manage large files and LFS installation"}</small></span></button>
</div>
{/if}
</div>
+57
View File
@@ -12,6 +12,7 @@
children: BranchTreeNode[];
branchCount: number;
current: boolean;
branches: GitBranchInfo[];
folders: Map<string, BranchFolderNode>;
}
@@ -31,6 +32,7 @@
depth: number;
branchCount: number;
current: boolean;
branches: GitBranchInfo[];
}
interface BranchLeafRow {
@@ -57,6 +59,7 @@
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[], depth: number) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
@@ -81,6 +84,7 @@
onRenameBranch = () => {},
onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onDeleteBranchFolder = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
@@ -101,6 +105,7 @@
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextFolder = $state<BranchFolderRow | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
@@ -121,6 +126,7 @@
children: [],
branchCount: 0,
current: false,
branches: [],
folders: new Map(),
};
}
@@ -147,6 +153,7 @@
folder.branchCount += 1;
folder.current ||= branch.current;
folder.branches.push(branch);
parent = folder;
}
@@ -188,6 +195,7 @@
depth,
branchCount: node.branchCount,
current: node.current,
branches: node.branches,
});
if (isBranchFolderOpen(node.id)) {
@@ -272,8 +280,25 @@
contextMenuY = position.y;
}
async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
event.preventDefault();
event.stopPropagation();
if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
contextFolder = folder;
contextMenuX = event.clientX + 2;
contextMenuY = event.clientY + 2;
await tick();
if (contextFolder !== folder) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
contextMenuX = position.x;
contextMenuY = position.y;
}
function closeBranchContextMenu() {
contextBranch = null;
contextFolder = null;
}
async function renameContextBranch() {
@@ -297,6 +322,13 @@
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
}
async function deleteContextFolder() {
const folder = contextFolder;
if (!folder || isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
closeBranchContextMenu();
await onDeleteBranchFolder(folder.name, folder.branches, folder.depth);
}
async function createContextWorktree() {
const branch = contextBranch;
closeBranchContextMenu();
@@ -484,6 +516,7 @@
style={`--branch-indent: ${row.depth * 16}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`}
>
@@ -551,6 +584,7 @@
style={`--branch-indent: ${row.depth * 16}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`}
>
@@ -735,6 +769,29 @@
</div>
{/if}
{#if contextFolder}
<div
bind:this={branchContextMenuElement}
class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for branch folder ${contextFolder.name}`}
>
<button
class="danger"
type="button"
role="menuitem"
onclick={deleteContextFolder}
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
title={contextFolder.current ? "The current branch will be kept" : "Delete all branches in this folder"}
>
<Trash2 size={14} aria-hidden="true" />
Delete {contextFolder.branches.filter((branch) => !branch.current).length} branches
</button>
</div>
{/if}
{#if contextTag}
<div
bind:this={tagContextMenuElement}
+15 -3
View File
@@ -47,9 +47,21 @@
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
let targetCount = $derived(branches.length + commits.length);
let compareOptions = $derived([
...localBranches.map((branch) => ({ value: branchValue(branch), label: `${branch.name}${branch.current ? " (current)" : ""}`, group: "Branches - Local" })),
...remoteBranches.map((branch) => ({ value: branchValue(branch), label: branch.name, group: "Branches - Remote" })),
...commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })),
...localBranches.map((branch) => ({
value: branchValue(branch),
label: `${branch.name}${branch.current ? (isGerman ? " (aktuell)" : " (current)") : ""}`,
group: isGerman ? "Lokale Branches" : "Local branches",
})),
...remoteBranches.map((branch) => ({
value: branchValue(branch),
label: branch.name,
group: isGerman ? "Remote-Branches" : "Remote branches",
})),
...commits.map((item) => ({
value: item.hash,
label: commitOptionLabel(item),
group: "Commits",
})),
]);
function handleSubmit(event: SubmitEvent) {
+11 -9
View File
@@ -15,7 +15,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull" | "fetch" | "clone" | "rename";
action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
error: string;
isBusy: boolean;
initialUsername?: string;
@@ -47,19 +47,21 @@
password.trim().length > 0 &&
username.trim().length > 0,
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : "Pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull");
let actionTitle = $derived(
action === "push"
? "Authenticate push"
: action === "rename"
? "Authenticate remote rename"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
: action === "delete"
? "Authenticate remote deletion"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push" || action === "rename"
let actionHint = $derived(action === "push" || action === "rename" || action === "delete"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
@@ -80,7 +82,7 @@
<div class="cred-hero">
<div class="cred-hero-top">
<div class="cred-hero-icon">
{#if action === "push" || action === "rename"}
{#if action === "push" || action === "rename" || action === "delete"}
<Upload size={27} aria-hidden="true" />
{:else}
<Download size={27} aria-hidden="true" />
+295
View File
@@ -0,0 +1,295 @@
<script lang="ts">
import {
AlertTriangle,
ArchiveRestore,
Box,
Check,
CircleDashed,
Download,
HardDriveDownload,
Link2,
LoaderCircle,
PackageCheck,
Plus,
RefreshCw,
ShieldCheck,
Trash2,
X,
} from "@lucide/svelte";
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
interface Props {
status: GitLfsStatus | null;
language: AppLanguage;
isLoading: boolean;
isBusy: boolean;
error?: string;
onRefresh: () => void | Promise<void>;
onInstall: () => boolean | void | Promise<boolean | void>;
onTrack: (pattern: string, lockable: boolean) => boolean | Promise<boolean>;
onUntrack: (pattern: string) => void | Promise<void>;
onPull: () => void | Promise<void>;
onPrune: () => void | Promise<void>;
onClose: () => void;
}
let {
status = null,
language = "en",
isLoading = false,
isBusy = false,
error = "",
onRefresh = () => {},
onInstall = () => {},
onTrack = () => false,
onUntrack = () => {},
onPull = () => {},
onPrune = () => {},
onClose = () => {},
}: Props = $props();
let pattern = $state("");
let lockable = $state(false);
let isGerman = $derived(language === "de");
let setupReady = $derived(Boolean(status?.filters_configured && status?.hook_installed));
let downloadedCount = $derived(status?.files.filter((file) => file.downloaded).length ?? 0);
let totalSize = $derived(status?.files.reduce((sum, file) => sum + file.size, 0) ?? 0);
function formatBytes(value: number): string {
if (!value) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const amount = value / 1024 ** index;
return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`;
}
function versionLabel(value: string | null | undefined): string {
return value?.match(/git-lfs\/([^\s]+)/)?.[1] ?? value ?? "—";
}
function canRemove(item: GitLfsPattern): boolean {
return item.source === ".gitattributes";
}
async function submitPattern(event: SubmitEvent) {
event.preventDefault();
const value = pattern.trim();
if (!value || !setupReady) return;
if (await onTrack(value, lockable)) {
pattern = "";
lockable = false;
}
}
async function confirmPrune() {
const confirmed = window.confirm(isGerman
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
if (confirmed) await onPrune();
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog lfs-dialog" role="dialog" aria-modal="true" aria-labelledby="lfs-dialog-title">
<header class="dialog-header lfs-dialog-header">
<div class="lfs-heading">
<span class="lfs-mark" aria-hidden="true"><Box size={19} /></span>
<div>
<span class="eyebrow">Large file storage</span>
<p class="dialog-title" id="lfs-dialog-title">Git LFS</p>
</div>
</div>
<div class="dialog-header-actions">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
{isGerman ? "Prüfen" : "Check"}
</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={isGerman ? "Schließen" : "Close"}>
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="lfs-main">
{#if error}
<div class="lfs-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
{/if}
{#if isLoading && !status}
<div class="lfs-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>{isGerman ? "Git LFS wird geprüft…" : "Inspecting Git LFS…"}</span></div>
{:else if status}
<div class="lfs-content">
<section class="lfs-diagnostics" aria-label={isGerman ? "LFS-Diagnose" : "LFS diagnostics"}>
<article class:ready={status.available} class:error={!status.available}>
<span class="diagnostic-icon">{#if status.available}<PackageCheck size={18} />{:else}<AlertTriangle size={18} />{/if}</span>
<div><small>01 · {isGerman ? "Erweiterung" : "Extension"}</small><strong>{status.available ? `Git LFS ${versionLabel(status.version)}` : (isGerman ? "Nicht gefunden" : "Not found")}</strong><span>{status.bundled ? (isGerman ? "Mit Gitty gebündelt" : "Bundled with Gitty") : (isGerman ? "Systeminstallation" : "System installation")}</span></div>
</article>
<span class:ready={status.available} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
<article class:ready={setupReady} class:warning={status.available && !setupReady}>
<span class="diagnostic-icon">{#if setupReady}<ShieldCheck size={18} />{:else}<CircleDashed size={18} />{/if}</span>
<div><small>02 · Repository</small><strong>{setupReady ? (isGerman ? "Aktiv" : "Active") : (isGerman ? "Einrichtung nötig" : "Setup required")}</strong><span>{status.filters_configured ? "Filter ✓" : "Filter —"} · {status.hook_installed ? "Pre-push ✓" : "Pre-push —"}</span></div>
</article>
<span class:ready={setupReady} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
<article class:ready={status.files.length > 0}>
<span class="diagnostic-icon"><ArchiveRestore size={18} /></span>
<div><small>03 · {isGerman ? "Objekte" : "Objects"}</small><strong>{status.files.length} {isGerman ? "Dateien" : "files"}</strong><span>{downloadedCount} {isGerman ? "lokal" : "local"} · {formatBytes(totalSize)}</span></div>
</article>
</section>
{#if !status.available}
<section class="lfs-missing">
<span class="lfs-missing-icon"><AlertTriangle size={22} aria-hidden="true" /></span>
<div>
<strong>{isGerman ? "Git LFS ist nicht ausführbar" : "Git LFS cannot be started"}</strong>
<p>{isGerman ? "Gitty liefert Git LFS normalerweise mit. Installiere Gitty erneut oder installiere git-lfs systemweit und starte die App neu." : "Gitty normally includes Git LFS. Reinstall Gitty or install git-lfs system-wide, then restart the app."}</p>
</div>
</section>
{:else}
{#if !setupReady}
<section class="lfs-setup-card">
<div><strong>{isGerman ? "Für dieses Repository aktivieren" : "Activate for this repository"}</strong><p>{isGerman ? "Richtet Clean-/Smudge-Filter lokal ein und installiert den Pre-push-Hook. Globale Git-Einstellungen bleiben unverändert." : "Configures local clean/smudge filters and installs the pre-push hook. Global Git settings remain unchanged."}</p></div>
<button class="btn-primary" type="button" onclick={onInstall} disabled={isBusy}><ShieldCheck size={15} aria-hidden="true" />{isGerman ? "LFS aktivieren" : "Activate LFS"}</button>
</section>
{/if}
<div class="lfs-grid">
<section class="lfs-panel patterns-panel">
<header><div><span class="eyebrow">Tracking rules</span><h3>{isGerman ? "Muster" : "Patterns"}</h3></div><span>{status.patterns.length}</span></header>
<form class="lfs-track-form" onsubmit={submitPattern}>
<label><span>{isGerman ? "Neues Muster" : "New pattern"}</span><input type="text" bind:value={pattern} disabled={isBusy || !setupReady} autocomplete="off" spellcheck="false" placeholder="*.psd, Assets/**, video.mp4" /></label>
<label class="lfs-lockable"><input type="checkbox" bind:checked={lockable} disabled={isBusy || !setupReady} /><span><strong>Lockable</strong><small>{isGerman ? "Schreibgeschützt, solange nicht gesperrt" : "Read-only until locked"}</small></span></label>
<button class="btn-primary" type="submit" disabled={isBusy || !setupReady || !pattern.trim()}><Plus size={15} aria-hidden="true" />{isGerman ? "Hinzufügen" : "Add"}</button>
</form>
<div class="lfs-pattern-list">
{#if status.patterns.length === 0}
<div class="lfs-empty"><Box size={20} aria-hidden="true" /><span>{isGerman ? "Noch keine Dateien werden über LFS verwaltet." : "No files are tracked through LFS yet."}</span></div>
{:else}
{#each status.patterns as item (`${item.source}:${item.pattern}`)}
<article>
<div><code>{item.pattern}</code><span>{item.source}{item.lockable ? " · lockable" : ""}</span></div>
<button class="btn-sm danger" type="button" onclick={() => onUntrack(item.pattern)} disabled={isBusy || !canRemove(item)} title={canRemove(item) ? (isGerman ? "Muster entfernen" : "Remove pattern") : (isGerman ? "Nur Muster aus der Wurzel-.gitattributes können hier entfernt werden" : "Only patterns from the root .gitattributes can be removed here") }><Trash2 size={14} aria-hidden="true" /></button>
</article>
{/each}
{/if}
</div>
</section>
<section class="lfs-panel objects-panel">
<header><div><span class="eyebrow">Current checkout</span><h3>{isGerman ? "LFS-Dateien" : "LFS files"}</h3></div><span>{status.files.length}</span></header>
<div class="lfs-object-list">
{#if status.files.length === 0}
<div class="lfs-empty"><HardDriveDownload size={20} aria-hidden="true" /><span>{isGerman ? "Im aktuellen Stand sind keine LFS-Objekte vorhanden." : "The current checkout has no LFS objects."}</span></div>
{:else}
{#each status.files as file (`${file.oid}:${file.name}`)}
<article>
<span class:downloaded={file.downloaded} class="object-state" title={file.downloaded ? (isGerman ? "Objekt lokal vorhanden" : "Object available locally") : (isGerman ? "Nur LFS-Zeiger vorhanden" : "LFS pointer only")}>{#if file.downloaded}<Check size={12} />{:else}<Download size={12} />{/if}</span>
<div title={file.name}><strong>{file.name}</strong><span>{formatBytes(file.size)} · <code>{file.oid.slice(0, 10)}</code></span></div>
</article>
{/each}
{/if}
</div>
</section>
</div>
{/if}
</div>
{/if}
</div>
<footer class="lfs-footer">
<div><ShieldCheck size={14} aria-hidden="true" /><span>{isGerman ? ".gitattributes bleibt als normale Änderung sichtbar und muss committed werden." : ".gitattributes remains a normal change and must be committed."}</span></div>
<div>
<button class="btn-secondary" type="button" onclick={confirmPrune} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
</div>
</footer>
</div>
</div>
<style>
.lfs-dialog { --lfs-success: #4eca76; --lfs-warning: #f0b648; --lfs-danger: #e86060; width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(660px, calc(100vh - 32px)); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border-radius: 12px; }
.lfs-dialog-header, .lfs-heading, .dialog-header-actions, .lfs-footer, .lfs-footer > div { display: flex; align-items: center; }
.lfs-dialog-header { min-height: 58px; justify-content: space-between; padding: 10px 13px; }
.lfs-heading { gap: 9px; }
.lfs-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); border-radius: 9px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.dialog-header-actions { gap: 7px; }
.lfs-main { min-height: 0; overflow: hidden; }
.lfs-error { display: flex; align-items: center; gap: 8px; margin: 10px 13px 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); border-radius: 7px; color: var(--lfs-danger); background: color-mix(in srgb, var(--lfs-danger) 8%, transparent); font-size: 11px; }
.lfs-loading { display: grid; min-height: 280px; place-items: center; align-content: center; gap: 10px; color: var(--color-ink-dim); font-size: 12px; }
.lfs-content { min-height: 0; max-height: 548px; padding: 12px 13px 13px; overflow: auto; }
.lfs-diagnostics { display: grid; grid-template-columns: minmax(0, 1fr) 22px minmax(0, 1fr) 22px minmax(0, 1fr); align-items: center; margin-bottom: 11px; }
.lfs-diagnostics article { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; min-height: 64px; padding: 8px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.lfs-diagnostics article.ready { border-color: color-mix(in srgb, var(--lfs-success) 34%, var(--color-border)); }
.lfs-diagnostics article.warning { border-color: color-mix(in srgb, var(--lfs-warning) 38%, var(--color-border)); }
.lfs-diagnostics article.error { border-color: color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); }
.diagnostic-icon { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 8px; color: var(--color-ink-dim); background: var(--color-surface-hover); }
article.ready .diagnostic-icon { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
article.warning .diagnostic-icon { color: var(--lfs-warning); }
article.error .diagnostic-icon { color: var(--lfs-danger); }
.lfs-diagnostics article div { display: grid; min-width: 0; gap: 2px; }
.lfs-diagnostics small { color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.lfs-diagnostics strong { overflow: hidden; color: var(--color-ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.lfs-diagnostics article div > span { color: var(--color-ink-dim); font-size: 10px; }
.diagnostic-link { display: grid; place-items: center; color: var(--color-border); }
.diagnostic-link.ready { color: color-mix(in srgb, var(--lfs-success) 60%, var(--color-border)); }
.lfs-missing, .lfs-setup-card { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.lfs-missing-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--lfs-danger); }
.lfs-missing strong, .lfs-setup-card strong { color: var(--color-ink); font-size: 12px; }
.lfs-missing p, .lfs-setup-card p { margin: 4px 0 0; color: var(--color-ink-dim); font-size: 10.5px; line-height: 1.5; }
.lfs-setup-card { justify-content: space-between; margin-bottom: 11px; border-color: color-mix(in srgb, var(--lfs-warning) 32%, var(--color-border)); }
.lfs-setup-card > div { max-width: 650px; }
.lfs-setup-card button { flex: 0 0 auto; }
.lfs-grid { display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 10px; height: clamp(230px, 30vh, 285px); min-height: 0; }
.lfs-panel { display: grid; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); border: 1px solid var(--color-border-subtle); border-radius: 10px; overflow: hidden; background: var(--color-surface-raised); }
.objects-panel { grid-template-rows: auto minmax(0, 1fr); }
.lfs-panel > header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 74%, transparent); }
.lfs-panel h3 { margin: 2px 0 0; color: var(--color-ink); font-size: 13px; }
.lfs-panel > header > span { display: grid; min-width: 25px; height: 22px; place-items: center; border-radius: 11px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); font-size: 10px; font-weight: 800; }
.lfs-track-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 7px 9px; padding: 9px 10px; border-bottom: 1px solid var(--color-border-subtle); }
.lfs-track-form > label:first-child { display: grid; gap: 5px; grid-column: 1 / -1; }
.lfs-track-form label > span:first-child { color: var(--color-ink-dim); font-size: 9.5px; font-weight: 700; }
.lfs-track-form input[type="text"] { width: 100%; }
.lfs-lockable { display: inline-flex; width: fit-content; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
.lfs-lockable input[type="checkbox"] { width: 14px; min-width: 14px; height: 14px; margin: 0; padding: 0; border-radius: 3px; accent-color: var(--color-accent); cursor: pointer; box-shadow: none; }
.lfs-lockable > span { display: flex; align-items: baseline; gap: 4px; white-space: nowrap; }
.lfs-lockable strong { color: var(--color-ink); font-size: 10px; }
.lfs-lockable small { color: var(--color-ink-faint); font-size: 8.5px; }
.lfs-pattern-list, .lfs-object-list { min-height: 0; overflow: auto; }
.lfs-pattern-list article, .lfs-object-list article { display: flex; align-items: center; gap: 9px; min-height: 49px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); }
.lfs-pattern-list article:last-child, .lfs-object-list article:last-child { border-bottom: 0; }
.lfs-pattern-list article > div, .lfs-object-list article > div { display: grid; min-width: 0; flex: 1; gap: 3px; }
.lfs-pattern-list code, .lfs-object-list strong { overflow: hidden; color: var(--color-ink); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.lfs-pattern-list article span, .lfs-object-list article span { color: var(--color-ink-faint); font-size: 9px; }
.object-state { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; border-radius: 50%; color: var(--lfs-warning); background: color-mix(in srgb, var(--lfs-warning) 10%, transparent); }
.object-state.downloaded { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
.lfs-empty { display: grid; width: 100%; height: 100%; min-height: 80px; place-items: center; align-content: center; gap: 7px; padding: 14px; color: var(--color-ink-faint); text-align: center; }
.lfs-empty span { max-width: 260px; font-size: 10px; line-height: 1.45; }
.lfs-footer { min-height: 54px; justify-content: space-between; gap: 14px; padding: 9px 13px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.lfs-footer > div { gap: 7px; }
.lfs-footer > div:first-child { min-width: 0; color: var(--color-ink-dim); font-size: 9.5px; }
.lfs-footer > div:last-child { flex: 0 0 auto; gap: 8px; }
@media (max-width: 760px) {
.dialog-backdrop { padding: 10px; }
.lfs-dialog { width: min(620px, 100%); height: min(760px, 100%); max-height: none; }
.lfs-content { height: 100%; max-height: none; }
.lfs-diagnostics { grid-template-columns: 1fr; gap: 7px; }
.diagnostic-link { display: none; }
.lfs-grid { grid-template-columns: 1fr; height: auto; }
.lfs-panel { min-height: 210px; }
.lfs-footer { align-items: flex-start; flex-direction: column; }
.lfs-footer > div:last-child { width: 100%; justify-content: flex-end; }
}
@media (max-width: 460px) {
.lfs-dialog-header { min-height: 54px; }
.lfs-mark { width: 31px; height: 31px; }
.lfs-track-form { grid-template-columns: 1fr; }
.lfs-track-form > label:first-child { grid-column: 1; }
.lfs-track-form button { justify-self: end; }
.lfs-lockable > span { white-space: normal; }
.lfs-footer > div:first-child { display: none; }
.lfs-footer > div:last-child { justify-content: stretch; }
.lfs-footer button { flex: 1; }
}
</style>
+177 -1
View File
@@ -3,6 +3,7 @@
import {
AlertTriangle,
BookOpen,
Box,
Check,
ChevronRight,
CircleHelp,
@@ -468,6 +469,126 @@
},
];
deCategories.splice(deCategories.findIndex((category) => category.id === "remote") + 1, 0, {
id: "lfs",
label: "Git LFS",
description: "Große Binärdateien tracken, Objekte synchronisieren und bestehende Repositories sicher umstellen.",
sections: [
{
id: "lfs-overview",
title: "Was Git LFS macht",
summary: "Git LFS ersetzt große Dateien im Git-Verlauf durch kleine Zeigerdateien. Die eigentlichen Inhalte liegen im LFS-Speicher des Remotes und werden beim Checkout oder Pull passend geladen.",
steps: [
"Nutze LFS vor allem für große Binärdateien wie PSD-, Video-, Audio-, Modell- oder Archivdateien, die Git nicht sinnvoll als Text-Diff verwalten kann.",
"Gitty liefert die Git-LFS-Erweiterung in Desktop-Installern mit und zeigt Version, Filter sowie Pre-push-Hook im LFS-Dialog an.",
"Die LFS-Regeln stehen in .gitattributes und gehören deshalb wie normaler Quellcode in das Repository.",
],
},
{
id: "lfs-setup",
title: "Git LFS in Gitty einrichten",
summary: "Die Einrichtung gilt für das aktuell geöffnete Repository und verändert keine globalen Git-Einstellungen.",
steps: [
"Öffne im Repository das Menü Synchronisieren und wähle Git LFS.",
"Klicke auf LFS aktivieren, damit Gitty die lokalen Filter und den Pre-push-Hook einrichtet.",
"Füge ein Muster wie *.psd, Assets/** oder video.mp4 hinzu. Lockable markiert Dateien, die über einen kompatiblen LFS-Server gesperrt werden können.",
"Stage und committe anschließend .gitattributes zusammen mit den gewünschten Dateien.",
],
commands: [
{ command: "git lfs install --local", description: "LFS nur im aktuellen Repository aktivieren" },
{ command: "git lfs track \"*.psd\"", description: "Ein Dateimuster über Git LFS verwalten" },
{ command: "git add .gitattributes", description: "Die erzeugten Tracking-Regeln stagen" },
],
},
{
id: "lfs-sync",
title: "LFS-Objekte synchronisieren",
summary: "Ein normaler Pull in Gitty prüft nach erfolgreicher Git-Synchronisierung automatisch auf LFS und lädt benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.",
steps: [
"Normales Push nutzt den LFS-Pre-push-Hook und lädt neue LFS-Objekte vor den Git-Referenzen hoch.",
"Objekte laden im LFS-Dialog ist ein manueller Reparatur- oder Aktualisierungsschritt, falls lokale Inhalte fehlen.",
"Cache bereinigen entfernt sicher nicht mehr benötigte lokale Objekte; aktuell verwendete und noch nicht gepushte Inhalte bleiben erhalten.",
],
commands: [
{ command: "git lfs status", description: "LFS-Zustand und ausstehende Änderungen prüfen" },
{ command: "git lfs pull", description: "Benötigte LFS-Objekte manuell laden" },
{ command: "git lfs prune", description: "Nicht mehr benötigte lokale LFS-Objekte bereinigen" },
{ command: "git lfs lock <datei>", description: "Eine als lockable markierte Datei auf einem kompatiblen Remote sperren" },
],
},
{
id: "lfs-migrate",
title: "Bestehende Dateien umstellen",
summary: "Ein neu hinzugefügtes Tracking-Muster schreibt vorhandene Commits nicht rückwirkend um. Aktuelle Dateien lassen sich neu normalisieren; eine vollständige Migration verändert dagegen die Historie.",
commands: [
{ command: "git add --renormalize .", description: "Aktuelle Dateien erneut durch die neuen LFS-Regeln führen" },
{ command: "git lfs migrate import --include=\"*.psd\" --everything", description: "Passende Dateien in der gesamten Historie nach LFS migrieren" },
],
note: "Vorsicht: git lfs migrate import schreibt Commit-Hashes um. Stimme die Migration mit allen Beteiligten ab, erstelle vorher ein Backup und rechne bei bereits veröffentlichten Branches mit einem koordinierten Force-Push.",
},
],
});
enCategories.splice(enCategories.findIndex((category) => category.id === "remote") + 1, 0, {
id: "lfs",
label: "Git LFS",
description: "Track large binary files, synchronize objects, and migrate existing repositories safely.",
sections: [
{
id: "lfs-overview",
title: "What Git LFS does",
summary: "Git LFS replaces large files in Git history with small pointer files. The actual content is stored in the remote's LFS storage and downloaded for the relevant checkout or pull.",
steps: [
"Use LFS mainly for large binary files such as PSDs, videos, audio, models, or archives that Git cannot usefully manage as text diffs.",
"Gitty bundles the Git LFS extension in desktop installers and displays its version, filters, and pre-push hook in the LFS dialog.",
"LFS rules live in .gitattributes, so commit them to the repository like regular source code.",
],
},
{
id: "lfs-setup",
title: "Set up Git LFS in Gitty",
summary: "Setup applies to the currently open repository and does not change global Git settings.",
steps: [
"Open the Sync menu in the repository and select Git LFS.",
"Select Activate LFS so Gitty configures the local filters and pre-push hook.",
"Add a pattern such as *.psd, Assets/**, or video.mp4. Lockable marks files that can be locked through a compatible LFS server.",
"Stage and commit .gitattributes together with the files you want to track.",
],
commands: [
{ command: "git lfs install --local", description: "Activate LFS only in the current repository" },
{ command: "git lfs track \"*.psd\"", description: "Manage a file pattern through Git LFS" },
{ command: "git add .gitattributes", description: "Stage the generated tracking rules" },
],
},
{
id: "lfs-sync",
title: "Synchronize LFS objects",
summary: "After a successful regular pull, Gitty automatically checks for LFS and downloads required objects with the same remote and credentials. A second pull is not necessary.",
steps: [
"A normal push uses the LFS pre-push hook to upload new LFS objects before Git references are published.",
"Pull objects in the LFS dialog is a manual repair or refresh action when local content is missing.",
"Prune cache safely removes unused local objects while retaining current and unpushed content.",
],
commands: [
{ command: "git lfs status", description: "Inspect LFS state and pending changes" },
{ command: "git lfs pull", description: "Download required LFS objects manually" },
{ command: "git lfs prune", description: "Remove unused local LFS objects" },
{ command: "git lfs lock <file>", description: "Lock a lockable file on a compatible remote" },
],
},
{
id: "lfs-migrate",
title: "Migrate existing files",
summary: "Adding a tracking pattern does not rewrite existing commits. Current files can be renormalized, while a complete migration changes repository history.",
commands: [
{ command: "git add --renormalize .", description: "Run current files through the new LFS rules again" },
{ command: "git lfs migrate import --include=\"*.psd\" --everything", description: "Move matching files to LFS throughout repository history" },
],
note: "Caution: git lfs migrate import rewrites commit hashes. Coordinate the migration with every contributor, create a backup first, and expect a coordinated force push for published branches.",
},
],
});
// Extended handbook chapters. Keeping these additions next to the shared data makes
// it straightforward to compare the German and English coverage section by section.
deCategories.find((category) => category.id === "start")?.sections.push(
@@ -1399,6 +1520,33 @@
label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [
{
id: "changelog-2026-8-5",
title: "Version 2026.8.5",
summary: "Dieses Release integriert Git LFS direkt in Gitty und macht den Staging-Bereich bei vielen geänderten Dateien deutlich übersichtlicher.",
steps: [
"Git LFS ist direkt über das Synchronisierungsmenü erreichbar. Gitty prüft die verfügbare Erweiterung, die Repository-Konfiguration und den Pre-Push-Hook und zeigt an, ob Git LFS mit Gitty gebündelt oder systemweit installiert ist.",
"LFS-Muster lassen sich hinzufügen, als Lockable markieren und wieder entfernen. Der Dialog zeigt außerdem die LFS-Dateien des aktuellen Checkouts, lädt fehlende Objekte und bereinigt nicht mehr benötigte Cache-Objekte.",
"Nach einem erfolgreichen Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter manueller Pull ist nicht erforderlich.",
"Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.",
"Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.",
"Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.",
"Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Relative Pfade werden dabei aufgelöst.",
"Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.",
],
note: "Von Git LFS erzeugte Änderungen an .gitattributes gehören zum Repository und müssen wie jede andere Änderung committed werden. Bereits vorhandene Git-Historie wird durch neue Tracking-Muster nicht rückwirkend umgeschrieben.",
},
{
id: "changelog-2026-8-4",
title: "Version 2026.8.4",
summary: "Dieses Release vereinfacht die Verwaltung zusammengehöriger Branches und sorgt für eine einheitlichere, klarere Oberfläche.",
steps: [
"Lokale und verschachtelte Remote-Branch-Ordner lassen sich über ihr Kontextmenü gesammelt löschen. Der oberste Remote-Ordner wie origin ist geschützt. Der aktuell ausgecheckte Branch bleibt erhalten und einzelne Fehler werden nach Abschluss verständlich aufgeführt.",
"Der Compare-Auswahldialog ist vollständig auf Deutsch verfügbar und orientiert sich bei Feldern, Gruppen, Typografie und Dialogflächen am Styling der externen Tools.",
"Die Schließen-Schaltflächen der Repository-Tabs sind quadratisch und haben ausgewogenere Abstände sowie deutlichere Hover- und Tastaturfokus-Zustände.",
],
note: "Der oberste Remote-Ordner wie origin kann nicht gesammelt gelöscht werden. Seine Unterordner können weiterhin gezielt verwaltet werden.",
},
{
id: "changelog-2026-8-3",
title: "Version 2026.8.3",
@@ -1488,6 +1636,33 @@
label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [
{
id: "changelog-2026-8-5",
title: "Version 2026.8.5",
summary: "This release integrates Git LFS directly into Gitty and makes the staging area much easier to navigate when many files have changed.",
steps: [
"Git LFS is available directly from the Sync menu. Gitty checks the available extension, repository configuration, and pre-push hook, and reports whether Git LFS is bundled with Gitty or installed system-wide.",
"LFS patterns can be added, marked as Lockable, and removed again. The dialog also lists LFS files in the current checkout, downloads missing objects, and prunes unused cache objects.",
"After a successful pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. A second manual pull is no longer required.",
"Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.",
"The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.",
"The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. The selected name, parent path, and affected file count are now clearly separated.",
"Repositories can be opened directly at startup with --repo PATH or --repo=PATH. Relative paths are resolved automatically.",
"Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.",
],
note: "Changes to .gitattributes created by Git LFS belong to the repository and must be committed like any other change. New tracking patterns do not rewrite existing Git history retroactively.",
},
{
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",
@@ -1681,6 +1856,7 @@
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
{:else if category.id === "remote"}<Cloud size={17} aria-hidden="true" />
{:else if category.id === "lfs"}<Box size={17} aria-hidden="true" />
{:else if category.id === "troubleshooting"}<Wrench size={17} aria-hidden="true" />
{:else if category.id === "workflows"}<ListChecks size={17} aria-hidden="true" />
{:else if category.id === "reference"}<Library size={17} aria-hidden="true" />
@@ -1693,7 +1869,7 @@
<div class="help-nav-tip">
<span class="help-tip-icon"><Lightbulb size={16} aria-hidden="true" /></span>
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>stash</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>lfs</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
</div>
</nav>
+302 -36
View File
@@ -1,5 +1,14 @@
<script lang="ts">
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
import {
Archive,
ArrowLeft,
ArrowRight,
FileDiff,
Folder,
FolderOpen,
FolderTree,
RotateCcw,
} from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
@@ -17,11 +26,41 @@
onUnstage: (files: GitFileStatus[]) => void;
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
onDiscardMany: (files: GitFileStatus[]) => void;
onStash: (files: GitFileStatus[], label: string) => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
onUnstageAll: () => void;
}
type StatusViewMode = "list" | "tree";
type StatusLaneKind = "unstaged" | "staged";
interface StatusFolderNode {
kind: "folder";
name: string;
path: string;
depth: number;
fileCount: number;
children: StatusTreeNode[];
}
interface StatusFileNode {
kind: "file";
name: string;
path: string;
depth: number;
file: GitFileStatus;
}
type StatusTreeNode = StatusFolderNode | StatusFileNode;
interface StatusContextTarget {
lane: StatusLaneKind;
kind: "file" | "folder";
label: string;
files: GitFileStatus[];
}
let {
changedFiles = [],
stagedCount = 0,
@@ -36,6 +75,7 @@
onUnstage = () => {},
onDiscard = () => {},
onDiscardMany = () => {},
onStash = () => {},
onPatch = () => {},
onStageAll = () => {},
onUnstageAll = () => {},
@@ -65,8 +105,156 @@
return `${file.old_path ?? ""}:${file.path}`;
}
function sortStatusTree(nodes: StatusTreeNode[]) {
nodes.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) {
if (node.kind === "folder") sortStatusTree(node.children);
}
}
function buildStatusTree(files: GitFileStatus[]): StatusTreeNode[] {
const roots: StatusTreeNode[] = [];
const folders = new Map<string, StatusFolderNode>();
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
const parts = file.path.split(/[\\/]+/).filter(Boolean);
if (parts.length === 0) continue;
let currentPath = "";
let siblings = roots;
const ancestors: StatusFolderNode[] = [];
for (let index = 0; index < parts.length - 1; index++) {
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
let folder = folders.get(currentPath);
if (!folder) {
folder = {
kind: "folder",
name: parts[index],
path: currentPath,
depth: index,
fileCount: 0,
children: [],
};
folders.set(currentPath, folder);
siblings.push(folder);
}
ancestors.push(folder);
siblings = folder.children;
}
siblings.push({
kind: "file",
name: parts[parts.length - 1] ?? file.path,
path: file.path,
depth: Math.max(parts.length - 1, 0),
file,
});
for (const folder of ancestors) folder.fileCount += 1;
}
sortStatusTree(roots);
return roots;
}
function flattenStatusTree(nodes: StatusTreeNode[], lane: StatusLaneKind): StatusTreeNode[] {
const visible: StatusTreeNode[] = [];
for (const node of nodes) {
visible.push(node);
if (node.kind === "folder" && !collapsedStatusFolders.has(`${lane}:${node.path}`)) {
visible.push(...flattenStatusTree(node.children, lane));
}
}
return visible;
}
function listStatusRows(files: GitFileStatus[]): StatusFileNode[] {
return files.map((file) => ({
kind: "file",
name: fileName(file),
path: file.path,
depth: 0,
file,
}));
}
let selectedStatusPaths = $state<Set<string>>(new Set());
let selectionAnchorKey = $state("");
let statusView = $state<StatusViewMode>("list");
let collapsedStatusFolders = $state<Set<string>>(new Set());
let statusContextTarget = $state<StatusContextTarget | null>(null);
let statusContextMenuX = $state(0);
let statusContextMenuY = $state(0);
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
const next = new Set(collapsedStatusFolders);
const key = `${lane}:${path}`;
if (next.has(key)) next.delete(key);
else next.add(key);
collapsedStatusFolders = next;
}
function filesInStatusFolder(lane: StatusLaneKind, path: string): GitFileStatus[] {
const prefix = `${path.replace(/\\/g, "/").replace(/\/$/, "")}/`;
const files = lane === "unstaged" ? unstagedFiles : stagedFiles;
return files.filter((file) => {
const currentPath = file.path.replace(/\\/g, "/");
const oldPath = file.old_path?.replace(/\\/g, "/") ?? "";
return currentPath.startsWith(prefix) || oldPath.startsWith(prefix);
});
}
function openStatusContextMenu(
event: MouseEvent,
lane: StatusLaneKind,
kind: "file" | "folder",
label: string,
files: GitFileStatus[],
) {
event.preventDefault();
event.stopPropagation();
if (files.length === 0) return;
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
statusContextTarget = { lane, kind, label, files };
}
function statusContextName(label: string): string {
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
return normalized.split("/").pop() || label;
}
function statusContextParent(label: string): string {
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
const separator = normalized.lastIndexOf("/");
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
}
function closeStatusContextMenu() {
statusContextTarget = null;
}
function runStatusContextStageAction() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
if (target.lane === "unstaged") onStage(target.files);
else onUnstage(target.files);
}
function runStatusContextStashAction() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
onStash(target.files, target.label);
}
function handleStatusWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeStatusContextMenu();
}
function isStatusSelected(file: GitFileStatus): boolean {
return selectedStatusPaths.has(fileKey(file));
@@ -146,6 +334,10 @@
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
let unstagedTree = $derived(buildStatusTree(unstagedFiles));
let stagedTree = $derived(buildStatusTree(stagedFiles));
let visibleUnstagedRows = $derived(statusView === "tree" ? flattenStatusTree(unstagedTree, "unstaged") : listStatusRows(unstagedFiles));
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
@@ -157,12 +349,22 @@
});
</script>
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
<div class="section-head">
<div>
<span class="eyebrow">Workspace</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
</div>
<div class="status-view-switch" role="group" aria-label="Changes view">
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
</button>
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
<FolderTree size={13} aria-hidden="true" /><span>Tree</span>
</button>
</div>
<div class="status-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<span class="pill pill-count">{unstagedCount} unstaged</span>
@@ -181,19 +383,22 @@
{:else if changedFiles.length === 0}
<div class="blank-state">No file changes returned.</div>
{:else}
<div class="status-lanes overflow-auto">
<section class="status-lane" aria-label="Unstaged changes">
<div class="status-lanes">
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
<header class="status-lane-head">
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
<div class="status-lane-title">
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
<span class="status-lane-count">{unstagedCount}</span>
</div>
<div class="status-lane-actions">
{#if selectedUnstagedCount > 1}
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
<Check size={13} aria-hidden="true" /> Stage all
<ArrowRight size={13} aria-hidden="true" /> Stage all
</button>
{#if selectedUnstagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
@@ -203,37 +408,54 @@
</div>
</header>
<div class="status-file-list">
{#each unstagedFiles as file (`unstaged:${fileKey(file)}`)}
{@const stageTargets = selectedStageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
{#if row.kind === "folder"}
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "folder", row.path, filesInStatusFolder("unstaged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
{#if collapsedStatusFolders.has(`unstaged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
<strong>{row.name}</strong>
<span class="status-folder-count">{row.fileCount}</span>
</button>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{:else}
{@const file = row.file}
{@const stageTargets = selectedStageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each}
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
</div>
</section>
<section class="status-lane" aria-label="Staged changes">
<div class="status-flow-divider" aria-hidden="true">
<span><ArrowRight size={12} /></span>
</div>
<section class="status-lane staged-lane" aria-label="Staged changes">
<header class="status-lane-head">
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
<div class="status-lane-title">
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
<span class="status-lane-count">{stagedCount}</span>
</div>
<div class="status-lane-actions">
{#if selectedStagedCount > 1}
<span class="status-selection-count">{selectedStagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
<Undo2 size={13} aria-hidden="true" /> Unstage all
<ArrowLeft size={13} aria-hidden="true" /> Unstage all
</button>
{#if selectedStagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
@@ -243,20 +465,30 @@
</div>
</header>
<div class="status-file-list">
{#each stagedFiles as file (`staged:${fileKey(file)}`)}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
{#each visibleStagedRows as row (`staged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
{#if row.kind === "folder"}
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "folder", row.path, filesInStatusFolder("staged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
<i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
{#if collapsedStatusFolders.has(`staged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
<strong>{row.name}</strong>
<span class="status-folder-count">{row.fileCount}</span>
</button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><Undo2 size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{:else}
{@const file = row.file}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each}
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
</div>
@@ -284,6 +516,40 @@
{/if}
</section>
{#if statusContextTarget}
<div class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div class="status-context-label">
<span class="status-context-object-icon" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
</span>
<span class="status-context-object-copy">
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
</span>
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
{statusContextTarget.files.length}
</span>
</div>
<button type="button" role="menuitem" onclick={runStatusContextStageAction} disabled={isBusy}>
<span class="status-context-action-icon" aria-hidden="true">
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
</span>
</button>
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
<span class="status-context-action-copy">
<strong>Stash {statusContextTarget.kind}</strong>
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
</span>
</button>
</div>
{/if}
<style>
.status-panel-overlay {
position: absolute;
+38 -1
View File
@@ -14,6 +14,7 @@ import type {
GitBranch,
GitCommit,
GitCommitComparison,
GitLfsStatus,
GitRepositoryFile,
GitRemote,
MergeStrategy,
@@ -90,6 +91,35 @@ export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path });
}
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
return invoke<GitLfsStatus>("git_lfs_status", { path });
}
export function installGitLfs(path: string): Promise<GitLfsStatus> {
return invoke<GitLfsStatus>("git_lfs_install", { path });
}
export function trackGitLfsPattern(path: string, pattern: string, lockable = false): Promise<GitLfsStatus> {
return invoke<GitLfsStatus>("git_lfs_track", { path, pattern, lockable });
}
export function untrackGitLfsPattern(path: string, pattern: string): Promise<GitLfsStatus> {
return invoke<GitLfsStatus>("git_lfs_untrack", { path, pattern });
}
export function pullGitLfsObjects(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus> {
return invoke<GitLfsStatus>("git_lfs_pull", {
path,
remote: remote || null,
username: username ?? null,
password: password ?? null,
});
}
export function pruneGitLfsObjects(path: string): Promise<GitLfsStatus> {
return invoke<GitLfsStatus>("git_lfs_prune", { path });
}
// Sets the taskbar icon badge to ahead + behind + changed status files (0 clears it). Windows only — a no-op on
// other platforms, since Windows has no native numeric badge to fall back to.
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
@@ -107,7 +137,12 @@ export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
console.log("remove_remote")
return invoke("remove_remote", { path, name }); }
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise<GitStatus> { return invoke("delete_remote_branch", { path, remote, branch }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string, username?: string, password?: string): Promise<GitStatus> {
return invoke("delete_remote_branch", { path, remote, branch, username: username ?? null, password: password ?? null });
}
export function deleteRemoteBranches(path: string, remote: string, branches: string[], username?: string, password?: string): Promise<GitStatus> {
return invoke("delete_remote_branches", { path, remote, branches, username: username ?? null, password: password ?? null });
}
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });
@@ -297,11 +332,13 @@ export function stashPush(
path: string,
message?: string,
includeUntracked = true,
paths?: string[],
): Promise<GitStatus> {
return invoke<GitStatus>("stash_push", {
path,
message: message?.trim() ? message.trim() : null,
includeUntracked,
paths: paths?.length ? paths : null,
});
}
+28
View File
@@ -193,6 +193,34 @@ export interface GitRepositoryFile {
status: FileStatusKind | null;
}
export interface GitLfsPattern {
pattern: string;
source: string;
lockable: boolean;
tracked: boolean;
}
export interface GitLfsFile {
name: string;
size: number;
checkout: boolean;
downloaded: boolean;
oid_type: string;
oid: string;
version: string;
}
export interface GitLfsStatus {
available: boolean;
bundled: boolean;
version: string | null;
filters_configured: boolean;
hook_installed: boolean;
repository_uses_lfs: boolean;
patterns: GitLfsPattern[];
files: GitLfsFile[];
}
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];