Compare commits

..
12 Commits
Author SHA1 Message Date
Christoph eb65c1def7 fix(workflows): improve AUR SSH key handling and clone/push retries
publish / Build and publish Windows installer (release) Successful in 22m29s
publish / Build and publish Ubuntu AppImage (release) Successful in 37m9s
publish / Build and publish gitty-desktop to AUR (release) Failing after 52m11s
This update enhances the workflow for interacting with the AUR by
adding robust error handling for SSH key retrieval and repository
cloning/pushing. It implements retry logic with exponential backoff
for both SSH host key retrieval and git operations, improving
resilience against transient network issues.

- Added retries for SSH key retrieval with exponential backoff
- Implemented retry logic for cloning and pushing to AUR repository
- Ensured script exits gracefully with error messages on failure
2026-08-04 22:23:50 +02:00
Christoph 96e3805568 fix(dialog): improve escape key handling for dialogs
publish / Build and publish Windows installer (release) Successful in 23m4s
publish / Build and publish Ubuntu AppImage (release) Successful in 44m12s
publish / Build and publish gitty-desktop to AUR (release) Failing after 54m38s
The handling of the Escape key has been refined to ensure that it
closes the correct dialog based on the current state. Additionally,
the z-index for the compare dialog backdrop has been updated to
ensure proper layering with other dialogs.

- Enhanced Escape key functionality for better user experience
- Updated z-index for compare dialog backdrop to avoid overlap
2026-08-04 21:17:04 +02:00
Christoph 2d9a10e85c feat(workflows): implement timeout for pacman installation
publish / Build and publish Windows installer (release) Successful in 24m55s
publish / Build and publish Ubuntu AppImage (release) Successful in 33m25s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 16m4s
This update introduces a timeout mechanism for the pacman installation process in the app builder workflow. If the installation does not complete within 30 minutes, it will terminate and log a timeout message, improving the robustness of the build process.

- Adds a timeout for the pacman command to prevent indefinite hangs
- Implements a heartbeat to monitor the installation progress
- Provides clear error messages for timeout and failure scenarios
2026-08-04 20:26:20 +02:00
Christoph 12d1b2657f fix(workflow): improve Arch Linux package installation process
publish / Build and publish Ubuntu AppImage (release) Canceled after 6m6s
publish / Build and publish Windows installer (release) Canceled after 6m4s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 6m2s
The workflow for building and publishing to Arch Linux has been updated to
enhance the package installation process. This includes adding multiple
mirror sources to ensure reliability and implementing retry logic for
package installation attempts.

- Removed dependency on the Windows publish job
- Added backup mirrors for Arch Linux package management
- Implemented retry logic for package installation failures
2026-08-04 20:19:20 +02:00
Christoph b0b4fb69cb Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-08-04 20:11:51 +02:00
Christoph f3a3fcc079 feat(ci): update workflow for Windows and add Ubuntu publishing
The workflow has been modified to focus on building and publishing
Windows installers, with the previous matrix strategy removed.
Additionally, a new job for building and publishing Ubuntu AppImages
has been added, ensuring that the necessary dependencies and steps
are included for a successful build.

- Renamed job to reflect Windows installer publishing
- Added Ubuntu AppImage publishing workflow
- Simplified job structure by removing matrix strategy
2026-08-04 20:11:44 +02:00
Christoph 9d18f3af61 Update version to 2026.8.1 2026-08-04 19:35:08 +02:00
Christoph c4a922894f style(app): update styles for file history dialog
publish / Build and publish gitty-desktop to AUR (release) Failing after 4m40s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 37m41s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 37m26s
2026-08-04 18:41:19 +02:00
Christoph 41f474f1eb feat(dialog): add file history dialog component 2026-08-04 18:41:19 +02:00
Christoph 6dd70ec52a refactor(app): remove file history panel and related logic 2026-08-04 18:41:19 +02:00
Christoph 90070697dd refactor(explorer): update context menu for file history 2026-08-04 18:41:19 +02:00
Christoph 50524c2759 feat(commits): enhance commit listing with pagination and skipping
This update introduces pagination and skipping functionality for the
commit listing feature, allowing users to load commits in pages and
navigate through them more efficiently. The UI has been adjusted to
support loading more commits dynamically, improving the overall user
experience when dealing with large repositories.

