Compare commits

...
35 Commits
Author SHA1 Message Date
Christoph 25d7f27d7a feat(release): attach native Arch package and repackage for AUR
publish / Build and publish AUR packages (release) Successful in 46m31s
publish / Build and publish Ubuntu AppImage (release) Successful in 20m25s
publish / Build and publish Windows installer (release) Successful in 22m15s
Update release workflow to detect and attach the native Arch package.
Switch the binary AUR recipe to repackage the .pkg.tar.zst. Adjust
checksums and packaging steps so the output carries only the native
payload without bundling upstream package metadata.

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

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

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

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

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

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

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

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

- Make the close button a 24px circular, grid-centered element.
- Add focus-visible and hover color with a subtle accent background.
- Reduce the close icon to 11px to improve visual balance.
2026-08-15 18:51:01 +02:00
Christoph a7558e457d feat(compare): add German localization and revamp compare UI
Pass the application language into compare dialogs and add German
translations so titles, buttons and helper text render appropriately.
Refactor dialog markup to introduce a compact header with a GitCompare
icon, show changed-file counts, and surface contextual help and warnings.
Adjust and extend CSS for spacing, layout and responsive behavior.

- Pass app language into compare components and derive isGerman flag
- Replace icons and restructure headers; add counts, help and warnings
- Update styles for sizing, spacing, responsive rules and new classes
2026-08-15 18:39:10 +02:00
Christoph 412e8d19b5 feat(ui): add SelectMenu component and replace native selects
Introduce a reusable SelectMenu component and replace native select
controls across the UI. This centralizes select behavior and styling,
enabling grouped options, placeholders, and a consistent popup
interaction. Add comprehensive CSS and light-theme tweaks, and update
rebase action styling to integrate the new control.

- Add a unified SelectMenu component and wire change handlers.
- Implement .select-menu styles, popup behavior, and theme overrides.
- Replace ad-hoc native selects in dialogs and rebase UI for consistency.
2026-08-15 18:26:32 +02:00
Christoph 32832f5db7 feat(git): support credentials for remote branch rename
Allow remote branch renames to be performed with optional credentials so
operations against protected remotes succeed when authentication is needed.
The backend accepts username/password and uses an authenticated push path
when provided, while the frontend prompts for and reuses stored credentials.

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

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

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

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

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

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

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

- Remote URL resolution now considers direction (pull/push) and mode
- Credential dialog, saving, and keychain handling updated to pass and
  respect the mode
- Unique askpass scripts generated per invocation to avoid clashes
2026-08-13 22:27:00 +02:00
Christoph Brandau f621638eb3 feat(system): enable window destroy capability and stabilize shutdown
publish / Build and publish Ubuntu AppImage (release) Successful in 22m40s
publish / Build and publish Windows installer (release) Successful in 31m3s
publish / Build and publish gitty-desktop to AUR (release) Successful in 50m26s
Adds a new window capability to allow destroying the window.
It also changes shutdown handling in telemetry to avoid errors.

- Add core:window:allow-destroy to default capabilities
- Avoid shutdown errors by returning a pending promise in tracedInvoke
- Improve stability during shutdown for in-flight telemetry calls
2026-08-13 22:11:45 +02:00
Christoph 56467f481d Merge pull request 'Open code test' (#26) from openCodeTest into main
publish / Build and publish Ubuntu AppImage (release) Failing after 2m20s
publish / Build and publish Windows installer (release) Successful in 27m20s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 40m8s
Reviewed-on: #26
2026-08-13 18:48:11 +00:00
Christoph Brandau 6434a9f10c refactor(credentials): remove credential expiry support and API
The credential expiry feature has been removed from both the Rust
backend and the frontend. Stored credentials now include only
username and password.

- Remove expiresAt field from StoredCredential
- Simplify API by removing expiry param from credSave
- Drop expiry UI and expiry checks across the app
2026-08-13 20:45:43 +02:00
Christoph Brandau cc0f1c076f refactor(ui): simplify branch status display and adjust layout
The changes simplify how branch status is shown in HistoryPanel.
The remoteName helper and the check icon were removed.
Status is composed inline with the label for local and remote branches.
CSS tweaks wrap content and adjust widths for better responsiveness.

- Remove remoteName helper and Check icon from HistoryPanel
- Inline status text for local/remote branches and avoid icons
- Improve graph layout: wrap refs and constrain widths on hover
2026-08-13 20:06:27 +02:00
Christoph Brandau b6b9964a93 feat(git): add upstream tracking and local-only branch UI
This release adds upstream tracking for branches and a local-only
indicator in the UI.
The Git integration now exposes upstreams and supports publish flows.
This enables publishing and remote-tracking configuration.

- Introduces upstream tracking and local-only branch UI markers
- Updates toolbar to reflect publish-local state and status indicators
- Bumps version to 2026.8.3 and updates changelog
2026-08-13 19:32:26 +02:00
Christoph Brandau f43fe00873 feat(git): rename remote branches atomically via push
Adds a new command to rename remote branches and update tracking refs.
Renaming remote refs is performed atomically using a single push.
The push creates the new remote ref and deletes the old if it succeeds.
The frontend now invokes remote-rename when needed and shows labels.

- Atomic remote rename via push with create/ref and delete
- Frontend supports remote branch renames from the branch panel
- Compare UI now shows labels for remote refs in results
2026-08-13 18:32:16 +02:00
Christoph Brandau ca14fac90c feat(ui): refine history graph visuals and layout
Refines history panel visuals and layout, including min width handling.
Removes the old branch visibility select in favor of a dialog button.
It also adds a graph connector to show primary branches.
Adds branch chips, icons, and hover effects to commits for clarity.

- History panel visuals and min width updated to improve layout.
- Removed branch visibility select; added dialog to customize branches.
- Branch chips and graph connectors enhanced with icons and hover effects.
2026-08-13 18:01:10 +02:00
Christoph Brandau 608b3131d2 feat(history-panel): add branch visibility modes and richer refs
Adds branch visibility modes to the history panel and remote branches.
A data model supports commits and refs including local and remote.
UI tweaks add compact ref chips and a new details panel.

- Implement focus/local/all/custom modes for branch visibility
- Introduce CommitBranchDecoration and CommitRefSummary types
- Wire remote branches and ahead/behind data to UI
2026-08-13 15:02:42 +02:00
Christoph Brandau a823aabbb9 feat(external-tools): force tools to open in new windows where needed
Adds a helper that forces selected tools to launch in a new window
instead of reusing the current one. This is applied to code editors,
diff/merge, and terminal launches, aligning behavior across platforms.
Presets and defaults are updated to pass new-window or equivalent flags,
and tests verify the new behavior for common tools.

- Update code editors to always use a new window when opened
- Normalize launch flags for Windows terminals and diff tools
- Add tests covering new-window behavior for common tools
2026-08-13 14:33:05 +02:00
Christoph Brandau 15d1f2bfd6 feat(external-tools): add cross-platform external tool discovery
Adds a new external tools subsystem to detect and launch
diff and editor tools across Windows, macOS, and Linux.
It exposes data models for tools, commands, and results to the UI
and serializes them for consumption by the app.

- Implement cross-platform discovery of editors and diff tools
- Expose serialized results to the UI for user selection
- Centralize per-OS known tool lists and overrides
2026-08-13 14:08:02 +02:00
Christoph Brandau 3eb554fee7 feat(ui): add command palette and commit selection sync
Introduce a global command palette for quick access to common repository
actions, branches, files, and commits. Wire it into the main shell so it
can open settings, help, and other dialogs while keeping keyboard access
consistent.

Also add commit selection state to the history view so the active commit
is highlighted and brought into view when chosen from either the palette
or the history panel.
2026-08-13 07:53:19 +02:00
Christoph Brandau 1fa57eea6f feat(git): convert Git commands to async for better performance
This update modifies several Git command functions to be asynchronous,
improving the responsiveness of the application. By utilizing async
runtime, operations that involve I/O or long-running tasks can now run
without blocking the main thread, enhancing user experience.

- Converted multiple Git command functions to async
- Introduced a helper function to handle async tasks with error management
- Improved overall performance and responsiveness of Git operations
2026-08-11 09:53:34 +02:00
Christoph Brandau 444a7acadd Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-08-10 17:30:33 +02:00
Christoph Brandau ac7cacd687 feat(telemetry): handle application shutdown process
This update introduces a structured shutdown process for the application. It ensures that background tasks are properly terminated and telemetry is notified when the frontend is shutting down, preventing any ongoing operations from continuing during this state.

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