- Added pagination support for commit history
- Introduced a loading mechanism for fetching more commits
- Updated UI components to reflect changes in commit loading behavior
2026-08-04 18:04:10 +02:00
17 changed files with 882 additions and 734 deletions
+198 -49
View File
@@ -5,7 +5,8 @@ on:
types: [published] types: [published]
jobs: jobs:
publish-tauri: publish-windows:
name: Build and publish Windows installer
permissions: permissions:
contents: write contents: write
environment: production environment: production
@@ -30,25 +31,7 @@ jobs:
run: run:
working-directory: GitLite working-directory: GitLite
strategy: runs-on: windows-latest
fail-fast: false
max-parallel: 1
matrix:
include:
# - platform: 'macos-latest'
# args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest'
# args: '--target x86_64-apple-darwin'
- platform: "windows-latest"
bundles: "nsis"
updater_platform: "windows-x86_64"
no_strip: ""
- platform: "ubuntu-22.04"
bundles: "appimage"
updater_platform: "linux-x86_64"
no_strip: "1"
runs-on: ${{ matrix.platform }}
steps: steps:
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there. # cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
@@ -56,25 +39,11 @@ jobs:
with: with:
path: GitLite path: GitLite
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: setup node - name: setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: lts/*
- name: Install uv
if: matrix.platform == 'ubuntu-22.04'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: install rust toolchain
if: matrix.platform == 'ubuntu-22.04'
uses: dtolnay/rust-toolchain@stable
- name: install frontend dependencies - name: install frontend dependencies
run: npm ci run: npm ci
@@ -88,7 +57,6 @@ jobs:
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');" node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Commit and Push Changes - name: Commit and Push Changes
if: matrix.platform == 'windows-latest'
shell: powershell shell: powershell
run: | run: |
git config user.name '${{ vars.USERNAME_GIT }}' git config user.name '${{ vars.USERNAME_GIT }}'
@@ -139,15 +107,15 @@ jobs:
env: env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
NO_STRIP: ${{ matrix.no_strip }} NO_STRIP: ""
run: npm run tauri -- build --bundles ${{ matrix.bundles }} run: npm run tauri -- build --bundles nsis
# ---------------------- # ----------------------
# Upload to MinIO (via mc) and create latest.json # Upload to MinIO (via mc) and create latest.json
# ---------------------- # ----------------------
- name: Upload artifacts to MinIO with cicd_tool - name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool working-directory: GitLite/cicd_tool
env: env:
PUBLISH_PLATFORM: ${{ matrix.updater_platform }} PUBLISH_PLATFORM: windows-x86_64
run: | run: |
uv sync uv sync
uv run main.py uv run main.py
@@ -167,11 +135,66 @@ jobs:
- name: Prepare Arch job container - name: Prepare Arch job container
working-directory: / working-directory: /
run: | run: |
pacman -Syu --noconfirm set -euo pipefail
pacman -S --needed --noconfirm \
base-devel curl git nodejs npm openssh rust \ # The runner image may contain only a single geo mirror. Seed several
webkit2gtk-4.1 gtk3 hicolor-icon-theme \ # currently active HTTPS mirrors and retain the image's list as a
# fallback so one slow CDN cannot fail the complete release.
ARCH_MIRROR_BACKUP="$(mktemp)"
cp /etc/pacman.d/mirrorlist "$ARCH_MIRROR_BACKUP"
printf '%s\n' \
'Server = https://frankfurt.mirror.pkgbuild.com/$repo/os/$arch' \
'Server = https://mirror.osbeck.com/archlinux/$repo/os/$arch' \
'Server = https://mirror.ubrco.de/archlinux/$repo/os/$arch' \
'Server = https://ftp.halifax.rwth-aachen.de/archlinux/$repo/os/$arch' \
'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' \
> /etc/pacman.d/mirrorlist
sed -n '/^[[:space:]]*Server[[:space:]]*=/p' "$ARCH_MIRROR_BACKUP" \
>> /etc/pacman.d/mirrorlist
ARCH_PACKAGES=(
base-devel curl git nodejs npm openssh rust
webkit2gtk-4.1 gtk3 hicolor-icon-theme
libappindicator-gtk3 librsvg xdotool libappindicator-gtk3 librsvg xdotool
)
for ARCH_INSTALL_ATTEMPT in 1 2 3; do
echo "pacman attempt $ARCH_INSTALL_ATTEMPT of 3"
timeout --signal=TERM 30m \
pacman -Syu --needed --noconfirm --noprogressbar \
"${ARCH_PACKAGES[@]}" \
</dev/null &
ARCH_PACMAN_PID=$!
(
ARCH_WAIT_SECONDS=0
while true; do
sleep 30
((ARCH_WAIT_SECONDS += 30))
echo "pacman is still running (${ARCH_WAIT_SECONDS}s elapsed)"
done
) &
ARCH_HEARTBEAT_PID=$!
ARCH_PACMAN_STATUS=0
wait "$ARCH_PACMAN_PID" || ARCH_PACMAN_STATUS=$?
kill "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
wait "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
if [ "$ARCH_PACMAN_STATUS" -eq 0 ]; then
break
fi
if [ "$ARCH_PACMAN_STATUS" -eq 124 ]; then
echo "pacman attempt timed out after 30 minutes" >&2
fi
if [ "$ARCH_INSTALL_ATTEMPT" -eq 3 ]; then
echo "pacman failed after 3 attempts" >&2
exit 1
fi
sleep "$((ARCH_INSTALL_ATTEMPT * 5))"
done
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@@ -218,6 +241,8 @@ jobs:
AUR_GIT_NAME: ${{ vars.USERNAME_GIT }} AUR_GIT_NAME: ${{ vars.USERNAME_GIT }}
AUR_GIT_EMAIL: ${{ vars.EMAIL_GIT }} AUR_GIT_EMAIL: ${{ vars.EMAIL_GIT }}
run: | run: |
set -euo pipefail
if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then
echo "AUR_SSH_PRIVATE_KEY is required" >&2 echo "AUR_SSH_PRIVATE_KEY is required" >&2
exit 1 exit 1
@@ -226,13 +251,48 @@ jobs:
install -d -m 0700 "$HOME/.ssh" install -d -m 0700 "$HOME/.ssh"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur" printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
chmod 0600 "$HOME/.ssh/aur" chmod 0600 "$HOME/.ssh/aur"
ssh-keyscan -H aur.archlinux.org >> "$HOME/.ssh/known_hosts"
chmod 0600 "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes"
AUR_CHECKOUT="$(mktemp -d)/gitty-desktop" AUR_RETRY_DELAYS=(30 60 120 240)
git -c init.defaultBranch=master clone \ AUR_KNOWN_HOSTS_TEMP="$(mktemp)"
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CHECKOUT" AUR_HOST_KEY_READY=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR host-key attempt $AUR_ATTEMPT of 5"
if timeout 30s ssh-keyscan -T 15 -H aur.archlinux.org > "$AUR_KNOWN_HOSTS_TEMP" \
&& [ -s "$AUR_KNOWN_HOSTS_TEMP" ]; then
AUR_HOST_KEY_READY=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$AUR_HOST_KEY_READY" -ne 1 ]; then
echo "Could not retrieve the AUR SSH host key after 5 attempts" >&2
exit 1
fi
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"
AUR_CHECKOUT_ROOT="$(mktemp -d)"
AUR_CHECKOUT=""
for AUR_ATTEMPT in 1 2 3 4 5; do
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT"
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/" cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/"
cd "$AUR_CHECKOUT" cd "$AUR_CHECKOUT"
@@ -246,4 +306,93 @@ jobs:
fi fi
git commit -m "Update to $PACKAGE_VERSION" git commit -m "Update to $PACKAGE_VERSION"
git push origin HEAD:master AUR_PUSHED=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR push attempt $AUR_ATTEMPT of 5"
if timeout 3m git push origin HEAD:master; then
AUR_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 [ "$AUR_PUSHED" -ne 1 ]; then
echo "Could not publish to AUR after 5 attempts" >&2
exit 1
fi
publish-ubuntu:
name: Build and publish Ubuntu AppImage
permissions:
contents: write
environment: production
env:
GITEA_API: https://git.cbsk-tech.de/api/v1
OWNER: Christoph
REPO: GitLite
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
GITEA_FALLBACK_TOKEN: ${{ github.token }}
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
defaults:
run:
working-directory: GitLite
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
with:
path: GitLite
- name: Install Ubuntu dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install frontend dependencies
run: npm ci
- name: Update package.json version
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
- name: Update tauri.conf.json version
env:
RELEASE_VERSION: ${{ github.ref_name }}
run: |
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Build Tauri App
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
NO_STRIP: "1"
run: npm run tauri -- build --bundles appimage
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: linux-x86_64
run: |
uv sync
uv run main.py
+1 -1
View File
@@ -106,7 +106,7 @@ The command list below includes the repository-management and synchronization AP
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>` - `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>` - `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>` - `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>` - `list_commits(path: string, limit?: number, skip?: number): Promise<GitCommit[]>`
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>` - `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path) - `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>` - `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.7.22", "version": "2026.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.7.22", "version": "2026.8.1",
"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.7.22", "version": "2026.8.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+53 -3
View File
@@ -2917,17 +2917,31 @@ fn cherry_pick_status_or_error(
} }
#[tauri::command] #[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> { pub fn list_commits(
path: String,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
commits_for_repo(&repo, limit) commit_page_for_repo(&repo, limit, skip)
} }
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> { fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
commit_page_for_repo(repo, Some(bounded_limit), None)
}
fn commit_page_for_repo(
repo: &Path,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, String> {
if verify_commit(repo, "HEAD").is_err() { if verify_commit(repo, "HEAD").is_err() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let limit = limit.unwrap_or(100).clamp(1, 500).to_string(); let limit = limit.unwrap_or(100).clamp(1, 5_000).to_string();
let skip = skip.unwrap_or(0).min(10_000_000).to_string();
// Fetch the per-commit changed files inline via `--name-status` in a single // Fetch the per-commit changed files inline via `--name-status` in a single
// `git log` process, instead of spawning one `git diff-tree` per commit // `git log` process, instead of spawning one `git diff-tree` per commit
// (which was ~100 extra processes and the main cost of opening a repo). // (which was ~100 extra processes and the main cost of opening a repo).
@@ -2943,6 +2957,8 @@ fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, S
"-z", "-z",
"--root", "--root",
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f", "--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
"--skip",
skip.as_str(),
"-n", "-n",
limit.as_str(), limit.as_str(),
], ],
@@ -6339,6 +6355,40 @@ mod tests {
})); }));
} }
#[test]
fn commit_pages_use_stable_non_overlapping_offsets() {
let repo = init_temp_repo("commit_pages");
commit_initial_file(&repo.path);
for index in 1..=3 {
fs::write(repo.path.join("old.txt"), format!("version {index}\n"))
.expect("tracked file should change");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(
&repo.path,
["commit", "-q", "-m", format!("commit {index}").as_str()],
);
}
let all = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
let first = commit_page_for_repo(&repo.path, Some(2), Some(0))
.expect("first commit page should load");
let second = commit_page_for_repo(&repo.path, Some(2), Some(2))
.expect("second commit page should load");
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert_eq!(first[0].hash, all[0].hash);
assert_eq!(first[1].hash, all[1].hash);
assert_eq!(second[0].hash, all[2].hash);
assert_eq!(second[1].hash, all[3].hash);
assert!(
first
.iter()
.all(|commit| second.iter().all(|other| other.hash != commit.hash))
);
}
#[tokio::test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
+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.7.22", "version": "2026.8.1",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+233 -230
View File
@@ -8,37 +8,27 @@
import TitleBar from "./lib/TitleBar.svelte"; import TitleBar from "./lib/TitleBar.svelte";
import RepoToolbar from "./lib/RepoToolbar.svelte"; import RepoToolbar from "./lib/RepoToolbar.svelte";
import RepoTabs from "./lib/RepoTabs.svelte"; import RepoTabs from "./lib/RepoTabs.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte"; import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte"; import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte"; import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BlameDialog from "./lib/components/BlameDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte";
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte"; import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte"; import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HelpOverlay from "./lib/components/HelpOverlay.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte";
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import ReflogDialog from "./lib/components/ReflogDialog.svelte"; import ReflogDialog from "./lib/components/ReflogDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte"; import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte";
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte"; import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte";
import WorktreeDialog from "./lib/components/WorktreeDialog.svelte";
import { import {
amendCommit, amendCommit,
@@ -229,7 +219,6 @@
const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2"; const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
const EXPLORER_PANEL_COLLAPSED_KEY = "gitlite.explorerPanelCollapsed.v1"; const EXPLORER_PANEL_COLLAPSED_KEY = "gitlite.explorerPanelCollapsed.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1"; const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
const FILE_HISTORY_WIDTH_KEY = "gitlite.fileHistoryWidth.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220; const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT; const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640; const COMMIT_PANEL_MAX_HEIGHT = 640;
@@ -246,10 +235,8 @@
const HISTORY_ASIDE_DEFAULT_WIDTH = 620; const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560; const HISTORY_ASIDE_MIN_WIDTH = 560;
const HISTORY_ASIDE_MAX_WIDTH = 920; const HISTORY_ASIDE_MAX_WIDTH = 920;
const FILE_HISTORY_DEFAULT_WIDTH = 300;
const FILE_HISTORY_MIN_WIDTH = 240;
const FILE_HISTORY_MAX_WIDTH = 520;
const ERROR_AUTO_HIDE_MS = 6000; const ERROR_AUTO_HIDE_MS = 6000;
const COMMIT_HISTORY_PAGE_SIZE = 50;
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
@@ -279,6 +266,10 @@
let tags: GitTag[] = []; let tags: GitTag[] = [];
let stashes: GitStash[] = []; let stashes: GitStash[] = [];
let commits: GitCommit[] = []; let commits: GitCommit[] = [];
let commitHistoryHasMore = false;
let commitHistoryLoadingMore = false;
let commitHistoryLoadError = "";
let commitHistoryRequestId = 0;
let repoFiles: GitRepositoryFile[] = []; let repoFiles: GitRepositoryFile[] = [];
let selectedExplorerPath = ""; let selectedExplorerPath = "";
let selectedExplorerKind: ExplorerNodeKind = "file"; let selectedExplorerKind: ExplorerNodeKind = "file";
@@ -286,6 +277,8 @@
let expandedCommitHashes = new Set<string>(); let expandedCommitHashes = new Set<string>();
let fileHistory: GitCommit[] = []; let fileHistory: GitCommit[] = [];
let fileHistoryLoading = false; let fileHistoryLoading = false;
let fileHistoryError = "";
let fileHistoryDialogOpen = false;
let fileHistoryRequestId = 0; let fileHistoryRequestId = 0;
let activeFileHistoryRequestId = ""; let activeFileHistoryRequestId = "";
let repoOpenRequestId = 0; let repoOpenRequestId = 0;
@@ -418,11 +411,6 @@
let resizingHistoryAside = false; let resizingHistoryAside = false;
let historyResizeStartX = 0; let historyResizeStartX = 0;
let historyResizeStartWidth = 0; let historyResizeStartWidth = 0;
let fileHistoryWidth = loadFileHistoryWidth();
let resizingFileHistory = false;
let fileHistoryResizeStartX = 0;
let fileHistoryResizeStartWidth = 0;
let fileHistoryCollapsed = true;
let themeMediaQuery: MediaQueryList | undefined; let themeMediaQuery: MediaQueryList | undefined;
let appVersion = ""; let appVersion = "";
@@ -754,7 +742,7 @@
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return; if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
applyStatus(nextStatus); applyStatus(nextStatus);
// Something changed — reload branches, commits and files in one bundled call. // Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(path, 100); const bundle = await openRepositoryBundle(path, Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1);
if (!sameRepoPath(path, activeRepoPath)) return; if (!sameRepoPath(path, activeRepoPath)) return;
const previousHeadHash = lastFileHistoryHeadHash; const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(path, bundle.branches); await refreshBranchList(path, bundle.branches);
@@ -1516,28 +1504,6 @@
} }
} }
function clampFileHistoryWidth(value: number): number {
return Math.min(FILE_HISTORY_MAX_WIDTH, Math.max(FILE_HISTORY_MIN_WIDTH, Math.round(value)));
}
function loadFileHistoryWidth(): number {
try {
const stored = Number(localStorage.getItem(FILE_HISTORY_WIDTH_KEY));
if (Number.isFinite(stored) && stored > 0) return clampFileHistoryWidth(stored);
} catch {
// Fall through to the default below.
}
return FILE_HISTORY_DEFAULT_WIDTH;
}
function persistFileHistoryWidth(value: number) {
try {
localStorage.setItem(FILE_HISTORY_WIDTH_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) { function startCommitPanelResize(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
resizingCommitPanel = true; resizingCommitPanel = true;
@@ -1719,50 +1685,14 @@
persistHistoryAsideWidth(historyAsideWidth); persistHistoryAsideWidth(historyAsideWidth);
} }
function startFileHistoryResize(event: PointerEvent) {
if (fileHistoryCollapsed) return;
event.preventDefault();
resizingFileHistory = true;
fileHistoryResizeStartX = event.clientX;
fileHistoryResizeStartWidth = fileHistoryWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onFileHistoryResizeMove(event: PointerEvent) {
if (!resizingFileHistory) return;
fileHistoryWidth = clampFileHistoryWidth(fileHistoryResizeStartWidth + (fileHistoryResizeStartX - event.clientX));
}
function endFileHistoryResize(event: PointerEvent) {
if (!resizingFileHistory) return;
resizingFileHistory = false;
persistFileHistoryWidth(fileHistoryWidth);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onFileHistoryResizeKeydown(event: KeyboardEvent) {
if (fileHistoryCollapsed || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
event.preventDefault();
fileHistoryWidth = clampFileHistoryWidth(fileHistoryWidth + (event.key === "ArrowLeft" ? 20 : -20));
persistFileHistoryWidth(fileHistoryWidth);
}
function toggleFileHistoryCollapsed() {
fileHistoryCollapsed = !fileHistoryCollapsed;
}
function revealFileHistory() {
fileHistoryCollapsed = false;
}
function hideAndResetFileHistory() { function hideAndResetFileHistory() {
fileHistoryRequestId += 1; fileHistoryRequestId += 1;
cancelActiveFileHistoryLoad(); cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = ""; activeFileHistoryRequestId = "";
fileHistoryLoading = false; fileHistoryLoading = false;
fileHistoryError = "";
fileHistory = []; fileHistory = [];
fileHistoryCollapsed = true; fileHistoryDialogOpen = false;
} }
function rememberRecentRepo(path: string) { function rememberRecentRepo(path: string) {
@@ -1826,6 +1756,10 @@
branches = []; branches = [];
stashes = []; stashes = [];
commits = []; commits = [];
commitHistoryHasMore = false;
commitHistoryLoadingMore = false;
commitHistoryLoadError = "";
commitHistoryRequestId += 1;
lastFileHistoryHeadHash = ""; lastFileHistoryHeadHash = "";
repoFiles = []; repoFiles = [];
selectedExplorerPath = ""; selectedExplorerPath = "";
@@ -1833,6 +1767,9 @@
expandedExplorerPaths = new Set(); expandedExplorerPaths = new Set();
expandedCommitHashes = new Set(); expandedCommitHashes = new Set();
fileHistory = []; fileHistory = [];
fileHistoryLoading = false;
fileHistoryError = "";
fileHistoryDialogOpen = false;
compareFrom = ""; compareFrom = "";
compareTo = ""; compareTo = "";
comparison = null; comparison = null;
@@ -1945,7 +1882,19 @@
} }
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) { async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100)); const targetLimit = prefetched
? Math.max(COMMIT_HISTORY_PAGE_SIZE, prefetched.length - 1)
: Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length);
const requestId = ++commitHistoryRequestId;
commitHistoryLoadingMore = false;
commitHistoryLoadError = "";
const history = prefetched ?? (await listCommits(path, targetLimit + 1, 0));
if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;
commits = history.slice(0, targetLimit);
commitHistoryHasMore = history.length > targetLimit;
commitHistoryLoadingMore = false;
commitHistoryLoadError = "";
lastFileHistoryHeadHash = commits[0]?.hash ?? ""; lastFileHistoryHeadHash = commits[0]?.hash ?? "";
const hashes = new Set(commits.map((c) => c.hash)); const hashes = new Set(commits.map((c) => c.hash));
if (compareFrom && !hashes.has(compareFrom)) compareFrom = ""; if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
@@ -1958,6 +1907,33 @@
} }
} }
async function loadMoreCommitHistory() {
if (!activeRepoPath || commitHistoryLoadingMore || !commitHistoryHasMore || isBusy) return;
const path = activeRepoPath;
const offset = commits.length;
const requestId = ++commitHistoryRequestId;
commitHistoryLoadingMore = true;
commitHistoryLoadError = "";
try {
const history = await listCommits(path, COMMIT_HISTORY_PAGE_SIZE + 1, offset);
if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;
const knownHashes = new Set(commits.map((commit) => commit.hash));
const nextPage = history
.slice(0, COMMIT_HISTORY_PAGE_SIZE)
.filter((commit) => !knownHashes.has(commit.hash));
commits = [...commits, ...nextPage];
commitHistoryHasMore = history.length > COMMIT_HISTORY_PAGE_SIZE;
} catch (error) {
if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;
commitHistoryLoadError = errorToMessage(error);
} finally {
if (requestId === commitHistoryRequestId) commitHistoryLoadingMore = false;
}
}
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) { async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
repoFiles = prefetched ?? (await listRepositoryFiles(path)); repoFiles = prefetched ?? (await listRepositoryFiles(path));
const folderPaths = allExplorerFolderPaths(repoFiles); const folderPaths = allExplorerFolderPaths(repoFiles);
@@ -1965,10 +1941,7 @@
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) { if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
selectedExplorerPath = ""; selectedExplorerPath = "";
selectedExplorerKind = "file"; selectedExplorerKind = "file";
cancelActiveFileHistoryLoad(); hideAndResetFileHistory();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
} }
} }
@@ -1977,7 +1950,7 @@
await refreshBranchList(path); await refreshBranchList(path);
await refreshTags(path); await refreshTags(path);
await refreshCommitHistory(path); await refreshCommitHistory(path);
if (lastFileHistoryHeadHash !== previousHeadHash && !fileHistoryCollapsed) { if (lastFileHistoryHeadHash !== previousHeadHash && fileHistoryDialogOpen) {
await refreshFileHistory(path); await refreshFileHistory(path);
} }
} }
@@ -1992,7 +1965,9 @@
void cancelFileHistory(requestId).catch(() => {}); void cancelFileHistory(requestId).catch(() => {});
} }
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) { async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath, force = false) {
if (!force && !fileHistoryDialogOpen && !globalSearchOpen) return;
const requestId = ++fileHistoryRequestId; const requestId = ++fileHistoryRequestId;
cancelActiveFileHistoryLoad(); cancelActiveFileHistoryLoad();
@@ -2006,6 +1981,7 @@
const historyRequestId = `file-history-${requestId}-${Date.now()}`; const historyRequestId = `file-history-${requestId}-${Date.now()}`;
activeFileHistoryRequestId = historyRequestId; activeFileHistoryRequestId = historyRequestId;
fileHistoryLoading = true; fileHistoryLoading = true;
fileHistoryError = "";
fileHistory = []; fileHistory = [];
try { try {
@@ -2017,7 +1993,10 @@
const message = errorToMessage(error); const message = errorToMessage(error);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) { if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = []; fileHistory = [];
if (!isCancellationMessage(message)) errorMessage = message; if (!isCancellationMessage(message)) {
fileHistoryError = message;
if (!fileHistoryDialogOpen) errorMessage = message;
}
} }
} finally { } finally {
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) { if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
@@ -2043,7 +2022,7 @@
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
// Single backend round-trip: resolves the repo and reads status, branches, // Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls. // commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100); const bundle = await openRepositoryBundle(path, COMMIT_HISTORY_PAGE_SIZE + 1);
if (requestId !== repoOpenRequestId) return; if (requestId !== repoOpenRequestId) return;
resetRepositoryState(false); resetRepositoryState(false);
applyStatus(bundle.status); applyStatus(bundle.status);
@@ -2117,7 +2096,7 @@
directoryName || undefined, directoryName || undefined,
username, username,
password, password,
100, COMMIT_HISTORY_PAGE_SIZE + 1,
); );
resetRepositoryState(false); resetRepositoryState(false);
applyStatus(bundle.status); applyStatus(bundle.status);
@@ -3679,19 +3658,37 @@
return folders; return folders;
} }
// Loads history for a selected explorer node without blocking the rest of the UI // Loads history without blocking the rest of the UI. A request id guards
// (isBusy/runOperation would disable every button in the app while this awaits). // against a slower, stale request overwriting a newer file selection.
// A request id guards against a slower, stale request overwriting a newer selection.
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) { async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
await refreshFileHistory(repo, path); await refreshFileHistory(repo, path, true);
}
function openFileHistoryDialog(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file" || !node.tracked) return;
selectedExplorerPath = node.path;
selectedExplorerKind = "file";
fileHistoryDialogOpen = true;
fileHistoryError = "";
void loadSelectedFileHistory(node.path);
trackEvent("file_history_opened", {
source: "explorer_context_menu",
});
}
function closeFileHistoryDialog() {
fileHistoryRequestId += 1;
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistoryError = "";
fileHistoryDialogOpen = false;
} }
async function selectExplorerNode(node: ExplorerNode) { async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath) return; if (!activeRepoPath) return;
selectedExplorerPath = node.path; selectedExplorerPath = node.path;
selectedExplorerKind = node.kind; selectedExplorerKind = node.kind;
revealFileHistory();
void loadSelectedFileHistory(node.path);
trackEvent("explorer_node_selected", { trackEvent("explorer_node_selected", {
kind: node.kind, kind: node.kind,
tracked: node.tracked ? 1 : 0, tracked: node.tracked ? 1 : 0,
@@ -3703,7 +3700,6 @@
selectedExplorerPath = file.path; selectedExplorerPath = file.path;
selectedExplorerKind = "file"; selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]); expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
revealFileHistory();
void loadSelectedFileHistory(file.path); void loadSelectedFileHistory(file.path);
trackEvent("explorer_file_selected", { trackEvent("explorer_file_selected", {
@@ -3728,8 +3724,6 @@
if (!activeRepoPath || node.kind !== "file") return; if (!activeRepoPath || node.kind !== "file") return;
selectedExplorerPath = node.path; selectedExplorerPath = node.path;
selectedExplorerKind = "file"; selectedExplorerKind = "file";
revealFileHistory();
void loadSelectedFileHistory(node.path);
try { try {
await openRepositoryFile(activeRepoPath, node.path); await openRepositoryFile(activeRepoPath, node.path);
@@ -3973,9 +3967,10 @@
helpOpen = false; helpOpen = false;
return; return;
} }
if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu(); if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && fileHistoryDialogOpen && !isBusy) closeFileHistoryDialog();
else if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm(); else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog(); else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
@@ -4335,7 +4330,7 @@
<section <section
class="workspace" class="workspace"
aria-label="Git workspace" aria-label="Git workspace"
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px; --file-history-width: {fileHistoryWidth}px;" style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px;"
> >
<!-- Left sidebar: branches + explorer --> <!-- Left sidebar: branches + explorer -->
@@ -4438,6 +4433,7 @@
onCollapseAllFolders={collapseAllExplorerFolders} onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode} onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer} onOpenFile={openFileFromExplorer}
onFileHistory={openFileHistoryDialog}
onBlame={openBlame} onBlame={openBlame}
collapsed={explorerPanelCollapsed} collapsed={explorerPanelCollapsed}
onToggleCollapsed={toggleExplorerPanelCollapsed} onToggleCollapsed={toggleExplorerPanelCollapsed}
@@ -4562,8 +4558,8 @@
onkeydown={onHistoryAsideResizeKeydown} onkeydown={onHistoryAsideResizeKeydown}
></div> ></div>
<!-- Right sidebar: commit graph + file history --> <!-- Right sidebar: commit graph -->
<aside class="history-aside" class:file-history-collapsed={fileHistoryCollapsed} aria-label="Commit history"> <aside class="history-aside" aria-label="Commit history">
<HistoryPanel <HistoryPanel
{commits} {commits}
{localBranchNames} {localBranchNames}
@@ -4572,7 +4568,11 @@
repositoryKey={activeRepoPath} repositoryKey={activeRepoPath}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
hasMore={commitHistoryHasMore}
isLoadingMore={commitHistoryLoadingMore}
loadMoreError={commitHistoryLoadError}
{expandedCommitHashes} {expandedCommitHashes}
onLoadMore={loadMoreCommitHistory}
onRestoreCommit={restoreCommit} onRestoreCommit={restoreCommit}
onPreviewCommitFile={previewCommitFileFromHistory} onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog} onCreateBranchFromCommit={openNewBranchDialog}
@@ -4584,36 +4584,6 @@
expandedCommitHashes = next; expandedCommitHashes = next;
}} }}
/> />
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="file-history-resize-handle"
class:resizing={resizingFileHistory}
role="separator"
aria-orientation="vertical"
aria-label="Resize file history width"
aria-valuenow={fileHistoryWidth}
aria-valuemin={FILE_HISTORY_MIN_WIDTH}
aria-valuemax={FILE_HISTORY_MAX_WIDTH}
tabindex={fileHistoryCollapsed ? -1 : 0}
onpointerdown={startFileHistoryResize}
onpointermove={onFileHistoryResizeMove}
onpointerup={endFileHistoryResize}
onpointercancel={endFileHistoryResize}
onkeydown={onFileHistoryResizeKeydown}
></div>
<FileHistoryPanel
{fileHistory}
{selectedExplorerPath}
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
{hasRepository}
{isBusy}
isLoading={fileHistoryLoading}
onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit}
collapsed={fileHistoryCollapsed}
onToggleCollapsed={toggleFileHistoryCollapsed}
/>
</aside> </aside>
</section> </section>
{/if} {/if}
@@ -4665,7 +4635,9 @@
{/if} {/if}
{#if helpOpen} {#if helpOpen}
<HelpOverlay language={appLanguage} onClose={() => { helpOpen = false; }} /> {#await import("./lib/components/HelpOverlay.svelte") then module}
<module.default language={appLanguage} onClose={() => { helpOpen = false; }} />
{/await}
{/if} {/if}
{#if aiReviewOpen && aiReviewResult} {#if aiReviewOpen && aiReviewResult}
@@ -4688,28 +4660,32 @@
{/if} {/if}
{#if linePatchOpen && linePatchFile} {#if linePatchOpen && linePatchFile}
<LinePatchDialog {#await import("./lib/components/LinePatchDialog.svelte") then module}
file={linePatchFile} <module.default
staged={linePatchStaged} file={linePatchFile}
patch={linePatchText} staged={linePatchStaged}
{isBusy} patch={linePatchText}
isLoading={linePatchLoading} {isBusy}
error={linePatchError} isLoading={linePatchLoading}
onClose={closeLinePatch} error={linePatchError}
onRefresh={refreshLinePatch} onClose={closeLinePatch}
onApply={applyLinePatch} onRefresh={refreshLinePatch}
/> onApply={applyLinePatch}
/>
{/await}
{/if} {/if}
{#if blameOpen} {#if blameOpen}
<BlameDialog {#await import("./lib/components/BlameDialog.svelte") then module}
filePath={blameFilePath} <module.default
lines={blameLines} filePath={blameFilePath}
{isBusy} lines={blameLines}
isLoading={blameLoading} {isBusy}
error={blameError} isLoading={blameLoading}
onClose={closeBlame} error={blameError}
/> onClose={closeBlame}
/>
{/await}
{/if} {/if}
{#if pendingDiscard} {#if pendingDiscard}
@@ -4723,45 +4699,64 @@
/> />
{/if} {/if}
{#if fileHistoryDialogOpen}
{#await import("./lib/components/FileHistoryDialog.svelte") then module}
<module.default
{fileHistory}
filePath={selectedExplorerPath}
{isBusy}
isLoading={fileHistoryLoading}
error={fileHistoryError}
onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit}
onClose={closeFileHistoryDialog}
/>
{/await}
{/if}
{#if globalSearchOpen} {#if globalSearchOpen}
<GlobalSearchDialog {#await import("./lib/components/GlobalSearchDialog.svelte") then module}
{hasRepository} <module.default
{isBusy} {hasRepository}
isSearching={globalSearchBusy} {isBusy}
error={globalSearchError} isSearching={globalSearchBusy}
results={globalSearchResults} error={globalSearchError}
files={repoFiles} results={globalSearchResults}
fileHistory={fileHistory} files={repoFiles}
selectedFilePath={selectedExplorerPath} fileHistory={fileHistory}
onClose={closeGlobalSearchDialog} selectedFilePath={selectedExplorerPath}
onSearch={runGlobalSearch} onClose={closeGlobalSearchDialog}
onCancel={cancelGlobalSearch} onSearch={runGlobalSearch}
onDiff={diffSearchHit} onCancel={cancelGlobalSearch}
onSelectFile={selectFileFromSearch} onDiff={diffSearchHit}
onFileHistoryDiff={diffSelectedFileFromCommit} onSelectFile={selectFileFromSearch}
onFileHistoryRestore={restoreSelectedFileFromCommit} onFileHistoryDiff={diffSelectedFileFromCommit}
/> onFileHistoryRestore={restoreSelectedFileFromCommit}
/>
{/await}
{/if} {/if}
{#if worktreeDialogOpen} {#if worktreeDialogOpen}
<WorktreeDialog {#await import("./lib/components/WorktreeDialog.svelte") then module}
{worktrees} <module.default
{branches} {worktrees}
initialBranch={worktreeInitialBranch} {branches}
isLoading={worktreesLoading} initialBranch={worktreeInitialBranch}
{isBusy} isLoading={worktreesLoading}
error={worktreeError} {isBusy}
onRefresh={refreshWorktrees} error={worktreeError}
onOpen={openWorktreeTab} onRefresh={refreshWorktrees}
onAdd={createWorktree} onOpen={openWorktreeTab}
onRemove={removeSelectedWorktree} onAdd={createWorktree}
onMove={moveSelectedWorktree} onRemove={removeSelectedWorktree}
onLock={lockSelectedWorktree} onMove={moveSelectedWorktree}
onUnlock={unlockSelectedWorktree} onLock={lockSelectedWorktree}
onPrune={pruneStaleWorktrees} onUnlock={unlockSelectedWorktree}
onRepair={repairSelectedWorktree} onPrune={pruneStaleWorktrees}
onClose={closeWorktreeDialog} onRepair={repairSelectedWorktree}
/> onClose={closeWorktreeDialog}
/>
{/await}
{/if} {/if}
<!-- Create a branch from a specific commit in the history --> <!-- Create a branch from a specific commit in the history -->
@@ -4797,29 +4792,33 @@
<!-- Choose the AI provider/model used to generate commit messages --> <!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen} {#if aiSettingsOpen}
<AiSettingsDialog {#await import("./lib/components/AiSettingsDialog.svelte") then module}
settings={aiSettings} <module.default
localModels={localModelOptions} settings={aiSettings}
onSave={saveAiSettings} localModels={localModelOptions}
onClose={() => { aiSettingsOpen = false; }} onSave={saveAiSettings}
/> onClose={() => { aiSettingsOpen = false; }}
/>
{/await}
{/if} {/if}
<!-- Compare: pick the two commits to diff --> <!-- Compare: pick the two commits to diff -->
{#if interactiveRebaseOpen} {#if interactiveRebaseOpen}
<InteractiveRebaseDialog {#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
{branches} <module.default
currentBranch={status?.current_branch ?? ""} {branches}
base={interactiveRebaseBase} currentBranch={status?.current_branch ?? ""}
commits={interactiveRebaseCommits} base={interactiveRebaseBase}
isLoading={interactiveRebaseLoading} commits={interactiveRebaseCommits}
{isBusy} isLoading={interactiveRebaseLoading}
{operation} {isBusy}
error={interactiveRebaseError} {operation}
onBaseChange={loadInteractiveRebaseRange} error={interactiveRebaseError}
onStart={runInteractiveRebase} onBaseChange={loadInteractiveRebaseRange}
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }} onStart={runInteractiveRebase}
/> onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
/>
{/await}
{/if} {/if}
{#if reflogOpen} {#if reflogOpen}
@@ -4854,16 +4853,18 @@
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) --> <!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
{#if compareDialogOpen && comparison} {#if compareDialogOpen && comparison}
<CompareDialog {#await import("./lib/components/CompareDialog.svelte") then module}
{comparison} <module.default
{selectedDiffPath} {comparison}
{isBusy} {selectedDiffPath}
highlightQuery={diffHighlightQuery} {isBusy}
restoreLabel={pendingRestoreFile ? "Restore file" : ""} highlightQuery={diffHighlightQuery}
onClose={closeCompareDialog} restoreLabel={pendingRestoreFile ? "Restore file" : ""}
onRestore={restorePreviewedCommitFile} onClose={closeCompareDialog}
onSelectFile={selectDiffFile} onRestore={restorePreviewedCommitFile}
/> onSelectFile={selectDiffFile}
/>
{/await}
{/if} {/if}
<!-- Credential dialog for push/pull --> <!-- Credential dialog for push/pull -->
@@ -4918,16 +4919,18 @@
<!-- Conflict resolve dialog --> <!-- Conflict resolve dialog -->
{#if resolveDialogOpen} {#if resolveDialogOpen}
<ResolveDialog {#await import("./lib/components/ResolveDialog.svelte") then module}
{conflictedFiles} <module.default
{conflictTarget} {conflictedFiles}
{conflict} {conflictTarget}
{preparedResolutions} {conflict}
{isBusy} {preparedResolutions}
{operation} {isBusy}
onClose={() => { resolveDialogOpen = false; }} {operation}
onSelectFile={selectConflictFile} onClose={() => { resolveDialogOpen = false; }}
onMarkResolved={handleMarkResolved} onSelectFile={selectConflictFile}
onApply={applyPreparedResolutions} onMarkResolved={handleMarkResolved}
/> onApply={applyPreparedResolutions}
/>
{/await}
{/if} {/if}
+196 -253
View File
@@ -1517,20 +1517,15 @@
.history-aside { .history-aside {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 8px minmax(240px, var(--file-history-width, 300px)); grid-template-columns: minmax(0, 1fr);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
column-gap: 0; column-gap: 0;
row-gap: 8px; row-gap: 8px;
} }
.history-aside.file-history-collapsed {
grid-template-columns: minmax(0, 1fr) 0 42px;
}
.left-sidebar-resize-handle, .left-sidebar-resize-handle,
.history-resize-handle, .history-resize-handle {
.file-history-resize-handle {
position: relative; position: relative;
z-index: 10; z-index: 10;
display: flex; display: flex;
@@ -1541,8 +1536,7 @@
touch-action: none; touch-action: none;
} }
.left-sidebar-resize-handle::before, .left-sidebar-resize-handle::before,
.history-resize-handle::before, .history-resize-handle::before {
.file-history-resize-handle::before {
content: ""; content: "";
width: 3px; width: 3px;
height: 40px; height: 40px;
@@ -1553,26 +1547,16 @@
.left-sidebar-resize-handle:hover::before, .left-sidebar-resize-handle:hover::before,
.left-sidebar-resize-handle.resizing::before, .left-sidebar-resize-handle.resizing::before,
.history-resize-handle:hover::before, .history-resize-handle:hover::before,
.history-resize-handle.resizing::before, .history-resize-handle.resizing::before {
.file-history-resize-handle:hover::before,
.file-history-resize-handle.resizing::before {
background: var(--color-accent); background: var(--color-accent);
box-shadow: 0 0 14px rgba(65,209,255,0.3); box-shadow: 0 0 14px rgba(65,209,255,0.3);
} }
.left-sidebar-resize-handle:focus-visible, .left-sidebar-resize-handle:focus-visible,
.history-resize-handle:focus-visible, .history-resize-handle:focus-visible {
.file-history-resize-handle:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: 2px; outline-offset: 2px;
} }
.history-aside.file-history-collapsed .file-history-resize-handle {
pointer-events: none;
}
.history-aside.file-history-collapsed .file-history-resize-handle::before {
display: none;
}
.left-sidebar { .left-sidebar {
display: grid; display: grid;
grid-template-rows: minmax(180px, var(--branch-panel-height, 260px)) 14px minmax(150px, var(--stash-panel-height, 190px)) 14px minmax(220px, 1fr); grid-template-rows: minmax(180px, var(--branch-panel-height, 260px)) 14px minmax(150px, var(--stash-panel-height, 190px)) 14px minmax(220px, 1fr);
@@ -2635,225 +2619,6 @@
background: rgba(65,209,255,0.08); background: rgba(65,209,255,0.08);
color: var(--color-ink); color: var(--color-ink);
} }
.file-history-panel.collapsed {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
}
.file-history-rail-toggle {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding: 12px 0;
border: 0;
border-radius: 0;
color: var(--color-ink-dim);
background: linear-gradient(180deg, rgba(100,108,255,0.12), rgba(65,209,255,0.04));
}
.file-history-rail-toggle:hover:not(:disabled) {
color: var(--color-ink);
background: linear-gradient(180deg, rgba(100,108,255,0.18), rgba(65,209,255,0.08));
}
.file-history-rail-toggle span {
writing-mode: vertical-rl;
text-orientation: mixed;
overflow: hidden;
color: currentColor;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.file-history-rail-toggle small {
display: inline-grid;
place-items: center;
min-width: 22px;
min-height: 22px;
border-radius: 999px;
color: var(--color-ink);
background: rgba(94,110,156,0.18);
font-size: 11px;
font-weight: 800;
}
.file-history-head { align-items: flex-start; }
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
.file-history-head-actions {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 6px;
}
.file-history-toggle {
width: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(65,209,255,0.2);
border-radius: 7px;
color: var(--color-ink-dim);
background: rgba(65,209,255,0.06);
}
.file-history-toggle:hover:not(:disabled) {
border-color: rgba(65,209,255,0.45);
color: #ffffff;
background: rgba(65,209,255,0.13);
}
.section-head .file-history-name {
display: -webkit-box;
max-height: 2.4em;
margin: 2px 0 0;
overflow: hidden;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-history-count { flex: 0 0 auto; margin-top: 2px; }
.file-history-row { grid-template-columns: minmax(0, 1fr); min-width: 0; overflow: hidden; }
.file-history-row .commit-line { overflow: hidden; }
.file-history-row .commit-line strong {
display: -webkit-box;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-history-actions { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; align-items: center; gap: 6px; }
.file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; }
.file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; }
/* --- File history loading (scoped, non-blocking) --- */
.file-history-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 156px;
padding: 22px;
overflow: hidden;
background:
radial-gradient(circle at center 42%, rgba(111, 140, 255, 0.1), transparent 52%),
linear-gradient(180deg, rgba(15, 18, 31, 0.55), transparent);
}
.file-history-loading-mark {
position: relative;
display: grid;
place-items: center;
width: 86px;
height: 86px;
isolation: isolate;
}
.file-history-loading-halo {
position: absolute;
inset: 10px;
border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 18px;
transform: rotate(45deg);
animation: fhl-halo-breathe 2.4s ease-in-out infinite;
}
.file-history-loading-traces {
position: absolute;
inset: -12px;
width: 110px;
height: 110px;
overflow: visible;
}
.file-history-loading-traces .fhl-trace {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-dasharray: 92;
stroke-dashoffset: 92;
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.3));
animation: fhl-trace-draw 2.4s ease-in-out infinite;
}
.file-history-loading-traces .fhl-trace-main {
stroke: #6f8cff;
}
.file-history-loading-traces .fhl-trace-branch {
stroke: #4db6d6;
animation-delay: 0.28s;
}
.file-history-loading-icon {
position: relative;
z-index: 1;
width: 64px;
height: 64px;
object-fit: contain;
filter:
drop-shadow(0 10px 14px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 12px rgba(77, 182, 214, 0.2));
animation: fhl-icon-float 2.4s ease-in-out infinite;
}
.file-history-loading-label {
color: var(--color-ink-faint);
font-size: 12.5px;
font-weight: 800;
}
.file-history-loading-bar {
position: relative;
width: 118px;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(111, 140, 255, 0.14);
}
.file-history-loading-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 42%, #4db6d6 74%, transparent);
box-shadow: 0 0 12px rgba(77, 182, 214, 0.24);
animation: fhl-bar-slide 1.35s ease-in-out infinite;
}
@keyframes fhl-halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.94); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes fhl-icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.02); }
}
@keyframes fhl-trace-draw {
0% { stroke-dashoffset: 92; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@keyframes fhl-bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@media (prefers-reduced-motion: reduce) {
.file-history-loading-halo,
.file-history-loading-traces .fhl-trace,
.file-history-loading-icon,
.file-history-loading-bar span { animation: none; }
.file-history-loading-traces .fhl-trace { stroke-dashoffset: 0; opacity: 0.72; }
}
/* --- Git graph --- */ /* --- Git graph --- */
.graph-list { .graph-list {
@@ -2861,6 +2626,31 @@
background: var(--color-surface-dim); background: var(--color-surface-dim);
} }
.history-load-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 54px;
padding: 9px 12px;
border-top: 1px solid var(--color-border-subtle);
color: var(--color-ink-faint);
background: var(--color-surface-dim);
font-size: 11.5px;
}
.history-load-more-button {
min-height: 30px;
border-color: transparent;
color: var(--color-primary);
background: transparent;
}
.history-load-more-button:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.graph-row { .graph-row {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@@ -3091,6 +2881,101 @@
box-shadow: var(--app-dialog-shadow); box-shadow: var(--app-dialog-shadow);
overflow: hidden; overflow: hidden;
} }
.file-history-dialog-backdrop { z-index: 70; }
.compare-dialog-backdrop { z-index: 80; }
.file-history-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(780px, calc(100vw - 32px));
height: min(720px, calc(100vh - 32px));
border: 1px solid var(--color-border);
border-radius: 12px;
outline: 0;
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
overflow: hidden;
}
.file-history-dialog-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
min-height: 72px;
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-dim);
}
.file-history-dialog-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid color-mix(in srgb, var(--color-primary) 24%, var(--color-border));
border-radius: 9px;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface-solid));
}
.file-history-dialog-heading { display: grid; min-width: 0; gap: 2px; }
.file-history-dialog-heading h2 {
margin: 0;
overflow: hidden;
color: var(--color-ink);
font-size: 15px;
font-weight: 750;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-path {
overflow: hidden;
color: var(--color-ink-faint);
font: 10.5px/1.35 var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-body { min-height: 0; overflow: hidden; background: var(--color-surface-solid); }
.file-history-dialog-list { height: 100%; padding: 6px; overflow: auto; }
.file-history-dialog-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
min-height: 66px;
padding: 9px 10px;
border-bottom: 1px solid var(--color-border-subtle);
}
.file-history-dialog-row:last-child { border-bottom: 0; }
.file-history-dialog-row:hover { background: var(--color-surface-hover); }
.file-history-dialog-commit { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; min-width: 0; }
.file-history-dialog-commit > div { display: grid; min-width: 0; gap: 4px; }
.file-history-dialog-commit strong {
overflow: hidden;
color: var(--color-ink);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-commit span { overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.file-history-dialog-commit code { color: var(--color-primary); font-family: var(--font-mono); }
.file-history-dialog-row time { color: var(--color-ink-faint); font-size: 10.5px; white-space: nowrap; }
.file-history-dialog-actions { display: flex; align-items: center; gap: 5px; }
.file-history-dialog-actions button { min-height: 28px; white-space: nowrap; }
.file-history-dialog-state {
display: grid;
place-items: center;
align-content: center;
gap: 7px;
height: 100%;
min-height: 220px;
padding: 28px;
color: var(--color-ink-faint);
text-align: center;
}
.file-history-dialog-state svg { color: var(--color-primary); }
.file-history-dialog-state strong { color: var(--color-ink); font-size: 13px; }
.file-history-dialog-state span { max-width: 520px; font-size: 11.5px; line-height: 1.5; overflow-wrap: anywhere; }
.file-history-dialog-state.error svg,
.file-history-dialog-state.error strong { color: var(--color-danger); }
.compare-dialog { .compare-dialog {
width: min(1560px, calc(100vw - 32px)); width: min(1560px, calc(100vw - 32px));
height: min(940px, calc(100vh - 32px)); height: min(940px, calc(100vh - 32px));
@@ -5672,12 +5557,10 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.left-sidebar-resize-handle, .left-sidebar-resize-handle,
.history-resize-handle, .history-resize-handle,
.file-history-resize-handle,
.panel-resize-handle, .panel-resize-handle,
.left-panel-resize-handle { background: var(--color-border-subtle); } .left-panel-resize-handle { background: var(--color-border-subtle); }
.left-sidebar-resize-handle::before, .left-sidebar-resize-handle::before,
.history-resize-handle::before, .history-resize-handle::before { width: 1px; height: 48px; background: var(--color-border); }
.file-history-resize-handle::before { width: 1px; height: 48px; background: var(--color-border); }
.panel-resize-handle::before { width: 44px; height: 2px; } .panel-resize-handle::before { width: 44px; height: 2px; }
.status-toolbar { .status-toolbar {
@@ -5743,7 +5626,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.explorer-row .status-badge { min-width: 18px; overflow: hidden; color: #d9891b; background: transparent; text-indent: -999px; } .explorer-row .status-badge { min-width: 18px; overflow: hidden; color: #d9891b; background: transparent; text-indent: -999px; }
.explorer-row .status-badge::after { content: "M"; float: right; text-indent: 0; } .explorer-row .status-badge::after { content: "M"; float: right; text-indent: 0; }
.history-panel, .file-history-panel { min-height: 0; } .history-panel { min-height: 0; }
.graph-list { background: var(--color-surface-solid); } .graph-list { background: var(--color-surface-solid); }
.graph-gutter { background: var(--color-surface-dim); } .graph-gutter { background: var(--color-surface-dim); }
.commit-body { .commit-body {
@@ -5753,10 +5636,12 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
} }
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); } .graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); } .graph-row:hover .commit-body { background: var(--color-surface-hover); }
.graph-row {
content-visibility: auto;
contain-intrinsic-block-size: 108px;
}
.commit-avatar { border-radius: 50%; } .commit-avatar { border-radius: 50%; }
.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; } .commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; }
.file-history-row { margin: 0; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; background: transparent; }
.workspace-statusbar { .workspace-statusbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5808,6 +5693,10 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
} }
@media (hover: none), (pointer: coarse) {
.status-file-actions { opacity: 1; }
}
:root[data-theme="light"] .repo-tab-wrap { :root[data-theme="light"] .repo-tab-wrap {
background: rgba(255,255,255,0.5); background: rgba(255,255,255,0.5);
} }
@@ -6106,12 +5995,53 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
background: #eef3f9; background: #eef3f9;
} }
:root[data-theme="light"] .cred-card {
border-color: rgba(49, 95, 214, 0.22);
background: #ffffff;
box-shadow: 0 28px 72px rgba(28, 44, 74, 0.2), 0 0 0 1px rgba(255,255,255,0.82) inset;
}
:root[data-theme="light"] .cred-hero {
border-bottom-color: rgba(34, 49, 78, 0.1);
background:
radial-gradient(circle at 8% 0%, rgba(49,95,214,0.13), transparent 42%),
linear-gradient(135deg, #f8faff, #eef4fc);
}
:root[data-theme="light"] .cred-hero-icon {
border-color: rgba(49,95,214,0.2);
color: #ffffff;
background: linear-gradient(135deg, #4d8dff, #0f8fb5);
box-shadow: 0 12px 26px rgba(49,95,214,0.2), inset 0 1px 0 rgba(255,255,255,0.24);
}
:root[data-theme="light"] .cred-hero-label { color: #315fd6; }
:root[data-theme="light"] .cred-hero-title { color: #172033; }
:root[data-theme="light"] .cred-hero-copy { color: #526078; }
:root[data-theme="light"] .cred-security-note {
border-color: rgba(15,143,181,0.2);
color: #21647a;
background: rgba(15,143,181,0.07);
}
:root[data-theme="light"] .cred-security-note svg { color: #0f8fb5; }
:root[data-theme="light"] .cred-body {
background: #ffffff;
}
:root[data-theme="light"] .cred-seg-btn.active { :root[data-theme="light"] .cred-seg-btn.active {
border-color: rgba(49,95,214,0.26); border-color: rgba(49,95,214,0.26);
background: linear-gradient(135deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08)); background: linear-gradient(135deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08));
box-shadow: 0 8px 18px rgba(28,44,74,0.1), inset 0 1px 0 rgba(255,255,255,0.9); box-shadow: 0 8px 18px rgba(28,44,74,0.1), inset 0 1px 0 rgba(255,255,255,0.9);
} }
:root[data-theme="light"] .cred-seg-btn:hover:not(.active) {
color: var(--color-ink);
background: rgba(255,255,255,0.68);
}
:root[data-theme="light"] .cred-close { :root[data-theme="light"] .cred-close {
border-color: var(--color-border-subtle); border-color: var(--color-border-subtle);
color: var(--color-ink-dim); color: var(--color-ink-dim);
@@ -6147,10 +6077,18 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
:root[data-theme="light"] .cred-input input, :root[data-theme="light"] .cred-input input,
:root[data-theme="light"] .cred-expiry input[type="date"] { :root[data-theme="light"] .cred-expiry input[type="date"] {
border-color: var(--color-border-input);
background: rgba(255,255,255,0.92); background: rgba(255,255,255,0.92);
color-scheme: light; color-scheme: light;
} }
:root[data-theme="light"] .cred-input input::placeholder { color: #8995a8; }
:root[data-theme="light"] .cred-reveal:hover {
color: var(--color-ink);
background: var(--color-surface-hover);
}
:root[data-theme="light"] .cred-token-hint code { :root[data-theme="light"] .cred-token-hint code {
color: #0d78a0; color: #0d78a0;
} }
@@ -6209,13 +6147,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
} }
/* Compact: stack history panels vertically, narrow sidebars */ /* Compact workspace with narrow sidebars */
@media (max-width: 960px) { @media (max-width: 960px) {
.workspace { grid-template-columns: 180px 8px minmax(0, 1fr) 0 300px; row-gap: 6px; } .workspace { grid-template-columns: 180px 8px minmax(0, 1fr) 0 300px; row-gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); row-gap: 6px; } .history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr); row-gap: 0; }
.history-aside.file-history-collapsed { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 42px; } .history-resize-handle { display: none; }
.history-resize-handle,
.file-history-resize-handle { display: none; }
.shell-body { gap: 6px; } .shell-body { gap: 6px; }
.left-sidebar { .left-sidebar {
grid-template-rows: minmax(180px, var(--branch-panel-height, 230px)) 14px minmax(150px, var(--stash-panel-height, 170px)) 14px minmax(220px, 1fr); grid-template-rows: minmax(180px, var(--branch-panel-height, 230px)) 14px minmax(150px, var(--stash-panel-height, 170px)) 14px minmax(220px, 1fr);
@@ -6262,8 +6198,15 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.tb-btn { width: 34px; } .tb-btn { width: 34px; }
.workspace { grid-template-columns: 1fr; gap: 6px; } .workspace { grid-template-columns: 1fr; gap: 6px; }
.left-sidebar-resize-handle { display: none; } .left-sidebar-resize-handle { display: none; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; } .history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr); min-height: 240px; }
.history-aside.file-history-collapsed { grid-template-rows: minmax(200px, 1fr) 42px; } .file-history-dialog-backdrop { padding: 8px; }
.file-history-dialog { width: 100%; height: 100%; }
.file-history-dialog-header { min-height: 64px; padding: 9px 10px; }
.file-history-dialog-icon { width: 34px; height: 34px; }
.file-history-dialog-row { grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
.file-history-dialog-row time { display: none; }
.file-history-dialog-actions { display: grid; }
.file-history-dialog-actions button { width: 32px; min-width: 32px; padding: 0; overflow: hidden; color: var(--color-ink-muted); font-size: 0; gap: 0; }
.left-sidebar { .left-sidebar {
grid-template-rows: minmax(180px, var(--branch-panel-height, 240px)) 14px minmax(150px, var(--stash-panel-height, 180px)) 14px minmax(220px, 1fr); grid-template-rows: minmax(180px, var(--branch-panel-height, 240px)) 14px minmax(150px, var(--stash-panel-height, 180px)) 14px minmax(220px, 1fr);
min-height: 560px; min-height: 560px;
+6 -1
View File
@@ -2,6 +2,11 @@
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte"; import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types"; import type { GitBlameLine } from "../types";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface BlameGroup { interface BlameGroup {
id: string; id: string;
hash: string; hash: string;
@@ -66,7 +71,7 @@
if (!seconds) return ""; if (!seconds) return "";
const date = new Date(seconds * 1000); const date = new Date(seconds * 1000);
if (Number.isNaN(date.getTime())) return ""; if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); return commitDateFormatter.format(date);
} }
function groupTooltip(group: BlameGroup): string { function groupTooltip(group: BlameGroup): string {
+1 -1
View File
@@ -216,7 +216,7 @@
</script> </script>
<div <div
class="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="Commit comparison" tabindex="-1">
+25 -4
View File
@@ -11,6 +11,7 @@
FileCog, FileCog,
FileImage, FileImage,
FileJson, FileJson,
FileSearch,
FileSpreadsheet, FileSpreadsheet,
FileText, FileText,
FileType, FileType,
@@ -37,6 +38,7 @@
onCollapseAllFolders: () => void; onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void; onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void; onOpenFile: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void; onBlame: (node: ExplorerNode) => void;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapsed?: () => void; onToggleCollapsed?: () => void;
@@ -54,6 +56,7 @@
onCollapseAllFolders = () => {}, onCollapseAllFolders = () => {},
onSelectNode = () => {}, onSelectNode = () => {},
onOpenFile = () => {}, onOpenFile = () => {},
onFileHistory = () => {},
onBlame = () => {}, onBlame = () => {},
collapsed = false, collapsed = false,
onToggleCollapsed = () => {}, onToggleCollapsed = () => {},
@@ -175,7 +178,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 - 56)); contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 132));
} }
function closeFileContextMenu() { function closeFileContextMenu() {
@@ -196,6 +199,13 @@
onBlame(node); onBlame(node);
} }
function openContextFileHistory() {
const node = contextNode;
if (!node || node.kind !== "file" || !node.tracked) return;
closeFileContextMenu();
onFileHistory(node);
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu(); if (event.key === "Escape") closeFileContextMenu();
} }
@@ -261,12 +271,14 @@
{:else} {:else}
<div class="explorer-list overflow-auto p-2"> <div class="explorer-list overflow-auto p-2">
{#each visibleNodes as node (`${node.kind}:${node.path}`)} {#each visibleNodes as node (`${node.kind}:${node.path}`)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="explorer-row" class="explorer-row"
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind} class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
class:folder={node.kind === "folder"} class:folder={node.kind === "folder"}
style={`--depth: ${node.depth}`} style={`--depth: ${node.depth}`}
title={node.path} title={node.path}
oncontextmenu={(event) => openFileContextMenu(event, node)}
> >
{#if node.kind === "folder"} {#if node.kind === "folder"}
<button <button
@@ -328,9 +340,8 @@
class="explorer-select" class="explorer-select"
type="button" type="button"
onclick={() => onSelectNode(node)} onclick={() => onSelectNode(node)}
oncontextmenu={(event) => openFileContextMenu(event, node)}
disabled={isBusy} disabled={isBusy}
title={`Show history for ${node.path}`} title={`Select ${node.path}`}
> >
<span>{node.name}</span> <span>{node.name}</span>
</button> </button>
@@ -355,6 +366,16 @@
tabindex="-1" tabindex="-1"
aria-label={`Actions for ${contextNode.path}`} aria-label={`Actions for ${contextNode.path}`}
> >
<button
type="button"
role="menuitem"
onclick={openContextFileHistory}
disabled={!contextNode.tracked}
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
>
<History size={14} aria-hidden="true" />
File history
</button>
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
@@ -372,7 +393,7 @@
disabled={!contextNode.tracked || contextNode.status === "deleted"} disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"} title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
> >
<History size={14} aria-hidden="true" /> <FileSearch size={14} aria-hidden="true" />
Blame Blame
</button> </button>
</div> </div>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { AlertCircle, GitCompare, History, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props {
fileHistory: GitCommit[];
filePath: string;
isBusy: boolean;
isLoading: boolean;
error?: string;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
onClose: () => void;
}
let {
fileHistory = [],
filePath = "",
isBusy = false,
isLoading = false,
error = "",
onDiff = () => {},
onRestore = () => {},
onClose = () => {},
}: Props = $props();
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return commitDateFormatter.format(date);
}
function fileName(path: string): string {
return path.split(/[\\/]/).pop() ?? path;
}
function closeFromBackdrop(event: MouseEvent) {
if (event.target === event.currentTarget && !isBusy) onClose();
}
</script>
<div class="dialog-backdrop app-chrome-backdrop file-history-dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="file-history-dialog" role="dialog" aria-modal="true" aria-labelledby="file-history-dialog-title" tabindex="-1">
<header class="file-history-dialog-header">
<div class="file-history-dialog-icon" aria-hidden="true"><History size={19} /></div>
<div class="file-history-dialog-heading">
<span class="eyebrow">File history</span>
<h2 id="file-history-dialog-title">{fileName(filePath)}</h2>
<span class="file-history-dialog-path" title={filePath}>{filePath}</span>
</div>
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label="Close file history">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="file-history-dialog-body">
{#if isLoading}
<div class="file-history-dialog-state" role="status" aria-live="polite">
<LoaderCircle class="spin" size={22} aria-hidden="true" />
<strong>Loading file history…</strong>
<span>Reading commits that changed this file.</span>
</div>
{:else if error}
<div class="file-history-dialog-state error" role="alert">
<AlertCircle size={22} aria-hidden="true" />
<strong>File history could not be loaded.</strong>
<span>{error}</span>
</div>
{:else if fileHistory.length === 0}
<div class="file-history-dialog-state">
<History size={22} aria-hidden="true" />
<strong>No history found.</strong>
<span>This file has no committed changes yet.</span>
</div>
{:else}
<div class="file-history-dialog-list">
{#each fileHistory as item (item.hash)}
<article class="file-history-dialog-row">
<div class="file-history-dialog-commit">
<span class="commit-avatar" aria-hidden="true">{item.author_name.trim().slice(0, 2).toUpperCase() || "?"}</span>
<div>
<strong title={item.summary}>{item.summary}</strong>
<span><code>{item.short_hash}</code> · {item.author_name}</span>
</div>
</div>
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="file-history-dialog-actions">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes against the working tree">
<GitCompare size={14} aria-hidden="true" /> Diff
</button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore this file from the selected commit">
<RotateCcw size={14} aria-hidden="true" /> Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</div>
</div>
</div>
-172
View File
@@ -1,172 +0,0 @@
<script lang="ts">
import { ChevronLeft, ChevronRight, GitCompare, History, RotateCcw } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { GitCommit } from "../types";
interface Props {
fileHistory: GitCommit[];
selectedExplorerPath: string;
selectedExplorerLabel: string;
hasRepository: boolean;
isBusy: boolean;
isLoading?: boolean;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
fileHistory = [],
selectedExplorerPath = "",
selectedExplorerLabel = "File history",
hasRepository = false,
isBusy = false,
isLoading = false,
onDiff = () => {},
onRestore = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function fileName(path: string): string {
return path.split("/").pop() ?? path;
}
function pathTooltip(node: HTMLElement, text: string) {
let el: HTMLDivElement | null = null;
function show(e: MouseEvent) {
if (!text || !text.includes("/")) return;
el = document.createElement("div");
el.className = "path-tooltip";
el.textContent = text;
document.body.appendChild(el);
move(e);
}
function move(e: MouseEvent) {
if (!el) return;
const tw = el.offsetWidth;
const left = Math.max(8, Math.min(e.clientX - tw / 2, window.innerWidth - tw - 8));
el.style.left = `${left}px`;
el.style.top = `${e.clientY - 44}px`;
}
function hide() {
el?.remove();
el = null;
}
node.addEventListener("mouseenter", show);
node.addEventListener("mousemove", move);
node.addEventListener("mouseleave", hide);
return {
update(v: string) { text = v; },
destroy() {
hide();
node.removeEventListener("mouseenter", show);
node.removeEventListener("mousemove", move);
node.removeEventListener("mouseleave", hide);
},
};
}
</script>
<section class="panel file-history-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Selected file history">
{#if collapsed}
<button
class="file-history-rail-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title="Expand file history"
aria-label="Expand file history panel"
>
<ChevronLeft size={16} aria-hidden="true" />
<span>File history</span>
{#if selectedExplorerPath}
<small>{fileHistory.length}</small>
{/if}
</button>
{:else}
<div class="section-head file-history-head">
<div class="file-history-heading">
<span class="eyebrow">{selectedExplorerLabel}</span>
<h2
class="file-history-name"
use:pathTooltip={selectedExplorerPath}
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
</div>
<div class="file-history-head-actions">
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
<button
class="file-history-toggle panel-collapse-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title="Collapse file history"
aria-label="Collapse file history panel"
>
<ChevronRight size={14} aria-hidden="true" />
</button>
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if !selectedExplorerPath}
<div class="blank-state">Select a file in Explorer.</div>
{:else if isLoading}
<div class="file-history-loading" role="status" aria-live="polite">
<div class="file-history-loading-mark" aria-hidden="true">
<span class="file-history-loading-halo"></span>
<svg class="file-history-loading-traces" viewBox="0 0 120 120">
<path class="fhl-trace fhl-trace-main" d="M14 82 C38 60, 48 58, 60 60 S84 66, 106 36" />
<path class="fhl-trace fhl-trace-branch" d="M28 36 C52 48, 70 70, 91 94" />
</svg>
<img src={iconUrl} alt="" class="file-history-loading-icon" />
</div>
<span class="file-history-loading-label">Loading history…</span>
<span class="file-history-loading-bar" aria-hidden="true"><span></span></span>
</div>
{:else if fileHistory.length === 0}
<div class="blank-state">No history returned for this selection.</div>
{:else}
<div class="history-list overflow-auto p-2">
{#each fileHistory as item (item.hash)}
<article class="commit-row compact file-history-row">
<div class="commit-line">
<History size={16} aria-hidden="true" />
<div class="commit-line-text">
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
</div>
</div>
<div class="commit-actions file-history-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree">
<GitCompare size={15} aria-hidden="true" />
Diff
</button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore selected file from this commit">
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+6 -1
View File
@@ -15,6 +15,11 @@
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types"; import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
import LanguageIcon from "./LanguageIcon.svelte"; import LanguageIcon from "./LanguageIcon.svelte";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
type SearchTab = "code" | "files"; type SearchTab = "code" | "files";
interface Props { interface Props {
@@ -82,7 +87,7 @@
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;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); return commitDateFormatter.format(date);
} }
function displayPath(hit: GitSearchHit): string { function displayPath(hit: GitSearchHit): string {
+47 -11
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte"; import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types"; import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment { interface GraphSegment {
@@ -27,6 +27,10 @@
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55", "#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
]; ];
const GRAPH_LANE = 18; const GRAPH_LANE = 18;
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
@@ -36,7 +40,11 @@
repositoryKey: string; repositoryKey: string;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
hasMore: boolean;
isLoadingMore: boolean;
loadMoreError: string;
expandedCommitHashes: Set<string>; expandedCommitHashes: Set<string>;
onLoadMore: () => void | Promise<void>;
onRestoreCommit: (commit: GitCommit) => void; onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void; onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void; onToggleCommitFiles: (hash: string) => void;
@@ -53,7 +61,11 @@
repositoryKey = "", repositoryKey = "",
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
hasMore = false,
isLoadingMore = false,
loadMoreError = "",
expandedCommitHashes = new Set(), expandedCommitHashes = new Set(),
onLoadMore = () => {},
onRestoreCommit = () => {}, onRestoreCommit = () => {},
onPreviewCommitFile = () => {}, onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {}, onToggleCommitFiles = () => {},
@@ -71,6 +83,17 @@
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list");
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
void onLoadMore();
}
}, { root, rootMargin: "240px 0px" });
observer.observe(node);
return { destroy: () => observer.disconnect() };
}
function laneColor(col: number): string { function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length]; return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
} }
@@ -464,7 +487,7 @@
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;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); return commitDateFormatter.format(date);
} }
$effect(() => { $effect(() => {
@@ -518,8 +541,8 @@
<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>
<div class="section-head-actions"> {#if localBranchNames.length > 0}
{#if localBranchNames.length > 0} <div class="section-head-actions">
<button <button
class="graph-branch-dialog-button" class="graph-branch-dialog-button"
type="button" type="button"
@@ -530,21 +553,19 @@
Branches Branches
<span>{visibleBranchCount}/{localBranchNames.length}</span> <span>{visibleBranchCount}/{localBranchNames.length}</span>
</button> </button>
{/if} </div>
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}> {/if}
{visibleCommits.length}
</span>
</div>
</div> </div>
{#if !hasRepository} {#if !hasRepository}
<div class="blank-state">No repository loaded.</div> <div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0} {:else if commits.length === 0}
<div class="blank-state">No commits returned.</div> <div class="blank-state">No commits returned.</div>
{:else if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else} {:else}
<div class="history-list graph-list overflow-auto"> <div class="history-list graph-list overflow-auto">
{#if visibleCommits.length === 0}
<div class="blank-state">No loaded commits match the selected branches.</div>
{/if}
{#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]}
@@ -698,6 +719,21 @@
</div> </div>
</article> </article>
{/each} {/each}
{#if hasMore || isLoadingMore || loadMoreError}
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
{#if isLoadingMore}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
<span>Loading older commits…</span>
{:else if loadMoreError}
<span title={loadMoreError}>Older commits could not be loaded.</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
{:else}
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
Load older commits
</button>
{/if}
</div>
{/if}
</div> </div>
{/if} {/if}
+4 -2
View File
@@ -144,6 +144,8 @@
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null)); let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null)); let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length); let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length); let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
@@ -201,7 +203,7 @@
</div> </div>
</header> </header>
<div class="status-file-list"> <div class="status-file-list">
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)} {#each unstagedFiles as file (`unstaged:${fileKey(file)}`)}
{@const stageTargets = selectedStageTargets(file)} {@const stageTargets = selectedStageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}> <article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}> <button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
@@ -241,7 +243,7 @@
</div> </div>
</header> </header>
<div class="status-file-list"> <div class="status-file-list">
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)} {#each stagedFiles as file (`staged:${fileKey(file)}`)}
{@const unstageTargets = selectedUnstageTargets(file)} {@const unstageTargets = selectedUnstageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}> <article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}> <button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
+2 -2
View File
@@ -366,8 +366,8 @@ export function credDelete(key: string): Promise<void> {
return invoke<void>("cred_delete", { key }); return invoke<void>("cred_delete", { key });
} }
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> { export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit }); return invoke<GitCommit[]>("list_commits", { path, limit, skip });
} }
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> { export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {